mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-21 04:52:08 -04:00
gokrazy/build: use AWS SDK instead of shelling out to aws CLI
Replace the four aws CLI shell-outs (s3 cp, ec2 import-snapshot, describe-import-snapshot-tasks, register-image) with aws-sdk-go-v2 S3 and EC2 clients. Credentials come from the SDK default chain, so existing aws sso login / aws configure / AWS_PROFILE / env / aws-vault sessions keep working. Verify auth via sts:GetCallerIdentity before the slow image build so a logged-out user fails in seconds, not minutes. Upload via the S3 manager (concurrent multipart) with a native progress reader, log [n/4] step lines, and bail on terminal import-snapshot failure states instead of polling forever. Updates #1866 Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
committed by
Kristoffer Dalby
parent
b049ce71a5
commit
a7f3e08335
13
go.mod
13
go.mod
@@ -10,9 +10,10 @@ require (
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa
|
||||
github.com/andybalholm/brotli v1.1.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.58
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.316.1
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.3
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02
|
||||
@@ -321,17 +322,17 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1
|
||||
github.com/aws/smithy-go v1.25.1
|
||||
github.com/aws/smithy-go v1.27.3
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bkielbasa/cyclop v1.2.1 // indirect
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
|
||||
|
||||
26
go.sum
26
go.sum
@@ -144,8 +144,8 @@ github.com/ashanbrown/makezero v1.1.1 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5Fc
|
||||
github.com/ashanbrown/makezero v1.1.1/go.mod h1:i1bJLCRSCHOcOa9Y6MyF2FTfMZMFdHvxKHxgO5Z1axI=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 h1:zAxi9p3wsZMIaVCdoiQp2uZ9k1LsZvmAnoTBeZPXom0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8/go.mod h1:3XkePX5dSaxveLAYY7nsbsZZrKxCyEuE5pM4ziFxyGg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
|
||||
@@ -156,18 +156,20 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8Tc
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.58 h1:/BsEGAyMai+KdXS+CMHlLhB5miAO19wOqE6tj8azWPM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.58/go.mod h1:KHM3lfl/sAJBCoLI1Lsg5w4SD2VDYWwQi7vxbKhw7TI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.316.1 h1:x3XE3BMK8aUpGx/m4CwmCmxc1LnN6saZujJ5K6pIFXU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.316.1/go.mod h1:eoF0SIRbTgKWnTcTPYckiURPba/7ilfEkvwL4V1iHK4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.5 h1:siiQ+jummya9OLPDEyHVb2dLW4aOMe22FGDd0sAfuSw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.5/go.mod h1:iHVx2J9pWzITdP5MJY6qWfG34TfD9EA+Qi3eV6qQCXw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.12 h1:tkVNm99nkJnFo1H9IIQb5QkCiPcvCDn3Pos+IeTbGRA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.12/go.mod h1:dIVlquSPUMqEJtx2/W17SM2SuESRaVEhEV9alcMqxjw=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.3 h1:JBod0SnNqcWQ0+uAyzeRFG1zCHotW8DukumYYyNy0zo=
|
||||
@@ -182,8 +184,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
|
||||
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ=
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c=
|
||||
github.com/beevik/ntp v0.2.0/go.mod h1:hIHWr+l3+/clUnF44zdK+CWW7fO8dR5cIylAQ76NRpg=
|
||||
|
||||
@@ -51,8 +51,14 @@ For UTM, see the [UTM instructions](UTM.md).
|
||||
|
||||
### Build an AMI
|
||||
|
||||
`go run build.go --bucket=your-S3-temp-bucket` to build an AMI. Make
|
||||
sure your "aws" command is in your path and has access.
|
||||
`go run build.go --bucket=your-S3-temp-bucket` to build an AMI.
|
||||
|
||||
Credentials come from the AWS SDK's default chain, so authenticate any way it
|
||||
recognizes: `aws sso login`, `aws configure`, an `AWS_PROFILE`,
|
||||
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` env vars, or
|
||||
`aws-vault exec <profile> -- go run build.go --bucket=...` (aws-vault injects
|
||||
temporary credentials as env vars). If no credentials are found the build stops
|
||||
with a message telling you how to log in.
|
||||
|
||||
### Creating an instance
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
// 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.
|
||||
// to S3 and register an AMI using the AWS SDK for Go v2. Credentials are
|
||||
// resolved via the SDK's default chain (the same sources the "aws" CLI
|
||||
// uses: env vars, ~/.aws, SSO cache, IMDS). 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
|
||||
@@ -24,10 +26,20 @@
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
|
||||
"github.com/aws/aws-sdk-go-v2/service/ec2"
|
||||
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sts"
|
||||
"github.com/mattn/go-isatty"
|
||||
"tailscale.com/gokrazy/mkfs"
|
||||
tsrate "tailscale.com/tstime/rate"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
@@ -68,7 +80,7 @@ type Config struct {
|
||||
|
||||
// Logf receives human-readable progress. If nil, log.Printf is used.
|
||||
Logf logger.Logf
|
||||
// Stderr receives the output of subprocesses (monogok, aws). If nil,
|
||||
// Stderr receives the output of subprocesses (monogok). If nil,
|
||||
// os.Stderr is used. Keeping this off stdout lets callers reserve
|
||||
// stdout for machine-readable output.
|
||||
Stderr io.Writer
|
||||
@@ -81,6 +93,11 @@ type Builder struct {
|
||||
|
||||
conf gokrazyConfig // parsed <Dir>/<App>/config.json
|
||||
res Result
|
||||
|
||||
// s3c and ec2c are lazily created by awsClients on first use and
|
||||
// cached; they're nil until then.
|
||||
s3c *s3.Client
|
||||
ec2c *ec2.Client
|
||||
}
|
||||
|
||||
// baseImageSizeBytes is the size of the disk image we ask monogok to
|
||||
@@ -209,19 +226,30 @@ func (b *Builder) BuildGAF(ctx context.Context) (string, error) {
|
||||
// returns the populated Result. Config.Bucket and Config.Region select
|
||||
// where the AMI is built.
|
||||
func (b *Builder) BuildAMI(ctx context.Context) (Result, error) {
|
||||
// Verify AWS auth before the slow image build so a logged-out user
|
||||
// fails in seconds, not minutes.
|
||||
if err := b.checkAWSAuth(ctx); err != nil {
|
||||
return b.res, b.fail(err)
|
||||
}
|
||||
|
||||
b.logf("[1/4] building image (%s)", b.App)
|
||||
if _, err := b.BuildImage(ctx); err != nil {
|
||||
return b.res, err // BuildImage already wrapped+recorded it
|
||||
}
|
||||
|
||||
b.logf("[2/4] uploading to s3://%s/%s.img", b.Bucket, b.App)
|
||||
if err := b.uploadToS3(ctx); err != nil {
|
||||
return b.res, b.fail(fmt.Errorf("copy to S3: %w", err))
|
||||
}
|
||||
|
||||
b.logf("[3/4] importing EBS snapshot")
|
||||
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)
|
||||
|
||||
b.logf("[4/4] registering AMI")
|
||||
if err := b.registerAMI(ctx, snapID); err != nil {
|
||||
return b.res, b.fail(fmt.Errorf("register AMI: %w", err))
|
||||
}
|
||||
@@ -229,6 +257,41 @@ func (b *Builder) BuildAMI(ctx context.Context) (Result, error) {
|
||||
return b.res, nil
|
||||
}
|
||||
|
||||
// awsClients lazily loads AWS config (via the SDK default credential
|
||||
// chain, honoring the resolved region) and constructs the S3 and EC2
|
||||
// clients, caching them on b.
|
||||
func (b *Builder) awsClients(ctx context.Context) (*s3.Client, *ec2.Client, error) {
|
||||
if b.s3c != nil && b.ec2c != nil {
|
||||
return b.s3c, b.ec2c, nil
|
||||
}
|
||||
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(b.Region))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("loading AWS config (check AWS_PROFILE / ~/.aws, or run `aws sso login`): %w", err)
|
||||
}
|
||||
b.s3c = s3.NewFromConfig(cfg)
|
||||
b.ec2c = ec2.NewFromConfig(cfg)
|
||||
return b.s3c, b.ec2c, nil
|
||||
}
|
||||
|
||||
// checkAWSAuth verifies the caller has usable AWS credentials by making
|
||||
// an sts:GetCallerIdentity call. It runs as a preflight before the slow
|
||||
// image build so a missing or expired login fails early with an
|
||||
// actionable message instead of deep in the pipeline. On success it logs
|
||||
// the account/identity so the user can confirm they're publishing to the
|
||||
// right account.
|
||||
func (b *Builder) checkAWSAuth(ctx context.Context) error {
|
||||
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(b.Region))
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading AWS config (check AWS_PROFILE / ~/.aws, or run `aws sso login`): %w", err)
|
||||
}
|
||||
out, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("AWS credentials invalid or expired — run `aws sso login` or `aws configure`, or set AWS_PROFILE / AWS_ACCESS_KEY_ID: %w", err)
|
||||
}
|
||||
b.logf("AWS account %s (%s), region %s", aws.ToString(out.Account), aws.ToString(out.Arn), b.Region)
|
||||
return 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.
|
||||
@@ -278,13 +341,41 @@ func (b *Builder) buildImage(ctx context.Context, gaf bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadToS3 uploads the built .img to s3://<Bucket>/.
|
||||
// uploadToS3 uploads the built .img to s3://<Bucket>/<App>.img using a
|
||||
// concurrent multipart upload, showing progress on b.Stderr.
|
||||
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()
|
||||
s3c, _, err := b.awsClients(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imgPath := filepath.Join(b.Dir, b.App+".img")
|
||||
f, err := os.Open(imgPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", imgPath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var size int64 = -1
|
||||
if fi, err := f.Stat(); err == nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
|
||||
pr := b.newProgressReader(f, "uploading", size)
|
||||
defer pr.done()
|
||||
|
||||
up := manager.NewUploader(s3c, func(u *manager.Uploader) {
|
||||
u.PartSize = 16 * 1024 * 1024 // 16 MiB parts
|
||||
u.Concurrency = 8 // upload up to 8 parts at once
|
||||
})
|
||||
_, err = up.Upload(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(b.Bucket),
|
||||
Key: aws.String(b.App + ".img"),
|
||||
Body: pr,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploading %s: %w", imgPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// importSnapshot starts an EC2 import-snapshot task from the uploaded
|
||||
@@ -305,30 +396,22 @@ func (b *Builder) importSnapshot(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
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()
|
||||
_, ec2c, err := b.awsClients(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("import snapshot: %v: %s", err, out)
|
||||
return "", err
|
||||
}
|
||||
var resp struct {
|
||||
ImportTaskID string `json:"ImportTaskId"`
|
||||
out, err := ec2c.ImportSnapshot(ctx, &ec2.ImportSnapshotInput{
|
||||
DiskContainer: &ec2types.SnapshotDiskContainer{
|
||||
Url: aws.String("s3://" + b.Bucket + "/" + b.App + ".img"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("import snapshot: %w", err)
|
||||
}
|
||||
/*
|
||||
{
|
||||
"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)
|
||||
if out.ImportTaskId == nil {
|
||||
return "", fmt.Errorf("import snapshot: empty ImportTaskId in response")
|
||||
}
|
||||
return resp.ImportTaskID, nil
|
||||
return *out.ImportTaskId, nil
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -355,51 +438,56 @@ func (b *Builder) startImportSnapshot(ctx context.Context) (importTaskID string,
|
||||
*/
|
||||
|
||||
func (b *Builder) waitForImportSnapshot(ctx context.Context, importTaskID string) (snapID string, err error) {
|
||||
_, ec2c, err := b.awsClients(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
interactive := isTerminal(b.Stderr)
|
||||
var lastPhase string // last (status/message) reported in non-interactive mode
|
||||
for {
|
||||
out, err := b.awsCmd(ctx, "ec2", "describe-import-snapshot-tasks", "--import-task-ids", importTaskID).CombinedOutput()
|
||||
out, err := ec2c.DescribeImportSnapshotTasks(ctx, &ec2.DescribeImportSnapshotTasksInput{
|
||||
ImportTaskIds: []string{importTaskID},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("describe import snapshot tasks: %v: %s", err, out)
|
||||
return "", fmt.Errorf("describe import snapshot tasks: %w", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
ImportSnapshotTasks []struct {
|
||||
SnapshotTaskDetail struct {
|
||||
SnapshotID string `json:"SnapshotId"`
|
||||
Status string `json:"Status"`
|
||||
StatusMessage string `json:"StatusMessage"`
|
||||
Progress string `json:"Progress"`
|
||||
} `json:"SnapshotTaskDetail"`
|
||||
} `json:"ImportSnapshotTasks"`
|
||||
var status, statusMessage, progress string
|
||||
if len(out.ImportSnapshotTasks) > 0 {
|
||||
if d := out.ImportSnapshotTasks[0].SnapshotTaskDetail; d != nil {
|
||||
snapID = aws.ToString(d.SnapshotId)
|
||||
status = aws.ToString(d.Status)
|
||||
statusMessage = aws.ToString(d.StatusMessage)
|
||||
progress = aws.ToString(d.Progress)
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(out, &resp); err != nil {
|
||||
return "", fmt.Errorf("unmarshal response: %v: %s", err, out)
|
||||
}
|
||||
var d struct {
|
||||
SnapshotID string `json:"SnapshotId"`
|
||||
Status string `json:"Status"`
|
||||
StatusMessage string `json:"StatusMessage"`
|
||||
Progress string `json:"Progress"`
|
||||
}
|
||||
if len(resp.ImportSnapshotTasks) > 0 {
|
||||
d = resp.ImportSnapshotTasks[0].SnapshotTaskDetail
|
||||
}
|
||||
if d.Status == "completed" {
|
||||
if status == "completed" {
|
||||
if interactive {
|
||||
fmt.Fprintln(b.Stderr) // move off the live progress line
|
||||
}
|
||||
return d.SnapshotID, nil
|
||||
return snapID, nil
|
||||
}
|
||||
// A failed import stays out of "completed" forever; stop instead of
|
||||
// polling until the context is cancelled.
|
||||
if importFailed(status) {
|
||||
if interactive {
|
||||
fmt.Fprintln(b.Stderr)
|
||||
}
|
||||
msg := statusMessage
|
||||
if msg == "" {
|
||||
msg = status
|
||||
}
|
||||
return "", fmt.Errorf("import snapshot task %s failed: %s", importTaskID, msg)
|
||||
}
|
||||
|
||||
if interactive {
|
||||
// Repaint one line in place each poll.
|
||||
fmt.Fprintf(b.Stderr, "\r\x1b[K%s", importProgressLine(d.Status, d.StatusMessage, d.Progress))
|
||||
} else if phase := d.Status + "/" + d.StatusMessage; phase != lastPhase {
|
||||
// Non-interactive (CI, pipe): one line per phase change only,
|
||||
// not a fresh line every poll.
|
||||
fmt.Fprintf(b.Stderr, "\r\x1b[K%s", importProgressLine(status, statusMessage, progress))
|
||||
} else if phase := status + "/" + statusMessage; phase != lastPhase {
|
||||
// Non-interactive (pipe/redirect): one line per phase change
|
||||
// only, not a fresh line every poll.
|
||||
lastPhase = phase
|
||||
b.logf("%s", importProgressLine(d.Status, d.StatusMessage, d.Progress))
|
||||
b.logf("%s", importProgressLine(status, statusMessage, progress))
|
||||
}
|
||||
|
||||
select {
|
||||
@@ -413,6 +501,18 @@ func (b *Builder) waitForImportSnapshot(ctx context.Context, importTaskID string
|
||||
}
|
||||
}
|
||||
|
||||
// importFailed reports whether an import-snapshot task Status is a
|
||||
// terminal failure state (as opposed to "active"/"completed"). AWS uses
|
||||
// "deleting"/"deleted" when an import is cancelled or fails, and may
|
||||
// report "error"; treat any status mentioning "error" as failed too.
|
||||
func importFailed(status string) bool {
|
||||
switch status {
|
||||
case "deleting", "deleted", "error":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(status), "error")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -428,46 +528,36 @@ func (b *Builder) registerAMI(ctx context.Context, ebsSnapID string) error {
|
||||
default:
|
||||
return fmt.Errorf("unknown arch %q", b.conf.GOARCH())
|
||||
}
|
||||
out, err := b.awsCmd(ctx, "ec2", "register-image",
|
||||
"--name", b.res.Name,
|
||||
"--architecture", arch,
|
||||
_, ec2c, err := b.awsClients(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := ec2c.RegisterImage(ctx, &ec2.RegisterImageInput{
|
||||
Name: aws.String(b.res.Name),
|
||||
Architecture: ec2types.ArchitectureValues(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()
|
||||
VirtualizationType: aws.String("hvm"),
|
||||
RootDeviceName: aws.String("/dev/sda1"),
|
||||
EnaSupport: aws.Bool(true),
|
||||
ImdsSupport: ec2types.ImdsSupportValuesV20,
|
||||
BootMode: ec2types.BootModeValues(bootMode),
|
||||
BlockDeviceMappings: []ec2types.BlockDeviceMapping{{
|
||||
DeviceName: aws.String("/dev/sda1"),
|
||||
Ebs: &ec2types.EbsBlockDevice{SnapshotId: aws.String(ebsSnapID)},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("register image: %v: %s", err, out)
|
||||
return fmt.Errorf("register image: %w", err)
|
||||
}
|
||||
/*
|
||||
On success:
|
||||
{
|
||||
"ImageId": "ami-052e1538166886ad2"
|
||||
}
|
||||
*/
|
||||
var resp struct {
|
||||
ImageID string `json:"ImageId"`
|
||||
if aws.ToString(out.ImageId) == "" {
|
||||
return fmt.Errorf("empty image ID in register-image response")
|
||||
}
|
||||
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
|
||||
b.res.AMI = *out.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
|
||||
@@ -508,6 +598,126 @@ func isTerminal(w io.Writer) bool {
|
||||
return ok && isatty.IsTerminal(f.Fd())
|
||||
}
|
||||
|
||||
// progressReader wraps an io.Reader and reports how many bytes have been
|
||||
// read against total (which may be -1 if unknown). On a terminal it
|
||||
// repaints a single live line on b.Stderr showing percent, bytes, and a
|
||||
// smoothed rate; otherwise it emits a log line each time another 10% is
|
||||
// done. A background goroutine drives the display; call done() to stop it
|
||||
// and print the final state.
|
||||
type progressReader struct {
|
||||
r io.Reader
|
||||
b *Builder
|
||||
verb string // e.g. "uploading"
|
||||
total int64 // -1 if unknown
|
||||
|
||||
read atomic.Int64 // cumulative bytes read so far
|
||||
|
||||
interactive bool
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// newProgressReader wraps r and starts displaying progress. total is the
|
||||
// expected size in bytes, or -1 if unknown.
|
||||
func (b *Builder) newProgressReader(r io.Reader, verb string, total int64) *progressReader {
|
||||
pr := &progressReader{
|
||||
r: r,
|
||||
b: b,
|
||||
verb: verb,
|
||||
total: total,
|
||||
interactive: isTerminal(b.Stderr),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
pr.wg.Add(1)
|
||||
go pr.run()
|
||||
return pr
|
||||
}
|
||||
|
||||
func (pr *progressReader) Read(p []byte) (int, error) {
|
||||
n, err := pr.r.Read(p)
|
||||
pr.read.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (pr *progressReader) run() {
|
||||
defer pr.wg.Done()
|
||||
var rate tsrate.Value
|
||||
rate.HalfLife = 5 * time.Second
|
||||
var prev int64
|
||||
interval := time.Second
|
||||
if !pr.interactive {
|
||||
interval = 2 * time.Second
|
||||
}
|
||||
tc := time.NewTicker(interval)
|
||||
defer tc.Stop()
|
||||
|
||||
lastBucket := -1 // last 10%-bucket logged in non-interactive mode
|
||||
for {
|
||||
select {
|
||||
case <-pr.stop:
|
||||
return
|
||||
case <-tc.C:
|
||||
cur := pr.read.Load()
|
||||
rate.Add(float64(max(cur-prev, 0)))
|
||||
prev = cur
|
||||
if pr.interactive {
|
||||
fmt.Fprintf(pr.b.Stderr, "\r\x1b[K%s", progressLine(pr.verb, cur, pr.total, rate.Rate()))
|
||||
} else if pr.total > 0 {
|
||||
if bucket := int(cur * 10 / pr.total); bucket > lastBucket {
|
||||
lastBucket = bucket
|
||||
pr.b.logf("%s", progressLine(pr.verb, cur, pr.total, rate.Rate()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// done stops the background display and prints a final line.
|
||||
func (pr *progressReader) done() {
|
||||
close(pr.stop)
|
||||
pr.wg.Wait()
|
||||
cur := pr.read.Load()
|
||||
if pr.interactive {
|
||||
fmt.Fprintf(pr.b.Stderr, "\r\x1b[K%s\n", progressLine(pr.verb, cur, pr.total, 0))
|
||||
} else {
|
||||
pr.b.logf("%s: done (%s)", pr.verb, humanBytes(float64(cur)))
|
||||
}
|
||||
}
|
||||
|
||||
// progressLine formats a one-line byte-progress status. rate is bytes/sec
|
||||
// (0 to omit). total may be -1 (unknown), in which case percent is omitted.
|
||||
func progressLine(verb string, cur, total int64, rate float64) string {
|
||||
if total > 0 {
|
||||
pct := 100 * float64(cur) / float64(total)
|
||||
s := fmt.Sprintf("%s: %.1f%% (%s / %s)", verb, pct, humanBytes(float64(cur)), humanBytes(float64(total)))
|
||||
if rate > 0 {
|
||||
s += fmt.Sprintf(" %s/s", humanBytes(rate))
|
||||
}
|
||||
return s
|
||||
}
|
||||
s := fmt.Sprintf("%s: %s", verb, humanBytes(float64(cur)))
|
||||
if rate > 0 {
|
||||
s += fmt.Sprintf(" %s/s", humanBytes(rate))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// humanBytes formats n bytes with an IEC (binary) unit.
|
||||
func humanBytes(n float64) string {
|
||||
switch {
|
||||
case n < 1<<10:
|
||||
return fmt.Sprintf("%.0fB", n)
|
||||
case n < 1<<20:
|
||||
return fmt.Sprintf("%.2fKiB", n/(1<<10))
|
||||
case n < 1<<30:
|
||||
return fmt.Sprintf("%.2fMiB", n/(1<<20))
|
||||
case n < 1<<40:
|
||||
return fmt.Sprintf("%.2fGiB", n/(1<<30))
|
||||
default:
|
||||
return fmt.Sprintf("%.2fTiB", n/(1<<40))
|
||||
}
|
||||
}
|
||||
|
||||
// awsArch maps a Go GOARCH to the AWS EC2 --architecture value.
|
||||
func awsArch(goarch string) string {
|
||||
switch goarch {
|
||||
|
||||
@@ -92,3 +92,65 @@ func TestAMINameFrom(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportFailed(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{"active", false},
|
||||
{"completed", false},
|
||||
{"", false},
|
||||
{"deleting", true},
|
||||
{"deleted", true},
|
||||
{"error", true},
|
||||
{"some-error-state", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.status, func(t *testing.T) {
|
||||
if got := importFailed(tt.status); got != tt.want {
|
||||
t.Errorf("importFailed(%q) = %v; want %v", tt.status, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
verb string
|
||||
cur, total int64
|
||||
rate float64
|
||||
want string
|
||||
}{
|
||||
{"known-with-rate", "uploading", 512 << 20, 1 << 30, 64 << 20, "uploading: 50.0% (512.00MiB / 1.00GiB) 64.00MiB/s"},
|
||||
{"known-no-rate", "uploading", 1 << 20, 4 << 20, 0, "uploading: 25.0% (1.00MiB / 4.00MiB)"},
|
||||
{"unknown-total", "uploading", 3 << 20, -1, 1 << 20, "uploading: 3.00MiB 1.00MiB/s"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := progressLine(tt.verb, tt.cur, tt.total, tt.rate); got != tt.want {
|
||||
t.Errorf("progressLine = %q; want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
n float64
|
||||
want string
|
||||
}{
|
||||
{512, "512B"},
|
||||
{1 << 10, "1.00KiB"},
|
||||
{1 << 20, "1.00MiB"},
|
||||
{1536 << 20, "1.50GiB"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
if got := humanBytes(tt.n); got != tt.want {
|
||||
t.Errorf("humanBytes(%v) = %q; want %q", tt.n, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user