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.
This commit is contained in:
Thales Maciel 2026-03-16 15:30:08 -03:00
parent 238bb8a020
commit fcedacba5c
No known key found for this signature in database
GPG key ID: 33112E6833C34679
23 changed files with 927 additions and 96 deletions

View file

@ -4,12 +4,13 @@
- `cmd/banger` and `cmd/bangerd` are the primary user-facing entrypoints. - `cmd/banger` and `cmd/bangerd` are the primary user-facing entrypoints.
- `internal/` contains the daemon, CLI, RPC, storage, Firecracker, and system integration code. - `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. - `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. - The daemon keeps state under XDG directories rather than the old repo-local `state/` layout.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `make build` builds `./banger` and `./bangerd`. - `make build` builds `./banger` and `./bangerd`.
- `make runtime-bundle` bootstraps `./runtime/` from `runtime-bundle.toml`. - `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 create --name testbox` creates and starts a VM.
- `./banger vm ssh testbox` connects to a running guest. - `./banger vm ssh testbox` connects to a running guest.
- `./banger vm stop testbox` stops a VM while preserving its disks. - `./banger vm stop testbox` stops a VM while preserving its disks.

View file

@ -15,7 +15,7 @@ BINARIES := banger bangerd
GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort) GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort)
RUNTIME_EXECUTABLES := firecracker customize.sh dns.sh packages.sh nat.sh namegen RUNTIME_EXECUTABLES := firecracker customize.sh dns.sh packages.sh nat.sh namegen
RUNTIME_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4 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_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 RUNTIME_MODULES_DIR := wtf/root/lib/modules/6.8.0-94-generic

View file

