banger/internal/daemon/doctor.go
Thales Maciel 59e48e830b
daemon: split owner daemon from root helper
Move the supported systemd path to two services: an owner-user bangerd for
orchestration and a narrow root helper for bridge/tap, NAT/resolver, dm/loop,
and Firecracker ownership. This removes repeated sudo from daily vm and image
flows without leaving the general daemon running as root.

Add install metadata, system install/status/restart/uninstall commands, and a
system-owned runtime layout. Keep user SSH/config material in the owner home,
lock file_sync to the owner home, and move daemon known_hosts handling out of
the old root-owned control path.

Route privileged lifecycle steps through typed privilegedOps calls, harden the
two systemd units, and rewrite smoke plus docs around the supported service
model.

Verified with make build, make test, make lint, and make smoke on the
supported systemd host path.
2026-04-26 12:43:17 -03:00

215 lines
7.3 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) {
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 <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.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"
}