Files
2026-09-21 15:59:20 +01:00

242 lines
7.5 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")
artifact := fs.String("artifact", "", "prebuilt preview bundle to deploy")
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
}
artifactProvided := *artifact != ""
if artifactProvided {
exists, err := previewArtifactExists(*artifact)
if err != nil {
fmt.Fprintf(os.Stderr, "preview deploy: %v\n", err)
return 2
}
if !exists {
fmt.Fprintf(os.Stderr, "preview deploy: prebuilt artifact %s does not exist\n", *artifact)
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) }()
tarPath := *artifact
if tarPath == "" {
tarPath = filepath.Join(tmpDir, "kandev-preview.tar.gz")
}
previewURL, err := deployArtifacts(ctx, tarPath, spritesToken, spriteName, *port, *skipWebInstall, !artifactProvided)
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 deploys an archive or builds one when buildIfMissing is true.
// Using a single client for the full flow avoids redundant auth round-trips.
func deployArtifacts(ctx context.Context, tarPath, spritesToken, spriteName string, port int, skipWebInstall, buildIfMissing bool) (string, error) {
return deployArtifactsWithHooks(
ctx,
tarPath,
spritesToken,
spriteName,
port,
skipWebInstall,
buildIfMissing,
buildPreviewArtifact,
deployPreviewArtifact,
)
}
type previewArtifactBuilder func(context.Context, string, string, bool, bool) error
type previewArtifactDeployer func(context.Context, string, string, string, int) (string, error)
func deployArtifactsWithHooks(
ctx context.Context,
tarPath, spritesToken, spriteName string,
port int,
skipWebInstall, buildIfMissing bool,
build previewArtifactBuilder,
deploy previewArtifactDeployer,
) (string, error) {
exists, err := previewArtifactExists(tarPath)
if err != nil {
return "", err
}
if !exists {
if !buildIfMissing {
return "", fmt.Errorf("prebuilt artifact %s does not exist", tarPath)
}
binDir := filepath.Join(filepath.Dir(tarPath), "bin")
if err := build(ctx, binDir, tarPath, skipWebInstall, false); err != nil {
return "", err
}
}
return deploy(ctx, tarPath, spritesToken, spriteName, port)
}
func previewArtifactExists(tarPath string) (bool, error) {
if tarPath == "" {
return false, fmt.Errorf("preview artifact path is required")
}
info, err := os.Stat(tarPath)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("stat preview artifact %s: %w", tarPath, err)
}
if !info.Mode().IsRegular() {
return false, fmt.Errorf("preview artifact %s is not a regular file", tarPath)
}
return true, nil
}
func buildPreviewArtifact(ctx context.Context, binDir, tarPath string, skipWebInstall, skipWebBuild bool) error {
fmt.Fprintln(os.Stderr, "building linux/amd64 binaries...")
if err := buildLinuxBinaries(ctx, binDir); err != nil {
return fmt.Errorf("build binaries: %w", err)
}
if !skipWebBuild {
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)
}
return nil
}
func deployPreviewArtifact(ctx context.Context, tarPath, spritesToken, spriteName string, port int) (string, error) {
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) {
if err := retrySpriteControl(ctx, "update URL settings", func(updateCtx context.Context) error {
return client.UpdateURLSettings(updateCtx, spriteName, &sprites.URLSettings{Auth: "public"})
}); err != nil {
return "", fmt.Errorf("update URL settings: %w", err)
}
var sprite *sprites.Sprite
if err := retrySpriteControl(ctx, "get sprite URL", func(getCtx context.Context) error {
var err error
sprite, err = client.GetSprite(getCtx, spriteName)
return err
}); 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
}