Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -239,6 +240,7 @@ lamvms wait [フラグ]
| フラグ | 説明 | デフォルト |
|--------|------|-----------|
| `--image-version` | 待機する特定バージョン | 最新バージョン |
| `--build-logs` / `--no-build-logs` | 待機中にビルドの CloudWatch Logs を tail する | `true` |
| `--keep-versions N` | 待機後に古いバージョンを削除 | `0`(無効) |

### rollback
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
}

Expand Down
46 changes: 43 additions & 3 deletions deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
}
}
}
105 changes: 105 additions & 0 deletions deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package lamvms

import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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")
}
}
6 changes: 5 additions & 1 deletion logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
4 changes: 2 additions & 2 deletions wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
Expand Down