@ -4,10 +4,16 @@ Persistent Firecracker development VMs managed through a Go daemon, CLI, and TUI
## Requirements ## Requirements
- Linux host with KVM (`/dev/kvm` access) - Linux host with KVM (`/dev/kvm` access)
- `sudo`, `ip`, `curl`, `ssh`, `jq` - Core VM lifecycle: `sudo`, `ip`, `dmsetup`, `losetup`, `blockdev`, `truncate`, `pgrep`, `ps`
- `dmsetup`, `losetup`, `blockdev` - Guest rootfs patching: `e2cp`, `e2rm`, `debugfs`
- `e2cp`, `e2rm`, `debugfs` - Guest work disk creation/resizing: `mkfs.ext4`, `e2fsck`, `resize2fs`, `mount`, `umount`, `cp`
- `mapdns` - 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 Bundle
Runtime artifacts are no longer tracked directly in Git. Source checkouts use a 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: The bundle contains:
- `firecracker` - `firecracker`
- `wtf/root/boot/vmlinux-6.8.0-94-generic` - `bundle.json` with the bundle's default kernel/initrd/modules/rootfs paths
- `wtf/root/boot/initrd.img-6.8.0-94-generic` - a kernel, initrd, and modules tree referenced by `bundle.json`
- `wtf/root/lib/modules/6.8.0-94-generic/`
- `rootfs-docker.ext4` - `rootfs-docker.ext4`
- `rootfs.ext4` when present - `rootfs.ext4` when present
- `packages.apt` - `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 repo-built `./banger`. You can override either with `runtime_dir` in
`~/.config/banger/config.toml` or `BANGER_RUNTIME_DIR`. `~/.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: Useful config keys:
- `runtime_dir` - `runtime_dir`
- `firecracker_bin` - `firecracker_bin`
- `mapdns_bin`
- `mapdns_data_file`
- `ssh_key_path` - `ssh_key_path`
- `namegen_path` - `namegen_path`
- `customize_script` - `customize_script`
@ -143,8 +153,8 @@ banger image delete docker-dev
``` ```
`banger` auto-registers the bundled `default_rootfs` image when it exists. If `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 the bundle does not include a separate base `rootfs.ext4`, `image build` falls
`rootfs-docker.ext4` as its default base image. back to using `rootfs-docker.ext4` as its default base image.
## Networking And DNS ## Networking And DNS
Enable NAT when creating or updating a VM: 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 ## Maintaining The Runtime Bundle
Maintain the checked-in manifest in [`runtime-bundle.toml`](/home/thales/projects/personal/banger/runtime-bundle.toml) 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: Package a local `./runtime/` tree for publication:
```bash ```bash

View file

@ -46,11 +46,28 @@ STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/image-bu
VM_ROOT="$STATE/vms" VM_ROOT="$STATE/vms"
mkdir -p "$VM_ROOT" 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" BASE_ROOTFS="$RUNTIME_DIR/rootfs.ext4"
FC_BIN="$RUNTIME_DIR/firecracker" FC_BIN="$RUNTIME_DIR/firecracker"
KERNEL="$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic" KERNEL="$(bundle_path default_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" INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")"
SSH_KEY="$RUNTIME_DIR/id_ed25519" SSH_KEY="$RUNTIME_DIR/id_ed25519"
NAT_SCRIPT="$RUNTIME_DIR/nat.sh" NAT_SCRIPT="$RUNTIME_DIR/nat.sh"
@ -63,7 +80,7 @@ BASE_ROOTFS=""
OUT_ROOTFS="" OUT_ROOTFS=""
SIZE_SPEC="" SIZE_SPEC=""
INSTALL_DOCKER=0 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)" PACKAGES_FILE="$(banger_packages_file)"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in

14
dns.sh
View file

@ -1,13 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
MAPDNS_BIN="${MAPDNS_BIN:-mapdns}" MAPDNS_BIN="${MAPDNS_BIN:-${BANGER_MAPDNS_BIN:-mapdns}}"
MAPDNS_DATA_FILE="/home/thales/.local/share/mapdns/records.json" MAPDNS_DATA_FILE="${MAPDNS_DATA_FILE:-${BANGER_MAPDNS_DATA_FILE:-}}"
banger_mapdns_cmd() { banger_mapdns_cmd() {
local subcommand="$1" local subcommand="$1"
shift 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() { banger_dns_name() {
@ -20,7 +24,9 @@ banger_dns_write_record() {
local guest_ip="$2" local guest_ip="$2"
local dns_name 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")" dns_name="$(banger_dns_name "$vm_name")"
banger_mapdns_cmd set "$dns_name" "$guest_ip" >/dev/null banger_mapdns_cmd set "$dns_name" "$guest_ip" >/dev/null
} }

View file

@ -46,9 +46,26 @@ STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/interact
VM_ROOT="$STATE/vms" VM_ROOT="$STATE/vms"
mkdir -p "$VM_ROOT" 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" FC_BIN="$RUNTIME_DIR/firecracker"
KERNEL="$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic" KERNEL="$(bundle_path default_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" INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")"
SSH_KEY="$RUNTIME_DIR/id_ed25519" SSH_KEY="$RUNTIME_DIR/id_ed25519"
BR_DEV="br-fc" BR_DEV="br-fc"

View file

@ -121,6 +121,7 @@ func newVMCommand() *cobra.Command {
newVMShowCommand(), newVMShowCommand(),
newVMActionCommand("start", "Start a VM", "vm.start"), newVMActionCommand("start", "Start a VM", "vm.start"),
newVMActionCommand("stop", "Stop a VM", "vm.stop"), newVMActionCommand("stop", "Stop a VM", "vm.stop"),
newVMKillCommand(),
newVMActionCommand("restart", "Restart a VM", "vm.restart"), newVMActionCommand("restart", "Restart a VM", "vm.restart"),
newVMActionCommand("delete", "Delete a VM", "vm.delete"), newVMActionCommand("delete", "Delete a VM", "vm.delete"),
newVMSetCommand(), newVMSetCommand(),
@ -131,6 +132,35 @@ func newVMCommand() *cobra.Command {
return cmd return cmd
} }
func newVMKillCommand() *cobra.Command {
var signal string
cmd := &cobra.Command{
Use: "kill <id-or-name>",
Short: "Send a signal to a VM process",
Args: exactArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>"),
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 { func newVMCreateCommand() *cobra.Command {
var params api.VMCreateParams var params api.VMCreateParams
@ -290,6 +320,9 @@ func newVMSSHCommand() *cobra.Command {
if err != nil { if err != nil {
return err 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]}) result, err := rpc.Call[api.VMSSHResult](cmd.Context(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: args[0]})
if err != nil { if err != nil {
return err return err
@ -643,6 +676,15 @@ func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]s
return args, nil 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 { func absolutizeImageBuildPaths(params *api.ImageBuildParams) error {
var err error var err error
for _, value := range []*string{&params.BaseRootfs, &params.KernelPath, &params.InitrdPath, &params.ModulesDir} { for _, value := range []*string{&params.BaseRootfs, &params.KernelPath, &params.InitrdPath, &params.ModulesDir} {

View file

@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings"
"testing" "testing"
"banger/internal/api" "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) { func TestVMSetParamsFromFlags(t *testing.T) {
params, err := vmSetParamsFromFlags("devbox", 4, 2048, "16G", true, false) 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) { func TestNewBangerdCommandRejectsArgs(t *testing.T) {
cmd := NewBangerdCommand() cmd := NewBangerdCommand()
cmd.SetArgs([]string{"extra"}) cmd.SetArgs([]string{"extra"})

View file

@ -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 { func prepareSSHCmd(layout paths.Layout, cfg model.DaemonConfig, action actionRequest) tea.Cmd {
return func() tea.Msg { 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}) result, err := rpc.Call[api.VMSSHResult](context.Background(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: action.id})
if err != nil { if err != nil {
return externalPreparedMsg{action: action, err: err} return externalPreparedMsg{action: action, err: err}

View file

@ -1,6 +1,7 @@
package config package config
import ( import (
"errors"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@ -9,12 +10,15 @@ import (
"banger/internal/model" "banger/internal/model"
"banger/internal/paths" "banger/internal/paths"
"banger/internal/runtimebundle"
) )
type fileConfig struct { type fileConfig struct {
RuntimeDir string `toml:"runtime_dir"` RuntimeDir string `toml:"runtime_dir"`
RepoRoot string `toml:"repo_root"` RepoRoot string `toml:"repo_root"`
FirecrackerBin string `toml:"firecracker_bin"` FirecrackerBin string `toml:"firecracker_bin"`
MapDNSBin string `toml:"mapdns_bin"`
MapDNSDataFile string `toml:"mapdns_data_file"`
SSHKeyPath string `toml:"ssh_key_path"` SSHKeyPath string `toml:"ssh_key_path"`
NamegenPath string `toml:"namegen_path"` NamegenPath string `toml:"namegen_path"`
CustomizeScript string `toml:"customize_script"` 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) cfg.RuntimeDir = paths.ResolveRuntimeDir(file.RuntimeDir, file.RepoRoot)
applyRuntimeDefaults(&cfg) if err := applyRuntimeDefaults(&cfg); err != nil {
return cfg, err
}
if file.FirecrackerBin != "" { if file.FirecrackerBin != "" {
cfg.FirecrackerBin = file.FirecrackerBin cfg.FirecrackerBin = file.FirecrackerBin
} }
if file.MapDNSBin != "" {
cfg.MapDNSBin = file.MapDNSBin
}
if file.MapDNSDataFile != "" {
cfg.MapDNSDataFile = file.MapDNSDataFile
}
if file.SSHKeyPath != "" { if file.SSHKeyPath != "" {
cfg.SSHKeyPath = file.SSHKeyPath cfg.SSHKeyPath = file.SSHKeyPath
} }
@ -132,21 +144,31 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) {
} }
cfg.MetricsPollInterval = duration 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 return cfg, nil
} }
func applyRuntimeDefaults(cfg *model.DaemonConfig) { func applyRuntimeDefaults(cfg *model.DaemonConfig) error {
if cfg.RuntimeDir == "" { 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 == "" { if cfg.DefaultRootfs == "" {
cfg.DefaultRootfs = firstExistingRuntimePath( cfg.DefaultRootfs = firstExistingRuntimePath(
filepath.Join(cfg.RuntimeDir, "rootfs-docker.ext4"), filepath.Join(cfg.RuntimeDir, "rootfs-docker.ext4"),
@ -159,10 +181,35 @@ func applyRuntimeDefaults(cfg *model.DaemonConfig) {
cfg.DefaultRootfs, 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 { func defaultRuntimePath(current, runtimeDir, relative string) string {
if current != "" { if current != "" || relative == "" {
return current return current
} }
return filepath.Join(runtimeDir, relative) return filepath.Join(runtimeDir, relative)

View file

@ -1,14 +1,97 @@
package config package config
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"banger/internal/paths" "banger/internal/paths"
"banger/internal/runtimebundle"
) )
func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) { 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() runtimeDir := t.TempDir()
for _, rel := range []string{ for _, rel := range []string{
"firecracker", "firecracker",
@ -36,37 +119,27 @@ func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) {
t.Fatalf("Load: %v", err) 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") { if cfg.FirecrackerBin != filepath.Join(runtimeDir, "firecracker") {
t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin) 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") { if cfg.DefaultKernel != filepath.Join(runtimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic") {
t.Fatalf("DefaultKernel = %q", cfg.DefaultKernel) 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") { if cfg.MapDNSDataFile != "/tmp/mapdns-records.json" {
t.Fatalf("DefaultPackagesFile = %q", cfg.DefaultPackagesFile) t.Fatalf("MapDNSDataFile = %q", cfg.MapDNSDataFile)
} }
} }

View file

@ -52,3 +52,57 @@ func TestEnsureDefaultImageUsesConfiguredDefaultRootfs(t *testing.T) {
t.Fatalf("KernelPath = %q, want %q", image.KernelPath, kernel) 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()
}

View file

@ -75,6 +75,9 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (m
if params.Docker { if params.Docker {
args = append(args, "--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 := exec.CommandContext(ctx, "bash", args...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr 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_RUNTIME_DIR="+d.config.RuntimeDir,
"BANGER_STATE_DIR="+filepath.Join(d.layout.StateDir, "image-build"), "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 { if err := cmd.Run(); err != nil {
_ = os.RemoveAll(artifactDir) _ = os.RemoveAll(artifactDir)
return model.Image{}, err return model.Image{}, err

View file

@ -17,10 +17,7 @@ type natRule struct {
} }
func (d *Daemon) ensureNAT(ctx context.Context, vm model.VMRecord, enable bool) error { func (d *Daemon) ensureNAT(ctx context.Context, vm model.VMRecord, enable bool) error {
if err := system.RequireCommands(ctx, "iptables", "sysctl"); err != nil { uplink, err := d.validateNATPrereqs(ctx)
return err
}
uplink, err := d.defaultUplink(ctx)
if err != nil { if err != nil {
return err return err
} }
@ -47,6 +44,16 @@ func (d *Daemon) ensureNAT(ctx context.Context, vm model.VMRecord, enable bool)
return nil 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) { func (d *Daemon) defaultUplink(ctx context.Context) (string, error) {
out, err := d.runner.Run(ctx, "ip", "route", "show", "default") out, err := d.runner.Run(ctx, "ip", "route", "show", "default")
if err != nil { if err != nil {

View file

@ -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 ""
}
}

View file

@ -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) { 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 return model.VMRecord{}, err
} }
if err := os.MkdirAll(vm.Runtime.VMDir, 0o755); err != nil { 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 size > vm.Spec.WorkDiskSizeBytes {
if exists(vm.Runtime.WorkDiskPath) { 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 { if err := system.ResizeExt4Image(ctx, d.runner, vm.Runtime.WorkDiskPath, size); err != nil {
return model.VMRecord{}, err return model.VMRecord{}, err
} }
@ -690,7 +693,12 @@ func clearRuntimeHandles(vm *model.VMRecord) {
} }
func (d *Daemon) setDNS(ctx context.Context, vmName, guestIP string) error { 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 return err
} }
@ -698,7 +706,7 @@ func (d *Daemon) removeDNS(ctx context.Context, dnsName string) error {
if dnsName == "" { if dnsName == "" {
return nil 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") { if err != nil && strings.Contains(err.Error(), "not found") {
return nil return nil
} }
@ -710,28 +718,6 @@ func (d *Daemon) killVMProcess(ctx context.Context, pid int) error {
return err 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) { func (d *Daemon) generateName(ctx context.Context) (string, error) {
if exists(d.config.NamegenPath) { if exists(d.config.NamegenPath) {
out, err := d.runner.Run(ctx, d.config.NamegenPath) out, err := d.runner.Run(ctx, d.config.NamegenPath)
@ -759,3 +745,19 @@ func defaultInt(value, fallback int) int {
} }
return fallback 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
}

View file

@ -37,6 +37,8 @@ const (
type DaemonConfig struct { type DaemonConfig struct {
RuntimeDir string RuntimeDir string
FirecrackerBin string FirecrackerBin string
MapDNSBin string
MapDNSDataFile string
SSHKeyPath string SSHKeyPath string
NamegenPath string NamegenPath string
CustomizeScript string CustomizeScript string

View file

@ -7,6 +7,8 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"banger/internal/runtimebundle"
) )
type Layout struct { type Layout struct {
@ -100,6 +102,9 @@ func HasRuntimeBundle(dir string) bool {
if strings.TrimSpace(dir) == "" { if strings.TrimSpace(dir) == "" {
return false return false
} }
if _, err := runtimebundle.LoadBundleMetadata(dir); err == nil {
return true
}
required := []string{ required := []string{
"firecracker", "firecracker",
"customize.sh", "customize.sh",

View file

@ -1,9 +1,12 @@
package paths package paths
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"banger/internal/runtimebundle"
) )
func TestResolveRuntimeDirPrefersEnv(t *testing.T) { func TestResolveRuntimeDirPrefersEnv(t *testing.T) {
@ -52,12 +55,23 @@ func TestResolveRuntimeDirUsesSourceCheckoutRuntimeSubdir(t *testing.T) {
func createRuntimeBundle(t *testing.T, runtimeDir string) { func createRuntimeBundle(t *testing.T, runtimeDir string) {
t.Helper() 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{ for _, rel := range []string{
"firecracker", metadata.FirecrackerBin,
"customize.sh", metadata.SSHKeyPath,
"packages.apt", metadata.NamegenPath,
"rootfs-docker.ext4", metadata.CustomizeScript,
"wtf/root/boot/vmlinux-6.8.0-94-generic", metadata.DefaultPackages,
metadata.DefaultRootfs,
metadata.DefaultKernel,
} { } {
path := filepath.Join(runtimeDir, rel) path := filepath.Join(runtimeDir, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 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) 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)
}
} }

View file

@ -6,6 +6,8 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -19,13 +21,29 @@ import (
) )
type Manifest struct { type Manifest struct {
Version string `toml:"version"` Version string `toml:"version"`
URL string `toml:"url"` URL string `toml:"url"`
SHA256 string `toml:"sha256"` SHA256 string `toml:"sha256"`
BundleRoot string `toml:"bundle_root"` BundleRoot string `toml:"bundle_root"`
RequiredPaths []string `toml:"required_paths"` 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) { func LoadManifest(path string) (Manifest, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@ -38,6 +56,7 @@ func LoadManifest(path string) (Manifest, error) {
manifest.BundleRoot = strings.TrimSpace(manifest.BundleRoot) manifest.BundleRoot = strings.TrimSpace(manifest.BundleRoot)
manifest.URL = strings.TrimSpace(manifest.URL) manifest.URL = strings.TrimSpace(manifest.URL)
manifest.SHA256 = strings.ToLower(strings.TrimSpace(manifest.SHA256)) manifest.SHA256 = strings.ToLower(strings.TrimSpace(manifest.SHA256))
manifest.BundleMeta = normalizeBundleMetadata(manifest.BundleMeta)
for i, required := range manifest.RequiredPaths { for i, required := range manifest.RequiredPaths {
manifest.RequiredPaths[i] = filepath.Clean(strings.TrimSpace(required)) 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 { if err := ValidateBundle(bundleDir, manifest.RequiredPaths); err != nil {
return err return err
} }
if _, err := LoadBundleMetadata(bundleDir); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
stageDir := filepath.Join(workDir, "stage") stageDir := filepath.Join(workDir, "stage")
if err := os.Rename(bundleDir, stageDir); err != nil { 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 { if err := ValidateBundle(runtimeDir, manifest.RequiredPaths); err != nil {
return "", err return "", err
} }
metadata, err := metadataArchiveBytes(runtimeDir, manifest.BundleMeta)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(outArchive), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(outArchive), 0o755); err != nil {
return "", err return "", err
} }
@ -143,6 +169,11 @@ func Package(runtimeDir, outArchive string, manifest Manifest) (string, error) {
return "", err 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 { if err := tw.Close(); err != nil {
return "", err return "", err
} }
@ -152,6 +183,115 @@ func Package(runtimeDir, outArchive string, manifest Manifest) (string, error) {
return hex.EncodeToString(hash.Sum(nil)), nil 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 { func addPathToArchive(tw *tar.Writer, runtimeDir, bundleRoot, rel string) error {
srcPath := filepath.Join(runtimeDir, rel) srcPath := filepath.Join(runtimeDir, rel)
info, err := os.Lstat(srcPath) info, err := os.Lstat(srcPath)
@ -201,6 +341,23 @@ func addPathToArchive(tw *tar.Writer, runtimeDir, bundleRoot, rel string) error
return err 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 { func resolveSource(manifestDir, source string) string {
parsed, err := url.Parse(source) parsed, err := url.Parse(source)
if err == nil && parsed.Scheme != "" { if err == nil && parsed.Scheme != "" {

View file

@ -7,6 +7,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -17,12 +18,18 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) {
manifestDir := t.TempDir() manifestDir := t.TempDir()
bundleData := buildArchive(t, map[string]string{ bundleData := buildArchive(t, map[string]string{
"runtime/firecracker": "fc", "runtime/firecracker": "fc",
"runtime/id_ed25519": "key",
"runtime/namegen": "namegen",
"runtime/customize.sh": "#!/bin/bash\n", "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/packages.apt": "vim\n",
"runtime/rootfs-docker.ext4": "rootfs", "runtime/rootfs-docker.ext4": "rootfs",
"runtime/wtf/root/boot/vmlinux-6.8.0-94-generic": "kernel", "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/boot/initrd.img-6.8.0-94-generic": "initrd",
"runtime/wtf/root/lib/modules/6.8.0-94-generic/modules.dep": "dep", "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") archivePath := filepath.Join(manifestDir, "bundle.tar.gz")
if err := os.WriteFile(archivePath, bundleData, 0o644); err != nil { if err := os.WriteFile(archivePath, bundleData, 0o644); err != nil {
@ -68,6 +75,8 @@ func TestPackageWritesArchive(t *testing.T) {
runtimeDir := t.TempDir() runtimeDir := t.TempDir()
for _, rel := range []string{ for _, rel := range []string{
"firecracker", "firecracker",
"id_ed25519",
"namegen",
"customize.sh", "customize.sh",
"packages.apt", "packages.apt",
"rootfs-docker.ext4", "rootfs-docker.ext4",
@ -94,8 +103,21 @@ func TestPackageWritesArchive(t *testing.T) {
} }
manifest := Manifest{ manifest := Manifest{
BundleRoot: "runtime", 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{ RequiredPaths: []string{
"firecracker", "firecracker",
"id_ed25519",
"namegen",
"customize.sh", "customize.sh",
"packages.apt", "packages.apt",
"rootfs-docker.ext4", "rootfs-docker.ext4",
@ -115,6 +137,53 @@ func TestPackageWritesArchive(t *testing.T) {
if _, err := os.Stat(outArchive); err != nil { if _, err := os.Stat(outArchive); err != nil {
t.Fatalf("archive missing: %v", err) 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 { func buildArchive(t *testing.T, files map[string]string) []byte {
@ -148,3 +217,12 @@ func sha256Hex(data []byte) string {
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]) 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)
}

View file

@ -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 + ")"
}

View file

@ -18,3 +18,14 @@ required_paths = [
"wtf/root/boot/initrd.img-6.8.0-94-generic", "wtf/root/boot/initrd.img-6.8.0-94-generic",
"wtf/root/lib/modules/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"