242 lines
7.5 KiB
Go
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
|
|
}
|