banger/internal/daemon/capabilities.go
Thales Maciel 37c4c091ec
Add guest sessions and agent VM defaults
Add daemon-backed workspace and guest-session primitives so host
orchestrators can prepare /root/repo, launch long-lived guest commands,
and attach to pipe-mode sessions over the local stdio mux bridge.

Persist richer session metadata and launch diagnostics, preflight guest
cwd/command requirements, make pipe-mode attach rehydratable from guest
state after daemon restart, and allow submodules when workspace prepare
runs in full_copy mode.

At the same time, stop vm run from auto-attaching opencode, make it
print next-step commands instead, and make glibc guest images more
agent-ready by installing node, opencode, claude, and pi while syncing
opencode/claude/pi auth files into work disks on VM start.

Validation:
- GOCACHE=/tmp/banger-gocache go test ./...
- make build
- banger vm workspace prepare --help
- banger vm session --help
- banger vm session start --help
- banger vm session attach --help
2026-04-12 23:48:42 -03:00

324 lines
9.9 KiB
Go

package daemon
import (
"context"
"errors"
"net"
"os"
"strings"
"banger/internal/firecracker"
"banger/internal/guestconfig"
"banger/internal/model"
"banger/internal/system"
"banger/internal/vmdns"
)
type vmCapability interface {
Name() string
}
type startPreflightCapability interface {
AddStartPreflight(context.Context, *Daemon, *system.Preflight, model.VMRecord, model.Image)
}
type guestConfigCapability interface {
ContributeGuest(*guestconfig.Builder, model.VMRecord, model.Image)
}
type machineConfigCapability interface {
ContributeMachine(*firecracker.MachineConfig, model.VMRecord, model.Image)
}
type prepareHostCapability interface {
PrepareHost(context.Context, *Daemon, *model.VMRecord, model.Image) error
}
type postStartCapability interface {
PostStart(context.Context, *Daemon, model.VMRecord, model.Image) error
}
type cleanupCapability interface {
Cleanup(context.Context, *Daemon, model.VMRecord) error
}
type configChangeCapability interface {
ApplyConfigChange(context.Context, *Daemon, model.VMRecord, model.VMRecord) error
}
type doctorCapability interface {
AddDoctorChecks(context.Context, *Daemon, *system.Report)
}
func (d *Daemon) registeredCapabilities() []vmCapability {
if len(d.vmCaps) > 0 {
return d.vmCaps
}
return []vmCapability{
workDiskCapability{},
opencodeCapability{},
dnsCapability{},
natCapability{},
}
}
func (d *Daemon) addCapabilityStartPrereqs(ctx context.Context, checks *system.Preflight, vm model.VMRecord, image model.Image) {
for _, capability := range d.registeredCapabilities() {
if hook, ok := capability.(startPreflightCapability); ok {
hook.AddStartPreflight(ctx, d, checks, vm, image)
}
}
}
func (d *Daemon) contributeGuestConfig(builder *guestconfig.Builder, vm model.VMRecord, image model.Image) {
for _, capability := range d.registeredCapabilities() {
if hook, ok := capability.(guestConfigCapability); ok {
hook.ContributeGuest(builder, vm, image)
}
}
}
func (d *Daemon) contributeMachineConfig(cfg *firecracker.MachineConfig, vm model.VMRecord, image model.Image) {
for _, capability := range d.registeredCapabilities() {
if hook, ok := capability.(machineConfigCapability); ok {
hook.ContributeMachine(cfg, vm, image)
}
}
}
func (d *Daemon) prepareCapabilityHosts(ctx context.Context, vm *model.VMRecord, image model.Image) error {
prepared := make([]vmCapability, 0, len(d.registeredCapabilities()))
for _, capability := range d.registeredCapabilities() {
hook, ok := capability.(prepareHostCapability)
if !ok {
continue
}
if err := hook.PrepareHost(ctx, d, vm, image); err != nil {
d.cleanupPreparedCapabilities(context.Background(), vm, prepared)
return err
}
prepared = append(prepared, capability)
}
return nil
}
func (d *Daemon) postStartCapabilities(ctx context.Context, vm model.VMRecord, image model.Image) error {
for _, capability := range d.registeredCapabilities() {
switch capability.Name() {
case "dns":
vmCreateStage(ctx, "apply_dns", "publishing vm dns record")
case "nat":
if vm.Spec.NATEnabled {
vmCreateStage(ctx, "apply_nat", "configuring nat")
}
}
if hook, ok := capability.(postStartCapability); ok {
if err := hook.PostStart(ctx, d, vm, image); err != nil {
return err
}
}
}
return nil
}
func (d *Daemon) cleanupCapabilityState(ctx context.Context, vm model.VMRecord) error {
return d.cleanupPreparedCapabilities(ctx, &vm, d.registeredCapabilities())
}
func (d *Daemon) cleanupPreparedCapabilities(ctx context.Context, vm *model.VMRecord, capabilities []vmCapability) error {
var err error
for index := len(capabilities) - 1; index >= 0; index-- {
hook, ok := capabilities[index].(cleanupCapability)
if !ok {
continue
}
err = joinErr(err, hook.Cleanup(ctx, d, *vm))
}
return err
}
func (d *Daemon) applyCapabilityConfigChanges(ctx context.Context, before, after model.VMRecord) error {
for _, capability := range d.registeredCapabilities() {
if hook, ok := capability.(configChangeCapability); ok {
if err := hook.ApplyConfigChange(ctx, d, before, after); err != nil {
return err
}
}
}
return nil
}
func (d *Daemon) addCapabilityDoctorChecks(ctx context.Context, report *system.Report) {
for _, capability := range d.registeredCapabilities() {
if hook, ok := capability.(doctorCapability); ok {
hook.AddDoctorChecks(ctx, d, report)
}
}
}
type workDiskCapability struct{}
func (workDiskCapability) Name() string { return "work-disk" }
func (workDiskCapability) AddStartPreflight(_ context.Context, _ *Daemon, checks *system.Preflight, vm model.VMRecord, image model.Image) {
if exists(vm.Runtime.WorkDiskPath) {
return
}
imageSeed := ""
if image.RootfsPath != "" {
imageSeed = image.WorkSeedPath
}
if exists(imageSeed) {
if info, err := os.Stat(imageSeed); err == nil && vm.Spec.WorkDiskSizeBytes > info.Size() {
checks.RequireCommand("e2fsck", toolHint("e2fsck"))
checks.RequireCommand("resize2fs", toolHint("resize2fs"))
}
return
}
for _, command := range []string{"mkfs.ext4", "mount", "umount", "cp"} {
checks.RequireCommand(command, toolHint(command))
}
}
func (workDiskCapability) ContributeGuest(builder *guestconfig.Builder, _ model.VMRecord, _ model.Image) {
builder.AddMount(guestconfig.MountSpec{
Source: "/dev/vdb",
Target: "/root",
FSType: "ext4",
Options: []string{"defaults"},
Dump: 0,
Pass: 2,
})
}
func (workDiskCapability) ContributeMachine(cfg *firecracker.MachineConfig, vm model.VMRecord, _ model.Image) {
cfg.Drives = append(cfg.Drives, firecracker.DriveConfig{
ID: "work",
Path: vm.Runtime.WorkDiskPath,
ReadOnly: false,
})
}
func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.VMRecord, image model.Image) error {
prep, err := d.ensureWorkDisk(ctx, vm, image)
if err != nil {
return err
}
if err := d.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep); err != nil {
return err
}
if err := d.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil {
return err
}
if err := d.ensureOpencodeAuthOnWorkDisk(ctx, vm); err != nil {
return err
}
if err := d.ensureClaudeAuthOnWorkDisk(ctx, vm); err != nil {
return err
}
return d.ensurePiAuthOnWorkDisk(ctx, vm)
}
func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) {
if d.store != nil && strings.TrimSpace(d.config.DefaultImageName) != "" {
if image, err := d.store.GetImageByName(context.Background(), d.config.DefaultImageName); err == nil && strings.TrimSpace(image.WorkSeedPath) != "" && exists(image.WorkSeedPath) {
checks := system.NewPreflight()
checks.RequireFile(image.WorkSeedPath, "default image work-seed", `rebuild the default image to regenerate the /root seed`)
report.AddPreflight("feature /root work disk", checks, "seeded /root work disk artifact available")
return
}
}
checks := system.NewPreflight()
for _, command := range []string{"mkfs.ext4", "mount", "umount", "cp"} {
checks.RequireCommand(command, toolHint(command))
}
report.AddPreflight("feature /root work disk", checks, "fallback /root work disk tooling available")
report.AddWarn("feature /root work disk", "default image has no work-seed artifact; new VM creates will be slower until the image is rebuilt")
}
type dnsCapability struct{}
func (dnsCapability) Name() string { return "dns" }
func (dnsCapability) PostStart(ctx context.Context, d *Daemon, vm model.VMRecord, _ model.Image) error {
return d.setDNS(ctx, vm.Name, vm.Runtime.GuestIP)
}
func (dnsCapability) Cleanup(ctx context.Context, d *Daemon, vm model.VMRecord) error {
return d.removeDNS(ctx, vm.Runtime.DNSName)
}
func (dnsCapability) AddDoctorChecks(_ context.Context, _ *Daemon, report *system.Report) {
conn, err := net.ListenPacket("udp", vmdns.DefaultListenAddr)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "address already in use") {
report.AddWarn("feature vm dns", "listener address "+vmdns.DefaultListenAddr+" is already in use")
return
}
report.AddFail("feature vm dns", "cannot bind "+vmdns.DefaultListenAddr+": "+err.Error())
return
}
_ = conn.Close()
report.AddPass("feature vm dns", "listener can bind "+vmdns.DefaultListenAddr)
}
type natCapability struct{}
func (natCapability) Name() string { return "nat" }
func (natCapability) AddStartPreflight(ctx context.Context, d *Daemon, checks *system.Preflight, vm model.VMRecord, _ model.Image) {
if !vm.Spec.NATEnabled {
return
}
d.addNATPrereqs(ctx, checks)
}
func (natCapability) PostStart(ctx context.Context, d *Daemon, vm model.VMRecord, _ model.Image) error {
if !vm.Spec.NATEnabled {
return nil
}
return d.ensureNAT(ctx, vm, true)
}
func (natCapability) Cleanup(ctx context.Context, d *Daemon, vm model.VMRecord) error {
if !vm.Spec.NATEnabled {
return nil
}
if strings.TrimSpace(vm.Runtime.GuestIP) == "" || strings.TrimSpace(vm.Runtime.TapDevice) == "" {
if d.logger != nil {
d.logger.Debug("skipping nat cleanup without runtime network handles", append(vmLogAttrs(vm), "guest_ip", vm.Runtime.GuestIP, "tap_device", vm.Runtime.TapDevice)...)
}
return nil
}
return d.ensureNAT(ctx, vm, false)
}
func (natCapability) ApplyConfigChange(ctx context.Context, d *Daemon, before, after model.VMRecord) error {
if before.Spec.NATEnabled == after.Spec.NATEnabled {
return nil
}
if after.State != model.VMStateRunning || !system.ProcessRunning(after.Runtime.PID, after.Runtime.APISockPath) {
return nil
}
return d.ensureNAT(ctx, after, after.Spec.NATEnabled)
}
func (natCapability) AddDoctorChecks(ctx context.Context, d *Daemon, report *system.Report) {
checks := system.NewPreflight()
checks.RequireCommand("ip", toolHint("ip"))
d.addNATPrereqs(ctx, checks)
if len(checks.Problems()) > 0 {
report.Add(system.CheckStatusFail, "feature nat", checks.Problems()...)
return
}
uplink, err := d.defaultUplink(ctx)
if err != nil {
report.AddFail("feature nat", err.Error())
return
}
report.AddPass("feature nat", "iptables/sysctl available, uplink "+uplink)
}
func joinErr(current, next error) error {
return errors.Join(current, next)
}