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 <kristoffer@tailscale.com>
This commit is contained in:
committed by
Kristoffer Dalby
parent
bb4f458207
commit
7653a1e438
+38
-401
@@ -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 <app>-<tag> (releases); otherwise <app>-<git describe>-<unixtime> for
|
||||
// ad-hoc builds. If git is unavailable it falls back to <app>-<unixtime>.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 <Dir>/<App>/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
|
||||
// <Dir>/<App>/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://<Bucket>/.
|
||||
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 <app>-<tag> (releases); otherwise
|
||||
// <app>-<git describe>-<unixtime> for ad-hoc builds. If git is
|
||||
// unavailable it falls back to <app>-<unixtime>.
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+8
-40
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user