Phase 4 of the daemon god-struct refactor. VM lifecycle, create-op
registry, handle cache, disk provisioning, stats polling, ports
query, and the per-VM lock set all move off *Daemon onto *VMService.
Daemon keeps thin forwarders only for FindVM / TouchVM (dispatch
surface) and is otherwise out of VM lifecycle. Lazy-init via
d.vmSvc() mirrors the earlier services so test literals like
\`&Daemon{store: db, runner: r}\` still get a functional service
without spelling one out.
Three small cleanups along the way:
* preflight helpers (validateStartPrereqs / addBaseStartPrereqs
/ addBaseStartCommandPrereqs / validateWorkDiskResizePrereqs)
move with the VM methods that call them.
* cleanupRuntime / rebuildDNS move to *VMService, with
HostNetwork primitives (findFirecrackerPID, cleanupDMSnapshot,
killVMProcess, releaseTap, waitForExit, sendCtrlAltDel)
reached through s.net instead of the hostNet() facade.
* vsockAgentBinary becomes a package-level function so both
*Daemon (doctor) and *VMService (preflight) call one entry
point instead of each owning a forwarder method.
WorkspaceService's peer deps switch from eager method values to
closures — vmSvc() constructs VMService with WorkspaceService as a
peer, so resolving d.vmSvc().FindVM at construction time recursed
through workspaceSvc() → vmSvc(). Closures defer the lookup to call
time.
Pure code motion: build + unit tests green, lint clean. No RPC
surface or lock-ordering changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
6.4 KiB
Go
190 lines
6.4 KiB
Go
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) {
|
|
layout, err := paths.Resolve()
|
|
if err != nil {
|
|
return system.Report{}, err
|
|
}
|
|
cfg, err := config.Load(layout)
|
|
if err != nil {
|
|
return system.Report{}, err
|
|
}
|
|
d := &Daemon{
|
|
layout: layout,
|
|
config: cfg,
|
|
runner: system.NewRunner(),
|
|
}
|
|
db, storeErr := store.Open(layout.DBPath)
|
|
if storeErr == nil {
|
|
defer db.Close()
|
|
d.store = db
|
|
}
|
|
return d.doctorReport(ctx, storeErr), nil
|
|
}
|
|
|
|
func (d *Daemon) doctorReport(ctx context.Context, storeErr error) system.Report {
|
|
report := system.Report{}
|
|
|
|
addArchitectureCheck(&report)
|
|
|
|
if 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",
|
|
)
|
|
} else {
|
|
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.layout)
|
|
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 <name>.vm` routes through banger")
|
|
return
|
|
}
|
|
report.AddWarn(
|
|
"ssh shortcut",
|
|
fmt.Sprintf("`ssh <name>.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.vmSvc().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(vsockHostDevicePath, "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"
|
|
}
|