package daemon import ( "context" "fmt" "os" "runtime" "strings" "banger/internal/config" "banger/internal/imagecat" "banger/internal/model" "banger/internal/paths" "banger/internal/store" "banger/internal/system" ) func Doctor(ctx context.Context) (system.Report, error) { userLayout, err := paths.Resolve() if err != nil { return system.Report{}, err } cfg, err := config.Load(userLayout) if err != nil { return system.Report{}, err } layout := paths.ResolveSystem() // Doctor must be read-only: running it should never mutate the // state DB (no migrations, no WAL checkpoint, no pragma writes). // Skip OpenReadOnly entirely when the DB file doesn't exist — // that's a fresh install, not an error condition. The first // daemon start will create the file. storeMissing differentiates // "no DB yet" (pass) from "DB present but unreadable" (fail) in // the report. d := &Daemon{ layout: layout, userLayout: userLayout, config: cfg, runner: system.NewRunner(), } var storeErr error storeMissing := false if _, statErr := os.Stat(layout.DBPath); statErr != nil { if os.IsNotExist(statErr) { storeMissing = true } else { storeErr = statErr } } else { db, err := store.OpenReadOnly(layout.DBPath) if err != nil { storeErr = err } else { defer db.Close() d.store = db } } wireServices(d) return d.doctorReport(ctx, storeErr, storeMissing), nil } func (d *Daemon) doctorReport(ctx context.Context, storeErr error, storeMissing bool) system.Report { report := system.Report{} addArchitectureCheck(&report) switch { case storeMissing: report.AddPass("state store", "will be created on first daemon start at "+d.layout.DBPath) case storeErr != nil: report.AddFail( "state store", fmt.Sprintf("open %s: %v", d.layout.DBPath, storeErr), "remove or restore the file if corrupt; otherwise check its permissions", ) default: report.AddPass("state store", "readable at "+d.layout.DBPath) } report.AddPreflight("host runtime", d.runtimeChecks(), runtimeStatus(d.config)) report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available") report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available") d.addVMDefaultsCheck(&report) d.addSSHShortcutCheck(&report) d.addCapabilityDoctorChecks(ctx, &report) return report } // addSSHShortcutCheck surfaces a gentle warning when banger maintains // an ssh_config file but the user hasn't wired it into ~/.ssh/config. // This is intentionally a warn, not a fail — the shortcut is opt-in // convenience and `banger vm ssh` works either way. func (d *Daemon) addSSHShortcutCheck(report *system.Report) { bangerConfig := BangerSSHConfigPath(d.userLayout) if strings.TrimSpace(bangerConfig) == "" { return } if _, err := os.Stat(bangerConfig); err != nil { // No banger ssh_config rendered yet — nothing to include. return } installed, err := UserSSHIncludeInstalled() if err != nil { report.AddWarn("ssh shortcut", fmt.Sprintf("could not read ~/.ssh/config: %v", err)) return } if installed { report.AddPass("ssh shortcut", "enabled — `ssh .vm` routes through banger") return } report.AddWarn( "ssh shortcut", fmt.Sprintf("`ssh .vm` not enabled (opt-in); run `banger ssh-config --install` or add `Include %s` to ~/.ssh/config", bangerConfig), ) } // addArchitectureCheck surfaces a hard-fail when banger is running on // a non-amd64 host. Companion binaries are pinned to amd64 in the // Makefile, the published kernel catalog ships only x86_64 images, and // OCI import pulls linux/amd64 layers. Letting users discover this // through cryptic downstream failures is worse than saying it up front. func addArchitectureCheck(report *system.Report) { if runtime.GOARCH == "amd64" { report.AddPass("host architecture", "amd64") return } report.AddFail( "host architecture", fmt.Sprintf("running on %s; banger today only supports amd64/x86_64 hosts", runtime.GOARCH), "companion build, kernel catalog, and OCI import all assume linux/amd64", ) } // addVMDefaultsCheck surfaces the effective VM sizing that `vm run` / // `vm create` will apply when the user omits the flags. Shown as a // PASS check so it always renders, with per-field provenance // (config|auto|builtin) so users can tell what's driving each number. func (d *Daemon) addVMDefaultsCheck(report *system.Report) { host, err := system.ReadHostResources() var cpus int var memBytes int64 if err == nil { cpus = host.CPUCount memBytes = host.TotalMemoryBytes } defaults := model.ResolveVMDefaults(d.config.VMDefaults, cpus, memBytes) details := []string{ fmt.Sprintf("vcpu: %d (%s)", defaults.VCPUCount, defaults.VCPUSource), fmt.Sprintf("memory: %d MiB (%s)", defaults.MemoryMiB, defaults.MemorySource), fmt.Sprintf("disk: %s (%s)", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), defaults.WorkDiskSource), "override any of these in ~/.config/banger/config.toml under [vm_defaults]", } report.AddPass("vm defaults", details...) } func (d *Daemon) runtimeChecks() *system.Preflight { checks := system.NewPreflight() checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`) checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) if helper, err := vsockAgentBinary(d.layout); err == nil { checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`) } else { checks.Addf("%v", err) } if d.store != nil && strings.TrimSpace(d.config.DefaultImageName) != "" { name := d.config.DefaultImageName image, err := d.store.GetImageByName(context.Background(), name) if err == nil { checks.RequireFile(image.RootfsPath, "default image rootfs", `re-register or rebuild the default image`) checks.RequireFile(image.KernelPath, "default image kernel", `re-register or rebuild the default image`) if strings.TrimSpace(image.InitrdPath) != "" { checks.RequireFile(image.InitrdPath, "default image initrd", `re-register or rebuild the default image`) } } else if !defaultImageInCatalog(name) { checks.Addf("default image %q is not registered and not in the imagecat catalog", name) } // If the default image isn't local but is cataloged, vm create // will auto-pull it on first use — no error to surface. } return checks } func defaultImageInCatalog(name string) bool { catalog, err := imagecat.LoadEmbedded() if err != nil { return false } _, err = catalog.Lookup(name) return err == nil } func (d *Daemon) coreVMLifecycleChecks() *system.Preflight { checks := system.NewPreflight() d.vm.addBaseStartCommandPrereqs(checks) return checks } func (d *Daemon) vsockChecks() *system.Preflight { checks := system.NewPreflight() if helper, err := vsockAgentBinary(d.layout); err == nil { checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`) } else { checks.Addf("%v", err) } checks.RequireFile(d.vm.vsockHostDevice, "vsock host device", "load the vhost_vsock kernel module on the host") return checks } func runtimeStatus(cfg model.DaemonConfig) string { if strings.TrimSpace(cfg.FirecrackerBin) == "" { return "firecracker not configured" } return "firecracker and ssh key resolved" }