diff --git a/hypervisor/firecracker/create.go b/hypervisor/firecracker/create.go index b4b55720..a74d0026 100644 --- a/hypervisor/firecracker/create.go +++ b/hypervisor/firecracker/create.go @@ -57,6 +57,15 @@ func (fc *Firecracker) prepareOCI(ctx context.Context, vmID string, vmCfg *types return storageConfigs, nil } +// DevPath maps idx to vda..vdz, vdaa..vdaz, vdba..vdbz, ... +func DevPath(idx int) string { + const letters = 26 + if idx < letters { + return fmt.Sprintf("/dev/vd%c", 'a'+idx) + } + return fmt.Sprintf("/dev/vd%c%c", 'a'+(idx/letters)-1, 'a'+idx%letters) +} + // EnsureVmlinux decompresses kernelPath if needed and returns the ELF path. func EnsureVmlinux(kernelPath string) (string, error) { elfMagic := []byte{0x7f, 'E', 'L', 'F'} @@ -163,12 +172,3 @@ func buildCmdline(storageConfigs []*types.StorageConfig, networkConfigs []*types networkConfigs, vmName, dnsServers, ) } - -// DevPath maps idx to vda..vdz, vdaa..vdaz, vdba..vdbz, ... -func DevPath(idx int) string { - const letters = 26 - if idx < letters { - return fmt.Sprintf("/dev/vd%c", 'a'+idx) - } - return fmt.Sprintf("/dev/vd%c%c", 'a'+(idx/letters)-1, 'a'+idx%letters) -} diff --git a/hypervisor/inspect.go b/hypervisor/inspect.go index 5c914034..b2863630 100644 --- a/hypervisor/inspect.go +++ b/hypervisor/inspect.go @@ -63,7 +63,7 @@ func (b *Backend) ResolveRefs(ctx context.Context, refs []string) ([]string, err }) } -// LoadRecord returns a value-copy of the VMRecord. +// LoadRecord returns a shallow value-copy; pointer/slice/map fields still alias the live record. Treat as read-only outside DB transactions. func (b *Backend) LoadRecord(ctx context.Context, id string) (VMRecord, error) { var rec VMRecord return rec, b.DB.With(ctx, func(idx *VMIndex) error { diff --git a/hypervisor/snapshot.go b/hypervisor/snapshot.go index ef529b1a..3f27fe03 100644 --- a/hypervisor/snapshot.go +++ b/hypervisor/snapshot.go @@ -73,6 +73,9 @@ func PreflightRestore(srcDir, rootDir, runDir string, rec *VMRecord, integrity f func CloneStorageConfigs(storageConfigs []*types.StorageConfig) []*types.StorageConfig { out := make([]*types.StorageConfig, 0, len(storageConfigs)) for _, sc := range storageConfigs { + if sc == nil { + continue + } cp := *sc out = append(out, &cp) } diff --git a/hypervisor/state.go b/hypervisor/state.go index 733135fc..c19d71b3 100644 --- a/hypervisor/state.go +++ b/hypervisor/state.go @@ -16,7 +16,7 @@ import ( "github.com/cocoonstack/cocoon/utils" ) -const socketProbeTimeout = 2 * time.Second +const socketProbeTimeout = 500 * time.Millisecond // WithRunningVM calls fn if rec still points to a live VM process. func (b *Backend) WithRunningVM(ctx context.Context, rec *VMRecord, fn func(pid int) error) error { @@ -88,11 +88,14 @@ func (b *Backend) WithPausedVM(ctx context.Context, rec *VMRecord, pause, resume }) } -// UpdateStates batch-updates State; transitions to Stopped close the compute interval when one is open (covers Error→Stopped from rm --force or recovery stop). Error transitions leave StoppedAt nil because many MarkError paths can't prove the process is dead. +// UpdateStates flips ids to Stopped or Error and emits compute.stop on Running→Stopped (Error paths can't prove the process is dead so the interval stays open until a confirmed-dead helper closes it). To open a fresh interval, use BatchMarkStarted — UpdateStates intentionally rejects Running to avoid silent ledger drift. func (b *Backend) UpdateStates(ctx context.Context, ids []string, state types.VMState) error { if len(ids) == 0 { return nil } + if state == types.VMStateRunning { + return fmt.Errorf("UpdateStates(Running) not allowed; use BatchMarkStarted") + } now := time.Now() var stopped []metering.Entry if err := b.DB.Update(ctx, func(idx *VMIndex) error { @@ -103,15 +106,9 @@ func (b *Backend) UpdateStates(ctx context.Context, ids []string, state types.VM } r.State = state r.UpdatedAt = now - switch state { - case types.VMStateRunning: - r.StartedAt = &now - r.StoppedAt = nil - case types.VMStateStopped: - if hasOpenComputeInterval(r) { - r.StoppedAt = &now - stopped = append(stopped, b.makeEntry(metering.KindVMComputeStop, id, metering.ReasonStopUser, shapeFromConfig(r.Config), now)) - } + if state == types.VMStateStopped && hasOpenComputeInterval(r) { + r.StoppedAt = &now + stopped = append(stopped, b.makeEntry(metering.KindVMComputeStop, id, metering.ReasonStopUser, shapeFromConfig(r.Config), now)) } } return nil @@ -129,7 +126,7 @@ func (b *Backend) MarkError(ctx context.Context, id string) { } } -// BatchMarkStarted flips ids to VMStateRunning; State==Running entrants are stale-running (close stop-crash, then open fresh). +// BatchMarkStarted flips ids to VMStateRunning; entrants with an open compute interval are stale-running (close stop-crash, then open fresh). func (b *Backend) BatchMarkStarted(ctx context.Context, ids []string) error { if len(ids) == 0 { return nil diff --git a/hypervisor/state_test.go b/hypervisor/state_test.go index 18e0b3d9..d358bc0d 100644 --- a/hypervisor/state_test.go +++ b/hypervisor/state_test.go @@ -126,12 +126,10 @@ func TestUpdateStatesEmitsOnlyOnRunningToStopped(t *testing.T) { t.Errorf("Created→Stopped emitted %d; want 0", len(got)) } - if err := b.UpdateStates(ctx, []string{"vm1"}, types.VMStateRunning); err != nil { - t.Fatalf("UpdateStates(running): %v", err) - } - if got := rec.Entries(); len(got) != 0 { - t.Errorf("Stopped→Running emitted %d; want 0", len(got)) + if err := b.BatchMarkStarted(ctx, []string{"vm1"}); err != nil { + t.Fatalf("BatchMarkStarted: %v", err) } + rec.Reset() if err := b.UpdateStates(ctx, []string{"vm1"}, types.VMStateStopped); err != nil { t.Fatalf("UpdateStates(stopped): %v", err) @@ -141,8 +139,8 @@ func TestUpdateStatesEmitsOnlyOnRunningToStopped(t *testing.T) { t.Fatalf("Running→Stopped: got %+v, want one compute.stop reason=user", entries) } - if err := b.UpdateStates(ctx, []string{"vm1"}, types.VMStateRunning); err != nil { - t.Fatalf("UpdateStates(running again): %v", err) + if err := b.BatchMarkStarted(ctx, []string{"vm1"}); err != nil { + t.Fatalf("BatchMarkStarted (relaunch): %v", err) } rec.Reset() if err := b.UpdateStates(ctx, []string{"vm1"}, types.VMStateError); err != nil { @@ -153,6 +151,15 @@ func TestUpdateStatesEmitsOnlyOnRunningToStopped(t *testing.T) { } } +func TestUpdateStatesRunningIsRejected(t *testing.T) { + b, _ := newMeteringTestBackend(t) + ctx := t.Context() + seedVMRecord(t, b, "vm1", 1, 1<<30, 10<<30, false) + if err := b.UpdateStates(ctx, []string{"vm1"}, types.VMStateRunning); err == nil { + t.Fatal("UpdateStates(Running) must return an error to steer callers to BatchMarkStarted") + } +} + func TestPrepareStartClosesIntervalAfterMarkError(t *testing.T) { // Running→Error must leave the interval open (UpdateStates(Error) doesn't write StoppedAt). The next PrepareStart confirms the process is dead and closes the interval. b, rec := newMeteringTestBackend(t) @@ -488,8 +495,11 @@ func TestDeleteAfterErrorEmitsOnlyStorageStop(t *testing.T) { b, rec := newMeteringTestBackend(t) ctx := t.Context() seedVMRecord(t, b, "vm1", 2, 2<<30, 20<<30, true) - if err := b.UpdateStates(ctx, []string{"vm1"}, types.VMStateRunning); err != nil { - t.Fatalf("UpdateStates(running): %v", err) + if err := b.BatchMarkStarted(ctx, []string{"vm1"}); err != nil { + t.Fatalf("BatchMarkStarted: %v", err) + } + if err := b.UpdateStates(ctx, []string{"vm1"}, types.VMStateStopped); err != nil { + t.Fatalf("UpdateStates(stopped): %v", err) } if err := b.UpdateStates(ctx, []string{"vm1"}, types.VMStateError); err != nil { t.Fatalf("UpdateStates(error): %v", err) diff --git a/images/cloudimg/cloudimg.go b/images/cloudimg/cloudimg.go index d82962b0..ac913926 100644 --- a/images/cloudimg/cloudimg.go +++ b/images/cloudimg/cloudimg.go @@ -31,6 +31,9 @@ type CloudImg struct { } func New(ctx context.Context, conf *config.Config) (*CloudImg, error) { + if conf == nil { + return nil, fmt.Errorf("config is nil") + } cfg := NewConfig(conf) if err := cfg.EnsureDirs(); err != nil { return nil, fmt.Errorf("ensure dirs: %w", err) diff --git a/metadata/fat12.go b/metadata/fat12.go index 96231396..34401a80 100644 --- a/metadata/fat12.go +++ b/metadata/fat12.go @@ -27,6 +27,19 @@ const ( mediaDesc = 0xF8 ) +// CreateFAT12 streams a 1 MiB FAT12 image with VFAT long-filename support to w. +// label is the volume label (e.g. "CIDATA"); files maps filename → content. +func CreateFAT12(w io.Writer, label string, files map[string][]byte) error { + b := newFAT12Builder(label) + + for _, name := range slices.Sorted(maps.Keys(files)) { + if err := b.addFile(name, files[name]); err != nil { + return err + } + } + return b.writeTo(w) +} + type dataEntry struct { data []byte numClusters int @@ -189,19 +202,6 @@ func (b *fat12Builder) makeBootSector() []byte { return boot } -// CreateFAT12 streams a 1 MiB FAT12 image with VFAT long-filename support to w. -// label is the volume label (e.g. "CIDATA"); files maps filename → content. -func CreateFAT12(w io.Writer, label string, files map[string][]byte) error { - b := newFAT12Builder(label) - - for _, name := range slices.Sorted(maps.Keys(files)) { - if err := b.addFile(name, files[name]); err != nil { - return err - } - } - return b.writeTo(w) -} - // setFATEntry writes a 12-bit value into the FAT at the given cluster index. func setFATEntry(fat []byte, cluster int, val uint16) { off := cluster + cluster/2 //nolint:mnd