diff --git a/AGENTS.md b/AGENTS.md index 9c8df2d..c5d6dc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,8 @@ - `make build` builds `./banger`, `./bangerd`, and the bundled `./runtime/banger-vsock-agent` guest helper. - `make bench-create` benchmarks `vm create` and first-SSH readiness on the current host. - `make runtime-bundle` bootstraps `./runtime/` from the archive referenced by `RUNTIME_MANIFEST`; the checked-in `runtime-bundle.toml` is only a template. -- `make rootfs-void` builds an experimental local-only `x86_64-glibc` Void rootfs plus work-seed under `./runtime/`; it does not replace the default Debian path or teach `banger image build` about Void. +- `make void-kernel` downloads and stages a Void `linux6.12` kernel under `./runtime/void-kernel`, including extracted `vmlinux`, raw `vmlinuz`, a matching generated `initramfs`, config, and matching modules. +- `make rootfs-void` builds an experimental local-only `x86_64-glibc` Void rootfs plus work-seed under `./runtime/`; it prefers staged `./runtime/void-kernel` modules when present, but does not replace the default Debian path or teach `banger image build` about Void. - `make verify-void` registers `void-exp` and runs the normal smoke test against that image. - `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. @@ -34,8 +35,8 @@ - Primary automated coverage is `go test ./...`. - Manual verification for VM lifecycle changes: `./banger vm create`, confirm SSH access, then stop/delete the VM. - For host-integration changes, run `./banger doctor` as a quick readiness check before the live VM smoke. -- Rebuilt images now include `mise`, `opencode`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up. -- The experimental Void rootfs path now includes the repo's basic dev baseline plus Docker and Compose, alongside boot, SSH, the vsock HTTP health agent, pinned `mise` plus `opencode` for `root`, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed. Keep further baked-in tooling deliberate and user-driven. +- Rebuilt images now include `mise`, `opencode`, a host-reachable default `opencode` server service on guest TCP port `4096`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up. +- The experimental Void rootfs path now includes the repo's basic dev baseline plus Docker and Compose, alongside boot, SSH, a guest network bootstrap sourced from the kernel `ip=` cmdline, the vsock HTTP health agent, pinned `mise` plus `opencode` for `root`, the default host-reachable `opencode` server service on guest TCP port `4096`, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed. When `./runtime/void-kernel/` exists, the Void image registration path expects a complete staged Void kernel, initramfs, and modules tree and points `void-exp` at it. Keep further baked-in tooling deliberate and user-driven. - Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. If you touch `/root` provisioning, verify both the rootfs and the work-seed output. - The daemon may keep idle TAP devices in a pool for faster creates. Smoke tests should treat `tap-pool-*` devices as reusable capacity, not cleanup leaks. - If you add a new operational workflow, document how to exercise it in `README.md`. diff --git a/Makefile b/Makefile index 2eea15d..36ee25c 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ VOID_VM_NAME ?= void-dev .DEFAULT_GOAL := help -.PHONY: help build banger bangerd test fmt tidy clean rootfs rootfs-void void-register void-vm verify-void install runtime-bundle runtime-package check-runtime bench-create +.PHONY: help build banger bangerd test fmt tidy clean rootfs rootfs-void void-kernel void-register void-vm verify-void install runtime-bundle runtime-package check-runtime bench-create help: @printf '%s\n' \ @@ -39,6 +39,7 @@ help: ' make tidy Run go mod tidy' \ ' make clean Remove built Go binaries' \ ' make rootfs Rebuild the source-checkout default Debian rootfs image in ./runtime' \ + ' make void-kernel Download and stage a Void kernel, initramfs, and modules under ./runtime/void-kernel' \ ' make rootfs-void Build an experimental Void Linux rootfs and work-seed in ./runtime' \ ' make void-register Register or update the experimental Void image as $(VOID_IMAGE_NAME)' \ ' make void-vm Register the experimental Void image and create a VM named $(VOID_VM_NAME)' \ @@ -107,11 +108,14 @@ install: build check-runtime rootfs: BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs.sh +void-kernel: + BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-void-kernel.sh + rootfs-void: BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs-void.sh void-register: build - ./banger image register --name "$(VOID_IMAGE_NAME)" --rootfs "$(abspath $(RUNTIME_SOURCE_DIR))/rootfs-void.ext4" --work-seed "$(abspath $(RUNTIME_SOURCE_DIR))/rootfs-void.work-seed.ext4" --packages "$(abspath packages.void)" + BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" VOID_IMAGE_NAME="$(VOID_IMAGE_NAME)" BANGER_BIN="$(abspath ./banger)" ./register-void-image.sh void-vm: void-register ./banger vm create --image "$(VOID_IMAGE_NAME)" --name "$(VOID_VM_NAME)" diff --git a/README.md b/README.md index 2282fb7..dac9f0b 100644 --- a/README.md +++ b/README.md @@ -212,10 +212,11 @@ banger image build --name docker-dev --docker Rebuilt images install a pinned `mise` at `/usr/local/bin/mise`, activate it for bash login and interactive shells, install `opencode` through `mise`, -configure `tmux-resurrect` plus `tmux-continuum` for `root` with periodic -autosaves and manual-only restore by default, and bake in the -`banger-vsock-agent` systemd service used by the post-SSH reminder path and -guest health checks. They +expose `/usr/local/bin/opencode`, configure `tmux-resurrect` plus +`tmux-continuum` for `root` with periodic autosaves and manual-only restore by +default, start a host-reachable `opencode serve` service on guest TCP port +`4096`, and bake in the `banger-vsock-agent` systemd service used by the +post-SSH reminder path and guest health checks. They also emit a `work-seed.ext4` sidecar that lets new VMs clone a prepared `/root` work disk instead of rebuilding it from scratch on every create. @@ -225,6 +226,17 @@ banger image show docker-dev banger image delete docker-dev ``` +Promote an existing unmanaged image into a managed one: +```bash +banger image promote default +banger image promote void-exp +``` + +Promotion copies the image's `rootfs` and optional `work-seed` into the +daemon's managed image state directory and keeps the same image ID, so existing +VM references stay valid. The image's kernel, initrd, modules, and package +manifest paths stay pointed at their current locations. + `banger` auto-registers the bundled `default_rootfs` image when it exists. If the bundle does not include a separate base `rootfs.ext4`, `image build` falls back to using `rootfs-docker.ext4` as its default base image. @@ -253,6 +265,10 @@ short best-effort HTTP and HTTPS probes; detected web listeners are shown as `https://.vm:port/`. Older images without `ss` may need rebuilding before `vm ports` works. +Newly rebuilt images also start `opencode serve` by default on guest TCP port +`4096`, bound on guest interfaces so the host can reach it directly at the +guest IP or via the endpoint shown by `banger vm ports`. + ## Storage Model - VMs share a read-only base rootfs image. - Each VM gets its own sparse writable system overlay for `/`. @@ -286,7 +302,8 @@ make rootfs ``` That rebuild also regenerates `./runtime/rootfs-docker.work-seed.ext4`, which -the daemon uses to speed up future `vm create` calls. +the daemon uses to speed up future `vm create` calls, and bakes in the default +host-reachable `opencode` server service. If your runtime bundle does not include `./runtime/rootfs.ext4`, pass an explicit base image instead: @@ -303,25 +320,37 @@ make rootfs `make rootfs` expects a bootstrapped runtime bundle. If `./runtime/rootfs.ext4` is not available, pass an explicit `--base-rootfs` to `./make-rootfs.sh`. Existing VMs keep using their current image and disks; rebuilds only affect VMs -created from the rebuilt image afterward. +created from the rebuilt image afterward. Restarting an existing VM is not +enough to pick up guest provisioning changes such as the default `opencode` +server service. ## Experimental Void Rootfs There is also a separate, opt-in builder for an experimental Void Linux guest path: ```bash +make void-kernel make rootfs-void ``` That writes: +- `./runtime/void-kernel/` when `make void-kernel` is used - `./runtime/rootfs-void.ext4` - `./runtime/rootfs-void.work-seed.ext4` This path is intentionally local-only and does not change the default Debian -image flow. It reuses the current runtime bundle kernel, initrd, and modules, -but builds a lean `x86_64-glibc` Void userspace with: +image flow. `make void-kernel` stages an actual Void `linux6.12` kernel package +under `./runtime/void-kernel/`, including the raw `vmlinuz`, extracted +Firecracker `vmlinux`, a matching `initramfs`, the matching config, and the +matching modules tree. The initramfs is generated locally with `dracut` +against the downloaded Void sysroot so the kernel, initrd, and modules stay +aligned. `make rootfs-void` then prefers that staged modules tree when it exists; +otherwise it falls back to the runtime bundle modules. The rootfs builder +itself still builds a lean `x86_64-glibc` Void userspace with: - `bash` installed for interactive/admin use - pinned `mise` installed at `/usr/local/bin/mise`, activated for `root` bash shells - `opencode` installed through `mise`, with `/usr/local/bin/opencode` available by default +- a guest network bootstrap that configures the VM NIC from the kernel `ip=` boot arg +- a host-reachable `opencode serve` runit service enabled on guest TCP port `4096` - `docker` plus `docker-compose` installed from Void packages - the `docker` runit service enabled, with Docker netfilter/forwarding kernel prep - `openssh` enabled under runit @@ -333,26 +362,30 @@ It still keeps some Debian-oriented extras out for now: - no tmux plugin defaults The builder fetches official static XBPS tools and packages from the Void -mirror during the build. It currently supports only `x86_64-glibc`. +mirror during the build. The kernel fetcher and rootfs builder currently +support only `x86_64`. The package set comes from [`packages.void`](/home/thales/projects/personal/banger/packages.void). -You can override the mirror, size, or output path directly: +You can override the mirror, size, output path, or kernel package directly: ```bash +./make-void-kernel.sh --kernel-package linux6.12 ./make-rootfs-void.sh --mirror https://repo-default.voidlinux.org --size 2G ``` The fastest local iteration loop does not require changing your default image config at all: ```bash +make void-kernel make rootfs-void make void-register ./banger vm create --image void-exp --name void-dev ./banger vm ssh void-dev ``` -Rebuild the Void rootfs and recreate existing `void-exp` VMs after changing the -package set or guest provisioning; restart alone will not update the image -contents or `/root` work-seed. +Rebuild the staged Void kernel or Void rootfs, then recreate existing +`void-exp` VMs after changing the package set, guest provisioning, or staged +kernel artifacts; restart alone will not update the image contents, kernel, or +`/root` work-seed. There is also a smoke path for the experimental image: ```bash @@ -361,7 +394,9 @@ make verify-void `make void-register` uses the unmanaged image registration path to create or update a `void-exp` image record in place, so repeated rebuilds do not require -editing `~/.config/banger/config.toml`. +editing `~/.config/banger/config.toml`. It expects a complete staged Void +kernel set under `./runtime/void-kernel/` and points the experimental image at +the staged Void `vmlinux`, `initramfs`, and matching modules tree. There is also a one-step helper target: ```bash @@ -390,6 +425,9 @@ banger image register \ --name void-exp \ --rootfs ./runtime/rootfs-void.ext4 \ --work-seed ./runtime/rootfs-void.work-seed.ext4 \ + --kernel ./runtime/void-kernel/boot/vmlinux-6.12.77_1 \ + --initrd ./runtime/void-kernel/boot/initramfs-6.12.77_1.img \ + --modules ./runtime/void-kernel/lib/modules/6.12.77_1 \ --packages ./packages.void ``` diff --git a/customize.sh b/customize.sh index a290e96..1576382 100755 --- a/customize.sh +++ b/customize.sh @@ -418,6 +418,12 @@ DEBIAN_FRONTEND=noninteractive apt-get -y upgrade DEBIAN_FRONTEND=noninteractive apt-get -y install ${APT_PACKAGES_ESCAPED} curl -fsSL https://mise.run | MISE_INSTALL_PATH=\"$MISE_INSTALL_PATH\" MISE_VERSION=\"$MISE_VERSION\" sh \"$MISE_INSTALL_PATH\" use -g github:anomalyco/opencode +\"$MISE_INSTALL_PATH\" reshim +if [[ ! -e /root/.local/share/mise/shims/opencode ]]; then + echo 'opencode shim not found after mise install' >&2 + exit 1 +fi +ln -snf /root/.local/share/mise/shims/opencode /usr/local/bin/opencode mkdir -p /etc/profile.d cat > /etc/profile.d/mise.sh <<'MISEPROFILE' if [ -n \"\${BASH_VERSION:-}\" ] && [ -x \"$MISE_INSTALL_PATH\" ]; then @@ -441,6 +447,28 @@ fi rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh chmod 0755 /usr/local/bin/banger-vsock-agent mkdir -p /etc/modules-load.d /etc/systemd/system +cat > /etc/systemd/system/banger-opencode.service <<'EOF' +[Unit] +Description=Banger opencode server +After=network.target +RequiresMountsFor=/root + +[Service] +Type=simple +Environment=HOME=/root +WorkingDirectory=/root +ExecStart=/usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096 +Restart=on-failure +RestartSec=1 + +[Install] +WantedBy=multi-user.target +EOF +chmod 0644 /etc/systemd/system/banger-opencode.service +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl enable --now banger-opencode.service || true +fi cat > /etc/modules-load.d/banger-vsock.conf <<'EOF' vsock vmw_vsock_virtio_transport diff --git a/examples/void-exp.config.toml b/examples/void-exp.config.toml index 75ee7e2..a3a1b89 100644 --- a/examples/void-exp.config.toml +++ b/examples/void-exp.config.toml @@ -3,8 +3,12 @@ # Copy the values you want into ~/.config/banger/config.toml and replace # /abs/path/to/banger with your checkout path. Do not set default_base_rootfs # to the Void image yet; banger image build still assumes the Debian flow. +# If you run `make void-kernel`, also merge the commented kernel/initrd/modules lines. runtime_dir = "/abs/path/to/banger/runtime" default_image_name = "void-exp" default_rootfs = "/abs/path/to/banger/runtime/rootfs-void.ext4" default_work_seed = "/abs/path/to/banger/runtime/rootfs-void.work-seed.ext4" +# default_kernel = "/abs/path/to/banger/runtime/void-kernel/boot/vmlinux-6.12.77_1" +# default_initrd = "/abs/path/to/banger/runtime/void-kernel/boot/initramfs-6.12.77_1.img" +# default_modules_dir = "/abs/path/to/banger/runtime/void-kernel/lib/modules/6.12.77_1" diff --git a/internal/api/types.go b/internal/api/types.go index 55c962f..77ec00e 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -1,6 +1,10 @@ package api -import "banger/internal/model" +import ( + "time" + + "banger/internal/model" +) type Empty struct{} @@ -24,6 +28,32 @@ type VMCreateParams struct { NoStart bool `json:"no_start,omitempty"` } +type VMCreateStatusParams struct { + ID string `json:"id"` +} + +type VMCreateOperation struct { + ID string `json:"id"` + VMID string `json:"vm_id,omitempty"` + VMName string `json:"vm_name,omitempty"` + Stage string `json:"stage,omitempty"` + Detail string `json:"detail,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Done bool `json:"done"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + VM *model.VMRecord `json:"vm,omitempty"` +} + +type VMCreateBeginResult struct { + Operation VMCreateOperation `json:"operation"` +} + +type VMCreateStatusResult struct { + Operation VMCreateOperation `json:"operation"` +} + type VMRefParams struct { IDOrName string `json:"id_or_name"` } diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 9de83e5..0d53a05 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -46,6 +46,16 @@ var ( vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName}) } + vmCreateBeginFunc = func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error) { + return rpc.Call[api.VMCreateBeginResult](ctx, socketPath, "vm.create.begin", params) + } + vmCreateStatusFunc = func(ctx context.Context, socketPath, operationID string) (api.VMCreateStatusResult, error) { + return rpc.Call[api.VMCreateStatusResult](ctx, socketPath, "vm.create.status", api.VMCreateStatusParams{ID: operationID}) + } + vmCreateCancelFunc = func(ctx context.Context, socketPath, operationID string) error { + _, err := rpc.Call[api.Empty](ctx, socketPath, "vm.create.cancel", api.VMCreateStatusParams{ID: operationID}) + return err + } vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) { return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName}) } @@ -323,11 +333,11 @@ func newVMCreateCommand() *cobra.Command { if err != nil { return err } - result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.create", params) + vm, err := runVMCreate(cmd.Context(), layout.SocketPath, cmd.ErrOrStderr(), params) if err != nil { return err } - return printVMSummary(cmd.OutOrStdout(), result.VM) + return printVMSummary(cmd.OutOrStdout(), vm) }, } cmd.Flags().StringVar(&name, "name", "", "vm name") @@ -575,6 +585,7 @@ func newImageCommand() *cobra.Command { cmd.AddCommand( newImageBuildCommand(), newImageRegisterCommand(), + newImagePromoteCommand(), newImageListCommand(), newImageShowCommand(), newImageDeleteCommand(), @@ -651,6 +662,28 @@ func newImageRegisterCommand() *cobra.Command { return cmd } +func newImagePromoteCommand() *cobra.Command { + return &cobra.Command{ + Use: "promote ", + Short: "Promote an unmanaged image to a managed artifact", + Args: exactArgsUsage(1, "usage: banger image promote "), + 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.ImageShowResult](cmd.Context(), layout.SocketPath, "image.promote", api.ImageRefParams{IDOrName: args[0]}) + if err != nil { + return err + } + return printImageSummary(cmd.OutOrStdout(), result.Image) + }, + } +} + func newImageListCommand() *cobra.Command { return &cobra.Command{ Use: "list", @@ -1255,6 +1288,141 @@ type anyWriter interface { Write(p []byte) (n int, err error) } +func runVMCreate(ctx context.Context, socketPath string, stderr io.Writer, params api.VMCreateParams) (model.VMRecord, error) { + begin, err := vmCreateBeginFunc(ctx, socketPath, params) + if err != nil { + return model.VMRecord{}, err + } + renderer := newVMCreateProgressRenderer(stderr) + renderer.render(begin.Operation) + + op := begin.Operation + for { + if op.Done { + renderer.render(op) + if op.Success && op.VM != nil { + return *op.VM, nil + } + if strings.TrimSpace(op.Error) == "" { + return model.VMRecord{}, errors.New("vm create failed") + } + return model.VMRecord{}, errors.New(op.Error) + } + + select { + case <-ctx.Done(): + cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = vmCreateCancelFunc(cancelCtx, socketPath, op.ID) + return model.VMRecord{}, ctx.Err() + case <-time.After(200 * time.Millisecond): + } + + status, err := vmCreateStatusFunc(ctx, socketPath, op.ID) + if err != nil { + if ctx.Err() != nil { + cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = vmCreateCancelFunc(cancelCtx, socketPath, op.ID) + return model.VMRecord{}, ctx.Err() + } + return model.VMRecord{}, err + } + op = status.Operation + renderer.render(op) + } +} + +type vmCreateProgressRenderer struct { + out io.Writer + enabled bool + lastLine string +} + +func newVMCreateProgressRenderer(out io.Writer) *vmCreateProgressRenderer { + return &vmCreateProgressRenderer{ + out: out, + enabled: writerSupportsProgress(out), + } +} + +func (r *vmCreateProgressRenderer) render(op api.VMCreateOperation) { + if r == nil || !r.enabled { + return + } + line := formatVMCreateProgress(op) + if line == "" || line == r.lastLine { + return + } + r.lastLine = line + _, _ = fmt.Fprintln(r.out, line) +} + +func writerSupportsProgress(out io.Writer) bool { + file, ok := out.(*os.File) + if !ok { + return false + } + info, err := file.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +func formatVMCreateProgress(op api.VMCreateOperation) string { + stage := strings.TrimSpace(op.Stage) + detail := strings.TrimSpace(op.Detail) + label := vmCreateStageLabel(stage) + if label == "" && detail == "" { + return "" + } + if label == "" { + return "[vm create] " + detail + } + if detail == "" { + return "[vm create] " + label + } + return "[vm create] " + label + ": " + detail +} + +func vmCreateStageLabel(stage string) string { + switch strings.TrimSpace(stage) { + case "queued": + return "queued" + case "resolve_image": + return "resolving image" + case "reserve_vm": + return "allocating vm" + case "preflight": + return "checking host prerequisites" + case "prepare_rootfs": + return "preparing root filesystem" + case "prepare_host_features": + return "preparing host features" + case "prepare_work_disk": + return "preparing work disk" + case "boot_firecracker": + return "starting firecracker" + case "wait_vsock_agent": + return "waiting for vsock agent" + case "wait_guest_ready": + return "waiting for guest services" + case "wait_opencode": + return "waiting for opencode" + case "apply_dns": + return "publishing dns" + case "apply_nat": + return "configuring nat" + case "finalize": + return "finalizing" + case "ready": + return "ready" + default: + return strings.ReplaceAll(stage, "_", " ") + } +} + func shortID(id string) string { if len(id) <= 12 { return id diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index da0869d..e64f6be 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -170,6 +170,17 @@ func TestImageRegisterFlagsExist(t *testing.T) { } } +func TestImagePromoteCommandExists(t *testing.T) { + root := NewBangerCommand() + image, _, err := root.Find([]string{"image"}) + if err != nil { + t.Fatalf("find image: %v", err) + } + if _, _, err := image.Find([]string{"promote"}); err != nil { + t.Fatalf("find promote: %v", err) + } +} + func TestVMKillFlagsExist(t *testing.T) { root := NewBangerCommand() vm, _, err := root.Find([]string{"vm"}) @@ -304,6 +315,95 @@ func TestVMCreateParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) { } } +func TestRunVMCreatePollsUntilDone(t *testing.T) { + origBegin := vmCreateBeginFunc + origStatus := vmCreateStatusFunc + origCancel := vmCreateCancelFunc + t.Cleanup(func() { + vmCreateBeginFunc = origBegin + vmCreateStatusFunc = origStatus + vmCreateCancelFunc = origCancel + }) + + vm := model.VMRecord{ + ID: "vm-id", + Name: "devbox", + Spec: model.VMSpec{WorkDiskSizeBytes: model.DefaultWorkDiskSize}, + Runtime: model.VMRuntime{ + State: model.VMStateRunning, + GuestIP: "172.16.0.2", + DNSName: "devbox.vm", + }, + } + vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) { + return api.VMCreateBeginResult{ + Operation: api.VMCreateOperation{ + ID: "op-1", + Stage: "prepare_work_disk", + Detail: "cloning work seed", + }, + }, nil + } + statusCalls := 0 + vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) { + statusCalls++ + if statusCalls == 1 { + return api.VMCreateStatusResult{ + Operation: api.VMCreateOperation{ + ID: "op-1", + Stage: "wait_opencode", + Detail: "waiting for opencode on guest port 4096", + }, + }, nil + } + return api.VMCreateStatusResult{ + Operation: api.VMCreateOperation{ + ID: "op-1", + Stage: "ready", + Detail: "vm is ready", + Done: true, + Success: true, + VM: &vm, + }, + }, nil + } + vmCreateCancelFunc = func(context.Context, string, string) error { + t.Fatal("cancel should not be called") + return nil + } + + got, err := runVMCreate(context.Background(), "/tmp/bangerd.sock", &bytes.Buffer{}, api.VMCreateParams{Name: "devbox"}) + if err != nil { + t.Fatalf("runVMCreate: %v", err) + } + if got.Name != vm.Name || got.Runtime.GuestIP != vm.Runtime.GuestIP { + t.Fatalf("vm = %+v, want %+v", got, vm) + } + if statusCalls != 2 { + t.Fatalf("statusCalls = %d, want 2", statusCalls) + } +} + +func TestVMCreateProgressRendererSuppressesDuplicateLines(t *testing.T) { + var stderr bytes.Buffer + renderer := &vmCreateProgressRenderer{out: &stderr, enabled: true} + + renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"}) + renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"}) + renderer.render(api.VMCreateOperation{Stage: "wait_opencode", Detail: "waiting for opencode on guest port 4096"}) + + lines := strings.Split(strings.TrimSpace(stderr.String()), "\n") + if len(lines) != 2 { + t.Fatalf("rendered lines = %q, want 2 lines", stderr.String()) + } + if lines[0] != "[vm create] preparing work disk: cloning work seed" { + t.Fatalf("first line = %q", lines[0]) + } + if lines[1] != "[vm create] waiting for opencode: waiting for opencode on guest port 4096" { + t.Fatalf("second line = %q", lines[1]) + } +} + func TestVMSetParamsFromFlagsConflict(t *testing.T) { if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil { t.Fatal("expected nat conflict error") diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index c5dabb8..0b866bc 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -56,6 +56,7 @@ func (d *Daemon) registeredCapabilities() []vmCapability { } return []vmCapability{ workDiskCapability{}, + opencodeCapability{}, dnsCapability{}, natCapability{}, } @@ -103,6 +104,14 @@ func (d *Daemon) prepareCapabilityHosts(ctx context.Context, vm *model.VMRecord, func (d *Daemon) postStartCapabilities(ctx context.Context, vm model.VMRecord, image model.Image) error { for _, capability := range d.registeredCapabilities() { + switch capability.Name() { + case "dns": + vmCreateStage(ctx, "apply_dns", "publishing vm dns record") + case "nat": + if vm.Spec.NATEnabled { + vmCreateStage(ctx, "apply_nat", "configuring nat") + } + } if hook, ok := capability.(postStartCapability); ok { if err := hook.PostStart(ctx, d, vm, image); err != nil { return err @@ -191,10 +200,11 @@ func (workDiskCapability) ContributeMachine(cfg *firecracker.MachineConfig, vm m } func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.VMRecord, image model.Image) error { - if err := d.ensureWorkDisk(ctx, vm, image); err != nil { + prep, err := d.ensureWorkDisk(ctx, vm, image) + if err != nil { return err } - return d.ensureAuthorizedKeyOnWorkDisk(ctx, vm) + return d.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep) } func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) { diff --git a/internal/daemon/capabilities_test.go b/internal/daemon/capabilities_test.go index f2ae707..13a6350 100644 --- a/internal/daemon/capabilities_test.go +++ b/internal/daemon/capabilities_test.go @@ -143,3 +143,15 @@ func TestContributeHooksPopulateGuestAndMachineConfig(t *testing.T) { t.Fatalf("guest fstab = %q, want %q", fstab, want) } } + +func TestRegisteredCapabilitiesIncludeOpencode(t *testing.T) { + d := &Daemon{} + var names []string + for _, capability := range d.registeredCapabilities() { + names = append(names, capability.Name()) + } + want := []string{"work-disk", "opencode", "dns", "nat"} + if !reflect.DeepEqual(names, want) { + t.Fatalf("capabilities = %v, want %v", names, want) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a226d15..6eb0ea6 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -32,6 +32,8 @@ type Daemon struct { runner system.CommandRunner logger *slog.Logger mu sync.Mutex + createOpsMu sync.Mutex + createOps map[string]*vmCreateOperationState vmLocksMu sync.Mutex vmLocks map[string]*sync.Mutex tapPoolMu sync.Mutex @@ -249,6 +251,27 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } vm, err := d.CreateVM(ctx, params) return marshalResultOrError(api.VMShowResult{VM: vm}, err) + case "vm.create.begin": + params, err := rpc.DecodeParams[api.VMCreateParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + op, err := d.BeginVMCreate(ctx, params) + return marshalResultOrError(api.VMCreateBeginResult{Operation: op}, err) + case "vm.create.status": + params, err := rpc.DecodeParams[api.VMCreateStatusParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + op, err := d.VMCreateStatus(ctx, params.ID) + return marshalResultOrError(api.VMCreateStatusResult{Operation: op}, err) + case "vm.create.cancel": + params, err := rpc.DecodeParams[api.VMCreateStatusParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + err = d.CancelVMCreate(ctx, params.ID) + return marshalResultOrError(api.Empty{}, err) case "vm.list": vms, err := d.store.ListVMs(ctx) return marshalResultOrError(api.VMListResult{VMs: vms}, err) @@ -376,6 +399,13 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } image, err := d.RegisterImage(ctx, params) return marshalResultOrError(api.ImageShowResult{Image: image}, err) + case "image.promote": + params, err := rpc.DecodeParams[api.ImageRefParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + image, err := d.PromoteImage(ctx, params.IDOrName) + return marshalResultOrError(api.ImageShowResult{Image: image}, err) case "image.delete": params, err := rpc.DecodeParams[api.ImageRefParams](req) if err != nil { @@ -405,6 +435,7 @@ func (d *Daemon) backgroundLoop() { if err := d.stopStaleVMs(context.Background()); err != nil && d.logger != nil { d.logger.Error("background stale sweep failed", "error", err.Error()) } + d.pruneVMCreateOperations(time.Now().Add(-10 * time.Minute)) } } } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 7ecd4e2..cf5ef8a 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -2,6 +2,7 @@ package daemon import ( "bufio" + "bytes" "context" "encoding/json" "net" @@ -13,6 +14,7 @@ import ( "banger/internal/api" "banger/internal/model" + "banger/internal/paths" "banger/internal/rpc" "banger/internal/store" ) @@ -368,6 +370,178 @@ func TestRegisterImageRejectsManagedOverwrite(t *testing.T) { } } +func TestPromoteImageCopiesArtifactsAndPreservesIdentity(t *testing.T) { + dir := t.TempDir() + rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) + workSeed := filepath.Join(dir, "rootfs-docker.work-seed.ext4") + workSeedContent := []byte("seed-data") + if err := os.WriteFile(workSeed, workSeedContent, 0o644); err != nil { + t.Fatalf("WriteFile(workSeed): %v", err) + } + + db := openDefaultImageStore(t, dir) + now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC) + existing := model.Image{ + ID: "promote-image-id", + Name: "default", + Managed: false, + RootfsPath: rootfs, + WorkSeedPath: workSeed, + KernelPath: kernel, + InitrdPath: initrd, + ModulesDir: modulesDir, + PackagesPath: packages, + Docker: true, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.UpsertImage(context.Background(), existing); err != nil { + t.Fatalf("UpsertImage: %v", err) + } + vm := testVM("uses-default", existing.ID, "172.16.0.44") + if err := db.UpsertVM(context.Background(), vm); err != nil { + t.Fatalf("UpsertVM: %v", err) + } + + d := &Daemon{ + layout: modelPathsLayoutForTest(dir), + store: db, + } + + image, err := d.PromoteImage(context.Background(), "default") + if err != nil { + t.Fatalf("PromoteImage: %v", err) + } + if !image.Managed { + t.Fatal("promoted image should be managed") + } + if image.ID != existing.ID || image.Name != existing.Name { + t.Fatalf("promoted image identity changed: %+v", image) + } + if !image.CreatedAt.Equal(existing.CreatedAt) { + t.Fatalf("CreatedAt = %s, want preserved %s", image.CreatedAt, existing.CreatedAt) + } + if !image.UpdatedAt.After(existing.UpdatedAt) { + t.Fatalf("UpdatedAt = %s, want newer than %s", image.UpdatedAt, existing.UpdatedAt) + } + wantArtifactDir := filepath.Join(d.layout.ImagesDir, existing.ID) + if image.ArtifactDir != wantArtifactDir { + t.Fatalf("ArtifactDir = %q, want %q", image.ArtifactDir, wantArtifactDir) + } + if image.RootfsPath != filepath.Join(wantArtifactDir, "rootfs.ext4") { + t.Fatalf("RootfsPath = %q, want managed copy", image.RootfsPath) + } + if image.WorkSeedPath != filepath.Join(wantArtifactDir, "work-seed.ext4") { + t.Fatalf("WorkSeedPath = %q, want managed copy", image.WorkSeedPath) + } + if image.KernelPath != kernel || image.InitrdPath != initrd || image.ModulesDir != modulesDir || image.PackagesPath != packages { + t.Fatalf("boot support paths changed unexpectedly: %+v", image) + } + + rootfsContent, err := os.ReadFile(rootfs) + if err != nil { + t.Fatalf("ReadFile(rootfs): %v", err) + } + managedRootfsContent, err := os.ReadFile(image.RootfsPath) + if err != nil { + t.Fatalf("ReadFile(managed rootfs): %v", err) + } + if !bytes.Equal(managedRootfsContent, rootfsContent) { + t.Fatal("managed rootfs copy content mismatch") + } + managedWorkSeedContent, err := os.ReadFile(image.WorkSeedPath) + if err != nil { + t.Fatalf("ReadFile(managed work seed): %v", err) + } + if !bytes.Equal(managedWorkSeedContent, workSeedContent) { + t.Fatal("managed work seed copy content mismatch") + } + + got, err := db.GetImageByName(context.Background(), "default") + if err != nil { + t.Fatalf("GetImageByName: %v", err) + } + if got.RootfsPath != image.RootfsPath || !got.Managed || got.ArtifactDir != image.ArtifactDir { + t.Fatalf("stored promoted image = %+v, want %+v", got, image) + } + gotVM, err := db.GetVMByID(context.Background(), vm.ID) + if err != nil { + t.Fatalf("GetVMByID: %v", err) + } + if gotVM.ImageID != existing.ID { + t.Fatalf("VM image ID = %q, want preserved %q", gotVM.ImageID, existing.ID) + } +} + +func TestPromoteImageRejectsManagedImage(t *testing.T) { + dir := t.TempDir() + rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) + db := openDefaultImageStore(t, dir) + now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC) + if err := db.UpsertImage(context.Background(), model.Image{ + ID: "managed-id", + Name: "default", + Managed: true, + ArtifactDir: filepath.Join(dir, "images", "managed-id"), + RootfsPath: rootfs, + KernelPath: kernel, + InitrdPath: initrd, + ModulesDir: modulesDir, + PackagesPath: packages, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("UpsertImage: %v", err) + } + d := &Daemon{ + layout: modelPathsLayoutForTest(dir), + store: db, + } + + _, err := d.PromoteImage(context.Background(), "default") + if err == nil || !strings.Contains(err.Error(), "already managed") { + t.Fatalf("PromoteImage(managed) error = %v", err) + } +} + +func TestPromoteImageSkipsMissingWorkSeed(t *testing.T) { + dir := t.TempDir() + rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) + db := openDefaultImageStore(t, dir) + now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC) + existing := model.Image{ + ID: "promote-missing-seed", + Name: "default", + Managed: false, + RootfsPath: rootfs, + WorkSeedPath: filepath.Join(dir, "missing.work-seed.ext4"), + KernelPath: kernel, + InitrdPath: initrd, + ModulesDir: modulesDir, + PackagesPath: packages, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.UpsertImage(context.Background(), existing); err != nil { + t.Fatalf("UpsertImage: %v", err) + } + d := &Daemon{ + layout: modelPathsLayoutForTest(dir), + store: db, + } + + image, err := d.PromoteImage(context.Background(), "default") + if err != nil { + t.Fatalf("PromoteImage: %v", err) + } + if image.WorkSeedPath != "" { + t.Fatalf("WorkSeedPath = %q, want empty for missing source work seed", image.WorkSeedPath) + } + if _, err := os.Stat(filepath.Join(image.ArtifactDir, "work-seed.ext4")); !os.IsNotExist(err) { + t.Fatalf("managed work-seed should not exist, stat error = %v", err) + } +} + func openDefaultImageStore(t *testing.T, dir string) *store.Store { t.Helper() db, err := store.Open(filepath.Join(dir, "state.db")) @@ -405,6 +579,12 @@ func writeDefaultImageArtifacts(t *testing.T, dir string) (rootfs, kernel, initr return rootfs, kernel, initrd, modulesDir, packages } +func modelPathsLayoutForTest(dir string) paths.Layout { + return paths.Layout{ + ImagesDir: filepath.Join(dir, "images"), + } +} + func TestStartVMDNSFailsWhenAddressBusy(t *testing.T) { t.Parallel() diff --git a/internal/daemon/fastpath_test.go b/internal/daemon/fastpath_test.go index b0f327d..aeafe7e 100644 --- a/internal/daemon/fastpath_test.go +++ b/internal/daemon/fastpath_test.go @@ -2,12 +2,17 @@ package daemon import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "errors" "os" "path/filepath" "strconv" "testing" + "banger/internal/guest" "banger/internal/model" ) @@ -34,7 +39,7 @@ func TestEnsureWorkDiskClonesSeedImageAndResizes(t *testing.T) { image := testImage("image-seeded") image.WorkSeedPath = seedPath - if err := d.ensureWorkDisk(context.Background(), &vm, image); err != nil { + if _, err := d.ensureWorkDisk(context.Background(), &vm, image); err != nil { t.Fatalf("ensureWorkDisk: %v", err) } runner.assertExhausted() @@ -90,3 +95,38 @@ func TestTapPoolWarmsAndReusesIdleTap(t *testing.T) { } runner.assertExhausted() } + +func TestEnsureAuthorizedKeyOnWorkDiskSkipsRepairForMatchingSeededFingerprint(t *testing.T) { + t.Parallel() + + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + sshKeyPath := filepath.Join(t.TempDir(), "id_rsa") + if err := os.WriteFile(sshKeyPath, privateKeyPEM, 0o600); err != nil { + t.Fatalf("WriteFile(private key): %v", err) + } + fingerprint, err := guest.AuthorizedPublicKeyFingerprint(sshKeyPath) + if err != nil { + t.Fatalf("AuthorizedPublicKeyFingerprint: %v", err) + } + + runner := &scriptedRunner{t: t} + d := &Daemon{ + runner: runner, + config: model.DaemonConfig{SSHKeyPath: sshKeyPath}, + } + vm := testVM("seeded-fastpath", "image-seeded-fastpath", "172.16.0.62") + vm.Runtime.WorkDiskPath = filepath.Join(t.TempDir(), "root.ext4") + image := model.Image{SeededSSHPublicKeyFingerprint: fingerprint} + + if err := d.ensureAuthorizedKeyOnWorkDisk(context.Background(), &vm, image, workDiskPreparation{ClonedFromSeed: true}); err != nil { + t.Fatalf("ensureAuthorizedKeyOnWorkDisk: %v", err) + } + runner.assertExhausted() +} diff --git a/internal/daemon/image_seed.go b/internal/daemon/image_seed.go new file mode 100644 index 0000000..97f6c34 --- /dev/null +++ b/internal/daemon/image_seed.go @@ -0,0 +1,86 @@ +package daemon + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "banger/internal/guest" + "banger/internal/model" + "banger/internal/system" +) + +func (d *Daemon) seedAuthorizedKeyOnExt4Image(ctx context.Context, imagePath string) (string, error) { + if strings.TrimSpace(d.config.SSHKeyPath) == "" { + return "", nil + } + fingerprint, err := guest.AuthorizedPublicKeyFingerprint(d.config.SSHKeyPath) + if err != nil { + return "", fmt.Errorf("derive authorized ssh key fingerprint: %w", err) + } + publicKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath) + if err != nil { + return "", fmt.Errorf("derive authorized ssh key: %w", err) + } + mountDir, cleanup, err := system.MountTempDir(ctx, d.runner, imagePath, false) + if err != nil { + return "", err + } + defer cleanup() + + if err := d.flattenNestedWorkHome(ctx, mountDir); err != nil { + return "", err + } + + sshDir := filepath.Join(mountDir, ".ssh") + if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", sshDir); err != nil { + return "", err + } + if _, err := d.runner.RunSudo(ctx, "chmod", "700", sshDir); err != nil { + return "", err + } + + authorizedKeysPath := filepath.Join(sshDir, "authorized_keys") + existing, err := d.runner.RunSudo(ctx, "cat", authorizedKeysPath) + if err != nil { + existing = nil + } + merged := mergeAuthorizedKey(existing, publicKey) + tmpFile, err := os.CreateTemp("", "banger-image-authorized-keys-*") + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + if _, err := tmpFile.Write(merged); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + return "", err + } + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return "", err + } + defer os.Remove(tmpPath) + if _, err := d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil { + return "", err + } + return fingerprint, nil +} + +func (d *Daemon) refreshManagedWorkSeedFingerprint(ctx context.Context, image model.Image, fingerprint string) error { + if !image.Managed || strings.TrimSpace(image.WorkSeedPath) == "" || strings.TrimSpace(fingerprint) == "" { + return nil + } + seededFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, image.WorkSeedPath) + if err != nil { + return err + } + if seededFingerprint == "" || seededFingerprint == image.SeededSSHPublicKeyFingerprint { + return nil + } + image.SeededSSHPublicKeyFingerprint = seededFingerprint + image.UpdatedAt = model.Now() + return d.store.UpsertImage(ctx, image) +} diff --git a/internal/daemon/imagebuild.go b/internal/daemon/imagebuild.go index 7848335..bccf6f3 100644 --- a/internal/daemon/imagebuild.go +++ b/internal/daemon/imagebuild.go @@ -14,8 +14,10 @@ import ( "banger/internal/firecracker" "banger/internal/guest" + "banger/internal/guestnet" "banger/internal/hostnat" "banger/internal/model" + "banger/internal/opencode" "banger/internal/system" "banger/internal/vsockagent" ) @@ -103,6 +105,10 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) ( return err } defer client.Close() + authorizedKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath) + if err != nil { + return err + } helperBytes, err := os.ReadFile(d.config.VSockAgentPath) if err != nil { @@ -117,7 +123,7 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) ( if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil { return err } - if err := client.RunScript(ctx, buildProvisionScript(vm.Name, d.config.DefaultDNS, packages, spec.InstallDocker), spec.BuildLog); err != nil { + if err := client.RunScript(ctx, buildProvisionScript(vm.Name, d.config.DefaultDNS, string(authorizedKey), packages, spec.InstallDocker), spec.BuildLog); err != nil { return err } if strings.TrimSpace(spec.ModulesDir) != "" { @@ -250,7 +256,7 @@ func (d *Daemon) shutdownImageBuildVM(ctx context.Context, vm imageBuildVM) erro return d.waitForExit(ctx, vm.PID, vm.APISock, 15*time.Second) } -func buildProvisionScript(vmName, dnsServer string, packages []string, installDocker bool) string { +func buildProvisionScript(vmName, dnsServer, authorizedKey string, packages []string, installDocker bool) string { var script bytes.Buffer script.WriteString("set -euo pipefail\n") fmt.Fprintf(&script, "printf 'nameserver %%s\\n' %s > /etc/resolv.conf\n", shellQuote(dnsServer)) @@ -260,11 +266,14 @@ func buildProvisionScript(vmName, dnsServer string, packages []string, installDo script.WriteString("sed -i '\\|^/dev/vdb[[:space:]]\\+/home[[:space:]]|d; \\|^/dev/vdc[[:space:]]\\+/var[[:space:]]|d' /etc/fstab\n") script.WriteString("if ! grep -q '^tmpfs /run ' /etc/fstab; then echo 'tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0' >> /etc/fstab; fi\n") script.WriteString("if ! grep -q '^tmpfs /tmp ' /etc/fstab; then echo 'tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0' >> /etc/fstab; fi\n") + appendAuthorizedKeySetup(&script, authorizedKey) script.WriteString("apt-get update\n") script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y upgrade\n") fmt.Fprintf(&script, "PACKAGES=%s\n", shellArray(packages)) script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y install \"${PACKAGES[@]}\"\n") + appendGuestNetworkSetup(&script) appendMiseSetup(&script) + appendOpenCodeServiceSetup(&script) appendTmuxSetup(&script) appendVSockPingSetup(&script) if installDocker { @@ -279,6 +288,15 @@ func buildProvisionScript(vmName, dnsServer string, packages []string, installDo return script.String() } +func appendAuthorizedKeySetup(script *bytes.Buffer, authorizedKey string) { + script.WriteString("mkdir -p /root/.ssh\n") + script.WriteString("chmod 700 /root/.ssh\n") + script.WriteString("cat > /root/.ssh/authorized_keys <<'EOF'\n") + script.WriteString(strings.TrimSpace(authorizedKey)) + script.WriteString("\nEOF\n") + script.WriteString("chmod 600 /root/.ssh/authorized_keys\n") +} + func buildModulesCommand(modulesBase string) string { return fmt.Sprintf("bash -se <<'EOF'\nset -euo pipefail\nmkdir -p /lib/modules\ntar -C /lib/modules -xf -\ndepmod -a %s\nmkdir -p /etc/modules-load.d\nprintf 'nf_tables\\nnft_chain_nat\\nveth\\nbr_netfilter\\noverlay\\n' > /etc/modules-load.d/docker-netfilter.conf\nmkdir -p /etc/sysctl.d\ncat > /etc/sysctl.d/99-docker.conf <<'SYSCTL'\nnet.bridge.bridge-nf-call-iptables = 1\nnet.bridge.bridge-nf-call-ip6tables = 1\nnet.ipv4.ip_forward = 1\nSYSCTL\nsysctl --system >/dev/null 2>&1 || true\nEOF", shellQuote(modulesBase)) } @@ -286,6 +304,9 @@ func buildModulesCommand(modulesBase string) string { func appendMiseSetup(script *bytes.Buffer) { fmt.Fprintf(script, "curl -fsSL https://mise.run | MISE_INSTALL_PATH=%s MISE_VERSION=%s sh\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultMiseVersion)) fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultOpenCodeTool)) + fmt.Fprintf(script, "%s reshim\n", shellQuote(defaultMiseInstallPath)) + fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi\n", shellQuote(opencode.ShimPath)) + fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(opencode.ShimPath), shellQuote(opencode.GuestBinaryPath)) script.WriteString("mkdir -p /etc/profile.d\n") script.WriteString("cat > /etc/profile.d/mise.sh <<'EOF'\n") fmt.Fprintf(script, "if [ -n \"${BASH_VERSION:-}\" ] && [ -x %s ]; then\n", shellQuote(defaultMiseInstallPath)) @@ -296,6 +317,28 @@ func appendMiseSetup(script *bytes.Buffer) { appendLineIfMissing(script, "/etc/bash.bashrc", defaultMiseActivateLine) } +func appendGuestNetworkSetup(script *bytes.Buffer) { + script.WriteString("mkdir -p /usr/local/libexec /etc/systemd/system\n") + script.WriteString("cat > " + guestnet.GuestScriptPath + " <<'EOF'\n") + script.WriteString(guestnet.BootstrapScript()) + script.WriteString("EOF\n") + script.WriteString("chmod 0755 " + guestnet.GuestScriptPath + "\n") + script.WriteString("cat > /etc/systemd/system/" + guestnet.SystemdServiceName + " <<'EOF'\n") + script.WriteString(guestnet.SystemdServiceUnit()) + script.WriteString("EOF\n") + script.WriteString("chmod 0644 /etc/systemd/system/" + guestnet.SystemdServiceName + "\n") + script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + guestnet.SystemdServiceName + " || true; fi\n") +} + +func appendOpenCodeServiceSetup(script *bytes.Buffer) { + script.WriteString("mkdir -p /etc/systemd/system\n") + script.WriteString("cat > /etc/systemd/system/" + opencode.ServiceName + " <<'EOF'\n") + script.WriteString(opencode.ServiceUnit()) + script.WriteString("EOF\n") + script.WriteString("chmod 0644 /etc/systemd/system/" + opencode.ServiceName + "\n") + script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + opencode.ServiceName + " || true; fi\n") +} + func appendTmuxSetup(script *bytes.Buffer) { fmt.Fprintf(script, "TMUX_PLUGIN_DIR=%s\n", shellQuote(defaultTMUXPluginDir)) fmt.Fprintf(script, "TMUX_RESURRECT_DIR=%s\n", shellQuote(defaultTMUXResurrectDir)) diff --git a/internal/daemon/imagebuild_test.go b/internal/daemon/imagebuild_test.go index f8cc09d..3a42612 100644 --- a/internal/daemon/imagebuild_test.go +++ b/internal/daemon/imagebuild_test.go @@ -8,14 +8,28 @@ import ( func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) { t.Parallel() - script := buildProvisionScript("devbox", "1.1.1.1", []string{"git", "curl"}, false) + script := buildProvisionScript("devbox", "1.1.1.1", "ssh-ed25519 AAAATESTKEY banger", []string{"git", "curl"}, false) for _, snippet := range []string{ + "mkdir -p /root/.ssh", + "cat > /root/.ssh/authorized_keys <<'EOF'", + "ssh-ed25519 AAAATESTKEY banger", + "cat > /usr/local/libexec/banger-network-bootstrap <<'EOF'", + "ip addr replace \"$guest_ip/$prefix\" dev \"$iface\"", + "cat > /etc/systemd/system/banger-network.service <<'EOF'", + "systemctl enable --now banger-network.service || true", "curl -fsSL https://mise.run | MISE_INSTALL_PATH='/usr/local/bin/mise' MISE_VERSION='v2025.12.0' sh", "'/usr/local/bin/mise' use -g 'github:anomalyco/opencode'", + "'/usr/local/bin/mise' reshim", + "if [[ ! -e '/root/.local/share/mise/shims/opencode' ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi", + "ln -snf '/root/.local/share/mise/shims/opencode' '/usr/local/bin/opencode'", "cat > /etc/profile.d/mise.sh <<'EOF'", "if [ -n \"${BASH_VERSION:-}\" ] && [ -x '/usr/local/bin/mise' ]; then", `eval "$(/usr/local/bin/mise activate bash)"`, `if ! grep -Fqx 'eval "$(/usr/local/bin/mise activate bash)"' '/etc/bash.bashrc'; then`, + "cat > /etc/systemd/system/banger-opencode.service <<'EOF'", + "RequiresMountsFor=/root", + "ExecStart=/usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096", + "systemctl enable --now banger-opencode.service || true", `git clone --depth 1 'https://github.com/tmux-plugins/tpm' "$TMUX_PLUGIN_DIR/tpm"`, `git clone --depth 1 'https://github.com/tmux-plugins/tmux-resurrect' "$TMUX_PLUGIN_DIR/tmux-resurrect"`, `git clone --depth 1 'https://github.com/tmux-plugins/tmux-continuum' "$TMUX_PLUGIN_DIR/tmux-continuum"`, diff --git a/internal/daemon/images.go b/internal/daemon/images.go index 6efdc9e..d24aa9c 100644 --- a/internal/daemon/images.go +++ b/internal/daemon/images.go @@ -103,26 +103,33 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i _ = os.RemoveAll(artifactDir) return model.Image{}, err } + seededSSHPublicKeyFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath) + if err != nil { + _ = logFile.Sync() + _ = os.RemoveAll(artifactDir) + return model.Image{}, err + } if err := writePackagesMetadata(rootfsPath, d.config.DefaultPackagesFile); err != nil { _ = logFile.Sync() _ = os.RemoveAll(artifactDir) return model.Image{}, err } image = model.Image{ - ID: id, - Name: name, - Managed: true, - ArtifactDir: artifactDir, - RootfsPath: rootfsPath, - WorkSeedPath: workSeedPath, - KernelPath: kernelPath, - InitrdPath: initrdPath, - ModulesDir: modulesDir, - PackagesPath: d.config.DefaultPackagesFile, - BuildSize: params.Size, - Docker: params.Docker, - CreatedAt: now, - UpdatedAt: now, + ID: id, + Name: name, + Managed: true, + ArtifactDir: artifactDir, + RootfsPath: rootfsPath, + WorkSeedPath: workSeedPath, + KernelPath: kernelPath, + InitrdPath: initrdPath, + ModulesDir: modulesDir, + PackagesPath: d.config.DefaultPackagesFile, + BuildSize: params.Size, + SeededSSHPublicKeyFingerprint: seededSSHPublicKeyFingerprint, + Docker: params.Docker, + CreatedAt: now, + UpdatedAt: now, } if err := d.store.UpsertImage(ctx, image); err != nil { return model.Image{}, err @@ -220,6 +227,105 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara return image, nil } +func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model.Image, err error) { + d.mu.Lock() + defer d.mu.Unlock() + + op := d.beginOperation("image.promote") + defer func() { + if err != nil { + op.fail(err, imageLogAttrs(image)...) + return + } + op.done(imageLogAttrs(image)...) + }() + + image, err = d.FindImage(ctx, idOrName) + if err != nil { + return model.Image{}, err + } + if image.Managed { + return model.Image{}, fmt.Errorf("image %s is already managed", image.Name) + } + if err := validateImagePromotePaths(image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir, image.PackagesPath); err != nil { + return model.Image{}, err + } + if strings.TrimSpace(d.layout.ImagesDir) == "" { + return model.Image{}, errors.New("images dir is not configured") + } + if err := os.MkdirAll(d.layout.ImagesDir, 0o755); err != nil { + return model.Image{}, err + } + + artifactDir := filepath.Join(d.layout.ImagesDir, image.ID) + if _, statErr := os.Stat(artifactDir); statErr == nil { + return model.Image{}, fmt.Errorf("artifact dir already exists: %s", artifactDir) + } else if !os.IsNotExist(statErr) { + return model.Image{}, statErr + } + + stageDir, err := os.MkdirTemp(d.layout.ImagesDir, image.ID+".promote-") + if err != nil { + return model.Image{}, err + } + cleanupStage := true + defer func() { + if cleanupStage { + _ = os.RemoveAll(stageDir) + } + }() + + rootfsPath := filepath.Join(stageDir, "rootfs.ext4") + op.stage("copy_rootfs", "source_rootfs_path", image.RootfsPath, "target_rootfs_path", rootfsPath) + if err := system.CopyFilePreferClone(image.RootfsPath, rootfsPath); err != nil { + return model.Image{}, err + } + + workSeedPath := "" + if image.WorkSeedPath != "" { + if _, statErr := os.Stat(image.WorkSeedPath); statErr != nil { + if os.IsNotExist(statErr) { + op.stage("skip_missing_work_seed", "source_work_seed_path", image.WorkSeedPath) + image.WorkSeedPath = "" + } else { + return model.Image{}, statErr + } + } + } + if image.WorkSeedPath != "" { + workSeedPath = filepath.Join(stageDir, "work-seed.ext4") + op.stage("copy_work_seed", "source_work_seed_path", image.WorkSeedPath, "target_work_seed_path", workSeedPath) + if err := system.CopyFilePreferClone(image.WorkSeedPath, workSeedPath); err != nil { + return model.Image{}, err + } + image.SeededSSHPublicKeyFingerprint, err = d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath) + if err != nil { + return model.Image{}, err + } + } else { + image.SeededSSHPublicKeyFingerprint = "" + } + + op.stage("activate_artifacts", "artifact_dir", artifactDir) + if err := os.Rename(stageDir, artifactDir); err != nil { + return model.Image{}, err + } + cleanupStage = false + + image.Managed = true + image.ArtifactDir = artifactDir + image.RootfsPath = filepath.Join(artifactDir, "rootfs.ext4") + if workSeedPath != "" { + image.WorkSeedPath = filepath.Join(artifactDir, "work-seed.ext4") + } + image.UpdatedAt = model.Now() + if err := d.store.UpsertImage(ctx, image); err != nil { + _ = os.RemoveAll(artifactDir) + return model.Image{}, err + } + return image, nil +} + func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir, packagesPath string) error { checks := system.NewPreflight() checks.RequireFile(rootfsPath, "rootfs image", `pass --rootfs `) @@ -239,6 +345,22 @@ func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath return checks.Err("image register failed") } +func validateImagePromotePaths(rootfsPath, kernelPath, initrdPath, modulesDir, packagesPath string) error { + checks := system.NewPreflight() + checks.RequireFile(rootfsPath, "rootfs image", `re-register the image with a valid rootfs`) + checks.RequireFile(kernelPath, "kernel image", `re-register the image with a valid kernel`) + if initrdPath != "" { + checks.RequireFile(initrdPath, "initrd image", `re-register the image with a valid initrd`) + } + if modulesDir != "" { + checks.RequireDir(modulesDir, "kernel modules dir", `re-register the image with a valid modules dir`) + } + if packagesPath != "" { + checks.RequireFile(packagesPath, "packages manifest", `re-register the image with a valid packages manifest`) + } + return checks.Err("image promote failed") +} + func writePackagesMetadata(rootfsPath, packagesPath string) error { if rootfsPath == "" || packagesPath == "" { return nil diff --git a/internal/daemon/opencode.go b/internal/daemon/opencode.go new file mode 100644 index 0000000..791a5e4 --- /dev/null +++ b/internal/daemon/opencode.go @@ -0,0 +1,18 @@ +package daemon + +import ( + "context" + + "banger/internal/model" + "banger/internal/opencode" +) + +type opencodeCapability struct{} + +func (opencodeCapability) Name() string { return "opencode" } + +func (opencodeCapability) PostStart(ctx context.Context, d *Daemon, vm model.VMRecord, _ model.Image) error { + return opencode.WaitReady(ctx, d.logger, vm.Runtime.VSockPath, func(stage, detail string) { + vmCreateStage(ctx, stage, detail) + }) +} diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index a0507e5..a602f1a 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -49,10 +49,12 @@ func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm mo if imageName == "" { imageName = d.config.DefaultImageName } + vmCreateStage(ctx, "resolve_image", "resolving image") image, err := d.FindImage(ctx, imageName) if err != nil { return model.VMRecord{}, err } + vmCreateStage(ctx, "resolve_image", "using image "+image.Name) op.stage("image_resolved", imageLogAttrs(image)...) name := strings.TrimSpace(params.Name) if name == "" { @@ -126,6 +128,8 @@ func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm mo MetricsPath: filepath.Join(vmDir, "metrics.json"), }, } + vmCreateBindVM(ctx, vm) + vmCreateStage(ctx, "reserve_vm", fmt.Sprintf("allocated %s (%s)", vm.Name, vm.Runtime.GuestIP)) if err := d.store.UpsertVM(ctx, vm); err != nil { return model.VMRecord{}, err } @@ -168,6 +172,7 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod op.done(vmLogAttrs(vm)...) }() op.stage("preflight") + vmCreateStage(ctx, "preflight", "checking host prerequisites") if err := d.validateStartPrereqs(ctx, vm, image); err != nil { return model.VMRecord{}, err } @@ -209,11 +214,13 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod } op.stage("system_overlay", "overlay_path", vm.Runtime.SystemOverlay) + vmCreateStage(ctx, "prepare_rootfs", "preparing system overlay") if err := d.ensureSystemOverlay(ctx, &vm); err != nil { return model.VMRecord{}, err } op.stage("dm_snapshot", "dm_name", dmName) + vmCreateStage(ctx, "prepare_rootfs", "creating root filesystem snapshot") handles, err := d.createDMSnapshot(ctx, image.RootfsPath, vm.Runtime.SystemOverlay, dmName) if err != nil { return model.VMRecord{}, err @@ -241,10 +248,12 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod } op.stage("patch_root_overlay") + vmCreateStage(ctx, "prepare_rootfs", "writing guest configuration") if err := d.patchRootOverlay(ctx, vm, image); err != nil { return cleanupOnErr(err) } op.stage("prepare_host_features") + vmCreateStage(ctx, "prepare_host_features", "preparing host-side vm features") if err := d.prepareCapabilityHosts(ctx, &vm, image); err != nil { return cleanupOnErr(err) } @@ -265,6 +274,7 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod return cleanupOnErr(err) } op.stage("firecracker_launch", "log_path", vm.Runtime.LogPath, "metrics_path", vm.Runtime.MetricsPath) + vmCreateStage(ctx, "boot_firecracker", "starting firecracker") firecrackerCtx := context.Background() machineConfig := firecracker.MachineConfig{ BinaryPath: fcPath, @@ -304,15 +314,18 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod return cleanupOnErr(err) } op.stage("vsock_access", "vsock_path", vm.Runtime.VSockPath, "vsock_cid", vm.Runtime.VSockCID) + vmCreateStage(ctx, "wait_vsock_agent", "waiting for guest vsock agent") if err := d.ensureSocketAccess(ctx, vm.Runtime.VSockPath, "firecracker vsock socket"); err != nil { return cleanupOnErr(err) } op.stage("post_start_features") + vmCreateStage(ctx, "wait_guest_ready", "waiting for guest services") if err := d.postStartCapabilities(ctx, vm, image); err != nil { return cleanupOnErr(err) } system.TouchNow(&vm) op.stage("persist") + vmCreateStage(ctx, "finalize", "saving vm state") if err := d.store.UpsertVM(ctx, vm); err != nil { return cleanupOnErr(err) } @@ -777,58 +790,75 @@ func (d *Daemon) patchRootOverlay(ctx context.Context, vm model.VMRecord, image return nil } -func (d *Daemon) ensureWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image) error { +type workDiskPreparation struct { + ClonedFromSeed bool +} + +func (d *Daemon) ensureWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image) (workDiskPreparation, error) { if exists(vm.Runtime.WorkDiskPath) { - return nil + return workDiskPreparation{}, nil } if exists(image.WorkSeedPath) { + vmCreateStage(ctx, "prepare_work_disk", "cloning work seed") if err := system.CopyFilePreferClone(image.WorkSeedPath, vm.Runtime.WorkDiskPath); err != nil { - return err + return workDiskPreparation{}, err } seedInfo, err := os.Stat(image.WorkSeedPath) if err != nil { - return err + return workDiskPreparation{}, err } if vm.Spec.WorkDiskSizeBytes < seedInfo.Size() { - return fmt.Errorf("requested work disk size %d is smaller than seed image %d", vm.Spec.WorkDiskSizeBytes, seedInfo.Size()) + return workDiskPreparation{}, fmt.Errorf("requested work disk size %d is smaller than seed image %d", vm.Spec.WorkDiskSizeBytes, seedInfo.Size()) } if vm.Spec.WorkDiskSizeBytes > seedInfo.Size() { + vmCreateStage(ctx, "prepare_work_disk", "resizing work disk") if err := system.ResizeExt4Image(ctx, d.runner, vm.Runtime.WorkDiskPath, vm.Spec.WorkDiskSizeBytes); err != nil { - return err + return workDiskPreparation{}, err } } - return nil + return workDiskPreparation{ClonedFromSeed: true}, nil } + vmCreateStage(ctx, "prepare_work_disk", "creating empty work disk") if _, err := d.runner.Run(ctx, "truncate", "-s", strconv.FormatInt(vm.Spec.WorkDiskSizeBytes, 10), vm.Runtime.WorkDiskPath); err != nil { - return err + return workDiskPreparation{}, err } if _, err := d.runner.Run(ctx, "mkfs.ext4", "-F", vm.Runtime.WorkDiskPath); err != nil { - return err + return workDiskPreparation{}, err } rootMount, cleanupRoot, err := system.MountTempDir(ctx, d.runner, vm.Runtime.DMDev, true) if err != nil { - return err + return workDiskPreparation{}, err } defer cleanupRoot() workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false) if err != nil { - return err + return workDiskPreparation{}, err } defer cleanupWork() + vmCreateStage(ctx, "prepare_work_disk", "copying /root into work disk") if err := system.CopyDirContents(ctx, d.runner, filepath.Join(rootMount, "root"), workMount, true); err != nil { - return err + return workDiskPreparation{}, err } if err := d.flattenNestedWorkHome(ctx, workMount); err != nil { - return err + return workDiskPreparation{}, err } - return nil + return workDiskPreparation{}, nil } -func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { +func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image, prep workDiskPreparation) error { + fingerprint, err := guest.AuthorizedPublicKeyFingerprint(d.config.SSHKeyPath) + if err != nil { + return fmt.Errorf("derive authorized ssh key fingerprint: %w", err) + } + if prep.ClonedFromSeed && image.SeededSSHPublicKeyFingerprint != "" && image.SeededSSHPublicKeyFingerprint == fingerprint { + vmCreateStage(ctx, "prepare_work_disk", "using seeded SSH access") + return nil + } publicKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath) if err != nil { return fmt.Errorf("derive authorized ssh key: %w", err) } + vmCreateStage(ctx, "prepare_work_disk", "repairing SSH access on work disk") workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false) if err != nil { return err @@ -873,6 +903,12 @@ func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VM if _, err := d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil { return err } + if prep.ClonedFromSeed && image.Managed { + vmCreateStage(ctx, "prepare_work_disk", "refreshing managed work seed") + if err := d.refreshManagedWorkSeedFingerprint(ctx, image, fingerprint); err != nil { + return err + } + } return nil } diff --git a/internal/daemon/vm_create_ops.go b/internal/daemon/vm_create_ops.go new file mode 100644 index 0000000..0b856a3 --- /dev/null +++ b/internal/daemon/vm_create_ops.go @@ -0,0 +1,205 @@ +package daemon + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "banger/internal/api" + "banger/internal/model" +) + +type vmCreateProgressKey struct{} + +type vmCreateOperationState struct { + mu sync.Mutex + cancel context.CancelFunc + op api.VMCreateOperation +} + +func newVMCreateOperationState() (*vmCreateOperationState, error) { + id, err := model.NewID() + if err != nil { + return nil, err + } + now := model.Now() + return &vmCreateOperationState{ + op: api.VMCreateOperation{ + ID: id, + Stage: "queued", + Detail: "waiting to start", + StartedAt: now, + UpdatedAt: now, + }, + }, nil +} + +func withVMCreateProgress(ctx context.Context, op *vmCreateOperationState) context.Context { + if op == nil { + return ctx + } + return context.WithValue(ctx, vmCreateProgressKey{}, op) +} + +func vmCreateProgressFromContext(ctx context.Context) *vmCreateOperationState { + if ctx == nil { + return nil + } + op, _ := ctx.Value(vmCreateProgressKey{}).(*vmCreateOperationState) + return op +} + +func vmCreateStage(ctx context.Context, stage, detail string) { + if op := vmCreateProgressFromContext(ctx); op != nil { + op.stage(stage, detail) + } +} + +func vmCreateBindVM(ctx context.Context, vm model.VMRecord) { + if op := vmCreateProgressFromContext(ctx); op != nil { + op.bindVM(vm) + } +} + +func (op *vmCreateOperationState) setCancel(cancel context.CancelFunc) { + op.mu.Lock() + defer op.mu.Unlock() + op.cancel = cancel +} + +func (op *vmCreateOperationState) bindVM(vm model.VMRecord) { + op.mu.Lock() + defer op.mu.Unlock() + op.op.VMID = vm.ID + op.op.VMName = vm.Name +} + +func (op *vmCreateOperationState) stage(stage, detail string) { + op.mu.Lock() + defer op.mu.Unlock() + stage = strings.TrimSpace(stage) + detail = strings.TrimSpace(detail) + if stage == "" { + stage = op.op.Stage + } + if stage == op.op.Stage && detail == op.op.Detail { + return + } + op.op.Stage = stage + op.op.Detail = detail + op.op.UpdatedAt = model.Now() +} + +func (op *vmCreateOperationState) done(vm model.VMRecord) { + op.mu.Lock() + defer op.mu.Unlock() + vmCopy := vm + op.op.VMID = vm.ID + op.op.VMName = vm.Name + op.op.Stage = "ready" + op.op.Detail = "vm is ready" + op.op.Done = true + op.op.Success = true + op.op.Error = "" + op.op.VM = &vmCopy + op.op.UpdatedAt = model.Now() +} + +func (op *vmCreateOperationState) fail(err error) { + op.mu.Lock() + defer op.mu.Unlock() + op.op.Done = true + op.op.Success = false + if err != nil { + op.op.Error = err.Error() + } + if strings.TrimSpace(op.op.Detail) == "" { + op.op.Detail = "vm create failed" + } + op.op.UpdatedAt = model.Now() +} + +func (op *vmCreateOperationState) snapshot() api.VMCreateOperation { + op.mu.Lock() + defer op.mu.Unlock() + snapshot := op.op + if snapshot.VM != nil { + vmCopy := *snapshot.VM + snapshot.VM = &vmCopy + } + return snapshot +} + +func (op *vmCreateOperationState) cancelOperation() { + op.mu.Lock() + cancel := op.cancel + op.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (d *Daemon) BeginVMCreate(_ context.Context, params api.VMCreateParams) (api.VMCreateOperation, error) { + op, err := newVMCreateOperationState() + if err != nil { + return api.VMCreateOperation{}, err + } + createCtx, cancel := context.WithCancel(context.Background()) + op.setCancel(cancel) + + d.createOpsMu.Lock() + if d.createOps == nil { + d.createOps = map[string]*vmCreateOperationState{} + } + d.createOps[op.op.ID] = op + d.createOpsMu.Unlock() + + go d.runVMCreateOperation(withVMCreateProgress(createCtx, op), op, params) + return op.snapshot(), nil +} + +func (d *Daemon) runVMCreateOperation(ctx context.Context, op *vmCreateOperationState, params api.VMCreateParams) { + vm, err := d.CreateVM(ctx, params) + if err != nil { + op.fail(err) + return + } + op.done(vm) +} + +func (d *Daemon) VMCreateStatus(_ context.Context, id string) (api.VMCreateOperation, error) { + d.createOpsMu.Lock() + op, ok := d.createOps[strings.TrimSpace(id)] + d.createOpsMu.Unlock() + if !ok { + return api.VMCreateOperation{}, fmt.Errorf("vm create operation not found: %s", id) + } + return op.snapshot(), nil +} + +func (d *Daemon) CancelVMCreate(_ context.Context, id string) error { + d.createOpsMu.Lock() + op, ok := d.createOps[strings.TrimSpace(id)] + d.createOpsMu.Unlock() + if !ok { + return fmt.Errorf("vm create operation not found: %s", id) + } + op.cancelOperation() + return nil +} + +func (d *Daemon) pruneVMCreateOperations(olderThan time.Time) { + d.createOpsMu.Lock() + defer d.createOpsMu.Unlock() + for id, op := range d.createOps { + snapshot := op.snapshot() + if !snapshot.Done { + continue + } + if snapshot.UpdatedAt.Before(olderThan) { + delete(d.createOps, id) + } + } +} diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index 893c216..3298a69 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -716,7 +716,7 @@ func TestEnsureAuthorizedKeyOnWorkDiskRepairsNestedRootLayout(t *testing.T) { vm := testVM("seed-repair", "image-seed-repair", "172.16.0.61") vm.Runtime.WorkDiskPath = workDiskDir - if err := d.ensureAuthorizedKeyOnWorkDisk(context.Background(), &vm); err != nil { + if err := d.ensureAuthorizedKeyOnWorkDisk(context.Background(), &vm, model.Image{}, workDiskPreparation{}); err != nil { t.Fatalf("ensureAuthorizedKeyOnWorkDisk: %v", err) } if _, err := os.Stat(filepath.Join(workDiskDir, "root")); !os.IsNotExist(err) { @@ -748,6 +748,61 @@ func TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) { } } +func TestBeginVMCreateCompletesAndReturnsStatus(t *testing.T) { + t.Parallel() + + ctx := context.Background() + db := openDaemonStore(t) + image := testImage("default") + image.ID = "default-image-id" + image.Name = "default" + if err := db.UpsertImage(ctx, image); err != nil { + t.Fatalf("UpsertImage: %v", err) + } + + d := &Daemon{ + store: db, + layout: paths.Layout{ + VMsDir: t.TempDir(), + }, + config: model.DaemonConfig{ + DefaultImageName: image.Name, + BridgeIP: model.DefaultBridgeIP, + }, + } + + op, err := d.BeginVMCreate(ctx, api.VMCreateParams{Name: "queued", NoStart: true}) + if err != nil { + t.Fatalf("BeginVMCreate: %v", err) + } + if op.ID == "" { + t.Fatal("operation id should be populated") + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + status, err := d.VMCreateStatus(ctx, op.ID) + if err != nil { + t.Fatalf("VMCreateStatus: %v", err) + } + if !status.Done { + time.Sleep(10 * time.Millisecond) + continue + } + if !status.Success { + t.Fatalf("status = %+v, want success", status) + } + if status.VM == nil || status.VM.Name != "queued" { + t.Fatalf("status VM = %+v, want queued vm", status.VM) + } + if status.VM.State != model.VMStateStopped { + t.Fatalf("status VM state = %s, want stopped", status.VM.State) + } + return + } + t.Fatal("vm create operation did not finish before timeout") +} + func TestCreateVMUsesDefaultsWhenCPUAndMemoryOmitted(t *testing.T) { ctx := context.Background() db := openDaemonStore(t) diff --git a/internal/guest/ssh.go b/internal/guest/ssh.go index 01829d0..3422da2 100644 --- a/internal/guest/ssh.go +++ b/internal/guest/ssh.go @@ -4,6 +4,8 @@ import ( "archive/tar" "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -137,6 +139,15 @@ func AuthorizedPublicKey(path string) ([]byte, error) { return ssh.MarshalAuthorizedKey(signer.PublicKey()), nil } +func AuthorizedPublicKeyFingerprint(path string) (string, error) { + key, err := AuthorizedPublicKey(path) + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(strings.TrimSpace(string(key)))) + return hex.EncodeToString(sum[:]), nil +} + func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } diff --git a/internal/guestnet/assets/bootstrap.sh b/internal/guestnet/assets/bootstrap.sh new file mode 100644 index 0000000..38a75ec --- /dev/null +++ b/internal/guestnet/assets/bootstrap.sh @@ -0,0 +1,132 @@ +#!/bin/sh +set -eu + +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +if ! command -v ip >/dev/null 2>&1; then + exit 0 +fi + +cmdline="$(cat /proc/cmdline 2>/dev/null || true)" +ip_arg="" +for arg in $cmdline; do + case "$arg" in + ip=*) + ip_arg="${arg#ip=}" + break + ;; + esac +done + +if [ -z "$ip_arg" ]; then + exit 0 +fi + +field() { + printf '%s' "$ip_arg" | cut -d: -f"$1" +} + +mask_to_prefix() { + case "$1" in + [0-9]|[1-2][0-9]|3[0-2]) + printf '%s\n' "$1" + return 0 + ;; + esac + + prefix=0 + old_ifs=$IFS + IFS=. + set -- $1 + IFS=$old_ifs + if [ "$#" -ne 4 ]; then + return 1 + fi + + for octet in "$@"; do + case "$octet" in + 255) prefix=$((prefix + 8)) ;; + 254) prefix=$((prefix + 7)) ;; + 252) prefix=$((prefix + 6)) ;; + 248) prefix=$((prefix + 5)) ;; + 240) prefix=$((prefix + 4)) ;; + 224) prefix=$((prefix + 3)) ;; + 192) prefix=$((prefix + 2)) ;; + 128) prefix=$((prefix + 1)) ;; + 0) ;; + *) return 1 ;; + esac + done + + printf '%s\n' "$prefix" +} + +find_iface() { + hint="$1" + if [ -n "$hint" ] && [ -d "/sys/class/net/$hint" ]; then + printf '%s\n' "$hint" + return 0 + fi + + for path in /sys/class/net/*; do + [ -e "$path" ] || continue + iface="${path##*/}" + if [ "$iface" = "lo" ]; then + continue + fi + printf '%s\n' "$iface" + return 0 + done + + return 1 +} + +guest_ip="$(field 1)" +gateway_ip="$(field 3)" +netmask="$(field 4)" +iface_hint="$(field 6)" +dns1="$(field 8)" +dns2="$(field 9)" + +if [ -z "$guest_ip" ]; then + exit 0 +fi + +iface="" +attempt=0 +while [ "$attempt" -lt 50 ]; do + iface="$(find_iface "$iface_hint" || true)" + if [ -n "$iface" ]; then + break + fi + attempt=$((attempt + 1)) + sleep 0.2 +done + +if [ -z "$iface" ]; then + exit 0 +fi + +prefix="$(mask_to_prefix "$netmask" || printf '24\n')" + +ip link set "$iface" up +ip addr replace "$guest_ip/$prefix" dev "$iface" + +if [ -n "$gateway_ip" ]; then + ip route replace default via "$gateway_ip" dev "$iface" +fi + +if [ -n "$dns1" ] || [ -n "$dns2" ]; then + tmp_resolv="/tmp/.banger-resolv.conf.$$" + : > "$tmp_resolv" + if [ -n "$dns1" ]; then + printf 'nameserver %s\n' "$dns1" >> "$tmp_resolv" + fi + if [ -n "$dns2" ]; then + printf 'nameserver %s\n' "$dns2" >> "$tmp_resolv" + fi + if [ -s "$tmp_resolv" ]; then + cat "$tmp_resolv" > /etc/resolv.conf + fi + rm -f "$tmp_resolv" +fi diff --git a/internal/guestnet/assets/systemd.service b/internal/guestnet/assets/systemd.service new file mode 100644 index 0000000..8dcc4d0 --- /dev/null +++ b/internal/guestnet/assets/systemd.service @@ -0,0 +1,13 @@ +[Unit] +Description=Banger guest network bootstrap +After=local-fs.target +Before=network.target network-online.target +ConditionPathExists=/proc/cmdline + +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/banger-network-bootstrap +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/internal/guestnet/assets/void-core-service.sh b/internal/guestnet/assets/void-core-service.sh new file mode 100644 index 0000000..82f5827 --- /dev/null +++ b/internal/guestnet/assets/void-core-service.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if [ -x /usr/local/libexec/banger-network-bootstrap ]; then + /usr/local/libexec/banger-network-bootstrap +fi diff --git a/internal/guestnet/guestnet.go b/internal/guestnet/guestnet.go new file mode 100644 index 0000000..d4dfc6f --- /dev/null +++ b/internal/guestnet/guestnet.go @@ -0,0 +1,30 @@ +package guestnet + +import _ "embed" + +const ( + GuestScriptPath = "/usr/local/libexec/banger-network-bootstrap" + SystemdServiceName = "banger-network.service" + VoidCoreServicePath = "/etc/runit/core-services/20-banger-network.sh" +) + +var ( + //go:embed assets/bootstrap.sh + bootstrapScript string + //go:embed assets/systemd.service + systemdService string + //go:embed assets/void-core-service.sh + voidCoreService string +) + +func BootstrapScript() string { + return bootstrapScript +} + +func SystemdServiceUnit() string { + return systemdService +} + +func VoidCoreService() string { + return voidCoreService +} diff --git a/internal/model/types.go b/internal/model/types.go index aabbc4f..400019f 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -61,20 +61,21 @@ type DaemonConfig struct { } type Image struct { - ID string `json:"id"` - Name string `json:"name"` - Managed bool `json:"managed"` - ArtifactDir string `json:"artifact_dir,omitempty"` - RootfsPath string `json:"rootfs_path"` - WorkSeedPath string `json:"work_seed_path,omitempty"` - KernelPath string `json:"kernel_path"` - InitrdPath string `json:"initrd_path,omitempty"` - ModulesDir string `json:"modules_dir,omitempty"` - PackagesPath string `json:"packages_path,omitempty"` - BuildSize string `json:"build_size,omitempty"` - Docker bool `json:"docker"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Managed bool `json:"managed"` + ArtifactDir string `json:"artifact_dir,omitempty"` + RootfsPath string `json:"rootfs_path"` + WorkSeedPath string `json:"work_seed_path,omitempty"` + KernelPath string `json:"kernel_path"` + InitrdPath string `json:"initrd_path,omitempty"` + ModulesDir string `json:"modules_dir,omitempty"` + PackagesPath string `json:"packages_path,omitempty"` + BuildSize string `json:"build_size,omitempty"` + SeededSSHPublicKeyFingerprint string `json:"seeded_ssh_public_key_fingerprint,omitempty"` + Docker bool `json:"docker"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type VMSpec struct { diff --git a/internal/opencode/opencode.go b/internal/opencode/opencode.go new file mode 100644 index 0000000..e5d75c6 --- /dev/null +++ b/internal/opencode/opencode.go @@ -0,0 +1,104 @@ +package opencode + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "banger/internal/vsockagent" +) + +const ( + Port = 4096 + Host = "0.0.0.0" + GuestBinaryPath = "/usr/local/bin/opencode" + ShimPath = "/root/.local/share/mise/shims/opencode" + ServiceName = "banger-opencode.service" + RunitServiceName = "banger-opencode" + ReadyTimeout = 15 * time.Second + pollInterval = 200 * time.Millisecond +) + +func ServiceUnit() string { + return fmt.Sprintf(`[Unit] +Description=Banger opencode server +After=network.target +RequiresMountsFor=/root + +[Service] +Type=simple +Environment=HOME=/root +WorkingDirectory=/root +ExecStart=%s serve --hostname %s --port %d +Restart=on-failure +RestartSec=1 + +[Install] +WantedBy=multi-user.target +`, GuestBinaryPath, Host, Port) +} + +func RunitRunScript() string { + return fmt.Sprintf(`#!/bin/sh +set -e +export HOME=/root +cd /root +exec %s serve --hostname %s --port %d +`, GuestBinaryPath, Host, Port) +} + +func Ready(listeners []vsockagent.PortListener) bool { + for _, listener := range listeners { + if strings.ToLower(strings.TrimSpace(listener.Proto)) != "tcp" { + continue + } + if listener.Port == Port { + return true + } + } + return false +} + +func WaitReady(ctx context.Context, logger *slog.Logger, socketPath string, report func(stage, detail string)) error { + return waitReady(ctx, logger, socketPath, ReadyTimeout, report) +} + +func waitReady(ctx context.Context, logger *slog.Logger, socketPath string, timeout time.Duration, report func(stage, detail string)) error { + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + var lastErr error + for { + portsCtx, portsCancel := context.WithTimeout(waitCtx, 3*time.Second) + listeners, err := vsockagent.Ports(portsCtx, logger, socketPath) + portsCancel() + if err == nil { + if Ready(listeners) { + return nil + } + if report != nil { + report("wait_opencode", fmt.Sprintf("waiting for opencode on guest port %d", Port)) + } + lastErr = fmt.Errorf("guest port %d is not listening yet", Port) + } else { + if report != nil { + report("wait_vsock_agent", "waiting for guest vsock agent") + } + lastErr = err + } + + select { + case <-waitCtx.Done(): + if lastErr != nil { + return fmt.Errorf("opencode server did not become ready on guest port %d: %w", Port, lastErr) + } + return fmt.Errorf("opencode server did not become ready on guest port %d before timeout", Port) + case <-ticker.C: + } + } +} diff --git a/internal/opencode/opencode_test.go b/internal/opencode/opencode_test.go new file mode 100644 index 0000000..e0ecdb9 --- /dev/null +++ b/internal/opencode/opencode_test.go @@ -0,0 +1,116 @@ +package opencode + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "banger/internal/vsockagent" +) + +func TestServiceUnitContainsExpectedExecStart(t *testing.T) { + unit := ServiceUnit() + for _, snippet := range []string{ + "RequiresMountsFor=/root", + "WorkingDirectory=/root", + "Environment=HOME=/root", + "ExecStart=/usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096", + "WantedBy=multi-user.target", + } { + if !strings.Contains(unit, snippet) { + t.Fatalf("service unit missing snippet %q\nunit:\n%s", snippet, unit) + } + } +} + +func TestRunitRunScriptContainsExpectedExec(t *testing.T) { + script := RunitRunScript() + for _, snippet := range []string{ + "export HOME=/root", + "cd /root", + "exec /usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096", + } { + if !strings.Contains(script, snippet) { + t.Fatalf("runit script missing snippet %q\nscript:\n%s", snippet, script) + } + } +} + +func TestReadyMatchesTCPPort(t *testing.T) { + if Ready([]vsockagent.PortListener{{Proto: "udp", Port: Port}}) { + t.Fatal("udp listener should not satisfy readiness") + } + if Ready([]vsockagent.PortListener{{Proto: "tcp", Port: 8080}}) { + t.Fatal("wrong tcp port should not satisfy readiness") + } + if !Ready([]vsockagent.PortListener{{Proto: "tcp", Port: Port}}) { + t.Fatal("tcp listener on opencode port should satisfy readiness") + } +} + +func TestWaitReadyReturnsWhenPortIsListening(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "opencode.vsock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { + _ = listener.Close() + _ = os.Remove(socketPath) + }) + + serverDone := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + serverDone <- err + return + } + defer conn.Close() + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil { + serverDone <- err + return + } + if got := string(buf[:n]); got != "CONNECT 42070\n" { + serverDone <- fmt.Errorf("unexpected connect message %q", got) + return + } + if _, err := conn.Write([]byte("OK 1\n")); err != nil { + serverDone <- err + return + } + reqBuf := make([]byte, 0, 512) + for { + n, err = conn.Read(buf) + if err != nil { + serverDone <- err + return + } + reqBuf = append(reqBuf, buf[:n]...) + if strings.Contains(string(reqBuf), "\r\n\r\n") { + break + } + } + if !strings.Contains(string(reqBuf), "GET /ports HTTP/1.1\r\n") { + serverDone <- fmt.Errorf("unexpected ports payload %q", string(reqBuf)) + return + } + body := []byte(`{"listeners":[{"proto":"tcp","bind_address":"0.0.0.0","port":4096}]}`) + _, err = conn.Write([]byte(fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body))) + serverDone <- err + }() + + if err := waitReady(context.Background(), nil, socketPath, time.Second, nil); err != nil { + t.Fatalf("waitReady: %v", err) + } + if err := <-serverDone; err != nil { + t.Fatalf("server: %v", err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 0a55e3c..f15ebfc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -80,6 +80,7 @@ func (s *Store) migrate() error { modules_dir TEXT, packages_path TEXT, build_size TEXT, + seeded_ssh_public_key_fingerprint TEXT, docker INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -107,6 +108,9 @@ func (s *Store) migrate() error { if err := ensureColumnExists(s.db, "images", "work_seed_path", "TEXT"); err != nil { return err } + if err := ensureColumnExists(s.db, "images", "seeded_ssh_public_key_fingerprint", "TEXT"); err != nil { + return err + } return nil } @@ -116,8 +120,8 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { const query = ` INSERT INTO images ( id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, - modules_dir, packages_path, build_size, docker, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, managed=excluded.managed, @@ -129,6 +133,7 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { modules_dir=excluded.modules_dir, packages_path=excluded.packages_path, build_size=excluded.build_size, + seeded_ssh_public_key_fingerprint=excluded.seeded_ssh_public_key_fingerprint, docker=excluded.docker, updated_at=excluded.updated_at` _, err := s.db.ExecContext(ctx, query, @@ -143,6 +148,7 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { image.ModulesDir, image.PackagesPath, image.BuildSize, + image.SeededSSHPublicKeyFingerprint, boolToInt(image.Docker), image.CreatedAt.Format(time.RFC3339), image.UpdatedAt.Format(time.RFC3339), @@ -151,15 +157,15 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { } func (s *Store) GetImageByName(ctx context.Context, name string) (model.Image, error) { - return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images WHERE name = ?", name) + return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at FROM images WHERE name = ?", name) } func (s *Store) GetImageByID(ctx context.Context, id string) (model.Image, error) { - return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images WHERE id = ?", id) + return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at FROM images WHERE id = ?", id) } func (s *Store) ListImages(ctx context.Context) ([]model.Image, error) { - rows, err := s.db.QueryContext(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images ORDER BY created_at ASC") + rows, err := s.db.QueryContext(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at FROM images ORDER BY created_at ASC") if err != nil { return nil, err } @@ -337,6 +343,7 @@ func scanImageRow(row scanner) (model.Image, error) { var image model.Image var managed, docker int var workSeedPath sql.NullString + var seededSSHPublicKeyFingerprint sql.NullString var createdAt, updatedAt string err := row.Scan( &image.ID, @@ -350,6 +357,7 @@ func scanImageRow(row scanner) (model.Image, error) { &image.ModulesDir, &image.PackagesPath, &image.BuildSize, + &seededSSHPublicKeyFingerprint, &docker, &createdAt, &updatedAt, @@ -360,6 +368,7 @@ func scanImageRow(row scanner) (model.Image, error) { image.Managed = managed == 1 image.Docker = docker == 1 image.WorkSeedPath = workSeedPath.String + image.SeededSSHPublicKeyFingerprint = seededSSHPublicKeyFingerprint.String image.CreatedAt, err = time.Parse(time.RFC3339, createdAt) if err != nil { return image, err diff --git a/internal/store/store_test.go b/internal/store/store_test.go index dfbf401..0e7ea2a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -335,20 +335,21 @@ func openTestStore(t *testing.T) *Store { func sampleImage(name string) model.Image { now := fixedTime() return model.Image{ - ID: name + "-id", - Name: name, - Managed: true, - ArtifactDir: "/artifacts/" + name, - RootfsPath: "/images/" + name + ".ext4", - WorkSeedPath: "/images/" + name + ".work-seed.ext4", - KernelPath: "/kernels/" + name, - InitrdPath: "/initrd/" + name, - ModulesDir: "/modules/" + name, - PackagesPath: "/packages/" + name + ".apt", - BuildSize: "8G", - Docker: true, - CreatedAt: now, - UpdatedAt: now, + ID: name + "-id", + Name: name, + Managed: true, + ArtifactDir: "/artifacts/" + name, + RootfsPath: "/images/" + name + ".ext4", + WorkSeedPath: "/images/" + name + ".work-seed.ext4", + KernelPath: "/kernels/" + name, + InitrdPath: "/initrd/" + name, + ModulesDir: "/modules/" + name, + PackagesPath: "/packages/" + name + ".apt", + BuildSize: "8G", + SeededSSHPublicKeyFingerprint: "seeded-fingerprint", + Docker: true, + CreatedAt: now, + UpdatedAt: now, } } diff --git a/internal/system/system.go b/internal/system/system.go index ff63516..f29b464 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -397,9 +397,10 @@ func UpdateFSTab(existing string) string { func BuildBootArgs(vmName, guestIP, bridgeIP, dns string) string { return fmt.Sprintf( - "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw ip=%s::%s:255.255.255.0::eth0:off:%s hostname=%s systemd.mask=home.mount systemd.mask=var.mount", + "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw ip=%s::%s:255.255.255.0:%s:eth0:off:%s hostname=%s systemd.mask=home.mount systemd.mask=var.mount", guestIP, bridgeIP, + vmName, dns, vmName, ) diff --git a/internal/system/system_test.go b/internal/system/system_test.go index deaa7f1..c72eca5 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -167,6 +167,16 @@ func TestReadNormalizedLines(t *testing.T) { } } +func TestBuildBootArgsIncludesHostnameInIPField(t *testing.T) { + t.Parallel() + + got := BuildBootArgs("devbox", "172.16.0.2", "172.16.0.1", "1.1.1.1") + want := "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw ip=172.16.0.2::172.16.0.1:255.255.255.0:devbox:eth0:off:1.1.1.1 hostname=devbox systemd.mask=home.mount systemd.mask=var.mount" + if got != want { + t.Fatalf("BuildBootArgs() = %q, want %q", got, want) + } +} + func TestWriteExt4FileRemovesTempFileAndReturnsCopyError(t *testing.T) { t.Parallel() diff --git a/make-rootfs-void.sh b/make-rootfs-void.sh index fb21397..1cf5c89 100755 --- a/make-rootfs-void.sh +++ b/make-rootfs-void.sh @@ -18,8 +18,10 @@ Defaults: --arch x86_64 --packages ./packages.void -This path is experimental and local-only. It reuses the current runtime bundle -kernel/initrd/modules and does not change the default Debian image flow. +This path is experimental and local-only. If ./runtime/void-kernel exists it +uses the staged Void kernel modules from that directory; otherwise it falls back +to the current runtime bundle modules. It does not change the default Debian +image flow. EOF } @@ -85,6 +87,14 @@ bundle_path() { printf '%s\n' "$fallback" } +find_latest_module_dir() { + local root="$1" + if [[ ! -d "$root" ]]; then + return 1 + fi + find "$root" -mindepth 1 -maxdepth 1 -type d | sort | tail -n 1 +} + find_static_binary() { local name="$1" find "$STATIC_DIR" -type f \( -name "$name" -o -name "$name.static" \) -perm -u+x | sort | head -n 1 @@ -94,6 +104,15 @@ find_static_keys_dir() { find "$STATIC_DIR" -type d -path '*/var/db/xbps/keys' | sort | head -n 1 } +install_root_authorized_key() { + local public_key + public_key="$(ssh-keygen -y -f "$SSH_KEY")" + sudo mkdir -p "$ROOT_MOUNT/root/.ssh" + printf '%s\n' "$public_key" | sudo tee "$ROOT_MOUNT/root/.ssh/authorized_keys" >/dev/null + sudo chmod 700 "$ROOT_MOUNT/root/.ssh" + sudo chmod 600 "$ROOT_MOUNT/root/.ssh/authorized_keys" +} + ensure_sshd_include() { local cfg="$ROOT_MOUNT/etc/ssh/sshd_config" local tmp_cfg="$TMP_DIR/sshd_config" @@ -137,6 +156,34 @@ EOF sudo ln -snf /etc/sv/banger-vsock-agent "$ROOT_MOUNT/etc/runit/runsvdir/default/banger-vsock-agent" } +install_opencode_service() { + local service_dir="$ROOT_MOUNT/etc/sv/banger-opencode" + local run_path="$service_dir/run" + local finish_path="$service_dir/finish" + + sudo mkdir -p "$service_dir" + cat <<'EOF' | sudo tee "$run_path" >/dev/null +#!/bin/sh +set -e +export HOME=/root +cd /root +exec /usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096 +EOF + cat <<'EOF' | sudo tee "$finish_path" >/dev/null +#!/bin/sh +exit 0 +EOF + sudo chmod 0755 "$run_path" "$finish_path" + sudo mkdir -p "$ROOT_MOUNT/etc/runit/runsvdir/default" + sudo ln -snf /etc/sv/banger-opencode "$ROOT_MOUNT/etc/runit/runsvdir/default/banger-opencode" +} + +install_guest_network_bootstrap() { + sudo mkdir -p "$ROOT_MOUNT/usr/local/libexec" "$ROOT_MOUNT/etc/runit/core-services" + sudo install -m 0755 "$GUESTNET_BOOTSTRAP_SCRIPT" "$ROOT_MOUNT/usr/local/libexec/banger-network-bootstrap" + sudo install -m 0644 "$GUESTNET_VOID_CORE_SERVICE" "$ROOT_MOUNT/etc/runit/core-services/20-banger-network.sh" +} + configure_docker_bootstrap() { local modules_conf="$ROOT_MOUNT/etc/modules-load.d/docker-netfilter.conf" local sysctl_conf="$ROOT_MOUNT/etc/sysctl.d/99-docker.conf" @@ -346,6 +393,7 @@ if [[ ! -d "$RUNTIME_DIR" ]]; then fi BUNDLE_METADATA="$RUNTIME_DIR/bundle.json" +SSH_KEY="$(bundle_path ssh_key_path "$RUNTIME_DIR/id_ed25519")" OUT_ROOTFS="$RUNTIME_DIR/rootfs-void.ext4" SIZE_SPEC="2G" MIRROR="https://repo-default.voidlinux.org" @@ -353,11 +401,17 @@ ARCH="x86_64" MISE_VERSION="v2025.12.0" MISE_INSTALL_PATH="/usr/local/bin/mise" OPENCODE_TOOL="github:anomalyco/opencode" +GUESTNET_BOOTSTRAP_SCRIPT="$SCRIPT_DIR/internal/guestnet/assets/bootstrap.sh" +GUESTNET_VOID_CORE_SERVICE="$SCRIPT_DIR/internal/guestnet/assets/void-core-service.sh" MODULES_DIR="$(bundle_path default_modules_dir "$RUNTIME_DIR/wtf/root/lib/modules/6.8.0-94-generic")" +VOID_KERNEL_MODULES_DIR="$(find_latest_module_dir "$RUNTIME_DIR/void-kernel/lib/modules" || true)" VSOCK_AGENT="$(bundle_path vsock_agent_path "$RUNTIME_DIR/banger-vsock-agent")" if [[ "$VSOCK_AGENT" == "$RUNTIME_DIR/banger-vsock-agent" && ! -x "$VSOCK_AGENT" ]]; then VSOCK_AGENT="$(bundle_path vsock_ping_helper_path "$RUNTIME_DIR/banger-vsock-pingd")" fi +if [[ -n "$VOID_KERNEL_MODULES_DIR" ]]; then + MODULES_DIR="$VOID_KERNEL_MODULES_DIR" +fi while [[ $# -gt 0 ]]; do case "$1" in @@ -417,6 +471,14 @@ if [[ ! -x "$VSOCK_AGENT" ]]; then log "run 'make build' or refresh the runtime bundle" exit 1 fi +if [[ ! -f "$GUESTNET_BOOTSTRAP_SCRIPT" ]]; then + log "guest network bootstrap script not found: $GUESTNET_BOOTSTRAP_SCRIPT" + exit 1 +fi +if [[ ! -f "$GUESTNET_VOID_CORE_SERVICE" ]]; then + log "guest network core-service shim not found: $GUESTNET_VOID_CORE_SERVICE" + exit 1 +fi if [[ -e "$OUT_ROOTFS" ]]; then log "output rootfs already exists: $OUT_ROOTFS" exit 1 @@ -426,6 +488,7 @@ require_command curl require_command tar require_command sudo require_command mkfs.ext4 +require_command ssh-keygen require_command mount require_command umount require_command install @@ -498,7 +561,11 @@ if [[ -n "$XBPS_QUERY" && -x "$XBPS_QUERY" ]]; then sudo env XBPS_ARCH="$ARCH" "$XBPS_QUERY" -r "$ROOT_MOUNT" -l | awk '/^ii/ {print " " $2}' || true fi -log "copying bundled kernel modules into the guest" +if [[ -n "$VOID_KERNEL_MODULES_DIR" ]]; then + log "copying staged Void kernel modules into the guest" +else + log "copying bundled kernel modules into the guest" +fi sudo mkdir -p "$ROOT_MOUNT/lib/modules" sudo cp -a "$MODULES_DIR" "$ROOT_MOUNT/lib/modules/" @@ -507,6 +574,7 @@ sudo mkdir -p "$ROOT_MOUNT/usr/local/bin" sudo install -m 0755 "$VSOCK_AGENT" "$ROOT_MOUNT/usr/local/bin/banger-vsock-agent" log "preparing SSH and runit services" +install_guest_network_bootstrap ensure_sshd_include enable_sshd_service install_vsock_service @@ -516,7 +584,8 @@ normalize_root_shell configure_root_bash_prompt log "installing mise and opencode" install_mise_and_opencode -sudo mkdir -p "$ROOT_MOUNT/root/.ssh" +install_opencode_service +install_root_authorized_key sudo touch "$ROOT_MOUNT/etc/fstab" "$ROOT_MOUNT/etc/hostname" sudo chroot "$ROOT_MOUNT" /usr/bin/ssh-keygen -A diff --git a/make-void-kernel.sh b/make-void-kernel.sh new file mode 100755 index 0000000..fb67ba4 --- /dev/null +++ b/make-void-kernel.sh @@ -0,0 +1,391 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + printf '[make-void-kernel] %s\n' "$*" +} + +usage() { + cat <<'EOF' +Usage: ./make-void-kernel.sh [--out-dir ] [--mirror ] [--arch ] [--kernel-package ] [--print-register-flags] + +Download and stage a Void Linux kernel under ./runtime/void-kernel for the +experimental Void guest flow. + +Defaults: + --out-dir ./runtime/void-kernel + --mirror https://repo-default.voidlinux.org + --arch x86_64 + --kernel-package linux6.12 + +The staged output contains: + boot/vmlinux- Firecracker-usable kernel extracted from vmlinuz + boot/vmlinuz- Raw distro boot image from the Void package + boot/initramfs-.img Matching initramfs generated with dracut + boot/config- Void kernel config + lib/modules// Matching kernel modules tree + +If --print-register-flags is passed, the script does not download anything. It +prints the banger image register flags for an existing staged Void kernel. +EOF +} + +require_command() { + local name="$1" + command -v "$name" >/dev/null 2>&1 || { + log "required command not found: $name" + exit 1 + } +} + +normalize_mirror() { + local mirror="${1%/}" + mirror="${mirror%/current}" + mirror="${mirror%/static}" + printf '%s\n' "$mirror" +} + +find_static_binary() { + local name="$1" + find "$STATIC_DIR" -type f \( -name "$name" -o -name "$name.static" \) -perm -u+x | sort | head -n 1 +} + +find_static_keys_dir() { + find "$STATIC_DIR" -type d -path '*/var/db/xbps/keys' | sort | head -n 1 +} + +find_latest_matching() { + local dir="$1" + local pattern="$2" + if [[ ! -d "$dir" ]]; then + return 1 + fi + find "$dir" -maxdepth 1 -type f -name "$pattern" | sort | tail -n 1 +} + +find_latest_module_dir() { + local root="$1" + if [[ ! -d "$root" ]]; then + return 1 + fi + find "$root" -mindepth 1 -maxdepth 1 -type d | sort | tail -n 1 +} + +print_register_flags() { + local kernel="" + local initrd="" + local modules="" + + kernel="$(find_latest_matching "$OUT_DIR/boot" 'vmlinux-*' || true)" + initrd="$(find_latest_matching "$OUT_DIR/boot" 'initramfs-*' || true)" + modules="$(find_latest_module_dir "$OUT_DIR/lib/modules" || true)" + + if [[ -z "$kernel" || -z "$modules" ]]; then + log "staged Void kernel not found under $OUT_DIR" + exit 1 + fi + + printf -- '--kernel %q ' "$kernel" + if [[ -n "$initrd" ]]; then + printf -- '--initrd %q ' "$initrd" + fi + printf -- '--modules %q\n' "$modules" +} + +check_elf() { + local path="$1" + readelf -h "$path" >/dev/null 2>&1 +} + +ensure_stage_root_layout() { + mkdir -p "$STAGE_ROOT/usr" + + if [[ ! -e "$STAGE_ROOT/bin" ]]; then + ln -snf usr/bin "$STAGE_ROOT/bin" + fi + if [[ ! -e "$STAGE_ROOT/sbin" ]]; then + ln -snf usr/bin "$STAGE_ROOT/sbin" + fi + if [[ ! -e "$STAGE_ROOT/usr/sbin" ]]; then + ln -snf bin "$STAGE_ROOT/usr/sbin" + fi + if [[ ! -e "$STAGE_ROOT/lib" ]]; then + ln -snf usr/lib "$STAGE_ROOT/lib" + fi + if [[ ! -e "$STAGE_ROOT/lib64" ]]; then + ln -snf usr/lib "$STAGE_ROOT/lib64" + fi + if [[ ! -e "$STAGE_ROOT/usr/lib64" ]]; then + ln -snf lib "$STAGE_ROOT/usr/lib64" + fi + if [[ -x "$STAGE_ROOT/usr/bin/udevd" ]]; then + mkdir -p "$STAGE_ROOT/usr/lib/udev" "$STAGE_ROOT/usr/lib/systemd" + if [[ ! -e "$STAGE_ROOT/usr/lib/udev/udevd" ]]; then + ln -snf ../../bin/udevd "$STAGE_ROOT/usr/lib/udev/udevd" + fi + if [[ ! -e "$STAGE_ROOT/usr/lib/systemd/systemd-udevd" ]]; then + ln -snf ../../bin/udevd "$STAGE_ROOT/usr/lib/systemd/systemd-udevd" + fi + fi +} + +sync_host_dracut_tree() { + if [[ ! -d /usr/lib/dracut ]]; then + log "host dracut support files not found under /usr/lib/dracut" + exit 1 + fi + rm -rf "$STAGE_ROOT/usr/lib/dracut" + mkdir -p "$STAGE_ROOT/usr/lib" + cp -a /usr/lib/dracut "$STAGE_ROOT/usr/lib/dracut" +} + +build_initramfs() { + local kver="$1" + local modules_dir="$2" + local out="$3" + local config_dir="$TMP_DIR/dracut.conf.d" + local tmpdir="$TMP_DIR/dracut-tmp" + local force_drivers="virtio virtio_ring virtio_mmio virtio_blk virtio_net virtio_console ext4 vsock vmw_vsock_virtio_transport" + + mkdir -p "$config_dir" "$tmpdir" + ensure_stage_root_layout + sync_host_dracut_tree + + log "generating initramfs for kernel $kver with host dracut against the staged Void sysroot" + env dracutbasedir="/usr/lib/dracut" dracut \ + --force \ + --kver "$kver" \ + --sysroot "$STAGE_ROOT" \ + --kmoddir "$modules_dir" \ + --conf /dev/null \ + --confdir "$config_dir" \ + --tmpdir "$tmpdir" \ + --no-hostonly \ + --filesystems "ext4" \ + --force-drivers "$force_drivers" \ + --gzip \ + "$out" +} + +extract_vmlinux() { + local image="$1" + local out="$2" + local tmp="$TMP_DIR/vmlinux.extract" + + if check_elf "$image"; then + install -m 0644 "$image" "$out" + return 0 + fi + + try_decompress() { + local header="$1" + local marker="$2" + local command="$3" + local pos="" + + while IFS= read -r pos; do + [[ -n "$pos" ]] || continue + pos="${pos%%:*}" + tail -c+"$pos" "$image" | eval "$command" >"$tmp" 2>/dev/null || true + if check_elf "$tmp"; then + install -m 0644 "$tmp" "$out" + return 0 + fi + done < <(tr "$header\n$marker" "\n$marker=" < "$image" | grep -abo "^$marker" || true) + + return 1 + } + + try_decompress '\037\213\010' "xy" "gunzip" && return 0 + try_decompress '\3757zXZ\000' "abcde" "unxz" && return 0 + try_decompress "BZh" "xy" "bunzip2" && return 0 + try_decompress '\135\000\000\000' "xxx" "unlzma" && return 0 + try_decompress '\002!L\030' "xxx" "lz4 -d" && return 0 + try_decompress '(\265/\375' "xxx" "unzstd" && return 0 + + return 1 +} + +resolve_kernel_package_file() { + local escaped_name="" + escaped_name="$(printf '%s\n' "$KERNEL_PACKAGE" | sed 's/[.[\*^$()+?{|]/\\&/g')" + + curl -fsSL "$REPO_URL/" | + grep -o "${escaped_name}-[0-9][^\" >]*\\.${ARCH}\\.xbps" | + sort -u | + tail -n 1 +} + +cleanup() { + if [[ -n "${TMP_DIR:-}" && -d "${TMP_DIR:-}" ]]; then + rm -rf "$TMP_DIR" + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_RUNTIME_DIR="$SCRIPT_DIR" +if [[ -d "$SCRIPT_DIR/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime" +fi +RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" +OUT_DIR="$RUNTIME_DIR/void-kernel" +MIRROR="https://repo-default.voidlinux.org" +ARCH="x86_64" +KERNEL_PACKAGE="linux6.12" +PRINT_REGISTER_FLAGS=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --out-dir) + OUT_DIR="${2:-}" + shift 2 + ;; + --mirror) + MIRROR="${2:-}" + shift 2 + ;; + --arch) + ARCH="${2:-}" + shift 2 + ;; + --kernel-package) + KERNEL_PACKAGE="${2:-}" + shift 2 + ;; + --print-register-flags) + PRINT_REGISTER_FLAGS=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + log "unknown option: $1" + usage + exit 1 + ;; + esac +done + +MIRROR="$(normalize_mirror "$MIRROR")" +REPO_URL="$MIRROR/current" +STATIC_ARCHIVE_URL="$MIRROR/static/xbps-static-latest.x86_64-musl.tar.xz" + +if [[ "$PRINT_REGISTER_FLAGS" == "1" ]]; then + print_register_flags + exit 0 +fi + +if [[ "$ARCH" != "x86_64" ]]; then + log "unsupported arch: $ARCH" + log "this experimental downloader currently supports only x86_64" + exit 1 +fi +if [[ ! -d "$RUNTIME_DIR" ]]; then + log "runtime bundle not found: $RUNTIME_DIR" + exit 1 +fi +if [[ -e "$OUT_DIR" ]]; then + log "output directory already exists: $OUT_DIR" + log "remove it first if you want to re-stage a different Void kernel" + exit 1 +fi + +require_command curl +require_command tar +require_command cp +require_command find +require_command grep +require_command cut +require_command readelf +require_command file +require_command install +require_command tail +require_command xz +require_command gzip +require_command bzip2 +require_command dracut + +TMP_DIR="$(mktemp -d -t banger-void-kernel-XXXXXX)" +STATIC_DIR="$TMP_DIR/static" +STAGE_ROOT="$TMP_DIR/root" +STAGE_OUT="$TMP_DIR/out" +STATIC_ARCHIVE="$TMP_DIR/xbps-static.tar.xz" +trap cleanup EXIT + +mkdir -p "$STATIC_DIR" "$STAGE_ROOT/var/db/xbps/keys" "$STAGE_OUT/boot" "$STAGE_OUT/lib/modules" + +log "downloading static XBPS from $STATIC_ARCHIVE_URL" +curl -fsSL "$STATIC_ARCHIVE_URL" -o "$STATIC_ARCHIVE" +tar -xf "$STATIC_ARCHIVE" -C "$STATIC_DIR" + +XBPS_INSTALL="$(find_static_binary xbps-install)" +STATIC_KEYS_DIR="$(find_static_keys_dir)" +if [[ -z "$XBPS_INSTALL" || ! -x "$XBPS_INSTALL" ]]; then + log "failed to locate xbps-install in the static archive" + exit 1 +fi +if [[ -z "$STATIC_KEYS_DIR" || ! -d "$STATIC_KEYS_DIR" ]]; then + log "failed to locate Void repository keys in the static archive" + exit 1 +fi + +cp -a "$STATIC_KEYS_DIR/." "$STAGE_ROOT/var/db/xbps/keys/" + +KERNEL_PACKAGE_FILE="$(resolve_kernel_package_file)" +if [[ -z "$KERNEL_PACKAGE_FILE" ]]; then + log "failed to resolve a package file for $KERNEL_PACKAGE in $REPO_URL" + exit 1 +fi + +log "staging $KERNEL_PACKAGE_FILE into a temporary root" +env XBPS_ARCH="$ARCH" "$XBPS_INSTALL" -S -y -U -r "$STAGE_ROOT" -R "$REPO_URL" linux-base "$KERNEL_PACKAGE" dracut eudev >/dev/null + +VMLINUX_RAW="$(find_latest_matching "$STAGE_ROOT/boot" 'vmlinuz-*' || true)" +KERNEL_CONFIG="$(find_latest_matching "$STAGE_ROOT/boot" 'config-*' || true)" +MODULES_DIR="$(find_latest_module_dir "$STAGE_ROOT/usr/lib/modules" || true)" +KERNEL_VERSION="$(basename "$MODULES_DIR")" +INITRAMFS_NAME="initramfs-${KERNEL_VERSION}.img" +INITRAMFS_RAW="$STAGE_OUT/boot/$INITRAMFS_NAME" + +if [[ -z "$VMLINUX_RAW" || -z "$KERNEL_CONFIG" || -z "$MODULES_DIR" ]]; then + log "staged Void kernel is missing expected boot artifacts" + exit 1 +fi +if [[ ! -x "$STAGE_ROOT/usr/bin/udevd" ]]; then + log "staged Void sysroot is missing /usr/bin/udevd after package install" + exit 1 +fi + +VMLINUX_BASE="$(basename "$VMLINUX_RAW")" +VMLINUX_OUT="$STAGE_OUT/boot/vmlinux-${VMLINUX_BASE#vmlinuz-}" +install -m 0644 "$VMLINUX_RAW" "$STAGE_OUT/boot/$VMLINUX_BASE" +install -m 0644 "$KERNEL_CONFIG" "$STAGE_OUT/boot/$(basename "$KERNEL_CONFIG")" +build_initramfs "$KERNEL_VERSION" "$MODULES_DIR" "$INITRAMFS_RAW" +cp -a "$MODULES_DIR" "$STAGE_OUT/lib/modules/" + +log "extracting Firecracker kernel from $(basename "$VMLINUX_RAW")" +if ! extract_vmlinux "$VMLINUX_RAW" "$VMLINUX_OUT"; then + log "failed to extract an uncompressed vmlinux from $VMLINUX_RAW" + log "raw kernel image type: $(file -b "$VMLINUX_RAW")" + exit 1 +fi + +cat >"$STAGE_OUT/metadata.json" <&2 +} + +find_latest_matching() { + local dir="$1" + local pattern="$2" + if [[ ! -d "$dir" ]]; then + return 1 + fi + find "$dir" -maxdepth 1 -type f -name "$pattern" | sort | tail -n 1 +} + +find_latest_module_dir() { + local root="$1" + if [[ ! -d "$root" ]]; then + return 1 + fi + find "$root" -mindepth 1 -maxdepth 1 -type d | sort | tail -n 1 +} + +resolve_banger_bin() { + if [[ -n "${BANGER_BIN:-}" ]]; then + printf '%s\n' "$BANGER_BIN" + return + fi + if [[ -x "$SCRIPT_DIR/banger" ]]; then + printf '%s\n' "$SCRIPT_DIR/banger" + return + fi + if command -v banger >/dev/null 2>&1; then + command -v banger + return + fi + log "banger binary not found; build it first with 'make build' or set BANGER_BIN" + exit 1 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_RUNTIME_DIR="$SCRIPT_DIR" +if [[ -d "$SCRIPT_DIR/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime" +fi + +RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" +IMAGE_NAME="${VOID_IMAGE_NAME:-void-exp}" +BANGER_BIN="$(resolve_banger_bin)" +ROOTFS="$RUNTIME_DIR/rootfs-void.ext4" +WORK_SEED="$RUNTIME_DIR/rootfs-void.work-seed.ext4" +PACKAGES="$SCRIPT_DIR/packages.void" + +if [[ ! -f "$ROOTFS" ]]; then + log "missing Void rootfs: $ROOTFS" + exit 1 +fi +if [[ ! -f "$WORK_SEED" ]]; then + log "missing Void work-seed: $WORK_SEED" + exit 1 +fi + +args=( + image register + --name "$IMAGE_NAME" + --rootfs "$ROOTFS" + --work-seed "$WORK_SEED" + --packages "$PACKAGES" +) + +if [[ ! -d "$RUNTIME_DIR/void-kernel" ]]; then + log "missing staged Void kernel artifacts: $RUNTIME_DIR/void-kernel" + log "run 'make void-kernel' before registering $IMAGE_NAME" + exit 1 +fi + +kernel="$(find_latest_matching "$RUNTIME_DIR/void-kernel/boot" 'vmlinux-*' || true)" +initrd="$(find_latest_matching "$RUNTIME_DIR/void-kernel/boot" 'initramfs-*' || true)" +modules="$(find_latest_module_dir "$RUNTIME_DIR/void-kernel/lib/modules" || true)" + +if [[ -z "$kernel" || -z "$initrd" || -z "$modules" ]]; then + log "staged Void kernel is incomplete; expected vmlinux, initramfs, and modules under $RUNTIME_DIR/void-kernel" + exit 1 +fi + +log "using staged Void kernel artifacts from $RUNTIME_DIR/void-kernel" +args+=(--kernel "$kernel" --initrd "$initrd" --modules "$modules") + +"$BANGER_BIN" "${args[@]}" diff --git a/verify.sh b/verify.sh index 8c0af2d..53478e1 100755 --- a/verify.sh +++ b/verify.sh @@ -33,6 +33,7 @@ SSH_COMMON_ARGS=( -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ) +OPENCODE_PORT=4096 firecracker_running() { local pid="$1" @@ -68,6 +69,21 @@ wait_for_ssh() { return 1 } +wait_for_tcp() { + local host="$1" + local port="$2" + local deadline="$3" + + while ((SECONDS < deadline)); do + if (exec 3<>/dev/tcp/"$host"/"$port") >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + + return 1 +} + refresh_vm_metadata() { if ! VM_JSON="$(./banger vm show "$VM_NAME" 2>/dev/null)"; then return 1 @@ -240,9 +256,21 @@ if ! wait_for_ssh "$GUEST_IP" "$BOOT_DEADLINE"; then fi ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "uname -a" >/dev/null -if [[ "$IMAGE_NAME" == "void-exp" ]]; then - log "asserting mise and opencode are available in the Void guest" - ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "command -v mise >/dev/null 2>&1 && command -v opencode >/dev/null 2>&1 && mise --version >/dev/null 2>&1 && opencode --version >/dev/null 2>&1" >/dev/null +log "asserting opencode is available and listening in the guest" +ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "command -v opencode >/dev/null 2>&1 && ss -H -lntp | awk '\$4 ~ /:${OPENCODE_PORT}\$/ { found = 1 } END { exit found ? 0 : 1 }'" >/dev/null + +log "asserting opencode server is reachable from the host" +if ! wait_for_tcp "$GUEST_IP" "$OPENCODE_PORT" "$BOOT_DEADLINE"; then + log "opencode server did not become reachable at ${GUEST_IP}:${OPENCODE_PORT}" + dump_diagnostics + exit 1 +fi + +log "asserting opencode port is reported by banger vm ports" +if ! ./banger vm ports "$VM_NAME" | grep -F ":${OPENCODE_PORT}" >/dev/null 2>&1; then + log "banger vm ports did not report ${OPENCODE_PORT}" + dump_diagnostics + exit 1 fi if (( NAT_ENABLED )); then