From fcedacba5c0ab992744d1c3af41ec006a9e93684 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Mon, 16 Mar 2026 15:30:08 -0300 Subject: [PATCH] Make runtime defaults portable Stop assuming one workstation layout for runtime artifacts, mapdns, and host tooling. The daemon and shell helpers now use portable mapdns configuration, and runtime bundles can carry bundle.json metadata for their default kernel, initrd, modules, rootfs, and helper paths. Load bundle metadata through config with a legacy layout fallback, thread mapdns_bin/mapdns_data_file through the Go and shell paths, and add command-scoped preflight checks for VM start, NAT, image build, work-disk resize, and SSH so missing tools or artifacts fail with actionable errors. Update the runtime-bundle manifest, docs, and tests to match the new model. Verified with go test ./..., make build, and bash -n customize.sh interactive.sh dns.sh make-rootfs.sh verify.sh. --- AGENTS.md | 3 +- Makefile | 2 +- README.md | 30 +++-- customize.sh | 23 +++- dns.sh | 14 ++- interactive.sh | 21 +++- internal/cli/banger.go | 42 +++++++ internal/cli/cli_test.go | 34 ++++++ internal/cli/tui.go | 3 + internal/config/config.go | 71 +++++++++-- internal/config/config_test.go | 121 +++++++++++++++---- internal/daemon/daemon_test.go | 54 +++++++++ internal/daemon/images.go | 9 ++ internal/daemon/nat.go | 15 ++- internal/daemon/preflight.go | 123 +++++++++++++++++++ internal/daemon/vm.go | 52 ++++---- internal/model/types.go | 2 + internal/paths/paths.go | 5 + internal/paths/paths_test.go | 31 ++++- internal/runtimebundle/bundle.go | 167 +++++++++++++++++++++++++- internal/runtimebundle/bundle_test.go | 78 ++++++++++++ internal/system/preflight.go | 112 +++++++++++++++++ runtime-bundle.toml | 11 ++ 23 files changed, 927 insertions(+), 96 deletions(-) create mode 100644 internal/daemon/preflight.go create mode 100644 internal/system/preflight.go diff --git a/AGENTS.md b/AGENTS.md index 08afbc2..df34220 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,12 +4,13 @@ - `cmd/banger` and `cmd/bangerd` are the primary user-facing entrypoints. - `internal/` contains the daemon, CLI, RPC, storage, Firecracker, and system integration code. - `customize.sh`, `make-rootfs.sh`, and `interactive.sh` remain as image-build/customization helpers; normal VM lifecycle and NAT management are handled by the Go control plane. -- Source checkouts use a generated `./runtime/` bundle for Firecracker, kernels, modules, rootfs images, and helper copies. Those runtime artifacts are not meant to be tracked directly in Git. +- Source checkouts use a generated `./runtime/` bundle for Firecracker, kernels, modules, rootfs images, and helper copies. Bundle defaults come from `./runtime/bundle.json` when present. Those runtime artifacts are not meant to be tracked directly in Git. - The daemon keeps state under XDG directories rather than the old repo-local `state/` layout. ## Build, Test, and Development Commands - `make build` builds `./banger` and `./bangerd`. - `make runtime-bundle` bootstraps `./runtime/` from `runtime-bundle.toml`. +- `banger` validates required host tools per command and reports actionable missing-tool errors; do not assume one workstation's package set. - `./banger vm create --name testbox` creates and starts a VM. - `./banger vm ssh testbox` connects to a running guest. - `./banger vm stop testbox` stops a VM while preserving its disks. diff --git a/Makefile b/Makefile index dcc858d..a0bc1b5 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ BINARIES := banger bangerd GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort) RUNTIME_EXECUTABLES := firecracker customize.sh dns.sh packages.sh nat.sh namegen RUNTIME_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4 -RUNTIME_OPTIONAL_DATA_FILES := rootfs.ext4 +RUNTIME_OPTIONAL_DATA_FILES := rootfs.ext4 bundle.json RUNTIME_BOOT_FILES := wtf/root/boot/vmlinux-6.8.0-94-generic wtf/root/boot/initrd.img-6.8.0-94-generic RUNTIME_MODULES_DIR := wtf/root/lib/modules/6.8.0-94-generic diff --git a/README.md b/README.md index d926d80..8277105 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,16 @@ Persistent Firecracker development VMs managed through a Go daemon, CLI, and TUI ## Requirements - Linux host with KVM (`/dev/kvm` access) -- `sudo`, `ip`, `curl`, `ssh`, `jq` -- `dmsetup`, `losetup`, `blockdev` -- `e2cp`, `e2rm`, `debugfs` -- `mapdns` +- Core VM lifecycle: `sudo`, `ip`, `dmsetup`, `losetup`, `blockdev`, `truncate`, `pgrep`, `ps` +- Guest rootfs patching: `e2cp`, `e2rm`, `debugfs` +- Guest work disk creation/resizing: `mkfs.ext4`, `e2fsck`, `resize2fs`, `mount`, `umount`, `cp` +- SSH and logs: `ssh` +- DNS publishing: `mapdns` +- Optional NAT: `iptables`, `sysctl` +- Image build helper flow: `bash`, `curl`, `jq`, `sha256sum` + +`banger` validates these per command and returns actionable errors instead of +assuming one workstation layout. ## Runtime Bundle Runtime artifacts are no longer tracked directly in Git. Source checkouts use a @@ -16,9 +22,8 @@ generated `./runtime/` bundle, while installed binaries use The bundle contains: - `firecracker` -- `wtf/root/boot/vmlinux-6.8.0-94-generic` -- `wtf/root/boot/initrd.img-6.8.0-94-generic` -- `wtf/root/lib/modules/6.8.0-94-generic/` +- `bundle.json` with the bundle's default kernel/initrd/modules/rootfs paths +- a kernel, initrd, and modules tree referenced by `bundle.json` - `rootfs-docker.ext4` - `rootfs.ext4` when present - `packages.apt` @@ -112,9 +117,14 @@ the executable. Source-checkout binaries resolve it from `./runtime` next to the repo-built `./banger`. You can override either with `runtime_dir` in `~/.config/banger/config.toml` or `BANGER_RUNTIME_DIR`. +`mapdns` uses its own default data store unless you set `mapdns_data_file` or +`BANGER_MAPDNS_DATA_FILE`. + Useful config keys: - `runtime_dir` - `firecracker_bin` +- `mapdns_bin` +- `mapdns_data_file` - `ssh_key_path` - `namegen_path` - `customize_script` @@ -143,8 +153,8 @@ banger image delete docker-dev ``` `banger` auto-registers the bundled `default_rootfs` image when it exists. If -`rootfs.ext4` is not present in the bundle, `image build` falls back to using -`rootfs-docker.ext4` as its default base image. +the bundle does not include a separate base `rootfs.ext4`, `image build` falls +back to using `rootfs-docker.ext4` as its default base image. ## Networking And DNS Enable NAT when creating or updating a VM: @@ -184,7 +194,7 @@ is not available, pass an explicit `--base-rootfs` to `./make-rootfs.sh`. ## Maintaining The Runtime Bundle Maintain the checked-in manifest in [`runtime-bundle.toml`](/home/thales/projects/personal/banger/runtime-bundle.toml) -with the published bundle URL and SHA256. +with the published bundle URL, SHA256, and `bundle_metadata` defaults. Package a local `./runtime/` tree for publication: ```bash diff --git a/customize.sh b/customize.sh index 5171134..2d5be2c 100755 --- a/customize.sh +++ b/customize.sh @@ -46,11 +46,28 @@ STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/image-bu VM_ROOT="$STATE/vms" mkdir -p "$VM_ROOT" +BUNDLE_METADATA="$RUNTIME_DIR/bundle.json" + +bundle_path() { + local key="$1" + local fallback="$2" + local rel="" + + if [[ -f "$BUNDLE_METADATA" ]] && command -v jq >/dev/null 2>&1; then + rel="$(jq -r --arg key "$key" '.[$key] // empty' "$BUNDLE_METADATA" 2>/dev/null || true)" + fi + if [[ -n "$rel" && "$rel" != "null" ]]; then + printf '%s\n' "$RUNTIME_DIR/$rel" + return + fi + printf '%s\n' "$fallback" +} + BASE_ROOTFS="$RUNTIME_DIR/rootfs.ext4" FC_BIN="$RUNTIME_DIR/firecracker" -KERNEL="$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic" -INITRD="$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic" +KERNEL="$(bundle_path default_kernel "$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic")" +INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")" SSH_KEY="$RUNTIME_DIR/id_ed25519" NAT_SCRIPT="$RUNTIME_DIR/nat.sh" @@ -63,7 +80,7 @@ BASE_ROOTFS="" OUT_ROOTFS="" SIZE_SPEC="" INSTALL_DOCKER=0 -MODULES_DIR="$RUNTIME_DIR/wtf/root/lib/modules/6.8.0-94-generic" +MODULES_DIR="$(bundle_path default_modules_dir "$RUNTIME_DIR/wtf/root/lib/modules/6.8.0-94-generic")" PACKAGES_FILE="$(banger_packages_file)" while [[ $# -gt 0 ]]; do case "$1" in diff --git a/dns.sh b/dns.sh index 9438e44..34142ce 100644 --- a/dns.sh +++ b/dns.sh @@ -1,13 +1,17 @@ #!/usr/bin/env bash -MAPDNS_BIN="${MAPDNS_BIN:-mapdns}" -MAPDNS_DATA_FILE="/home/thales/.local/share/mapdns/records.json" +MAPDNS_BIN="${MAPDNS_BIN:-${BANGER_MAPDNS_BIN:-mapdns}}" +MAPDNS_DATA_FILE="${MAPDNS_DATA_FILE:-${BANGER_MAPDNS_DATA_FILE:-}}" banger_mapdns_cmd() { local subcommand="$1" shift - "$MAPDNS_BIN" "$subcommand" --data-file "$MAPDNS_DATA_FILE" "$@" + if [[ -n "$MAPDNS_DATA_FILE" ]]; then + "$MAPDNS_BIN" "$subcommand" --data-file "$MAPDNS_DATA_FILE" "$@" + return + fi + "$MAPDNS_BIN" "$subcommand" "$@" } banger_dns_name() { @@ -20,7 +24,9 @@ banger_dns_write_record() { local guest_ip="$2" local dns_name - mkdir -p "$(dirname "$MAPDNS_DATA_FILE")" + if [[ -n "$MAPDNS_DATA_FILE" ]]; then + mkdir -p "$(dirname "$MAPDNS_DATA_FILE")" + fi dns_name="$(banger_dns_name "$vm_name")" banger_mapdns_cmd set "$dns_name" "$guest_ip" >/dev/null } diff --git a/interactive.sh b/interactive.sh index e9c2fba..c7ea2e2 100755 --- a/interactive.sh +++ b/interactive.sh @@ -46,9 +46,26 @@ STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/interact VM_ROOT="$STATE/vms" mkdir -p "$VM_ROOT" +BUNDLE_METADATA="$RUNTIME_DIR/bundle.json" + +bundle_path() { + local key="$1" + local fallback="$2" + local rel="" + + if [[ -f "$BUNDLE_METADATA" ]] && command -v jq >/dev/null 2>&1; then + rel="$(jq -r --arg key "$key" '.[$key] // empty' "$BUNDLE_METADATA" 2>/dev/null || true)" + fi + if [[ -n "$rel" && "$rel" != "null" ]]; then + printf '%s\n' "$RUNTIME_DIR/$rel" + return + fi + printf '%s\n' "$fallback" +} + FC_BIN="$RUNTIME_DIR/firecracker" -KERNEL="$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic" -INITRD="$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic" +KERNEL="$(bundle_path default_kernel "$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic")" +INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")" SSH_KEY="$RUNTIME_DIR/id_ed25519" BR_DEV="br-fc" diff --git a/internal/cli/banger.go b/internal/cli/banger.go index f6a2c4f..98f4085 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -121,6 +121,7 @@ func newVMCommand() *cobra.Command { newVMShowCommand(), newVMActionCommand("start", "Start a VM", "vm.start"), newVMActionCommand("stop", "Stop a VM", "vm.stop"), + newVMKillCommand(), newVMActionCommand("restart", "Restart a VM", "vm.restart"), newVMActionCommand("delete", "Delete a VM", "vm.delete"), newVMSetCommand(), @@ -131,6 +132,35 @@ func newVMCommand() *cobra.Command { return cmd } +func newVMKillCommand() *cobra.Command { + var signal string + cmd := &cobra.Command{ + Use: "kill ", + Short: "Send a signal to a VM process", + Args: exactArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] "), + RunE: func(cmd *cobra.Command, args []string) error { + if err := system.EnsureSudo(cmd.Context()); err != nil { + return err + } + layout, _, err := ensureDaemon(cmd.Context()) + if err != nil { + return err + } + result, err := rpc.Call[api.VMShowResult]( + cmd.Context(), + layout.SocketPath, + "vm.kill", + api.VMKillParams{IDOrName: args[0], Signal: signal}, + ) + if err != nil { + return err + } + return printVMSummary(cmd.OutOrStdout(), result.VM) + }, + } + cmd.Flags().StringVar(&signal, "signal", "TERM", "signal name to send") + return cmd +} func newVMCreateCommand() *cobra.Command { var params api.VMCreateParams @@ -290,6 +320,9 @@ func newVMSSHCommand() *cobra.Command { if err != nil { return err } + if err := validateSSHPrereqs(cfg); err != nil { + return err + } result, err := rpc.Call[api.VMSSHResult](cmd.Context(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: args[0]}) if err != nil { return err @@ -643,6 +676,15 @@ func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]s return args, nil } +func validateSSHPrereqs(cfg model.DaemonConfig) error { + checks := system.NewPreflight() + checks.RequireCommand("ssh", "install openssh-client") + if strings.TrimSpace(cfg.SSHKeyPath) != "" { + checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or refresh the runtime bundle`) + } + return checks.Err("ssh preflight failed") +} + func absolutizeImageBuildPaths(params *api.ImageBuildParams) error { var err error for _, value := range []*string{¶ms.BaseRootfs, ¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir} { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0ff0537..d390ff5 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "banger/internal/api" @@ -39,6 +40,20 @@ func TestVMCreateFlagsExist(t *testing.T) { } } +func TestVMKillFlagsExist(t *testing.T) { + root := NewBangerCommand() + vm, _, err := root.Find([]string{"vm"}) + if err != nil { + t.Fatalf("find vm: %v", err) + } + kill, _, err := vm.Find([]string{"kill"}) + if err != nil { + t.Fatalf("find kill: %v", err) + } + if kill.Flags().Lookup("signal") == nil { + t.Fatal("missing signal flag") + } +} func TestVMSetParamsFromFlags(t *testing.T) { params, err := vmSetParamsFromFlags("devbox", 4, 2048, "16G", true, false) @@ -82,6 +97,25 @@ func TestSSHCommandArgs(t *testing.T) { } } +func TestValidateSSHPrereqs(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "id_ed25519") + if err := os.WriteFile(keyPath, []byte("key"), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + + if err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: keyPath}); err != nil { + t.Fatalf("validateSSHPrereqs: %v", err) + } +} + +func TestValidateSSHPrereqsFailsForMissingKey(t *testing.T) { + err := validateSSHPrereqs(model.DaemonConfig{SSHKeyPath: "/does/not/exist"}) + if err == nil || !strings.Contains(err.Error(), "ssh private key") { + t.Fatalf("validateSSHPrereqs() error = %v, want missing key", err) + } +} + func TestNewBangerdCommandRejectsArgs(t *testing.T) { cmd := NewBangerdCommand() cmd.SetArgs([]string{"extra"}) diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 9c56861..6f6e1f8 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -1155,6 +1155,9 @@ func deleteActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { func prepareSSHCmd(layout paths.Layout, cfg model.DaemonConfig, action actionRequest) tea.Cmd { return func() tea.Msg { + if err := validateSSHPrereqs(cfg); err != nil { + return externalPreparedMsg{action: action, err: err} + } result, err := rpc.Call[api.VMSSHResult](context.Background(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: action.id}) if err != nil { return externalPreparedMsg{action: action, err: err} diff --git a/internal/config/config.go b/internal/config/config.go index 8fda1d9..a437102 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "os" "path/filepath" "time" @@ -9,12 +10,15 @@ import ( "banger/internal/model" "banger/internal/paths" + "banger/internal/runtimebundle" ) type fileConfig struct { RuntimeDir string `toml:"runtime_dir"` RepoRoot string `toml:"repo_root"` FirecrackerBin string `toml:"firecracker_bin"` + MapDNSBin string `toml:"mapdns_bin"` + MapDNSDataFile string `toml:"mapdns_data_file"` SSHKeyPath string `toml:"ssh_key_path"` NamegenPath string `toml:"namegen_path"` CustomizeScript string `toml:"customize_script"` @@ -64,11 +68,19 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { } cfg.RuntimeDir = paths.ResolveRuntimeDir(file.RuntimeDir, file.RepoRoot) - applyRuntimeDefaults(&cfg) + if err := applyRuntimeDefaults(&cfg); err != nil { + return cfg, err + } if file.FirecrackerBin != "" { cfg.FirecrackerBin = file.FirecrackerBin } + if file.MapDNSBin != "" { + cfg.MapDNSBin = file.MapDNSBin + } + if file.MapDNSDataFile != "" { + cfg.MapDNSDataFile = file.MapDNSDataFile + } if file.SSHKeyPath != "" { cfg.SSHKeyPath = file.SSHKeyPath } @@ -132,21 +144,31 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { } cfg.MetricsPollInterval = duration } + if value := os.Getenv("BANGER_MAPDNS_BIN"); value != "" { + cfg.MapDNSBin = value + } + if value := os.Getenv("BANGER_MAPDNS_DATA_FILE"); value != "" { + cfg.MapDNSDataFile = value + } + if cfg.MapDNSBin == "" { + cfg.MapDNSBin = "mapdns" + } return cfg, nil } -func applyRuntimeDefaults(cfg *model.DaemonConfig) { +func applyRuntimeDefaults(cfg *model.DaemonConfig) error { if cfg.RuntimeDir == "" { - return + 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 } - 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.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") if cfg.DefaultRootfs == "" { cfg.DefaultRootfs = firstExistingRuntimePath( filepath.Join(cfg.RuntimeDir, "rootfs-docker.ext4"), @@ -159,10 +181,35 @@ func applyRuntimeDefaults(cfg *model.DaemonConfig) { 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.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.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 != "" { + if current != "" || relative == "" { return current } return filepath.Join(runtimeDir, relative) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1b8cbf0..cb5826f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,14 +1,97 @@ package config import ( + "encoding/json" "os" "path/filepath" "testing" "banger/internal/paths" + "banger/internal/runtimebundle" ) func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) { + runtimeDir := t.TempDir() + meta := runtimebundle.BundleMetadata{ + FirecrackerBin: "bin/firecracker", + SSHKeyPath: "keys/id_ed25519", + NamegenPath: "bin/namegen", + CustomizeScript: "scripts/customize.sh", + DefaultPackages: "config/packages.apt", + DefaultRootfs: "images/rootfs-docker.ext4", + DefaultKernel: "kernels/vmlinux", + DefaultInitrd: "kernels/initrd.img", + DefaultModulesDir: "modules/current", + } + for _, rel := range []string{ + meta.FirecrackerBin, + meta.SSHKeyPath, + meta.NamegenPath, + meta.CustomizeScript, + meta.DefaultPackages, + meta.DefaultRootfs, + meta.DefaultKernel, + meta.DefaultInitrd, + filepath.Join(meta.DefaultModulesDir, "modules.dep"), + } { + path := filepath.Join(runtimeDir, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + data, err := json.Marshal(meta) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(filepath.Join(runtimeDir, runtimebundle.BundleMetadataFile), data, 0o644); err != nil { + t.Fatalf("write bundle metadata: %v", err) + } + + t.Setenv("BANGER_RUNTIME_DIR", runtimeDir) + cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.RuntimeDir != runtimeDir { + t.Fatalf("RuntimeDir = %q, want %q", cfg.RuntimeDir, runtimeDir) + } + if cfg.FirecrackerBin != filepath.Join(runtimeDir, meta.FirecrackerBin) { + t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin) + } + if cfg.SSHKeyPath != filepath.Join(runtimeDir, meta.SSHKeyPath) { + t.Fatalf("SSHKeyPath = %q", cfg.SSHKeyPath) + } + if cfg.NamegenPath != filepath.Join(runtimeDir, meta.NamegenPath) { + t.Fatalf("NamegenPath = %q", cfg.NamegenPath) + } + if cfg.CustomizeScript != filepath.Join(runtimeDir, meta.CustomizeScript) { + t.Fatalf("CustomizeScript = %q", cfg.CustomizeScript) + } + if cfg.DefaultRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) { + t.Fatalf("DefaultRootfs = %q", cfg.DefaultRootfs) + } + if cfg.DefaultBaseRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) { + t.Fatalf("DefaultBaseRootfs = %q", cfg.DefaultBaseRootfs) + } + if cfg.DefaultKernel != filepath.Join(runtimeDir, meta.DefaultKernel) { + t.Fatalf("DefaultKernel = %q", cfg.DefaultKernel) + } + if cfg.DefaultInitrd != filepath.Join(runtimeDir, meta.DefaultInitrd) { + t.Fatalf("DefaultInitrd = %q", cfg.DefaultInitrd) + } + if cfg.DefaultModulesDir != filepath.Join(runtimeDir, meta.DefaultModulesDir) { + t.Fatalf("DefaultModulesDir = %q", cfg.DefaultModulesDir) + } + if cfg.DefaultPackagesFile != filepath.Join(runtimeDir, meta.DefaultPackages) { + t.Fatalf("DefaultPackagesFile = %q", cfg.DefaultPackagesFile) + } +} + +func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) { runtimeDir := t.TempDir() for _, rel := range []string{ "firecracker", @@ -36,37 +119,27 @@ func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) { t.Fatalf("Load: %v", err) } - if cfg.RuntimeDir != runtimeDir { - t.Fatalf("RuntimeDir = %q, want %q", cfg.RuntimeDir, runtimeDir) - } if cfg.FirecrackerBin != filepath.Join(runtimeDir, "firecracker") { t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin) } - if cfg.SSHKeyPath != filepath.Join(runtimeDir, "id_ed25519") { - t.Fatalf("SSHKeyPath = %q", cfg.SSHKeyPath) - } - if cfg.NamegenPath != filepath.Join(runtimeDir, "namegen") { - t.Fatalf("NamegenPath = %q", cfg.NamegenPath) - } - if cfg.CustomizeScript != filepath.Join(runtimeDir, "customize.sh") { - t.Fatalf("CustomizeScript = %q", cfg.CustomizeScript) - } - if cfg.DefaultRootfs != filepath.Join(runtimeDir, "rootfs-docker.ext4") { - t.Fatalf("DefaultRootfs = %q", cfg.DefaultRootfs) - } - if cfg.DefaultBaseRootfs != filepath.Join(runtimeDir, "rootfs-docker.ext4") { - t.Fatalf("DefaultBaseRootfs = %q", cfg.DefaultBaseRootfs) - } if cfg.DefaultKernel != filepath.Join(runtimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic") { t.Fatalf("DefaultKernel = %q", cfg.DefaultKernel) } - if cfg.DefaultInitrd != filepath.Join(runtimeDir, "wtf/root/boot/initrd.img-6.8.0-94-generic") { - t.Fatalf("DefaultInitrd = %q", cfg.DefaultInitrd) +} + +func TestLoadAppliesMapDNSEnvOverrides(t *testing.T) { + t.Setenv("BANGER_MAPDNS_BIN", "/opt/bin/mapdns") + t.Setenv("BANGER_MAPDNS_DATA_FILE", "/tmp/mapdns-records.json") + + cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) + if err != nil { + t.Fatalf("Load: %v", err) } - if cfg.DefaultModulesDir != filepath.Join(runtimeDir, "wtf/root/lib/modules/6.8.0-94-generic") { - t.Fatalf("DefaultModulesDir = %q", cfg.DefaultModulesDir) + + if cfg.MapDNSBin != "/opt/bin/mapdns" { + t.Fatalf("MapDNSBin = %q", cfg.MapDNSBin) } - if cfg.DefaultPackagesFile != filepath.Join(runtimeDir, "packages.apt") { - t.Fatalf("DefaultPackagesFile = %q", cfg.DefaultPackagesFile) + if cfg.MapDNSDataFile != "/tmp/mapdns-records.json" { + t.Fatalf("MapDNSDataFile = %q", cfg.MapDNSDataFile) } } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 35d09fd..1e962b8 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -52,3 +52,57 @@ func TestEnsureDefaultImageUsesConfiguredDefaultRootfs(t *testing.T) { t.Fatalf("KernelPath = %q, want %q", image.KernelPath, kernel) } } + +func TestSetDNSUsesConfiguredMapDNSDataFile(t *testing.T) { + t.Parallel() + + dataFile := filepath.Join(t.TempDir(), "mapdns", "records.json") + runner := &scriptedRunner{ + t: t, + steps: []runnerStep{ + { + call: runnerCall{ + name: "custom-mapdns", + args: []string{"set", "--data-file", dataFile, "devbox.vm", "172.16.0.8"}, + }, + }, + }, + } + d := &Daemon{ + runner: runner, + config: model.DaemonConfig{ + MapDNSBin: "custom-mapdns", + MapDNSDataFile: dataFile, + }, + } + + if err := d.setDNS(context.Background(), "devbox", "172.16.0.8"); err != nil { + t.Fatalf("setDNS: %v", err) + } + runner.assertExhausted() +} + +func TestSetDNSUsesMapDNSDefaultsWhenDataFileUnset(t *testing.T) { + t.Parallel() + + runner := &scriptedRunner{ + t: t, + steps: []runnerStep{ + { + call: runnerCall{ + name: "mapdns", + args: []string{"set", "devbox.vm", "172.16.0.8"}, + }, + }, + }, + } + d := &Daemon{ + runner: runner, + config: model.DaemonConfig{}, + } + + if err := d.setDNS(context.Background(), "devbox", "172.16.0.8"); err != nil { + t.Fatalf("setDNS: %v", err) + } + runner.assertExhausted() +} diff --git a/internal/daemon/images.go b/internal/daemon/images.go index e467e5a..0eecbed 100644 --- a/internal/daemon/images.go +++ b/internal/daemon/images.go @@ -75,6 +75,9 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (m if params.Docker { args = append(args, "--docker") } + if err := d.validateImageBuildPrereqs(ctx, baseRootfs, kernelPath, initrdPath, modulesDir); err != nil { + return model.Image{}, err + } cmd := exec.CommandContext(ctx, "bash", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -85,6 +88,12 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (m "BANGER_RUNTIME_DIR="+d.config.RuntimeDir, "BANGER_STATE_DIR="+filepath.Join(d.layout.StateDir, "image-build"), ) + if d.config.MapDNSBin != "" { + cmd.Env = append(cmd.Env, "BANGER_MAPDNS_BIN="+d.config.MapDNSBin) + } + if d.config.MapDNSDataFile != "" { + cmd.Env = append(cmd.Env, "BANGER_MAPDNS_DATA_FILE="+d.config.MapDNSDataFile) + } if err := cmd.Run(); err != nil { _ = os.RemoveAll(artifactDir) return model.Image{}, err diff --git a/internal/daemon/nat.go b/internal/daemon/nat.go index c0775a7..9781883 100644 --- a/internal/daemon/nat.go +++ b/internal/daemon/nat.go @@ -17,10 +17,7 @@ type natRule struct { } func (d *Daemon) ensureNAT(ctx context.Context, vm model.VMRecord, enable bool) error { - if err := system.RequireCommands(ctx, "iptables", "sysctl"); err != nil { - return err - } - uplink, err := d.defaultUplink(ctx) + uplink, err := d.validateNATPrereqs(ctx) if err != nil { return err } @@ -47,6 +44,16 @@ func (d *Daemon) ensureNAT(ctx context.Context, vm model.VMRecord, enable bool) return nil } +func (d *Daemon) validateNATPrereqs(ctx context.Context) (string, error) { + checks := system.NewPreflight() + checks.RequireCommand("ip", toolHint("ip")) + d.addNATPrereqs(ctx, checks) + if err := checks.Err("nat preflight failed"); err != nil { + return "", err + } + return d.defaultUplink(ctx) +} + func (d *Daemon) defaultUplink(ctx context.Context) (string, error) { out, err := d.runner.Run(ctx, "ip", "route", "show", "default") if err != nil { diff --git a/internal/daemon/preflight.go b/internal/daemon/preflight.go new file mode 100644 index 0000000..8441a0b --- /dev/null +++ b/internal/daemon/preflight.go @@ -0,0 +1,123 @@ +package daemon + +import ( + "context" + "os" + "path/filepath" + "strings" + + "banger/internal/model" + "banger/internal/paths" + "banger/internal/system" +) + +func (d *Daemon) validateStartPrereqs(ctx context.Context, vm model.VMRecord, image model.Image) error { + checks := system.NewPreflight() + hint := paths.RuntimeBundleHint() + + for _, command := range []string{"sudo", "ip", "dmsetup", "losetup", "blockdev", "truncate", "pgrep", "ps", "chown", "chmod", "kill", "e2cp", "e2rm", "debugfs"} { + checks.RequireCommand(command, toolHint(command)) + } + checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint) + checks.RequireExecutable(d.config.MapDNSBin, "mapdns binary", `install mapdns or set "mapdns_bin" / BANGER_MAPDNS_BIN`) + checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid image or rebuild the runtime bundle") + checks.RequireFile(image.KernelPath, "kernel image", `set "default_kernel" or refresh the runtime bundle`) + if strings.TrimSpace(image.InitrdPath) != "" { + checks.RequireFile(image.InitrdPath, "initrd image", `set "default_initrd" or refresh the runtime bundle`) + } + if !exists(vm.Runtime.WorkDiskPath) { + for _, command := range []string{"mkfs.ext4", "mount", "umount", "cp"} { + checks.RequireCommand(command, toolHint(command)) + } + } + if vm.Spec.NATEnabled { + d.addNATPrereqs(ctx, checks) + } + if dataFile := strings.TrimSpace(d.config.MapDNSDataFile); dataFile != "" { + parent := filepath.Dir(dataFile) + if parent != "." && parent != "" { + if _, err := os.Stat(parent); err != nil && !os.IsNotExist(err) { + checks.Addf("mapdns data directory %s is not accessible (%v)", parent, err) + } + } + } + return checks.Err("vm start preflight failed") +} + +func (d *Daemon) validateImageBuildPrereqs(ctx context.Context, baseRootfs, kernelPath, initrdPath, modulesDir string) error { + checks := system.NewPreflight() + hint := paths.RuntimeBundleHint() + + for _, command := range []string{"bash", "sudo", "ip", "curl", "ssh", "jq", "sha256sum", "e2fsck", "resize2fs"} { + checks.RequireCommand(command, toolHint(command)) + } + checks.RequireExecutable(d.config.CustomizeScript, "customize.sh helper", hint) + checks.RequireExecutable(d.config.MapDNSBin, "mapdns binary", `install mapdns or set "mapdns_bin" / BANGER_MAPDNS_BIN`) + checks.RequireFile(baseRootfs, "base rootfs image", `pass --base-rootfs or set "default_base_rootfs"`) + checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`) + if strings.TrimSpace(initrdPath) != "" { + checks.RequireFile(initrdPath, "initrd image", `pass --initrd or set "default_initrd"`) + } + if strings.TrimSpace(modulesDir) != "" { + checks.RequireDir(modulesDir, "modules directory", `pass --modules or set "default_modules_dir"`) + } + return checks.Err("image build preflight failed") +} + +func (d *Daemon) validateWorkDiskResizePrereqs() error { + checks := system.NewPreflight() + checks.RequireCommand("truncate", toolHint("truncate")) + checks.RequireCommand("e2fsck", `install e2fsprogs`) + checks.RequireCommand("resize2fs", `install e2fsprogs`) + return checks.Err("work disk resize preflight failed") +} + +func (d *Daemon) addNATPrereqs(ctx context.Context, checks *system.Preflight) { + checks.RequireCommand("iptables", toolHint("iptables")) + checks.RequireCommand("sysctl", toolHint("sysctl")) + out, err := d.runner.Run(ctx, "ip", "route", "show", "default") + if err != nil { + checks.Addf("failed to inspect the default route for NAT: %v", err) + return + } + if _, err := parseDefaultUplink(string(out)); err != nil { + checks.Addf("failed to detect the uplink interface for NAT: %v", err) + } +} + +func toolHint(command string) string { + switch command { + case "ip": + return "install iproute2" + case "iptables": + return "install iptables" + case "sysctl", "losetup", "blockdev", "mount", "umount": + return "install util-linux" + case "dmsetup": + return "install device-mapper" + case "pgrep", "ps", "kill": + return "install procps" + case "chown", "chmod", "cp", "truncate": + return "install coreutils" + case "e2fsck", "resize2fs", "debugfs", "mkfs.ext4": + return "install e2fsprogs" + case "e2cp", "e2rm": + return "install e2tools" + case "curl": + return "install curl" + case "jq": + return "install jq" + case "sha256sum": + return "install coreutils" + case "mapdns": + return `install mapdns or set "mapdns_bin" / BANGER_MAPDNS_BIN` + case "ssh": + return "install openssh-client" + case "bash": + return "install bash" + case "sudo": + return "install sudo" + default: + return "" + } +} diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index 87b9855..ea8f7c2 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -125,7 +125,7 @@ func (d *Daemon) StartVM(ctx context.Context, idOrName string) (model.VMRecord, } func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image model.Image) (model.VMRecord, error) { - if err := d.requireStartPrereqs(ctx); err != nil { + if err := d.validateStartPrereqs(ctx, vm, image); err != nil { return model.VMRecord{}, err } if err := os.MkdirAll(vm.Runtime.VMDir, 0o755); err != nil { @@ -389,6 +389,9 @@ func (d *Daemon) SetVM(ctx context.Context, params api.VMSetParams) (model.VMRec } if size > vm.Spec.WorkDiskSizeBytes { if exists(vm.Runtime.WorkDiskPath) { + if err := d.validateWorkDiskResizePrereqs(); err != nil { + return model.VMRecord{}, err + } if err := system.ResizeExt4Image(ctx, d.runner, vm.Runtime.WorkDiskPath, size); err != nil { return model.VMRecord{}, err } @@ -690,7 +693,12 @@ func clearRuntimeHandles(vm *model.VMRecord) { } func (d *Daemon) setDNS(ctx context.Context, vmName, guestIP string) error { - _, err := d.runner.Run(ctx, "mapdns", "set", "--data-file", "/home/thales/.local/share/mapdns/records.json", vmName+".vm", guestIP) + if dataFile := strings.TrimSpace(d.config.MapDNSDataFile); dataFile != "" { + if err := os.MkdirAll(filepath.Dir(dataFile), 0o755); err != nil { + return err + } + } + _, err := d.runner.Run(ctx, d.mapdnsBinary(), d.mapdnsArgs("set", vmName+".vm", guestIP)...) return err } @@ -698,7 +706,7 @@ func (d *Daemon) removeDNS(ctx context.Context, dnsName string) error { if dnsName == "" { return nil } - _, err := d.runner.Run(ctx, "mapdns", "rm", "--data-file", "/home/thales/.local/share/mapdns/records.json", dnsName) + _, err := d.runner.Run(ctx, d.mapdnsBinary(), d.mapdnsArgs("rm", dnsName)...) if err != nil && strings.Contains(err.Error(), "not found") { return nil } @@ -710,28 +718,6 @@ func (d *Daemon) killVMProcess(ctx context.Context, pid int) error { return err } -func (d *Daemon) requireStartPrereqs(ctx context.Context) error { - return system.RequireCommands( - ctx, - "sudo", - "ip", - "dmsetup", - "losetup", - "blockdev", - "e2cp", - "e2rm", - "debugfs", - "mkfs.ext4", - "truncate", - "pgrep", - "mount", - "umount", - "cp", - "ps", - "mapdns", - ) -} - func (d *Daemon) generateName(ctx context.Context) (string, error) { if exists(d.config.NamegenPath) { out, err := d.runner.Run(ctx, d.config.NamegenPath) @@ -759,3 +745,19 @@ func defaultInt(value, fallback int) int { } return fallback } + +func (d *Daemon) mapdnsBinary() string { + if value := strings.TrimSpace(d.config.MapDNSBin); value != "" { + return value + } + return "mapdns" +} + +func (d *Daemon) mapdnsArgs(subcommand string, args ...string) []string { + out := []string{subcommand} + if value := strings.TrimSpace(d.config.MapDNSDataFile); value != "" { + out = append(out, "--data-file", value) + } + out = append(out, args...) + return out +} diff --git a/internal/model/types.go b/internal/model/types.go index 53db8a5..70ff75b 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -37,6 +37,8 @@ const ( type DaemonConfig struct { RuntimeDir string FirecrackerBin string + MapDNSBin string + MapDNSDataFile string SSHKeyPath string NamegenPath string CustomizeScript string diff --git a/internal/paths/paths.go b/internal/paths/paths.go index eaa7202..0663730 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strconv" "strings" + + "banger/internal/runtimebundle" ) type Layout struct { @@ -100,6 +102,9 @@ func HasRuntimeBundle(dir string) bool { if strings.TrimSpace(dir) == "" { return false } + if _, err := runtimebundle.LoadBundleMetadata(dir); err == nil { + return true + } required := []string{ "firecracker", "customize.sh", diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go index f6a7631..9a0ba9e 100644 --- a/internal/paths/paths_test.go +++ b/internal/paths/paths_test.go @@ -1,9 +1,12 @@ package paths import ( + "encoding/json" "os" "path/filepath" "testing" + + "banger/internal/runtimebundle" ) func TestResolveRuntimeDirPrefersEnv(t *testing.T) { @@ -52,12 +55,23 @@ func TestResolveRuntimeDirUsesSourceCheckoutRuntimeSubdir(t *testing.T) { func createRuntimeBundle(t *testing.T, runtimeDir string) { t.Helper() + metadata := runtimebundle.BundleMetadata{ + FirecrackerBin: "bin/firecracker", + SSHKeyPath: "keys/id_ed25519", + NamegenPath: "bin/namegen", + CustomizeScript: "scripts/customize.sh", + DefaultPackages: "config/packages.apt", + DefaultRootfs: "images/rootfs-docker.ext4", + DefaultKernel: "kernels/vmlinux", + } for _, rel := range []string{ - "firecracker", - "customize.sh", - "packages.apt", - "rootfs-docker.ext4", - "wtf/root/boot/vmlinux-6.8.0-94-generic", + metadata.FirecrackerBin, + metadata.SSHKeyPath, + metadata.NamegenPath, + metadata.CustomizeScript, + metadata.DefaultPackages, + metadata.DefaultRootfs, + metadata.DefaultKernel, } { path := filepath.Join(runtimeDir, rel) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -67,4 +81,11 @@ func createRuntimeBundle(t *testing.T, runtimeDir string) { t.Fatalf("write %s: %v", path, err) } } + data, err := json.Marshal(metadata) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(filepath.Join(runtimeDir, runtimebundle.BundleMetadataFile), data, 0o644); err != nil { + t.Fatalf("write bundle metadata: %v", err) + } } diff --git a/internal/runtimebundle/bundle.go b/internal/runtimebundle/bundle.go index 0025c1e..1b2e906 100644 --- a/internal/runtimebundle/bundle.go +++ b/internal/runtimebundle/bundle.go @@ -6,6 +6,8 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" + "errors" "fmt" "io" "net/http" @@ -19,13 +21,29 @@ import ( ) type Manifest struct { - Version string `toml:"version"` - URL string `toml:"url"` - SHA256 string `toml:"sha256"` - BundleRoot string `toml:"bundle_root"` - RequiredPaths []string `toml:"required_paths"` + Version string `toml:"version"` + URL string `toml:"url"` + SHA256 string `toml:"sha256"` + BundleRoot string `toml:"bundle_root"` + RequiredPaths []string `toml:"required_paths"` + BundleMeta BundleMetadata `toml:"bundle_metadata"` } +type BundleMetadata struct { + FirecrackerBin string `json:"firecracker_bin" toml:"firecracker_bin"` + SSHKeyPath string `json:"ssh_key_path" toml:"ssh_key_path"` + NamegenPath string `json:"namegen_path" toml:"namegen_path"` + CustomizeScript string `json:"customize_script" toml:"customize_script"` + DefaultPackages string `json:"default_packages_file" toml:"default_packages_file"` + DefaultRootfs string `json:"default_rootfs" toml:"default_rootfs"` + DefaultBaseRootfs string `json:"default_base_rootfs,omitempty" toml:"default_base_rootfs"` + DefaultKernel string `json:"default_kernel" toml:"default_kernel"` + DefaultInitrd string `json:"default_initrd,omitempty" toml:"default_initrd"` + DefaultModulesDir string `json:"default_modules_dir,omitempty" toml:"default_modules_dir"` +} + +const BundleMetadataFile = "bundle.json" + func LoadManifest(path string) (Manifest, error) { data, err := os.ReadFile(path) if err != nil { @@ -38,6 +56,7 @@ func LoadManifest(path string) (Manifest, error) { manifest.BundleRoot = strings.TrimSpace(manifest.BundleRoot) manifest.URL = strings.TrimSpace(manifest.URL) manifest.SHA256 = strings.ToLower(strings.TrimSpace(manifest.SHA256)) + manifest.BundleMeta = normalizeBundleMetadata(manifest.BundleMeta) for i, required := range manifest.RequiredPaths { manifest.RequiredPaths[i] = filepath.Clean(strings.TrimSpace(required)) } @@ -91,6 +110,9 @@ func Bootstrap(ctx context.Context, manifest Manifest, manifestPath, outDir stri if err := ValidateBundle(bundleDir, manifest.RequiredPaths); err != nil { return err } + if _, err := LoadBundleMetadata(bundleDir); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } stageDir := filepath.Join(workDir, "stage") if err := os.Rename(bundleDir, stageDir); err != nil { @@ -122,6 +144,10 @@ func Package(runtimeDir, outArchive string, manifest Manifest) (string, error) { if err := ValidateBundle(runtimeDir, manifest.RequiredPaths); err != nil { return "", err } + metadata, err := metadataArchiveBytes(runtimeDir, manifest.BundleMeta) + if err != nil { + return "", err + } if err := os.MkdirAll(filepath.Dir(outArchive), 0o755); err != nil { return "", err } @@ -143,6 +169,11 @@ func Package(runtimeDir, outArchive string, manifest Manifest) (string, error) { return "", err } } + if len(metadata) != 0 { + if err := addBytesToArchive(tw, manifest.BundleRoot, BundleMetadataFile, metadata, 0o644); err != nil { + return "", err + } + } if err := tw.Close(); err != nil { return "", err } @@ -152,6 +183,115 @@ func Package(runtimeDir, outArchive string, manifest Manifest) (string, error) { return hex.EncodeToString(hash.Sum(nil)), nil } +func LoadBundleMetadata(runtimeDir string) (BundleMetadata, error) { + path := filepath.Join(runtimeDir, BundleMetadataFile) + data, err := os.ReadFile(path) + if err != nil { + return BundleMetadata{}, err + } + var meta BundleMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return BundleMetadata{}, fmt.Errorf("parse %s: %w", path, err) + } + meta = normalizeBundleMetadata(meta) + if err := validateBundleMetadata(runtimeDir, meta); err != nil { + return BundleMetadata{}, err + } + return meta, nil +} + +func validateBundleMetadata(runtimeDir string, meta BundleMetadata) error { + required := []struct { + value string + label string + }{ + {meta.FirecrackerBin, "firecracker_bin"}, + {meta.SSHKeyPath, "ssh_key_path"}, + {meta.NamegenPath, "namegen_path"}, + {meta.CustomizeScript, "customize_script"}, + {meta.DefaultPackages, "default_packages_file"}, + {meta.DefaultRootfs, "default_rootfs"}, + {meta.DefaultKernel, "default_kernel"}, + } + for _, field := range required { + if strings.TrimSpace(field.value) == "" { + return fmt.Errorf("runtime bundle metadata missing %s", field.label) + } + } + for _, field := range []struct { + value string + label string + required bool + }{ + {meta.FirecrackerBin, "firecracker_bin", true}, + {meta.SSHKeyPath, "ssh_key_path", true}, + {meta.NamegenPath, "namegen_path", true}, + {meta.CustomizeScript, "customize_script", true}, + {meta.DefaultPackages, "default_packages_file", true}, + {meta.DefaultRootfs, "default_rootfs", true}, + {meta.DefaultBaseRootfs, "default_base_rootfs", false}, + {meta.DefaultKernel, "default_kernel", true}, + {meta.DefaultInitrd, "default_initrd", false}, + {meta.DefaultModulesDir, "default_modules_dir", false}, + } { + if strings.TrimSpace(field.value) == "" { + continue + } + resolved, err := resolveMetadataPath(runtimeDir, field.value) + if err != nil { + return fmt.Errorf("runtime bundle metadata %s: %w", field.label, err) + } + if _, err := os.Stat(resolved); err != nil { + if field.required || !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("runtime bundle metadata %s points to missing path %s", field.label, resolved) + } + } + } + return nil +} + +func resolveMetadataPath(runtimeDir, rel string) (string, error) { + rel = filepath.Clean(strings.TrimSpace(rel)) + if rel == "." || rel == "" || filepath.IsAbs(rel) || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("invalid relative path %q", rel) + } + return filepath.Join(runtimeDir, rel), nil +} + +func metadataArchiveBytes(runtimeDir string, meta BundleMetadata) ([]byte, error) { + meta = normalizeBundleMetadata(meta) + if strings.TrimSpace(meta.FirecrackerBin) == "" && + strings.TrimSpace(meta.SSHKeyPath) == "" && + strings.TrimSpace(meta.NamegenPath) == "" && + strings.TrimSpace(meta.CustomizeScript) == "" && + strings.TrimSpace(meta.DefaultPackages) == "" && + strings.TrimSpace(meta.DefaultRootfs) == "" && + strings.TrimSpace(meta.DefaultBaseRootfs) == "" && + strings.TrimSpace(meta.DefaultKernel) == "" && + strings.TrimSpace(meta.DefaultInitrd) == "" && + strings.TrimSpace(meta.DefaultModulesDir) == "" { + return nil, nil + } + if err := validateBundleMetadata(runtimeDir, meta); err != nil { + return nil, err + } + return json.MarshalIndent(meta, "", " ") +} + +func normalizeBundleMetadata(meta BundleMetadata) BundleMetadata { + meta.FirecrackerBin = strings.TrimSpace(meta.FirecrackerBin) + meta.SSHKeyPath = strings.TrimSpace(meta.SSHKeyPath) + meta.NamegenPath = strings.TrimSpace(meta.NamegenPath) + meta.CustomizeScript = strings.TrimSpace(meta.CustomizeScript) + meta.DefaultPackages = strings.TrimSpace(meta.DefaultPackages) + meta.DefaultRootfs = strings.TrimSpace(meta.DefaultRootfs) + meta.DefaultBaseRootfs = strings.TrimSpace(meta.DefaultBaseRootfs) + meta.DefaultKernel = strings.TrimSpace(meta.DefaultKernel) + meta.DefaultInitrd = strings.TrimSpace(meta.DefaultInitrd) + meta.DefaultModulesDir = strings.TrimSpace(meta.DefaultModulesDir) + return meta +} + func addPathToArchive(tw *tar.Writer, runtimeDir, bundleRoot, rel string) error { srcPath := filepath.Join(runtimeDir, rel) info, err := os.Lstat(srcPath) @@ -201,6 +341,23 @@ func addPathToArchive(tw *tar.Writer, runtimeDir, bundleRoot, rel string) error return err } +func addBytesToArchive(tw *tar.Writer, bundleRoot, rel string, data []byte, mode int64) error { + name := rel + if bundleRoot != "" { + name = filepath.Join(bundleRoot, rel) + } + header := &tar.Header{ + Name: filepath.ToSlash(name), + Mode: mode, + Size: int64(len(data)), + } + if err := tw.WriteHeader(header); err != nil { + return err + } + _, err := tw.Write(data) + return err +} + func resolveSource(manifestDir, source string) string { parsed, err := url.Parse(source) if err == nil && parsed.Scheme != "" { diff --git a/internal/runtimebundle/bundle_test.go b/internal/runtimebundle/bundle_test.go index 22962e9..cc8affc 100644 --- a/internal/runtimebundle/bundle_test.go +++ b/internal/runtimebundle/bundle_test.go @@ -7,6 +7,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "os" "path/filepath" "strings" @@ -17,12 +18,18 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) { manifestDir := t.TempDir() bundleData := buildArchive(t, map[string]string{ "runtime/firecracker": "fc", + "runtime/id_ed25519": "key", + "runtime/namegen": "namegen", "runtime/customize.sh": "#!/bin/bash\n", + "runtime/packages.sh": "#!/bin/bash\n", + "runtime/dns.sh": "#!/bin/bash\n", + "runtime/nat.sh": "#!/bin/bash\n", "runtime/packages.apt": "vim\n", "runtime/rootfs-docker.ext4": "rootfs", "runtime/wtf/root/boot/vmlinux-6.8.0-94-generic": "kernel", "runtime/wtf/root/boot/initrd.img-6.8.0-94-generic": "initrd", "runtime/wtf/root/lib/modules/6.8.0-94-generic/modules.dep": "dep", + "runtime/bundle.json": mustJSON(t, BundleMetadata{FirecrackerBin: "firecracker", SSHKeyPath: "id_ed25519", NamegenPath: "namegen", CustomizeScript: "customize.sh", DefaultPackages: "packages.apt", DefaultRootfs: "rootfs-docker.ext4", DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic", DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic", DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic"}), }) archivePath := filepath.Join(manifestDir, "bundle.tar.gz") if err := os.WriteFile(archivePath, bundleData, 0o644); err != nil { @@ -68,6 +75,8 @@ func TestPackageWritesArchive(t *testing.T) { runtimeDir := t.TempDir() for _, rel := range []string{ "firecracker", + "id_ed25519", + "namegen", "customize.sh", "packages.apt", "rootfs-docker.ext4", @@ -94,8 +103,21 @@ func TestPackageWritesArchive(t *testing.T) { } manifest := Manifest{ BundleRoot: "runtime", + BundleMeta: BundleMetadata{ + FirecrackerBin: "firecracker", + SSHKeyPath: "id_ed25519", + NamegenPath: "namegen", + CustomizeScript: "customize.sh", + DefaultPackages: "packages.apt", + DefaultRootfs: "rootfs-docker.ext4", + DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic", + DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic", + DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic", + }, RequiredPaths: []string{ "firecracker", + "id_ed25519", + "namegen", "customize.sh", "packages.apt", "rootfs-docker.ext4", @@ -115,6 +137,53 @@ func TestPackageWritesArchive(t *testing.T) { if _, err := os.Stat(outArchive); err != nil { t.Fatalf("archive missing: %v", err) } + runtimeOut := filepath.Join(t.TempDir(), "runtime") + if err := Bootstrap(context.Background(), Manifest{ + URL: outArchive, + SHA256: sum, + BundleRoot: "runtime", + RequiredPaths: manifest.RequiredPaths, + }, filepath.Join(t.TempDir(), "runtime-bundle.toml"), runtimeOut); err != nil { + t.Fatalf("Bootstrap packaged archive: %v", err) + } + if _, err := os.Stat(filepath.Join(runtimeOut, BundleMetadataFile)); err != nil { + t.Fatalf("bundle metadata missing after bootstrap: %v", err) + } + meta, err := LoadBundleMetadata(runtimeOut) + if err != nil { + t.Fatalf("LoadBundleMetadata: %v", err) + } + if meta.DefaultRootfs != manifest.BundleMeta.DefaultRootfs { + t.Fatalf("DefaultRootfs = %q, want %q", meta.DefaultRootfs, manifest.BundleMeta.DefaultRootfs) + } +} + +func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) { + runtimeDir := t.TempDir() + for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "customize.sh", "packages.apt", "rootfs-docker.ext4"} { + path := filepath.Join(runtimeDir, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + } + data := mustJSON(t, BundleMetadata{ + FirecrackerBin: "firecracker", + SSHKeyPath: "id_ed25519", + NamegenPath: "namegen", + CustomizeScript: "customize.sh", + DefaultPackages: "packages.apt", + DefaultRootfs: "rootfs-docker.ext4", + DefaultKernel: "missing-kernel", + }) + if err := os.WriteFile(filepath.Join(runtimeDir, BundleMetadataFile), []byte(data), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if _, err := LoadBundleMetadata(runtimeDir); err == nil || !strings.Contains(err.Error(), "default_kernel") { + t.Fatalf("LoadBundleMetadata() error = %v, want default_kernel failure", err) + } } func buildArchive(t *testing.T, files map[string]string) []byte { @@ -148,3 +217,12 @@ func sha256Hex(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } + +func mustJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + return string(data) +} diff --git a/internal/system/preflight.go b/internal/system/preflight.go new file mode 100644 index 0000000..cb0e770 --- /dev/null +++ b/internal/system/preflight.go @@ -0,0 +1,112 @@ +package system + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type Preflight struct { + problems []string +} + +func NewPreflight() *Preflight { + return &Preflight{} +} + +func (p *Preflight) RequireCommand(name, hint string) { + value := strings.TrimSpace(name) + if value == "" { + p.add("command name is not configured%s", formatHint(hint)) + return + } + if _, err := exec.LookPath(value); err != nil { + p.add("required command %q not found%s", value, formatHint(hint)) + } +} + +func (p *Preflight) RequireExecutable(pathOrName, label, hint string) { + value := strings.TrimSpace(pathOrName) + if value == "" { + p.add("%s is not configured%s", label, formatHint(hint)) + return + } + if strings.ContainsRune(value, filepath.Separator) { + info, err := os.Stat(value) + if err != nil { + p.add("%s not found at %s%s", label, value, formatHint(hint)) + return + } + if info.IsDir() || info.Mode()&0o111 == 0 { + p.add("%s is not executable at %s%s", label, value, formatHint(hint)) + } + return + } + if _, err := exec.LookPath(value); err != nil { + p.add("missing %s %q%s", label, value, formatHint(hint)) + } +} + +func (p *Preflight) RequireFile(path, label, hint string) { + value := strings.TrimSpace(path) + if value == "" { + p.add("%s is not configured%s", label, formatHint(hint)) + return + } + info, err := os.Stat(value) + if err != nil { + p.add("%s not found at %s%s", label, value, formatHint(hint)) + return + } + if info.IsDir() { + p.add("%s expected a file at %s%s", label, value, formatHint(hint)) + } +} + +func (p *Preflight) RequireDir(path, label, hint string) { + value := strings.TrimSpace(path) + if value == "" { + p.add("%s is not configured%s", label, formatHint(hint)) + return + } + info, err := os.Stat(value) + if err != nil { + p.add("%s not found at %s%s", label, value, formatHint(hint)) + return + } + if !info.IsDir() { + p.add("%s expected a directory at %s%s", label, value, formatHint(hint)) + } +} + +func (p *Preflight) Addf(format string, args ...any) { + p.add(format, args...) +} + +func (p *Preflight) Err(prefix string) error { + if len(p.problems) == 0 { + return nil + } + var builder strings.Builder + builder.WriteString(strings.TrimSpace(prefix)) + for _, problem := range p.problems { + builder.WriteString("\n- ") + builder.WriteString(problem) + } + return errors.New(builder.String()) +} + +func (p *Preflight) add(format string, args ...any) { + p.problems = append(p.problems, fmt.Sprintf(format, args...)) +} + +func formatHint(hint string) string { + hint = strings.TrimSpace(hint) + if hint == "" { + return "" + } + return " (" + hint + ")" +} diff --git a/runtime-bundle.toml b/runtime-bundle.toml index 748823f..286404c 100644 --- a/runtime-bundle.toml +++ b/runtime-bundle.toml @@ -18,3 +18,14 @@ required_paths = [ "wtf/root/boot/initrd.img-6.8.0-94-generic", "wtf/root/lib/modules/6.8.0-94-generic", ] + +[bundle_metadata] +firecracker_bin = "firecracker" +ssh_key_path = "id_ed25519" +namegen_path = "namegen" +customize_script = "customize.sh" +default_packages_file = "packages.apt" +default_rootfs = "rootfs-docker.ext4" +default_kernel = "wtf/root/boot/vmlinux-6.8.0-94-generic" +default_initrd = "wtf/root/boot/initrd.img-6.8.0-94-generic" +default_modules_dir = "wtf/root/lib/modules/6.8.0-94-generic"