package daemon import ( "context" "errors" "fmt" "os" "strconv" "strings" "time" "banger/internal/daemon/fcproc" "banger/internal/firecracker" "banger/internal/model" "banger/internal/namegen" "banger/internal/system" "banger/internal/vmdns" ) // Cross-service constants. Kept in vm.go because both lifecycle // (VMService) and networking (HostNetwork) reference them; moving // them to either owner would read as a layering violation. var ( errWaitForExitTimeout = fcproc.ErrWaitForExitTimeout gracefulShutdownWait = 10 * time.Second vsockReadyWait = 30 * time.Second vsockReadyPoll = 200 * time.Millisecond ) // rebuildDNS enumerates live VMs and republishes the DNS record set. // Lives on VMService because "alive" is a VM-state concern that // HostNetwork shouldn't need to reach into. VMService orchestrates: // VM list from the store, alive filter, hand the resulting map to // HostNetwork.replaceDNS. func (s *VMService) rebuildDNS(ctx context.Context) error { if s.net == nil { return nil } vms, err := s.store.ListVMs(ctx) if err != nil { return err } records := make(map[string]string) for _, vm := range vms { if !s.vmAlive(vm) { continue } if strings.TrimSpace(vm.Runtime.GuestIP) == "" { continue } records[vmdns.RecordName(vm.Name)] = vm.Runtime.GuestIP } return s.net.replaceDNS(records) } func persistRuntimeTeardownState(vm *model.VMRecord, h model.VMHandles) { if vm == nil { return } vm.Runtime.TapDevice = h.TapDevice vm.Runtime.BaseLoop = h.BaseLoop vm.Runtime.COWLoop = h.COWLoop vm.Runtime.DMName = h.DMName vm.Runtime.DMDev = h.DMDev } func clearRuntimeTeardownState(vm *model.VMRecord) { if vm == nil { return } vm.Runtime.TapDevice = "" vm.Runtime.BaseLoop = "" vm.Runtime.COWLoop = "" vm.Runtime.DMName = "" vm.Runtime.DMDev = "" } func teardownHandlesForCleanup(vm model.VMRecord, live model.VMHandles) model.VMHandles { recovered := live if strings.TrimSpace(recovered.TapDevice) == "" { recovered.TapDevice = strings.TrimSpace(vm.Runtime.TapDevice) } if strings.TrimSpace(recovered.BaseLoop) == "" { recovered.BaseLoop = strings.TrimSpace(vm.Runtime.BaseLoop) } if strings.TrimSpace(recovered.COWLoop) == "" { recovered.COWLoop = strings.TrimSpace(vm.Runtime.COWLoop) } if strings.TrimSpace(recovered.DMName) == "" { recovered.DMName = strings.TrimSpace(vm.Runtime.DMName) } if strings.TrimSpace(recovered.DMDev) == "" { recovered.DMDev = strings.TrimSpace(vm.Runtime.DMDev) } return recovered } // cleanupRuntime tears down the host-side state for a VM: firecracker // process, DM snapshot, capabilities, tap, sockets. Lives on VMService // because it reaches into handles (VMService-owned); the capability // teardown goes through the capHooks seam to keep Daemon out of the // dependency chain. // // Idempotency contract: every step runs even when an earlier step // fails, and the per-step errors are joined into the returned value. // A waitForExit timeout (firecracker refused to die) used to early- // return, leaving DM/feature/tap state stranded on the host across // daemon restarts. With collect-and-continue the kernel teardowns // still attempt; in the worst case (firecracker actually still alive) // they fail with EBUSY which is also surfaced via errors.Join — no // damage, but the operator sees the full picture. func (s *VMService) cleanupRuntime(ctx context.Context, vm model.VMRecord, preserveDisks bool) error { if s.logger != nil { s.logger.Debug("cleanup runtime", append(vmLogAttrs(vm), "preserve_disks", preserveDisks)...) } h := s.vmHandles(vm.ID) cleanupPID := h.PID if vm.Runtime.APISockPath != "" { if pid, err := s.net.findFirecrackerPID(ctx, vm.Runtime.APISockPath); err == nil && pid > 0 { cleanupPID = pid } } var waitErr error if cleanupPID > 0 && system.ProcessRunning(cleanupPID, vm.Runtime.APISockPath) { _ = s.net.killVMProcess(ctx, cleanupPID) waitErr = s.net.waitForExit(ctx, cleanupPID, vm.Runtime.APISockPath, 30*time.Second) if waitErr != nil && s.logger != nil { s.logger.Warn("cleanup wait_for_exit failed; continuing teardown", append(vmLogAttrs(vm), "pid", cleanupPID, "error", waitErr.Error())...) } } handles := teardownHandlesForCleanup(vm, h) snapshotErr := s.net.cleanupDMSnapshot(ctx, dmSnapshotHandles{ BaseLoop: handles.BaseLoop, COWLoop: handles.COWLoop, DMName: handles.DMName, DMDev: handles.DMDev, }) featureErr := s.capHooks.cleanupState(ctx, vm) var tapErr error // Prefer the handle cache (fresh from startVMLocked), but fall // back to the VMRuntime mirrors so restart-time cleanup still works // when handles.json is missing or corrupt. tap := handles.TapDevice if tap != "" { tapErr = s.net.releaseTap(ctx, tap) } if vm.Runtime.APISockPath != "" { _ = os.Remove(vm.Runtime.APISockPath) } if vm.Runtime.VSockPath != "" { _ = os.Remove(vm.Runtime.VSockPath) } // Remove the jailer chroot tree (kernel hard-links, mknod'd device // nodes, the chroot root itself). Skipped silently when the jailer // is disabled or the chroot was never created. We intentionally // don't gate on JailerEnabled today — old VMs created before the // flag flipped on still need their chroots removed if any exist. jailerErr := s.cleanupJailerChroot(ctx, vm) // The handles are only meaningful while the kernel objects exist; // dropping them here keeps the cache in sync with reality even // when the caller forgets to call clearVMHandles explicitly. s.clearVMHandles(vm) if !preserveDisks && vm.Runtime.VMDir != "" { return errors.Join(waitErr, snapshotErr, featureErr, tapErr, jailerErr, os.RemoveAll(vm.Runtime.VMDir)) } return errors.Join(waitErr, snapshotErr, featureErr, tapErr, jailerErr) } // cleanupJailerChroot removes the per-VM chroot tree if it exists. Returns // nil silently when the jailer was never enabled or the chroot path can't // be computed (no JailerChrootBase configured). func (s *VMService) cleanupJailerChroot(ctx context.Context, vm model.VMRecord) error { base := strings.TrimSpace(s.config.JailerChrootBase) if base == "" { return nil } chrootRoot := firecracker.JailerChrootRoot(base, vm.ID) if _, err := os.Stat(chrootRoot); os.IsNotExist(err) { return nil } return s.privOps().CleanupJailerChroot(ctx, chrootRoot) } func (s *VMService) generateName(ctx context.Context) (string, error) { _ = ctx if name := strings.TrimSpace(namegen.Generate()); name != "" { return name, nil } return "vm-" + strconv.FormatInt(time.Now().Unix(), 10), nil } func bridgePrefix(bridgeIP string) string { parts := strings.Split(bridgeIP, ".") if len(parts) < 3 { return bridgeIP } return strings.Join(parts[:3], ".") } func optionalIntOrDefault(value *int, fallback int) int { if value != nil { return *value } return fallback } func validateOptionalPositiveSetting(label string, value *int) error { if value == nil { return nil } if *value <= 0 { return fmt.Errorf("%s must be a positive integer", label) } return nil }