82572684
fix(agent): declare disk images raw so the GPT stays writable
a73x 2026-07-30 12:19
Commit message
internal/agent/cloudhv/cloudhv.go
| Old | New | ||
|---|---|---|---|
| @@ -91,9 +91,14 @@ func (p *Provisioner) buildArgs(spec state.VMSpec) []string { | |||
| 91 | "--kernel", p.firmware, | 91 | "--kernel", p.firmware, |
| 92 | "--cpus", fmt.Sprintf("boot=%d", spec.VCPUs), | 92 | "--cpus", fmt.Sprintf("boot=%d", spec.VCPUs), |
| 93 | "--memory", fmt.Sprintf("size=%dM", spec.MemMB), | 93 | "--memory", fmt.Sprintf("size=%dM", spec.MemMB), |
| 94 | // image_type=raw is load-bearing, not decoration: autodetected raw | ||
| 95 | // makes CH DISABLE SECTOR 0 WRITES, so the first boot's growpart | ||
| 96 | // rewrites the partition table only in memory and the guest dies in | ||
| 97 | // initramfs at its first power cycle. Declared raw keeps the GPT | ||
| 98 | // writable. (Autodetection is also deprecated in CH v53.) | ||
| 94 | "--disk", | 99 | "--disk", |
| 95 | fmt.Sprintf("path=%s", p.st.DiskPath(vmID)), | 100 | fmt.Sprintf("path=%s,image_type=raw", p.st.DiskPath(vmID)), |
| 96 | fmt.Sprintf("path=%s,readonly=on", p.st.SeedPath(vmID)), | 101 | fmt.Sprintf("path=%s,image_type=raw,readonly=on", p.st.SeedPath(vmID)), |
| 97 | "--net", fmt.Sprintf("tap=%s,mac=%s", tap, mac), | 102 | "--net", fmt.Sprintf("tap=%s,mac=%s", tap, mac), |
| 98 | "--serial", fmt.Sprintf("socket=%s", p.st.SerialSocketPath(vmID)), | 103 | "--serial", fmt.Sprintf("socket=%s", p.st.SerialSocketPath(vmID)), |
| 99 | "--console", "off", | 104 | "--console", "off", |
internal/agent/cloudhv/cloudhv_test.go
| Old | New | ||
|---|---|---|---|
| @@ -39,6 +39,21 @@ func TestBuildArgs(t *testing.T) { | |||
| 39 | assert.Contains(t, joined, "tap=eit-vm1,mac="+state.MAC("vm1")) | 39 | assert.Contains(t, joined, "tap=eit-vm1,mac="+state.MAC("vm1")) |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | // TestBuildArgsDeclaresRawImageType pins image_type=raw on BOTH disk entries. | ||
| 43 | // Left to autodetection, CH treats "raw" as a guess and DISABLES SECTOR 0 | ||
| 44 | // WRITES — so the first boot's growpart rewrites the partition table only in | ||
| 45 | // memory, and the VM lands in initramfs emergency mode at its first power | ||
| 46 | // cycle (on-disk GPT still image-sized, filesystem inside already grown). | ||
| 47 | func TestBuildArgsDeclaresRawImageType(t *testing.T) { | ||
| 48 | st, err := state.Open(t.TempDir()) | ||
| 49 | require.NoError(t, err) | ||
| 50 | p := New(st, "ch", "fw", nil) | ||
| 51 | args := p.buildArgs(state.VMSpec{VMID: "vm1", VCPUs: 1, MemMB: 512}) | ||
| 52 | joined := strings.Join(args, " ") | ||
| 53 | assert.Contains(t, joined, st.DiskPath("vm1")+",image_type=raw") | ||
| 54 | assert.Contains(t, joined, st.SeedPath("vm1")+",image_type=raw,readonly=on") | ||
| 55 | } | ||
| 56 | |||
| 42 | func TestBuildArgsUsesSerialSocket(t *testing.T) { | 57 | func TestBuildArgsUsesSerialSocket(t *testing.T) { |
| 43 | st, err := state.Open(t.TempDir()) | 58 | st, err := state.Open(t.TempDir()) |
| 44 | require.NoError(t, err) | 59 | require.NoError(t, err) |
internal/server/api/client/client.go
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,7 @@ type ( | |||
| 31 | VM = types.VM | 31 | VM = types.VM |
| 32 | CreateVMRequest = types.CreateVMRequest | 32 | CreateVMRequest = types.CreateVMRequest |
| 33 | CreateVMResponse = types.CreateVMResponse | 33 | CreateVMResponse = types.CreateVMResponse |
| 34 | PatchVMRequest = types.PatchVMRequest | ||
| 34 | Me = types.Me | 35 | Me = types.Me |
| 35 | CreateAPITokenResponse = types.CreateAPITokenResponse | 36 | CreateAPITokenResponse = types.CreateAPITokenResponse |
| 36 | UserCA = types.UserCA | 37 | UserCA = types.UserCA |
| @@ -138,6 +139,12 @@ func (c *Client) DeleteVM(ctx context.Context, id string) error { | |||
| 138 | return c.do(ctx, http.MethodDelete, "/api/v1/vms/"+url.PathEscape(id), nil, nil) | 139 | return c.do(ctx, http.MethodDelete, "/api/v1/vms/"+url.PathEscape(id), nil, nil) |
| 139 | } | 140 | } |
| 140 | 141 | ||
| 142 | // PatchVM sets the VM's desired power state ("running" or "stopped"); the | ||
| 143 | // agent actuates it asynchronously — poll ActualPower for the outcome. | ||
| 144 | func (c *Client) PatchVM(ctx context.Context, id, powerState string) error { | ||
| 145 | return c.do(ctx, http.MethodPatch, "/api/v1/vms/"+url.PathEscape(id), PatchVMRequest{PowerState: powerState}, nil) | ||
| 146 | } | ||
| 147 | |||
| 141 | // Me returns the signed-in identity (tenant handle + bound email) for the | 148 | // Me returns the signed-in identity (tenant handle + bound email) for the |
| 142 | // credential this client carries. It takes no context — the consumers (smoke | 149 | // credential this client carries. It takes no context — the consumers (smoke |
| 143 | // gate, CLI) call it as a quick synchronous probe; the do timeout bounds it. | 150 | // gate, CLI) call it as a quick synchronous probe; the do timeout bounds it. |
internal/server/api/client/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -145,6 +145,22 @@ func TestDeleteVMEscapesID(t *testing.T) { | |||
| 145 | } | 145 | } |
| 146 | } | 146 | } |
| 147 | 147 | ||
| 148 | func TestPatchVM(t *testing.T) { | ||
| 149 | var cap capture | ||
| 150 | srv := serve(t, &cap, http.StatusOK, "{}") | ||
| 151 | c := &client.Client{BaseURL: srv.URL, Token: "tok"} | ||
| 152 | |||
| 153 | if err := c.PatchVM(context.Background(), "vm-123", "stopped"); err != nil { | ||
| 154 | t.Fatalf("PatchVM: %v", err) | ||
| 155 | } | ||
| 156 | if cap.method != http.MethodPatch || cap.path != "/api/v1/vms/vm-123" { | ||
| 157 | t.Errorf("request = %s %s, want PATCH /api/v1/vms/vm-123", cap.method, cap.path) | ||
| 158 | } | ||
| 159 | if !strings.Contains(string(cap.body), `"power_state":"stopped"`) { | ||
| 160 | t.Errorf("body = %s, want power_state stopped", cap.body) | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 148 | func TestNoAuthHeaderWhenTokenEmpty(t *testing.T) { | 164 | func TestNoAuthHeaderWhenTokenEmpty(t *testing.T) { |
| 149 | var cap capture | 165 | var cap capture |
| 150 | srv := serve(t, &cap, http.StatusOK, `[]`) | 166 | srv := serve(t, &cap, http.StatusOK, `[]`) |
internal/smoke/scenario.go
| Old | New | ||
|---|---|---|---|
| @@ -32,6 +32,38 @@ func bootProofCommand(agentStateDir, vmID string) string { | |||
| 32 | return fmt.Sprintf(`sudo cat '%s/vms/%s/serial.log' 2>/dev/null | tr -cd '\11\12\15\40-\176'`, agentStateDir, vmID) | 32 | return fmt.Sprintf(`sudo cat '%s/vms/%s/serial.log' 2>/dev/null | tr -cd '\11\12\15\40-\176'`, agentStateDir, vmID) |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | // truncateSerialCommand empties a VM's serial log on the agent host. The log | ||
| 36 | // deliberately survives VM restarts, so a post-restart boot proof would pass | ||
| 37 | // on the FIRST boot's bytes unless it starts from an empty file. | ||
| 38 | func truncateSerialCommand(agentStateDir, vmID string) string { | ||
| 39 | return fmt.Sprintf(`sudo truncate -s 0 '%s/vms/%s/serial.log'`, agentStateDir, vmID) | ||
| 40 | } | ||
| 41 | |||
| 42 | // proveBoot polls the VM's serial log over SSH until it shows a userspace | ||
| 43 | // boot (login prompt), a panic/root-mount failure (immediate FAIL), or the | ||
| 44 | // deadline passes. phase names which boot is being proven in the failure | ||
| 45 | // messages ("first boot", "after power cycle"). | ||
| 46 | func proveBoot(ctx context.Context, cfg Config, vmID string, runSSH sshFunc, now func() time.Time, sleep func(time.Duration), phase string) error { | ||
| 47 | remoteCmd := bootProofCommand(cfg.AgentStateDir, vmID) | ||
| 48 | err := pollLoop(ctx, now, sleep, 180*time.Second, 6*time.Second, func() (bool, error) { | ||
| 49 | serial, sshErr := runSSH(ctx, remoteCmd) | ||
| 50 | if sshErr != nil { | ||
| 51 | // Match the bash scenario's "|| true": an SSH hiccup mid-boot is | ||
| 52 | // not fatal on its own, just an empty-serial iteration. | ||
| 53 | serial = "" | ||
| 54 | } | ||
| 55 | booted, panicked := classifySerial(serial) | ||
| 56 | if panicked { | ||
| 57 | return false, fmt.Errorf("FAIL: guest panic / root-mount failure in serial log (%s)", phase) | ||
| 58 | } | ||
| 59 | return booted, nil | ||
| 60 | }) | ||
| 61 | if errors.Is(err, errPollTimeout) { | ||
| 62 | return fmt.Errorf("FAIL: no userspace boot evidence in serial within 180s (%s)", phase) | ||
| 63 | } | ||
| 64 | return err | ||
| 65 | } | ||
| 66 | |||
| 35 | // vmAPI is the subset of the shared API client the scenario needs. Declaring | 67 | // vmAPI is the subset of the shared API client the scenario needs. Declaring |
| 36 | // it lets tests supply a fake instead of a real HTTP-backed *client.Client. | 68 | // it lets tests supply a fake instead of a real HTTP-backed *client.Client. |
| 37 | type vmAPI interface { | 69 | type vmAPI interface { |
| @@ -39,6 +71,7 @@ type vmAPI interface { | |||
| 39 | CreateVM(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) | 71 | CreateVM(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) |
| 40 | ListVMs(ctx context.Context) ([]client.VM, error) | 72 | ListVMs(ctx context.Context) ([]client.VM, error) |
| 41 | DeleteVM(ctx context.Context, id string) error | 73 | DeleteVM(ctx context.Context, id string) error |
| 74 | PatchVM(ctx context.Context, id, powerState string) error | ||
| 42 | } | 75 | } |
| 43 | 76 | ||
| 44 | // getVM finds the VM with the given id in the current listing. The bool | 77 | // getVM finds the VM with the given id in the current listing. The bool |
| @@ -142,33 +175,65 @@ func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH | |||
| 142 | } | 175 | } |
| 143 | coldStart := now().Sub(start) | 176 | coldStart := now().Sub(start) |
| 144 | 177 | ||
| 145 | remoteCmd := bootProofCommand(cfg.AgentStateDir, vmID) | 178 | if err := proveBoot(ctx, cfg, vmID, runSSH, now, sleep, "first boot"); err != nil { |
| 146 | err = pollLoop(ctx, now, sleep, 180*time.Second, 6*time.Second, func() (bool, error) { | 179 | return "", err |
| 147 | serial, sshErr := runSSH(ctx, remoteCmd) | 180 | } |
| 148 | if sshErr != nil { | 181 | |
| 149 | // Match the bash scenario's "|| true": an SSH hiccup mid-boot is | 182 | gateOK := false |
| 150 | // not fatal on its own, just an empty-serial iteration. | 183 | if gate != nil { |
| 151 | serial = "" | 184 | if err := gate.exec(ctx, vmName); err != nil { |
| 185 | return "", err | ||
| 152 | } | 186 | } |
| 153 | booted, panicked := classifySerial(serial) | 187 | gateOK = true |
| 154 | if panicked { | 188 | } |
| 155 | return false, errors.New("FAIL: guest panic / root-mount failure in serial log") | 189 | |
| 190 | // Power cycle: prove the guest comes BACK. A first boot runs on the | ||
| 191 | // kernel's in-memory partition table; only a stop→start proves the | ||
| 192 | // on-disk GPT survived growpart. The sector-0 regression hid exactly | ||
| 193 | // here — every first boot green, every persistent VM lost at its first | ||
| 194 | // reboot. | ||
| 195 | if err := c.PatchVM(ctx, vmID, "stopped"); err != nil { | ||
| 196 | return "", fmt.Errorf("patch vm stopped: %w", err) | ||
| 197 | } | ||
| 198 | err = pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) { | ||
| 199 | vm, present, err := getVM(ctx, c, vmID) | ||
| 200 | if err != nil { | ||
| 201 | return false, fmt.Errorf("poll vm stopped: %w", err) | ||
| 156 | } | 202 | } |
| 157 | return booted, nil | 203 | return present && vm.ActualPower == "stopped", nil |
| 158 | }) | 204 | }) |
| 159 | if err != nil { | 205 | if err != nil { |
| 160 | if errors.Is(err, errPollTimeout) { | 206 | if errors.Is(err, errPollTimeout) { |
| 161 | return "", errors.New("FAIL: no userspace boot evidence in serial within 180s") | 207 | return "", errors.New("FAIL: VM did not power off within 120s of the power cycle's stop") |
| 162 | } | 208 | } |
| 163 | return "", err | 209 | return "", err |
| 164 | } | 210 | } |
| 165 | 211 | if _, err := runSSH(ctx, truncateSerialCommand(cfg.AgentStateDir, vmID)); err != nil { | |
| 166 | gateOK := false | 212 | return "", fmt.Errorf("truncate serial log before reboot proof: %w", err) |
| 213 | } | ||
| 214 | if err := c.PatchVM(ctx, vmID, "running"); err != nil { | ||
| 215 | return "", fmt.Errorf("patch vm running: %w", err) | ||
| 216 | } | ||
| 217 | err = pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) { | ||
| 218 | vm, present, err := getVM(ctx, c, vmID) | ||
| 219 | if err != nil { | ||
| 220 | return false, fmt.Errorf("poll vm restarted: %w", err) | ||
| 221 | } | ||
| 222 | return present && vm.ActualPower == "running", nil | ||
| 223 | }) | ||
| 224 | if err != nil { | ||
| 225 | if errors.Is(err, errPollTimeout) { | ||
| 226 | return "", errors.New("FAIL: VM did not power on within 120s of the power cycle's start") | ||
| 227 | } | ||
| 228 | return "", err | ||
| 229 | } | ||
| 230 | if err := proveBoot(ctx, cfg, vmID, runSSH, now, sleep, "after power cycle"); err != nil { | ||
| 231 | return "", err | ||
| 232 | } | ||
| 167 | if gate != nil { | 233 | if gate != nil { |
| 168 | if err := gate.exec(ctx, vmName); err != nil { | 234 | if err := gate.exec(ctx, vmName); err != nil { |
| 169 | return "", err | 235 | return "", fmt.Errorf("gate SSH after power cycle: %w", err) |
| 170 | } | 236 | } |
| 171 | gateOK = true | ||
| 172 | } | 237 | } |
| 173 | 238 | ||
| 174 | if err := c.DeleteVM(ctx, vmID); err != nil { | 239 | if err := c.DeleteVM(ctx, vmID); err != nil { |
| @@ -188,7 +253,7 @@ func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH | |||
| 188 | return "", err | 253 | return "", err |
| 189 | } | 254 | } |
| 190 | 255 | ||
| 191 | msg := fmt.Sprintf("SMOKE COMPLETE — booted under UEFI, cold_start=%ds, reaped OK", int64(coldStart.Seconds())) | 256 | msg := fmt.Sprintf("SMOKE COMPLETE — booted under UEFI, cold_start=%ds, reboot: ok, reaped OK", int64(coldStart.Seconds())) |
| 192 | if gateOK { | 257 | if gateOK { |
| 193 | msg += ", gate SSH: ok" | 258 | msg += ", gate SSH: ok" |
| 194 | } | 259 | } |
internal/smoke/scenario_test.go
| Old | New | ||
|---|---|---|---|
| @@ -54,12 +54,14 @@ type fakeClock struct{ t time.Time } | |||
| 54 | func (c *fakeClock) now() time.Time { return c.t } | 54 | func (c *fakeClock) now() time.Time { return c.t } |
| 55 | func (c *fakeClock) sleep(d time.Duration) { c.t = c.t.Add(d) } | 55 | func (c *fakeClock) sleep(d time.Duration) { c.t = c.t.Add(d) } |
| 56 | 56 | ||
| 57 | // testAPI implements vmAPI by delegating to per-test closures. | 57 | // testAPI implements vmAPI by delegating to per-test closures. patchVMFunc |
| 58 | // defaults to accepting any power-state change when nil. | ||
| 58 | type testAPI struct { | 59 | type testAPI struct { |
| 59 | listHostsFunc func(ctx context.Context) ([]client.Host, error) | 60 | listHostsFunc func(ctx context.Context) ([]client.Host, error) |
| 60 | createVMFunc func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) | 61 | createVMFunc func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) |
| 61 | listVMsFunc func(ctx context.Context) ([]client.VM, error) | 62 | listVMsFunc func(ctx context.Context) ([]client.VM, error) |
| 62 | deleteVMFunc func(ctx context.Context, id string) error | 63 | deleteVMFunc func(ctx context.Context, id string) error |
| 64 | patchVMFunc func(ctx context.Context, id, powerState string) error | ||
| 63 | } | 65 | } |
| 64 | 66 | ||
| 65 | func (a *testAPI) ListHosts(ctx context.Context) ([]client.Host, error) { | 67 | func (a *testAPI) ListHosts(ctx context.Context) ([]client.Host, error) { |
| @@ -72,6 +74,12 @@ func (a *testAPI) ListVMs(ctx context.Context) ([]client.VM, error) { | |||
| 72 | return a.listVMsFunc(ctx) | 74 | return a.listVMsFunc(ctx) |
| 73 | } | 75 | } |
| 74 | func (a *testAPI) DeleteVM(ctx context.Context, id string) error { return a.deleteVMFunc(ctx, id) } | 76 | func (a *testAPI) DeleteVM(ctx context.Context, id string) error { return a.deleteVMFunc(ctx, id) } |
| 77 | func (a *testAPI) PatchVM(ctx context.Context, id, powerState string) error { | ||
| 78 | if a.patchVMFunc == nil { | ||
| 79 | return nil | ||
| 80 | } | ||
| 81 | return a.patchVMFunc(ctx, id, powerState) | ||
| 82 | } | ||
| 75 | 83 | ||
| 76 | func baseCfg() Config { | 84 | func baseCfg() Config { |
| 77 | return Config{AgentStateDir: "/var/lib/eitri-agent", AgentUserHost: "ubuntu@10.0.0.5", AgentPort: 22} | 85 | return Config{AgentStateDir: "/var/lib/eitri-agent", AgentUserHost: "ubuntu@10.0.0.5", AgentPort: 22} |
| @@ -83,6 +91,8 @@ func noopReadPubKey() string { return "ssh-ed25519 AAAAfake test@smoke" } | |||
| 83 | 91 | ||
| 84 | func TestRunScenarioSuccess(t *testing.T) { | 92 | func TestRunScenarioSuccess(t *testing.T) { |
| 85 | listVMsCalls := 0 | 93 | listVMsCalls := 0 |
| 94 | power := "running" // actual power the fake reports; PatchVM moves it | ||
| 95 | deleted := false | ||
| 86 | api := &testAPI{ | 96 | api := &testAPI{ |
| 87 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 97 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 88 | return []client.Host{{ID: "host-1"}}, nil | 98 | return []client.Host{{ID: "host-1"}}, nil |
| @@ -96,30 +106,43 @@ func TestRunScenarioSuccess(t *testing.T) { | |||
| 96 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | 106 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { |
| 97 | listVMsCalls++ | 107 | listVMsCalls++ |
| 98 | switch { | 108 | switch { |
| 109 | case deleted: | ||
| 110 | // Reap poll: absent from the first check. | ||
| 111 | return nil, nil | ||
| 99 | case listVMsCalls < 3: | 112 | case listVMsCalls < 3: |
| 100 | return []client.VM{{ID: "vm-1", Phase: "booting"}}, nil | 113 | return []client.VM{{ID: "vm-1", Phase: "booting"}}, nil |
| 101 | case listVMsCalls == 3: | ||
| 102 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9"}}, nil | ||
| 103 | default: | 114 | default: |
| 104 | // Reap poll: absent from the first check. | 115 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9", ActualPower: power}}, nil |
| 105 | return nil, nil | ||
| 106 | } | 116 | } |
| 107 | }, | 117 | }, |
| 118 | patchVMFunc: func(ctx context.Context, id, powerState string) error { | ||
| 119 | if id != "vm-1" { | ||
| 120 | t.Errorf("patchVM id = %q, want vm-1", id) | ||
| 121 | } | ||
| 122 | power = powerState // the fake actuates instantly | ||
| 123 | return nil | ||
| 124 | }, | ||
| 108 | deleteVMFunc: func(ctx context.Context, id string) error { | 125 | deleteVMFunc: func(ctx context.Context, id string) error { |
| 109 | if id != "vm-1" { | 126 | if id != "vm-1" { |
| 110 | t.Errorf("deleteVM id = %q, want vm-1", id) | 127 | t.Errorf("deleteVM id = %q, want vm-1", id) |
| 111 | } | 128 | } |
| 129 | deleted = true | ||
| 112 | return nil | 130 | return nil |
| 113 | }, | 131 | }, |
| 114 | } | 132 | } |
| 115 | 133 | ||
| 116 | sshCalls := 0 | 134 | sshCalls := 0 |
| 135 | sawTruncate := false | ||
| 117 | runSSH := func(ctx context.Context, remoteCmd string) (string, error) { | 136 | runSSH := func(ctx context.Context, remoteCmd string) (string, error) { |
| 118 | sshCalls++ | 137 | sshCalls++ |
| 119 | if !strings.Contains(remoteCmd, "vm-1") { | 138 | if !strings.Contains(remoteCmd, "vm-1") { |
| 120 | t.Errorf("remoteCmd = %q, want it to reference vm-1", remoteCmd) | 139 | t.Errorf("remoteCmd = %q, want it to reference vm-1", remoteCmd) |
| 121 | } | 140 | } |
| 122 | if sshCalls < 2 { | 141 | if strings.Contains(remoteCmd, "truncate") { |
| 142 | sawTruncate = true | ||
| 143 | return "", nil | ||
| 144 | } | ||
| 145 | if !sawTruncate && sshCalls < 2 { | ||
| 123 | return "[ 0.1] Booting Linux...", nil | 146 | return "[ 0.1] Booting Linux...", nil |
| 124 | } | 147 | } |
| 125 | return "Ubuntu 24.04 LTS ubuntu-vm login: ", nil | 148 | return "Ubuntu 24.04 LTS ubuntu-vm login: ", nil |
| @@ -136,6 +159,61 @@ func TestRunScenarioSuccess(t *testing.T) { | |||
| 136 | if !strings.Contains(msg, "cold_start=") { | 159 | if !strings.Contains(msg, "cold_start=") { |
| 137 | t.Errorf("message = %q, want cold_start=", msg) | 160 | t.Errorf("message = %q, want cold_start=", msg) |
| 138 | } | 161 | } |
| 162 | if !strings.Contains(msg, "reboot: ok") { | ||
| 163 | t.Errorf("message = %q, want reboot: ok", msg) | ||
| 164 | } | ||
| 165 | if !sawTruncate { | ||
| 166 | t.Error("serial log was never truncated before the reboot proof — proof could pass on first-boot bytes") | ||
| 167 | } | ||
| 168 | } | ||
| 169 | |||
| 170 | // TestRunScenarioRebootDeathFails pins the regression that motivated the | ||
| 171 | // power-cycle leg: a VM whose second boot never reaches userspace (stale | ||
| 172 | // on-disk GPT → initramfs emergency mode) must FAIL the smoke, not pass on | ||
| 173 | // its green first boot. | ||
| 174 | func TestRunScenarioRebootDeathFails(t *testing.T) { | ||
| 175 | power := "running" | ||
| 176 | api := &testAPI{ | ||
| 177 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | ||
| 178 | return []client.Host{{ID: "host-1"}}, nil | ||
| 179 | }, | ||
| 180 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 181 | return client.CreateVMResponse{ID: "vm-1"}, nil | ||
| 182 | }, | ||
| 183 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | ||
| 184 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9", ActualPower: power}}, nil | ||
| 185 | }, | ||
| 186 | patchVMFunc: func(ctx context.Context, id, powerState string) error { | ||
| 187 | power = powerState | ||
| 188 | return nil | ||
| 189 | }, | ||
| 190 | deleteVMFunc: func(ctx context.Context, id string) error { | ||
| 191 | t.Fatal("DeleteVM must not be called when the reboot proof failed") | ||
| 192 | return nil | ||
| 193 | }, | ||
| 194 | } | ||
| 195 | |||
| 196 | sawTruncate := false | ||
| 197 | runSSH := func(ctx context.Context, remoteCmd string) (string, error) { | ||
| 198 | if strings.Contains(remoteCmd, "truncate") { | ||
| 199 | sawTruncate = true | ||
| 200 | return "", nil | ||
| 201 | } | ||
| 202 | if sawTruncate { | ||
| 203 | // Post-reboot serial: emergency shell, never a login prompt. | ||
| 204 | return "Press Enter for system maintenance", nil | ||
| 205 | } | ||
| 206 | return "Ubuntu 24.04 LTS ubuntu-vm login: ", nil | ||
| 207 | } | ||
| 208 | |||
| 209 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 210 | _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey) | ||
| 211 | if err == nil { | ||
| 212 | t.Fatal("runScenario: want error for a VM that never came back, got nil") | ||
| 213 | } | ||
| 214 | if !strings.Contains(err.Error(), "power cycle") { | ||
| 215 | t.Errorf("error = %q, want it to mention the power cycle", err.Error()) | ||
| 216 | } | ||
| 139 | } | 217 | } |
| 140 | 218 | ||
| 141 | // --- runScenario: serial panic ----------------------------------------- | 219 | // --- runScenario: serial panic ----------------------------------------- |
| @@ -209,6 +287,7 @@ func TestRunScenarioNeverReadyTimesOut(t *testing.T) { | |||
| 209 | // --- runScenario: never-reaped timeout --------------------------------- | 287 | // --- runScenario: never-reaped timeout --------------------------------- |
| 210 | 288 | ||
| 211 | func TestRunScenarioNeverReapedTimesOut(t *testing.T) { | 289 | func TestRunScenarioNeverReapedTimesOut(t *testing.T) { |
| 290 | power := "running" | ||
| 212 | api := &testAPI{ | 291 | api := &testAPI{ |
| 213 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 292 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 214 | return []client.Host{{ID: "host-1"}}, nil | 293 | return []client.Host{{ID: "host-1"}}, nil |
| @@ -218,7 +297,11 @@ func TestRunScenarioNeverReapedTimesOut(t *testing.T) { | |||
| 218 | }, | 297 | }, |
| 219 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | 298 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { |
| 220 | // Always present, even after delete — models a stuck reap. | 299 | // Always present, even after delete — models a stuck reap. |
| 221 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9"}}, nil | 300 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9", ActualPower: power}}, nil |
| 301 | }, | ||
| 302 | patchVMFunc: func(ctx context.Context, id, powerState string) error { | ||
| 303 | power = powerState | ||
| 304 | return nil | ||
| 222 | }, | 305 | }, |
| 223 | deleteVMFunc: func(ctx context.Context, id string) error { return nil }, | 306 | deleteVMFunc: func(ctx context.Context, id string) error { return nil }, |
| 224 | } | 307 | } |
| @@ -268,6 +351,8 @@ func TestRunScenarioNoHosts(t *testing.T) { | |||
| 268 | func happyPathAPI(t *testing.T, calls *[]string) *testAPI { | 351 | func happyPathAPI(t *testing.T, calls *[]string) *testAPI { |
| 269 | t.Helper() | 352 | t.Helper() |
| 270 | listVMsCalls := 0 | 353 | listVMsCalls := 0 |
| 354 | power := "running" | ||
| 355 | deleted := false | ||
| 271 | return &testAPI{ | 356 | return &testAPI{ |
| 272 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 357 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 273 | return []client.Host{{ID: "host-1"}}, nil | 358 | return []client.Host{{ID: "host-1"}}, nil |
| @@ -279,15 +364,22 @@ func happyPathAPI(t *testing.T, calls *[]string) *testAPI { | |||
| 279 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { | 364 | listVMsFunc: func(ctx context.Context) ([]client.VM, error) { |
| 280 | listVMsCalls++ | 365 | listVMsCalls++ |
| 281 | switch { | 366 | switch { |
| 367 | case deleted: | ||
| 368 | return nil, nil | ||
| 282 | case listVMsCalls < 3: | 369 | case listVMsCalls < 3: |
| 283 | return []client.VM{{ID: "vm-1", Phase: "booting"}}, nil | 370 | return []client.VM{{ID: "vm-1", Phase: "booting"}}, nil |
| 284 | case listVMsCalls == 3: | ||
| 285 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9"}}, nil | ||
| 286 | default: | 371 | default: |
| 287 | return nil, nil | 372 | return []client.VM{{ID: "vm-1", Phase: "ready", AssignedIP: "10.0.0.9", ActualPower: power}}, nil |
| 288 | } | 373 | } |
| 289 | }, | 374 | }, |
| 290 | deleteVMFunc: func(ctx context.Context, id string) error { return nil }, | 375 | patchVMFunc: func(ctx context.Context, id, powerState string) error { |
| 376 | power = powerState | ||
| 377 | return nil | ||
| 378 | }, | ||
| 379 | deleteVMFunc: func(ctx context.Context, id string) error { | ||
| 380 | deleted = true | ||
| 381 | return nil | ||
| 382 | }, | ||
| 291 | } | 383 | } |
| 292 | } | 384 | } |
| 293 | 385 | ||
| @@ -328,7 +420,9 @@ func TestRunScenarioGateRegistersBeforeCreateAndExecsAfterBoot(t *testing.T) { | |||
| 328 | t.Errorf("message = %q, want it to mention gate SSH: ok", msg) | 420 | t.Errorf("message = %q, want it to mention gate SSH: ok", msg) |
| 329 | } | 421 | } |
| 330 | 422 | ||
| 331 | want := []string{"register", "createVM", "exec"} | 423 | // exec appears twice: once after the first boot proof, once after the |
| 424 | // power-cycle proof — SSH through the gate must survive a reboot too. | ||
| 425 | want := []string{"register", "createVM", "exec", "exec"} | ||
| 332 | if len(calls) != len(want) { | 426 | if len(calls) != len(want) { |
| 333 | t.Fatalf("call order = %v, want %v", calls, want) | 427 | t.Fatalf("call order = %v, want %v", calls, want) |
| 334 | } | 428 | } |