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"