banger/internal/config/config.go
Thales Maciel 2362d0ae39
Serve a local web UI from bangerd
Add a localhost-only web console so VM and image management no longer depends on the CLI for every inspection and lifecycle action.

Wire bangerd up to a configurable web listener, expose dashboard and async image-build state through the daemon, and serve CSRF-protected HTML pages with host-path picking, VM/image detail views, logs, ports, and progress polling for long-running operations.

Keep the browser path aligned with the existing sudo and host-owned artifact model: surface sudo readiness, print the web URL in daemon status, and document the new workflow. Polish the UI with resource usage cards, clearer clickable affordances, cancel paths, confirmation prompts, image-name links, and HTTP port links.

Validation: GOCACHE=/tmp/banger-gocache go test ./...
2026-03-21 16:47:47 -03:00

259 lines
8.5 KiB
Go

package config
import (
"errors"
"os"
"path/filepath"
"strings"
"time"
toml "github.com/pelletier/go-toml"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/runtimebundle"
)
type fileConfig struct {
RuntimeDir string `toml:"runtime_dir"`
RepoRoot string `toml:"repo_root"`
LogLevel string `toml:"log_level"`
WebListenAddr *string `toml:"web_listen_addr"`
FirecrackerBin string `toml:"firecracker_bin"`
SSHKeyPath string `toml:"ssh_key_path"`
NamegenPath string `toml:"namegen_path"`
CustomizeScript string `toml:"customize_script"`
VSockAgent string `toml:"vsock_agent_path"`
VSockPingHelper string `toml:"vsock_ping_helper_path"`
DefaultWorkSeed string `toml:"default_work_seed"`
DefaultImageName string `toml:"default_image_name"`
DefaultRootfs string `toml:"default_rootfs"`
DefaultBaseRootfs string `toml:"default_base_rootfs"`
DefaultKernel string `toml:"default_kernel"`
DefaultInitrd string `toml:"default_initrd"`
DefaultModulesDir string `toml:"default_modules_dir"`
DefaultPackages string `toml:"default_packages_file"`
AutoStopStaleAfter string `toml:"auto_stop_stale_after"`
StatsPollInterval string `toml:"stats_poll_interval"`
MetricsPoll string `toml:"metrics_poll_interval"`
BridgeName string `toml:"bridge_name"`
BridgeIP string `toml:"bridge_ip"`
CIDR string `toml:"cidr"`
TapPoolSize int `toml:"tap_pool_size"`
DefaultDNS string `toml:"default_dns"`
}
func Load(layout paths.Layout) (model.DaemonConfig, error) {
cfg := model.DaemonConfig{
LogLevel: "info",
WebListenAddr: "127.0.0.1:7777",
AutoStopStaleAfter: 0,
StatsPollInterval: model.DefaultStatsPollInterval,
MetricsPollInterval: model.DefaultMetricsPollInterval,
BridgeName: model.DefaultBridgeName,
BridgeIP: model.DefaultBridgeIP,
CIDR: model.DefaultCIDR,
TapPoolSize: 4,
DefaultDNS: model.DefaultDNS,
DefaultImageName: "default",
}
path := filepath.Join(layout.ConfigDir, "config.toml")
info, err := os.Stat(path)
var file fileConfig
if err != nil {
if !os.IsNotExist(err) {
return cfg, err
}
} else if !info.IsDir() {
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := toml.Unmarshal(data, &file); err != nil {
return cfg, err
}
}
cfg.RuntimeDir = paths.ResolveRuntimeDir(file.RuntimeDir, file.RepoRoot)
if err := applyRuntimeDefaults(&cfg); err != nil {
return cfg, err
}
if file.FirecrackerBin != "" {
cfg.FirecrackerBin = file.FirecrackerBin
}
if file.LogLevel != "" {
cfg.LogLevel = file.LogLevel
}
if file.WebListenAddr != nil {
cfg.WebListenAddr = strings.TrimSpace(*file.WebListenAddr)
}
if file.NamegenPath != "" {
cfg.NamegenPath = file.NamegenPath
}
if file.CustomizeScript != "" {
cfg.CustomizeScript = file.CustomizeScript
}
if file.VSockAgent != "" {
cfg.VSockAgentPath = file.VSockAgent
} else if file.VSockPingHelper != "" {
cfg.VSockAgentPath = file.VSockPingHelper
}
if file.DefaultWorkSeed != "" {
cfg.DefaultWorkSeed = file.DefaultWorkSeed
}
if file.DefaultImageName != "" {
cfg.DefaultImageName = file.DefaultImageName
}
if file.DefaultRootfs != "" {
cfg.DefaultRootfs = file.DefaultRootfs
}
if file.DefaultBaseRootfs != "" {
cfg.DefaultBaseRootfs = file.DefaultBaseRootfs
}
if file.DefaultKernel != "" {
cfg.DefaultKernel = file.DefaultKernel
}
if file.DefaultInitrd != "" {
cfg.DefaultInitrd = file.DefaultInitrd
}
if file.DefaultModulesDir != "" {
cfg.DefaultModulesDir = file.DefaultModulesDir
}
if file.DefaultPackages != "" {
cfg.DefaultPackagesFile = file.DefaultPackages
}
if file.BridgeName != "" {
cfg.BridgeName = file.BridgeName
}
if file.BridgeIP != "" {
cfg.BridgeIP = file.BridgeIP
}
if file.CIDR != "" {
cfg.CIDR = file.CIDR
}
if file.TapPoolSize > 0 {
cfg.TapPoolSize = file.TapPoolSize
}
if file.DefaultDNS != "" {
cfg.DefaultDNS = file.DefaultDNS
}
if file.AutoStopStaleAfter != "" {
duration, err := time.ParseDuration(file.AutoStopStaleAfter)
if err != nil {
return cfg, err
}
cfg.AutoStopStaleAfter = duration
}
if file.StatsPollInterval != "" {
duration, err := time.ParseDuration(file.StatsPollInterval)
if err != nil {
return cfg, err
}
cfg.StatsPollInterval = duration
}
if file.MetricsPoll != "" {
duration, err := time.ParseDuration(file.MetricsPoll)
if err != nil {
return cfg, err
}
cfg.MetricsPollInterval = duration
}
if value := os.Getenv("BANGER_LOG_LEVEL"); value != "" {
cfg.LogLevel = value
}
return cfg, nil
}
func applyRuntimeDefaults(cfg *model.DaemonConfig) error {
if cfg.RuntimeDir == "" {
return nil
}
meta, err := runtimebundle.LoadBundleMetadata(cfg.RuntimeDir)
switch {
case err == nil:
applyBundleMetadataDefaults(cfg, cfg.RuntimeDir, meta)
case errors.Is(err, os.ErrNotExist):
applyLegacyRuntimeDefaults(cfg)
default:
return err
}
if cfg.DefaultRootfs == "" {
cfg.DefaultRootfs = firstExistingRuntimePath(
filepath.Join(cfg.RuntimeDir, "rootfs-docker.ext4"),
filepath.Join(cfg.RuntimeDir, "rootfs.ext4"),
)
}
if cfg.DefaultBaseRootfs == "" {
cfg.DefaultBaseRootfs = firstExistingRuntimePath(
filepath.Join(cfg.RuntimeDir, "rootfs.ext4"),
cfg.DefaultRootfs,
)
}
if cfg.DefaultWorkSeed == "" && cfg.DefaultRootfs != "" {
cfg.DefaultWorkSeed = firstExistingRuntimePath(associatedWorkSeedPath(cfg.DefaultRootfs))
}
return nil
}
func applyBundleMetadataDefaults(cfg *model.DaemonConfig, runtimeDir string, meta runtimebundle.BundleMetadata) {
cfg.FirecrackerBin = defaultRuntimePath(cfg.FirecrackerBin, runtimeDir, meta.FirecrackerBin)
cfg.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, runtimeDir, meta.SSHKeyPath)
cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, runtimeDir, meta.NamegenPath)
cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, runtimeDir, meta.CustomizeScript)
cfg.VSockAgentPath = defaultRuntimePath(cfg.VSockAgentPath, runtimeDir, meta.VSockAgentPath)
cfg.DefaultWorkSeed = defaultRuntimePath(cfg.DefaultWorkSeed, runtimeDir, meta.DefaultWorkSeed)
cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, runtimeDir, meta.DefaultKernel)
cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, runtimeDir, meta.DefaultInitrd)
cfg.DefaultModulesDir = defaultRuntimePath(cfg.DefaultModulesDir, runtimeDir, meta.DefaultModulesDir)
cfg.DefaultPackagesFile = defaultRuntimePath(cfg.DefaultPackagesFile, runtimeDir, meta.DefaultPackages)
cfg.DefaultRootfs = defaultRuntimePath(cfg.DefaultRootfs, runtimeDir, meta.DefaultRootfs)
cfg.DefaultBaseRootfs = defaultRuntimePath(cfg.DefaultBaseRootfs, runtimeDir, meta.DefaultBaseRootfs)
}
func applyLegacyRuntimeDefaults(cfg *model.DaemonConfig) {
cfg.FirecrackerBin = defaultRuntimePath(cfg.FirecrackerBin, cfg.RuntimeDir, "firecracker")
cfg.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, cfg.RuntimeDir, "id_ed25519")
cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, cfg.RuntimeDir, "namegen")
cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, cfg.RuntimeDir, "customize.sh")
cfg.VSockAgentPath = firstExistingRuntimePath(
defaultRuntimePath(cfg.VSockAgentPath, cfg.RuntimeDir, "banger-vsock-agent"),
filepath.Join(cfg.RuntimeDir, "banger-vsock-pingd"),
)
cfg.DefaultWorkSeed = defaultRuntimePath(cfg.DefaultWorkSeed, cfg.RuntimeDir, "rootfs-docker.work-seed.ext4")
cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, cfg.RuntimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic")
cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, cfg.RuntimeDir, "wtf/root/boot/initrd.img-6.8.0-94-generic")
cfg.DefaultModulesDir = defaultRuntimePath(cfg.DefaultModulesDir, cfg.RuntimeDir, "wtf/root/lib/modules/6.8.0-94-generic")
cfg.DefaultPackagesFile = defaultRuntimePath(cfg.DefaultPackagesFile, cfg.RuntimeDir, "packages.apt")
}
func defaultRuntimePath(current, runtimeDir, relative string) string {
if current != "" || relative == "" {
return current
}
return filepath.Join(runtimeDir, relative)
}
func firstExistingRuntimePath(paths ...string) string {
for _, candidate := range paths {
if candidate == "" {
continue
}
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}
func associatedWorkSeedPath(rootfsPath string) string {
rootfsPath = strings.TrimSpace(rootfsPath)
if rootfsPath == "" {
return ""
}
if strings.HasSuffix(rootfsPath, ".ext4") {
return strings.TrimSuffix(rootfsPath, ".ext4") + ".work-seed.ext4"
}
return rootfsPath + ".work-seed"
}