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 }