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:
parent
238bb8a020
commit
fcedacba5c
23 changed files with 927 additions and 96 deletions
|
|
@ -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.
|
||||
|
|
|
|||
2
Makefile
2
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
|
||||
|
||||
|
|
|
|||
30
README.md
30
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
|
||||
|
|
|
|||
23
customize.sh
23
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
|
||||
|
|
|
|||
10
dns.sh
10
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 <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 {
|
||||
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} {
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
123
internal/daemon/preflight.go
Normal file
123
internal/daemon/preflight.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ const (
|
|||
type DaemonConfig struct {
|
||||
RuntimeDir string
|
||||
FirecrackerBin string
|
||||
MapDNSBin string
|
||||
MapDNSDataFile string
|
||||
SSHKeyPath string
|
||||
NamegenPath string
|
||||
CustomizeScript string
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -24,8 +26,24 @@ type Manifest struct {
|
|||
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 != "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
112
internal/system/preflight.go
Normal file
112
internal/system/preflight.go
Normal 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 + ")"
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue