Files
kandev/apps/backend/cmd/preview/deploy.go
T
2026-09-06 15:14:28 +01:00

162 lines
5.3 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"github.com/kandev/kandev/internal/common/ports"
sprites "github.com/superfly/sprites-go"
)
func runDeploy(ctx context.Context, args []string) int {
fs := flag.NewFlagSet("deploy", flag.ContinueOnError)
pr := fs.Int("pr", 0, "PR number (required)")
sha := fs.String("sha", "", "commit SHA to display in the comment")
repo := fs.String("repo", envOr("GITHUB_REPOSITORY", ""), "owner/repo")
port := fs.Int("port", ports.Backend, "kandev backend port exposed by the sprite")
skipWebInstall := fs.Bool("skip-web-install", false, "skip pnpm install (CI already ran it)")
skipDescription := fs.Bool("skip-description", false, "skip the PR description update")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "preview deploy: %v\n", err)
return 2
}
if *pr == 0 {
fmt.Fprintln(os.Stderr, "preview deploy: --pr is required")
return 2
}
if *repo == "" {
fmt.Fprintln(os.Stderr, "preview deploy: --repo or GITHUB_REPOSITORY is required")
return 2
}
spritesToken := os.Getenv("SPRITES_API_TOKEN")
if spritesToken == "" {
fmt.Fprintln(os.Stderr, "preview deploy: SPRITES_API_TOKEN is required")
return 2
}
ghToken := os.Getenv("GH_TOKEN")
if ghToken == "" && !*skipDescription {
fmt.Fprintln(os.Stderr, "preview deploy: GH_TOKEN is required")
return 2
}
spriteName := fmt.Sprintf("kandev-pr-%d", *pr)
tmpDir, err := os.MkdirTemp("", "kandev-preview-*")
if err != nil {
fmt.Fprintf(os.Stderr, "preview deploy: mktemp: %v\n", err)
return 1
}
defer func() { _ = os.RemoveAll(tmpDir) }()
binDir := filepath.Join(tmpDir, "bin")
tarPath := filepath.Join(tmpDir, "kandev-preview.tar.gz")
previewURL, err := deployArtifacts(ctx, binDir, tarPath, spritesToken, spriteName, *port, *skipWebInstall)
if err != nil {
fmt.Fprintf(os.Stderr, "preview deploy: %v\n", err)
return 1
}
fmt.Printf("preview URL: %s\n", previewURL)
if *skipDescription {
return 0
}
section := buildDeploySection(previewURL, *sha)
if err := upsertDescriptionSection(ctx, ghToken, *repo, *pr, section); err != nil {
fmt.Fprintf(os.Stderr, "preview deploy: update PR description: %v\n", err)
return 1
}
return 0
}
// deployArtifacts builds the bundle, deploys it to the sprite, and returns the public URL.
// Using a single client for the full flow avoids redundant auth round-trips.
func deployArtifacts(ctx context.Context, binDir, tarPath, spritesToken, spriteName string, port int, skipWebInstall bool) (string, error) {
fmt.Fprintln(os.Stderr, "building linux/amd64 binaries...")
if err := buildLinuxBinaries(ctx, binDir); err != nil {
return "", fmt.Errorf("build binaries: %w", err)
}
fmt.Fprintln(os.Stderr, "building web frontend...")
if err := buildWeb(ctx, skipWebInstall); err != nil {
return "", fmt.Errorf("build web: %w", err)
}
fmt.Fprintln(os.Stderr, "packaging bundle...")
if err := packageBundle(binDir, tarPath); err != nil {
return "", fmt.Errorf("package bundle: %w", err)
}
client := newSpriteClient(spritesToken)
defer func() { _ = client.Close() }()
fmt.Fprintf(os.Stderr, "getting or creating sprite %s...\n", spriteName)
sprite, err := getOrCreateSprite(ctx, client, spriteName)
if err != nil {
return "", fmt.Errorf("get/create sprite: %w", err)
}
fmt.Fprintln(os.Stderr, "uploading bundle...")
if err := uploadBundle(ctx, sprite, tarPath); err != nil {
return "", fmt.Errorf("upload bundle: %w", err)
}
fmt.Fprintln(os.Stderr, "extracting and configuring...")
if err := extractBundle(ctx, sprite, port); err != nil {
return "", fmt.Errorf("extract bundle: %w", err)
}
// Enable the public URL before deploying the service. This sets the auth
// mode on the sprite (not the service), so it is safe to call before the
// service exists. Doing it first avoids a service restart mid-health-check.
previewURL, err := enablePublicURL(ctx, client, spriteName)
if err != nil {
return "", fmt.Errorf("enable public URL: %w", err)
}
fmt.Fprintln(os.Stderr, "deploying kandev service...")
if err := deployService(ctx, sprite, port); err != nil {
return "", fmt.Errorf("deploy service: %w", err)
}
// Health check via internal localhost to avoid Sprites routing lag during
// service transitions (the external URL may return 502 while the new
// service process is starting up).
fmt.Fprintln(os.Stderr, "waiting for kandev to be healthy...")
if err := waitForKandev(ctx, sprite, port); err != nil {
return "", fmt.Errorf("health check: %w", err)
}
return previewURL, nil
}
// enablePublicURL sets the sprite's URL to public mode and returns the URL.
// Accepts the already-open client from deployArtifacts to avoid a second auth handshake.
func enablePublicURL(ctx context.Context, client *sprites.Client, spriteName string) (string, error) {
updateCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
defer cancel()
if err := client.UpdateURLSettings(updateCtx, spriteName, &sprites.URLSettings{Auth: "public"}); err != nil {
return "", fmt.Errorf("update URL settings: %w", err)
}
getCtx, getCancel := context.WithTimeout(ctx, spriteStepTimeout)
defer getCancel()
sprite, err := client.GetSprite(getCtx, spriteName)
if err != nil {
return "", fmt.Errorf("get sprite URL: %w", err)
}
if sprite.URL == "" {
return "", fmt.Errorf("sprite %s has no URL assigned yet", spriteName)
}
return sprite.URL, nil
}