diff --git a/Makefile b/Makefile index de93ce2..e2fadc4 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,15 @@ GOFMT ?= gofmt INSTALL ?= install PREFIX ?= $(HOME)/.local BINDIR ?= $(PREFIX)/bin +LIBDIR ?= $(PREFIX)/lib +RUNTIMEDIR ?= $(LIBDIR)/banger DESTDIR ?= 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 $(wildcard rootfs.ext4) $(wildcard rootfs-docker.ext4) +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 .DEFAULT_GOAL := help @@ -17,12 +23,12 @@ help: @printf '%s\n' \ 'Targets:' \ ' make build Build ./banger and ./bangerd' \ - ' make install Build and install binaries into $(DESTDIR)$(BINDIR)' \ + ' make install Build and install binaries plus the runtime bundle into $(DESTDIR)$(BINDIR) and $(DESTDIR)$(RUNTIMEDIR)' \ ' make test Run go test ./...' \ ' make fmt Format Go sources under cmd/ and internal/' \ ' make tidy Run go mod tidy' \ ' make clean Remove built Go binaries' \ - ' make rootfs Run ./make-rootfs.sh' + ' make rootfs Rebuild the repo-local default rootfs image' build: $(BINARIES) @@ -45,9 +51,24 @@ clean: rm -f ./banger ./bangerd install: build + @for path in $(RUNTIME_EXECUTABLES) $(RUNTIME_BOOT_FILES) $(RUNTIME_MODULES_DIR) packages.apt id_ed25519; do \ + test -e "$$path" || { echo "missing runtime artifact: $$path" >&2; exit 1; }; \ + done + @test -e rootfs-docker.ext4 || test -e rootfs.ext4 || { echo "missing runtime artifact: rootfs-docker.ext4 or rootfs.ext4" >&2; exit 1; } mkdir -p "$(DESTDIR)$(BINDIR)" + mkdir -p "$(DESTDIR)$(RUNTIMEDIR)" + mkdir -p "$(DESTDIR)$(RUNTIMEDIR)/wtf/root/boot" + mkdir -p "$(DESTDIR)$(RUNTIMEDIR)/wtf/root/lib/modules" $(INSTALL) -m 0755 ./banger "$(DESTDIR)$(BINDIR)/banger" $(INSTALL) -m 0755 ./bangerd "$(DESTDIR)$(BINDIR)/bangerd" + @for path in $(RUNTIME_EXECUTABLES); do \ + $(INSTALL) -m 0755 "$$path" "$(DESTDIR)$(RUNTIMEDIR)/$$path"; \ + done + @for path in $(RUNTIME_DATA_FILES) $(RUNTIME_BOOT_FILES); do \ + $(INSTALL) -m 0644 "$$path" "$(DESTDIR)$(RUNTIMEDIR)/$$path"; \ + done + $(INSTALL) -m 0600 id_ed25519 "$(DESTDIR)$(RUNTIMEDIR)/id_ed25519" + cp -a "$(RUNTIME_MODULES_DIR)" "$(DESTDIR)$(RUNTIMEDIR)/wtf/root/lib/modules/" rootfs: ./make-rootfs.sh diff --git a/customize.sh b/customize.sh index 91a868a..de4df18 100755 --- a/customize.sh +++ b/customize.sh @@ -29,19 +29,20 @@ parse_size() { return 1 } -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$DIR/dns.sh" -source "$DIR/packages.sh" -STATE="$DIR/state" +RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +source "$RUNTIME_DIR/dns.sh" +source "$RUNTIME_DIR/packages.sh" +STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/image-build}" VM_ROOT="$STATE/vms" mkdir -p "$VM_ROOT" -BASE_ROOTFS="$DIR/rootfs.ext4" -FC_BIN="$DIR/firecracker" +BASE_ROOTFS="$RUNTIME_DIR/rootfs.ext4" +FC_BIN="$RUNTIME_DIR/firecracker" -KERNEL="$DIR/wtf/root/boot/vmlinux-6.8.0-94-generic" -INITRD="$DIR/wtf/root/boot/initrd.img-6.8.0-94-generic" -SSH_KEY="$DIR/id_ed25519" +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" +SSH_KEY="$RUNTIME_DIR/id_ed25519" +NAT_SCRIPT="$RUNTIME_DIR/nat.sh" BR_DEV="br-fc" BR_IP="172.16.0.1" @@ -52,7 +53,7 @@ BASE_ROOTFS="" OUT_ROOTFS="" SIZE_SPEC="" INSTALL_DOCKER=0 -MODULES_DIR="$DIR/wtf/root/lib/modules/6.8.0-94-generic" +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 @@ -304,7 +305,7 @@ jq -n \ > "$VM_DIR/vm.json" log "enabling NAT for customization" -sudo -E ./nat.sh up "$VM_TAG" >/dev/null +sudo -E "$NAT_SCRIPT" up "$VM_TAG" >/dev/null log "waiting for SSH" SSH_READY=0 diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 433c47a..f6a2c4f 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -131,6 +131,7 @@ func newVMCommand() *cobra.Command { return cmd } + func newVMCreateCommand() *cobra.Command { var params api.VMCreateParams cmd := &cobra.Command{ @@ -372,6 +373,9 @@ func newImageBuildCommand() *cobra.Command { Short: "Build an image", Args: noArgsUsage("usage: banger image build"), RunE: func(cmd *cobra.Command, args []string) error { + if err := absolutizeImageBuildPaths(¶ms); err != nil { + return err + } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } @@ -631,14 +635,28 @@ func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]s return nil, errors.New("vm has no guest IP") } args := []string{} - if cfg.RepoRoot != "" { - args = append(args, "-i", filepath.Join(cfg.RepoRoot, "id_ed25519")) + if cfg.SSHKeyPath != "" { + args = append(args, "-i", cfg.SSHKeyPath) } args = append(args, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "root@"+guestIP) args = append(args, extra...) return args, nil } +func absolutizeImageBuildPaths(params *api.ImageBuildParams) error { + var err error + for _, value := range []*string{¶ms.BaseRootfs, ¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir} { + if *value == "" || filepath.IsAbs(*value) { + continue + } + *value, err = filepath.Abs(*value) + if err != nil { + return err + } + } + return nil +} + func printJSON(out anyWriter, v any) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 5261343..0ff0537 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -6,6 +6,7 @@ import ( "reflect" "testing" + "banger/internal/api" "banger/internal/model" ) @@ -38,6 +39,7 @@ func TestVMCreateFlagsExist(t *testing.T) { } } + func TestVMSetParamsFromFlags(t *testing.T) { params, err := vmSetParamsFromFlags("devbox", 4, 2048, "16G", true, false) if err != nil { @@ -64,12 +66,12 @@ func TestVMSetParamsFromFlagsConflict(t *testing.T) { } func TestSSHCommandArgs(t *testing.T) { - args, err := sshCommandArgs(model.DaemonConfig{RepoRoot: "/repo"}, "172.16.0.2", []string{"--", "uname", "-a"}) + args, err := sshCommandArgs(model.DaemonConfig{SSHKeyPath: "/bundle/id_ed25519"}, "172.16.0.2", []string{"--", "uname", "-a"}) if err != nil { t.Fatalf("sshCommandArgs: %v", err) } want := []string{ - "-i", "/repo/id_ed25519", + "-i", "/bundle/id_ed25519", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "root@172.16.0.2", @@ -127,3 +129,37 @@ func TestDaemonOutdated(t *testing.T) { t.Fatal("expected replaced daemon executable to be outdated") } } + +func TestAbsolutizeImageBuildPaths(t *testing.T) { + dir := t.TempDir() + prev, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(prev) + }) + + params := api.ImageBuildParams{ + BaseRootfs: "images/base.ext4", + KernelPath: "/kernel", + InitrdPath: "boot/initrd.img", + ModulesDir: "modules", + } + if err := absolutizeImageBuildPaths(¶ms); err != nil { + t.Fatalf("absolutizeImageBuildPaths: %v", err) + } + + want := api.ImageBuildParams{ + BaseRootfs: filepath.Join(dir, "images/base.ext4"), + KernelPath: "/kernel", + InitrdPath: filepath.Join(dir, "boot/initrd.img"), + ModulesDir: filepath.Join(dir, "modules"), + } + if !reflect.DeepEqual(params, want) { + t.Fatalf("params = %+v, want %+v", params, want) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8cfd136..8fda1d9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,8 +12,14 @@ import ( ) type fileConfig struct { + RuntimeDir string `toml:"runtime_dir"` RepoRoot string `toml:"repo_root"` + FirecrackerBin string `toml:"firecracker_bin"` + SSHKeyPath string `toml:"ssh_key_path"` + NamegenPath string `toml:"namegen_path"` + CustomizeScript string `toml:"customize_script"` DefaultImageName string `toml:"default_image_name"` + DefaultRootfs string `toml:"default_rootfs"` DefaultBaseRootfs string `toml:"default_base_rootfs"` DefaultKernel string `toml:"default_kernel"` DefaultInitrd string `toml:"default_initrd"` @@ -30,7 +36,6 @@ type fileConfig struct { func Load(layout paths.Layout) (model.DaemonConfig, error) { cfg := model.DaemonConfig{ - RepoRoot: paths.DetectRepoRoot(), AutoStopStaleAfter: 0, StatsPollInterval: model.DefaultStatsPollInterval, MetricsPollInterval: model.DefaultMetricsPollInterval, @@ -40,39 +45,45 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { DefaultDNS: model.DefaultDNS, DefaultImageName: "default", } - if cfg.RepoRoot != "" { - cfg.DefaultBaseRootfs = filepath.Join(cfg.RepoRoot, "rootfs.ext4") - cfg.DefaultKernel = filepath.Join(cfg.RepoRoot, "wtf/root/boot/vmlinux-6.8.0-94-generic") - cfg.DefaultInitrd = filepath.Join(cfg.RepoRoot, "wtf/root/boot/initrd.img-6.8.0-94-generic") - cfg.DefaultModulesDir = filepath.Join(cfg.RepoRoot, "wtf/root/lib/modules/6.8.0-94-generic") - cfg.DefaultPackagesFile = filepath.Join(cfg.RepoRoot, "packages.apt") - } path := filepath.Join(layout.ConfigDir, "config.toml") info, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return cfg, nil - } - return cfg, err - } - if info.IsDir() { - return cfg, nil - } - data, err := os.ReadFile(path) - if err != nil { - return cfg, err - } var file fileConfig - if err := toml.Unmarshal(data, &file); err != nil { - return cfg, err + if err != nil { + if !os.IsNotExist(err) { + return cfg, err + } + } else if !info.IsDir() { + data, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + if err := toml.Unmarshal(data, &file); err != nil { + return cfg, err + } } - if file.RepoRoot != "" { - cfg.RepoRoot = file.RepoRoot + + cfg.RuntimeDir = paths.ResolveRuntimeDir(file.RuntimeDir, file.RepoRoot) + applyRuntimeDefaults(&cfg) + + if file.FirecrackerBin != "" { + cfg.FirecrackerBin = file.FirecrackerBin + } + if file.SSHKeyPath != "" { + cfg.SSHKeyPath = file.SSHKeyPath + } + if file.NamegenPath != "" { + cfg.NamegenPath = file.NamegenPath + } + if file.CustomizeScript != "" { + cfg.CustomizeScript = file.CustomizeScript } if file.DefaultImageName != "" { cfg.DefaultImageName = file.DefaultImageName } + if file.DefaultRootfs != "" { + cfg.DefaultRootfs = file.DefaultRootfs + } if file.DefaultBaseRootfs != "" { cfg.DefaultBaseRootfs = file.DefaultBaseRootfs } @@ -123,3 +134,48 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { } return cfg, nil } + +func applyRuntimeDefaults(cfg *model.DaemonConfig) { + if cfg.RuntimeDir == "" { + return + } + 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"), + filepath.Join(cfg.RuntimeDir, "rootfs.ext4"), + ) + } + if cfg.DefaultBaseRootfs == "" { + cfg.DefaultBaseRootfs = firstExistingRuntimePath( + filepath.Join(cfg.RuntimeDir, "rootfs.ext4"), + cfg.DefaultRootfs, + ) + } +} + +func defaultRuntimePath(current, runtimeDir, relative string) string { + if current != "" { + return current + } + return filepath.Join(runtimeDir, relative) +} + +func firstExistingRuntimePath(paths ...string) string { + for _, candidate := range paths { + if candidate == "" { + continue + } + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + return "" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..1b8cbf0 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,72 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "banger/internal/paths" +) + +func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) { + runtimeDir := t.TempDir() + for _, rel := range []string{ + "firecracker", + "id_ed25519", + "namegen", + "customize.sh", + "packages.apt", + "rootfs-docker.ext4", + "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/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) + } + } + + 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, "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) + } + if cfg.DefaultModulesDir != filepath.Join(runtimeDir, "wtf/root/lib/modules/6.8.0-94-generic") { + t.Fatalf("DefaultModulesDir = %q", cfg.DefaultModulesDir) + } + if cfg.DefaultPackagesFile != filepath.Join(runtimeDir, "packages.apt") { + t.Fatalf("DefaultPackagesFile = %q", cfg.DefaultPackagesFile) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 489ad86..84e5d6a 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -267,13 +267,13 @@ func (d *Daemon) backgroundLoop() { } func (d *Daemon) ensureDefaultImage(ctx context.Context) error { - if d.config.DefaultImageName == "" || d.config.RepoRoot == "" { + if d.config.DefaultImageName == "" { return nil } if _, err := d.store.GetImageByName(ctx, d.config.DefaultImageName); err == nil { return nil } - rootfs := filepath.Join(d.config.RepoRoot, "rootfs-docker.ext4") + rootfs := d.config.DefaultRootfs kernel := d.config.DefaultKernel initrd := d.config.DefaultInitrd if !exists(rootfs) || !exists(kernel) { diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go new file mode 100644 index 0000000..35d09fd --- /dev/null +++ b/internal/daemon/daemon_test.go @@ -0,0 +1,54 @@ +package daemon + +import ( + "context" + "os" + "path/filepath" + "testing" + + "banger/internal/model" + "banger/internal/store" +) + +func TestEnsureDefaultImageUsesConfiguredDefaultRootfs(t *testing.T) { + dir := t.TempDir() + rootfs := filepath.Join(dir, "rootfs-docker.ext4") + kernel := filepath.Join(dir, "vmlinux") + for _, path := range []string{rootfs, kernel} { + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + + db, err := store.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + + d := &Daemon{ + config: model.DaemonConfig{ + DefaultImageName: "default", + DefaultRootfs: rootfs, + DefaultKernel: kernel, + }, + store: db, + } + + if err := d.ensureDefaultImage(context.Background()); err != nil { + t.Fatalf("ensureDefaultImage: %v", err) + } + + image, err := db.GetImageByName(context.Background(), "default") + if err != nil { + t.Fatalf("GetImageByName: %v", err) + } + if image.RootfsPath != rootfs { + t.Fatalf("RootfsPath = %q, want %q", image.RootfsPath, rootfs) + } + if image.KernelPath != kernel { + t.Fatalf("KernelPath = %q, want %q", image.KernelPath, kernel) + } +} diff --git a/internal/daemon/images.go b/internal/daemon/images.go index 7af675c..d4ca0da 100644 --- a/internal/daemon/images.go +++ b/internal/daemon/images.go @@ -22,9 +22,6 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (m if _, err := d.FindImage(ctx, name); err == nil { return model.Image{}, fmt.Errorf("image name already exists: %s", name) } - if d.config.RepoRoot == "" { - return model.Image{}, fmt.Errorf("repo root not found; set repo_root in config.toml") - } baseRootfs := params.BaseRootfs if baseRootfs == "" { baseRootfs = d.config.DefaultBaseRootfs @@ -42,7 +39,10 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (m return model.Image{}, err } rootfsPath := filepath.Join(artifactDir, "rootfs.ext4") - script := filepath.Join(d.config.RepoRoot, "customize.sh") + script := d.config.CustomizeScript + if script == "" { + return model.Image{}, fmt.Errorf("customize script not configured; set runtime_dir or customize_script in config.toml") + } if _, err := os.Stat(script); err != nil { return model.Image{}, fmt.Errorf("customize.sh not found at %s", script) } @@ -78,7 +78,12 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (m cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin - cmd.Dir = d.config.RepoRoot + cmd.Dir = d.layout.StateDir + cmd.Env = append( + os.Environ(), + "BANGER_RUNTIME_DIR="+d.config.RuntimeDir, + "BANGER_STATE_DIR="+filepath.Join(d.layout.StateDir, "image-build"), + ) if err := cmd.Run(); err != nil { _ = os.RemoveAll(artifactDir) return model.Image{}, err diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index 5567b1f..2086b5e 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -546,10 +546,10 @@ func (d *Daemon) createTap(ctx context.Context, tap string) error { } func (d *Daemon) firecrackerBinary() (string, error) { - if d.config.RepoRoot == "" { - return "", errors.New("repo root not detected") + if d.config.FirecrackerBin == "" { + return "", errors.New("firecracker binary not configured; set runtime_dir or firecracker_bin in config.toml") } - path := filepath.Join(d.config.RepoRoot, "firecracker") + path := d.config.FirecrackerBin if !exists(path) { return "", fmt.Errorf("firecracker binary not found at %s", path) } @@ -689,15 +689,12 @@ func (d *Daemon) requireStartPrereqs(ctx context.Context) error { } func (d *Daemon) generateName(ctx context.Context) (string, error) { - if d.config.RepoRoot != "" { - namegen := filepath.Join(d.config.RepoRoot, "namegen") - if exists(namegen) { - out, err := d.runner.Run(ctx, namegen) - if err == nil { - name := strings.TrimSpace(string(out)) - if name != "" { - return name, nil - } + if exists(d.config.NamegenPath) { + out, err := d.runner.Run(ctx, d.config.NamegenPath) + if err == nil { + name := strings.TrimSpace(string(out)) + if name != "" { + return name, nil } } } diff --git a/internal/model/types.go b/internal/model/types.go index 35cc403..53db8a5 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -35,7 +35,11 @@ const ( ) type DaemonConfig struct { - RepoRoot string + RuntimeDir string + FirecrackerBin string + SSHKeyPath string + NamegenPath string + CustomizeScript string AutoStopStaleAfter time.Duration StatsPollInterval time.Duration MetricsPollInterval time.Duration @@ -44,6 +48,7 @@ type DaemonConfig struct { CIDR string DefaultDNS string DefaultImageName string + DefaultRootfs string DefaultBaseRootfs string DefaultKernel string DefaultInitrd string diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 6a4a81c..e4e6653 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "os" - "os/exec" "path/filepath" "strconv" "strings" @@ -66,57 +65,64 @@ func Ensure(layout Layout) error { return nil } -func DetectRepoRoot() string { - if env := os.Getenv("BANGER_REPO_ROOT"); env != "" { - return env - } - candidates := []string{} - if wd, err := os.Getwd(); err == nil { - candidates = append(candidates, wd) - } - if exe, err := os.Executable(); err == nil { - candidates = append(candidates, filepath.Dir(exe)) - } - if look, err := exec.LookPath("firecracker"); err == nil { - candidates = append(candidates, filepath.Dir(look)) - } - for _, candidate := range candidates { - if root := walkForRepoRoot(candidate); root != "" { - return root +var executablePath = os.Executable + +func ResolveRuntimeDir(configuredRuntimeDir, deprecatedRepoRoot string) string { + for _, candidate := range []string{ + os.Getenv("BANGER_RUNTIME_DIR"), + os.Getenv("BANGER_REPO_ROOT"), + configuredRuntimeDir, + deprecatedRepoRoot, + } { + if candidate = strings.TrimSpace(candidate); candidate != "" { + return filepath.Clean(candidate) } } + exe, err := executablePath() + if err != nil { + return "" + } + exeDir := filepath.Dir(exe) + if filepath.Base(exeDir) == "bin" { + installRuntimeDir := filepath.Clean(filepath.Join(exeDir, "..", "lib", "banger")) + if HasRuntimeBundle(installRuntimeDir) { + return installRuntimeDir + } + } + if HasRuntimeBundle(exeDir) { + return exeDir + } return "" } -func walkForRepoRoot(start string) string { - current := start - for { - if hasRepoArtifacts(current) { - return current - } - parent := filepath.Dir(current) - if parent == current { - return "" - } - current = parent +func HasRuntimeBundle(dir string) bool { + if strings.TrimSpace(dir) == "" { + return false + } + required := []string{ + "firecracker", + "customize.sh", + "packages.apt", + "wtf/root/boot/vmlinux-6.8.0-94-generic", } -} - -func hasRepoArtifacts(dir string) bool { - required := []string{"firecracker", "README.md"} for _, name := range required { if _, err := os.Stat(filepath.Join(dir, name)); err != nil { return false } } - return true + for _, name := range []string{"rootfs-docker.ext4", "rootfs.ext4"} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return true + } + } + return false } func BangerdPath() (string, error) { if env := os.Getenv("BANGER_DAEMON_BIN"); env != "" { return env, nil } - exe, err := os.Executable() + exe, err := executablePath() if err != nil { return "", err } @@ -129,16 +135,6 @@ func BangerdPath() (string, error) { return candidate, nil } } - if root := DetectRepoRoot(); root != "" { - for _, candidate := range []string{ - filepath.Join(root, "bangerd"), - filepath.Join(root, "bangerd.exe"), - } { - if _, err := os.Stat(candidate); err == nil { - return candidate, nil - } - } - } return "", errors.New("bangerd binary not found next to banger; build ./cmd/bangerd") } diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go new file mode 100644 index 0000000..d78cdfa --- /dev/null +++ b/internal/paths/paths_test.go @@ -0,0 +1,69 @@ +package paths + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveRuntimeDirPrefersEnv(t *testing.T) { + t.Setenv("BANGER_RUNTIME_DIR", "/env/runtime") + + if got := ResolveRuntimeDir("/config/runtime", "/deprecated/repo"); got != "/env/runtime" { + t.Fatalf("ResolveRuntimeDir() = %q, want /env/runtime", got) + } +} + +func TestResolveRuntimeDirUsesInstalledLayout(t *testing.T) { + root := t.TempDir() + runtimeDir := filepath.Join(root, "lib", "banger") + createRuntimeBundle(t, runtimeDir) + + origExecutablePath := executablePath + executablePath = func() (string, error) { + return filepath.Join(root, "bin", "banger"), nil + } + t.Cleanup(func() { + executablePath = origExecutablePath + }) + + if got := ResolveRuntimeDir("", ""); got != runtimeDir { + t.Fatalf("ResolveRuntimeDir() = %q, want %q", got, runtimeDir) + } +} + +func TestResolveRuntimeDirUsesExecutableDirectoryBundle(t *testing.T) { + root := t.TempDir() + createRuntimeBundle(t, root) + + origExecutablePath := executablePath + executablePath = func() (string, error) { + return filepath.Join(root, "banger"), nil + } + t.Cleanup(func() { + executablePath = origExecutablePath + }) + + if got := ResolveRuntimeDir("", ""); got != root { + t.Fatalf("ResolveRuntimeDir() = %q, want %q", got, root) + } +} + +func createRuntimeBundle(t *testing.T, runtimeDir string) { + t.Helper() + for _, rel := range []string{ + "firecracker", + "customize.sh", + "packages.apt", + "rootfs-docker.ext4", + "wtf/root/boot/vmlinux-6.8.0-94-generic", + } { + 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) + } + } +}