Make installed banger self-contained

Fix the misleading make install path where banger and bangerd still depended on a repo checkout for Firecracker, guest artifacts, image builds, and the SSH key.

Replace repo-root inference with an explicit runtime bundle model: resolve a runtime_dir from env/config/install layout, derive concrete artifact paths from it, and update the daemon, CLI, and image-build flow to use those paths. Keep repo_root only as an explicit compatibility alias instead of auto-detecting it.

Teach customize.sh to run from a read-only bundled runtime tree while writing transient state under XDG/BANGER_STATE_DIR, and make make install copy the runtime assets into PREFIX/lib/banger so installed binaries stay usable outside the repo.

Validate with go test ./..., make build, bash -n customize.sh, and make install DESTDIR=/tmp/banger-install PREFIX=/usr. An out-of-repo installed-binary smoke test was attempted, but this sandbox blocked bangerd from binding its Unix socket (setsockopt: operation not permitted).
This commit is contained in:
Thales Maciel 2026-03-16 14:26:50 -03:00
parent 375900cf65
commit ce1be52047
No known key found for this signature in database
GPG key ID: 33112E6833C34679
13 changed files with 437 additions and 107 deletions

View file

@ -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(&params); 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{&params.BaseRootfs, &params.KernelPath, &params.InitrdPath, &params.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 {

View file

@ -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(&params); 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)
}
}

View file

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

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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