diff --git a/README.ja.md b/README.ja.md index 8a5a55a..8f5e41b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -224,6 +224,7 @@ lamvms deploy [フラグ] | `--src` | zip アーカイブのソースディレクトリ | microvm 定義のディレクトリ | | `--skip-archive` | zip 作成・S3 アップロードをスキップ | `false` | | `--wait` / `--no-wait` | ビルド完了を待機 | `true` | +| `--build-logs` / `--no-build-logs` | 待機中にビルドの CloudWatch Logs を tail する | `true` | | `--keep-versions N` | 最新 N 件のアクティブバージョンを保持 | `0`(無効) | | `--dry-run` | 実行内容を表示するのみ | `false` | | `--symlink` | 実体展開せずシンボリックリンクとしてアーカイブに格納する(`zip --symlink` と同等) | `false` | @@ -239,6 +240,7 @@ lamvms wait [フラグ] | フラグ | 説明 | デフォルト | |--------|------|-----------| | `--image-version` | 待機する特定バージョン | 最新バージョン | +| `--build-logs` / `--no-build-logs` | 待機中にビルドの CloudWatch Logs を tail する | `true` | | `--keep-versions N` | 待機後に古いバージョンを削除 | `0`(無効) | ### rollback diff --git a/README.md b/README.md index 4c40f23..2a4be4e 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ lamvms deploy [flags] | `--src` | Source directory for zip archive | Directory of microvm definition | | `--skip-archive` | Skip zip creation and S3 upload | `false` | | `--wait` / `--no-wait` | Wait for build completion | `true` | +| `--build-logs` / `--no-build-logs` | Tail the build's CloudWatch Logs while waiting | `true` | | `--keep-versions N` | Keep N latest active versions, delete older | `0` (disabled) | | `--dry-run` | Show what would be done | `false` | | `--symlink` | Keep symlinks in the archive instead of following them (same as `zip --symlink`) | `false` | @@ -239,6 +240,7 @@ lamvms wait [flags] | Flag | Description | Default | |------|-------------|---------| | `--image-version` | Specific version to wait for | Latest version | +| `--build-logs` / `--no-build-logs` | Tail the build's CloudWatch Logs while waiting | `true` | | `--keep-versions N` | Delete old versions after wait | `0` (disabled) | ### rollback diff --git a/cli.go b/cli.go index daff68a..ca47610 100644 --- a/cli.go +++ b/cli.go @@ -76,6 +76,7 @@ type DeployOption struct { Src string `help:"Source directory to archive and upload. Defaults to the directory of the microvm definition file." json:"src,omitempty"` SkipArchive bool `help:"Skip creating and uploading zip archive." default:"false" json:"skip_archive,omitempty"` Wait bool `help:"Wait for the image build to complete." default:"true" negatable:"" json:"wait,omitempty"` + BuildLogs bool `help:"Tail the build's CloudWatch Logs while waiting." default:"true" negatable:"" json:"build_logs,omitempty"` KeepVersions int `help:"Number of latest versions to keep. Older versions will be deleted." default:"0" json:"keep_versions,omitempty"` DryRun bool `help:"Dry run." default:"false" json:"dry_run,omitempty"` Symlink bool `help:"Keep symlinks in the archive instead of following them (same as zip --symlink,-y)." default:"false" json:"symlink,omitempty"` @@ -84,6 +85,7 @@ type DeployOption struct { // WaitOption represents options for the wait subcommand. type WaitOption struct { Version string `help:"Image version to wait for. Defaults to the latest version." name:"image-version" json:"image_version,omitempty"` + BuildLogs bool `help:"Tail the build's CloudWatch Logs while waiting." default:"true" negatable:"" json:"build_logs,omitempty"` KeepVersions int `help:"Number of latest versions to keep. Older versions will be deleted." default:"0" json:"keep_versions,omitempty"` } diff --git a/deploy.go b/deploy.go index 1f8d974..c8356fb 100644 --- a/deploy.go +++ b/deploy.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "os" + "os/exec" "path/filepath" "strings" "time" @@ -64,7 +65,7 @@ func (app *App) Deploy(ctx context.Context, opt *DeployOption) error { return nil } - if err := app.waitForVersion(ctx, imageARN, imageVersion); err != nil { + if err := app.waitForVersion(ctx, imageARN, imageVersion, opt.BuildLogs); err != nil { return err } @@ -198,8 +199,14 @@ func (app *App) updateMicrovmImage(ctx context.Context, existing *lambdamicrovms return aws.ToString(out.ImageArn), aws.ToString(out.ImageVersion), nil } -func (app *App) waitForVersion(ctx context.Context, imageARN, imageVersion string) error { +func (app *App) waitForVersion(ctx context.Context, imageARN, imageVersion string, showBuildLogs bool) error { slog.Info("waiting for version to be ready", "image", imageARN, "version", imageVersion) + + if showBuildLogs { + stop := app.startBuildLogTail(ctx, imageVersion) + defer stop() + } + for { select { case <-ctx.Done(): @@ -265,5 +272,38 @@ func (app *App) getFailureReason(ctx context.Context, imageARN, imageVersion str if len(reasons) > 0 { return strings.Join(reasons, "; ") } - return "(no reason found, check CloudWatch logs: /aws/lambda-microvms/)" + name := aws.ToString(app.microvmImage.Name) + return fmt.Sprintf("(no reason found, check CloudWatch logs: %s)", microvmLogGroupName(name)) +} + +func (app *App) startBuildLogTail(ctx context.Context, imageVersion string) (stop func()) { + name := aws.ToString(app.microvmImage.Name) + logGroup := microvmLogGroupName(name) + command := []string{"aws"} + if app.profile != "" { + command = append(command, "--profile", app.profile) + } + if app.awsConfig.Region != "" { + command = append(command, "--region", app.awsConfig.Region) + } + command = append(command, "logs", "tail", logGroup, "--follow", "--log-stream-name-prefix", imageVersion+"/") + + tailCtx, cancel := context.WithCancel(ctx) + cmd := exec.CommandContext(tailCtx, command[0], command[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + cancel() + slog.Warn("failed to start build log tail, continuing without it", "error", err) + return func() {} + } + slog.Debug("invoking command", "command", strings.Join(command, " ")) + + return func() { + cancel() + if err := cmd.Wait(); err != nil { + slog.Debug("build log tail process exited", "error", err) + } + } } diff --git a/deploy_test.go b/deploy_test.go index 35ddf0a..1b426d8 100644 --- a/deploy_test.go +++ b/deploy_test.go @@ -2,6 +2,10 @@ package lamvms import ( "context" + "os" + "path/filepath" + "runtime" + "strings" "testing" "time" @@ -291,3 +295,104 @@ func TestDeploy_DryRun(t *testing.T) { t.Fatal(err) } } + +func TestDeploy_Create_BuildLogsBestEffortWhenAWSCLIMissing(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + ctrl := gomock.NewController(t) + mock := NewMockLambdaMicroVMsClient(ctrl) + + expectListNotFound(mock) + + mock.EXPECT(). + CreateMicrovmImage(gomock.Any(), gomock.Any()). + Return(&lambdamicrovms.CreateMicrovmImageOutput{ + Name: aws.String("test-microvm"), + ImageArn: aws.String(testImageARN), + ImageVersion: aws.String("1.0"), + State: types.MicrovmImageStateCreating, + }, nil) + + expectVersionSuccessful(mock, "1.0") + + app := newTestApp(t, mock, "testdata/microvm.json") + if err := app.Deploy(context.Background(), &DeployOption{SkipArchive: true, Wait: true, BuildLogs: true}); err != nil { + t.Fatal(err) + } +} + +func newFakeAWS(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake aws helper is a POSIX shell script") + } + dir := t.TempDir() + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$FAKE_AWS_ARGS_FILE\"\nsleep 5\n" + if err := os.WriteFile(filepath.Join(dir, "aws"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return filepath.Join(t.TempDir(), "args.txt") +} + +func TestStartBuildLogTail_NotFound(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + app := newTestApp(t, nil, "testdata/microvm.json") + stop := app.startBuildLogTail(context.Background(), "1.0") + stop() +} + +func TestStartBuildLogTail_BuildsCommand(t *testing.T) { + argsFile := newFakeAWS(t) + t.Setenv("FAKE_AWS_ARGS_FILE", argsFile) + + app := newTestApp(t, nil, "testdata/microvm.json") + app.profile = "test-profile" + app.awsConfig.Region = "ap-northeast-1" + + stop := app.startBuildLogTail(context.Background(), "1.0") + defer stop() + + deadline := time.Now().Add(3 * time.Second) + var got []byte + for { + var err error + got, err = os.ReadFile(argsFile) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("args file was not written in time: %v", err) + } + time.Sleep(10 * time.Millisecond) + } + gotArgs := strings.Split(strings.TrimRight(string(got), "\n"), "\n") + want := []string{ + "--profile", "test-profile", + "--region", "ap-northeast-1", + "logs", "tail", "/aws/lambda-microvms/test-microvm", + "--follow", "--log-stream-name-prefix", "1.0/", + } + if strings.Join(gotArgs, " ") != strings.Join(want, " ") { + t.Errorf("aws args = %v, want %v", gotArgs, want) + } +} + +func TestStartBuildLogTail_StopDoesNotHang(t *testing.T) { + newFakeAWS(t) + + app := newTestApp(t, nil, "testdata/microvm.json") + stop := app.startBuildLogTail(context.Background(), "1.0") + + done := make(chan struct{}) + go func() { + stop() + close(done) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("stop() did not return in time") + } +} diff --git a/logs.go b/logs.go index f38864a..54b7a62 100644 --- a/logs.go +++ b/logs.go @@ -20,11 +20,15 @@ type LogsOption struct { FilterPattern string `help:"The filter pattern to use." json:"filter_pattern,omitempty"` } +func microvmLogGroupName(name string) string { + return fmt.Sprintf("/aws/lambda-microvms/%s", name) +} + // Logs tails CloudWatch logs for the MicroVM image. func (app *App) Logs(ctx context.Context, opt *LogsOption) error { img := app.microvmImage name := aws.ToString(img.Name) - logGroup := fmt.Sprintf("/aws/lambda-microvms/%s", name) + logGroup := microvmLogGroupName(name) command := []string{"aws"} if app.profile != "" { diff --git a/wait.go b/wait.go index 22f6925..0f58bcc 100644 --- a/wait.go +++ b/wait.go @@ -28,7 +28,7 @@ func (app *App) Wait(ctx context.Context, opt *WaitOption) error { if opt.Version != "" { slog.Info("waiting for version", "name", name, "version", opt.Version) - if err := app.waitForVersion(ctx, imageARN, opt.Version); err != nil { + if err := app.waitForVersion(ctx, imageARN, opt.Version, opt.BuildLogs); err != nil { return err } } else { @@ -43,7 +43,7 @@ func (app *App) Wait(ctx context.Context, opt *WaitOption) error { return err } slog.Info("waiting for version", "name", name, "version", version) - if err := app.waitForVersion(ctx, imageARN, version); err != nil { + if err := app.waitForVersion(ctx, imageARN, version, opt.BuildLogs); err != nil { return err } }