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
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.48.0] - 2026-06-13
### Fixed
- Instances launched from a `spawn image import` **warm AMI** can now retrieve
their Administrator password again (`spawn connect --rdp` and the EC2 console's
"Get Windows password"). The warm-AMI build imaged the seed after EC2Launch had
already generated its one-time password, so every launch from the warm AMI
returned "Password is not available. The instance was launched from a custom
AMI…". The build now re-arms EC2Launch (`reset -c`) over SSM before imaging — so
a fresh password is generated on each launch — and captures the image with
`NoReboot` so the re-armed state is preserved. This keeps the warm AMI
non-generalized (no Sysprep). The warm build now requires the seed's SSM agent
to come Online (it was best-effort) and fails loudly otherwise (#153).

### Added
- `spawn snapshot create --from` now accepts a **directory** or a
Expand Down
52 changes: 45 additions & 7 deletions cmd/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,15 @@ func runImageImport(cmd *cobra.Command, args []string) error {
// and spored + the SSM agent are installed/running), creates a new AMI from it,
// tags it, and terminates the seed. The seed is always terminated, even on
// error. Returns the warm AMI id. (#98)
// ec2LaunchRearmCommand re-arms EC2Launch v2's run-once tasks — chiefly
// setAdminAccount, which generates the GetPasswordData-retrievable Administrator
// password — so an AMI captured from an already-booted seed hands out a fresh
// password on each launch. `reset -c` clears the run-once state and instance
// logs WITHOUT generalizing (no Sysprep), keeping the warm AMI a non-generalized
// single-instance image, and does not shut the instance down (#153).
// $Env:ProgramFiles resolves to "C:\Program Files" regardless of localization.
const ec2LaunchRearmCommand = `& "$Env:ProgramFiles\Amazon\EC2Launch\EC2Launch.exe" reset -c`

func buildWarmAMI(ctx context.Context, awsClient *aws.Client, region, name, version, baseAMI string, prog *progress.Progress) (string, error) {
seedType := imageImportWarmType
if err := guardWindowsInstanceType("windows", seedType); err != nil {
Expand Down Expand Up @@ -547,21 +556,50 @@ func buildWarmAMI(ctx context.Context, awsClient *aws.Client, region, name, vers
}
prog.Complete(waitLabel)

// Best-effort: give the SSM agent + spored a short, bounded window to come
// online so the warm image captures a fully-settled machine. Never fatal.
prog.Start("Letting spored/SSM settle (best-effort)")
if err := awsClient.WaitForSSMOnline(ctx, region, seedID, 10*time.Minute); err != nil {
fmt.Fprintf(os.Stderr, " note: SSM not Online yet on the seed; imaging anyway (the agent is installed and will register on the warm AMI's next boot).\n")
// SSM must come Online here: we use it to re-arm EC2Launch before imaging
// (below), and that step is required — a warm AMI built without it can't hand
// out an Administrator password (#153). SSM registration can be slow on
// Windows first boot, so the window is generous.
ssmLabel := "Waiting for SSM agent Online on seed (needed to re-arm EC2Launch)"
prog.Start(ssmLabel)
if err := awsClient.WaitForSSMOnline(ctx, region, seedID, time.Duration(imageImportWarmTimeout)*time.Minute); err != nil {
prog.Error(ssmLabel, err)
return "", fmt.Errorf("warm seed's SSM agent never came Online, so EC2Launch can't be re-armed for password generation: %w", err)
}
prog.Complete(ssmLabel)

// Re-arm EC2Launch v2 before imaging (#153). EC2Launch generates the
// retrievable Administrator password via its `setAdminAccount` task, which
// runs ONCE — on the seed's first boot it already ran, so an AMI captured as-is
// hands out no password ("Password is not available. The instance was launched
// from a custom AMI..."). `EC2Launch.exe reset -c` clears the run-once state so
// setAdminAccount runs again on the warm AMI's next boot, WITHOUT generalizing
// (we deliberately keep a non-generalized single-instance image, #98). reset
// stages state for next boot and does NOT shut the instance down, so we image
// immediately after — with NoReboot so the captured AMI keeps the armed state.
rearmLabel := "Re-arming EC2Launch for password generation (reset -c)"
prog.Start(rearmLabel)
res2, err := awsClient.RunPowerShell(ctx, region, seedID, ec2LaunchRearmCommand, 5*time.Minute)
if err != nil {
prog.Error(rearmLabel, err)
return "", fmt.Errorf("re-arm EC2Launch on warm seed: %w", err)
}
if res2.Status != "Success" {
prog.Error(rearmLabel, fmt.Errorf("status %s", res2.Status))
return "", fmt.Errorf("EC2Launch reset did not succeed on warm seed (status %s): %s\n%s", res2.Status, res2.Stdout, res2.Stderr)
}
prog.Complete("Letting spored/SSM settle (best-effort)")
prog.Complete(rearmLabel)

prog.Start("Creating warm AMI from seed")
warmName := fmt.Sprintf("%s-warm-%s", name, version)
warmID, err := awsClient.CreateAMI(ctx, region, aws.CreateAMIInput{
InstanceID: seedID,
Name: warmName,
Description: fmt.Sprintf("spawn warm AMI from %s (fast-boot, first boot already done)", baseAMI),
NoReboot: false, // let Windows flush cleanly for a consistent image
// NoReboot: a reboot here would let the just-re-armed EC2Launch
// setAdminAccount task run on the SEED, consuming the run-once state before
// the snapshot captures it — defeating the re-arm. Capture the armed state. (#153)
NoReboot: true,
})
if err != nil {
prog.Error("Creating warm AMI from seed", err)
Expand Down
24 changes: 24 additions & 0 deletions cmd/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ import (
"testing"
)

// TestEC2LaunchRearmCommand pins the re-arm the warm-AMI build runs before
// imaging (#153): it must use EC2Launch's `reset` (re-enables setAdminAccount so
// GetPasswordData works on launches from the warm AMI) and must NOT generalize
// the image (no `sysprep`), since the warm AMI is a non-generalized
// single-instance image (#98).
func TestEC2LaunchRearmCommand(t *testing.T) {
cmd := ec2LaunchRearmCommand
if !strings.Contains(cmd, "EC2Launch.exe") {
t.Errorf("re-arm command should invoke EC2Launch.exe; got %q", cmd)
}
if !strings.Contains(cmd, " reset") {
t.Errorf("re-arm command must use `reset` to re-enable setAdminAccount; got %q", cmd)
}
if !strings.Contains(cmd, "-c") {
t.Errorf("re-arm command should pass -c to clear run-once state + logs; got %q", cmd)
}
if strings.Contains(cmd, "sysprep") {
t.Errorf("re-arm must NOT sysprep/generalize the warm AMI; got %q", cmd)
}
if !strings.Contains(cmd, `$Env:ProgramFiles\Amazon\EC2Launch`) {
t.Errorf("re-arm command should use the EC2Launch v2 install path; got %q", cmd)
}
}

func TestValidateImageImportFlags(t *testing.T) {
cases := []struct {
name string
Expand Down
Loading