From bb3c8639749cf054d3fc77284bd3a7fab0fb8191 Mon Sep 17 00:00:00 2001 From: mashiike Date: Thu, 9 Jul 2026 13:04:47 +0900 Subject: [PATCH 1/2] feat(deploy): tail build logs from CloudWatch while waiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a --build-logs/--no-build-logs flag (default true) to deploy and wait that tails the MicroVM image build's CloudWatch Logs stream while polling for version readiness, shelling out to `aws logs tail --follow` scoped to the build's log stream prefix. If the aws CLI is missing or fails to start, it logs a warning and continues without it — build log visibility is best-effort and never fails the wait itself. deploy と wait に --build-logs/--no-build-logs フラグ(デフォルト true)を 追加し、バージョンの準備完了をポーリングする間、MicroVM イメージビルドの CloudWatch Logs ストリームを aws logs tail --follow に委譲して tail する。 aws CLI が見つからない、または起動に失敗した場合は警告ログを出して継続する (ビルドログ表示はベストエフォートであり、wait 自体を失敗させない)。 Co-Authored-By: Claude Sonnet 5 --- README.ja.md | 2 + README.md | 2 + cli.go | 2 + deploy.go | 46 +++++++++++++++++++-- deploy_test.go | 106 +++++++++++++++++++++++++++++++++++++++++++++++++ logs.go | 6 ++- wait.go | 4 +- 7 files changed, 162 insertions(+), 6 deletions(-) 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..878c20a 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,105 @@ 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() + dir := t.TempDir() + name := "aws" + if runtime.GOOS == "windows" { + name = "aws.bat" + } + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$FAKE_AWS_ARGS_FILE\"\nsleep 5\n" + if err := os.WriteFile(filepath.Join(dir, name), []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 } } From 4ffd3a929124bbfab0d912c38a8fa7baaf128d69 Mon Sep 17 00:00:00 2001 From: mashiike Date: Thu, 9 Jul 2026 15:30:08 +0900 Subject: [PATCH 2/2] fix(test): skip newFakeAWS on windows instead of faking a .bat file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review pointed out that newFakeAWS wrote a POSIX shell script even when naming the file aws.bat for Windows, which would never execute there. Skip the test on Windows instead of pretending to support it (CI only runs on ubuntu-latest, so this has no coverage impact today). Copilot のレビューで、newFakeAWS が Windows 向けに aws.bat という名前を 付けていても内容は常に POSIX シェルスクリプトで、実際には実行できないと 指摘された。サポートしているふりをするのではなく、Windows では明示的に テストを skip する(CI は ubuntu-latest のみなので、カバレッジへの影響はない)。 Co-Authored-By: Claude Sonnet 5 --- deploy_test.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/deploy_test.go b/deploy_test.go index 878c20a..1b426d8 100644 --- a/deploy_test.go +++ b/deploy_test.go @@ -323,13 +323,12 @@ func TestDeploy_Create_BuildLogsBestEffortWhenAWSCLIMissing(t *testing.T) { func newFakeAWS(t *testing.T) string { t.Helper() - dir := t.TempDir() - name := "aws" if runtime.GOOS == "windows" { - name = "aws.bat" + 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, name), []byte(script), 0o755); err != nil { + 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"))