gokrazy: add --json output, --region pinning, git-derived AMI names
Make build.go drivable and consumable by a CI builder. --json prints one machine-readable result line to stdout while all logs/progress go to stderr, so scripts can capture data cleanly. --region (honoring $AWS_REGION, default us-east-1) pins import+register deterministically. AMI names derive from git: <app>-<tag> on a tagged commit, else <app>-<describe>-<unixtime>. Updates #1866 Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
committed by
Kristoffer Dalby
parent
7e62ead76e
commit
911c5e58ed
+117
-11
@@ -24,12 +24,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
app = flag.String("app", "tsapp", "appliance name; one of the subdirectories of gokrazy/")
|
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")
|
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")
|
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")
|
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
|
// 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
|
// produce (and that the AWS AMI import expects). It has to be large
|
||||||
// enough to fit gokrazy's standard partition layout (see
|
// enough to fit gokrazy's standard partition layout (see
|
||||||
@@ -105,11 +127,16 @@ func main() {
|
|||||||
log.Fatalf("config.json GOARCH %q must be amd64 or arm64", conf.GOARCH())
|
log.Fatalf("config.json GOARCH %q must be amd64 or arm64", conf.GOARCH())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
awsRegion = resolveRegion(*region, os.Getenv("AWS_REGION"))
|
||||||
|
res.App = *app
|
||||||
|
res.Arch = awsArch(conf.GOARCH())
|
||||||
|
|
||||||
if err := buildImage(); err != nil {
|
if err := buildImage(); err != nil {
|
||||||
log.Fatalf("build image: %v", err)
|
log.Fatalf("build image: %v", err)
|
||||||
}
|
}
|
||||||
if *build || *gaf {
|
if *build || *gaf {
|
||||||
log.Printf("built. stopping.")
|
log.Printf("built. stopping.")
|
||||||
|
emitJSON()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +153,53 @@ func main() {
|
|||||||
log.Fatalf("waitForImportSnapshot(%v): %v", importTask, err)
|
log.Fatalf("waitForImportSnapshot(%v): %v", importTask, err)
|
||||||
}
|
}
|
||||||
log.Printf("snap ID: %v", snapID)
|
log.Printf("snap ID: %v", snapID)
|
||||||
|
res.Snapshot = snapID
|
||||||
|
res.Region = awsRegion
|
||||||
|
|
||||||
ami, err := makeAMI(fmt.Sprintf(*app+"-%d", time.Now().Unix()), snapID)
|
res.Name = amiName(*app)
|
||||||
|
ami, err := makeAMI(res.Name, snapID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("makeAMI: %v", err)
|
log.Fatalf("makeAMI: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("made AMI: %v", ami)
|
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() {
|
||||||
|
if !*jsonOut {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(os.Stdout).Encode(&res); err != nil {
|
||||||
|
log.Fatalf("encoding json result: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildImage() error {
|
func buildImage() error {
|
||||||
@@ -159,12 +227,13 @@ func buildImage() error {
|
|||||||
|
|
||||||
cmd := exec.Command("go", args...)
|
cmd := exec.Command("go", args...)
|
||||||
cmd.Dir = filepath.Join(dir, *app)
|
cmd.Dir = filepath.Join(dir, *app)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stderr
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if *gaf {
|
if *gaf {
|
||||||
|
res.GAF = filepath.Join(dir, *app+".gaf")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,18 +247,55 @@ func buildImage() error {
|
|||||||
return fmt.Errorf("formatting /perm in %s: %v", imgPath, err)
|
return fmt.Errorf("formatting /perm in %s: %v", imgPath, err)
|
||||||
}
|
}
|
||||||
log.Printf("Wrote ext4 /perm filesystem to %s.", imgPath)
|
log.Printf("Wrote ext4 /perm filesystem to %s.", imgPath)
|
||||||
|
res.Image = imgPath
|
||||||
return nil
|
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 {
|
func copyToS3() error {
|
||||||
cmd := exec.Command("aws", "s3", "cp", *app+".img", "s3://"+*bucket+"/")
|
cmd := awsCmd("s3", "cp", *app+".img", "s3://"+*bucket+"/")
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stderr
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startImportSnapshot() (importTaskID string, err error) {
|
func startImportSnapshot() (importTaskID string, err error) {
|
||||||
out, err := exec.Command("aws", "ec2", "import-snapshot", "--disk-container", "Url=s3://"+*bucket+"/"+*app+".img").CombinedOutput()
|
out, err := awsCmd("ec2", "import-snapshot", "--disk-container", "Url=s3://"+*bucket+"/"+*app+".img").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("import snapshot: %v: %s", err, out)
|
return "", fmt.Errorf("import snapshot: %v: %s", err, out)
|
||||||
}
|
}
|
||||||
@@ -240,7 +346,7 @@ func startImportSnapshot() (importTaskID string, err error) {
|
|||||||
|
|
||||||
func waitForImportSnapshot(importTaskID string) (snapID string, err error) {
|
func waitForImportSnapshot(importTaskID string) (snapID string, err error) {
|
||||||
for {
|
for {
|
||||||
out, err := exec.Command("aws", "ec2", "describe-import-snapshot-tasks", "--import-task-ids", importTaskID).CombinedOutput()
|
out, err := awsCmd("ec2", "describe-import-snapshot-tasks", "--import-task-ids", importTaskID).CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("describe import snapshot tasks: %v: %s", err, out)
|
return "", fmt.Errorf("describe import snapshot tasks: %v: %s", err, out)
|
||||||
}
|
}
|
||||||
@@ -301,7 +407,7 @@ func makeAMI(name, ebsSnapID string) (ami string, err error) {
|
|||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unknown arch %q", conf.GOARCH())
|
return "", fmt.Errorf("unknown arch %q", conf.GOARCH())
|
||||||
}
|
}
|
||||||
out, err := exec.Command("aws", "ec2", "register-image",
|
out, err := awsCmd("ec2", "register-image",
|
||||||
"--name", name,
|
"--name", name,
|
||||||
"--architecture", arch,
|
"--architecture", arch,
|
||||||
// register-image defaults to paravirtual; arm64 rejects that
|
// register-image defaults to paravirtual; arm64 rejects that
|
||||||
|
|||||||
@@ -96,6 +96,43 @@ 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 {
|
func findKernelPath(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
goModPath := filepath.Join("..", "go.mod")
|
goModPath := filepath.Join("..", "go.mod")
|
||||||
|
|||||||
Reference in New Issue
Block a user