From 7653a1e438105edf88a9b5224318c1dfaa01b6f3 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 14 Jul 2026 13:53:16 +0000 Subject: [PATCH] gokrazy: split build.go into thin main + reusable build package Move the appliance/AMI build logic into tailscale.com/gokrazy/build so Go callers (e.g. flash-appliance) can call it directly instead of driving build.go over --json. build.go is now a thin flag wrapper; flags and --json output are unchanged. Package-level state becomes a Builder with an exported Config, ctx-first steps, and a Build one-shot. Updates #1866 Signed-off-by: Kristoffer Dalby --- gokrazy/build.go | 439 +++-------------------------- gokrazy/build/build.go | 538 ++++++++++++++++++++++++++++++++++++ gokrazy/build/build_test.go | 74 +++++ gokrazy/gokrazy_test.go | 48 +--- 4 files changed, 658 insertions(+), 441 deletions(-) create mode 100644 gokrazy/build/build.go create mode 100644 gokrazy/build/build_test.go diff --git a/gokrazy/build.go b/gokrazy/build.go index 5b7d3655c..ea96336fe 100644 --- a/gokrazy/build.go +++ b/gokrazy/build.go @@ -6,194 +6,69 @@ // As of 2024-06-02 this is a exploratory work in progress and is // not intended for serious use. // +// The build logic lives in tailscale.com/gokrazy/build; this is a thin +// CLI wrapper around it. +// // Tracking issue is https://github.com/tailscale/tailscale/issues/1866 package main import ( + "context" "encoding/json" "flag" - "fmt" "log" "os" - "os/exec" - "path/filepath" - "strings" - "time" - "tailscale.com/gokrazy/mkfs" + "tailscale.com/gokrazy/build" ) var ( - app = flag.String("app", "tsapp", "appliance name; one of the subdirectories of gokrazy/") - bucket = flag.String("bucket", "tskrazy-import", "S3 bucket to upload disk image to while making AMI") - build = flag.Bool("build", false, "if true, just build locally and stop, without uploading") - gaf = flag.Bool("gaf", false, "if true, build a gokrazy archive format file instead of a full disk image") - jsonOut = flag.Bool("json", false, "emit one machine-readable JSON result line to stdout") - region = flag.String("region", "", "AWS region for import+register; default us-east-1 (honors $AWS_REGION)") + app = flag.String("app", "tsapp", "appliance name; one of the subdirectories of gokrazy/") + bucket = flag.String("bucket", "tskrazy-import", "S3 bucket to upload disk image to while making AMI") + buildLocal = flag.Bool("build", false, "if true, just build locally and stop, without uploading") + gaf = flag.Bool("gaf", false, "if true, build a gokrazy archive format file instead of a full disk image") + jsonOut = flag.Bool("json", false, "emit one machine-readable JSON result line to stdout") + region = flag.String("region", "", "AWS region for import+register; default us-east-1 (honors $AWS_REGION)") ) -// awsRegion is the resolved AWS region used for every aws invocation. Set once -// in main from resolveRegion. -var awsRegion string - -// result is the machine-readable output printed to stdout when --json is set. -// Fields are populated as the run progresses; per docs/cli.md, existing fields -// keep their meaning and consumers must tolerate new ones. -type result struct { - App string `json:"app"` - Arch string `json:"arch"` - Name string `json:"name,omitempty"` - Image string `json:"image,omitempty"` - GAF string `json:"gaf,omitempty"` - Region string `json:"region,omitempty"` - Snapshot string `json:"snapshot,omitempty"` - AMI string `json:"ami,omitempty"` -} - -var res result - -// baseImageSizeBytes is the size of the disk image we ask monogok to -// produce (and that the AWS AMI import expects). It has to be large -// enough to fit gokrazy's standard partition layout (see -// github.com/bradfitz/monogok/disklayout): -// -// 4 MiB gap before the first partition -// 100 MiB boot (FAT) -// 500 MiB root A (squashfs; the partition OTA updates write into) -// 500 MiB root B (squashfs) -// ~96 MiB /perm (ext4; rest of the disk minus the secondary GPT) -// -// Bump this to give /perm more room (and to make the produced .img -// file larger). The same value is passed to monogok via -// --target_storage_bytes and to mkfs.Perm so the GPT and the ext4 -// inside it agree on the disk's size. -// -// imageSizeBytesFor may round this up; callers should use that helper -// instead of this constant. -const baseImageSizeBytes = 1258299392 - -// imageSizeBytesFor returns the disk image size to use for app. For Raspberry -// Pi appliances the size is rounded up to the next power of two because -// qemu-system-aarch64's raspi3b machine rejects SD card images whose size -// isn't a power of two. -func imageSizeBytesFor(app string) int64 { - if !strings.HasPrefix(app, "tsapp-pi.") { - return baseImageSizeBytes - } - n := int64(1) - for n < baseImageSizeBytes { - n <<= 1 - } - return n -} - -var conf gokrazyConfig - -// gokrazyConfig is the subset of gokrazy/internal/config.Struct -// that we care about. -type gokrazyConfig struct { - // Environment is os.Environment pairs to use when - // building userspace. - // See https://gokrazy.org/userguide/instance-config/#environment - Environment []string -} - -func (c *gokrazyConfig) GOARCH() string { - for _, e := range c.Environment { - if v, ok := strings.CutPrefix(e, "GOARCH="); ok { - return v - } - } - return "" -} - func main() { flag.Parse() + ctx := context.Background() - if *app == "" || strings.Contains(*app, "/") { - log.Fatalf("--app must be non-empty name such as 'tsapp' or 'natlabapp'") - } - - confJSON, err := os.ReadFile(filepath.Join(*app, "config.json")) + b, err := build.New(build.Config{ + App: *app, + Bucket: *bucket, + Region: build.ResolveRegion(*region, os.Getenv("AWS_REGION")), + }) if err != nil { - log.Fatalf("reading config.json: %v", err) + emitJSON(build.Result{App: *app, Error: err.Error()}) + log.Fatalf("%v", err) } - if err := json.Unmarshal(confJSON, &conf); err != nil { - log.Fatalf("unmarshaling config.json: %v", err) - } - switch conf.GOARCH() { - case "amd64", "arm64": + + // Which artifact to build is a CLI choice, mapped here to the + // matching Builder method: --gaf → GAF, --build → local image only, + // otherwise the full AMI pipeline. + var buildErr error + switch { + case *gaf: + _, buildErr = b.BuildGAF(ctx) + case *buildLocal: + _, buildErr = b.BuildImage(ctx) default: - log.Fatalf("config.json GOARCH %q must be amd64 or arm64", conf.GOARCH()) + _, buildErr = b.BuildAMI(ctx) } - awsRegion = resolveRegion(*region, os.Getenv("AWS_REGION")) - res.App = *app - res.Arch = awsArch(conf.GOARCH()) - - if err := buildImage(); err != nil { - log.Fatalf("build image: %v", err) + // With --json, print one machine-readable result line to stdout, + // including any error, so consumers see the outcome on stdout rather + // than only via the exit code. All logs/progress go to stderr. + emitJSON(b.Result()) + if buildErr != nil { + log.Fatalf("%v", buildErr) } - if *build || *gaf { - log.Printf("built. stopping.") - emitJSON() - return - } - - if err := copyToS3(); err != nil { - log.Fatalf("copy to S3: %v", err) - } - - importTask, err := startImportSnapshot() - if err != nil { - log.Fatalf("start import snapshot: %v", err) - } - snapID, err := waitForImportSnapshot(importTask) - if err != nil { - log.Fatalf("waitForImportSnapshot(%v): %v", importTask, err) - } - log.Printf("snap ID: %v", snapID) - res.Snapshot = snapID - res.Region = awsRegion - - res.Name = amiName(*app) - ami, err := makeAMI(res.Name, snapID) - if err != nil { - log.Fatalf("makeAMI: %v", err) - } - log.Printf("made AMI: %v", ami) - res.AMI = ami - emitJSON() } -// awsArch maps a Go GOARCH to the AWS EC2 --architecture value. -func awsArch(goarch string) string { - switch goarch { - case "arm64": - return "arm64" - case "amd64": - return "x86_64" - } - return "" -} - -// resolveRegion picks the AWS region: an explicit --region wins, then -// $AWS_REGION, then us-east-1 (where the Marketplace Catalog API and its -// source AMI live). -func resolveRegion(flagVal, env string) string { - if flagVal != "" { - return flagVal - } - if env != "" { - return env - } - return "us-east-1" -} - -// emitJSON writes the result as one JSON line to stdout when --json is set. -// Everything else in this program goes to stderr, so stdout is a clean data -// channel for scripts. -func emitJSON() { +// emitJSON writes res as one JSON line to stdout when --json is set. +func emitJSON(res build.Result) { if !*jsonOut { return } @@ -201,241 +76,3 @@ func emitJSON() { log.Fatalf("encoding json result: %v", err) } } - -func buildImage() error { - dir, err := os.Getwd() - if err != nil { - return err - } - if fi, err := os.Stat(filepath.Join(dir, *app)); err != nil || !fi.IsDir() { - return fmt.Errorf("in wrong directory %v; no %q subdirectory found", dir, *app) - } - - args := []string{"run", "github.com/bradfitz/monogok/cmd/monogok"} - if *gaf { - args = append(args, - "overwrite", - "--gaf", filepath.Join(dir, *app+".gaf"), - ) - } else { - args = append(args, - "overwrite", - "--full", filepath.Join(dir, *app+".img"), - fmt.Sprintf("--target_storage_bytes=%d", imageSizeBytesFor(*app)), - ) - } - - cmd := exec.Command("go", args...) - cmd.Dir = filepath.Join(dir, *app) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return err - } - if *gaf { - res.GAF = filepath.Join(dir, *app+".gaf") - return nil - } - - imgPath := filepath.Join(dir, *app+".img") - f, err := os.OpenFile(imgPath, os.O_RDWR, 0) - if err != nil { - return fmt.Errorf("open %s: %w", imgPath, err) - } - defer f.Close() - if err := mkfs.Perm(f, imageSizeBytesFor(*app)); err != nil { - return fmt.Errorf("formatting /perm in %s: %v", imgPath, err) - } - log.Printf("Wrote ext4 /perm filesystem to %s.", imgPath) - res.Image = imgPath - return nil -} - -// amiName returns a deterministic AMI name derived from git: on a tagged commit -// it's - (releases); otherwise -- for -// ad-hoc builds. If git is unavailable it falls back to -. -func amiName(app string) string { - exact, _ := gitOutput("describe", "--exact-match", "--tags", "HEAD") - describe, _ := gitOutput("describe", "--tags", "--always", "--dirty") - return amiNameFrom(app, exact, describe, time.Now().Unix()) -} - -// amiNameFrom is the pure decision behind amiName, split out for testing. -func amiNameFrom(app, exactTag, describe string, now int64) string { - if exactTag != "" { - return app + "-" + exactTag - } - if describe != "" { - return fmt.Sprintf("%s-%s-%d", app, describe, now) - } - return fmt.Sprintf("%s-%d", app, now) -} - -// gitOutput runs git with args and returns trimmed stdout, or an error (e.g. no -// git, not a repo, or the ref doesn't match). -func gitOutput(args ...string) (string, error) { - out, err := exec.Command("git", args...).Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -// awsCmd builds an aws command with the resolved --region prepended so every -// call targets the same region deterministically. -func awsCmd(args ...string) *exec.Cmd { - return exec.Command("aws", append([]string{"--region", awsRegion}, args...)...) -} - -func copyToS3() error { - cmd := awsCmd("s3", "cp", *app+".img", "s3://"+*bucket+"/") - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - return cmd.Run() -} - -func startImportSnapshot() (importTaskID string, err error) { - out, err := awsCmd("ec2", "import-snapshot", "--disk-container", "Url=s3://"+*bucket+"/"+*app+".img").CombinedOutput() - if err != nil { - return "", fmt.Errorf("import snapshot: %v: %s", err, out) - } - var resp struct { - ImportTaskID string `json:"ImportTaskId"` - } - /* - { - "ImportTaskId": "import-snap-0d2d72622b4359567", - "SnapshotTaskDetail": { - "DiskImageSize": 0.0, - "Progress": "0", - "Status": "active", - "StatusMessage": "pending", - "Url": "s3://tskrazy-import/tskrazy.img" - }, - "Tags": [] - } - */ - if err := json.Unmarshal(out, &resp); err != nil { - return "", fmt.Errorf("unmarshal response: %v: %s", err, out) - } - return resp.ImportTaskID, nil -} - -/* -% aws ec2 describe-import-snapshot-tasks --import-task-ids import-snap-0d2d72622b4359567 -{ - "ImportSnapshotTasks": [ - { - "ImportTaskId": "import-snap-0d2d72622b4359567", - "SnapshotTaskDetail": { - "DiskImageSize": 1258299392.0, - "Format": "RAW", - "SnapshotId": "snap-053efd3539d787927", - "Status": "completed", - "Url": "s3://tskrazy-import/tskrazy.img", - "UserBucket": { - "S3Bucket": "tskrazy-import", - "S3Key": "tskrazy.img" - } - }, - "Tags": [] - } - ] -} -*/ - -func waitForImportSnapshot(importTaskID string) (snapID string, err error) { - for { - out, err := awsCmd("ec2", "describe-import-snapshot-tasks", "--import-task-ids", importTaskID).CombinedOutput() - if err != nil { - return "", fmt.Errorf("describe import snapshot tasks: %v: %s", err, out) - } - - var resp struct { - ImportSnapshotTasks []struct { - SnapshotTaskDetail struct { - SnapshotID string `json:"SnapshotId"` - Status string `json:"Status"` - } `json:"SnapshotTaskDetail"` - } `json:"ImportSnapshotTasks"` - } - if err := json.Unmarshal(out, &resp); err != nil { - return "", fmt.Errorf("unmarshal response: %v: %s", err, out) - } - if len(resp.ImportSnapshotTasks) > 0 { - first := &resp.ImportSnapshotTasks[0] - if first.SnapshotTaskDetail.Status == "completed" { - return first.SnapshotTaskDetail.SnapshotID, nil - } - } - log.Printf("Still waiting; got: %s", out) - time.Sleep(5 * time.Second) - - // TODO(bradfitz): percentage bar? - // Looks like: - /* 2024/05/14 13:03:21 Still waiting; got: { - "ImportSnapshotTasks": [ - { - "ImportTaskId": "import-snap-0232251d0fbcb33fd", - "SnapshotTaskDetail": { - "DiskImageSize": 1258299392.0, - "Format": "RAW", - "Progress": "32", - "Status": "active", - "StatusMessage": "validated", - "Url": "s3://tskrazy-import/tskrazy.img", - "UserBucket": { - "S3Bucket": "tskrazy-import", - "S3Key": "tskrazy.img" - } - }, - "Tags": [] - } - ] - }*/ - } -} - -func makeAMI(name, ebsSnapID string) (ami string, err error) { - var arch, bootMode string - switch conf.GOARCH() { - case "arm64": - // arm64 instances boot UEFI-only; "uefi-preferred" is rejected. - arch, bootMode = "arm64", "uefi" - case "amd64": - arch, bootMode = "x86_64", "uefi-preferred" - default: - return "", fmt.Errorf("unknown arch %q", conf.GOARCH()) - } - out, err := awsCmd("ec2", "register-image", - "--name", name, - "--architecture", arch, - // register-image defaults to paravirtual; arm64 rejects that - // ("supports HVM AMIs only") and amd64 would produce an image that - // won't boot on Nitro. Both need HVM. - "--virtualization-type", "hvm", - "--root-device-name", "/dev/sda1", - "--ena-support", - "--imds-support", "v2.0", - "--boot-mode", bootMode, - "--block-device-mappings", "DeviceName=/dev/sda1,Ebs={SnapshotId="+ebsSnapID+"}").CombinedOutput() - if err != nil { - return "", fmt.Errorf("register image: %v: %s", err, out) - } - /* - On success: - { - "ImageId": "ami-052e1538166886ad2" - } - */ - var resp struct { - ImageID string `json:"ImageId"` - } - if err := json.Unmarshal(out, &resp); err != nil { - return "", fmt.Errorf("unmarshal response: %v: %s", err, out) - } - if resp.ImageID == "" { - return "", fmt.Errorf("empty image ID in response: %s", out) - } - return resp.ImageID, nil -} diff --git a/gokrazy/build/build.go b/gokrazy/build/build.go new file mode 100644 index 000000000..40b6bd49f --- /dev/null +++ b/gokrazy/build/build.go @@ -0,0 +1,538 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package build builds the Tailscale Appliance Gokrazy image and, +// optionally, an AWS AMI from it. +// +// It is the reusable core behind the gokrazy/build.go command: a +// [Builder] runs monogok to produce a disk image (or GAF), formats the +// ext4 /perm filesystem via gokrazy/mkfs, and can then upload the image +// to S3 and register an AMI by shelling out to the "aws" CLI. Callers +// that only want the image (e.g. flash-appliance tooling) can call +// [Builder.BuildImage] alone. +// +// Tracking issue is https://github.com/tailscale/tailscale/issues/1866 +package build + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "tailscale.com/gokrazy/mkfs" + "tailscale.com/types/logger" +) + +// Result is the machine-readable outcome of a build. Fields are +// populated as the run progresses; per docs/cli.md, existing fields +// keep their meaning and consumers must tolerate new ones. +type Result struct { + App string `json:"app"` + Arch string `json:"arch"` + Name string `json:"name,omitempty"` + Image string `json:"image,omitempty"` + GAF string `json:"gaf,omitempty"` + Region string `json:"region,omitempty"` + Snapshot string `json:"snapshot,omitempty"` + AMI string `json:"ami,omitempty"` + // Error is the error that ended the build, if any. It lets --json + // consumers see why a run failed rather than only its exit code. + Error string `json:"error,omitempty"` +} + +// Config configures a [Builder]. Only App is required; New fills in the +// rest with defaults. It holds build inputs only; what to produce is +// chosen by which build method you call ([Builder.BuildImage], +// [Builder.BuildGAF], or [Builder.BuildAMI]). +type Config struct { + // App is the appliance name, e.g. "tsapp". It must be a + // subdirectory of Dir containing a config.json. + App string + // Dir is the directory holding the appliance subdirectories. + // Empty means the current working directory. + Dir string + // Bucket is the S3 bucket that BuildAMI uploads the disk image to + // while registering the AMI. Unused by BuildImage and BuildGAF. + Bucket string + // Region is the AWS region BuildAMI imports and registers in. Empty + // means ResolveRegion("", $AWS_REGION). + Region string + + // Logf receives human-readable progress. If nil, log.Printf is used. + Logf logger.Logf + // Stderr receives the output of subprocesses (monogok, aws). If nil, + // os.Stderr is used. Keeping this off stdout lets callers reserve + // stdout for machine-readable output. + Stderr io.Writer +} + +// Builder builds one appliance image, GAF, or AMI per its Config. +// Create one with [New]; it is not safe for concurrent use. +type Builder struct { + Config + + conf gokrazyConfig // parsed //config.json + res Result +} + +// baseImageSizeBytes is the size of the disk image we ask monogok to +// produce (and that the AWS AMI import expects). It has to be large +// enough to fit gokrazy's standard partition layout (see +// github.com/bradfitz/monogok/disklayout): +// +// 4 MiB gap before the first partition +// 100 MiB boot (FAT) +// 500 MiB root A (squashfs; the partition OTA updates write into) +// 500 MiB root B (squashfs) +// ~96 MiB /perm (ext4; rest of the disk minus the secondary GPT) +// +// Bump this to give /perm more room (and to make the produced .img +// file larger). The same value is passed to monogok via +// --target_storage_bytes and to mkfs.Perm so the GPT and the ext4 +// inside it agree on the disk's size. +// +// imageSizeBytesFor may round this up; callers should use that helper +// instead of this constant. +const baseImageSizeBytes = 1258299392 + +// gokrazyConfig is the subset of gokrazy/internal/config.Struct +// that we care about. +type gokrazyConfig struct { + // Environment is os.Environment pairs to use when + // building userspace. + // See https://gokrazy.org/userguide/instance-config/#environment + Environment []string +} + +func (c *gokrazyConfig) GOARCH() string { + for _, e := range c.Environment { + if v, ok := strings.CutPrefix(e, "GOARCH="); ok { + return v + } + } + return "" +} + +// New validates cfg, fills in defaults, reads and parses +// //config.json, and validates its GOARCH. +func New(cfg Config) (*Builder, error) { + if cfg.App == "" || strings.Contains(cfg.App, "/") { + return nil, fmt.Errorf("App must be a non-empty name such as 'tsapp' or 'natlabapp'; got %q", cfg.App) + } + if cfg.Dir == "" { + wd, err := os.Getwd() + if err != nil { + return nil, err + } + cfg.Dir = wd + } + if cfg.Region == "" { + cfg.Region = ResolveRegion("", os.Getenv("AWS_REGION")) + } + if cfg.Stderr == nil { + cfg.Stderr = os.Stderr + } + + b := &Builder{Config: cfg} + + confJSON, err := os.ReadFile(filepath.Join(cfg.Dir, cfg.App, "config.json")) + if err != nil { + return nil, fmt.Errorf("reading config.json: %w", err) + } + if err := json.Unmarshal(confJSON, &b.conf); err != nil { + return nil, fmt.Errorf("unmarshaling config.json: %w", err) + } + switch b.conf.GOARCH() { + case "amd64", "arm64": + default: + return nil, fmt.Errorf("config.json GOARCH %q must be amd64 or arm64", b.conf.GOARCH()) + } + + b.res.App = cfg.App + b.res.Arch = awsArch(b.conf.GOARCH()) + return b, nil +} + +// Result returns the current result. It is fully populated after a +// successful Build; individual steps fill in their fields as they run, +// and Result.Error records the error that ended a failed run. +func (b *Builder) Result() Result { return b.res } + +// fail records err in the result and returns it, so callers that read +// Result() after a failed step (e.g. to emit --json) see why it failed. +func (b *Builder) fail(err error) error { + b.res.Error = err.Error() + return err +} + +// logf logs progress via b.Logf, or log.Printf if unset. +func (b *Builder) logf(format string, args ...any) { + if b.Logf != nil { + b.Logf(format, args...) + return + } + log.Printf(format, args...) +} + +// BuildImage runs monogok to produce a full disk image (.img) and +// formats its ext4 /perm filesystem. It returns the image path and sets +// Result.Image. This is the local artifact that BuildAMI publishes and +// that flash-appliance tooling writes to disk. +func (b *Builder) BuildImage(ctx context.Context) (string, error) { + if err := b.buildImage(ctx, false); err != nil { + return "", b.fail(fmt.Errorf("build image: %w", err)) + } + return b.res.Image, nil +} + +// BuildGAF runs monogok to produce a gokrazy archive format file (.gaf), +// the OTA update artifact. It returns the GAF path and sets Result.GAF. +// A GAF is an update archive, not a full disk, so it cannot be turned +// into an AMI. +func (b *Builder) BuildGAF(ctx context.Context) (string, error) { + if err := b.buildImage(ctx, true); err != nil { + return "", b.fail(fmt.Errorf("build GAF: %w", err)) + } + return b.res.GAF, nil +} + +// BuildAMI builds a full disk image and publishes it as an AWS AMI: +// upload to S3, import an EBS snapshot, and register the image. It +// returns the populated Result. Config.Bucket and Config.Region select +// where the AMI is built. +func (b *Builder) BuildAMI(ctx context.Context) (Result, error) { + if _, err := b.BuildImage(ctx); err != nil { + return b.res, err // BuildImage already wrapped+recorded it + } + + if err := b.uploadToS3(ctx); err != nil { + return b.res, b.fail(fmt.Errorf("copy to S3: %w", err)) + } + snapID, err := b.importSnapshot(ctx) + if err != nil { + return b.res, b.fail(fmt.Errorf("import snapshot: %w", err)) + } + b.logf("snap ID: %v", snapID) + + if err := b.registerAMI(ctx, snapID); err != nil { + return b.res, b.fail(fmt.Errorf("register AMI: %w", err)) + } + b.logf("made AMI: %v", b.res.AMI) + return b.res, nil +} + +// buildImage runs monogok to produce the disk image (or GAF when gaf is +// set) and, for a full image, formats the ext4 /perm filesystem. It sets +// res.Image or res.GAF. +func (b *Builder) buildImage(ctx context.Context, gaf bool) error { + appDir := filepath.Join(b.Dir, b.App) + if fi, err := os.Stat(appDir); err != nil || !fi.IsDir() { + return fmt.Errorf("in wrong directory %v; no %q subdirectory found", b.Dir, b.App) + } + + args := []string{"run", "github.com/bradfitz/monogok/cmd/monogok"} + if gaf { + args = append(args, + "overwrite", + "--gaf", filepath.Join(b.Dir, b.App+".gaf"), + ) + } else { + args = append(args, + "overwrite", + "--full", filepath.Join(b.Dir, b.App+".img"), + fmt.Sprintf("--target_storage_bytes=%d", imageSizeBytesFor(b.App)), + ) + } + + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = appDir + cmd.Stdout = b.Stderr + cmd.Stderr = b.Stderr + if err := cmd.Run(); err != nil { + return err + } + if gaf { + b.res.GAF = filepath.Join(b.Dir, b.App+".gaf") + return nil + } + + imgPath := filepath.Join(b.Dir, b.App+".img") + f, err := os.OpenFile(imgPath, os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("open %s: %w", imgPath, err) + } + defer f.Close() + if err := mkfs.Perm(f, imageSizeBytesFor(b.App)); err != nil { + return fmt.Errorf("formatting /perm in %s: %v", imgPath, err) + } + b.logf("Wrote ext4 /perm filesystem to %s.", imgPath) + b.res.Image = imgPath + return nil +} + +// uploadToS3 uploads the built .img to s3:///. +func (b *Builder) uploadToS3(ctx context.Context) error { + cmd := b.awsCmd(ctx, "s3", "cp", b.App+".img", "s3://"+b.Bucket+"/") + cmd.Dir = b.Dir + cmd.Stdout = b.Stderr + cmd.Stderr = b.Stderr + return cmd.Run() +} + +// importSnapshot starts an EC2 import-snapshot task from the uploaded +// image and waits for it to complete, returning the EBS snapshot ID. It +// sets res.Snapshot and res.Region. +func (b *Builder) importSnapshot(ctx context.Context) (string, error) { + taskID, err := b.startImportSnapshot(ctx) + if err != nil { + return "", err + } + snapID, err := b.waitForImportSnapshot(ctx, taskID) + if err != nil { + return "", fmt.Errorf("waitForImportSnapshot(%v): %w", taskID, err) + } + b.res.Snapshot = snapID + b.res.Region = b.Region + return snapID, nil +} + +func (b *Builder) startImportSnapshot(ctx context.Context) (importTaskID string, err error) { + out, err := b.awsCmd(ctx, "ec2", "import-snapshot", "--disk-container", "Url=s3://"+b.Bucket+"/"+b.App+".img").CombinedOutput() + if err != nil { + return "", fmt.Errorf("import snapshot: %v: %s", err, out) + } + var resp struct { + ImportTaskID string `json:"ImportTaskId"` + } + /* + { + "ImportTaskId": "import-snap-0d2d72622b4359567", + "SnapshotTaskDetail": { + "DiskImageSize": 0.0, + "Progress": "0", + "Status": "active", + "StatusMessage": "pending", + "Url": "s3://tskrazy-import/tskrazy.img" + }, + "Tags": [] + } + */ + if err := json.Unmarshal(out, &resp); err != nil { + return "", fmt.Errorf("unmarshal response: %v: %s", err, out) + } + return resp.ImportTaskID, nil +} + +/* +% aws ec2 describe-import-snapshot-tasks --import-task-ids import-snap-0d2d72622b4359567 +{ + "ImportSnapshotTasks": [ + { + "ImportTaskId": "import-snap-0d2d72622b4359567", + "SnapshotTaskDetail": { + "DiskImageSize": 1258299392.0, + "Format": "RAW", + "SnapshotId": "snap-053efd3539d787927", + "Status": "completed", + "Url": "s3://tskrazy-import/tskrazy.img", + "UserBucket": { + "S3Bucket": "tskrazy-import", + "S3Key": "tskrazy.img" + } + }, + "Tags": [] + } + ] +} +*/ + +func (b *Builder) waitForImportSnapshot(ctx context.Context, importTaskID string) (snapID string, err error) { + for { + out, err := b.awsCmd(ctx, "ec2", "describe-import-snapshot-tasks", "--import-task-ids", importTaskID).CombinedOutput() + if err != nil { + return "", fmt.Errorf("describe import snapshot tasks: %v: %s", err, out) + } + + var resp struct { + ImportSnapshotTasks []struct { + SnapshotTaskDetail struct { + SnapshotID string `json:"SnapshotId"` + Status string `json:"Status"` + } `json:"SnapshotTaskDetail"` + } `json:"ImportSnapshotTasks"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return "", fmt.Errorf("unmarshal response: %v: %s", err, out) + } + if len(resp.ImportSnapshotTasks) > 0 { + first := &resp.ImportSnapshotTasks[0] + if first.SnapshotTaskDetail.Status == "completed" { + return first.SnapshotTaskDetail.SnapshotID, nil + } + } + b.logf("Still waiting; got: %s", out) + + // TODO(bradfitz): percentage bar? + // Looks like: + /* 2024/05/14 13:03:21 Still waiting; got: { + "ImportSnapshotTasks": [ + { + "ImportTaskId": "import-snap-0232251d0fbcb33fd", + "SnapshotTaskDetail": { + "DiskImageSize": 1258299392.0, + "Format": "RAW", + "Progress": "32", + "Status": "active", + "StatusMessage": "validated", + "Url": "s3://tskrazy-import/tskrazy.img", + "UserBucket": { + "S3Bucket": "tskrazy-import", + "S3Key": "tskrazy.img" + } + }, + "Tags": [] + } + ] + }*/ + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(5 * time.Second): + } + } +} + +// registerAMI registers an AMI from the given EBS snapshot. The AMI name +// is derived from git via AMIName. It sets res.Name and res.AMI. +func (b *Builder) registerAMI(ctx context.Context, ebsSnapID string) error { + b.res.Name = AMIName(b.App, b.Dir) + + var arch, bootMode string + switch b.conf.GOARCH() { + case "arm64": + // arm64 instances boot UEFI-only; "uefi-preferred" is rejected. + arch, bootMode = "arm64", "uefi" + case "amd64": + arch, bootMode = "x86_64", "uefi-preferred" + default: + return fmt.Errorf("unknown arch %q", b.conf.GOARCH()) + } + out, err := b.awsCmd(ctx, "ec2", "register-image", + "--name", b.res.Name, + "--architecture", arch, + // register-image defaults to paravirtual; arm64 rejects that + // ("supports HVM AMIs only") and amd64 would produce an image that + // won't boot on Nitro. Both need HVM. + "--virtualization-type", "hvm", + "--root-device-name", "/dev/sda1", + "--ena-support", + "--imds-support", "v2.0", + "--boot-mode", bootMode, + "--block-device-mappings", "DeviceName=/dev/sda1,Ebs={SnapshotId="+ebsSnapID+"}").CombinedOutput() + if err != nil { + return fmt.Errorf("register image: %v: %s", err, out) + } + /* + On success: + { + "ImageId": "ami-052e1538166886ad2" + } + */ + var resp struct { + ImageID string `json:"ImageId"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return fmt.Errorf("unmarshal response: %v: %s", err, out) + } + if resp.ImageID == "" { + return fmt.Errorf("empty image ID in response: %s", out) + } + b.res.AMI = resp.ImageID + return nil +} + +// awsCmd builds an aws command with the resolved --region prepended so +// every call targets the same region deterministically. +func (b *Builder) awsCmd(ctx context.Context, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, "aws", append([]string{"--region", b.Region}, args...)...) +} + +// imageSizeBytesFor returns the disk image size to use for app. For Raspberry +// Pi appliances the size is rounded up to the next power of two because +// qemu-system-aarch64's raspi3b machine rejects SD card images whose size +// isn't a power of two. +func imageSizeBytesFor(app string) int64 { + if !strings.HasPrefix(app, "tsapp-pi.") { + return baseImageSizeBytes + } + n := int64(1) + for n < baseImageSizeBytes { + n <<= 1 + } + return n +} + +// awsArch maps a Go GOARCH to the AWS EC2 --architecture value. +func awsArch(goarch string) string { + switch goarch { + case "arm64": + return "arm64" + case "amd64": + return "x86_64" + } + return "" +} + +// ResolveRegion picks the AWS region: an explicit flagVal wins, then +// env ($AWS_REGION), then us-east-1 (where the Marketplace Catalog API +// and its source AMI live). +func ResolveRegion(flagVal, env string) string { + if flagVal != "" { + return flagVal + } + if env != "" { + return env + } + return "us-east-1" +} + +// AMIName returns a deterministic AMI name derived from git, running git +// in dir: on a tagged commit it's - (releases); otherwise +// -- for ad-hoc builds. If git is +// unavailable it falls back to -. +func AMIName(app, dir string) string { + exact, _ := gitOutput(dir, "describe", "--exact-match", "--tags", "HEAD") + describe, _ := gitOutput(dir, "describe", "--tags", "--always", "--dirty") + return amiNameFrom(app, exact, describe, time.Now().Unix()) +} + +// amiNameFrom is the pure decision behind AMIName, split out for testing. +func amiNameFrom(app, exactTag, describe string, now int64) string { + if exactTag != "" { + return app + "-" + exactTag + } + if describe != "" { + return fmt.Sprintf("%s-%s-%d", app, describe, now) + } + return fmt.Sprintf("%s-%d", app, now) +} + +// gitOutput runs git with args in dir and returns trimmed stdout, or an +// error (e.g. no git, not a repo, or the ref doesn't match). +func gitOutput(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/gokrazy/build/build_test.go b/gokrazy/build/build_test.go new file mode 100644 index 000000000..f14fdc96a --- /dev/null +++ b/gokrazy/build/build_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package build + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" +) + +func TestResolveRegion(t *testing.T) { + tests := []struct { + name, flag, env, want string + }{ + {"default", "", "", "us-east-1"}, + {"env", "", "eu-west-1", "eu-west-1"}, + {"flag", "ap-south-1", "", "ap-south-1"}, + {"flag-beats-env", "ap-south-1", "eu-west-1", "ap-south-1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResolveRegion(tt.flag, tt.env); got != tt.want { + t.Errorf("ResolveRegion(%q, %q) = %q; want %q", tt.flag, tt.env, got, tt.want) + } + }) + } +} + +func TestBuildCapturesError(t *testing.T) { + // An app dir with a config.json but no real appliance: the monogok + // build fails, and the build method must record the error in the + // Result for --json consumers. + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "badapp"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "badapp", "config.json"), + []byte(`{"Environment":["GOARCH=amd64"]}`), 0600); err != nil { + t.Fatal(err) + } + + b, err := New(Config{App: "badapp", Dir: dir, Stderr: io.Discard}) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := b.BuildGAF(context.Background()); err == nil { + t.Fatal("BuildGAF succeeded; want failure") + } + if b.Result().Error == "" { + t.Error("Result.Error is empty; want the failure reason") + } +} + +func TestAMINameFrom(t *testing.T) { + const now = 1720000000 + tests := []struct { + name, exactTag, describe, want string + }{ + {"tagged-release", "v1.2.3", "v1.2.3", "tsapp-v1.2.3"}, + {"adhoc-describe", "", "v1.2.3-4-gabc1234", "tsapp-v1.2.3-4-gabc1234-1720000000"}, + {"adhoc-dirty", "", "v1.2.3-dirty", "tsapp-v1.2.3-dirty-1720000000"}, + {"no-git", "", "", "tsapp-1720000000"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := amiNameFrom("tsapp", tt.exactTag, tt.describe, now); got != tt.want { + t.Errorf("amiNameFrom = %q; want %q", got, tt.want) + } + }) + } +} diff --git a/gokrazy/gokrazy_test.go b/gokrazy/gokrazy_test.go index f2ace872c..aa52ea2f6 100644 --- a/gokrazy/gokrazy_test.go +++ b/gokrazy/gokrazy_test.go @@ -76,9 +76,14 @@ func TestTsappConfigs(t *testing.T) { if err := json.Unmarshal(cfgBytes, &raw); err != nil { t.Fatalf("unmarshaling config.json as map: %v", err) } - gokCfg := gokrazyConfig{Environment: cfg.Environment} - if got := gokCfg.GOARCH(); got != tt.goarch { - t.Errorf("GOARCH = %q; want %q", got, tt.goarch) + var goarch string + for _, e := range cfg.Environment { + if v, ok := strings.CutPrefix(e, "GOARCH="); ok { + goarch = v + } + } + if goarch != tt.goarch { + t.Errorf("GOARCH = %q; want %q", goarch, tt.goarch) } if cfg.KernelPackage != tt.kernel { t.Errorf("KernelPackage = %q; want %q", cfg.KernelPackage, tt.kernel) @@ -96,43 +101,6 @@ func TestTsappConfigs(t *testing.T) { } } -func TestResolveRegion(t *testing.T) { - tests := []struct { - name, flag, env, want string - }{ - {"default", "", "", "us-east-1"}, - {"env", "", "eu-west-1", "eu-west-1"}, - {"flag", "ap-south-1", "", "ap-south-1"}, - {"flag-beats-env", "ap-south-1", "eu-west-1", "ap-south-1"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := resolveRegion(tt.flag, tt.env); got != tt.want { - t.Errorf("resolveRegion(%q, %q) = %q; want %q", tt.flag, tt.env, got, tt.want) - } - }) - } -} - -func TestAMINameFrom(t *testing.T) { - const now = 1720000000 - tests := []struct { - name, exactTag, describe, want string - }{ - {"tagged-release", "v1.2.3", "v1.2.3", "tsapp-v1.2.3"}, - {"adhoc-describe", "", "v1.2.3-4-gabc1234", "tsapp-v1.2.3-4-gabc1234-1720000000"}, - {"adhoc-dirty", "", "v1.2.3-dirty", "tsapp-v1.2.3-dirty-1720000000"}, - {"no-git", "", "", "tsapp-1720000000"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := amiNameFrom("tsapp", tt.exactTag, tt.describe, now); got != tt.want { - t.Errorf("amiNameFrom = %q; want %q", got, tt.want) - } - }) - } -} - func findKernelPath(t *testing.T) string { t.Helper() goModPath := filepath.Join("..", "go.mod")