package daemon import ( "context" "errors" "io" "net" "os" "path/filepath" "strings" "time" "banger/internal/api" "banger/internal/guest" "banger/internal/model" "banger/internal/system" ) func (s *VMService) StartVM(ctx context.Context, idOrName string) (model.VMRecord, error) { return s.withVMLockByRef(ctx, idOrName, func(vm model.VMRecord) (model.VMRecord, error) { image, err := s.store.GetImageByID(ctx, vm.ImageID) if err != nil { return model.VMRecord{}, err } if s.vmAlive(vm) { if s.logger != nil { s.logger.Info("vm already running", vmLogAttrs(vm)...) } return vm, nil } return s.startVMLocked(ctx, vm, image) }) } func (s *VMService) startVMLocked(ctx context.Context, vm model.VMRecord, image model.Image) (_ model.VMRecord, err error) { op := s.beginOperation(ctx, "vm.start", append(vmLogAttrs(vm), imageLogAttrs(image)...)...) defer func() { if err != nil { err = annotateLogPath(err, vm.Runtime.LogPath) op.fail(err, vmLogAttrs(vm)...) return } op.done(vmLogAttrs(vm)...) }() // Derive per-VM paths/names up front so every step sees the same // values. Shortening vm.ID mirrors how the pre-refactor inline // code did it. shortID := system.ShortID(vm.ID) apiSock := filepath.Join(s.layout.RuntimeDir, "fc-"+shortID+".sock") dmName := "fc-rootfs-" + shortID tapName := "tap-fc-" + shortID if strings.TrimSpace(vm.Runtime.VSockPath) == "" { vm.Runtime.VSockPath = defaultVSockPath(s.layout.RuntimeDir, vm.ID) } if vm.Runtime.VSockCID == 0 { vm.Runtime.VSockCID, err = defaultVSockCID(vm.Runtime.GuestIP) if err != nil { return model.VMRecord{}, err } } live := model.VMHandles{} sc := &startContext{ vm: &vm, image: image, live: &live, apiSock: apiSock, dmName: dmName, tapName: tapName, } if runErr := s.runStartSteps(ctx, op, sc, s.buildStartSteps(op, sc)); runErr != nil { // The step driver already ran rollback in reverse for every // succeeded step. All that's left is to persist the ERROR // state so operators see the failure via `vm show`. Use a // fresh context in case the request ctx is cancelled — DB // writes past this point are recovery, not user-driven. // // The store check is for tests that construct a bare Daemon // without a DB; production always has s.store non-nil. vm.State = model.VMStateError vm.Runtime.State = model.VMStateError vm.Runtime.LastError = runErr.Error() clearRuntimeTeardownState(&vm) s.clearVMHandles(vm) if s.store != nil { // We're in the recovery path: the start has already // failed, and the user will see runErr. A persist // failure here only affects what 'banger vm show' // reads on the next call, so we keep returning runErr // — but a silent swallow leaves operators unable to // debug "why does the record still say running?". Log // at warn instead. if persistErr := s.store.UpsertVM(context.Background(), vm); persistErr != nil && s.logger != nil { s.logger.Warn("persist vm error state failed", append(vmLogAttrs(vm), "error", persistErr.Error())...) } } return model.VMRecord{}, runErr } return vm, nil } func (s *VMService) StopVM(ctx context.Context, idOrName string) (model.VMRecord, error) { return s.withVMLockByRef(ctx, idOrName, func(vm model.VMRecord) (model.VMRecord, error) { return s.stopVMLocked(ctx, vm) }) } func (s *VMService) stopVMLocked(ctx context.Context, current model.VMRecord) (vm model.VMRecord, err error) { vm = current op := s.beginOperation(ctx, "vm.stop", "vm_ref", vm.ID) defer func() { if err != nil { op.fail(err, vmLogAttrs(vm)...) return } op.done(vmLogAttrs(vm)...) }() if !s.vmAlive(vm) { op.stage("cleanup_stale_runtime") if err := s.cleanupRuntime(ctx, vm, true); err != nil { return model.VMRecord{}, err } vm.State = model.VMStateStopped vm.Runtime.State = model.VMStateStopped clearRuntimeTeardownState(&vm) s.clearVMHandles(vm) if err := s.store.UpsertVM(ctx, vm); err != nil { return model.VMRecord{}, err } return vm, nil } op.stage("graceful_shutdown") // Reach into the guest over SSH to force a sync + queue a poweroff. // The sync is what keeps stop() from losing data: every dirty page // the guest hasn't flushed through virtio-blk to the work disk is // written out before this RPC returns. Once sync completes, // root.ext4 on the host is consistent and cleanupRuntime's SIGKILL // is safe — there is no benefit to waiting for the guest's // poweroff.target to finish, so we skip waitForExit entirely. // // When SSH is unreachable (broken sshd, network down, drifted host // key) we drop straight to SIGKILL via cleanupRuntime. The // previous fallback was SendCtrlAltDel + a 10-second wait for FC // to exit, but on Debian ctrl+alt+del routes to reboot.target, so // FC never exits on it — the wait was always a wasted 10s. We pay // the data-loss cost we already paid before (after the timeout // expired the old code SIGKILLed too), but without the latency. if err := s.requestGuestPoweroff(ctx, vm); err != nil { if s.logger != nil { s.logger.Warn("guest ssh poweroff failed; SIGKILL without sync", append(vmLogAttrs(vm), "error", err.Error())...) } } op.stage("cleanup_runtime") if err := s.cleanupRuntime(ctx, vm, true); err != nil { return model.VMRecord{}, err } vm.State = model.VMStateStopped vm.Runtime.State = model.VMStateStopped clearRuntimeTeardownState(&vm) s.clearVMHandles(vm) system.TouchNow(&vm) if err := s.store.UpsertVM(ctx, vm); err != nil { return model.VMRecord{}, err } return vm, nil } // requestGuestPoweroff dials the guest over SSH and runs a sync + // queues a poweroff job. The sync is the load-bearing piece — see the // comment in stopVMLocked. Returns the dial / SSH error if the guest // is unreachable; the caller treats that as a fallback signal. // // Bounded by a hard 2-second SSH-dial timeout. A reachable guest on // the host bridge dials in single-digit milliseconds; if we haven't // connected in 2s the guest is effectively gone, so we fail fast and // let the caller SIGKILL rather than burning latency on a doomed dial. func (s *VMService) requestGuestPoweroff(ctx context.Context, vm model.VMRecord) error { guestIP := strings.TrimSpace(vm.Runtime.GuestIP) if guestIP == "" { return errors.New("guest IP unknown") } dialCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() address := net.JoinHostPort(guestIP, "22") client, err := guest.Dial(dialCtx, address, s.config.SSHKeyPath, s.layout.KnownHostsPath) if err != nil { return err } defer client.Close() // `sync` runs synchronously and blocks RunScript until every dirty // page hits virtio-blk → root.ext4. That's the persistence // guarantee. The `systemctl --no-block poweroff` queues a job and // returns; whether poweroff.target completes before the SIGKILL // fallback fires is incidental — by then sync has already done // its work. The `|| /sbin/poweroff -f` is the last-ditch fallback // when systemd itself is wedged. const script = "sync; systemctl --no-block poweroff || /sbin/poweroff -f &" return client.RunScript(ctx, script, io.Discard) } func (s *VMService) KillVM(ctx context.Context, params api.VMKillParams) (model.VMRecord, error) { return s.withVMLockByRef(ctx, params.IDOrName, func(vm model.VMRecord) (model.VMRecord, error) { return s.killVMLocked(ctx, vm, params.Signal) }) } func (s *VMService) killVMLocked(ctx context.Context, current model.VMRecord, signalValue string) (vm model.VMRecord, err error) { vm = current op := s.beginOperation(ctx, "vm.kill", "vm_ref", vm.ID, "signal", signalValue) defer func() { if err != nil { op.fail(err, vmLogAttrs(vm)...) return } op.done(vmLogAttrs(vm)...) }() if !s.vmAlive(vm) { op.stage("cleanup_stale_runtime") if err := s.cleanupRuntime(ctx, vm, true); err != nil { return model.VMRecord{}, err } vm.State = model.VMStateStopped vm.Runtime.State = model.VMStateStopped clearRuntimeTeardownState(&vm) s.clearVMHandles(vm) if err := s.store.UpsertVM(ctx, vm); err != nil { return model.VMRecord{}, err } return vm, nil } signal := strings.TrimSpace(signalValue) if signal == "" { signal = "TERM" } pid := s.vmHandles(vm.ID).PID op.stage("send_signal", "pid", pid, "signal", signal) if err := s.privOps().SignalProcess(ctx, pid, signal); err != nil { return model.VMRecord{}, err } op.stage("wait_for_exit", "pid", pid) if err := s.net.waitForExit(ctx, pid, vm.Runtime.APISockPath, 30*time.Second); err != nil { if !errors.Is(err, errWaitForExitTimeout) { return model.VMRecord{}, err } op.stage("signal_timeout", "pid", pid, "signal", signal) } op.stage("cleanup_runtime") if err := s.cleanupRuntime(ctx, vm, true); err != nil { return model.VMRecord{}, err } vm.State = model.VMStateStopped vm.Runtime.State = model.VMStateStopped clearRuntimeTeardownState(&vm) s.clearVMHandles(vm) system.TouchNow(&vm) if err := s.store.UpsertVM(ctx, vm); err != nil { return model.VMRecord{}, err } return vm, nil } func (s *VMService) RestartVM(ctx context.Context, idOrName string) (vm model.VMRecord, err error) { op := s.beginOperation(ctx, "vm.restart", "vm_ref", idOrName) defer func() { if err != nil { op.fail(err, vmLogAttrs(vm)...) return } op.done(vmLogAttrs(vm)...) }() resolved, err := s.FindVM(ctx, idOrName) if err != nil { return model.VMRecord{}, err } return s.withVMLockByID(ctx, resolved.ID, func(vm model.VMRecord) (model.VMRecord, error) { op.stage("stop") vm, err = s.stopVMLocked(ctx, vm) if err != nil { return model.VMRecord{}, err } image, err := s.store.GetImageByID(ctx, vm.ImageID) if err != nil { return model.VMRecord{}, err } op.stage("start", vmLogAttrs(vm)...) return s.startVMLocked(ctx, vm, image) }) } func (s *VMService) DeleteVM(ctx context.Context, idOrName string) (model.VMRecord, error) { return s.withVMLockByRef(ctx, idOrName, func(vm model.VMRecord) (model.VMRecord, error) { return s.deleteVMLocked(ctx, vm) }) } func (s *VMService) deleteVMLocked(ctx context.Context, current model.VMRecord) (vm model.VMRecord, err error) { vm = current op := s.beginOperation(ctx, "vm.delete", "vm_ref", vm.ID) defer func() { if err != nil { op.fail(err, vmLogAttrs(vm)...) return } op.done(vmLogAttrs(vm)...) }() if s.vmAlive(vm) { pid := s.vmHandles(vm.ID).PID op.stage("kill_running_vm", "pid", pid) // Best-effort: cleanupRuntime below tears the process down // regardless. A kill failure here only matters when it // surfaces something operators should see (permission // denied, etc.), so promote it from a silent _ to a Warn // without changing the control flow. if killErr := s.net.killVMProcess(ctx, pid); killErr != nil && s.logger != nil { s.logger.Warn("kill vm process during delete failed", append(vmLogAttrs(vm), "pid", pid, "error", killErr.Error())...) } } op.stage("cleanup_runtime") if err := s.cleanupRuntime(ctx, vm, false); err != nil { return model.VMRecord{}, err } clearRuntimeTeardownState(&vm) op.stage("delete_store_record") if err := s.store.DeleteVM(ctx, vm.ID); err != nil { return model.VMRecord{}, err } if vm.Runtime.VMDir != "" { op.stage("delete_vm_dir", "vm_dir", vm.Runtime.VMDir) if err := os.RemoveAll(vm.Runtime.VMDir); err != nil { return model.VMRecord{}, err } } // Drop any host-key pins. A future VM reusing this IP or name // would otherwise trip the TOFU mismatch branch in // TOFUHostKeyCallback and fail to connect. removeVMKnownHosts(s.layout.KnownHostsPath, vm, s.logger) return vm, nil }