diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0127677
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,19 @@
+**
+!go.mod
+!go.sum
+!agent/
+!agent/**
+!errdefs/
+!errdefs/**
+!types/
+!types/**
+!version/
+!version/**
+!cmd/
+!cmd/kumabox-agent/
+!cmd/kumabox-agent/**
+!oci-images/
+!oci-images/ubuntu/
+!oci-images/ubuntu/overlay.sh
+!oci-images/ubuntu/network.sh
+!oci-images/ubuntu/kumabox-agent.service
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 54afd19..9754a36 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,9 +3,7 @@ name: CI
on:
pull_request:
push:
- branches:
- - main
- - develop
+ branches: [main, develop]
permissions:
contents: read
@@ -15,8 +13,11 @@ concurrency:
cancel-in-progress: true
jobs:
- lint:
- name: Lint
+ # verify covers T1/T2 only: pure models, coordinator ordering with fake ports,
+ # and real SQLite/filesystem integration. It needs no root and no KVM, so it
+ # runs on a plain runner. T3/T4 (real Cloud Hypervisor on Linux/KVM) are
+ # executed manually on Linux hosts with KVM and the required VMM tooling.
+ verify:
runs-on: ubuntu-latest
steps:
- name: Check out source
@@ -26,123 +27,21 @@ jobs:
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- cache: true
+ cache: false
- - name: Run golangci-lint
- uses: golangci/golangci-lint-action@v9
- with:
- version: v2.12
- args: --timeout=5m
-
- security:
- name: Vulnerability scan
- runs-on: ubuntu-latest
- steps:
- - name: Check out source
- uses: actions/checkout@v4
+ - name: Verify
+ run: make verify
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- # Keep the scanner on a standard library version containing the
- # security fixes reported by govulncheck. This does not change the
- # project's minimum Go version in go.mod.
- go-version: '1.26.6'
- cache: true
+ - name: Race detector
+ run: make race
- - name: Run govulncheck
- run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
-
- test:
- name: Go ${{ matrix.go }}
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- go:
- - '1.24.x'
- - stable
-
- steps:
- - name: Check out source
- uses: actions/checkout@v4
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version: ${{ matrix.go }}
- cache: true
-
- - name: Download Go modules
- run: go mod download
-
- - name: Check formatting
- shell: bash
- run: |
- files="$(gofmt -l .)"
- if [[ -n "$files" ]]; then
- printf 'Go files are not formatted:\n%s\n' "$files"
- exit 1
- fi
-
- - name: Reject tracked local documentation
- shell: bash
- run: |
- files="$(git ls-files docs)"
- if [[ -n "$files" ]]; then
- printf 'Files under docs/ are local-only and must not be tracked:\n%s\n' "$files"
- exit 1
- fi
+ - name: Lint
+ run: make lint
- name: Check module files
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
- - name: Run vet
- run: go vet ./...
-
- - name: Run tests with race detection
- run: go test -race -count=1 ./...
-
- - name: Build all packages
- run: go build ./...
-
- - name: Check shell scripts
- shell: bash
- run: |
- while IFS= read -r -d '' script; do
- bash -n "$script"
- done < <(find . -type f -name '*.sh' -not -path './.git/*' -print0)
-
- - name: Test release installer
- run: |
- test/release/install.sh
- test/release/check.sh
-
- build:
- name: Linux build
- runs-on: ubuntu-latest
- needs: test
- steps:
- - name: Check out source
- uses: actions/checkout@v4
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: go.mod
- cache: true
-
- - name: Build release-shaped binaries
- env:
- VERSION: 0.0.0-ci
- COMMIT: ${{ github.sha }}
- BUILD_TIME: ${{ github.event.head_commit.timestamp || github.event.repository.updated_at }}
- run: make build
-
- - name: Verify build outputs
- run: |
- test -x bin/kumabox
- test -x oci-images/ubuntu/kumabox-agent-linux-amd64
- ./bin/kumabox version
+ - name: Reject tracked local documentation
+ run: test -z "$(git ls-files docs)"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 6c43da2..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,142 +0,0 @@
-name: Release
-
-on:
- push:
- tags:
- - 'v*'
-
-permissions:
- contents: write
- packages: write
-
-jobs:
- build:
- name: Build ${{ matrix.arch }}
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- arch:
- - amd64
- - arm64
-
- steps:
- - name: Check out source
- uses: actions/checkout@v4
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: go.mod
- cache: true
-
- - name: Build release artifacts
- env:
- GOARCH: ${{ matrix.arch }}
- VERSION: ${{ github.ref_name }}
- COMMIT: ${{ github.sha }}
- BUILD_TIME: ${{ github.event.head_commit.timestamp || github.event.repository.updated_at }}
- shell: bash
- run: |
- set -Eeuo pipefail
- mkdir -p dist
- GOOS=linux CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X github.com/kumabox/kumabox/internal/version.Version=${VERSION} -X github.com/kumabox/kumabox/internal/version.Commit=${COMMIT} -X github.com/kumabox/kumabox/internal/version.BuildTime=${BUILD_TIME}" -o dist/kumabox ./cmd/kumabox
- GOOS=linux CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X github.com/kumabox/kumabox/internal/version.Version=${VERSION} -X github.com/kumabox/kumabox/internal/version.Commit=${COMMIT} -X github.com/kumabox/kumabox/internal/version.BuildTime=${BUILD_TIME}" -o dist/kumabox-agent ./cmd/agent
- install -m 0755 scripts/check.sh dist/kumabox-check
- cp LICENSE README.md dist/
- tar -C dist -czf "kumabox-${VERSION}-linux-${GOARCH}.tar.gz" kumabox kumabox-agent kumabox-check LICENSE README.md
- sha256sum "kumabox-${VERSION}-linux-${GOARCH}.tar.gz" > "kumabox-${VERSION}-linux-${GOARCH}.tar.gz.sha256"
- sha256sum --check "kumabox-${VERSION}-linux-${GOARCH}.tar.gz.sha256"
- tar -tzf "kumabox-${VERSION}-linux-${GOARCH}.tar.gz" | sort > archive-files.txt
- diff -u <(printf '%s\n' LICENSE README.md kumabox kumabox-agent kumabox-check | sort) archive-files.txt
-
- - name: Upload release artifacts
- uses: actions/upload-artifact@v4
- with:
- name: release-${{ matrix.arch }}
- path: |
- kumabox-*.tar.gz
- kumabox-*.sha256
-
- guest-image:
- name: Publish Ubuntu guest image
- runs-on: ubuntu-latest
- steps:
- - name: Check out source
- uses: actions/checkout@v4
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: go.mod
- cache: true
-
- - name: Build guest agents
- env:
- VERSION: ${{ github.ref_name }}
- COMMIT: ${{ github.sha }}
- BUILD_TIME: ${{ github.event.head_commit.timestamp || github.event.repository.updated_at }}
- shell: bash
- run: |
- set -Eeuo pipefail
- for arch in amd64 arm64; do
- GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath \
- -ldflags "-s -w -X github.com/kumabox/kumabox/internal/version.Version=${VERSION} -X github.com/kumabox/kumabox/internal/version.Commit=${COMMIT} -X github.com/kumabox/kumabox/internal/version.BuildTime=${BUILD_TIME}" \
- -o "oci-images/ubuntu/kumabox-agent-linux-${arch}" ./cmd/agent
- done
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Build and publish guest image
- uses: docker/build-push-action@v6
- with:
- context: oci-images/ubuntu
- file: oci-images/ubuntu/24.04/Dockerfile
- platforms: linux/amd64,linux/arm64
- push: true
- build-args: |
- KUMABOX_VERSION=${{ github.ref_name }}
- tags: |
- ghcr.io/kgpp34/kumabox/ubuntu:24.04
- ghcr.io/kgpp34/kumabox/ubuntu:24.04-${{ github.ref_name }}
- labels: |
- org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
- org.opencontainers.image.revision=${{ github.sha }}
- org.opencontainers.image.version=${{ github.ref_name }}
-
- publish:
- name: Publish GitHub release
- runs-on: ubuntu-latest
- needs:
- - build
- - guest-image
- steps:
- - name: Check out source
- uses: actions/checkout@v4
-
- - name: Download release artifacts
- uses: actions/download-artifact@v4
- with:
- pattern: release-*
- merge-multiple: true
-
- - name: Prepare bootstrap installer
- run: |
- install -m 0755 scripts/install.sh kumabox-install.sh
- sha256sum kumabox-install.sh > kumabox-install.sh.sha256
-
- - name: Create release
- env:
- GH_TOKEN: ${{ github.token }}
- run: gh release create "${{ github.ref_name }}" kumabox-*.tar.gz kumabox-*.tar.gz.sha256 kumabox-install.sh kumabox-install.sh.sha256 --generate-notes --verify-tag
diff --git a/.gitignore b/.gitignore
index f19648d..098f33d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,8 +2,15 @@
.DS_Store
bin/
dist/
+.rewrite-backup/
+.claude/
coverage.out
# Local design and implementation notes must never be committed.
docs/
-oci-images/ubuntu/kumabox-agent-linux-amd64
-oci-images/ubuntu/kumabox-agent-linux-arm64
+
+# Local Go and tooling caches are generated, never source.
+.cache*/
+.gocache*/
+.gomodcache*/
+gocache*/
+gomodcache*/
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..8a6c35e
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,180 @@
+version: "2"
+
+run:
+ timeout: 5m
+ tests: true
+ modules-download-mode: readonly
+
+linters:
+ default: none
+ enable:
+ - depguard
+ - dogsled
+ - errcheck
+ - forbidigo
+ - gochecknoinits
+ - gocritic
+ - gosec
+ - govet
+ - ineffassign
+ - modernize
+ - revive
+ - staticcheck
+ - unused
+ settings:
+ depguard:
+ rules:
+ no-command-dependencies:
+ files:
+ - "$all"
+ - "!$test"
+ - "!**/cli/**"
+ - "!**/cmd/**"
+ - "!**/main.go"
+ deny:
+ - pkg: github.com/kumabox/kumabox/cmd
+ desc: library packages must not import process entry points
+ - pkg: github.com/kumabox/kumabox/cli
+ desc: library packages must not import CLI adapters
+ composition-does-not-belong-to-modules:
+ files:
+ - "$all"
+ - "!$test"
+ - "!**/core/**"
+ - "!**/cli/**"
+ - "!**/main.go"
+ deny:
+ - pkg: github.com/kumabox/kumabox/core
+ desc: modules must not depend on application composition
+ shared-types-have-no-module-dependencies:
+ files:
+ - "**/types/**"
+ - "!$test"
+ deny:
+ - pkg: github.com/kumabox/kumabox/core
+ desc: shared data contracts must not depend on application services
+ - pkg: github.com/kumabox/kumabox/cli
+ desc: shared data contracts must not depend on command presentation
+ - pkg: github.com/kumabox/kumabox/images
+ desc: shared data contracts must not depend on image implementations
+ - pkg: github.com/kumabox/kumabox/sandbox
+ desc: shared data contracts must not depend on sandbox adapters
+ - pkg: github.com/kumabox/kumabox/disk
+ desc: shared data contracts must not depend on disk adapters
+ - pkg: github.com/kumabox/kumabox/metadata
+ desc: shared data contracts must not depend on persistence engines
+ catalog-is-an-edge-adapter:
+ files:
+ - "$all"
+ - "!$test"
+ - "!**/core/**"
+ - "!**/images/catalog/**"
+ deny:
+ - pkg: github.com/kumabox/kumabox/images/catalog
+ desc: only core may assemble the image catalog adapter
+ image-domain-is-engine-neutral:
+ files:
+ - "**/images/*.go"
+ - "!$test"
+ deny:
+ - pkg: github.com/kumabox/kumabox/metadata
+ desc: image domain contracts must not depend on persistence engines
+ testing-is-test-only:
+ files:
+ - "$all"
+ - "!$test"
+ deny:
+ - pkg: testing
+ desc: test helpers belong in test files, not production packages
+ sqlite-is-an-edge-adapter:
+ files:
+ - "$all"
+ - "!$test"
+ - "!**/core/**"
+ - "!**/metadata/sqlite/**"
+ deny:
+ - pkg: github.com/kumabox/kumabox/metadata/sqlite
+ desc: only core may assemble the SQLite adapter
+ source-is-an-edge-adapter:
+ files:
+ - "$all"
+ - "!$test"
+ - "!**/core/**"
+ - "!**/images/source/**"
+ deny:
+ - pkg: github.com/kumabox/kumabox/images/source
+ desc: only core may assemble container image sources
+ erofs-is-an-edge-adapter:
+ files:
+ - "$all"
+ - "!$test"
+ - "!**/core/**"
+ - "!**/images/erofs/**"
+ deny:
+ - pkg: github.com/kumabox/kumabox/images/erofs
+ desc: only core may assemble the EROFS converter
+ sql-only-in-sqlite:
+ files:
+ - "$all"
+ - "!$test"
+ - "!**/metadata/sqlite/**"
+ deny:
+ - pkg: database/sql
+ desc: SQL belongs to the metadata engine adapter
+ - pkg: modernc.org/sqlite
+ desc: the SQLite driver belongs to the metadata engine adapter
+ metadata-does-not-own-images:
+ files:
+ - "**/metadata/**"
+ - "!$test"
+ deny:
+ - pkg: github.com/kumabox/kumabox/images
+ desc: image business facts belong to images, not metadata
+ mechanisms-do-not-own-business:
+ files:
+ - "**/storage/**"
+ - "**/lock/**"
+ - "**/errdefs/**"
+ - "!$test"
+ deny:
+ - pkg: github.com/kumabox/kumabox/images
+ desc: mechanisms must not depend on image business logic
+ - pkg: github.com/kumabox/kumabox/metadata
+ desc: mechanisms must not depend on metadata engines
+ forbidigo:
+ forbid:
+ - pattern: ^panic$
+ msg: return a classified error instead of panicking
+ - pattern: ^os\.Getenv$
+ msg: environment access must stay at an approved configuration boundary
+ govet:
+ enable-all: true
+ disable:
+ - fieldalignment
+ - shadow
+ revive:
+ rules:
+ - name: exported
+ disabled: true
+ exclusions:
+ generated: strict
+ paths:
+ - vendor
+ - dist
+ rules:
+ - path: _test\.go
+ linters:
+ - forbidigo
+ - gosec
+
+formatters:
+ enable:
+ - gofumpt
+ - goimports
+ settings:
+ gofumpt:
+ extra:
+ group-params: true
+ goimports:
+ local-prefixes:
+ - github.com/kumabox/kumabox
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..06c8899
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,33 @@
+# Contributing to KumaBox
+
+## Prerequisites
+
+- Go 1.24 or newer
+- Bash
+- Linux for real microVM acceptance tests
+- `golangci-lint`, `gofumpt`, and `goimports` are installed into `bin/` by the Makefile when needed
+
+## Local workflow
+
+```bash
+git clone https://github.com/kumabox/kumabox.git
+cd kumabox
+make verify
+make lint
+```
+
+Keep changes within the owning module. KumaBox does not use `internal`, a generic `pkg`, or packages split only by declaration kind. Shared data belongs in `types`; capability interfaces stay with the module that owns or consumes the capability.
+
+Before submitting a change:
+
+```bash
+make fmt
+make verify
+make lint
+```
+
+Run the relevant local Linux acceptance procedure for changes involving KVM, Cloud Hypervisor, cgroup v2, CNI, EROFS, ext4, or vsock. Record the host versions and result in the pull request.
+
+## Compatibility
+
+Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` is the feature and behavior reference. A change may intentionally differ when KumaBox has a stronger safety or modularity guarantee; explain material behavior differences in the pull request. Design notes under `docs/` are local working material and must not be committed.
diff --git a/Makefile b/Makefile
index 175f2f2..d6b8880 100644
--- a/Makefile
+++ b/Makefile
@@ -1,35 +1,136 @@
-BINARY := kumabox
-BIN_DIR := bin
-GUEST_AGENT_BINARY_AMD64 := oci-images/ubuntu/kumabox-agent-linux-amd64
-GUEST_AGENT_BINARY_ARM64 := oci-images/ubuntu/kumabox-agent-linux-arm64
-VERSION ?= 0.0.0-dev
-COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
-BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
+.PHONY: all build agent install test doctor-check race verify lint vet fmt fmt-check deps clean coverage cloc help
-LDFLAGS := -X github.com/kumabox/kumabox/internal/version.Version=$(VERSION) \
- -X github.com/kumabox/kumabox/internal/version.Commit=$(COMMIT) \
- -X github.com/kumabox/kumabox/internal/version.BuildTime=$(BUILD_TIME)
+REPO_PATH := github.com/kumabox/kumabox
-.PHONY: build build-agent build-agent-linux-amd64 build-agent-linux-arm64 test test-e2e clean
+## Target OSes for vet / lint
+GOOSES ?= linux darwin
+REVISION := $(shell git rev-parse HEAD || echo unknown)
+BUILTAT := $(shell date +%Y-%m-%dT%H:%M:%S)
+VERSION := $(shell git describe --tags $(shell git rev-list --tags --max-count=1) 2>/dev/null || echo dev)
+GO_LDFLAGS ?= -X $(REPO_PATH)/version.Commit=$(REVISION) \
+ -X $(REPO_PATH)/version.BuildTime=$(BUILTAT) \
+ -X $(REPO_PATH)/version.Version=$(VERSION)
-build: build-agent
- mkdir -p $(BIN_DIR)
- go build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) ./cmd/kumabox
+ifneq ($(KEEP_SYMBOL), 1)
+ GO_LDFLAGS += -s
+endif
-build-agent:
- $(MAKE) build-agent-linux-amd64 build-agent-linux-arm64
+## Location to install dependencies and build outputs
+LOCALBIN ?= $(shell pwd)/bin
+PREFIX ?= /usr/local
+$(LOCALBIN):
+ mkdir -p $(LOCALBIN)
-build-agent-linux-amd64:
- GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o $(GUEST_AGENT_BINARY_AMD64) ./cmd/agent
+## Tool versions
+GOLANGCILINT_VERSION ?= v2.13.2
+GOLANGCILINT_ROOT := $(LOCALBIN)/golangci-lint-$(GOLANGCILINT_VERSION)
+GOLANGCILINT := $(GOLANGCILINT_ROOT)/golangci-lint
-build-agent-linux-arm64:
- GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o $(GUEST_AGENT_BINARY_ARM64) ./cmd/agent
+GOFUMPT_VERSION ?= v0.11.0
+GOIMPORTS_VERSION ?= v0.49.0
+GOFMT := $(LOCALBIN)/gofumpt-$(GOFUMPT_VERSION)
+GOIMPORTS := $(LOCALBIN)/goimports-$(GOIMPORTS_VERSION)
-test:
- go test ./...
+## Tool download targets
+.PHONY: golangci-lint
+golangci-lint: $(GOLANGCILINT)
+$(GOLANGCILINT):
+ GOBIN=$(GOLANGCILINT_ROOT) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCILINT_VERSION)
-test-e2e:
- test/e2e/e2e.sh $(E2E_ARGS)
+.PHONY: gofumpt
+gofumpt: $(GOFMT)
+$(GOFMT): | $(LOCALBIN)
+ GOBIN=$(LOCALBIN) go install mvdan.cc/gofumpt@$(GOFUMPT_VERSION)
+ mv $(LOCALBIN)/gofumpt $(GOFMT)
-clean:
- rm -rf $(BIN_DIR)
+.PHONY: goimports
+goimports: $(GOIMPORTS)
+$(GOIMPORTS): | $(LOCALBIN)
+ GOBIN=$(LOCALBIN) go install golang.org/x/tools/cmd/goimports@$(GOIMPORTS_VERSION)
+ mv $(LOCALBIN)/goimports $(GOIMPORTS)
+
+# --- Primary targets ---
+
+all: deps fmt lint test build ## Full pipeline: deps, fmt, lint, test, build
+
+# --- Dependencies ---
+
+deps: ## Tidy Go modules
+ go mod tidy
+
+# --- Build ---
+
+build: | $(LOCALBIN) ## Build kumabox and kumabox-check
+ CGO_ENABLED=0 go build -ldflags "$(GO_LDFLAGS)" -o $(LOCALBIN)/kumabox ./cmd/kumabox
+ cp scripts/kumabox-check.sh $(LOCALBIN)/kumabox-check
+ chmod 0755 $(LOCALBIN)/kumabox-check
+
+agent: | $(LOCALBIN) ## Build the Linux guest agent for the selected GOARCH
+ CGO_ENABLED=0 GOOS=linux GOARCH=$${GOARCH:-$$(go env GOARCH)} go build -trimpath -ldflags "$(GO_LDFLAGS)" -o $(LOCALBIN)/kumabox-agent ./cmd/kumabox-agent
+
+install: build ## Install kumabox and kumabox-check
+ install -d "$(DESTDIR)$(PREFIX)/bin"
+ install -m 0755 $(LOCALBIN)/kumabox "$(DESTDIR)$(PREFIX)/bin/kumabox"
+ install -m 0755 $(LOCALBIN)/kumabox-check "$(DESTDIR)$(PREFIX)/bin/kumabox-check"
+
+# --- Testing ---
+
+test: vet ## Run tests with race detection and coverage
+ go test -race -timeout 120s -count=1 -cover -coverprofile=coverage.out ./...
+
+doctor-check: ## Check host and guest shell script syntax
+ bash -n scripts/kumabox-check.sh
+ sh -n oci-images/ubuntu/overlay.sh
+ sh -n oci-images/ubuntu/network.sh
+
+race: ## Run all Go tests with race detection
+ go test -race ./...
+
+verify: fmt-check vet doctor-check test build ## Verify formatting, tests and build
+
+coverage: test ## Generate and display coverage report
+ go tool cover -func=coverage.out
+ @echo ""
+ @echo "To view HTML coverage report: go tool cover -html=coverage.out"
+
+# --- Code quality ---
+
+vet: ## Run go vet on every target OS
+ @for goos in $(GOOSES); do \
+ echo "==> go vet GOOS=$$goos"; \
+ GOOS=$$goos go vet ./... || exit 1; \
+ done
+
+lint: golangci-lint ## Run golangci-lint on every target OS
+ @for goos in $(GOOSES); do \
+ echo "==> golangci-lint GOOS=$$goos"; \
+ GOOS=$$goos $(GOLANGCILINT) run ./... || exit 1; \
+ done
+
+fmt: gofumpt goimports ## Format code with gofumpt and goimports
+ $(GOFMT) -extra -l -w .
+ $(GOIMPORTS) -l -w --local 'github.com/kumabox/kumabox' .
+
+fmt-check: gofumpt goimports ## Check formatting (fails if files need formatting)
+ @test -z "$$($(GOFMT) -extra -l .)" || { echo "Files need formatting (gofumpt):"; $(GOFMT) -extra -l .; exit 1; }
+ @test -z "$$($(GOIMPORTS) -l .)" || { echo "Files need formatting (goimports):"; $(GOIMPORTS) -l .; exit 1; }
+
+# --- Maintenance ---
+
+clean: ## Remove build artifacts, coverage files, and test cache
+ rm -f kumabox kumabox-linux-* kumabox-darwin-*
+ rm -rf bin/ dist/
+ rm -f coverage.out coverage.html coverage.txt
+ go clean -testcache
+
+cloc: ## Count lines of code excluding tests (requires cloc)
+ cloc --exclude-dir=vendor,dist --exclude-ext=json --not-match-f='_test\.go$$' .
+
+# --- Help ---
+
+help: ## Show this help message
+ @echo "KumaBox Makefile targets:"
+ @echo ""
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
+ awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
+ @echo ""
diff --git a/README.md b/README.md
index 1bd64ab..28f5082 100644
--- a/README.md
+++ b/README.md
@@ -1,279 +1,255 @@
+ English · 简体中文
+
+
+
+
+
+
-# KumaBox
-
-KumaBox is a daemonless microVM sandbox runtime for agents, automation, and
-untrusted workloads. It runs OCI images inside Cloud Hypervisor VMs on KVM and
-provides a container-like CLI for lifecycle, networking, command execution,
-snapshots, cloning, and device management.
+
+ Quick start ·
+ Architecture ·
+ Comparison ·
+ Roadmap
+
-[](https://github.com/kgpp34/KumaBox/actions/workflows/ci.yml)
-[](https://go.dev/)
-[](https://www.kernel.org/)
-[](LICENSE)
+AI agents write code, install packages, open network connections and touch
+files nobody reviewed. Running that on a shared kernel is a bet. KumaBox gives
+every task its own **KVM microVM** with its own kernel, disk and network
+namespace, and gets you from an OCI image to a running sandbox in one command.
> [!WARNING]
-> KumaBox is under active development. The CLI, metadata schema, and snapshot
-> format are not yet covered by a stable compatibility guarantee. Use it on
-> disposable Linux/KVM hosts until the first stable release.
-
-## Highlights
-
-- **MicroVM isolation**: each sandbox runs behind KVM in its own Cloud
- Hypervisor process instead of sharing the host kernel.
-- **Daemonless control plane**: commands open durable state, lock the affected
- resources, perform one operation, and exit. No KumaBox service is required.
-- **OCI direct boot**: OCI layers are converted to shared EROFS images and
- combined with a private writable disk for each VM.
-- **Guest execution**: run commands, stream stdin/stdout/stderr, allocate a TTY,
- and update guest identity through the vsock agent.
-- **CNI networking**: the default `cni:kumabox` network supports per-VM
- namespaces, TAP devices, multi-NIC configuration, cleanup, and reconciliation.
-- **Snapshots and clones**: capture stopped or running VMs, export and import
- snapshots, restore in place, hibernate, or clone with a fresh identity.
-- **Runtime devices**: attach data disks, virtio-fs shares, and VFIO PCI devices
- where the host and Cloud Hypervisor configuration support them.
-- **Switchable metadata**: JSON is the default; SQLite is available for stronger
- concurrent access, backup, and integrity checks.
-
-## Positioning
-
-KumaBox is a sandbox manager, not a Kubernetes container runtime and not a VMM
-library. The projects below operate at different layers:
-
-| Project | Interface presented to users | Isolation model | Primary use case |
-| --- | --- | --- | --- |
-| **KumaBox** | Daemonless VM-oriented CLI | KVM microVM through Cloud Hypervisor | Local agent sandboxes, automation, and explicit VM lifecycle management |
-| [Kata Containers](https://katacontainers.io/) | OCI/CRI container runtime | Lightweight VM containing the container workload | Adding VM isolation to containerd, CRI, and Kubernetes workflows |
-| [gVisor](https://gvisor.dev/) | OCI runtime (`runsc`) | Userspace application kernel; not a traditional guest VM | Sandboxing containers while retaining Docker/Kubernetes integration |
-| [Firecracker](https://firecracker-microvm.github.io/) | VMM process and API | KVM microVM with a deliberately minimal device model | Building serverless or container platforms that provide their own control plane |
-| [Cloud Hypervisor](https://www.cloudhypervisor.org/) | VMM process and API | KVM/MSHV VM optimized for modern cloud workloads | Building VM products; KumaBox uses it as its current backend |
-| [Cocoon](https://github.com/cocoonstack/cocoon) | Daemonless VM-oriented CLI | MicroVM through Cloud Hypervisor or Firecracker | A broader, more mature direct alternative in the same product category |
-
-Kata Containers is therefore not simply "a container running a nested VM."
-Container tooling calls the Kata runtime, and Kata places the workload inside a
-lightweight VM while preserving the expected container interface. Choose Kata
-when CRI/containerd/Kubernetes compatibility is the primary requirement. Choose
-gVisor when a userspace-kernel sandbox fits that container workflow. Choose a
-raw VMM when you are building the surrounding image, network, metadata, and
-lifecycle control plane yourself.
-
-KumaBox is intended for users who want to manage the sandbox directly as a VM
-without first deploying Kubernetes or a resident KumaBox daemon. It is not a
-drop-in OCI runtime replacement for Kata or gVisor, and its current backend and
-platform coverage are narrower than established projects.
-
-### KumaBox and Cocoon
-
-KumaBox and Cocoon are the closest comparison because both expose a daemonless,
-VM-oriented CLI and manage OCI images, CNI networking, snapshots, cloning,
-guest exec, hotplug, GC, and JSON/SQLite metadata. Their main difference is
-focus rather than basic command coverage:
-
-| Design area | KumaBox | Cocoon | Practical effect |
-| --- | --- | --- | --- |
-| VMM scope | Cloud Hypervisor only | Cloud Hypervisor and Firecracker | KumaBox has a smaller compatibility matrix; Cocoon offers more backend choice |
-| Guest scope | Linux direct boot and UEFI | Linux plus Windows support | Cocoon is the better fit when Windows or Firecracker is required |
-| Interrupted operations | One durable operation journal covers VM lifecycle, network, devices, snapshots, clone, restore, and hibernate | Targeted reconciliation and self-healing in individual lifecycle and device paths | KumaBox exposes one consistency model for auditing and extending crash recovery |
-| Integrity diagnostics | `metadata status`, `metadata verify`, verified SQLite backup, and `snapshot verify` | Metadata init/convert/backup and validation during normal operations | KumaBox provides explicit read-only preflight commands before maintenance or restore |
-| Dry-run output | Versioned JSON launch plan that must not create records or files | Human-readable generated launch commands | KumaBox is easier to consume from automated validation tooling |
-| Failure testing | Named fault points across metadata, network, snapshot, clone, delete, and GC boundaries | Extensive subsystem tests and targeted recovery tests | KumaBox tests one shared interruption model across subsystems |
-
-KumaBox's advantage is not broader feature coverage. It is a deliberately
-narrower Cloud Hypervisor product with centralized durability rules,
-machine-readable diagnostics, and fewer backend-specific branches to audit.
-Those advantages matter when building or operating Linux agent sandboxes around
-Cloud Hypervisor. Cocoon remains the stronger choice when backend flexibility,
-Windows guests, or its broader established feature set matters more.
-
-## Quick Start
-
-KumaBox currently supports Linux amd64 and arm64 hosts. The setup command
-installs pinned Cloud Hypervisor, firmware, CNI plugins, EROFS tooling, and the
-default `cni:kumabox` network.
+> KumaBox is under active development. The CLI, metadata schema and snapshot
+> format are not yet covered by a stability guarantee. Use disposable Linux/KVM
+> hosts until the first stable release.
+
+## What KumaBox is
+
+
+
+KumaBox is a **microVM sandbox runtime for AI agents and untrusted
+workloads**. It handles images, VM lifecycle, networking, snapshots, devices and
+guest execution end to end, so you work with sandboxes rather than raw VMMs.
+Anything that can run a command can drive it today: a coding agent, an agent framework's tool call,
+an RL or eval harness fanning out thousands of attempts, a CI job, or you at a
+terminal.
+
+Each sandbox is a real machine:
+
+- **Hardware isolation.** A dedicated guest kernel behind KVM, with one Cloud Hypervisor process per VM.
+- **OCI in, microVM out.** Digest-pinned OCI images become shared, read-only EROFS layers plus a private copy-on-write disk per VM.
+- **Real networking.** A network namespace per VM, multiqueue TAP and tc redirect through CNI, multiple NICs, live NIC resize.
+- **Guest execution without SSH.** `exec` over vsock with streamed stdout and stderr, stdin, env, workdir, TTY and real exit codes.
+- **Snapshots as first-class artifacts.** Stopped or running snapshots that you can verify, export, import, restore, hibernate, or clone with a fresh identity.
+- **Real devices when you need them.** Hotplug data disks, virtio-fs shares and VFIO PCI passthrough, for example a GPU.
+- **Built to be scripted.** `--json` output, versioned dry-run launch plans (`kumabox debug launch`) and per-VM usage intervals (`kumabox usage`).
+
+## Architecture
+
+
+
+**Lightweight control plane.** Every `kumabox` call opens durable state, takes
+resource locks, performs the operation and records the result. Each running VM
+is backed by its own Cloud Hypervisor process, so one sandbox can never take
+down another.
+
+**Crash-consistent, by design.** Multi-step changes are recorded in one
+operation journal covering VM lifecycle, network, devices, snapshots, clone,
+restore and hibernate. If a command is killed halfway, the next command
+reconciles the records against the real VMM and host-network state. Named
+fault-injection points across metadata, network, snapshot, clone, delete and GC
+boundaries are exercised in tests.
+
+**Switchable metadata.** JSON by default. SQLite when you need heavier
+concurrency, with `metadata status`, `metadata verify` and verified backups.
+
+| Path | Purpose |
+| --- | --- |
+| `/var/lib/kumabox` | Images, VM records, snapshots, network leases, content |
+| `/var/lib/kumabox/run` | PID files, API sockets, native restore staging |
+| `/var/log/kumabox` | VM and runtime logs |
+
+## Warm once, fork many
+
+
+
+Agents retry, branch and explore. Pay the setup cost once: boot, install
+dependencies, warm caches. Capture a **running snapshot** of memory and disks,
+then `clone` it for every attempt. Each clone gets a new network identity and a
+reseeded guest identity and entropy pool, so clones do not accidentally share
+secrets. Memory restore is selectable with `--restore-mode copy|ondemand|mmap`.
+
+## Quick start
+
+You need Linux amd64 or arm64 with `/dev/kvm`, and root.
```bash
-# Install the latest release and verify its checksum.
+# 1. Install and verify the release
curl -fsSLO https://github.com/kgpp34/KumaBox/releases/latest/download/kumabox-install.sh
curl -fsSLO https://github.com/kgpp34/KumaBox/releases/latest/download/kumabox-install.sh.sha256
sha256sum --check kumabox-install.sh.sha256
sudo sh kumabox-install.sh
-# Prepare and verify the host once.
+# 2. Prepare the host once: Cloud Hypervisor, firmware, CNI plugins, EROFS tools
sudo kumabox-check --upgrade
sudo kumabox doctor
-# Import the published OCI guest image.
-sudo kumabox image build \
- ghcr.io/kgpp34/kumabox/ubuntu:24.04 \
- --name ubuntu
-
-# Start a VM on the default CNI network.
-sudo kumabox run ubuntu \
- --name my-vm \
- --cpus 2 \
- --memory 1G \
- --storage 4G
+# 3. Build the published guest image
+sudo kumabox image build ghcr.io/kgpp34/kumabox/ubuntu:24.04 --name ubuntu
-# Interact with the guest. Run console in a separate terminal when needed.
+# 4. Run a sandbox and talk to it
+sudo kumabox run ubuntu --name my-vm --cpus 2 --memory 1G --storage 4G
sudo kumabox exec my-vm -- uname -a
sudo kumabox exec -it my-vm -- sh
-sudo kumabox console my-vm
-# Capture running state and create an independent clone.
+# 5. Warm once, fork many
sudo kumabox snapshot create my-vm --name base --type running
sudo kumabox clone base --name fresh
sudo kumabox exec fresh -- hostname
-# Clean up.
+# 6. Clean up
sudo kumabox delete fresh my-vm --force
sudo kumabox snapshot rm base
sudo kumabox image rm ubuntu
sudo kumabox gc
```
-The host and guest artifacts are a matched release pair. Pin a versioned guest
-tag such as `24.04-v0.1.0`, or an OCI digest, when reproducibility matters.
-
-## How It Works
-
-```mermaid
-flowchart LR
- User[User or automation] --> CLI
-
- subgraph Command[One KumaBox command]
- CLI[kumabox CLI]
- Locks[Process and resource locks]
- Runtime[VM lifecycle orchestration]
- State[Durable state JSON or SQLite]
-
- CLI --> Locks
- CLI --> Runtime
- CLI <--> State
- Runtime --> State
- end
-
- Runtime --> Image[OCI and EROFS layers]
- Runtime --> Disk[Writable disks]
- Runtime --> Network[CNI, netns, and TAP]
- Image --> VMM[Cloud Hypervisor]
- Disk --> VMM
- Network --> VMM
- VMM --> Guest[MicroVM guest]
- Guest --> Agent[kumabox-agent]
- CLI <-->|vsock| Agent
-
- CLI -. exits after the operation .-> NoDaemon[No resident KumaBox daemon]
- VMM -. remains while the VM runs .-> VMProcess[One VMM process per running VM]
-```
+Host and guest artifacts are a matched release pair. Pin a versioned guest tag
+such as `24.04-v0.1.0`, or an OCI digest, when reproducibility matters.
+`sudo kumabox-check` alone performs a read-only host audit.
-Durable data lives under `/var/lib/kumabox`, runtime sockets and native restore
-staging under `/var/lib/kumabox/run`, and logs under `/var/log/kumabox`.
-KumaBox reconciles these records with observed VMM and host-network state after
-an interrupted command or host restart.
+### Drive it from an agent
-## Requirements
+`exec --json` prints `ok`, `exitCode`, and base64-encoded `stdout` and `stderr`.
+The process exit code mirrors the guest command's exit code.
-| Component | Requirement |
-| --- | --- |
-| Host | Linux amd64 or arm64 |
-| Virtualization | Hardware virtualization and accessible `/dev/kvm` |
-| VMM | Cloud Hypervisor |
-| Disk tools | `qemu-img`, ext4 tools, and `mkfs.erofs` 1.8+ |
-| Networking | `/dev/net/tun`, `ip`, CNI plugins, and host forwarding |
-| Privileges | Root for KVM, TAP/CNI, device, and system-state operations |
-| Source builds | Go 1.24.4 or newer |
+```python
+import base64, json, subprocess
+
+def run_in_sandbox(vm: str, script: str, timeout: str = "120s") -> dict:
+ proc = subprocess.run(
+ ["sudo", "kumabox", "exec", "--json", "--timeout", timeout,
+ vm, "--", "sh", "-c", script],
+ capture_output=True, text=True,
+ )
+ result = json.loads(proc.stdout)
+ for key in ("stdout", "stderr"):
+ result[key] = base64.b64decode(result.get(key) or "").decode(errors="replace")
+ return result
-Run `sudo kumabox-check` for a read-only host audit. Run
-`sudo kumabox-check --fix` to create missing KumaBox directories and network
-configuration without upgrading pinned dependencies.
+print(run_in_sandbox("fresh", "echo hello from $(hostname)"))
+```
-## Core Commands
+Fan out parallel attempts from one warm snapshot:
+
+```bash
+for i in $(seq 1 8); do
+ sudo kumabox clone base --name try-$i &
+done
+wait
+sudo kumabox ps
+```
+
+The published Ubuntu guest is intentionally minimal. To bake in your own
+toolchain (Python, Node, browsers), extend
+[`oci-images/ubuntu/24.04/Dockerfile`](oci-images/ubuntu/24.04/Dockerfile),
+which already installs the matching `kumabox-agent`, kernel and initramfs.
+
+## Core commands
| Area | Commands |
| --- | --- |
| VM lifecycle | `run`, `create`, `start`, `stop`, `pause`, `resume`, `delete`, `ps`, `inspect` |
-| Guest access | `exec`, `console`, `logs`, `agent` |
-| Images | `image add`, `image build`, `image pull`, `image inspect`, `image ls`, `image rm` |
+| Guest access | `exec`, `console`, `logs`, `agent status`, `agent ping`, `agent reseed` |
+| Images | `image build`, `image add`, `image pull-oci`, `image pull`, `image import`, `image inspect`, `image ls`, `image rm` |
| Snapshots | `snapshot create`, `snapshot verify`, `snapshot export`, `snapshot import`, `restore`, `clone`, `hibernate` |
| Networking | `network inspect`, `network setup`, `network teardown`, `network resize` |
-| Devices | `disk`, `fs`, `device` |
-| Operations | `doctor`, `metadata`, `usage`, `gc`, `debug` |
+| Devices | `disk attach/detach/list`, `fs attach/detach/list`, `device attach/detach/list/state` |
+| Operations | `doctor`, `metadata`, `usage`, `gc`, `debug launch` |
-Use `kumabox --help` as the authoritative CLI reference. Inspection
-and automation-oriented commands support structured JSON output where shown by
-their help.
+`kumabox --help` is the authoritative reference.
-## Metadata Backends
+## How KumaBox compares
-JSON metadata is used by default:
+
-```bash
-sudo kumabox ps
-```
+[E2B](https://github.com/e2b-dev/infra) and
+[CubeSandbox](https://github.com/TencentCloud/CubeSandbox) are excellent
+projects that share KumaBox's goal of giving every agent task its own kernel.
+KumaBox takes a different path in a few places:
-Select SQLite consistently for every command that accesses the same state:
+- **VM-native, not container-shaped.** Sandboxes are real VMs with the full device model of Cloud Hypervisor: hotplug disks, virtio-fs shares, live NIC resize and VFIO PCI passthrough for GPUs and other accelerators.
+- **Snapshots you can hold.** A running snapshot is a verifiable, portable package. Export it, move it to another host, import it and clone from it.
+- **Layered images, shared on disk.** OCI layers become read-only EROFS images shared by every VM on the host; each VM only pays for its own copy-on-write writes.
+- **Minimal to install.** One Go binary plus Cloud Hypervisor and CNI plugins. Metadata lives in JSON or embedded SQLite, with no external database, cache or object store to operate.
+- **Correctness you can audit.** A single operation journal and named fault-injection points cover lifecycle, network, snapshot, clone and GC paths.
+- **MIT licensed**, on amd64 and arm64.
-```bash
-sudo kumabox --metadata-backend sqlite metadata init
-sudo kumabox --metadata-backend sqlite run ubuntu --name sqlite-vm --storage 4G
-sudo kumabox --metadata-backend sqlite ps
-sudo kumabox --metadata-backend sqlite metadata backup /var/lib/kumabox/metadata-backup.db
-```
+Related projects: [Kata Containers](https://katacontainers.io/),
+[gVisor](https://gvisor.dev/),
+[Firecracker](https://firecracker-microvm.github.io/),
+[Cloud Hypervisor](https://www.cloudhypervisor.org/) and
+[Cocoon](https://github.com/cocoonstack/cocoon).
+
+## Vision
+
+Every agent action should get a disposable computer that is as cheap to fork as
+a git branch and as safe as a separate machine. KumaBox builds that from the
+bottom up: first a correct, crash-consistent runtime on every host, then a
+long-running service and a multi-node control plane on top of the same
+journal and metadata, so a sandbox behaves the same on a laptop-sized server
+and across a fleet.
-Do not switch backends for an existing resource set without using the metadata
-conversion workflow exposed by `kumabox metadata --help`.
+## Roadmap
-## Build and Test
+> Proposed direction. Open an issue to weigh in.
+
+- [x] OCI to EROFS images, CNI networking, guest exec over vsock
+- [x] Running snapshots, clone, restore, hibernate, export and import
+- [x] Hotplug disks, virtio-fs, VFIO PCI; JSON and SQLite metadata
+- [ ] Daemon mode with an HTTP API
+- [ ] Multi-node control plane and scheduling
+- [ ] Go, Python and TypeScript SDKs
+- [ ] E2B-compatible API, so existing E2B code can point at KumaBox
+- [ ] MCP server, so agents can create and drive sandboxes as tools
+- [ ] Warm pools and published clone-latency benchmarks
+- [ ] Per-sandbox egress policy
+
+## Build and test
```bash
-git clone https://github.com/kgpp34/KumaBox.git
-cd KumaBox
+git clone https://github.com/kgpp34/KumaBox.git && cd KumaBox
make build
make test
go vet ./...
./bin/kumabox version --json
```
-The full test suite requires a Linux/KVM host and exercises OCI image creation,
-cold boot, guest exec and TTY, CNI allocation and cleanup, stopped and native
-snapshots, clone/restore, disk hotplug, and metadata backup:
+The E2E suite needs a Linux/KVM host and exercises OCI image creation, cold
+boot, guest exec and TTY, CNI allocation and cleanup, stopped and native
+snapshots, clone and restore, disk hotplug and metadata backup:
```bash
GO_BIN="$(go env GOROOT)/bin/go"
+sudo test/e2e/e2e.sh --go-bin "$GO_BIN" --metadata-backend sqlite
+sudo test/e2e/e2e.sh --go-bin "$GO_BIN" --metadata-backend json
+```
-sudo test/e2e/e2e.sh \
- --go-bin "$GO_BIN" \
- --metadata-backend sqlite
+README graphics are generated from code. Edit the scripts in
+`assets/readme/src/` and run `python3 assets/readme/src/build.py`.
-sudo test/e2e/e2e.sh \
- --go-bin "$GO_BIN" \
- --metadata-backend json
-```
+## Security model
+
+- KumaBox adds a VM boundary, but the VMM, KVM, guest kernel, firmware, images and agent remain in the trusted computing base.
+- Host setup changes privileged networking and system configuration. Review `scripts/check.sh` before running `--fix` or `--upgrade`.
+- VFIO hands a physical device to a guest and requires correct IOMMU grouping; misuse can affect host stability and isolation.
+- Snapshot compatibility depends on host architecture, Cloud Hypervisor version, VM configuration and capture mode.
-The E2E script uses KumaBox's fixed system paths and reserved `e2e-*` resource
-names. It reuses an existing managed E2E image unless `--rebuild-image` is
-specified.
-
-## Security and Limitations
-
-- KumaBox improves workload isolation by adding a VM boundary, but the VMM,
- KVM, guest kernel, firmware, image, agent, and host integrations remain in the
- trusted computing base.
-- Host setup changes privileged networking and system configuration. Review
- `scripts/check.sh` before running `--fix` or `--upgrade`.
-- VFIO passes a physical device to a guest and requires correct IOMMU grouping;
- misuse can affect host stability and isolation.
-- Snapshot compatibility depends on the host architecture, Cloud Hypervisor
- version, VM configuration, and capture mode.
-- Cloud Hypervisor is the only supported VMM backend. Firecracker is not part of
- the current release scope.
-
-Report reproducible bugs and security concerns through the repository issue
-tracker. Do not include secrets, private images, or production snapshots in a
-public report.
+Report reproducible bugs and security concerns through the issue tracker. Do
+not attach secrets, private images or production snapshots.
## License
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000..7e8a43b
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,246 @@
+
+
+
+
+
+ English · 简体中文
+
+
+
+
+
+
+
+
+
+
+ 快速开始 ·
+ 架构 ·
+ 对比 ·
+ 路线图
+
+
+AI Agent 会写代码、装依赖、发起网络连接,还会读写没人审过的文件。把这些放在共享内核上跑,
+本质上是在赌运气。KumaBox 为每个任务分配一台独立的 **KVM microVM**:独立的内核、独立的磁盘、
+独立的网络命名空间。从 OCI 镜像到一个可用的沙箱,只需要一条命令。
+
+> [!WARNING]
+> KumaBox 仍在快速迭代中。CLI、元数据格式和快照格式暂不承诺向后兼容。
+> 在首个稳定版发布之前,请在可随时重建的 Linux/KVM 主机上使用。
+
+## KumaBox 是什么
+
+
+
+KumaBox 是一个**面向 AI Agent 和不可信工作负载的 microVM 沙箱运行时**。镜像、虚拟机生命周期、
+网络、快照、设备和 guest 内命令执行,都由它一站式管理。你面对的是"沙箱",而不是裸的 VMM。
+
+任何能执行命令的程序现在都能驱动它,例如:
+编程 Agent、Agent 框架里的工具调用、一次拉起成千上万次尝试的 RL / 评测框架、CI 任务,
+或者坐在终端前的你。
+
+每个沙箱都是一台真正的机器:
+
+- **硬件级隔离。** 每个沙箱运行在 KVM 之上的独立 guest 内核里,每台 VM 对应一个独立的 Cloud Hypervisor 进程。
+- **OCI 进,microVM 出。** 按 digest 固定的 OCI 镜像被转换成共享、只读的 EROFS 层,再为每台 VM 叠加一块私有的写时复制磁盘。
+- **真实的网络。** 每台 VM 拥有独立的网络命名空间,通过 CNI 使用多队列 TAP 和 tc redirect。支持多网卡,也支持在线调整网卡。
+- **无需 SSH 即可执行命令。** `exec` 走 vsock 通道,支持流式 stdout / stderr、stdin、环境变量、工作目录、TTY,并返回真实的退出码。
+- **快照是一等公民。** 支持停机快照和运行态快照,可以校验、导出、导入、原地恢复、休眠,或者以全新身份克隆。
+- **按需使用真实设备。** 支持热插拔数据盘、virtio-fs 共享目录,以及 VFIO PCI 直通(例如 GPU)。
+- **为脚本化而生。** 提供 `--json` 输出、带版本号的启动计划预演(`kumabox debug launch`),以及按 VM 统计的用量区间(`kumabox usage`)。
+
+## 架构
+
+
+
+**轻量的控制面。** 每次调用 `kumabox`,都会打开持久化状态、获取资源锁、执行操作并记录结果。
+每台运行中的 VM 由各自独立的 Cloud Hypervisor 进程承载,一个沙箱出问题不会拖垮其他沙箱。
+
+**崩溃一致性是设计目标。** 所有多步骤变更都记录在同一套操作日志(operation journal)里,
+覆盖 VM 生命周期、网络、设备、快照、克隆、恢复和休眠。如果某条命令执行到一半被中断,
+下一次调用会把记录与真实的 VMM 进程和主机网络状态重新对齐。
+元数据、网络、快照、克隆、删除和 GC 的关键边界上都埋了具名的故障注入点,并有测试覆盖。
+
+**可切换的元数据后端。** 默认使用 JSON。需要更高并发时可以切换到 SQLite,
+并配合 `metadata status`、`metadata verify` 和经过校验的备份使用。
+
+| 路径 | 用途 |
+| --- | --- |
+| `/var/lib/kumabox` | 镜像、VM 记录、快照、网络租约和内容存储 |
+| `/var/lib/kumabox/run` | PID 文件、API socket、运行态恢复的暂存目录 |
+| `/var/log/kumabox` | VM 与运行时日志 |
+
+## 一次预热,无限分叉
+
+
+
+Agent 天生就会重试、分支和探索。准备环境的成本只需要付一次:启动、安装依赖、预热缓存。
+然后对内存和磁盘打一个**运行态快照**,每次尝试都从它 `clone` 出一台新沙箱。
+每个克隆都会分配新的网络身份,并重新注入 guest 身份标识和熵,避免克隆之间意外共享密钥。
+内存恢复方式可以通过 `--restore-mode copy|ondemand|mmap` 选择。
+
+## 快速开始
+
+需要一台 amd64 或 arm64 的 Linux 主机,能访问 `/dev/kvm`,并具备 root 权限。
+
+```bash
+# 1. 安装并校验发布包
+curl -fsSLO https://github.com/kgpp34/KumaBox/releases/latest/download/kumabox-install.sh
+curl -fsSLO https://github.com/kgpp34/KumaBox/releases/latest/download/kumabox-install.sh.sha256
+sha256sum --check kumabox-install.sh.sha256
+sudo sh kumabox-install.sh
+
+# 2. 一次性准备主机:Cloud Hypervisor、固件、CNI 插件、EROFS 工具
+sudo kumabox-check --upgrade
+sudo kumabox doctor
+
+# 3. 构建官方 guest 镜像
+sudo kumabox image build ghcr.io/kgpp34/kumabox/ubuntu:24.04 --name ubuntu
+
+# 4. 启动一个沙箱并与之交互
+sudo kumabox run ubuntu --name my-vm --cpus 2 --memory 1G --storage 4G
+sudo kumabox exec my-vm -- uname -a
+sudo kumabox exec -it my-vm -- sh
+
+# 5. 一次预热,无限分叉
+sudo kumabox snapshot create my-vm --name base --type running
+sudo kumabox clone base --name fresh
+sudo kumabox exec fresh -- hostname
+
+# 6. 清理
+sudo kumabox delete fresh my-vm --force
+sudo kumabox snapshot rm base
+sudo kumabox image rm ubuntu
+sudo kumabox gc
+```
+
+主机端和 guest 端的产物需要配套使用。对可复现性有要求时,请固定带版本号的 guest 标签
+(例如 `24.04-v0.1.0`)或 OCI digest。单独执行 `sudo kumabox-check` 会做一次只读的主机检查。
+
+### 在 Agent 中调用
+
+`exec --json` 会输出 `ok`、`exitCode`,以及经过 base64 编码的 `stdout` 和 `stderr`。
+进程的退出码与 guest 内命令的退出码一致。
+
+```python
+import base64, json, subprocess
+
+def run_in_sandbox(vm: str, script: str, timeout: str = "120s") -> dict:
+ proc = subprocess.run(
+ ["sudo", "kumabox", "exec", "--json", "--timeout", timeout,
+ vm, "--", "sh", "-c", script],
+ capture_output=True, text=True,
+ )
+ result = json.loads(proc.stdout)
+ for key in ("stdout", "stderr"):
+ result[key] = base64.b64decode(result.get(key) or "").decode(errors="replace")
+ return result
+
+print(run_in_sandbox("fresh", "echo hello from $(hostname)"))
+```
+
+从同一个预热好的快照并行分叉出多个尝试:
+
+```bash
+for i in $(seq 1 8); do
+ sudo kumabox clone base --name try-$i &
+done
+wait
+sudo kumabox ps
+```
+
+官方的 Ubuntu guest 镜像刻意保持精简。如果需要预装自己的工具链(Python、Node、浏览器等),
+可以在 [`oci-images/ubuntu/24.04/Dockerfile`](oci-images/ubuntu/24.04/Dockerfile) 的基础上扩展。
+这个 Dockerfile 已经内置了配套的 `kumabox-agent`、内核和 initramfs。
+
+## 常用命令
+
+| 领域 | 命令 |
+| --- | --- |
+| VM 生命周期 | `run`、`create`、`start`、`stop`、`pause`、`resume`、`delete`、`ps`、`inspect` |
+| Guest 访问 | `exec`、`console`、`logs`、`agent status`、`agent ping`、`agent reseed` |
+| 镜像 | `image build`、`image add`、`image pull-oci`、`image pull`、`image import`、`image inspect`、`image ls`、`image rm` |
+| 快照 | `snapshot create`、`snapshot verify`、`snapshot export`、`snapshot import`、`restore`、`clone`、`hibernate` |
+| 网络 | `network inspect`、`network setup`、`network teardown`、`network resize` |
+| 设备 | `disk attach/detach/list`、`fs attach/detach/list`、`device attach/detach/list/state` |
+| 运维 | `doctor`、`metadata`、`usage`、`gc`、`debug launch` |
+
+完整参数以 `kumabox --help` 为准。
+
+## 与同类项目的对比
+
+
+
+[E2B](https://github.com/e2b-dev/infra) 和
+[CubeSandbox](https://github.com/TencentCloud/CubeSandbox) 都是非常优秀的项目。
+它们和 KumaBox 目标一致:为每个 Agent 任务提供独立的内核。KumaBox 在以下几个方面走了不同的路线:
+
+- **VM 原生,而不是"套了 VM 的容器"。** 沙箱是真正的虚拟机,完整继承 Cloud Hypervisor 的设备模型:热插拔磁盘、virtio-fs 共享目录、在线调整网卡,以及面向 GPU 等加速卡的 VFIO PCI 直通。
+- **快照可以带走。** 运行态快照是一个可校验、可移植的包:导出、拷贝到另一台主机、导入,再从它克隆。
+- **分层镜像,磁盘共享。** OCI 层被转换成只读 EROFS 镜像,由同一主机上的所有 VM 共享。每台 VM 只为自己的写时复制数据付出存储成本。
+- **安装极简。** 只需一个 Go 二进制,加上 Cloud Hypervisor 和 CNI 插件。元数据存放在 JSON 或内嵌的 SQLite 中,不需要额外运维数据库、缓存或对象存储。
+- **正确性可审计。** 统一的操作日志和具名故障注入点,覆盖生命周期、网络、快照、克隆和 GC 等路径。
+- **MIT 许可**,同时支持 amd64 和 arm64。
+
+相关项目:[Kata Containers](https://katacontainers.io/)、
+[gVisor](https://gvisor.dev/)、
+[Firecracker](https://firecracker-microvm.github.io/)、
+[Cloud Hypervisor](https://www.cloudhypervisor.org/)、
+[Cocoon](https://github.com/cocoonstack/cocoon)。
+
+## 愿景
+
+每一次 Agent 行动都应该拥有一台用完即弃的计算机:像 git 分支一样便宜地分叉,像独立机器一样安全。
+KumaBox 自底向上构建这一目标。先在每台主机上做好一个正确、崩溃一致的运行时,
+再基于同一套操作日志和元数据,往上构建常驻服务和多节点控制面。
+这样,同一个沙箱在单台服务器上和整个集群中的行为是一致的。
+
+## 路线图
+
+> 规划方向,欢迎在 Issue 中参与讨论。
+
+- [x] OCI 转 EROFS 镜像、CNI 网络、基于 vsock 的 guest 命令执行
+- [x] 运行态快照、克隆、恢复、休眠、导出与导入
+- [x] 热插拔磁盘、virtio-fs、VFIO PCI;JSON 与 SQLite 元数据后端
+- [ ] 带 HTTP API 的 daemon 模式
+- [ ] 多节点控制面与调度
+- [ ] Go、Python、TypeScript SDK
+- [ ] E2B 兼容 API,让现有 E2B 代码可以直接切换到 KumaBox
+- [ ] MCP server,让 Agent 以工具的形式创建和操作沙箱
+- [ ] 预热池,以及公开的克隆延迟基准测试
+- [ ] 按沙箱粒度的出网策略
+
+## 构建与测试
+
+```bash
+git clone https://github.com/kgpp34/KumaBox.git && cd KumaBox
+make build
+make test
+go vet ./...
+./bin/kumabox version --json
+```
+
+E2E 测试需要 Linux/KVM 主机。它覆盖的流程包括:OCI 镜像构建、冷启动、guest 命令执行与 TTY、
+CNI 地址分配与清理、停机快照与运行态快照、克隆与恢复、磁盘热插拔,以及元数据备份。
+
+```bash
+GO_BIN="$(go env GOROOT)/bin/go"
+sudo test/e2e/e2e.sh --go-bin "$GO_BIN" --metadata-backend sqlite
+sudo test/e2e/e2e.sh --go-bin "$GO_BIN" --metadata-backend json
+```
+
+README 中的配图由代码生成。修改 `assets/readme/src/` 下的脚本后,
+运行 `python3 assets/readme/src/build.py` 即可重新生成。
+
+## 安全模型
+
+- KumaBox 在工作负载外增加了一层虚拟机边界,但 VMM、KVM、guest 内核、固件、镜像和 agent 仍然属于可信计算基。
+- 主机初始化会修改特权网络和系统配置。运行 `--fix` 或 `--upgrade` 之前,请先审阅 `scripts/check.sh`。
+- VFIO 会把物理设备直接交给 guest,需要正确的 IOMMU 分组;使用不当可能影响主机的稳定性和隔离性。
+- 快照的兼容性取决于主机架构、Cloud Hypervisor 版本、VM 配置和快照类型。
+
+可复现的 Bug 和安全问题请通过 Issue 反馈。请不要在公开报告中附带密钥、私有镜像或生产环境快照。
+
+## 许可证
+
+KumaBox 基于 [MIT 许可证](LICENSE) 开源。
diff --git a/agent/agent_test.go b/agent/agent_test.go
new file mode 100644
index 0000000..fc0abe9
--- /dev/null
+++ b/agent/agent_test.go
@@ -0,0 +1,286 @@
+package agent
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "log"
+ "net"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestProtocolEncodingIsStable(t *testing.T) {
+ var wire bytes.Buffer
+ encoder := NewEncoder(&wire)
+ if err := encoder.Encode(Message{Type: MessageExec, Argv: []string{"env"}, Env: map[string]string{"FOO": "bar"}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := encoder.Encode(Message{Type: MessageStdout, Data: []byte("hello\n")}); err != nil {
+ t.Fatal(err)
+ }
+ want := "{\"type\":\"exec\",\"argv\":[\"env\"],\"env\":{\"FOO\":\"bar\"}}\n" +
+ "{\"type\":\"stdout\",\"data\":\"aGVsbG8K\"}\n"
+ if wire.String() != want {
+ t.Fatalf("wire data = %q, want %q", wire.String(), want)
+ }
+}
+
+func TestEncoderAllowsOnlyOneTerminalMessage(t *testing.T) {
+ encoder := NewEncoder(io.Discard)
+ if err := encoder.Encode(Message{Type: MessageExit}); err != nil {
+ t.Fatal(err)
+ }
+ if err := encoder.Encode(Message{Type: MessageStdout, Data: []byte("late")}); !errors.Is(err, errTerminalMessageSent) {
+ t.Fatalf("late message error = %v", err)
+ }
+}
+
+func TestDecoderRejectsOversizedFrame(t *testing.T) {
+ decoder := NewDecoder(strings.NewReader(strings.Repeat("x", maximumFrameSize+1) + "\n"))
+ if _, err := decoder.Decode(); err == nil {
+ t.Fatal("decoder accepted an oversized frame")
+ }
+}
+
+func TestRunStreamsInputOutputAndExitStatus(t *testing.T) {
+ client, guest := net.Pipe()
+ server := &Server{logger: log.New(io.Discard, "", 0), connections: make(map[net.Conn]struct{})}
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ server.handle(context.Background(), guest)
+ }()
+
+ var stdout, stderr bytes.Buffer
+ code, err := Run(
+ t.Context(), client,
+ types.Command{
+ Args: []string{"sh", "-c", `printf '%s:' "$KUMABOX_TEST"; cat; printf 'warning' >&2; exit 7`},
+ Env: map[string]string{"KUMABOX_TEST": "value"},
+ },
+ strings.NewReader("input\n"), &stdout, &stderr,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if code != 7 || stdout.String() != "value:input\n" || stderr.String() != "warning" {
+ t.Fatalf("result: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
+ }
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("agent session did not close")
+ }
+}
+
+func TestServerCancelsCommandWhenHostDisconnects(t *testing.T) {
+ client, guest := net.Pipe()
+ server := &Server{logger: log.New(io.Discard, "", 0), connections: make(map[net.Conn]struct{})}
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ server.handle(context.Background(), guest)
+ }()
+
+ encoder := NewEncoder(client)
+ decoder := NewDecoder(client)
+ if err := encoder.Encode(Message{Type: MessageExec, Argv: []string{"sleep", "30"}}); err != nil {
+ t.Fatal(err)
+ }
+ message, err := decoder.Decode()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if message.Type != MessageStarted {
+ t.Fatalf("first response type = %q, want %q", message.Type, MessageStarted)
+ }
+ if err := client.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ select {
+ case <-done:
+ case <-time.After(3 * time.Second):
+ t.Fatal("guest command survived the host disconnect")
+ }
+}
+
+func TestServerRejectsUnexpectedInputFrame(t *testing.T) {
+ client, guest := net.Pipe()
+ server := &Server{logger: log.New(io.Discard, "", 0), connections: make(map[net.Conn]struct{})}
+ go server.handle(context.Background(), guest)
+
+ encoder := NewEncoder(client)
+ decoder := NewDecoder(client)
+ if err := encoder.Encode(Message{Type: MessageExec, Argv: []string{"sleep", "30"}}); err != nil {
+ t.Fatal(err)
+ }
+ if message, err := decoder.Decode(); err != nil || message.Type != MessageStarted {
+ t.Fatalf("started response = %#v, %v", message, err)
+ }
+ if err := encoder.Encode(Message{Type: MessageStdout}); err != nil {
+ t.Fatal(err)
+ }
+ message, err := decoder.Decode()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if message.Type != MessageError || !strings.Contains(message.Message, "unexpected frame type") {
+ t.Fatalf("protocol response = %#v", message)
+ }
+ _ = client.Close()
+}
+
+func TestServerReportsExitWhenCommandFinishesBeforeStdin(t *testing.T) {
+ client, guest := net.Pipe()
+ server := &Server{logger: log.New(io.Discard, "", 0), connections: make(map[net.Conn]struct{})}
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ server.handle(t.Context(), guest)
+ }()
+
+ encoder := NewEncoder(client)
+ decoder := NewDecoder(client)
+ if err := encoder.Encode(Message{Type: MessageExec, Argv: []string{"sh", "-c", "exit 19"}}); err != nil {
+ t.Fatal(err)
+ }
+ if message, err := decoder.Decode(); err != nil || message.Type != MessageStarted {
+ t.Fatalf("started response = %#v, %v", message, err)
+ }
+ message, err := decoder.Decode()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if message.Type != MessageExit || message.ExitCode != 19 {
+ t.Fatalf("exit response = %#v", message)
+ }
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("stdin receiver survived command exit")
+ }
+}
+
+type queueListener struct {
+ connections chan net.Conn
+ accepted chan struct{}
+ closed chan struct{}
+ closeOnce sync.Once
+}
+
+func newQueueListener(capacity int) *queueListener {
+ return &queueListener{
+ connections: make(chan net.Conn, capacity),
+ accepted: make(chan struct{}, capacity),
+ closed: make(chan struct{}),
+ }
+}
+
+func (l *queueListener) Accept() (net.Conn, error) {
+ select {
+ case connection := <-l.connections:
+ l.accepted <- struct{}{}
+ return connection, nil
+ case <-l.closed:
+ return nil, net.ErrClosed
+ }
+}
+
+func (l *queueListener) Close() error {
+ l.closeOnce.Do(func() { close(l.closed) })
+ return nil
+}
+
+func (*queueListener) Addr() net.Addr { return testAddress("agent") }
+
+type testAddress string
+
+func (a testAddress) Network() string { return "test" }
+func (a testAddress) String() string { return string(a) }
+
+func TestServerShutdownClosesIdleConnectionsAndWaits(t *testing.T) {
+ const connectionCount = 3
+ listener := newQueueListener(connectionCount)
+ server, err := NewServer(listener, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(t.Context())
+ serveDone := make(chan error, 1)
+ go func() { serveDone <- server.Serve(ctx) }()
+
+ clients := make([]net.Conn, 0, connectionCount)
+ for range connectionCount {
+ client, guest := net.Pipe()
+ if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ clients = append(clients, client)
+ listener.connections <- guest
+ }
+ for range connectionCount {
+ select {
+ case <-listener.accepted:
+ case <-time.After(time.Second):
+ t.Fatal("server did not accept every connection")
+ }
+ }
+ cancel()
+ select {
+ case err := <-serveDone:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("Serve did not wait for idle handlers to exit")
+ }
+ for index, client := range clients {
+ if _, err := client.Read(make([]byte, 1)); err == nil {
+ t.Fatalf("connection %d remained open after shutdown", index)
+ }
+ _ = client.Close()
+ }
+}
+
+type failingListener struct {
+ err error
+ closed bool
+}
+
+func (l *failingListener) Accept() (net.Conn, error) { return nil, l.err }
+func (l *failingListener) Close() error {
+ l.closed = true
+ return nil
+}
+func (*failingListener) Addr() net.Addr { return testAddress("failing") }
+
+func TestServerReturnsPermanentAcceptError(t *testing.T) {
+ failure := errors.New("accept failed permanently")
+ listener := &failingListener{err: failure}
+ server, err := NewServer(listener, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = server.Serve(t.Context())
+ if !errors.Is(err, failure) || !listener.closed {
+ t.Fatalf("Serve error = %v, listener closed = %v", err, listener.closed)
+ }
+}
+
+func TestMergeEnvironmentReplacesInheritedValues(t *testing.T) {
+ got := mergeEnvironment(
+ []string{"PATH=/bin", "A=old", "B=keep"},
+ map[string]string{"A": "new", "C": "added"},
+ )
+ want := []string{"PATH=/bin", "B=keep", "A=new", "C=added"}
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
+ t.Fatalf("environment = %#v, want %#v", got, want)
+ }
+}
diff --git a/agent/client.go b/agent/client.go
new file mode 100644
index 0000000..01a8a0b
--- /dev/null
+++ b/agent/client.go
@@ -0,0 +1,110 @@
+package agent
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sync/atomic"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+var errMissingExit = errors.New("agent connection closed before an exit frame")
+
+// Run executes a command over an already connected transport. Nil stdin
+// closes the guest process input immediately; nil output writers discard their
+// streams.
+func Run(ctx context.Context, connection io.ReadWriteCloser, command types.Command, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ if err := command.Validate(); err != nil {
+ return 0, fmt.Errorf("validate agent command: %w", err)
+ }
+ sessionCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ context.AfterFunc(sessionCtx, func() { _ = connection.Close() })
+
+ encoder := NewEncoder(connection)
+ decoder := NewDecoder(connection)
+ if err := encoder.Encode(Message{Type: MessageExec, Argv: command.Args, Env: command.Env}); err != nil {
+ return 0, fmt.Errorf("send exec frame: %w", err)
+ }
+
+ var inputError atomic.Pointer[error]
+ if stdin == nil {
+ if err := encoder.Encode(Message{Type: MessageStdinClose}); err != nil {
+ return 0, fmt.Errorf("close guest stdin: %w", err)
+ }
+ } else {
+ go sendInput(stdin, encoder, &inputError, cancel)
+ }
+
+ for {
+ message, err := decoder.Decode()
+ if err != nil {
+ if inputErr := storedInputError(&inputError); inputErr != nil {
+ return 0, inputErr
+ }
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return 0, ctxErr
+ }
+ if errors.Is(err, io.EOF) {
+ return 0, errMissingExit
+ }
+ return 0, err
+ }
+ switch message.Type {
+ case MessageStarted:
+ case MessageStdout:
+ if stdout != nil {
+ if _, err := stdout.Write(message.Data); err != nil {
+ return 0, fmt.Errorf("write command stdout: %w", err)
+ }
+ }
+ case MessageStderr:
+ if stderr != nil {
+ if _, err := stderr.Write(message.Data); err != nil {
+ return 0, fmt.Errorf("write command stderr: %w", err)
+ }
+ }
+ case MessageExit:
+ if inputErr := storedInputError(&inputError); inputErr != nil {
+ return 0, inputErr
+ }
+ return message.ExitCode, nil
+ case MessageError:
+ return 0, fmt.Errorf("guest agent: %s", message.Message)
+ default:
+ // Clients ignore unknown response messages so a
+ // newer agent can add optional progress or capability frames.
+ }
+ }
+}
+
+func sendInput(reader io.Reader, encoder *Encoder, result *atomic.Pointer[error], cancel context.CancelFunc) {
+ buffer := make([]byte, streamChunkSize)
+ for {
+ length, err := reader.Read(buffer)
+ if length > 0 {
+ if encodeErr := encoder.Encode(Message{Type: MessageStdin, Data: buffer[:length]}); encodeErr != nil {
+ return
+ }
+ }
+ if err == nil {
+ continue
+ }
+ if !errors.Is(err, io.EOF) {
+ errorCopy := err
+ result.Store(&errorCopy)
+ cancel()
+ }
+ _ = encoder.Encode(Message{Type: MessageStdinClose})
+ return
+ }
+}
+
+func storedInputError(result *atomic.Pointer[error]) error {
+ if err := result.Load(); err != nil {
+ return fmt.Errorf("read command stdin: %w", *err)
+ }
+ return nil
+}
diff --git a/agent/process_linux.go b/agent/process_linux.go
new file mode 100644
index 0000000..0da9924
--- /dev/null
+++ b/agent/process_linux.go
@@ -0,0 +1,31 @@
+//go:build linux
+
+package agent
+
+import (
+ "os"
+ "os/exec"
+ "syscall"
+)
+
+// configureProcess places the guest command in its own process group so
+// cancellation cannot leave background descendants behind.
+func configureProcess(command *exec.Cmd) {
+ command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ command.Cancel = func() error {
+ return syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
+ }
+}
+
+// processExitCode maps signal termination to the conventional shell status
+// 128+signal so the host CLI can preserve meaningful guest results.
+func processExitCode(state *os.ProcessState) int {
+ if code := state.ExitCode(); code >= 0 {
+ return code
+ }
+ status, ok := state.Sys().(syscall.WaitStatus)
+ if ok && status.Signaled() {
+ return 128 + int(status.Signal())
+ }
+ return 1
+}
diff --git a/agent/process_other.go b/agent/process_other.go
new file mode 100644
index 0000000..f1f8b03
--- /dev/null
+++ b/agent/process_other.go
@@ -0,0 +1,17 @@
+//go:build !linux
+
+package agent
+
+import (
+ "os"
+ "os/exec"
+)
+
+func configureProcess(*exec.Cmd) {}
+
+func processExitCode(state *os.ProcessState) int {
+ if code := state.ExitCode(); code >= 0 {
+ return code
+ }
+ return 1
+}
diff --git a/agent/protocol.go b/agent/protocol.go
new file mode 100644
index 0000000..be59d56
--- /dev/null
+++ b/agent/protocol.go
@@ -0,0 +1,163 @@
+// Package agent implements KumaBox's host/guest command channel. The wire
+// format carries one bounded JSON message per line and one operation per
+// vsock connection.
+package agent
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+ "sync/atomic"
+)
+
+const (
+ // Port is the guest endpoint for host-initiated agent sessions.
+ Port uint32 = 1024
+
+ // MessageExec starts one command session.
+ MessageExec = "exec"
+ // MessageReseed reserves the identity refresh operation.
+ MessageReseed = "reseed"
+ // MessageStdin carries one command input chunk.
+ MessageStdin = "stdin"
+ // MessageStdinClose closes command input without ending the session.
+ MessageStdinClose = "stdin_close"
+ // MessageStarted reports the guest process identifier.
+ MessageStarted = "started"
+ // MessageStdout carries one standard-output chunk.
+ MessageStdout = "stdout"
+ // MessageStderr carries one standard-error chunk.
+ MessageStderr = "stderr"
+ // MessageExit terminates a protocol session with a command status.
+ MessageExit = "exit"
+ // MessageError terminates a failed protocol session with a diagnostic.
+ MessageError = "error"
+
+ initialFrameBuffer = 64 * 1024
+ maximumFrameSize = 8 * 1024 * 1024
+ streamChunkSize = 32 * 1024
+)
+
+var errTerminalMessageSent = errors.New("terminal agent message already sent")
+
+// Message is the protocol union carried by each NDJSON frame. Fields
+// unrelated to Type remain empty and are omitted from the wire representation.
+type Message struct {
+ // Type selects the fields and transition represented by this message.
+ Type string `json:"type"`
+ // Argv contains the executable and arguments for MessageExec.
+ Argv []string `json:"argv,omitempty"`
+ // Env contains environment overrides for MessageExec.
+ Env map[string]string `json:"env,omitempty"`
+ // Data carries stdin, stdout, stderr, or entropy bytes.
+ Data []byte `json:"data,omitempty"`
+ // PID identifies a process reported by MessageStarted.
+ PID int `json:"pid,omitempty"`
+ // ExitCode is the guest command status reported by MessageExit.
+ ExitCode int `json:"exit_code,omitempty"`
+ // Message contains the diagnostic reported by MessageError.
+ Message string `json:"message,omitempty"`
+ // RegenMachineID requests machine identity renewal during a future reseed.
+ RegenMachineID bool `json:"regen_machine_id,omitempty"`
+}
+
+// Decoder reads bounded newline-delimited JSON messages.
+type Decoder struct {
+ scanner *bufio.Scanner
+}
+
+// NewDecoder constructs a decoder whose frame limit prevents an untrusted
+// peer from growing memory without bound.
+func NewDecoder(reader io.Reader) *Decoder {
+ scanner := bufio.NewScanner(reader)
+ scanner.Buffer(make([]byte, 0, initialFrameBuffer), maximumFrameSize)
+ return &Decoder{scanner: scanner}
+}
+
+// Decode reads one complete protocol message.
+func (d *Decoder) Decode() (Message, error) {
+ if !d.scanner.Scan() {
+ if err := d.scanner.Err(); err != nil {
+ return Message{}, fmt.Errorf("read agent frame: %w", err)
+ }
+ return Message{}, io.EOF
+ }
+ var message Message
+ if err := json.Unmarshal(d.scanner.Bytes(), &message); err != nil {
+ return Message{}, fmt.Errorf("decode agent frame: %w", err)
+ }
+ return message, nil
+}
+
+// Encoder serializes concurrent stdout and stderr writers onto one stream.
+// Exit and error are terminal: no later frame may be emitted.
+type Encoder struct {
+ mu sync.Mutex
+ encoder *json.Encoder
+ terminal bool
+}
+
+// NewEncoder constructs a newline-delimited JSON encoder.
+func NewEncoder(writer io.Writer) *Encoder {
+ return &Encoder{encoder: json.NewEncoder(writer)}
+}
+
+// Encode writes one message atomically with respect to other writers.
+func (e *Encoder) Encode(message Message) error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ if e.terminal {
+ return errTerminalMessageSent
+ }
+ if err := e.encoder.Encode(message); err != nil {
+ return fmt.Errorf("write agent frame: %w", err)
+ }
+ if terminalMessage(message.Type) {
+ e.terminal = true
+ }
+ return nil
+}
+
+func (e *Encoder) sendError(format string, args ...any) error {
+ return e.Encode(Message{Type: MessageError, Message: fmt.Sprintf(format, args...)})
+}
+
+func terminalMessage(messageType string) bool {
+ return messageType == MessageExit || messageType == MessageError
+}
+
+// framedWriter converts process output writes into bounded protocol frames.
+type framedWriter struct {
+ messageType string
+ encoder *Encoder
+ cancel context.CancelFunc
+ lastError atomic.Pointer[error]
+}
+
+func (w *framedWriter) Write(data []byte) (int, error) {
+ written := 0
+ for len(data) > 0 {
+ length := min(len(data), streamChunkSize)
+ if err := w.encoder.Encode(Message{Type: w.messageType, Data: data[:length]}); err != nil {
+ errorCopy := err
+ if w.lastError.CompareAndSwap(nil, &errorCopy) {
+ w.cancel()
+ }
+ return written, err
+ }
+ written += length
+ data = data[length:]
+ }
+ return written, nil
+}
+
+func (w *framedWriter) err() error {
+ if err := w.lastError.Load(); err != nil {
+ return *err
+ }
+ return nil
+}
diff --git a/agent/server.go b/agent/server.go
new file mode 100644
index 0000000..2205210
--- /dev/null
+++ b/agent/server.go
@@ -0,0 +1,273 @@
+package agent
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "os"
+ "os/exec"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+const processWaitDelay = 2 * time.Second
+
+// Server accepts independent agent sessions. Each connection executes exactly
+// one operation and owns one child-process tree.
+type Server struct {
+ listener net.Listener
+ logger *log.Logger
+
+ mu sync.Mutex
+ connections map[net.Conn]struct{}
+ closed bool
+}
+
+// NewServer constructs a guest agent around listener. A nil logger discards
+// diagnostics so protocol output never shares the command data channel.
+func NewServer(listener net.Listener, logger *log.Logger) (*Server, error) {
+ if listener == nil {
+ return nil, errors.New("agent listener is required")
+ }
+ if logger == nil {
+ logger = log.New(io.Discard, "", 0)
+ }
+ return &Server{listener: listener, logger: logger, connections: make(map[net.Conn]struct{})}, nil
+}
+
+// Serve handles sessions until ctx is canceled or the listener fails.
+func (s *Server) Serve(ctx context.Context) error {
+ stop := context.AfterFunc(ctx, func() { _ = s.Close() })
+ defer stop()
+
+ var sessions sync.WaitGroup
+ for {
+ connection, err := s.listener.Accept()
+ if err != nil {
+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
+ sessions.Wait()
+ return nil
+ }
+ _ = s.Close()
+ sessions.Wait()
+ return fmt.Errorf("accept agent connection: %w", err)
+ }
+ if !s.track(connection) {
+ _ = connection.Close()
+ continue
+ }
+ sessions.Add(1)
+ go func() {
+ defer sessions.Done()
+ s.handle(ctx, connection)
+ }()
+ }
+}
+
+// Close stops accepting sessions and unblocks active handlers.
+func (s *Server) Close() error {
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ return nil
+ }
+ s.closed = true
+ connections := make([]net.Conn, 0, len(s.connections))
+ for connection := range s.connections {
+ connections = append(connections, connection)
+ }
+ s.mu.Unlock()
+
+ err := s.listener.Close()
+ for _, connection := range connections {
+ err = errors.Join(err, connection.Close())
+ }
+ return err
+}
+
+func (s *Server) handle(ctx context.Context, connection net.Conn) {
+ defer s.untrack(connection)
+ defer connection.Close() //nolint:errcheck
+
+ decoder := NewDecoder(connection)
+ encoder := NewEncoder(connection)
+ first, err := decoder.Decode()
+ if err != nil {
+ if !errors.Is(err, io.EOF) {
+ s.logger.Printf("decode initial frame from %s: %v", connection.RemoteAddr(), err)
+ }
+ return
+ }
+ switch first.Type {
+ case MessageExec:
+ s.runCommand(ctx, connection, decoder, encoder, first)
+ case MessageReseed:
+ _ = encoder.sendError("reseed is not implemented by this KumaBox agent")
+ default:
+ _ = encoder.sendError("expected first frame type %q, got %q", MessageExec, first.Type)
+ }
+}
+
+func (s *Server) runCommand(parent context.Context, connection net.Conn, decoder *Decoder, encoder *Encoder, request Message) {
+ if len(request.Argv) == 0 || request.Argv[0] == "" {
+ _ = encoder.sendError("exec: argv is empty")
+ return
+ }
+ for key, value := range request.Env {
+ if key == "" || strings.ContainsAny(key, "=\x00") || strings.IndexByte(value, 0) >= 0 {
+ _ = encoder.sendError("exec: invalid environment variable %q", key)
+ return
+ }
+ }
+ for _, argument := range request.Argv {
+ if strings.IndexByte(argument, 0) >= 0 {
+ _ = encoder.sendError("exec: command arguments contain a NUL byte")
+ return
+ }
+ }
+
+ ctx, cancel := context.WithCancel(parent)
+ defer cancel()
+ command := exec.CommandContext(ctx, request.Argv[0], request.Argv[1:]...) //nolint:gosec // argv comes from the owner-only host channel and is never passed through a shell
+ command.WaitDelay = processWaitDelay
+ configureProcess(command)
+ if len(request.Env) > 0 {
+ command.Env = mergeEnvironment(os.Environ(), request.Env)
+ }
+ input, err := command.StdinPipe()
+ if err != nil {
+ _ = encoder.sendError("exec: open stdin: %v", err)
+ return
+ }
+ stdout := &framedWriter{messageType: MessageStdout, encoder: encoder, cancel: cancel}
+ stderr := &framedWriter{messageType: MessageStderr, encoder: encoder, cancel: cancel}
+ command.Stdout, command.Stderr = stdout, stderr
+ if err := command.Start(); err != nil {
+ _ = input.Close()
+ _ = encoder.sendError("exec: start %s: %v", request.Argv[0], err)
+ return
+ }
+ if err := encoder.Encode(Message{Type: MessageStarted, PID: command.Process.Pid}); err != nil {
+ cancel()
+ _ = command.Wait()
+ _ = input.Close()
+ return
+ }
+
+ inputDone := make(chan error, 1)
+ go func() {
+ inputErr := receiveInput(ctx, decoder, input)
+ if inputErr != nil {
+ cancel()
+ }
+ inputDone <- inputErr
+ }()
+ waitErr := command.Wait()
+ cancel()
+ _ = connection.SetReadDeadline(time.Now())
+ inputErr := <-inputDone
+
+ if outputErr := errors.Join(stdout.err(), stderr.err()); outputErr != nil {
+ s.logger.Printf("stream command output: %v", outputErr)
+ return
+ }
+ if inputErr != nil {
+ _ = encoder.sendError("exec: receive stdin: %v", inputErr)
+ return
+ }
+ exitCode := 0
+ var exitErr *exec.ExitError
+ switch {
+ case waitErr == nil:
+ case errors.As(waitErr, &exitErr):
+ exitCode = processExitCode(exitErr.ProcessState)
+ case errors.Is(waitErr, exec.ErrWaitDelay) && command.ProcessState != nil:
+ exitCode = processExitCode(command.ProcessState)
+ default:
+ _ = encoder.sendError("exec: wait %s: %v", request.Argv[0], waitErr)
+ return
+ }
+ if err := encoder.Encode(Message{Type: MessageExit, ExitCode: exitCode}); err != nil {
+ s.logger.Printf("send command exit: %v", err)
+ }
+}
+
+// mergeEnvironment removes inherited values that the request overrides and
+// appends the replacements in stable order. The child therefore receives one
+// unambiguous value for every environment key.
+func mergeEnvironment(base []string, overrides map[string]string) []string {
+ result := make([]string, 0, len(base)+len(overrides))
+ for _, pair := range base {
+ key, _, ok := strings.Cut(pair, "=")
+ if _, replaced := overrides[key]; ok && replaced {
+ continue
+ }
+ result = append(result, pair)
+ }
+ keys := make([]string, 0, len(overrides))
+ for key := range overrides {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ result = append(result, key+"="+overrides[key])
+ }
+ return result
+}
+
+func receiveInput(ctx context.Context, decoder *Decoder, input io.WriteCloser) error {
+ defer input.Close() //nolint:errcheck
+ inputOpen := true
+ for {
+ message, err := decoder.Decode()
+ if err != nil {
+ if ctx.Err() != nil {
+ return nil
+ }
+ if errors.Is(err, io.EOF) {
+ return errors.New("host connection closed")
+ }
+ return err
+ }
+ switch message.Type {
+ case MessageStdinClose:
+ return nil
+ case MessageStdin:
+ if len(message.Data) == 0 || !inputOpen {
+ continue
+ }
+ if _, err := input.Write(message.Data); err != nil {
+ _ = input.Close()
+ inputOpen = false
+ }
+ default:
+ return fmt.Errorf("unexpected frame type %q", message.Type)
+ }
+ select {
+ case <-ctx.Done():
+ return nil
+ default:
+ }
+ }
+}
+
+func (s *Server) track(connection net.Conn) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return false
+ }
+ s.connections[connection] = struct{}{}
+ return true
+}
+
+func (s *Server) untrack(connection net.Conn) {
+ s.mu.Lock()
+ delete(s.connections, connection)
+ s.mu.Unlock()
+}
diff --git a/agent/vsock_linux.go b/agent/vsock_linux.go
new file mode 100644
index 0000000..66e21ca
--- /dev/null
+++ b/agent/vsock_linux.go
@@ -0,0 +1,39 @@
+//go:build linux
+
+package agent
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/mdlayher/vsock"
+)
+
+// ListenVsock opens the guest endpoint and accepts only the host CID. Rejecting
+// guest-local peers prevents an unprivileged guest process from asking the root
+// agent to execute another command.
+func ListenVsock(port uint32) (net.Listener, error) {
+ listener, err := vsock.Listen(port, nil)
+ if err != nil {
+ return nil, fmt.Errorf("listen on vsock port %d: %w", port, err)
+ }
+ return &hostListener{Listener: listener}, nil
+}
+
+type hostListener struct {
+ net.Listener
+}
+
+func (l *hostListener) Accept() (net.Conn, error) {
+ for {
+ connection, err := l.Listener.Accept()
+ if err != nil {
+ return nil, err
+ }
+ address, ok := connection.RemoteAddr().(*vsock.Addr)
+ if ok && address.ContextID == vsock.Host {
+ return connection, nil
+ }
+ _ = connection.Close()
+ }
+}
diff --git a/agent/vsock_other.go b/agent/vsock_other.go
new file mode 100644
index 0000000..cfe11e0
--- /dev/null
+++ b/agent/vsock_other.go
@@ -0,0 +1,13 @@
+//go:build !linux
+
+package agent
+
+import (
+ "errors"
+ "net"
+)
+
+// ListenVsock reports the Linux-only guest transport on development hosts.
+func ListenVsock(uint32) (net.Listener, error) {
+ return nil, errors.New("AF_VSOCK guest agent is supported only on Linux")
+}
diff --git a/assets/readme/architecture.svg b/assets/readme/architecture.svg
new file mode 100644
index 0000000..96d6303
--- /dev/null
+++ b/assets/readme/architecture.svg
@@ -0,0 +1,115 @@
+
+KumaBox architecture: a short-lived CLI control plane driving Cloud Hypervisor microVMs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+How KumaBox works
+Each command is a short-lived process. Only the microVMs keep running.
+
+Your agent, script or CI job
+
+kumabox run | exec | clone --json
+
+kumabox
+one process per command
+
+Resource locks
+safe concurrent commands
+
+Operation journal
+resumes interrupted work
+
+Metadata
+JSON or SQLite backend
+
+Reconcile and GC
+records match reality
+
+Exits when the work is done. Nothing idles on the host.
+Subsystems each command drives
+
+
+Images
+OCI to shared EROFS layers
++ private copy-on-write disk
+
+
+Network
+CNI netns per VM
+multiqueue TAP, tc redirect
+
+
+Snapshots
+running or stopped, verified
+clone, hibernate, restore
+
+
+Devices
+hotplug disks, virtio-fs
+VFIO PCI passthrough
+
+Linux host with KVM
+amd64 or arm64
+
+cloud-hypervisor
+VMM process for vm-1
+
+microVM guest
+hardware boundary
+
+kumabox-agent on vsock :1024
+
+your workload: shell, code, tools
+
+rootfs: shared EROFS + private COW disk
+
+dedicated Linux guest kernel
+vm-2 ... vm-N, one VMM process each
+
+vm-2
+
+vm-3
+
+vm-4
+
+vm-5
+
+vm-6
+...
+
+Shared EROFS layer store: read-only, deduplicated across VMs
+
+CNI network: per-VM netns and TAP, NAT to the outside
+
+vsock
+
+VMM API
+
+
diff --git a/assets/readme/comparison.svg b/assets/readme/comparison.svg
new file mode 100644
index 0000000..d49a01c
--- /dev/null
+++ b/assets/readme/comparison.svg
@@ -0,0 +1,85 @@
+
+What you operate to get hardware-isolated sandboxes: KumaBox vs CubeSandbox vs self-hosted E2B
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+What you run on one host to get one kernel per task
+Taller stacks buy multi-tenant APIs, scheduling and dashboards. KumaBox keeps only what a single host needs.
+KumaBox
+MIT, Go
+
+
+Linux + KVM, amd64 or arm64
+
+cloud-hypervisor + CNI plugins
+
+kumabox, one binary
+
+That's the whole stack.
+No database, no cluster manager,
+no resident daemon.
+CubeSandbox, one-click node
+Apache-2.0
+
+
+Linux + KVM, x86_64 or ARM64
+
+CubeHypervisor + CubeShim
+
+Cubelet + CubeVS eBPF network
+
+CubeMaster + lifecycle manager
+
+CubeAPI, CubeProxy, CubeEgress
+
+MySQL, Redis, MinIO
+
+Web UI + CubeOps
+Full sandbox service with E2B API.
+Terraform or Kubernetes for clusters.
+E2B Embed, one machine
+Apache-2.0, Go
+
+
+Linux + KVM + Docker Compose
+
+Firecracker VMM
+
+Orchestrator + template builder
+
+API server + client proxy
+
+PostgreSQL, Redis, ClickHouse
+
+Dashboard + log pipeline
+Same runtime as E2B Cloud.
+Terraform or Kubernetes for more nodes.
+Single-host install footprint, from each project's repository and deploy files, September 2026.
+
diff --git a/assets/readme/hero.svg b/assets/readme/hero.svg
new file mode 100644
index 0000000..9f236ca
--- /dev/null
+++ b/assets/readme/hero.svg
@@ -0,0 +1,90 @@
+
+KumaBox: a disposable computer for every agent task
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Kuma Box
+A disposable computer
+for every agent task.
+Hardware-isolated microVMs on KVM,
+driven by one daemonless CLI.
+
+
+snapshot: base
+
+
+fresh-1
+
+
+
+fresh-2
+
+
+
+fresh-3
+
+
+
+fresh-4
+
+$ kumabox clone base --name fresh-N
+
+Own kernel per sandbox
+
+Zero resident daemons
+
+OCI images
+
+Running snapshots
+
diff --git a/assets/readme/lifecycle.svg b/assets/readme/lifecycle.svg
new file mode 100644
index 0000000..b3130a9
--- /dev/null
+++ b/assets/readme/lifecycle.svg
@@ -0,0 +1,86 @@
+
+Sandbox lifecycle: build once, warm once, fork many
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Warm once, fork many
+Pay the setup cost one time, then hand every agent attempt its own copy of a ready machine.
+
+1
+OCI image
+any digest-pinned ref
+
+image build
+
+2
+EROFS layers
+shared, read-only
+
+run
+
+3
+Running microVM
+agent ready on vsock
+
+exec
+
+4
+Warmed sandbox
+deps installed, caches hot
+
+snapshot
+
+5
+Running snapshot
+memory + disks captured
+
+
+
+
+
+
+
+
+
+clone x N
+new IP, new identity
+The same snapshot also lets you
+
+restore in place to roll back
+
+export and import to another host
+
+hibernate: free the VMM, keep the state
+
diff --git a/assets/readme/product.svg b/assets/readme/product.svg
new file mode 100644
index 0000000..80b14b8
--- /dev/null
+++ b/assets/readme/product.svg
@@ -0,0 +1,78 @@
+
+Product shape: callers, interfaces, and what one KumaBox sandbox gives you
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+What KumaBox is
+A sandbox runtime you install on one Linux box. Anything that can run a command can drive it.
+Who drives it
+
+Coding agents
+
+Agent frameworks
+
+RL and eval harnesses
+
+CI and batch jobs
+
+You, at a terminal
+How it is driven
+
+kumabox CLI
+available now
+Human output, or --json for machines
+Streams stdout, stderr and exit codes
+Dry-run launch plans via kumabox debug
+Planned
+
+Go SDK
+
+HTTP API, E2B-compatible
+
+MCP server for tool use
+What each sandbox gets
+
+
+one microVM
+
+Its own guest kernel behind KVM
+
+OCI rootfs + private writable disk
+
+Own network namespace and IP
+
+exec with env, workdir, stdin, TTY
+
+Snapshot, clone, hibernate, restore
+
+Opt-in data disks, virtio-fs, VFIO
+
+
+
diff --git a/assets/readme/src/build.py b/assets/readme/src/build.py
new file mode 100644
index 0000000..a03ea22
--- /dev/null
+++ b/assets/readme/src/build.py
@@ -0,0 +1,6 @@
+"""Regenerate every README graphic: python3 assets/readme/src/build.py"""
+import os, runpy, sys
+here = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, here)
+for name in ["gen_hero", "gen_product", "gen_arch", "gen_life", "gen_compare"]:
+ runpy.run_path(os.path.join(here, name + ".py"))
diff --git a/assets/readme/src/gen_arch.py b/assets/readme/src/gen_arch.py
new file mode 100644
index 0000000..1c49a16
--- /dev/null
+++ b/assets/readme/src/gen_arch.py
@@ -0,0 +1,90 @@
+from gen_common import *
+W, H = 1200, 780
+s = svg_open(W, H, "KumaBox architecture: a short-lived CLI control plane driving Cloud Hypervisor microVMs")
+s += t(48, 58, "How KumaBox works", 26, FROST, 700)
+s += t(48, 88, "Each command is a short-lived process. Only the microVMs keep running.", 16, MUTED)
+
+# ---- left column: control plane
+L, LW = 48, 472
+s += box(L, 116, LW, 56, fill=PANEL2)
+s += t(L+LW/2, 150, "Your agent, script or CI job", 17, FROST, 600, "middle")
+s += line(L+LW/2, 172, L+LW/2, 214)
+s += t(L+LW/2+14, 199, "kumabox run | exec | clone --json", 13, MUTED, cls="mono")
+
+s += box(L, 220, LW, 236, fill=PANEL, stroke=SLATE, sw=1.6)
+s += t(L+24, 256, "kumabox", 20, FROST, 700, cls="mono")
+s += t(L+130, 256, "one process per command", 14, MUTED)
+chips = [("Resource locks", "safe concurrent commands"),
+ ("Operation journal", "resumes interrupted work"),
+ ("Metadata", "JSON or SQLite backend"),
+ ("Reconcile and GC", "records match reality")]
+cw = (LW - 48 - 14) / 2
+for i, (a, b) in enumerate(chips):
+ x = L + 24 + (i % 2) * (cw + 14); y = 276 + (i // 2) * 66
+ s += box(x, y, cw, 54, fill=PANEL2, rx=10)
+ s += t(x+14, y+23, a, 15, FROST, 600)
+ s += t(x+14, y+43, b, 12.5, MUTED)
+s += f' \n'
+s += t(L+44, 433, "Exits when the work is done. Nothing idles on the host.", 14, FROST)
+
+s += t(L, 492, "Subsystems each command drives", 14, MUTED, 600)
+cards = [("Images", "OCI to shared EROFS layers", "+ private copy-on-write disk"),
+ ("Network", "CNI netns per VM", "multiqueue TAP, tc redirect"),
+ ("Snapshots", "running or stopped, verified", "clone, hibernate, restore"),
+ ("Devices", "hotplug disks, virtio-fs", "VFIO PCI passthrough")]
+cw2 = (LW - 16) / 2
+for i, (a, b, c) in enumerate(cards):
+ x = L + (i % 2) * (cw2 + 16); y = 506 + (i // 2) * 118
+ s += box(x, y, cw2, 104, fill=PANEL, rx=12)
+ s += f' \n'
+ s += t(x+20, y+37, a, 17, FROST, 700)
+ s += t(x+20, y+64, b, 13, MUTED)
+ s += t(x+20, y+84, c, 13, MUTED)
+
+# ---- right column: host
+R, RW = 600, 552
+s += box(R, 116, RW, 616, fill="#1A2638", stroke=LINE, rx=16, dash="5 6")
+s += t(R+24, 148, "Linux host with KVM", 16, FROST, 700)
+s += t(R+RW-24, 148, "amd64 or arm64", 13, MUTED, anchor="end")
+
+vx, vw = R+24, RW-48
+s += box(vx, 168, vw, 304, fill=PANEL, stroke=SLATE_L, sw=1.6)
+s += t(vx+20, 196, "cloud-hypervisor", 15, FROST, 700, cls="mono")
+s += t(vx+180, 196, "VMM process for vm-1", 13, MUTED)
+gx, gw = vx+20, vw-40
+s += box(gx, 212, gw, 244, fill=PANEL2, rx=10)
+s += t(gx+18, 238, "microVM guest", 14, FROST, 700)
+s += t(gx+gw-18, 238, "hardware boundary", 12.5, MUTED, anchor="end")
+layers = [("kumabox-agent on vsock :1024", HONEY, "#2B2A22"),
+ ("your workload: shell, code, tools", SLATE_L, "#22344A"),
+ ("rootfs: shared EROFS + private COW disk", LINE, "#1D2B3D"),
+ ("dedicated Linux guest kernel", LINE, "#18253A")]
+lx, lw = gx+18, gw-36
+for i, (lab, st, fl) in enumerate(layers):
+ y = 254 + i * 48
+ s += box(lx, y, lw, 40, fill=fl, stroke=st, rx=8, sw=1.4)
+ s += t(lx+16, y+26, lab, 13.5, HONEY if i == 0 else FROST, 600 if i == 0 else 400, cls="mono" if i == 0 else "")
+
+# more VMs
+s += t(vx, 506, "vm-2 ... vm-N, one VMM process each", 14, MUTED)
+for i in range(6):
+ cxp = vx + 34 + i * 82
+ if i == 5:
+ s += t(cxp, 560, "...", 22, MUTED, 700, "middle"); continue
+ s += cube(cxp, 540, 22, top=SLATE_L)
+ s += t(cxp, 598, f"vm-{i+2}", 12, MUTED, anchor="middle", cls="mono")
+
+s += box(vx, 618, vw, 42, fill="#1D2B3D", rx=10)
+s += t(vx+18, 644, "Shared EROFS layer store: read-only, deduplicated across VMs", 13.5, FROST)
+s += box(vx, 670, vw, 42, fill="#1D2B3D", rx=10)
+s += t(vx+18, 696, "CNI network: per-VM netns and TAP, NAT to the outside", 13.5, FROST)
+
+# ---- connections
+s += path(f"M{L+LW} 300 C {L+LW+50} 300, {lx-60} 274, {lx-4} 274", color=HONEY, sw=2.2, marker="arrH", cls="flow")
+s += t(L+LW+40, 262, "vsock", 12.5, HONEY, 600, "middle", "mono")
+s += path(f"M{L+LW} 392 L {vx-4} 392", color=SLATE_L, sw=2)
+s += t(L+LW+40, 382, "VMM API", 12.5, SLATE_L, 600, "middle", "mono")
+s += t(L+LW+40, 758, "", 1)
+s += '\n'
+open(os.path.join(OUT, "architecture.svg"), "w").write(s)
+print("arch ok")
diff --git a/assets/readme/src/gen_common.py b/assets/readme/src/gen_common.py
new file mode 100644
index 0000000..a927cca
--- /dev/null
+++ b/assets/readme/src/gen_common.py
@@ -0,0 +1,122 @@
+import os
+OUT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # assets/readme/
+# Shared design tokens + helpers for KumaBox README graphics.
+BG = "#172233" # hull navy
+PANEL = "#1F2D40" # logo dark face
+PANEL2 = "#243449"
+SLATE = "#4E6E8E" # logo light face
+SLATE_L = "#6F8FAF"
+LINE = "#34485F"
+FROST = "#E8EEF5"
+MUTED = "#98AABF"
+HONEY = "#F5B83D" # kuma loves honey: the single accent
+HONEY_D = "#C98E1F"
+
+SANS = "'Inter','Segoe UI','SF Pro Text',-apple-system,'Helvetica Neue',Arial,sans-serif"
+MONO = "'JetBrains Mono','SFMono-Regular',Menlo,Consolas,'DejaVu Sans Mono',monospace"
+
+def esc(s):
+ return s.replace("&","&").replace("<","<").replace(">",">")
+
+def svg_open(w, h, title, extra_style=""):
+ return f'''
+{esc(title)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+'''
+
+def t(x, y, s, size=16, fill=FROST, weight=400, anchor="start", cls="", extra=""):
+ c = f' class="{cls}"' if cls else ""
+ return f'{esc(s)} \n'
+
+def cube(cx, cy, s, top=SLATE_L, left=PANEL, right=SLATE, stroke="#0F1826", sw=2, extra=""):
+ """Isometric cube; (cx,cy) is the centre of the top face, s = half-diagonal."""
+ k = 0.866 * s
+ h = s * 1.15
+ topf = f"{cx},{cy-s/2} {cx+k},{cy} {cx},{cy+s/2} {cx-k},{cy}"
+ leftf = f"{cx-k},{cy} {cx},{cy+s/2} {cx},{cy+s/2+h} {cx-k},{cy+h}"
+ rightf= f"{cx+k},{cy} {cx},{cy+s/2} {cx},{cy+s/2+h} {cx+k},{cy+h}"
+ return (f' '
+ f' '
+ f' \n')
+
+def box(x, y, w, h, fill=PANEL, stroke=LINE, rx=12, sw=1.2, dash=None, extra=""):
+ d = f' stroke-dasharray="{dash}"' if dash else ""
+ return f' \n'
+
+def pill(x, y, label, fill=PANEL2, stroke=LINE, color=FROST, size=14, pad=14, h=30, mono=False, cw=None):
+ # rough width estimate (DejaVu is wide; be generous)
+ cw = cw or (size * (0.62 if mono else 0.58))
+ w = int(len(label) * cw + pad * 2)
+ cls = "mono" if mono else ""
+ return (box(x, y, w, h, fill=fill, stroke=stroke, rx=h/2) +
+ t(x + w/2, y + h/2 + size*0.36, label, size=size, fill=color, anchor="middle", cls=cls)), w
+
+def line(x1, y1, x2, y2, color=SLATE_L, sw=2, marker="arr", cls="", dash=None):
+ m = f' marker-end="url(#{marker})"' if marker else ""
+ c = f' class="{cls}"' if cls else ""
+ d = f' stroke-dasharray="{dash}"' if dash else ""
+ return f' \n'
+
+def path(d, color=SLATE_L, sw=2, marker="arr", cls="", fill="none", dash=None):
+ m = f' marker-end="url(#{marker})"' if marker else ""
+ c = f' class="{cls}"' if cls else ""
+ ds = f' stroke-dasharray="{dash}"' if dash else ""
+ return f' \n'
+
+def bear_box(cx, cy, s=1.0):
+ """The KumaBox mark: a bear peeking out of an isometric box. (cx,cy)=box top centre."""
+ g = [f'']
+ k = 86.6; hh = 50
+ # back rim of box (behind bear)
+ g.append(f' ')
+ # bear head
+ g.append(' ')
+ g.append(' ')
+ g.append(' ')
+ g.append(' ')
+ g.append(' ')
+ g.append(' ')
+ g.append(' ')
+ # front faces
+ g.append(f' ')
+ g.append(f' ')
+ # paws
+ g.append(' ')
+ g.append(' ')
+ # prompt glyph on left face, honey
+ g.append(f' ')
+ g.append(f' ')
+ # small cube glyph on right face
+ g.append(''
+ ' ')
+ g.append(' ')
+ return "\n".join(g) + "\n"
diff --git a/assets/readme/src/gen_compare.py b/assets/readme/src/gen_compare.py
new file mode 100644
index 0000000..1dcebcb
--- /dev/null
+++ b/assets/readme/src/gen_compare.py
@@ -0,0 +1,45 @@
+from gen_common import *
+W, H = 1200, 664
+s = svg_open(W, H, "What you operate to get hardware-isolated sandboxes: KumaBox vs CubeSandbox vs self-hosted E2B")
+s += t(48, 58, "What you run on one host to get one kernel per task", 26, FROST, 700)
+s += t(48, 88, "Taller stacks buy multi-tenant APIs, scheduling and dashboards. KumaBox keeps only what a single host needs.", 16, MUTED)
+
+cols = [
+ ("KumaBox", "MIT, Go", HONEY,
+ ["Linux + KVM, amd64 or arm64", "cloud-hypervisor + CNI plugins", "kumabox, one binary"],
+ ["No database, no cluster manager,", "no resident daemon."]),
+ ("CubeSandbox, one-click node", "Apache-2.0", SLATE_L,
+ ["Linux + KVM, x86_64 or ARM64", "CubeHypervisor + CubeShim", "Cubelet + CubeVS eBPF network", "CubeMaster + lifecycle manager",
+ "CubeAPI, CubeProxy, CubeEgress", "MySQL, Redis, MinIO", "Web UI + CubeOps"],
+ ["Full sandbox service with E2B API.", "Terraform or Kubernetes for clusters."]),
+ ("E2B Embed, one machine", "Apache-2.0, Go", SLATE_L,
+ ["Linux + KVM + Docker Compose", "Firecracker VMM", "Orchestrator + template builder", "API server + client proxy",
+ "PostgreSQL, Redis, ClickHouse", "Dashboard + log pipeline"],
+ ["Same runtime as E2B Cloud.", "Terraform or Kubernetes for more nodes."]),
+]
+base = 548; bh = 44; gap = 8; cw = 344
+for ci, (name, lic, accent, blocks, foot) in enumerate(cols):
+ x = 48 + ci * (cw + 36)
+ s += t(x, 146, name, 19, HONEY if ci == 0 else FROST, 700)
+ s += t(x + cw, 146, lic, 13, MUTED, anchor="end")
+ s += f' \n'
+ for bi, label in enumerate(blocks):
+ y = base - (bi + 1) * (bh + gap) + gap
+ top = bi == len(blocks) - 1
+ if ci == 0:
+ fill = "#2B2A22" if top else PANEL2; st = HONEY if top else LINE
+ else:
+ fill = PANEL if bi % 2 == 0 else PANEL2; st = LINE
+ s += box(x, y, cw, bh, fill=fill, stroke=st, rx=9, sw=1.4 if top and ci == 0 else 1.1)
+ s += t(x + 16, y + 28, label, 14, HONEY if (top and ci == 0) else FROST, 600 if (top and ci == 0) else 400,
+ cls="mono" if (top and ci == 0) else "")
+ if ci == 0:
+ ty = base - len(blocks) * (bh + gap)
+ s += path(f"M{x+cw/2} {ty-10} L {x+cw/2} {ty-120}", color=HONEY, sw=1.4, marker=None, dash="3 6")
+ s += t(x + cw/2, ty - 134, "That's the whole stack.", 17, HONEY, 700, "middle")
+ for li, fl in enumerate(foot):
+ s += t(x, base + 30 + li * 20, fl, 13.5, FROST if ci == 0 else MUTED)
+s += t(W - 48, H - 18, "Single-host install footprint, from each project's repository and deploy files, September 2026.", 11.5, MUTED, anchor="end")
+s += ' \n'
+open(os.path.join(OUT, "comparison.svg"), "w").write(s)
+print("cmp ok")
diff --git a/assets/readme/src/gen_hero.py b/assets/readme/src/gen_hero.py
new file mode 100644
index 0000000..f0ed1d5
--- /dev/null
+++ b/assets/readme/src/gen_hero.py
@@ -0,0 +1,42 @@
+from gen_common import *
+W, H = 1200, 460
+style = """
+ .clone { opacity: 0; animation: pop 6s ease-out infinite; }
+ .c1 { animation-delay: 0.3s; } .c2 { animation-delay: 0.7s; } .c3 { animation-delay: 1.1s; } .c4 { animation-delay: 1.5s; }
+ @keyframes pop { 0% {opacity:0; transform: translateX(-18px);} 12% {opacity:1; transform: translateX(0);} 86% {opacity:1;} 100% {opacity:0;} }
+ .pulse { animation: pulse 3s ease-in-out infinite; transform-origin: 820px 205px; }
+ @keyframes pulse { 0%,100% { opacity: .55; } 50% { opacity: 1; } }
+ @media (prefers-reduced-motion: reduce) { .clone { opacity: 1; } }
+"""
+s = svg_open(W, H, "KumaBox: a disposable computer for every agent task", style)
+s += bear_box(165, 188, 1.05)
+# text column
+x0 = 330
+s += f'Kuma Box \n'
+s += t(x0, 178, "A disposable computer", 30, FROST, 600)
+s += t(x0, 216, "for every agent task.", 30, FROST, 600)
+s += t(x0, 262, "Hardware-isolated microVMs on KVM,", 18, MUTED)
+s += t(x0, 288, "driven by one daemonless CLI.", 18, MUTED)
+
+# fleet animation
+sx, sy = 820, 190
+s += ' \n'
+s += cube(sx, sy, 44, top=HONEY, left=PANEL, right=SLATE)
+s += t(sx, 300, "snapshot: base", 14, HONEY, 600, "middle", "mono")
+targets = [(1060, 90), (1060, 168), (1060, 246), (1060, 324)]
+for i, (tx, ty) in enumerate(targets, 1):
+ d = f"M{sx+44} {sy+20} C {sx+140} {sy+20}, {tx-140} {ty+14}, {tx-34} {ty+14}"
+ s += path(d, color=SLATE_L, sw=1.6, marker=None, cls="flow")
+ s += f'' + cube(tx, ty, 24, top=SLATE_L) + t(tx+34, ty+18, f"fresh-{i}", 14, FROST, 500, cls="mono") + ' \n'
+s += t(940, 410, "$ kumabox clone base --name fresh-N", 14, MUTED, 400, "middle", "mono")
+
+# chips
+cx = 60; cy = 384
+for label in ["Own kernel per sandbox", "Zero resident daemons", "OCI images", "Running snapshots", "amd64 + arm64", "MIT"]:
+ p, w = pill(cx, cy, label, size=13, h=28, pad=12, cw=7.2)
+ if cx + w > 790:
+ break
+ s += p; cx += w + 10
+s += '\n'
+open(os.path.join(OUT, "hero.svg"), "w").write(s)
+print("hero ok")
diff --git a/assets/readme/src/gen_life.py b/assets/readme/src/gen_life.py
new file mode 100644
index 0000000..61b46e8
--- /dev/null
+++ b/assets/readme/src/gen_life.py
@@ -0,0 +1,50 @@
+from gen_common import *
+W, H = 1200, 430
+style = """
+ .fan { opacity: 0; animation: fan 5s ease-out infinite; }
+ .f1 { animation-delay: .2s; } .f2 { animation-delay: .5s; } .f3 { animation-delay: .8s; }
+ @keyframes fan { 0% {opacity:0;} 14% {opacity:1;} 88% {opacity:1;} 100% {opacity:0;} }
+ @media (prefers-reduced-motion: reduce) { .fan { opacity: 1; } }
+"""
+s = svg_open(W, H, "Sandbox lifecycle: build once, warm once, fork many", style)
+s += t(48, 58, "Warm once, fork many", 26, FROST, 700)
+s += t(48, 88, "Pay the setup cost one time, then hand every agent attempt its own copy of a ready machine.", 16, MUTED)
+
+steps = [(120, "OCI image", "any digest-pinned ref", SLATE_L, "image build"),
+ (330, "EROFS layers", "shared, read-only", SLATE_L, "run"),
+ (540, "Running microVM", "agent ready on vsock", SLATE_L, "exec"),
+ (750, "Warmed sandbox", "deps installed, caches hot", SLATE_L, "snapshot"),
+ (960, "Running snapshot", "memory + disks captured", HONEY, "clone")]
+cy = 190
+for i, (x, a, b, top, verb) in enumerate(steps):
+ s += cube(x, cy, 34, top=top)
+ s += f' ' + t(x-52, cy-29.5, str(i+1), 13, MUTED, 700, "middle")
+ s += t(x, cy+92, a, 16, HONEY if top == HONEY else FROST, 700, "middle")
+ s += t(x, cy+114, b, 13, MUTED, anchor="middle")
+ if i < len(steps) - 1:
+ nx = steps[i+1][0]
+ s += line(x+40, cy+20, nx-42, cy+20)
+ s += t((x+nx)/2, cy+8, verb, 13, SLATE_L, 600, "middle", "mono")
+
+# fan out
+fx = 1110
+
+for i, dy in enumerate([-70, 0, 70], 1):
+ s += f''
+ s += path(f"M1000 {cy+20} C 1040 {cy+20}, 1050 {cy+20+dy}, {fx-30} {cy+20+dy}", color=HONEY, sw=1.6, marker=None, dash="4 6")
+ s += cube(fx, cy+6+dy, 18, top=SLATE_L)
+ s += ' \n'
+s += t(fx, cy+132, "clone x N", 16, FROST, 700, "middle")
+s += t(fx, cy+154, "new IP, new identity", 13, MUTED, anchor="middle")
+
+# side branches from snapshot
+by = 368
+opts = ["restore in place to roll back", "export and import to another host", "hibernate: free the VMM, keep the state"]
+x = 48
+s += t(48, by-16, "The same snapshot also lets you", 14, MUTED, 600)
+for o in opts:
+ p, w = pill(x, by, o, size=13.5, h=32, pad=16, cw=7.3)
+ s += p; x += w + 12
+s += '\n'
+open(os.path.join(OUT, "lifecycle.svg"), "w").write(s)
+print("life ok")
diff --git a/assets/readme/src/gen_product.py b/assets/readme/src/gen_product.py
new file mode 100644
index 0000000..3f3d226
--- /dev/null
+++ b/assets/readme/src/gen_product.py
@@ -0,0 +1,48 @@
+from gen_common import *
+W, H = 1200, 520
+s = svg_open(W, H, "Product shape: callers, interfaces, and what one KumaBox sandbox gives you")
+s += t(48, 58, "What KumaBox is", 26, FROST, 700)
+s += t(48, 88, "A sandbox runtime you install on one Linux box. Anything that can run a command can drive it.", 16, MUTED)
+
+# left: callers
+s += t(48, 140, "Who drives it", 14, MUTED, 600)
+callers = ["Coding agents", "Agent frameworks", "RL and eval harnesses", "CI and batch jobs", "You, at a terminal"]
+for i, c in enumerate(callers):
+ y = 156 + i * 58
+ s += box(48, y, 268, 44, fill=PANEL2, rx=22)
+ s += t(182, y+28, c, 15, FROST, 500, "middle")
+
+# middle: interfaces
+MX, MW = 404, 336
+s += t(MX, 140, "How it is driven", 14, MUTED, 600)
+s += box(MX, 156, MW, 132, fill=PANEL, stroke=HONEY, sw=1.8)
+s += t(MX+22, 192, "kumabox CLI", 20, FROST, 700, cls="mono")
+s += t(MX+MW-22, 190, "available now", 12.5, HONEY, 600, "end")
+s += t(MX+22, 222, "Human output, or --json for machines", 13.5, MUTED)
+s += t(MX+22, 244, "Streams stdout, stderr and exit codes", 13.5, MUTED)
+s += t(MX+22, 266, "Dry-run launch plans via kumabox debug", 13.5, MUTED)
+planned = ["Go SDK", "HTTP API, E2B-compatible", "MCP server for tool use"]
+s += t(MX, 318, "Planned", 13, MUTED, 600)
+for i, p in enumerate(planned):
+ y = 330 + i * 52
+ s += box(MX, y, MW, 42, fill="none", stroke=SLATE, rx=10, dash="5 5")
+ s += t(MX+22, y+27, p, 14.5, MUTED, 500)
+
+# right: sandbox
+RX, RW = 820, 332
+s += t(RX, 140, "What each sandbox gets", 14, MUTED, 600)
+s += box(RX, 156, RW, 322, fill=PANEL, stroke=SLATE_L, sw=1.6)
+s += cube(RX+46, 190, 22, top=SLATE_L)
+s += t(RX+84, 204, "one microVM", 18, FROST, 700)
+feats = ["Its own guest kernel behind KVM", "OCI rootfs + private writable disk", "Own network namespace and IP",
+ "exec with env, workdir, stdin, TTY", "Snapshot, clone, hibernate, restore", "Opt-in data disks, virtio-fs, VFIO"]
+for i, f in enumerate(feats):
+ y = 262 + i * 36
+ s += f' \n'
+ s += t(RX+50, y, f, 14, FROST)
+
+s += line(324, 222, MX-10, 222)
+s += line(MX+MW+8, 222, RX-10, 222)
+s += '\n'
+open(os.path.join(OUT, "product.svg"), "w").write(s)
+print("product ok")
diff --git a/cgroup/cgroup.go b/cgroup/cgroup.go
new file mode 100644
index 0000000..3698968
--- /dev/null
+++ b/cgroup/cgroup.go
@@ -0,0 +1,44 @@
+// Package cgroup owns the minimal cgroup v2 scope used to contain each VMM.
+// CPU admission and placement policies remain outside this package until the
+// capacity phase; start currently applies weight and a vCPU-sized hard quota.
+package cgroup
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+const (
+ // Root is the Linux unified cgroup hierarchy.
+ Root = "/sys/fs/cgroup"
+ // DefaultParent contains KumaBox VMM scopes.
+ DefaultParent = "/sys/fs/cgroup/kumabox.slice"
+)
+
+// Manager prepares and reclaims per-sandbox scopes under one cgroup v2 parent.
+type Manager struct {
+ // parent is an absolute path below the unified hierarchy.
+ parent string
+}
+
+// New validates a cgroup parent without touching the host hierarchy.
+func New(parent string) (*Manager, error) {
+ if parent == "" {
+ parent = DefaultParent
+ }
+ clean := filepath.Clean(parent)
+ relative, err := filepath.Rel(Root, clean)
+ if err != nil || !filepath.IsAbs(clean) || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
+ return nil, fmt.Errorf("cgroup parent %q must be below %s", parent, Root)
+ }
+ return &Manager{parent: clean}, nil
+}
+
+// Parent returns the configured hierarchy path for diagnostics.
+func (m *Manager) Parent() string {
+ if m == nil {
+ return ""
+ }
+ return m.parent
+}
diff --git a/cgroup/cgroup_test.go b/cgroup/cgroup_test.go
new file mode 100644
index 0000000..0350fd4
--- /dev/null
+++ b/cgroup/cgroup_test.go
@@ -0,0 +1,40 @@
+package cgroup
+
+import "testing"
+
+func TestNewRequiresParentBelowUnifiedRoot(t *testing.T) {
+ tests := []struct {
+ name string
+ parent string
+ want string
+ valid bool
+ }{
+ {name: "default", want: DefaultParent, valid: true},
+ {name: "nested", parent: Root + "/tenant/kumabox.slice", want: Root + "/tenant/kumabox.slice", valid: true},
+ {name: "root itself", parent: Root},
+ {name: "outside root", parent: "/tmp/kumabox.slice"},
+ {name: "relative", parent: "kumabox.slice"},
+ {name: "escaped", parent: Root + "/../outside"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ manager, err := New(test.parent)
+ if !test.valid {
+ if err == nil {
+ t.Fatalf("New(%q) accepted invalid parent", test.parent)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if manager.Parent() != test.want {
+ t.Fatalf("Parent() = %q, want %q", manager.Parent(), test.want)
+ }
+ })
+ }
+ var manager *Manager
+ if manager.Parent() != "" {
+ t.Fatalf("nil manager parent = %q", manager.Parent())
+ }
+}
diff --git a/cgroup/manager_linux.go b/cgroup/manager_linux.go
new file mode 100644
index 0000000..db86f49
--- /dev/null
+++ b/cgroup/manager_linux.go
@@ -0,0 +1,184 @@
+//go:build linux
+
+package cgroup
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+// cpuPeriodMicros is the standard 100 ms CFS bandwidth period.
+const cpuPeriodMicros int64 = 100_000
+
+// scopeDir derives a leaf only from a validated sandbox ID.
+func (m *Manager) scopeDir(id types.SandboxID) (string, error) {
+ if m == nil || m.parent == "" {
+ return "", errors.New("cgroup manager is not configured")
+ }
+ if _, err := types.ParseSandboxID(id.String()); err != nil {
+ return "", err
+ }
+ return filepath.Join(m.parent, "sandbox-"+id.String()+".scope"), nil
+}
+
+// writeControl writes one kernel cgroup control file with operation context.
+func writeControl(directory, name, value string) error {
+ path := filepath.Join(directory, name)
+ if err := os.WriteFile(path, []byte(value), 0); err != nil {
+ return fmt.Errorf("write cgroup control %s: %w", path, err)
+ }
+ return nil
+}
+
+// Prepare creates or converges a CPU-controlled leaf and opens it for
+// CLONE_INTO_CGROUP. Reusing an empty leaf makes interrupted starts retryable.
+func (m *Manager) Prepare(_ context.Context, id types.SandboxID, cpus uint32) (*os.File, error) {
+ if m == nil || m.parent == "" {
+ return nil, errors.New("cgroup manager is not configured")
+ }
+ if cpus == 0 {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("cgroup vCPU count must be positive"))
+ }
+ if err := enableCPUHierarchy(m.parent); err != nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, err)
+ }
+ directory, err := m.scopeDir(id)
+ if err != nil {
+ return nil, err
+ }
+ if err := os.Mkdir(directory, 0o750); err != nil && !errors.Is(err, fs.ErrExist) {
+ return nil, fmt.Errorf("create cgroup scope: %w", err)
+ }
+ if err := writeCPULimits(directory, cpus); err != nil {
+ return nil, err
+ }
+ scope, err := os.Open(directory) //nolint:gosec // directory derives from fixed parent and validated UUID
+ if err != nil {
+ return nil, fmt.Errorf("open cgroup scope: %w", err)
+ }
+ return scope, nil
+}
+
+// writeCPULimits converges retryable scope controls before process placement.
+func writeCPULimits(directory string, cpus uint32) error {
+ weight := min(int(cpus), 10_000)
+ if err := writeControl(directory, "cpu.weight", strconv.Itoa(weight)); err != nil {
+ return err
+ }
+ quota := int64(cpus) * cpuPeriodMicros
+ if err := writeControl(directory, "cpu.max", fmt.Sprintf("%d %d", quota, cpuPeriodMicros)); err != nil {
+ return err
+ }
+ return nil
+}
+
+// PIDs returns every positive process currently owned by a sandbox scope.
+func (m *Manager) PIDs(id types.SandboxID) ([]int, error) {
+ directory, err := m.scopeDir(id)
+ if err != nil {
+ return nil, err
+ }
+ file, err := os.Open(filepath.Join(directory, "cgroup.procs")) //nolint:gosec // fixed file under validated scope
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("open cgroup.procs: %w", err)
+ }
+ defer file.Close() //nolint:errcheck // scan error remains primary and read-only close cannot change ownership
+ var result []int
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ pid, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
+ if err != nil || pid <= 0 {
+ return nil, fmt.Errorf("parse cgroup PID %q", scanner.Text())
+ }
+ result = append(result, pid)
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("scan cgroup.procs: %w", err)
+ }
+ slices.Sort(result)
+ return slices.Compact(result), nil
+}
+
+// Remove deletes an empty scope. Callers must prove the VMM absent first; this
+// method never sends cgroup.kill because an unverified process must survive.
+func (m *Manager) Remove(ctx context.Context, id types.SandboxID) error {
+ directory, err := m.scopeDir(id)
+ if err != nil {
+ return err
+ }
+ deadline := time.NewTimer(time.Second)
+ defer deadline.Stop()
+ ticker := time.NewTicker(10 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ err := os.Remove(directory)
+ switch {
+ case err == nil || errors.Is(err, fs.ErrNotExist):
+ return nil
+ case !errors.Is(err, syscall.EBUSY) && !errors.Is(err, syscall.ENOTEMPTY):
+ return fmt.Errorf("remove cgroup scope: %w", err)
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-deadline.C:
+ return fmt.Errorf("remove busy cgroup scope %s: %w", directory, syscall.EBUSY)
+ case <-ticker.C:
+ }
+ }
+}
+
+// enableCPUHierarchy enables delegation at root and at the KumaBox parent.
+func enableCPUHierarchy(parent string) error {
+ controllers, err := os.ReadFile(filepath.Join(Root, "cgroup.controllers"))
+ if err != nil {
+ return fmt.Errorf("cgroup v2 is unavailable: %w", err)
+ }
+ if !slices.Contains(strings.Fields(string(controllers)), "cpu") {
+ return errors.New("cgroup v2 CPU controller is unavailable")
+ }
+ relative, err := filepath.Rel(Root, parent)
+ if err != nil {
+ return err
+ }
+ current := Root
+ for element := range strings.SplitSeq(relative, string(filepath.Separator)) {
+ if err := enableCPU(current); err != nil {
+ return err
+ }
+ current = filepath.Join(current, element)
+ if err := os.Mkdir(current, 0o750); err != nil && !errors.Is(err, fs.ErrExist) {
+ return fmt.Errorf("create cgroup parent %s: %w", current, err)
+ }
+ }
+ return enableCPU(current)
+}
+
+// enableCPU avoids hierarchy-wide writes once the controller is active.
+func enableCPU(directory string) error {
+ path := filepath.Join(directory, "cgroup.subtree_control")
+ raw, err := os.ReadFile(path) //nolint:gosec // fixed control name under a validated cgroup hierarchy
+ if err != nil {
+ return fmt.Errorf("read %s: %w", path, err)
+ }
+ if slices.Contains(strings.Fields(string(raw)), "cpu") {
+ return nil
+ }
+ return writeControl(directory, "cgroup.subtree_control", "+cpu")
+}
diff --git a/cgroup/manager_linux_test.go b/cgroup/manager_linux_test.go
new file mode 100644
index 0000000..00f92ec
--- /dev/null
+++ b/cgroup/manager_linux_test.go
@@ -0,0 +1,90 @@
+//go:build linux
+
+package cgroup
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+const testID = types.SandboxID("123e4567-e89b-42d3-a456-426614174000")
+
+func TestWriteCPULimitsConvergesExistingScope(t *testing.T) {
+ directory := t.TempDir()
+ for _, cpus := range []uint32{2, 20_000} {
+ if err := writeCPULimits(directory, cpus); err != nil {
+ t.Fatal(err)
+ }
+ weight, err := os.ReadFile(filepath.Join(directory, "cpu.weight"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ maximum, err := os.ReadFile(filepath.Join(directory, "cpu.max"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantWeight, wantMaximum := "2", "200000 100000"
+ if cpus == 20_000 {
+ wantWeight, wantMaximum = "10000", "2000000000 100000"
+ }
+ if string(weight) != wantWeight || string(maximum) != wantMaximum {
+ t.Fatalf("CPUs %d: weight=%q max=%q", cpus, weight, maximum)
+ }
+ }
+}
+
+func TestPIDsSortsCompactsAndRemoveReclaimsEmptyScope(t *testing.T) {
+ manager := &Manager{parent: t.TempDir()}
+ directory, err := manager.scopeDir(testID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(directory, 0o750); err != nil {
+ t.Fatal(err)
+ }
+ processFile := filepath.Join(directory, "cgroup.procs")
+ if err := os.WriteFile(processFile, []byte("42\n7\n42\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ pids, err := manager.PIDs(testID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(pids, []int{7, 42}) {
+ t.Fatalf("PIDs() = %v", pids)
+ }
+ if err := os.Remove(processFile); err != nil {
+ t.Fatal(err)
+ }
+ if err := manager.Remove(t.Context(), testID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(directory); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("scope remains after remove: %v", err)
+ }
+}
+
+func TestRemoveBusyScopeHonorsCancellation(t *testing.T) {
+ manager := &Manager{parent: t.TempDir()}
+ directory, err := manager.scopeDir(testID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(directory, 0o750); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(directory, "busy"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ if err := manager.Remove(ctx, testID); !errors.Is(err, context.Canceled) {
+ t.Fatalf("Remove() error = %v", err)
+ }
+}
diff --git a/cgroup/manager_other.go b/cgroup/manager_other.go
new file mode 100644
index 0000000..817c004
--- /dev/null
+++ b/cgroup/manager_other.go
@@ -0,0 +1,27 @@
+//go:build !linux
+
+package cgroup
+
+import (
+ "context"
+ "errors"
+ "os"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+func unsupported() error {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("VMM cgroups require Linux cgroup v2"))
+}
+
+// Prepare rejects VMM launch on non-Linux development hosts.
+func (*Manager) Prepare(context.Context, types.SandboxID, uint32) (*os.File, error) {
+ return nil, unsupported()
+}
+
+// PIDs rejects process ownership inspection on non-Linux hosts.
+func (*Manager) PIDs(types.SandboxID) ([]int, error) { return nil, unsupported() }
+
+// Remove has no non-Linux scope to reclaim.
+func (*Manager) Remove(context.Context, types.SandboxID) error { return unsupported() }
diff --git a/cgroup/manager_other_test.go b/cgroup/manager_other_test.go
new file mode 100644
index 0000000..514ae01
--- /dev/null
+++ b/cgroup/manager_other_test.go
@@ -0,0 +1,26 @@
+//go:build !linux
+
+package cgroup
+
+import (
+ "testing"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestManagerOperationsRequireLinux(t *testing.T) {
+ manager, err := New(DefaultParent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := types.SandboxID("123e4567-e89b-42d3-a456-426614174000")
+ _, prepareErr := manager.Prepare(t.Context(), id, 2)
+ _, pidsErr := manager.PIDs(id)
+ removeErr := manager.Remove(t.Context(), id)
+ for operation, err := range map[string]error{"Prepare": prepareErr, "PIDs": pidsErr, "Remove": removeErr} {
+ if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeHostIncompatible {
+ t.Fatalf("%s error = %v", operation, err)
+ }
+ }
+}
diff --git a/cli/doctor/command.go b/cli/doctor/command.go
new file mode 100644
index 0000000..48a74d7
--- /dev/null
+++ b/cli/doctor/command.go
@@ -0,0 +1,68 @@
+// Package doctor exposes host prerequisite checks through kumabox.
+package doctor
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+
+ "github.com/spf13/cobra"
+)
+
+// checkerName resolves the separately installed host-check script through PATH.
+const checkerName = "kumabox-check"
+
+// processError preserves the checker's exit status without printing its diagnostics twice.
+type processError struct {
+ // err retains the subprocess failure for errors.As and errors.Is.
+ err error
+ // code is the checker process exit status.
+ code int
+}
+
+// Error forwards the original subprocess failure message.
+func (e *processError) Error() string { return e.err.Error() }
+
+// Unwrap preserves access to the original exec.ExitError.
+func (e *processError) Unwrap() error { return e.err }
+
+// ExitCode propagates the checker's status to the kumabox process.
+func (e *processError) ExitCode() int { return e.code }
+
+// Silent reports that the checker already wrote its own diagnostics.
+func (e *processError) Silent() bool { return true }
+
+// NewCommand returns the doctor command. Flag parsing belongs to kumabox-check, so
+// every argument after "doctor" is forwarded unchanged.
+func NewCommand() *cobra.Command {
+ return &cobra.Command{
+ Use: "doctor [--fix] [--upgrade] [--subnet=CIDR]",
+ Short: "check and repair host prerequisites",
+ DisableFlagParsing: true,
+ SilenceUsage: true,
+ SilenceErrors: true,
+ RunE: func(command *cobra.Command, args []string) error {
+ path, err := exec.LookPath(checkerName)
+ if err != nil {
+ return fmt.Errorf("find %s: %w", checkerName, err)
+ }
+
+ check := exec.CommandContext(command.Context(), path, args...) //nolint:gosec // executable is resolved by name from the operator-controlled PATH
+ check.Stdin = command.InOrStdin()
+ check.Stdout = command.OutOrStdout()
+ check.Stderr = command.ErrOrStderr()
+ if err := check.Run(); err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ return &processError{err: err, code: exitErr.ExitCode()}
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("run %s: %w", checkerName, err)
+ }
+ return err
+ }
+ return nil
+ },
+ }
+}
diff --git a/cli/doctor/command_test.go b/cli/doctor/command_test.go
new file mode 100644
index 0000000..48c8c63
--- /dev/null
+++ b/cli/doctor/command_test.go
@@ -0,0 +1,37 @@
+package doctor
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestCommandLetsScriptOwnFlags(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("test helper is a shell script")
+ }
+
+ dir := t.TempDir()
+ checker := filepath.Join(dir, checkerName)
+ contents := []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n")
+ if err := os.WriteFile(checker, contents, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ command := NewCommand()
+ command.SetArgs([]string{"--help", "--future-script-flag=value"})
+ command.SetContext(context.Background())
+ var stdout bytes.Buffer
+ command.SetOut(&stdout)
+ command.SetErr(&bytes.Buffer{})
+ if err := command.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ if got, want := stdout.String(), "--help\n--future-script-flag=value\n"; got != want {
+ t.Fatalf("forwarded arguments = %q, want %q", got, want)
+ }
+}
diff --git a/cli/image/command.go b/cli/image/command.go
new file mode 100644
index 0000000..902ac54
--- /dev/null
+++ b/cli/image/command.go
@@ -0,0 +1,44 @@
+// Package image adapts image workflows to Cobra commands and terminal output.
+// Core assembles dependencies; the images module owns import, verification, and removal.
+package image
+
+import (
+ "fmt"
+ "runtime"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+// configProvider defers reading immutable configuration until flags are parsed.
+type configProvider func() config.Config
+
+// NewCommand registers the image command tree using invocation-local configuration.
+func NewCommand(configuration configProvider) *cobra.Command {
+ command := &cobra.Command{Use: "image", Short: "manage OCI/docker images", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { return command.Help() }}
+ command.AddCommand(
+ newPullCommand(configuration),
+ newImportCommand(configuration),
+ newListCommand(configuration),
+ newInspectCommand(configuration),
+ newVerifyCommand(configuration),
+ newRemoveCommand(configuration),
+ )
+ return command
+}
+
+// parsePlatform rejects targets unsupported by the Linux image conversion pipeline.
+func parsePlatform(value string) (types.Platform, error) {
+ parts := strings.Split(value, "/")
+ if len(parts) != 2 || parts[0] != "linux" || (parts[1] != "amd64" && parts[1] != "arm64") {
+ return types.Platform{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("unsupported platform %q", value))
+ }
+ return types.Platform{OS: parts[0], Architecture: parts[1]}, nil
+}
+
+// defaultPlatform selects the host architecture while keeping the guest OS Linux.
+func defaultPlatform() string { return "linux/" + runtime.GOARCH }
diff --git a/cli/image/command_test.go b/cli/image/command_test.go
new file mode 100644
index 0000000..b08f19d
--- /dev/null
+++ b/cli/image/command_test.go
@@ -0,0 +1,279 @@
+package image
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/google/go-containerregistry/pkg/name"
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ "github.com/google/go-containerregistry/pkg/v1/layout"
+ "github.com/google/go-containerregistry/pkg/v1/mutate"
+ "github.com/google/go-containerregistry/pkg/v1/tarball"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+func newImageTestExecutor(t *testing.T) (storage.Roots, func(...string) (string, error)) {
+ t.Helper()
+ base := t.TempDir()
+ // This stand-in consumes tar input and writes deterministic bytes. Real EROFS is a Linux runbook check.
+ binary := filepath.Join(base, "mkfs.erofs")
+ script := "#!/bin/sh\nif [ \"$1\" = --version ]; then printf 'mkfs.erofs 1.8.10\\n'; exit 0; fi\nfor output do :; done\n/bin/cat > \"$output\"\n"
+ if err := os.WriteFile(binary, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", base+string(os.PathListSeparator)+os.Getenv("PATH"))
+ roots := storage.Roots{Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log")}
+ execute := func(args ...string) (string, error) {
+ command := NewCommand(func() config.Config { return imageTestConfig(roots) })
+ var out, stderr bytes.Buffer
+ command.SetOut(&out)
+ command.SetErr(&stderr)
+ command.SetArgs(args)
+ err := command.ExecuteContext(t.Context())
+ return out.String(), err
+ }
+ return roots, execute
+}
+
+// imageTestConfig returns production defaults scoped to one test directory.
+func imageTestConfig(roots storage.Roots) config.Config {
+ configuration := config.Default()
+ configuration.Paths = roots
+ return configuration
+}
+
+func TestImageCommandsFromLayoutAndArchive(t *testing.T) {
+ roots, execute := newImageTestExecutor(t)
+ base := filepath.Dir(roots.Data)
+ if out, err := execute("ls", "--json"); err != nil || out != "[]\n" {
+ t.Fatalf("empty list = %q, %v", out, err)
+ }
+ if _, err := execute("import", "tiny", "../../testdata/oci-layout", "--platform", "linux/amd64"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := execute("verify", "tiny"); err != nil {
+ t.Fatal(err)
+ }
+ archive := filepath.Join(base, "fixture.bin")
+ file, err := os.Create(archive)
+ if err != nil {
+ t.Fatal(err)
+ }
+ compressed := gzip.NewWriter(file)
+ tarWriter := tar.NewWriter(compressed)
+ if err := filepath.Walk("../../testdata/oci-layout", func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() {
+ return err
+ }
+ relative, err := filepath.Rel("../../testdata/oci-layout", path)
+ if err != nil {
+ return err
+ }
+ header := &tar.Header{Name: relative, Typeflag: tar.TypeReg, Size: info.Size(), Mode: 0o600}
+ if err := tarWriter.WriteHeader(header); err != nil {
+ return err
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ _, err = tarWriter.Write(data)
+ return err
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := tarWriter.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := compressed.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := execute("import", "alias", archive, "--platform", "linux/amd64"); err != nil {
+ t.Fatal(err)
+ }
+ out, err := execute("inspect", "tiny")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var image imageOutput
+ if err := json.Unmarshal([]byte(out), &image); err != nil {
+ t.Fatal(err)
+ }
+ if len(image.Names) != 2 || len(image.Layers) != 1 {
+ t.Fatalf("inspect = %s", out)
+ }
+ if !strings.Contains(out, "\n \"manifest_digest\":") || !strings.Contains(out, "\n \"architecture\":") {
+ t.Fatalf("inspect JSON is not indented: %s", out)
+ }
+ list, err := execute("ls")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(list, "IMAGE ID") || !strings.Contains(list, "PLATFORM") || !strings.Contains(list, "CREATED") || !strings.Contains(list, image.ManifestDigest[7:19]) || strings.Contains(list, image.ManifestDigest) {
+ t.Fatalf("list is not a readable image table: %s", list)
+ }
+ listJSON, err := execute("ls", "--json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var listed []imageOutput
+ if err := json.Unmarshal([]byte(listJSON), &listed); err != nil {
+ t.Fatal(err)
+ }
+ if len(listed) != 1 || listed[0].ManifestDigest != image.ManifestDigest || !strings.Contains(listJSON, "\n \"manifest_digest\":") {
+ t.Fatalf("list JSON is not an indented image array: %s", listJSON)
+ }
+
+ if _, err := execute("rm", "tiny", "alias"); err != nil {
+ t.Fatal(err)
+ }
+ if out, err := execute("ls", "--json"); err != nil || out != "[]\n" {
+ t.Fatalf("removed list = %q, %v", out, err)
+ }
+ paths, err := images.NewPaths(roots)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(paths.StagingDir())
+ if err != nil || len(entries) != 0 {
+ t.Fatalf("staging entries = %v, %v", entries, err)
+ }
+ if _, err := os.Stat(filepath.Join(roots.Data, "images", "blobs")); !os.IsNotExist(err) {
+ t.Fatalf("persistent OCI blobs exist: %v", err)
+ }
+}
+
+func TestImageCommandsFromDockerArchive(t *testing.T) {
+ roots, execute := newImageTestExecutor(t)
+ base := filepath.Dir(roots.Data)
+ fixture, err := layout.FromPath("../../testdata/oci-layout")
+ if err != nil {
+ t.Fatal(err)
+ }
+ index, err := fixture.ImageIndex()
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest, err := index.IndexManifest()
+ if err != nil {
+ t.Fatal(err)
+ }
+ image, err := fixture.Image(manifest.Manifests[0].Digest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ image, err = mutate.Config(image, v1.Config{Labels: map[string]string{types.ImageBootProfileLabel: string(types.BootProfileOverlayV1)}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ tag, err := name.NewTag("example/demo:one")
+ if err != nil {
+ t.Fatal(err)
+ }
+ archive := filepath.Join(base, "docker.bin")
+ // Use the dependency's Docker archive writer as an independent producer.
+ if err := tarball.WriteToFile(archive, tag, image); err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{
+ {"import", "docker-first", archive, "--platform", "linux/amd64"},
+ {"verify", "docker-first"},
+ {"import", "docker-alias", archive, "--platform", "linux/amd64", "--format", "docker", "--source-tag", "example/demo:one"},
+ {"verify", "docker-alias"},
+ } {
+ if _, err := execute(args...); err != nil {
+ t.Fatalf("%v: %v", args, err)
+ }
+ }
+ out, err := execute("inspect", "docker-first")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var first imageOutput
+ if err := json.Unmarshal([]byte(out), &first); err != nil {
+ t.Fatal(err)
+ }
+ if len(first.Names) != 2 || len(first.Layers) != 1 || first.Boot.Profile != string(types.BootProfileOverlayV1) || first.Boot.KernelFile == "" || first.Boot.InitrdFile == "" {
+ t.Fatalf("Docker inspect = %s", out)
+ }
+ if _, err := execute("import", "docker-first", archive, "--platform", "linux/amd64"); err != nil {
+ t.Fatal(err)
+ }
+ repeated, err := execute("inspect", "docker-first")
+ if err != nil || repeated != out {
+ t.Fatalf("repeated import changed metadata: %s, %v", repeated, err)
+ }
+ if _, err := execute("import", "oci-reference", "../../testdata/oci-layout", "--platform", "linux/amd64", "--format", "oci"); err != nil {
+ t.Fatal(err)
+ }
+ layers, err := os.ReadDir(filepath.Join(roots.Data, "images", "layers", "sha256"))
+ if err != nil || len(layers) != 1 {
+ t.Fatalf("Docker and OCI did not reuse the layer: %v, %v", layers, err)
+ }
+ // Two distinct configs sharing a layer still represent two source images.
+ secondImage, err := mutate.Config(image, v1.Config{Env: []string{"VARIANT=two"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ secondTag, err := name.NewTag("example/demo:two")
+ if err != nil {
+ t.Fatal(err)
+ }
+ multi := filepath.Join(base, "multi.tar")
+ if err := tarball.MultiWriteToFile(multi, map[name.Tag]v1.Image{tag: image, secondTag: secondImage}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := execute("import", "ambiguous", multi, "--platform", "linux/amd64"); err == nil {
+ t.Fatal("ambiguous Docker archive was imported")
+ }
+ if _, err := execute("inspect", "ambiguous"); err == nil {
+ t.Fatal("failed Docker import became visible")
+ }
+ if _, err := execute("import", "selected", multi, "--platform", "linux/amd64", "--source-tag", "example/demo:two"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := execute("verify", "selected"); err != nil {
+ t.Fatal(err)
+ }
+ staging, err := os.ReadDir(filepath.Join(roots.Data, "staging", "imports"))
+ if err != nil || len(staging) != 0 {
+ t.Fatalf("Docker import left staging: %v, %v", staging, err)
+ }
+ if _, err := execute("rm", "docker-first", "docker-alias", "oci-reference", "selected"); err != nil {
+ t.Fatal(err)
+ }
+ if out, err := execute("ls", "--json"); err != nil || out != "[]\n" {
+ t.Fatalf("removed Docker list = %q, %v", out, err)
+ }
+}
+
+func TestImportRejectsUnknownFormatBeforeOpeningStore(t *testing.T) {
+ base := t.TempDir()
+ roots := storage.Roots{Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log")}
+ command := NewCommand(func() config.Config { return imageTestConfig(roots) })
+ command.SetOut(&bytes.Buffer{})
+ command.SetErr(&bytes.Buffer{})
+ command.SetArgs([]string{"import", "demo", "missing.tar", "--format", "tar"})
+ if err := command.ExecuteContext(t.Context()); err == nil {
+ t.Fatal("unknown format was accepted")
+ } else if code, _ := errdefs.CodeOf(err); code != errdefs.CodeInvalidArgument {
+ t.Fatalf("format error = %v", err)
+ }
+ if _, err := os.Stat(roots.Data); !os.IsNotExist(err) {
+ t.Fatalf("invalid format created a store: %v", err)
+ }
+}
diff --git a/cli/image/import.go b/cli/image/import.go
new file mode 100644
index 0000000..e08b5c5
--- /dev/null
+++ b/cli/image/import.go
@@ -0,0 +1,120 @@
+package image
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+)
+
+// newPullCommand validates a registry reference and runs the shared image importer.
+// Progress finishes after the store closes so cleanup failures affect the final status.
+func newPullCommand(configuration configProvider) *cobra.Command {
+ platform := defaultPlatform()
+ command := &cobra.Command{
+ Use: "pull REF",
+ Short: "pull an OCI image from a registry",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ parsedPlatform, err := parsePlatform(platform)
+ if err != nil {
+ return err
+ }
+ input, name, err := core.NewRegistrySource(args[0])
+ if err != nil {
+ return err
+ }
+ progress, err := startImageProgress(command, "Pull", name)
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+ state, err := core.OpenImages(command.Context(), configuration())
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, state.Close()) }()
+ importer, err := core.NewImageImporter(command.Context(), state, progress, parsedPlatform)
+ if err != nil {
+ return err
+ }
+ if err := progress.Status("downloading and converting layers"); err != nil {
+ return err
+ }
+ image, err := importer.Import(command.Context(), name, parsedPlatform, input)
+ if err != nil {
+ return err
+ }
+ return writeImage(progress.Output(command.OutOrStdout()), image)
+ },
+ }
+ command.Flags().StringVar(&platform, "platform", platform, "target platform (linux/amd64 or linux/arm64)")
+ return command
+}
+
+// newImportCommand selects a local Docker or OCI source and runs the shared importer.
+// Defers retain cleanup errors and finish progress after all resources are released.
+//
+// validate --> open store --> stage source --> import --> write result
+// |
+// final progress <-- close store <-- clean source <----+
+func newImportCommand(configuration configProvider) *cobra.Command {
+ platform := defaultPlatform()
+ format := "auto"
+ sourceTag := ""
+ command := &cobra.Command{
+ Use: "import NAME PATH",
+ Short: "import a Docker image archive or OCI layout/archive",
+ Long: "Import a docker save archive or an OCI image layout/archive. " +
+ "Formats are detected automatically unless --format is set. " +
+ "Use --source-tag to select a Docker source image; NAME is its local KumaBox name. " +
+ "docker export archives are not supported.",
+ Args: cobra.ExactArgs(2),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ parsedPlatform, err := parsePlatform(platform)
+ if err != nil {
+ return err
+ }
+ sourceOptions := core.LocalImageOptions{Format: format, SourceTag: sourceTag}
+ err = sourceOptions.Validate()
+ if err != nil {
+ return err
+ }
+ progress, err := startImageProgress(command, "Import", args[0])
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+ state, err := core.OpenImages(command.Context(), configuration())
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, state.Close()) }()
+ if err := progress.Status("reading source"); err != nil {
+ return err
+ }
+ input, cleanup, err := state.OpenLocalSource(command.Context(), args[1], sourceOptions)
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, cleanup()) }()
+ importer, err := core.NewImageImporter(command.Context(), state, progress, parsedPlatform)
+ if err != nil {
+ return err
+ }
+ if err := progress.Status("converting layers"); err != nil {
+ return err
+ }
+ image, err := importer.Import(command.Context(), args[0], parsedPlatform, input)
+ if err != nil {
+ return err
+ }
+ return writeImage(progress.Output(command.OutOrStdout()), image)
+ },
+ }
+ command.Flags().StringVar(&platform, "platform", platform, "target platform (linux/amd64 or linux/arm64)")
+ command.Flags().StringVar(&format, "format", format, "input format (auto, docker, or oci); auto detects source contents")
+ command.Flags().StringVar(&sourceTag, "source-tag", sourceTag, "select a source image tag inside a Docker archive (docker save format)")
+ return command
+}
diff --git a/cli/image/output.go b/cli/image/output.go
new file mode 100644
index 0000000..e2ba9b5
--- /dev/null
+++ b/cli/image/output.go
@@ -0,0 +1,142 @@
+package image
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+// imageOutput is the CLI JSON schema, keeping serialization separate from domain types.
+// Detailed output retains full digests and byte counts; table formatting is presentation only.
+type imageOutput struct {
+ // Names are all local aliases associated with this manifest.
+ Names []string `json:"names"`
+ // ManifestDigest is the complete normalized manifest content identity.
+ ManifestDigest string `json:"manifest_digest"`
+ // Platform selects the Linux guest OS and architecture.
+ Platform platformOutput `json:"platform"`
+ // Layers preserve source order from the base layer to the topmost layer.
+ Layers []layerOutput `json:"layers"`
+ // Boot identifies selected kernel and initrd artifacts.
+ Boot bootOutput `json:"boot"`
+ // Size is the sum of converted EROFS layer sizes in bytes.
+ Size int64 `json:"size"`
+ // CreatedAt records local import publication time.
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type (
+ // platformOutput exposes the guest platform without domain serialization methods.
+ platformOutput struct {
+ // OS is the guest operating system.
+ OS string `json:"os"`
+ // Architecture is the guest CPU architecture.
+ Architecture string `json:"architecture"`
+ }
+ // layerOutput links an original layer to its converted filesystem and boot metadata.
+ layerOutput struct {
+ // SourceDigest identifies the original source layer blob.
+ SourceDigest string `json:"source_digest"`
+ // EROFSDigest identifies the converted filesystem artifact.
+ EROFSDigest string `json:"erofs_digest"`
+ // Size is the converted EROFS artifact size in bytes.
+ Size int64 `json:"size"`
+ // BootFiles are regular boot candidates extracted from this source layer.
+ BootFiles []bootFileOutput `json:"boot_files"`
+ // Whiteouts mark boot paths removed by this layer for overlay boot selection.
+ Whiteouts []string `json:"whiteouts,omitempty"`
+ // BootOpaque hides boot candidates from lower layers under an opaque boot directory.
+ BootOpaque bool `json:"boot_opaque,omitempty"`
+ }
+
+ // bootFileOutput describes one content-addressed regular boot candidate.
+ bootFileOutput struct {
+ // Name is the boot candidate filename within the layer.
+ Name string `json:"name"`
+ // Digest identifies the extracted file contents.
+ Digest string `json:"digest"`
+ // Size is the extracted file length in bytes.
+ Size int64 `json:"size"`
+ }
+ // bootOutput identifies the selected boot filenames and their source layer identities.
+ bootOutput struct {
+ // Profile names the declared host/guest boot contract; empty is undeclared.
+ Profile string `json:"profile"`
+ // KernelLayer is the source digest of the layer providing the selected kernel.
+ KernelLayer string `json:"kernel_layer"`
+ // KernelFile is the selected kernel filename.
+ KernelFile string `json:"kernel_file"`
+ // InitrdLayer is the source digest of the layer providing the selected initrd.
+ InitrdLayer string `json:"initrd_layer"`
+ // InitrdFile is the selected initrd filename.
+ InitrdFile string `json:"initrd_file"`
+ }
+)
+
+// imageResult projects domain metadata into the CLI schema without truncating content identities.
+func imageResult(image types.Image) imageOutput {
+ layers := make([]layerOutput, 0, len(image.Layers))
+ for _, layer := range image.Layers {
+ bootFiles := make([]bootFileOutput, 0, len(layer.BootFiles))
+ for _, file := range layer.BootFiles {
+ bootFiles = append(bootFiles, bootFileOutput{Name: file.Name, Digest: file.Digest.String(), Size: file.Size})
+ }
+ layers = append(layers, layerOutput{SourceDigest: layer.SourceDigest.String(), EROFSDigest: layer.EROFSDigest.String(), Size: layer.Size, BootFiles: bootFiles, Whiteouts: layer.Whiteouts, BootOpaque: layer.BootOpaque})
+ }
+ return imageOutput{Names: image.Names, ManifestDigest: image.ManifestDigest.String(), Platform: platformOutput{OS: image.Platform.OS, Architecture: image.Platform.Architecture}, Layers: layers, Boot: bootOutput{Profile: string(image.Boot.Profile), KernelLayer: image.Boot.KernelLayer.String(), KernelFile: image.Boot.KernelFile, InitrdLayer: image.Boot.InitrdLayer.String(), InitrdFile: image.Boot.InitrdFile}, Size: image.Size, CreatedAt: image.CreatedAt}
+}
+
+// writeImage reports the aliases and full manifest digest after a successful import.
+func writeImage(writer io.Writer, image types.Image) error {
+ _, err := fmt.Fprintf(writer, "%s\t%s\n", strings.Join(image.Names, ","), image.ManifestDigest)
+ return err
+}
+
+// writeJSON emits indented JSON followed by a newline for readable inspection and piping.
+func writeJSON(writer io.Writer, value any) error {
+ encoder := json.NewEncoder(writer)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(value)
+}
+
+// writeImagesTable renders a header even for an empty catalog and aligns readable summaries.
+// Full digests remain available through inspect and list --json.
+func writeImagesTable(writer io.Writer, items []types.Image) error {
+ table := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0)
+ if _, err := fmt.Fprintln(table, "NAME\tIMAGE ID\tPLATFORM\tSIZE\tCREATED"); err != nil {
+ return err
+ }
+ for _, item := range items {
+ names := strings.Join(item.Names, ", ")
+ if names == "" {
+ names = ""
+ }
+ if _, err := fmt.Fprintf(table, "%s\t%s\t%s/%s\t%s\t%s\n",
+ names, item.ManifestDigest.Hex()[:12], item.Platform.OS, item.Platform.Architecture,
+ imageSize(item.Size), item.CreatedAt.UTC().Format(time.RFC3339),
+ ); err != nil {
+ return err
+ }
+ }
+ return table.Flush()
+}
+
+// imageSize formats bytes with decimal SI units, carrying values that would round to 1000.0.
+func imageSize(size int64) string {
+ if size < 1000 {
+ return fmt.Sprintf("%dB", size)
+ }
+ value := float64(size)
+ for _, unit := range []string{"kB", "MB", "GB", "TB", "PB", "EB"} {
+ value /= 1000
+ if value < 999.95 || unit == "EB" {
+ return fmt.Sprintf("%.1f%s", value, unit)
+ }
+ }
+ return fmt.Sprintf("%dB", size)
+}
diff --git a/cli/image/output_test.go b/cli/image/output_test.go
new file mode 100644
index 0000000..614f82e
--- /dev/null
+++ b/cli/image/output_test.go
@@ -0,0 +1,84 @@
+package image
+
+import (
+ "bytes"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestImagesTableHeadersAndAlignedRows(t *testing.T) {
+ first, err := types.ParseDigest("sha256:" + strings.Repeat("a", 64))
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := types.ParseDigest("sha256:" + strings.Repeat("b", 64))
+ if err != nil {
+ t.Fatal(err)
+ }
+ created := time.Date(2026, 9, 14, 16, 30, 0, 0, time.FixedZone("UTC+8", 8*60*60))
+ items := []types.Image{
+ {
+ Names: []string{"demo", "demo-alias-with-a-long-name"}, ManifestDigest: first,
+ Platform: types.Platform{OS: "linux", Architecture: "amd64"}, Size: 127600000, CreatedAt: created,
+ },
+ {ManifestDigest: second, Platform: types.Platform{OS: "linux", Architecture: "arm64"}, Size: 1024, CreatedAt: created},
+ }
+ var out bytes.Buffer
+ if err := writeImagesTable(&out, items); err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("table = %q", out.String())
+ }
+ columns := []struct{ header, first, second string }{
+ {"NAME", "demo, demo-alias-with-a-long-name", ""},
+ {"IMAGE ID", "aaaaaaaaaaaa", "bbbbbbbbbbbb"},
+ {"PLATFORM", "linux/amd64", "linux/arm64"},
+ {"SIZE", "127.6MB", "1.0kB"},
+ {"CREATED", "2026-09-14T08:30:00Z", "2026-09-14T08:30:00Z"},
+ }
+ for _, column := range columns {
+ start := strings.Index(lines[0], column.header)
+ if start < 0 || strings.Index(lines[1], column.first) != start || strings.Index(lines[2], column.second) != start {
+ t.Fatalf("column %q is missing or misaligned:\n%s", column.header, out.String())
+ }
+ }
+ if strings.ContainsAny(out.String(), "\t\x1b") || strings.Contains(out.String(), first.String()) {
+ t.Fatalf("table contains raw tabs, control sequences or full digests: %q", out.String())
+ }
+ t.Log("\n" + out.String())
+}
+
+func TestEmptyImagesTableShowsHeaders(t *testing.T) {
+ var out bytes.Buffer
+ if err := writeImagesTable(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Count(out.String(), "\n") != 1 {
+ t.Fatalf("empty table = %q", out.String())
+ }
+ for _, header := range []string{"NAME", "IMAGE ID", "PLATFORM", "SIZE", "CREATED"} {
+ if !strings.Contains(out.String(), header) {
+ t.Fatalf("missing %q: %q", header, out.String())
+ }
+ }
+}
+
+type failingOutput struct{ err error }
+
+func (w failingOutput) Write([]byte) (int, error) { return 0, w.err }
+
+func TestQueryOutputPreservesWriteErrors(t *testing.T) {
+ failure := errors.New("output closed")
+ if err := writeImagesTable(failingOutput{failure}, nil); !errors.Is(err, failure) {
+ t.Fatalf("table error = %v", err)
+ }
+ if err := writeJSON(failingOutput{failure}, []imageOutput{}); !errors.Is(err, failure) {
+ t.Fatalf("JSON error = %v", err)
+ }
+}
diff --git a/cli/image/progress.go b/cli/image/progress.go
new file mode 100644
index 0000000..30d7008
--- /dev/null
+++ b/cli/image/progress.go
@@ -0,0 +1,149 @@
+package image
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+
+ "github.com/spf13/cobra"
+
+ cliprogress "github.com/kumabox/kumabox/cli/progress"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/types"
+)
+
+// imageProgress adapts image-specific counters and commit events to the shared
+// terminal renderer. Counts represent completed work rather than percentage.
+//
+// images.Reporter callbacks -> image message/counters -> progress.Renderer
+type imageProgress struct {
+ // mu protects counters, status, and commit state across worker callbacks.
+ mu sync.Mutex
+ // renderer owns terminal detection, serialization, animation, and shutdown.
+ renderer *cliprogress.Renderer
+ // label identifies the operation and quoted image reference.
+ label string
+ // status describes the current image workflow stage.
+ status string
+ // completed and total count successful layer or image callbacks.
+ completed int
+ total int
+ // unit labels the counter as layers or images.
+ unit string
+ // committed records durable state changed before a later error.
+ committed bool
+}
+
+var _ images.Reporter = (*imageProgress)(nil)
+
+// startImageProgress creates the image adapter on the command's stderr stream.
+func startImageProgress(command *cobra.Command, operation, reference string) (*imageProgress, error) {
+ return newImageProgress(command.Context(), command.ErrOrStderr(), fmt.Sprintf("%s %q", operation, reference))
+}
+
+// newImageProgress builds the domain adapter and writes its initial status.
+func newImageProgress(ctx context.Context, writer io.Writer, label string) (*imageProgress, error) {
+ status := "preparing image"
+ renderer, err := cliprogress.New(ctx, writer, label+" · "+status)
+ if err != nil {
+ return nil, err
+ }
+ return &imageProgress{renderer: renderer, label: label, status: status, unit: "layers"}, nil
+}
+
+// Status updates the visible image workflow stage.
+func (p *imageProgress) Status(status string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.status = status
+ return p.renderer.Update(p.messageLocked())
+}
+
+// Layer records an out-of-order layer completion. Redirected output receives
+// one durable line per layer while terminals redraw the aggregate counter.
+func (p *imageProgress) Layer(position, total int, digest types.Digest) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.completed++
+ p.total = total
+ if p.completed == p.total {
+ p.status = "publishing image"
+ }
+ if p.renderer.Animated() {
+ return p.renderer.Update(p.messageLocked())
+ }
+ return p.renderer.Update(fmt.Sprintf("Layer %d/%d %s complete", position+1, total, digest.Hex()[:12]))
+}
+
+// Committed records durable image publication before source and store cleanup.
+func (p *imageProgress) Committed(types.Image) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.committed = true
+ p.status = "finishing"
+ if p.renderer.Animated() {
+ return p.renderer.Update(p.messageLocked())
+ }
+ return p.renderer.Err()
+}
+
+// Removed records one successful image deletion.
+func (p *imageProgress) Removed(total int) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.committed = true
+ p.completed++
+ p.total, p.unit = total, "images"
+ p.status = "removing images"
+ if p.renderer.Animated() {
+ return p.renderer.Update(p.messageLocked())
+ }
+ return p.renderer.Err()
+}
+
+// Output coordinates command results with a live terminal frame.
+func (p *imageProgress) Output(writer io.Writer) io.Writer {
+ return p.renderer.Output(writer)
+}
+
+// Finish maps image commit and cancellation facts to a generic final outcome.
+func (p *imageProgress) Finish(operationErr error) error {
+ p.mu.Lock()
+ var classified *errdefs.Error
+ if errors.As(operationErr, &classified) && classified.Committed {
+ p.committed = true
+ }
+ renderErr := p.renderer.Err()
+ outcome := cliprogress.Succeeded
+ if operationErr != nil || renderErr != nil {
+ switch {
+ case p.committed:
+ outcome = cliprogress.CommittedWithErrors
+ case errors.Is(operationErr, context.Canceled):
+ outcome = cliprogress.Canceled
+ default:
+ outcome = cliprogress.Failed
+ }
+ }
+ detail := ""
+ if p.total > 0 {
+ detail = fmt.Sprintf(" (%d/%d %s)", p.completed, p.total, p.unit)
+ }
+ committed, label := p.committed, p.label
+ p.mu.Unlock()
+
+ reportErr := p.renderer.Finish(label, outcome, detail)
+ return errdefs.Context(reportErr, "image operation", label, "report", "check image state with image inspect", committed)
+}
+
+// messageLocked formats aggregate image state while p.mu is held.
+func (p *imageProgress) messageLocked() string {
+ message := p.label + " · " + p.status
+ if p.total > 0 {
+ message += fmt.Sprintf(" (%d/%d %s)", p.completed, p.total, p.unit)
+ }
+ return message
+}
diff --git a/cli/image/progress_test.go b/cli/image/progress_test.go
new file mode 100644
index 0000000..17de77b
--- /dev/null
+++ b/cli/image/progress_test.go
@@ -0,0 +1,156 @@
+package image
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestProgressLogsHaveNoAnimationControls(t *testing.T) {
+ var out bytes.Buffer
+ progress, err := newImageProgress(t.Context(), &out, `Import "demo"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Status("converting layers"); err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Layer(1, 2, types.Digest{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Layer(0, 2, types.Digest{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Committed(types.Image{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Finish(nil); err != nil {
+ t.Fatal(err)
+ }
+ if strings.ContainsAny(out.String(), "\r\x1b") || !strings.Contains(out.String(), "complete (2/2 layers)\n") {
+ t.Fatalf("plain progress = %q", out.String())
+ }
+ if !strings.Contains(out.String(), "preparing image") || strings.Count(out.String(), " complete\n") != 2 {
+ t.Fatalf("missing initial status or layer notifications: %s", out.String())
+ }
+}
+
+func TestProgressCountsConcurrentCompletedLayers(t *testing.T) {
+ var out bytes.Buffer
+ progress, err := newImageProgress(t.Context(), &out, `Import "demo"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var wait sync.WaitGroup
+ for _, position := range []int{2, 0, 1} {
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ if err := progress.Layer(position, 3, types.Digest{}); err != nil {
+ t.Error(err)
+ }
+ }()
+ }
+ wait.Wait()
+ if err := progress.Finish(nil); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Count(out.String(), "Layer ") != 3 || !strings.HasSuffix(out.String(), "complete (3/3 layers)\n") {
+ t.Fatalf("layer progress = %q", out.String())
+ }
+}
+
+type imageFailWriter struct {
+ bytes.Buffer
+ writes int
+ failAt int
+ failure error
+}
+
+func (w *imageFailWriter) Write(data []byte) (int, error) {
+ w.writes++
+ if w.writes == w.failAt {
+ return 0, w.failure
+ }
+ return w.Buffer.Write(data)
+}
+
+func TestProgressRetainsRenderingFailureAfterCommit(t *testing.T) {
+ failure := errors.New("terminal write failed")
+ writer := &imageFailWriter{failAt: 2, failure: failure}
+ progress, err := newImageProgress(t.Context(), writer, `Import "demo"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Status("converting layers"); !errors.Is(err, failure) {
+ t.Fatalf("status error = %v", err)
+ }
+ if err := progress.Committed(types.Image{}); !errors.Is(err, failure) {
+ t.Fatalf("commit report error = %v", err)
+ }
+ err = progress.Finish(failure)
+ var classified *errdefs.Error
+ if !errors.Is(err, failure) || !errors.As(err, &classified) || !classified.Committed {
+ t.Fatalf("final report error = %v", err)
+ }
+ if !strings.Contains(writer.String(), "committed with errors") {
+ t.Fatalf("committed error status = %q", writer.String())
+ }
+}
+
+func TestProgressKeepsResultsOnTheirWriter(t *testing.T) {
+ var progressOut, resultOut bytes.Buffer
+ progress, err := newImageProgress(t.Context(), &progressOut, `Verify "demo"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := progress.Output(&resultOut).Write([]byte("verified sha256:example\n")); err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Finish(nil); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(progressOut.String(), "sha256:example") || resultOut.String() != "verified sha256:example\n" {
+ t.Fatalf("progress=%q result=%q", progressOut.String(), resultOut.String())
+ }
+}
+
+func TestProgressReportsCancellation(t *testing.T) {
+ var out bytes.Buffer
+ progress, err := newImageProgress(t.Context(), &out, `Pull "demo"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Finish(context.Canceled); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasSuffix(out.String(), `Pull "demo" canceled`+"\n") {
+ t.Fatalf("canceled status = %q", out.String())
+ }
+}
+
+func TestRemovalProgressCountsCompletedImages(t *testing.T) {
+ var out bytes.Buffer
+ progress, err := newImageProgress(t.Context(), &out, `Remove "demo, alias"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Removed(2); err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Removed(2); err != nil {
+ t.Fatal(err)
+ }
+ if err := progress.Finish(nil); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasSuffix(out.String(), "complete (2/2 images)\n") {
+ t.Fatalf("removal status = %q", out.String())
+ }
+}
diff --git a/cli/image/query.go b/cli/image/query.go
new file mode 100644
index 0000000..11c5246
--- /dev/null
+++ b/cli/image/query.go
@@ -0,0 +1,95 @@
+package image
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/images"
+)
+
+// newListCommand renders catalog entries as an aligned table or detailed JSON.
+func newListCommand(configuration configProvider) *cobra.Command {
+ asJSON := false
+ command := &cobra.Command{
+ Use: "list",
+ Aliases: []string{"ls"},
+ Short: "list imported images",
+ Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, _ []string) (returnErr error) {
+ state, err := core.OpenImages(command.Context(), configuration())
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, state.Close()) }()
+ items, err := state.Catalog.List(command.Context())
+ if err != nil {
+ return err
+ }
+ if asJSON {
+ results := make([]imageOutput, 0, len(items))
+ for _, item := range items {
+ results = append(results, imageResult(item))
+ }
+ return writeJSON(command.OutOrStdout(), results)
+ }
+ return writeImagesTable(command.OutOrStdout(), items)
+ },
+ }
+ command.Flags().BoolVar(&asJSON, "json", false, "write JSON")
+ return command
+}
+
+// newInspectCommand resolves a name or digest and preserves full metadata in JSON.
+func newInspectCommand(configuration configProvider) *cobra.Command {
+ return &cobra.Command{
+ Use: "inspect IMAGE",
+ Short: "inspect an imported image",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ state, err := core.OpenImages(command.Context(), configuration())
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, state.Close()) }()
+ image, err := state.Catalog.Resolve(command.Context(), args[0])
+ if err != nil {
+ return err
+ }
+ return writeJSON(command.OutOrStdout(), imageResult(image))
+ },
+ }
+}
+
+// newVerifyCommand checks persisted artifacts and reports waiting on stderr.
+// Store cleanup completes before the progress reporter emits its final status.
+func newVerifyCommand(configuration configProvider) *cobra.Command {
+ return &cobra.Command{
+ Use: "verify IMAGE",
+ Short: "verify image artifacts",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ progress, err := startImageProgress(command, "Verify", args[0])
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+ if err := progress.Status("checking image artifacts"); err != nil {
+ return err
+ }
+ state, err := core.OpenImages(command.Context(), configuration())
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, state.Close()) }()
+ image, err := images.Verify(command.Context(), state.Paths, state.Catalog, args[0])
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprintf(progress.Output(command.OutOrStdout()), "verified %s\n", image.ManifestDigest)
+ return err
+ },
+ }
+}
diff --git a/cli/image/remove.go b/cli/image/remove.go
new file mode 100644
index 0000000..659f151
--- /dev/null
+++ b/cli/image/remove.go
@@ -0,0 +1,53 @@
+package image
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+)
+
+// newRemoveCommand removes references in argument order and counts completed removals.
+// Reporting errors after a successful removal carry committed state so callers know
+// that a failed command does not imply that the image is still present.
+func newRemoveCommand(configuration configProvider) *cobra.Command {
+ return &cobra.Command{
+ Use: "remove IMAGE...",
+ Aliases: []string{"rm"},
+ Short: "remove an imported image",
+ Args: cobra.MinimumNArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ progress, err := startImageProgress(command, "Remove", strings.Join(args, ", "))
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+ state, err := core.OpenImages(command.Context(), configuration())
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, state.Close()) }()
+ for _, reference := range args {
+ if err := progress.Status("waiting for image locks and removing artifacts"); err != nil {
+ return err
+ }
+ removed, err := images.Remove(command.Context(), state.Paths, state.Catalog, reference)
+ if err != nil {
+ return err
+ }
+ if err := progress.Removed(len(args)); err != nil {
+ return errdefs.Context(err, "remove image", reference, "report", "image removed", true)
+ }
+ if _, err := fmt.Fprintf(progress.Output(command.OutOrStdout()), "removed %s\n", strings.Join(removed.Names, ",")); err != nil {
+ return errdefs.Context(err, "remove image", reference, "report", "image removed", true)
+ }
+ }
+ return nil
+ },
+ }
+}
diff --git a/cli/progress/renderer.go b/cli/progress/renderer.go
new file mode 100644
index 0000000..19c33b2
--- /dev/null
+++ b/cli/progress/renderer.go
@@ -0,0 +1,263 @@
+// Package progress renders serialized CLI activity without knowing the domain
+// event that produced each message.
+package progress
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/mattn/go-isatty"
+)
+
+const frameInterval = 100 * time.Millisecond
+
+var (
+ spinnerFrames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
+ errFinished = errors.New("progress renderer is already finished")
+)
+
+// Outcome selects the final status text and terminal symbol.
+type Outcome uint8
+
+const (
+ _ Outcome = iota
+ // Succeeded reports that the operation and its cleanup completed.
+ Succeeded
+ // Failed reports that the operation made no known durable change.
+ Failed
+ // Canceled reports context cancellation before a durable change.
+ Canceled
+ // CommittedWithErrors reports a durable change followed by an error.
+ CommittedWithErrors
+)
+
+// Renderer serializes animation, status changes, result output, and shutdown.
+// Domain adapters supply already formatted messages and never access terminal
+// state directly.
+//
+// New -> initial frame -> Update / Output -> Finish
+// | ^ |
+// +---- ticker ----+---- stop ---+
+// context cancellation
+type Renderer struct {
+ // mu serializes ticker, adapter, result, and final writes.
+ mu sync.Mutex
+ // writer receives progress independently of command results.
+ writer io.Writer
+ // animated selects terminal redraws instead of durable plain lines.
+ animated bool
+ // message is the complete current activity text supplied by an adapter.
+ message string
+ // lastPlain suppresses consecutive duplicate statuses in redirected logs.
+ lastPlain string
+ // frame selects the next spinner glyph.
+ frame int
+ // err retains the first progress-stream failure.
+ err error
+ // finished prevents writes after the terminal result line.
+ finished bool
+ // stopOnce makes joining safe after cancellation or a rendering failure.
+ stopOnce sync.Once
+ // stop requests animation shutdown.
+ stop chan struct{}
+ // done closes after the animation goroutine and ticker have exited.
+ done chan struct{}
+}
+
+type tickerFactory func(time.Duration) (<-chan time.Time, func())
+
+// New writes the initial status and animates only when writer is a terminal.
+func New(ctx context.Context, writer io.Writer, initial string) (*Renderer, error) {
+ file, isFile := writer.(*os.File)
+ animated := isFile && isatty.IsTerminal(file.Fd())
+ return newRenderer(ctx, writer, initial, animated, systemTicker)
+}
+
+// newRenderer accepts a ticker factory so tests can advance animation without
+// wall-clock sleeps. Production construction always uses systemTicker.
+func newRenderer(ctx context.Context, writer io.Writer, initial string, animated bool, ticker tickerFactory) (*Renderer, error) {
+ if ctx == nil {
+ return nil, errors.New("progress context must not be nil")
+ }
+ if writer == nil {
+ return nil, errors.New("progress writer must not be nil")
+ }
+ if initial == "" {
+ return nil, errors.New("initial progress message must not be empty")
+ }
+ renderer := &Renderer{
+ writer: writer, animated: animated, message: initial,
+ stop: make(chan struct{}), done: make(chan struct{}),
+ }
+ if err := renderer.renderLocked(); err != nil {
+ return nil, err
+ }
+ if !animated {
+ close(renderer.done)
+ return renderer, nil
+ }
+ ticks, stopTicker := ticker(frameInterval)
+ go renderer.animate(ctx, ticks, stopTicker)
+ return renderer, nil
+}
+
+func systemTicker(interval time.Duration) (<-chan time.Time, func()) {
+ ticker := time.NewTicker(interval)
+ return ticker.C, ticker.Stop
+}
+
+// Animated reports whether the renderer redraws one terminal line.
+func (r *Renderer) Animated() bool { return r.animated }
+
+// Err returns the first rendering error, if any.
+func (r *Renderer) Err() error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.err
+}
+
+// Update replaces the current terminal frame. Plain streams print each
+// distinct status once, so repeated callbacks cannot flood redirected logs.
+func (r *Renderer) Update(message string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.finished {
+ return errFinished
+ }
+ if r.err != nil {
+ return r.err
+ }
+ r.message = message
+ r.err = r.renderLocked()
+ return r.err
+}
+
+// Output wraps a command-result writer. A live terminal frame is cleared
+// before the result write and restored afterward under the renderer lock.
+func (r *Renderer) Output(writer io.Writer) io.Writer {
+ return outputWriter{renderer: r, writer: writer}
+}
+
+type outputWriter struct {
+ // renderer owns serialization and the progress stream.
+ renderer *Renderer
+ // writer receives command result bytes unchanged.
+ writer io.Writer
+}
+
+func (w outputWriter) Write(data []byte) (int, error) {
+ renderer := w.renderer
+ renderer.mu.Lock()
+ defer renderer.mu.Unlock()
+ if renderer.finished {
+ return 0, errFinished
+ }
+ if renderer.err != nil {
+ return 0, renderer.err
+ }
+ if renderer.animated {
+ if _, err := fmt.Fprint(renderer.writer, "\r\x1b[2K"); err != nil {
+ renderer.err = err
+ return 0, err
+ }
+ }
+ written, writeErr := w.writer.Write(data)
+ if renderer.animated {
+ renderer.err = renderer.renderLocked()
+ }
+ return written, errors.Join(writeErr, renderer.err)
+}
+
+// Finish joins the animation before emitting one newline-terminated result.
+func (r *Renderer) Finish(label string, outcome Outcome, detail string) error {
+ r.stopAnimation()
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.finished {
+ return errFinished
+ }
+ r.finished = true
+ if r.err != nil && outcome == Succeeded {
+ outcome = Failed
+ }
+ text, symbol, err := outcomePresentation(outcome)
+ if err != nil {
+ return errors.Join(r.err, err)
+ }
+ message := label + " " + text + detail
+ if r.animated {
+ message = "\r\x1b[2K" + symbol + " " + message
+ }
+ _, writeErr := fmt.Fprintln(r.writer, message)
+ return errors.Join(r.err, writeErr)
+}
+
+func outcomePresentation(outcome Outcome) (string, string, error) {
+ switch outcome {
+ case Succeeded:
+ return "complete", "✓", nil
+ case Failed:
+ return "failed", "✗", nil
+ case Canceled:
+ return "canceled", "✗", nil
+ case CommittedWithErrors:
+ return "committed with errors", "✗", nil
+ default:
+ return "", "", fmt.Errorf("invalid progress outcome %d", outcome)
+ }
+}
+
+func (r *Renderer) animate(ctx context.Context, ticks <-chan time.Time, stopTicker func()) {
+ defer close(r.done)
+ defer stopTicker()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-r.stop:
+ return
+ case <-ticks:
+ r.mu.Lock()
+ if r.err == nil && !r.finished {
+ r.err = r.renderLocked()
+ }
+ failed := r.err != nil || r.finished
+ r.mu.Unlock()
+ if failed {
+ return
+ }
+ }
+ }
+}
+
+// stopAnimation requests shutdown and waits without holding mu, which lets an
+// in-flight ticker write finish before the final line is emitted.
+func (r *Renderer) stopAnimation() {
+ r.stopOnce.Do(func() {
+ close(r.stop)
+ <-r.done
+ })
+}
+
+// renderLocked emits one frame or one deduplicated plain line. The caller
+// holds mu whenever the renderer is visible to another goroutine.
+func (r *Renderer) renderLocked() error {
+ if r.animated {
+ _, err := fmt.Fprintf(r.writer, "\r\x1b[2K%s %s", spinnerFrames[r.frame%len(spinnerFrames)], r.message)
+ r.frame++
+ return err
+ }
+ if r.message == r.lastPlain {
+ return nil
+ }
+ if _, err := fmt.Fprintln(r.writer, r.message); err != nil {
+ return err
+ }
+ r.lastPlain = r.message
+ return nil
+}
diff --git a/cli/progress/renderer_test.go b/cli/progress/renderer_test.go
new file mode 100644
index 0000000..795d3a5
--- /dev/null
+++ b/cli/progress/renderer_test.go
@@ -0,0 +1,200 @@
+package progress
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+type observedWriter struct {
+ mu sync.Mutex
+ buffer bytes.Buffer
+ writes int
+ changed chan struct{}
+ failAt int
+ failure error
+}
+
+func (w *observedWriter) Write(data []byte) (int, error) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.writes++
+ select {
+ case w.changed <- struct{}{}:
+ default:
+ }
+ if w.writes == w.failAt {
+ return 0, w.failure
+ }
+ return w.buffer.Write(data)
+}
+
+func (w *observedWriter) snapshot() (string, int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.buffer.String(), w.writes
+}
+
+type manualTicker struct {
+ ticks chan time.Time
+ stopped chan struct{}
+ once sync.Once
+}
+
+func newManualTicker() *manualTicker {
+ return &manualTicker{ticks: make(chan time.Time), stopped: make(chan struct{})}
+}
+
+func (t *manualTicker) factory(time.Duration) (<-chan time.Time, func()) {
+ return t.ticks, func() { t.once.Do(func() { close(t.stopped) }) }
+}
+
+func waitForWrites(t *testing.T, writer *observedWriter, count int) {
+ t.Helper()
+ for {
+ if _, writes := writer.snapshot(); writes >= count {
+ return
+ }
+ select {
+ case <-writer.changed:
+ case <-time.After(time.Second):
+ t.Fatalf("writer did not reach %d writes", count)
+ }
+ }
+}
+
+func waitForDone(t *testing.T, renderer *Renderer) {
+ t.Helper()
+ select {
+ case <-renderer.done:
+ case <-time.After(time.Second):
+ t.Fatal("progress animation did not stop")
+ }
+}
+
+func TestPlainRendererDeduplicatesStatusesAndSeparatesOutput(t *testing.T) {
+ var progressOut, resultOut bytes.Buffer
+ renderer, err := New(t.Context(), &progressOut, "Import · preparing")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if renderer.Animated() {
+ t.Fatal("buffer-backed renderer enabled animation")
+ }
+ for _, status := range []string{"Import · preparing", "Import · converting", "Import · converting"} {
+ if err := renderer.Update(status); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := renderer.Output(&resultOut).Write([]byte("sha256:example\n")); err != nil {
+ t.Fatal(err)
+ }
+ if err := renderer.Finish("Import", Succeeded, " (2/2 layers)"); err != nil {
+ t.Fatal(err)
+ }
+ want := "Import · preparing\nImport · converting\nImport complete (2/2 layers)\n"
+ if progressOut.String() != want || strings.ContainsAny(progressOut.String(), "\r\x1b") {
+ t.Fatalf("plain progress = %q, want %q", progressOut.String(), want)
+ }
+ if resultOut.String() != "sha256:example\n" {
+ t.Fatalf("result output = %q", resultOut.String())
+ }
+}
+
+func TestAnimatedRendererUsesInjectedTicksAndJoins(t *testing.T) {
+ writer := &observedWriter{changed: make(chan struct{}, 1)}
+ ticker := newManualTicker()
+ renderer, err := newRenderer(t.Context(), writer, "Start · preparing", true, ticker.factory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(renderer.stopAnimation)
+ ticker.ticks <- time.Time{}
+ waitForWrites(t, writer, 2)
+ if err := renderer.Update("Start · launching"); err != nil {
+ t.Fatal(err)
+ }
+ var result bytes.Buffer
+ if _, err := renderer.Output(&result).Write([]byte("sandbox-id\n")); err != nil {
+ t.Fatal(err)
+ }
+ if err := renderer.Finish("Start", Succeeded, ""); err != nil {
+ t.Fatal(err)
+ }
+ waitForDone(t, renderer)
+ select {
+ case <-ticker.stopped:
+ default:
+ t.Fatal("animation ticker was not stopped")
+ }
+ out, _ := writer.snapshot()
+ if !strings.Contains(out, "⠋ Start · preparing") || !strings.Contains(out, "⠙ Start · preparing") {
+ t.Fatalf("spinner did not advance from injected tick: %q", out)
+ }
+ if !strings.HasSuffix(out, "\r\x1b[2K✓ Start complete\n") {
+ t.Fatalf("final terminal line = %q", out)
+ }
+ if result.String() != "sandbox-id\n" {
+ t.Fatalf("result output = %q", result.String())
+ }
+}
+
+func TestAnimatedRendererStopsOnCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(t.Context())
+ writer := &observedWriter{changed: make(chan struct{}, 1)}
+ ticker := newManualTicker()
+ renderer, err := newRenderer(ctx, writer, "Pull · preparing", true, ticker.factory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cancel()
+ waitForDone(t, renderer)
+ if err := renderer.Finish("Pull", Canceled, ""); err != nil {
+ t.Fatal(err)
+ }
+ out, _ := writer.snapshot()
+ if !strings.HasSuffix(out, "\r\x1b[2K✗ Pull canceled\n") {
+ t.Fatalf("canceled status = %q", out)
+ }
+}
+
+func TestAnimatedRendererRetainsWriteFailure(t *testing.T) {
+ failure := errors.New("terminal write failed")
+ writer := &observedWriter{changed: make(chan struct{}, 1), failAt: 2, failure: failure}
+ ticker := newManualTicker()
+ renderer, err := newRenderer(t.Context(), writer, "Verify · preparing", true, ticker.factory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ticker.ticks <- time.Time{}
+ waitForDone(t, renderer)
+ if !errors.Is(renderer.Err(), failure) {
+ t.Fatalf("retained error = %v", renderer.Err())
+ }
+ if err := renderer.Update("Verify · checking"); !errors.Is(err, failure) {
+ t.Fatalf("update error = %v", err)
+ }
+ if err := renderer.Finish("Verify", Failed, ""); !errors.Is(err, failure) {
+ t.Fatalf("finish error = %v", err)
+ }
+}
+
+func TestInitialWriteFailureDoesNotStartTicker(t *testing.T) {
+ failure := errors.New("initial write failed")
+ writer := &observedWriter{failAt: 1, failure: failure}
+ tickerStarted := false
+ _, err := newRenderer(t.Context(), writer, "Create · preparing", true, func(time.Duration) (<-chan time.Time, func()) {
+ tickerStarted = true
+ return make(chan time.Time), func() {}
+ })
+ if !errors.Is(err, failure) {
+ t.Fatalf("constructor error = %v", err)
+ }
+ if tickerStarted {
+ t.Fatal("ticker started after initial rendering failed")
+ }
+}
diff --git a/cli/root.go b/cli/root.go
new file mode 100644
index 0000000..cd07edd
--- /dev/null
+++ b/cli/root.go
@@ -0,0 +1,218 @@
+// Package cli builds the kumabox command tree and maps command failures to exit statuses.
+// Commands receive explicit streams and immutable configuration for independent invocations.
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+
+ "github.com/spf13/cobra"
+
+ doctorcmd "github.com/kumabox/kumabox/cli/doctor"
+ imagecmd "github.com/kumabox/kumabox/cli/image"
+ sandboxcmd "github.com/kumabox/kumabox/cli/sandbox"
+ snapshotcmd "github.com/kumabox/kumabox/cli/snapshot"
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/version"
+)
+
+// exitCoder preserves an explicit usage or subprocess status through error wrapping.
+type exitCoder interface {
+ // ExitCode is the process status to return for this failure.
+ ExitCode() int
+}
+
+// silentError identifies failures whose diagnostics were already written by a command.
+type silentError interface {
+ // Silent suppresses the entry point's additional diagnostic when true.
+ Silent() bool
+}
+
+// codedError attaches a CLI status while preserving the original error chain.
+type codedError struct {
+ // err is the original usage or command failure.
+ err error
+ // code is the status returned by the process entry point.
+ code int
+}
+
+// Error retains the diagnostic produced by the underlying failure.
+func (e *codedError) Error() string { return e.err.Error() }
+
+// Unwrap keeps errors.Is and errors.As available to callers.
+func (e *codedError) Unwrap() error { return e.err }
+
+// ExitCode supplies the CLI status attached during command execution.
+func (e *codedError) ExitCode() int { return e.code }
+
+// exitUsage distinguishes malformed invocations from domain operation failures.
+const exitUsage = 2
+
+// Execute runs one CLI invocation using the supplied context and output streams.
+// It returns errors without printing them; the process entry point prints diagnostics
+// unless Silent reports that a command already handled them.
+func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) error {
+ root, err := newRootCommand()
+ if err != nil {
+ return err
+ }
+ root.SetArgs(args)
+ root.SetOut(stdout)
+ root.SetErr(stderr)
+
+ // Resolve unknown commands before execution so Cobra usage errors keep exit 2.
+ if _, _, err := root.Find(args); err != nil {
+ return &codedError{err: err, code: exitUsage}
+ }
+ err = root.ExecuteContext(ctx)
+ if err == nil {
+ return nil
+ }
+ var coded exitCoder
+ if errors.As(err, &coded) {
+ return err
+ }
+ return &codedError{err: err, code: errorExitCode(err)}
+}
+
+// ExitCode returns the process exit status represented by err.
+func ExitCode(err error) int {
+ if err == nil {
+ return 0
+ }
+ var coded exitCoder
+ if errors.As(err, &coded) {
+ return coded.ExitCode()
+ }
+ return 1
+}
+
+// Silent reports whether the command already wrote its diagnostic output.
+func Silent(err error) bool {
+ var silent silentError
+ return errors.As(err, &silent) && silent.Silent()
+}
+
+// newRootCommand creates invocation-local flags, configuration loading, and
+// command modules. The provider observes the snapshot resolved after flag parsing.
+func newRootCommand() (*cobra.Command, error) {
+ loader := config.NewLoader()
+ configuration := config.Default()
+ configFile := ""
+ root := &cobra.Command{
+ Use: "kumabox",
+ Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, _ []string) error { return command.Help() },
+ Short: "microVM sandboxes for AI agents",
+ SilenceUsage: true,
+ SilenceErrors: true,
+ PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
+ resolved, err := loader.Load(configFile)
+ if err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ configuration = resolved
+ return nil
+ },
+ }
+ root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
+ return &codedError{err: err, code: exitUsage}
+ })
+ flags := root.PersistentFlags()
+ flags.StringVar(&configFile, "config", "", "explicit configuration file")
+ flags.String("root-dir", configuration.Paths.Data, "persistent data directory")
+ flags.String("run-dir", configuration.Paths.Run, "runtime state directory")
+ flags.String("log-dir", configuration.Paths.Log, "log directory")
+ flags.String("cni-conf-dir", configuration.Network.CNI.ConfDir, "CNI .conflist directory")
+ flags.String("cni-bin-dir", configuration.Network.CNI.BinDir, "CNI plugin binary directory")
+ flags.String("dns", configuration.Network.DNS, "comma-separated guest DNS servers")
+ for key, name := range map[string]string{
+ "paths.data": "root-dir", "paths.run": "run-dir", "paths.log": "log-dir",
+ "network.cni.conf_dir": "cni-conf-dir", "network.cni.bin_dir": "cni-bin-dir", "network.dns": "dns",
+ } {
+ if err := loader.BindFlag(key, flags.Lookup(name)); err != nil {
+ return nil, fmt.Errorf("bind --%s: %w", name, err)
+ }
+ }
+ provideConfig := func() config.Config { return configuration }
+
+ root.AddCommand(doctorcmd.NewCommand())
+ root.AddCommand(imagecmd.NewCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewConsoleCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewCreateCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewExecCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewInspectCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewListCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewLogsCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewRemoveCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewRestoreCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewRunCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewStartCommand(provideConfig))
+ root.AddCommand(sandboxcmd.NewStopCommand(provideConfig))
+ root.AddCommand(snapshotcmd.NewCommand(provideConfig))
+ root.AddCommand(newVersionCommand())
+ classifyArguments(root)
+ return root, nil
+}
+
+// usageArgs classifies positional validation failures as usage errors.
+func usageArgs(validate cobra.PositionalArgs) cobra.PositionalArgs {
+ return func(command *cobra.Command, args []string) error {
+ if err := validate(command, args); err != nil {
+ return &codedError{err: err, code: exitUsage}
+ }
+ return nil
+ }
+}
+
+// classifyArguments applies usage classification throughout the registered command tree.
+func classifyArguments(command *cobra.Command) {
+ if command.Args != nil {
+ command.Args = usageArgs(command.Args)
+ }
+ for _, child := range command.Commands() {
+ classifyArguments(child)
+ }
+}
+
+// errorExitCode groups domain error codes into the CLI's documented failure statuses.
+func errorExitCode(err error) int {
+ code, ok := errdefs.CodeOf(err)
+ if !ok {
+ return 1
+ }
+ switch code {
+ case errdefs.CodeNotFound:
+ return 3
+ case errdefs.CodeNameTaken, errdefs.CodeStateConflict, errdefs.CodeReferenced:
+ return 4
+ case errdefs.CodeInvalidArgument, errdefs.CodeHostIncompatible, errdefs.CodeImageIncompatible, errdefs.CodeDigestMismatch, errdefs.CodeArtifactCorrupt:
+ return 5
+ case errdefs.CodeArtifactUnavailable, errdefs.CodeStoreBusy:
+ return 6
+ default:
+ return 1
+ }
+}
+
+// newVersionCommand exposes build metadata as human-readable text or JSON.
+func newVersionCommand() *cobra.Command {
+ var asJSON bool
+ command := &cobra.Command{
+ Use: "version",
+ Short: "print the version",
+ Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, _ []string) error {
+ if asJSON {
+ return version.WriteJSON(command.OutOrStdout())
+ }
+ _, err := fmt.Fprintln(command.OutOrStdout(), version.String())
+ return err
+ },
+ }
+ command.Flags().BoolVar(&asJSON, "json", false, "print the version as JSON")
+ return command
+}
diff --git a/cli/root_test.go b/cli/root_test.go
new file mode 100644
index 0000000..50a967a
--- /dev/null
+++ b/cli/root_test.go
@@ -0,0 +1,204 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+func TestDoctorForwardsArgumentsAndExitCode(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("test helper is a shell script")
+ }
+
+ dir := t.TempDir()
+ checker := filepath.Join(dir, "kumabox-check")
+ contents := []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 1\n")
+ if err := os.WriteFile(checker, contents, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ var stdout bytes.Buffer
+ err := Execute(context.Background(), []string{
+ "doctor", "--fix", "--upgrade", "--subnet=10.89.0.0/16",
+ }, &stdout, &bytes.Buffer{})
+ if got := ExitCode(err); got != 1 {
+ t.Fatalf("ExitCode() = %d, want 1; err = %v", got, err)
+ }
+ if !Silent(err) {
+ t.Fatal("doctor process error must be silent")
+ }
+ if got, want := stdout.String(), "--fix\n--upgrade\n--subnet=10.89.0.0/16\n"; got != want {
+ t.Fatalf("forwarded arguments = %q, want %q", got, want)
+ }
+}
+
+func TestVersion(t *testing.T) {
+ var stdout bytes.Buffer
+ err := Execute(context.Background(), []string{"version"}, &stdout, &bytes.Buffer{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(stdout.String(), "kumabox ") {
+ t.Fatalf("version output = %q", stdout.String())
+ }
+}
+
+func TestExecuteConfigurationIsInvocationLocal(t *testing.T) {
+ base := t.TempDir()
+ for _, name := range []string{"first", "second"} {
+ dataRoot := filepath.Join(base, name, "data")
+ configFile := filepath.Join(base, name+".yaml")
+ contents := []byte("paths:\n data: " + dataRoot + "\n run: " + filepath.Join(base, name, "run") + "\n log: " + filepath.Join(base, name, "log") + "\n")
+ if err := os.WriteFile(configFile, contents, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var stdout bytes.Buffer
+ if err := Execute(t.Context(), []string{"--config", configFile, "image", "ls", "--json"}, &stdout, &bytes.Buffer{}); err != nil {
+ t.Fatal(err)
+ }
+ if stdout.String() != "[]\n" {
+ t.Fatalf("%s output = %q", name, stdout.String())
+ }
+ if _, err := os.Stat(filepath.Join(dataRoot, "meta", "meta.db")); err != nil {
+ t.Fatalf("%s invocation did not use its config: %v", name, err)
+ }
+ }
+}
+
+func TestExecutePassesConfigurationToImageAdapters(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("test converter is a POSIX shell script")
+ }
+ base := t.TempDir()
+ binary := filepath.Join(base, "configured-erofs")
+ marker := filepath.Join(base, "converter-used")
+ t.Setenv("TEST_EROFS_MARKER", marker)
+ script := "#!/bin/sh\nif [ \"$1\" = --version ]; then : > \"$TEST_EROFS_MARKER\"; printf 'mkfs.erofs 1.8.10\\n'; exit 0; fi\nfor output do :; done\n/bin/cat > \"$output\"\n"
+ if err := os.WriteFile(binary, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ configFile := filepath.Join(base, "config.yaml")
+ contents := []byte("paths:\n data: " + filepath.Join(base, "data") + "\n run: " + filepath.Join(base, "run") + "\n log: " + filepath.Join(base, "log") + "\nimages:\n erofs_binary: " + binary + "\n parallelism: 1\n")
+ if err := os.WriteFile(configFile, contents, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := Execute(t.Context(), []string{
+ "--config", configFile, "image", "import", "configured", "../testdata/oci-layout", "--platform", "linux/amd64",
+ }, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(marker); err != nil {
+ t.Fatalf("configured converter was not invoked: %v", err)
+ }
+}
+
+func TestInvalidConfigurationUsesDomainExitCode(t *testing.T) {
+ configFile := filepath.Join(t.TempDir(), "invalid.yaml")
+ if err := os.WriteFile(configFile, []byte("images:\n parallelism: 0\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ err := Execute(t.Context(), []string{"--config", configFile, "version"}, &bytes.Buffer{}, &bytes.Buffer{})
+ if got := ExitCode(err); got != 5 {
+ t.Fatalf("exit = %d, want 5; error = %v", got, err)
+ }
+}
+
+func TestNetworkFlagsAreBoundIntoConfiguration(t *testing.T) {
+ for _, args := range [][]string{
+ {"--cni-conf-dir", "relative", "version"},
+ {"--cni-bin-dir", "relative", "version"},
+ {"--dns", "not-an-ip", "version"},
+ } {
+ err := Execute(t.Context(), args, &bytes.Buffer{}, &bytes.Buffer{})
+ if got := ExitCode(err); got != 5 {
+ t.Fatalf("Execute(%v) exit = %d, want 5; error = %v", args, got, err)
+ }
+ }
+}
+
+func TestImageAndUsageExitCodes(t *testing.T) {
+ base := t.TempDir()
+ flags := []string{"--root-dir", filepath.Join(base, "data"), "--run-dir", filepath.Join(base, "run"), "--log-dir", filepath.Join(base, "log")}
+ for _, test := range []struct {
+ name string
+ args []string
+ code int
+ }{
+ {"unknown command", []string{"unknown"}, 2},
+ {"unknown image command", []string{"image", "unknown"}, 2},
+ {"missing image argument", []string{"image", "inspect"}, 2},
+ {"missing create image", []string{"create", "--name", "box"}, 2},
+ {"missing console sandbox", []string{"console"}, 2},
+ {"missing exec command", []string{"exec", "box"}, 2},
+ {"missing inspect sandbox", []string{"inspect"}, 2},
+ {"missing logs sandbox", []string{"logs"}, 2},
+ {"missing remove sandbox", []string{"rm"}, 2},
+ {"missing restore references", []string{"restore", "box"}, 2},
+ {"missing run image", []string{"run", "--name", "box"}, 2},
+ {"missing start sandbox", []string{"start"}, 2},
+ {"missing stop sandbox", []string{"stop"}, 2},
+ {"missing snapshot save sandbox", []string{"snapshot", "save"}, 2},
+ {"missing snapshot inspect reference", []string{"snapshot", "inspect"}, 2},
+ {"missing snapshot remove reference", []string{"snapshot", "rm"}, 2},
+ {"unexpected snapshot list argument", []string{"snapshot", "ls", "extra"}, 2},
+ {"unexpected ps argument", []string{"ps", "box"}, 2},
+ {"unsupported inspect flag", []string{"inspect", "box", "--json"}, 2},
+ {"unknown flag", []string{"image", "ls", "--wrong"}, 2},
+ {"unsupported platform", []string{"image", "pull", "example.com/image", "--platform", "windows/amd64"}, 5},
+ {"incompatible ps output", []string{"ps", "--json", "--quiet"}, 5},
+ {"missing image", []string{"image", "inspect", "missing"}, 3},
+ {"missing inspected sandbox", []string{"inspect", "missing"}, 3},
+ {"missing logged sandbox", []string{"logs", "missing"}, 3},
+ {"missing sandbox", []string{"rm", "missing"}, 3},
+ {"empty list", []string{"image", "ls", "--json"}, 0},
+ {"empty ps", []string{"ps", "--all", "--json"}, 0},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ args := append(append([]string(nil), flags...), test.args...)
+ err := Execute(t.Context(), args, &bytes.Buffer{}, &bytes.Buffer{})
+ if got := ExitCode(err); got != test.code {
+ t.Fatalf("exit = %d, want %d, error %v", got, test.code, err)
+ }
+ })
+ }
+}
+
+func TestDomainErrorExitCodes(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ code errdefs.Code
+ want int
+ }{
+ {"not found", errdefs.CodeNotFound, 3},
+ {"name taken", errdefs.CodeNameTaken, 4},
+ {"state conflict", errdefs.CodeStateConflict, 4},
+ {"referenced", errdefs.CodeReferenced, 4},
+ {"invalid argument", errdefs.CodeInvalidArgument, 5},
+ {"host incompatible", errdefs.CodeHostIncompatible, 5},
+ {"image incompatible", errdefs.CodeImageIncompatible, 5},
+ {"digest mismatch", errdefs.CodeDigestMismatch, 5},
+ {"artifact corrupt", errdefs.CodeArtifactCorrupt, 5},
+ {"artifact unavailable", errdefs.CodeArtifactUnavailable, 6},
+ {"store busy", errdefs.CodeStoreBusy, 6},
+ {"internal", errdefs.CodeInternal, 1},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ err := errdefs.Context(
+ errdefs.New(errdefs.ClassInternal, test.code, errors.New("failure")),
+ "operation", "entity", "phase", "action", false,
+ )
+ if got := errorExitCode(err); got != test.want {
+ t.Fatalf("errorExitCode(%q) = %d, want %d", test.code, got, test.want)
+ }
+ })
+ }
+}
diff --git a/cli/sandbox/console.go b/cli/sandbox/console.go
new file mode 100644
index 0000000..66ca06b
--- /dev/null
+++ b/cli/sandbox/console.go
@@ -0,0 +1,187 @@
+package sandbox
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/moby/term"
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+const defaultConsoleEscape = "^]"
+
+// NewConsoleCommand builds the interactive direct-boot console command.
+func NewConsoleCommand(configuration configProvider) *cobra.Command {
+ escapeText := defaultConsoleEscape
+ command := &cobra.Command{
+ Use: "console SANDBOX",
+ Short: "attach to a running sandbox console",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ escape, err := parseEscapeChar(escapeText)
+ if err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("--escape-char: %w", err))
+ }
+ input, ok := command.InOrStdin().(*os.File)
+ if !ok || !term.IsTerminal(input.Fd()) {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("console stdin must be a terminal"))
+ }
+
+ reference := args[0]
+ service, err := core.OpenSandbox(command.Context(), configuration(), nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(service.Close(), "console sandbox", reference, "close metadata", "retry the console connection", false))
+ }()
+ connection, err := service.Console(command.Context(), reference)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if connection != nil {
+ returnErr = errors.Join(returnErr, connection.Close())
+ }
+ }()
+
+ state, err := term.SetRawTerminal(input.Fd())
+ if err != nil {
+ return fmt.Errorf("set console terminal raw mode: %w", err)
+ }
+ defer func() {
+ restoreErr := term.RestoreTerminal(input.Fd(), state)
+ _, reportErr := fmt.Fprintf(command.ErrOrStderr(), "\r\nDisconnected from %s.\r\n", reference)
+ returnErr = errors.Join(returnErr, restoreErr, reportErr)
+ }()
+
+ if _, err := fmt.Fprintf(command.ErrOrStderr(), "Connected to %s (escape sequence: %s.).\r\n", reference, formatEscapeChar(escape)); err != nil {
+ return err
+ }
+ if remote, ok := connection.(*os.File); ok {
+ stopResize := relayConsoleResize(input.Fd(), remote.Fd())
+ defer stopResize()
+ }
+ err = relayConsole(command.Context(), connection, input, command.OutOrStdout(), []byte{escape, '.'})
+ connection = nil // relayConsole closes the connection on every return path.
+ return err
+ },
+ }
+ command.Flags().StringVar(&escapeText, "escape-char", defaultConsoleEscape, "detach escape character (single byte or ^X notation; press it then .)")
+ return command
+}
+
+// relayConsole copies both directions until the remote closes, the caller is
+// canceled, or the local escape sequence detaches. It never closes stdin.
+func relayConsole(ctx context.Context, remote io.ReadWriteCloser, input io.Reader, output io.Writer, escape []byte) error {
+ errorsOut := make(chan error, 2)
+ done := make(chan struct{})
+ go func() {
+ _, err := io.Copy(output, remote)
+ errorsOut <- err
+ }()
+ go func() {
+ reader := input
+ if len(escape) != 0 {
+ reader = term.NewEscapeProxy(input, escape)
+ }
+ _, err := io.Copy(remote, reader)
+ errorsOut <- err
+ }()
+ go func() {
+ select {
+ case <-ctx.Done():
+ _ = remote.Close()
+ case <-done:
+ }
+ }()
+
+ err := <-errorsOut
+ close(done)
+ closeErr := remote.Close()
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return errors.Join(ctxErr, closeErr)
+ }
+ if cleanConsoleExit(err) {
+ return nil
+ }
+ return errors.Join(err, closeErr)
+}
+
+// relayConsoleResize copies the local terminal size initially and on SIGWINCH.
+func relayConsoleResize(localFD, remoteFD uintptr) func() {
+ syncSize := func() {
+ if size, err := term.GetWinsize(localFD); err == nil {
+ _ = term.SetWinsize(remoteFD, size)
+ }
+ }
+ syncSize()
+
+ resize := make(chan os.Signal, 1)
+ done := make(chan struct{})
+ signal.Notify(resize, syscall.SIGWINCH)
+ go func() {
+ for {
+ select {
+ case <-resize:
+ syncSize()
+ case <-done:
+ return
+ }
+ }
+ }()
+ return func() {
+ signal.Stop(resize)
+ close(done)
+ }
+}
+
+// parseEscapeChar accepts caret notation or one ASCII byte.
+func parseEscapeChar(value string) (byte, error) {
+ if len(value) == 2 && value[0] == '^' {
+ char := value[1]
+ switch {
+ case char >= '@' && char <= '_':
+ return validateEscapeChar(char - '@')
+ case char >= 'a' && char <= 'z':
+ return validateEscapeChar(char - 'a' + 1)
+ default:
+ return 0, fmt.Errorf("invalid caret notation %q; use ^A through ^_", value)
+ }
+ }
+ if len(value) != 1 {
+ return 0, fmt.Errorf("expected one byte or ^X notation, got %q", value)
+ }
+ return validateEscapeChar(value[0])
+}
+
+func validateEscapeChar(value byte) (byte, error) {
+ if value == 0 || value == '\r' || value == '\n' || value == 0x7f || value >= 0x80 {
+ return 0, fmt.Errorf("byte 0x%02x cannot be used as an escape character", value)
+ }
+ return value, nil
+}
+
+func formatEscapeChar(value byte) string {
+ if value >= 1 && value <= 0x1f {
+ return "^" + string(rune(value+'@'))
+ }
+ return string(value)
+}
+
+func cleanConsoleExit(err error) bool {
+ if err == nil || errors.Is(err, io.EOF) || errors.Is(err, syscall.EIO) || errors.Is(err, os.ErrClosed) || errors.Is(err, net.ErrClosed) {
+ return true
+ }
+ var escaped term.EscapeError
+ return errors.As(err, &escaped)
+}
diff --git a/cli/sandbox/console_test.go b/cli/sandbox/console_test.go
new file mode 100644
index 0000000..fce0a4b
--- /dev/null
+++ b/cli/sandbox/console_test.go
@@ -0,0 +1,76 @@
+package sandbox
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net"
+ "testing"
+ "time"
+)
+
+func TestConsoleEscapeCharacters(t *testing.T) {
+ for _, test := range []struct {
+ input string
+ want byte
+ }{
+ {input: "^]", want: 0x1d},
+ {input: "^a", want: 0x01},
+ {input: "x", want: 'x'},
+ } {
+ got, err := parseEscapeChar(test.input)
+ if err != nil {
+ t.Fatalf("parseEscapeChar(%q): %v", test.input, err)
+ }
+ if got != test.want || formatEscapeChar(got) == "" {
+ t.Fatalf("parseEscapeChar(%q) = %#x, want %#x", test.input, got, test.want)
+ }
+ }
+ for _, invalid := range []string{"", "ab", "^?", "\n", string([]byte{0x80})} {
+ if _, err := parseEscapeChar(invalid); err == nil {
+ t.Fatalf("accepted escape character %q", invalid)
+ }
+ }
+}
+
+func TestRelayConsoleDetachesWithoutForwardingEscapeSequence(t *testing.T) {
+ client, server := net.Pipe()
+ t.Cleanup(func() { _ = server.Close() })
+ received := make(chan string, 1)
+ go func() {
+ data := make([]byte, 5)
+ _, err := io.ReadFull(server, data)
+ if err != nil {
+ received <- "error: " + err.Error()
+ return
+ }
+ received <- string(data)
+ }()
+
+ input := bytes.NewReader([]byte{'h', 'e', 'l', 'l', 'o', 0x1d, '.'})
+ if err := relayConsole(t.Context(), client, input, io.Discard, []byte{0x1d, '.'}); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case got := <-received:
+ if got != "hello" {
+ t.Fatalf("remote received %q", got)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("remote did not receive console input")
+ }
+}
+
+func TestRelayConsoleCancellationClosesRemote(t *testing.T) {
+ client, server := net.Pipe()
+ t.Cleanup(func() { _ = server.Close() })
+ input, writer := io.Pipe()
+ t.Cleanup(func() { _ = input.Close() })
+ t.Cleanup(func() { _ = writer.Close() })
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ if err := relayConsole(ctx, client, input, io.Discard, nil); !errors.Is(err, context.Canceled) {
+ t.Fatalf("relay cancellation error = %v", err)
+ }
+}
diff --git a/cli/sandbox/create.go b/cli/sandbox/create.go
new file mode 100644
index 0000000..2fc46a8
--- /dev/null
+++ b/cli/sandbox/create.go
@@ -0,0 +1,156 @@
+// Package sandbox exposes sandbox lifecycle commands through Cobra.
+// It owns argument parsing and terminal presentation while core owns application
+// ordering and assembles concrete adapters.
+package sandbox
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "strconv"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+// configProvider reads immutable configuration only after Cobra parses flags.
+type configProvider func() config.Config
+
+// createOptions contains the resource flags shared by create and run. Keeping
+// parsing here gives both commands one validation contract and one set of
+// defaults.
+type createOptions struct {
+ name string
+ cpus uint32
+ memory string
+ storageSize string
+ nics int
+ networkName string
+}
+
+// defaultCreateOptions returns the public resource defaults for a new sandbox.
+func defaultCreateOptions() createOptions {
+ return createOptions{
+ cpus: types.DefaultSandboxCPUs, memory: "1GiB", storageSize: "10GiB", nics: 1,
+ }
+}
+
+// addFlags registers the resource shape accepted by sandbox creation commands.
+func (o *createOptions) addFlags(command *cobra.Command) {
+ command.Flags().StringVar(&o.name, "name", o.name, "required sandbox name")
+ command.Flags().Uint32Var(&o.cpus, "cpus", o.cpus, "number of virtual CPUs")
+ command.Flags().StringVar(&o.memory, "memory", o.memory, "guest memory (for example 1GiB)")
+ command.Flags().StringVar(&o.storageSize, "storage", o.storageSize, "logical sparse COW size (minimum 10GiB)")
+ command.Flags().IntVar(&o.nics, "nics", o.nics, "number of network interfaces (0 disables networking)")
+ command.Flags().StringVar(&o.networkName, "network", o.networkName, "CNI network name (empty selects the default)")
+}
+
+// request validates CLI values before any persistent service is opened.
+func (o createOptions) request(imageReference string) (core.CreateSandboxRequest, error) {
+ if o.cpus == 0 || o.cpus > types.MaxSandboxCPUs {
+ return core.CreateSandboxRequest{}, invalidFlag("cpus", fmt.Errorf("must be between 1 and %d", types.MaxSandboxCPUs))
+ }
+ memoryBytes, err := parseBytes(o.memory)
+ if err != nil {
+ return core.CreateSandboxRequest{}, invalidFlag("memory", err)
+ }
+ if memoryBytes < types.MinSandboxMemory {
+ return core.CreateSandboxRequest{}, invalidFlag("memory", fmt.Errorf("must be at least %d bytes", types.MinSandboxMemory))
+ }
+ storageBytes, err := parseBytes(o.storageSize)
+ if err != nil {
+ return core.CreateSandboxRequest{}, invalidFlag("storage", err)
+ }
+ if storageBytes < types.MinSandboxStorage {
+ return core.CreateSandboxRequest{}, invalidFlag("storage", fmt.Errorf("must be at least %d bytes", types.MinSandboxStorage))
+ }
+ if o.nics < 0 || o.nics > types.MaxSandboxNICs {
+ return core.CreateSandboxRequest{}, invalidFlag("nics", fmt.Errorf("must be between 0 and %d", types.MaxSandboxNICs))
+ }
+ if o.nics == 0 && o.networkName != "" {
+ return core.CreateSandboxRequest{}, invalidFlag("network", errors.New("requires at least one NIC"))
+ }
+ sandboxConfig := types.SandboxConfig{
+ Name: o.name, CPUs: o.cpus, Memory: memoryBytes, Storage: storageBytes,
+ NICs: o.nics, NetworkName: o.networkName,
+ }
+ if err := sandboxConfig.Validate(); err != nil {
+ return core.CreateSandboxRequest{}, err
+ }
+ return core.CreateSandboxRequest{ImageReference: imageReference, Config: sandboxConfig}, nil
+}
+
+// NewCreateCommand builds the top-level create command.
+func NewCreateCommand(configuration configProvider) *cobra.Command {
+ options := defaultCreateOptions()
+ asJSON := false
+ command := &cobra.Command{
+ Use: "create IMAGE",
+ Short: "create a sandbox without starting it",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ request, err := options.request(args[0])
+ if err != nil {
+ return err
+ }
+ progress, err := startCreateProgress(command, options.name)
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+ service, err := core.OpenSandbox(command.Context(), configuration(), progress)
+ if err != nil {
+ return err
+ }
+ committed := false
+ defer func() {
+ closeErr := service.Close()
+ returnErr = errors.Join(returnErr, errdefs.Context(closeErr, "create sandbox", options.name, "close metadata", "inspect the sandbox before retrying", committed))
+ }()
+ record, err := service.Create(command.Context(), request)
+ if err != nil {
+ return err
+ }
+ committed = true
+ if err := writeSandboxResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil {
+ return errdefs.Context(err, "create sandbox", options.name, "output", "sandbox was created; inspect it before retrying", true)
+ }
+ return nil
+ },
+ }
+ options.addFlags(command)
+ command.Flags().BoolVar(&asJSON, "json", false, "print the created sandbox as indented JSON")
+ return command
+}
+
+// parseBytes accepts integer bytes or binary IEC units without floating-point rounding.
+func parseBytes(value string) (int64, error) {
+ if value == "" {
+ return 0, errors.New("size must not be empty")
+ }
+ digits := 0
+ for digits < len(value) && value[digits] >= '0' && value[digits] <= '9' {
+ digits++
+ }
+ if digits == 0 {
+ return 0, fmt.Errorf("invalid size %q", value)
+ }
+ number, err := strconv.ParseInt(value[:digits], 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid size %q: %w", value, err)
+ }
+ multiplier, ok := map[string]int64{"": 1, "B": 1, "KiB": 1 << 10, "MiB": 1 << 20, "GiB": 1 << 30, "TiB": 1 << 40}[value[digits:]]
+ if !ok || number == 0 || number > math.MaxInt64/multiplier {
+ return 0, fmt.Errorf("invalid or overflowing size %q; use B, KiB, MiB, GiB, or TiB", value)
+ }
+ return number * multiplier, nil
+}
+
+// invalidFlag attaches user-correctable classification to size parsing errors.
+func invalidFlag(name string, cause error) error {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("--%s: %w", name, cause))
+}
diff --git a/cli/sandbox/create_test.go b/cli/sandbox/create_test.go
new file mode 100644
index 0000000..489af0d
--- /dev/null
+++ b/cli/sandbox/create_test.go
@@ -0,0 +1,310 @@
+package sandbox
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ sandboxfs "github.com/kumabox/kumabox/sandbox"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestParseBytes(t *testing.T) {
+ for _, test := range []struct {
+ input string
+ want int64
+ }{
+ {"512MiB", 512 << 20},
+ {"10GiB", 10 << 30},
+ {"1024", 1024},
+ {"1TiB", 1 << 40},
+ } {
+ got, err := parseBytes(test.input)
+ if err != nil || got != test.want {
+ t.Fatalf("parseBytes(%q) = %d, %v; want %d", test.input, got, err, test.want)
+ }
+ }
+ for _, input := range []string{"", "-1GiB", "1GB", "1.5GiB", "0"} {
+ if _, err := parseBytes(input); err == nil {
+ t.Fatalf("parseBytes(%q) succeeded", input)
+ }
+ }
+}
+
+func TestCreateCommandMapsResourceValidationToFlags(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ flag string
+ }{
+ {name: "CPUs", args: []string{"demo", "--name", "box", "--cpus", "0"}, flag: "--cpus"},
+ {name: "memory", args: []string{"demo", "--name", "box", "--memory", "1MiB"}, flag: "--memory"},
+ {name: "storage", args: []string{"demo", "--name", "box", "--storage", "1GiB"}, flag: "--storage"},
+ {name: "NICs", args: []string{"demo", "--name", "box", "--nics", "-1"}, flag: "--nics"},
+ {name: "network without NIC", args: []string{"demo", "--name", "box", "--nics", "0", "--network", "bridge"}, flag: "--network"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ command := NewCreateCommand(func() config.Config {
+ t.Fatal("resource validation opened the sandbox service")
+ return config.Config{}
+ })
+ command.SetArgs(test.args)
+ err := command.ExecuteContext(t.Context())
+ if err == nil || !strings.Contains(err.Error(), test.flag) {
+ t.Fatalf("create error = %v, want flag %s", err, test.flag)
+ }
+ if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeInvalidArgument {
+ t.Fatalf("create error code = %q, %v", code, ok)
+ }
+ })
+ }
+}
+
+func TestWriteResultUsesFullIDAndIndentedJSON(t *testing.T) {
+ digest, err := types.ParseDigest("sha256:" + strings.Repeat("a", 64))
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := types.Sandbox{
+ ID: types.SandboxID("123e4567-e89b-42d3-a456-426614174000"),
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 2, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1, NetworkName: "bridge",
+ },
+ ImageDigest: digest, VMM: types.VMMCloudHypervisor, State: types.SandboxStateCreated, Generation: 2,
+ CreatedAt: time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 15, 10, 0, 1, 0, time.UTC),
+ Network: types.NetworkSetup{
+ Backend: types.NetworkBackendCNI, Namespace: "/var/run/netns/kumabox-test",
+ Interfaces: []types.NetworkInterface{{
+ Index: 0, Name: "eth0", TAP: "tap0", MAC: "02:00:00:00:00:01",
+ Queues: 4, QueueSize: 512, Network: "bridge",
+ IPv4: &types.IPv4Config{Address: "10.42.0.2", Gateway: "10.42.0.1", Prefix: 24},
+ }},
+ },
+ }
+ var text bytes.Buffer
+ if err := writeSandboxResult(&text, record, false); err != nil {
+ t.Fatal(err)
+ }
+ if text.String() != record.ID.String()+"\n" {
+ t.Fatalf("text result = %q", text.String())
+ }
+ var jsonOut bytes.Buffer
+ if err := writeSandboxResult(&jsonOut, record, true); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(jsonOut.String(), "\n \"id\":") || !strings.Contains(jsonOut.String(), "\"state\": \"created\"") || !strings.HasSuffix(jsonOut.String(), "\n") {
+ t.Fatalf("JSON result = %q", jsonOut.String())
+ }
+ var output sandboxOutput
+ if err := json.Unmarshal(jsonOut.Bytes(), &output); err != nil {
+ t.Fatal(err)
+ }
+ if output.NICs != 1 || output.NetworkName != "bridge" || output.Network == nil ||
+ len(output.Network.Interfaces) != 1 || output.Network.Interfaces[0].IPv4 == nil {
+ t.Fatalf("network JSON output = %+v", output)
+ }
+}
+
+func TestCreateCommandDefaultsToOneNIC(t *testing.T) {
+ command := NewCreateCommand(func() config.Config { return config.Config{} })
+ flag := command.Flags().Lookup("nics")
+ if flag == nil || flag.DefValue != "1" {
+ t.Fatalf("--nics default = %+v, want 1", flag)
+ }
+}
+
+func TestRunCommandUsesCreateResourceContract(t *testing.T) {
+ createCommand := NewCreateCommand(func() config.Config { return config.Config{} })
+ runCommand := NewRunCommand(func() config.Config { return config.Config{} })
+ for _, name := range []string{"name", "cpus", "memory", "storage", "nics", "network"} {
+ createFlag, runFlag := createCommand.Flags().Lookup(name), runCommand.Flags().Lookup(name)
+ if createFlag == nil || runFlag == nil {
+ t.Fatalf("shared flag --%s is missing", name)
+ }
+ if runFlag.DefValue != createFlag.DefValue {
+ t.Fatalf("run --%s default = %q, want create default %q", name, runFlag.DefValue, createFlag.DefValue)
+ }
+ }
+ if flag := runCommand.Flags().Lookup("json"); flag == nil {
+ t.Fatal("run --json is missing")
+ }
+}
+
+func TestRunCommandValidatesResourcesBeforeOpeningService(t *testing.T) {
+ command := NewRunCommand(func() config.Config {
+ t.Fatal("resource validation opened the sandbox service")
+ return config.Config{}
+ })
+ command.SetArgs([]string{"demo", "--name", "box", "--memory", "1MiB"})
+ err := command.ExecuteContext(t.Context())
+ if err == nil || !strings.Contains(err.Error(), "--memory") {
+ t.Fatalf("run error = %v, want --memory", err)
+ }
+ if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeInvalidArgument {
+ t.Fatalf("run error code = %q, %v", code, ok)
+ }
+}
+
+func TestCreateProgressReportsCommittedOutputFailure(t *testing.T) {
+ var stderr bytes.Buffer
+ progress, err := newTestProgress(&stderr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ progress.committed = true
+ failure := errors.New("stdout closed")
+ if err := progress.Finish(failure); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasSuffix(stderr.String(), "Create \"box\" committed with errors\n") {
+ t.Fatalf("progress = %q", stderr.String())
+ }
+}
+
+func TestCreateCommandPersistsCreatedSandboxAndFinalCOW(t *testing.T) {
+ base := t.TempDir()
+ roots := storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ }
+ seedImage(t, roots)
+ installFakeMKFS(t, base)
+ command := NewCreateCommand(func() config.Config { return sandboxTestConfig(roots) })
+ command.SetArgs([]string{"demo", "--name", "box", "--cpus", "1", "--nics", "0", "--json"})
+ var stdout, stderr bytes.Buffer
+ command.SetOut(&stdout)
+ command.SetErr(&stderr)
+ if err := command.ExecuteContext(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ var output sandboxOutput
+ if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
+ t.Fatalf("decode output %q: %v", stdout.String(), err)
+ }
+ if output.Name != "box" || output.State != "created" || output.Generation != 2 || output.ImageDigest == "" || output.UpdatedAt.IsZero() {
+ t.Fatalf("create output = %+v", output)
+ }
+ id, err := types.ParseSandboxID(output.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ paths, err := sandboxfs.NewPaths(roots)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cow, err := paths.COW(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info, err := os.Stat(cow); err != nil || info.Size() != types.DefaultSandboxStorage {
+ t.Fatalf("COW stat = %+v, %v", info, err)
+ }
+ if _, err := os.Stat(filepath.Join(roots.Data, "staging", "sandboxes")); !os.IsNotExist(err) {
+ t.Fatalf("sandbox staging directory exists: %v", err)
+ }
+ if !strings.HasSuffix(stderr.String(), "Create \"box\" complete\n") {
+ t.Fatalf("progress = %q", stderr.String())
+ }
+}
+
+func seedImage(t *testing.T, roots storage.Roots) {
+ t.Helper()
+ state, err := core.OpenImages(t.Context(), sandboxTestConfig(roots))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := state.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ source := digestOf(t, []byte("source"))
+ erofsData := []byte("erofs")
+ erofsDigest := digestOf(t, erofsData)
+ kernelData, initrdData := []byte("kernel"), []byte("initrd")
+ layer := types.Layer{
+ SourceDigest: source, EROFSDigest: erofsDigest, Size: int64(len(erofsData)),
+ BootFiles: []types.BootFile{
+ {Name: "vmlinuz", Digest: digestOf(t, kernelData), Size: int64(len(kernelData))},
+ {Name: "initrd.img", Digest: digestOf(t, initrdData), Size: int64(len(initrdData))},
+ },
+ }
+ if err := storage.EnsureDir(state.Paths.BootDir(source)); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(state.Paths.EROFS(source), erofsData, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ for index, file := range layer.BootFiles {
+ path, err := state.Paths.BootFile(source, file.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data := [][]byte{kernelData, initrdData}[index]
+ if err := os.WriteFile(path, data, 0o640); err != nil {
+ t.Fatal(err)
+ }
+ }
+ boot, err := images.SelectBoot([]types.Layer{layer})
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest := digestOf(t, []byte("manifest"))
+ if err := state.Catalog.CommitImport(t.Context(), images.ImportCommit{
+ Name: "demo", Manifest: types.Manifest{Digest: manifest, Platform: types.Platform{OS: "linux", Architecture: "amd64"}, Layers: []types.Descriptor{{Digest: source, Size: 6}}},
+ Layers: []types.Layer{layer}, Boot: boot, Size: layer.Size, Created: time.Now().UTC(),
+ }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// sandboxTestConfig returns production defaults scoped to one test directory.
+func sandboxTestConfig(roots storage.Roots) config.Config {
+ configuration := config.Default()
+ configuration.Paths = roots
+ return configuration
+}
+
+func digestOf(t *testing.T, data []byte) types.Digest {
+ t.Helper()
+ sum := sha256.Sum256(data)
+ digest, err := types.ParseDigest(fmt.Sprintf("sha256:%x", sum))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return digest
+}
+
+func installFakeMKFS(t *testing.T, base string) {
+ t.Helper()
+ binDir := filepath.Join(base, "bin")
+ if err := os.Mkdir(binDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+ formatter := filepath.Join(binDir, "mkfs.ext4")
+ script := []byte("#!/bin/sh\nfor last do :; done\nprintf '\\123\\357' | dd of=\"$last\" bs=1 seek=1080 conv=notrunc 2>/dev/null\n")
+ if err := os.WriteFile(formatter, script, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+}
+
+func newTestProgress(writer *bytes.Buffer) (*sandboxProgress, error) {
+ return newSandboxProgress(
+ context.Background(), writer, "create sandbox", `Create "box"`, "preparing sandbox", "inspect the sandbox state",
+ )
+}
diff --git a/cli/sandbox/exec.go b/cli/sandbox/exec.go
new file mode 100644
index 0000000..11ced39
--- /dev/null
+++ b/cli/sandbox/exec.go
@@ -0,0 +1,90 @@
+package sandbox
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+// commandExitError propagates a guest command's status without printing an
+// extra KumaBox diagnostic after the guest has already written its output.
+type commandExitError struct {
+ code int
+}
+
+func (e *commandExitError) Error() string {
+ return fmt.Sprintf("guest command exited with status %d", e.code)
+}
+func (e *commandExitError) ExitCode() int { return e.code }
+func (e *commandExitError) Silent() bool { return true }
+
+// NewExecCommand builds the streaming guest exec command.
+func NewExecCommand(configuration configProvider) *cobra.Command {
+ var environment []string
+ var interactive bool
+ command := &cobra.Command{
+ Use: "exec [flags] SANDBOX -- COMMAND [ARGS...]",
+ Short: "run a command inside a running sandbox",
+ Args: cobra.MinimumNArgs(2),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ environmentMap, err := parseEnvironment(environment)
+ if err != nil {
+ return invalidFlag("env", err)
+ }
+ invocation := types.Command{Args: append([]string(nil), args[1:]...), Env: environmentMap}
+ if err := invocation.Validate(); err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ service, err := core.OpenSandbox(command.Context(), configuration(), nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(service.Close(), "execute sandbox command", args[0], "close metadata", "retry the command", false))
+ }()
+ var input io.Reader
+ if interactive {
+ input = command.InOrStdin()
+ }
+ exitCode, err := service.Exec(command.Context(), args[0], invocation, input, command.OutOrStdout(), command.ErrOrStderr())
+ if err != nil {
+ return err
+ }
+ if exitCode != 0 {
+ return &commandExitError{code: exitCode}
+ }
+ return nil
+ },
+ }
+ command.Flags().StringArrayVarP(&environment, "env", "e", nil, "set a guest environment variable in KEY=VALUE form (repeatable)")
+ command.Flags().BoolVarP(&interactive, "interactive", "i", false, "attach stdin to the guest command")
+ return command
+}
+
+// parseEnvironment converts repeatable CLI values at the presentation
+// boundary. Later occurrences replace earlier ones, matching common CLI flag
+// behavior without leaking KEY=VALUE syntax into core or the guest client.
+func parseEnvironment(entries []string) (map[string]string, error) {
+ if len(entries) == 0 {
+ return nil, nil
+ }
+ environment := make(map[string]string, len(entries))
+ for _, entry := range entries {
+ key, value, ok := strings.Cut(entry, "=")
+ if !ok || key == "" {
+ return nil, fmt.Errorf("%q must be KEY=VALUE", entry)
+ }
+ if strings.IndexByte(entry, 0) >= 0 {
+ return nil, fmt.Errorf("%q must not contain NUL bytes", entry)
+ }
+ environment[key] = value
+ }
+ return environment, nil
+}
diff --git a/cli/sandbox/exec_test.go b/cli/sandbox/exec_test.go
new file mode 100644
index 0000000..747009d
--- /dev/null
+++ b/cli/sandbox/exec_test.go
@@ -0,0 +1,70 @@
+package sandbox
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+func TestParseEnvironment(t *testing.T) {
+ tests := []struct {
+ name string
+ entries []string
+ want map[string]string
+ wantErr string
+ }{
+ {name: "empty", entries: nil, want: nil},
+ {name: "values", entries: []string{"A=one", "EMPTY="}, want: map[string]string{"A": "one", "EMPTY": ""}},
+ {name: "value contains equals", entries: []string{"A=one=two"}, want: map[string]string{"A": "one=two"}},
+ {name: "duplicate uses last value", entries: []string{"A=one", "A=two"}, want: map[string]string{"A": "two"}},
+ {name: "missing separator", entries: []string{"A"}, wantErr: "must be KEY=VALUE"},
+ {name: "empty key", entries: []string{"=value"}, wantErr: "must be KEY=VALUE"},
+ {name: "NUL", entries: []string{"A=bad\x00value"}, wantErr: "must not contain NUL"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got, err := parseEnvironment(test.entries)
+ if test.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), test.wantErr) {
+ t.Fatalf("parseEnvironment(%q) error = %v, want %q", test.entries, err, test.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != len(test.want) {
+ t.Fatalf("environment = %#v, want %#v", got, test.want)
+ }
+ for key, value := range test.want {
+ if got[key] != value {
+ t.Fatalf("environment[%q] = %q, want %q", key, got[key], value)
+ }
+ }
+ })
+ }
+}
+
+func TestCommandExitErrorPreservesGuestStatus(t *testing.T) {
+ err := &commandExitError{code: 23}
+ if err.ExitCode() != 23 || !err.Silent() || !strings.Contains(err.Error(), "23") {
+ t.Fatalf("command exit error = %#v, %q", err, err.Error())
+ }
+}
+
+func TestExecCommandRejectsEnvironmentBeforeOpeningService(t *testing.T) {
+ command := NewExecCommand(func() config.Config {
+ t.Fatal("environment validation opened the sandbox service")
+ return config.Config{}
+ })
+ command.SetArgs([]string{"box", "--env", "BROKEN", "--", "env"})
+ err := command.ExecuteContext(t.Context())
+ if err == nil || !strings.Contains(err.Error(), "--env") {
+ t.Fatalf("exec error = %v, want --env context", err)
+ }
+ if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeInvalidArgument {
+ t.Fatalf("exec error code = %q, %v", code, ok)
+ }
+}
diff --git a/cli/sandbox/output.go b/cli/sandbox/output.go
new file mode 100644
index 0000000..8e71c55
--- /dev/null
+++ b/cli/sandbox/output.go
@@ -0,0 +1,226 @@
+package sandbox
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "text/tabwriter"
+ "time"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+// sandboxOutput is the stable JSON projection shared by sandbox commands.
+// It deliberately stays in the CLI package: types.Sandbox is the domain
+// contract, while this flattened shape is a user-facing serialization contract.
+type sandboxOutput struct {
+ // ID is the complete immutable sandbox UUID.
+ ID string `json:"id"`
+ // Name is the exact human-readable lookup key.
+ Name string `json:"name"`
+ // ImageDigest is the exact pinned manifest identity.
+ ImageDigest string `json:"image_digest"`
+ // VMM is the backend that owns this sandbox's runtime.
+ VMM string `json:"vmm"`
+ // State is the durable sandbox lifecycle state.
+ State string `json:"state"`
+ // CPUs is the requested virtual CPU count.
+ CPUs uint32 `json:"cpus"`
+ // Memory is requested guest memory in bytes.
+ Memory int64 `json:"memory"`
+ // Storage is the logical sparse COW size in bytes.
+ Storage int64 `json:"storage"`
+ // NICs is the requested network interface count.
+ NICs int `json:"nics"`
+ // NetworkName is the resolved CNI network name.
+ NetworkName string `json:"network_name,omitempty"`
+ // Network is the resolved provider-to-VMM handoff.
+ Network *networkOutput `json:"network,omitempty"`
+ // Generation fences stale lifecycle transitions.
+ Generation uint64 `json:"generation"`
+ // Failure explains retained cleanup work for an error-state sandbox.
+ Failure *sandboxFailureOutput `json:"failure,omitempty"`
+ // CreatedAt is the identity reservation time.
+ CreatedAt time.Time `json:"created_at"`
+ // UpdatedAt is the latest committed transition time.
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// sandboxFailureOutput is the user-facing diagnostic for an error-state sandbox.
+type sandboxFailureOutput struct {
+ // Phase identifies the lifecycle step that failed.
+ Phase string `json:"phase"`
+ // Message preserves the operator-facing failure detail.
+ Message string `json:"message"`
+}
+
+// networkOutput is the stable JSON projection of resolved sandbox networking.
+type networkOutput struct {
+ // Backend identifies the provider that owns host network resources.
+ Backend string `json:"backend"`
+ // Namespace is the absolute network namespace path.
+ Namespace string `json:"namespace"`
+ // Interfaces lists NICs in stable guest index order.
+ Interfaces []networkInterfaceOutput `json:"interfaces"`
+}
+
+// networkInterfaceOutput describes one guest NIC and its host TAP endpoint.
+type networkInterfaceOutput struct {
+ // Index is the stable zero-based guest NIC position.
+ Index int `json:"index"`
+ // Name is the guest interface name.
+ Name string `json:"name"`
+ // TAP is the host-side device opened by the VMM.
+ TAP string `json:"tap"`
+ // MAC is the durable guest hardware address.
+ MAC string `json:"mac"`
+ // Queues is the total virtio RX and TX queue count.
+ Queues int `json:"queues"`
+ // QueueSize is the descriptor count for each queue.
+ QueueSize int `json:"queue_size"`
+ // Network is the resolved CNI conflist name.
+ Network string `json:"network"`
+ // IPv4 is the optional guest-visible IPv4 assignment.
+ IPv4 *ipv4Output `json:"ipv4,omitempty"`
+}
+
+// ipv4Output is the stable JSON projection of a guest IPv4 assignment.
+type ipv4Output struct {
+ // Address is the guest IPv4 address without a prefix.
+ Address string `json:"address"`
+ // Gateway is the optional default gateway.
+ Gateway string `json:"gateway,omitempty"`
+ // Prefix is the CIDR prefix length.
+ Prefix int `json:"prefix"`
+}
+
+// removeOutput is the stable JSON result for a completed sandbox removal.
+type removeOutput struct {
+ // ID is the immutable identity whose resources were deleted.
+ ID string `json:"id"`
+ // Name is the released user-facing sandbox name.
+ Name string `json:"name"`
+}
+
+// sandboxResult projects a validated domain record into the CLI JSON schema.
+func sandboxResult(sandbox types.Sandbox) sandboxOutput {
+ result := sandboxOutput{
+ ID: sandbox.ID.String(), Name: sandbox.Config.Name, ImageDigest: sandbox.ImageDigest.String(), VMM: string(sandbox.VMM),
+ State: string(sandbox.State), CPUs: sandbox.Config.CPUs, Memory: sandbox.Config.Memory,
+ Storage: sandbox.Config.Storage, NICs: sandbox.Config.NICs, NetworkName: sandbox.Config.NetworkName,
+ Generation: sandbox.Generation,
+ CreatedAt: sandbox.CreatedAt.UTC(), UpdatedAt: sandbox.UpdatedAt.UTC(),
+ }
+ if sandbox.Network.Backend != "" {
+ result.Network = networkResult(sandbox.Network)
+ }
+ if sandbox.Failure != nil {
+ result.Failure = &sandboxFailureOutput{Phase: sandbox.Failure.Phase, Message: sandbox.Failure.Message}
+ }
+ return result
+}
+
+func networkResult(setup types.NetworkSetup) *networkOutput {
+ result := &networkOutput{
+ Backend: string(setup.Backend), Namespace: setup.Namespace,
+ Interfaces: make([]networkInterfaceOutput, 0, len(setup.Interfaces)),
+ }
+ for _, networkInterface := range setup.Interfaces {
+ item := networkInterfaceOutput{
+ Index: networkInterface.Index, Name: networkInterface.Name, TAP: networkInterface.TAP,
+ MAC: networkInterface.MAC, Queues: networkInterface.Queues, QueueSize: networkInterface.QueueSize,
+ Network: networkInterface.Network,
+ }
+ if networkInterface.IPv4 != nil {
+ item.IPv4 = &ipv4Output{
+ Address: networkInterface.IPv4.Address,
+ Gateway: networkInterface.IPv4.Gateway,
+ Prefix: networkInterface.IPv4.Prefix,
+ }
+ }
+ result.Interfaces = append(result.Interfaces, item)
+ }
+ return result
+}
+
+// writeSandboxJSON emits one complete sandbox as indented JSON.
+func writeSandboxJSON(writer io.Writer, sandbox types.Sandbox) error {
+ encoder := json.NewEncoder(writer)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(sandboxResult(sandbox))
+}
+
+// writeSandboxResult keeps lifecycle command output script-friendly and JSON complete.
+func writeSandboxResult(writer io.Writer, sandbox types.Sandbox, asJSON bool) error {
+ if !asJSON {
+ _, err := fmt.Fprintln(writer, sandbox.ID)
+ return err
+ }
+ return writeSandboxJSON(writer, sandbox)
+}
+
+// writeRemoveResult keeps text output script-friendly and JSON self-describing.
+func writeRemoveResult(writer io.Writer, sandbox types.Sandbox, asJSON bool) error {
+ if !asJSON {
+ _, err := fmt.Fprintln(writer, sandbox.ID)
+ return err
+ }
+ encoder := json.NewEncoder(writer)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(removeOutput{ID: sandbox.ID.String(), Name: sandbox.Config.Name})
+}
+
+// writeSandboxListJSON emits an array even when the metadata snapshot is empty.
+func writeSandboxListJSON(writer io.Writer, records []types.Sandbox) error {
+ results := make([]sandboxOutput, 0, len(records))
+ for _, record := range records {
+ results = append(results, sandboxResult(record))
+ }
+ encoder := json.NewEncoder(writer)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(results)
+}
+
+// writeSandboxIDs emits complete IDs so every line can be passed directly to rm.
+func writeSandboxIDs(writer io.Writer, records []types.Sandbox) error {
+ for _, record := range records {
+ if _, err := fmt.Fprintln(writer, record.ID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// writeSandboxTable renders headers for empty results and keeps IDs actionable.
+func writeSandboxTable(writer io.Writer, records []types.Sandbox) error {
+ table := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0)
+ if _, err := fmt.Fprintln(table, "SANDBOX ID\tNAME\tIMAGE ID\tVMM\tSTATE\tCPUS\tMEMORY\tSTORAGE\tNICS\tNETWORK\tCREATED"); err != nil {
+ return err
+ }
+ for _, record := range records {
+ if _, err := fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\t%d\t%s\t%s\n",
+ record.ID, record.Config.Name, record.ImageDigest.Hex()[:12], record.VMM, record.State, record.Config.CPUs,
+ formatIECBytes(record.Config.Memory), formatIECBytes(record.Config.Storage), record.Config.NICs,
+ record.Config.NetworkName,
+ record.CreatedAt.UTC().Format(time.RFC3339),
+ ); err != nil {
+ return err
+ }
+ }
+ return table.Flush()
+}
+
+// formatIECBytes renders binary resource sizes without losing their byte facts in JSON.
+func formatIECBytes(size int64) string {
+ if size < 1024 {
+ return fmt.Sprintf("%dB", size)
+ }
+ value := float64(size)
+ for _, unit := range []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} {
+ value /= 1024
+ if value < 1024 || unit == "EiB" {
+ return fmt.Sprintf("%.1f%s", value, unit)
+ }
+ }
+ return fmt.Sprintf("%dB", size)
+}
diff --git a/cli/sandbox/progress.go b/cli/sandbox/progress.go
new file mode 100644
index 0000000..d3a0504
--- /dev/null
+++ b/cli/sandbox/progress.go
@@ -0,0 +1,149 @@
+package sandbox
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+
+ "github.com/spf13/cobra"
+
+ cliprogress "github.com/kumabox/kumabox/cli/progress"
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+// sandboxProgress adapts SandboxService stages and commit events to the shared
+// renderer while retaining operation-specific recovery context.
+type sandboxProgress struct {
+ // mu protects stage and commit state across reporter and final callbacks.
+ mu sync.Mutex
+ // renderer owns terminal detection, serialization, animation, and shutdown.
+ renderer *cliprogress.Renderer
+ // operation supplies structured error context.
+ operation string
+ // label identifies the operation and quoted sandbox reference.
+ label string
+ // status is the current application workflow stage.
+ status string
+ // recovery describes how to inspect or retry a reporting failure.
+ recovery string
+ // committed records durable state changed before a later error.
+ committed bool
+}
+
+var _ core.SandboxReporter = (*sandboxProgress)(nil)
+
+// startCreateProgress starts progress for one create operation.
+func startCreateProgress(command *cobra.Command, name string) (*sandboxProgress, error) {
+ return startProgress(command, "create sandbox", fmt.Sprintf("Create %q", name), "preparing sandbox", "inspect the sandbox state")
+}
+
+// startRunProgress starts progress for one create-and-launch operation.
+func startRunProgress(command *cobra.Command, name string) (*sandboxProgress, error) {
+ return startProgress(command, "run sandbox", fmt.Sprintf("Run %q", name), "preparing sandbox", "inspect the sandbox state and VMM log")
+}
+
+// startRemoveProgress starts progress for one remove operation.
+func startRemoveProgress(command *cobra.Command, reference string) (*sandboxProgress, error) {
+ return startProgress(command, "remove sandbox", fmt.Sprintf("Remove %q", reference), "preparing removal", "retry removal or inspect retained state")
+}
+
+// startStartProgress starts progress for one VMM launch operation.
+func startStartProgress(command *cobra.Command, reference string) (*sandboxProgress, error) {
+ return startProgress(command, "start sandbox", fmt.Sprintf("Start %q", reference), "preparing start", "inspect the sandbox state and VMM log")
+}
+
+// startStopProgress starts progress for one controlled VMM termination.
+func startStopProgress(command *cobra.Command, reference string) (*sandboxProgress, error) {
+ return startProgress(command, "stop sandbox", fmt.Sprintf("Stop %q", reference), "preparing stop", "retry the stop or inspect the sandbox runtime")
+}
+
+// snapshotStatusProgress adapts snapshot-service status callbacks while using
+// the sandbox renderer for restore output and failure semantics.
+type snapshotStatusProgress struct{ *sandboxProgress }
+
+// Committed records a saved snapshot if a shared snapshot workflow emits one.
+func (p *snapshotStatusProgress) Committed(types.Snapshot) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.committed = true
+ return p.renderer.Err()
+}
+
+// startRestoreProgress starts progress for one native snapshot restore.
+func startRestoreProgress(command *cobra.Command, reference string) (*snapshotStatusProgress, error) {
+ progress, err := startProgress(command, "restore sandbox", fmt.Sprintf("Restore %q", reference), "preparing restore", "inspect the sandbox state and VMM log")
+ if err != nil {
+ return nil, err
+ }
+ return &snapshotStatusProgress{sandboxProgress: progress}, nil
+}
+
+func startProgress(command *cobra.Command, operation, label, status, recovery string) (*sandboxProgress, error) {
+ return newSandboxProgress(command.Context(), command.ErrOrStderr(), operation, label, status, recovery)
+}
+
+// newSandboxProgress builds the domain adapter and writes its initial status.
+func newSandboxProgress(ctx context.Context, writer io.Writer, operation, label, status, recovery string) (*sandboxProgress, error) {
+ renderer, err := cliprogress.New(ctx, writer, label+" · "+status)
+ if err != nil {
+ return nil, err
+ }
+ return &sandboxProgress{
+ renderer: renderer, operation: operation, label: label, status: status, recovery: recovery,
+ }, nil
+}
+
+// Status updates the current sandbox workflow stage.
+func (p *sandboxProgress) Status(status string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.status = status
+ return p.renderer.Update(p.label + " · " + status)
+}
+
+// Committed records a durable sandbox state change before cleanup completes.
+func (p *sandboxProgress) Committed(types.Sandbox) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.committed = true
+ p.status = "finishing"
+ if p.renderer.Animated() {
+ return p.renderer.Update(p.label + " · " + p.status)
+ }
+ return p.renderer.Err()
+}
+
+// Output coordinates command results with a live terminal frame.
+func (p *sandboxProgress) Output(writer io.Writer) io.Writer {
+ return p.renderer.Output(writer)
+}
+
+// Finish maps sandbox commit and cancellation facts to a generic final outcome.
+func (p *sandboxProgress) Finish(operationErr error) error {
+ p.mu.Lock()
+ var classified *errdefs.Error
+ if errors.As(operationErr, &classified) && classified.Committed {
+ p.committed = true
+ }
+ renderErr := p.renderer.Err()
+ outcome := cliprogress.Succeeded
+ if operationErr != nil || renderErr != nil {
+ switch {
+ case p.committed:
+ outcome = cliprogress.CommittedWithErrors
+ case errors.Is(operationErr, context.Canceled):
+ outcome = cliprogress.Canceled
+ default:
+ outcome = cliprogress.Failed
+ }
+ }
+ committed, operation, label, recovery := p.committed, p.operation, p.label, p.recovery
+ p.mu.Unlock()
+
+ reportErr := p.renderer.Finish(label, outcome, "")
+ return errdefs.Context(reportErr, operation, label, "report", recovery, committed)
+}
diff --git a/cli/sandbox/query.go b/cli/sandbox/query.go
new file mode 100644
index 0000000..a706d27
--- /dev/null
+++ b/cli/sandbox/query.go
@@ -0,0 +1,99 @@
+package sandbox
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+// NewInspectCommand builds the read-only detailed sandbox query. Inspect always
+// writes JSON so its complete output remains stable for people and scripts.
+func NewInspectCommand(configuration configProvider) *cobra.Command {
+ command := &cobra.Command{
+ Use: "inspect SANDBOX",
+ Short: "show detailed sandbox information as JSON",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ service, err := core.OpenSandbox(command.Context(), configuration(), nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(service.Close(), "inspect sandbox", args[0], "close metadata", "retry the query", false))
+ }()
+ record, err := service.Inspect(command.Context(), args[0])
+ if err != nil {
+ return err
+ }
+ return writeSandboxJSON(command.OutOrStdout(), record)
+ },
+ }
+ return command
+}
+
+// NewLogsCommand builds the persistent VMM log reader. Follow mode writes only
+// log bytes to stdout, leaving cancellation and diagnostics to the CLI shell.
+func NewLogsCommand(configuration configProvider) *cobra.Command {
+ var follow bool
+ var tail int
+ command := &cobra.Command{
+ Use: "logs [flags] SANDBOX",
+ Short: "show sandbox VMM logs",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ reference := args[0]
+ service, err := core.OpenSandbox(command.Context(), configuration(), nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(service.Close(), "read sandbox logs", reference, "close metadata", "retry the log stream", false))
+ }()
+ return service.Logs(command.Context(), reference, core.SandboxLogOptions{Tail: tail, Follow: follow}, command.OutOrStdout())
+ },
+ }
+ command.Flags().BoolVarP(&follow, "follow", "f", false, "follow appended log output")
+ command.Flags().IntVar(&tail, "tail", 0, "show only the last N lines (0 = all)")
+ return command
+}
+
+// NewListCommand builds the top-level Docker-style sandbox process listing.
+func NewListCommand(configuration configProvider) *cobra.Command {
+ var includeAll, asJSON, quiet bool
+ command := &cobra.Command{
+ Use: "ps",
+ Short: "list sandboxes",
+ Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, _ []string) (returnErr error) {
+ if asJSON && quiet {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("--json and --quiet cannot be used together"))
+ }
+ service, err := core.OpenSandbox(command.Context(), configuration(), nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(service.Close(), "list sandboxes", "", "close metadata", "retry the query", false))
+ }()
+ records, err := service.List(command.Context(), includeAll)
+ if err != nil {
+ return err
+ }
+ switch {
+ case asJSON:
+ return writeSandboxListJSON(command.OutOrStdout(), records)
+ case quiet:
+ return writeSandboxIDs(command.OutOrStdout(), records)
+ default:
+ return writeSandboxTable(command.OutOrStdout(), records)
+ }
+ },
+ }
+ command.Flags().BoolVarP(&includeAll, "all", "a", false, "show all sandboxes, including inactive states")
+ command.Flags().BoolVar(&asJSON, "json", false, "print sandboxes as indented JSON")
+ command.Flags().BoolVarP(&quiet, "quiet", "q", false, "print only full sandbox IDs")
+ return command
+}
diff --git a/cli/sandbox/query_test.go b/cli/sandbox/query_test.go
new file mode 100644
index 0000000..27273fa
--- /dev/null
+++ b/cli/sandbox/query_test.go
@@ -0,0 +1,268 @@
+package sandbox
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+func TestSandboxTableHasHeadersAndActionableID(t *testing.T) {
+ record := testSandboxRecord(t)
+ var output bytes.Buffer
+ if err := writeSandboxTable(&output, []types.Sandbox{record}); err != nil {
+ t.Fatal(err)
+ }
+ for _, value := range []string{
+ "SANDBOX ID", "NAME", "IMAGE ID", "STATE", "CPUS", "MEMORY", "STORAGE", "CREATED",
+ record.ID.String(), "box", record.ImageDigest.Hex()[:12], "created", "1.0GiB", "10.0GiB", "2026-09-16T02:00:00Z",
+ } {
+ if !strings.Contains(output.String(), value) {
+ t.Fatalf("table missing %q:\n%s", value, output.String())
+ }
+ }
+ if strings.ContainsAny(output.String(), "\t\x1b") || strings.Contains(output.String(), record.ImageDigest.String()) {
+ t.Fatalf("table contains tabs, terminal controls, or a full image digest: %q", output.String())
+ }
+}
+
+func TestEmptySandboxOutputsRemainScriptFriendly(t *testing.T) {
+ var table bytes.Buffer
+ if err := writeSandboxTable(&table, nil); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Count(table.String(), "\n") != 1 || !strings.Contains(table.String(), "SANDBOX ID") {
+ t.Fatalf("empty table = %q", table.String())
+ }
+ var jsonOutput bytes.Buffer
+ if err := writeSandboxListJSON(&jsonOutput, nil); err != nil {
+ t.Fatal(err)
+ }
+ if jsonOutput.String() != "[]\n" {
+ t.Fatalf("empty JSON = %q", jsonOutput.String())
+ }
+ var quiet bytes.Buffer
+ if err := writeSandboxIDs(&quiet, nil); err != nil {
+ t.Fatal(err)
+ }
+ if quiet.Len() != 0 {
+ t.Fatalf("empty quiet output = %q", quiet.String())
+ }
+}
+
+func TestInspectCommandAlwaysReturnsIndentedJSONByNameOrID(t *testing.T) {
+ base := t.TempDir()
+ roots := storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ }
+ seedImage(t, roots)
+ installFakeMKFS(t, base)
+ id := executeCreate(t, roots, "box")
+
+ byName := executeInspect(t, roots, "box")
+ byID := executeInspect(t, roots, id.String())
+ if byName != byID || !strings.Contains(byName, "\n \"id\"") {
+ t.Fatalf("inspect outputs are not identical indented JSON:\nname=%s\nid=%s", byName, byID)
+ }
+ var output sandboxOutput
+ if err := json.Unmarshal([]byte(byName), &output); err != nil {
+ t.Fatalf("decode inspect JSON %q: %v", byName, err)
+ }
+ if output.ID != id.String() || output.Name != "box" || output.State != "created" || output.Failure != nil {
+ t.Fatalf("inspect = %+v", output)
+ }
+ if output.ImageDigest == "" || output.CPUs != 1 || output.Memory != types.DefaultSandboxMemory || output.Storage != types.DefaultSandboxStorage {
+ t.Fatalf("inspect omitted identity or resources: %+v", output)
+ }
+}
+
+func TestSandboxJSONIncludesRetainedFailure(t *testing.T) {
+ record := testSandboxRecord(t)
+ record.State = types.SandboxStateError
+ record.Failure = &types.SandboxFailure{Phase: "disk", Message: "mkfs failed"}
+ var output bytes.Buffer
+ if err := writeSandboxJSON(&output, record); err != nil {
+ t.Fatal(err)
+ }
+ var decoded sandboxOutput
+ if err := json.Unmarshal(output.Bytes(), &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if decoded.Failure == nil || decoded.Failure.Phase != "disk" || decoded.Failure.Message != "mkfs failed" {
+ t.Fatalf("failure = %+v", decoded.Failure)
+ }
+}
+
+func TestListCommandShowsCreatedOnlyWithAllAndTracksRemoval(t *testing.T) {
+ base := t.TempDir()
+ roots := storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ }
+ seedImage(t, roots)
+ installFakeMKFS(t, base)
+ id := executeCreate(t, roots, "box")
+
+ if output := executeList(t, roots); strings.Contains(output, id.String()) || !strings.Contains(output, "SANDBOX ID") {
+ t.Fatalf("default ps output = %q", output)
+ }
+ jsonOutput := executeList(t, roots, "--all", "--json")
+ var records []sandboxOutput
+ if err := json.Unmarshal([]byte(jsonOutput), &records); err != nil {
+ t.Fatalf("decode ps JSON %q: %v", jsonOutput, err)
+ }
+ if len(records) != 1 || records[0].ID != id.String() || records[0].Name != "box" || records[0].State != "created" {
+ t.Fatalf("ps --all --json = %+v", records)
+ }
+ if output := executeList(t, roots, "-a", "--quiet"); output != id.String()+"\n" {
+ t.Fatalf("ps --all --quiet = %q", output)
+ }
+ if output := executeList(t, roots, "-a"); !strings.Contains(output, id.String()) || !strings.Contains(output, "box") {
+ t.Fatalf("ps --all table = %q", output)
+ }
+
+ remove := NewRemoveCommand(func() config.Config { return sandboxTestConfig(roots) })
+ remove.SetArgs([]string{id.String()})
+ remove.SetOut(&bytes.Buffer{})
+ remove.SetErr(&bytes.Buffer{})
+ if err := remove.ExecuteContext(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if output := executeList(t, roots, "--all", "--json"); output != "[]\n" {
+ t.Fatalf("ps after rm = %q", output)
+ }
+}
+
+func TestListCommandRejectsJSONWithQuiet(t *testing.T) {
+ base := t.TempDir()
+ roots := storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ }
+ command := NewListCommand(func() config.Config { return sandboxTestConfig(roots) })
+ command.SetArgs([]string{"--json", "--quiet"})
+ if err := command.ExecuteContext(t.Context()); err == nil {
+ t.Fatal("ps accepted --json with --quiet")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeInvalidArgument {
+ t.Fatalf("ps error code = %q, %v", code, err)
+ }
+}
+
+func TestLogsCommandStreamsTailByName(t *testing.T) {
+ base := t.TempDir()
+ roots := storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ }
+ seedImage(t, roots)
+ installFakeMKFS(t, base)
+ id := executeCreate(t, roots, "box")
+ paths, err := vmm.NewPaths(roots)
+ if err != nil {
+ t.Fatal(err)
+ }
+ logDir, err := paths.LogDir(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := storage.EnsureDir(logDir); err != nil {
+ t.Fatal(err)
+ }
+ logFile, err := paths.LogFile(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(logFile, []byte("first\nsecond\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ command := NewLogsCommand(func() config.Config { return sandboxTestConfig(roots) })
+ command.SetArgs([]string{"--tail", "1", "box"})
+ var output bytes.Buffer
+ command.SetOut(&output)
+ command.SetErr(&bytes.Buffer{})
+ if err := command.ExecuteContext(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if output.String() != "second\n" {
+ t.Fatalf("logs output = %q", output.String())
+ }
+
+ command = NewLogsCommand(func() config.Config { return sandboxTestConfig(roots) })
+ command.SetArgs([]string{"box", "--tail", "-1"})
+ if err := command.ExecuteContext(t.Context()); err == nil {
+ t.Fatal("logs accepted negative --tail")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeInvalidArgument {
+ t.Fatalf("logs error = %v", err)
+ }
+}
+
+type sandboxFailingOutput struct{ err error }
+
+func (writer sandboxFailingOutput) Write([]byte) (int, error) { return 0, writer.err }
+
+func TestSandboxListOutputPreservesWriteErrors(t *testing.T) {
+ failure := errors.New("output closed")
+ for name, write := range map[string]func() error{
+ "inspect": func() error { return writeSandboxJSON(sandboxFailingOutput{failure}, testSandboxRecord(t)) },
+ "table": func() error { return writeSandboxTable(sandboxFailingOutput{failure}, nil) },
+ "json": func() error { return writeSandboxListJSON(sandboxFailingOutput{failure}, nil) },
+ "quiet": func() error {
+ return writeSandboxIDs(sandboxFailingOutput{failure}, []types.Sandbox{testSandboxRecord(t)})
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ if err := write(); !errors.Is(err, failure) {
+ t.Fatalf("write error = %v", err)
+ }
+ })
+ }
+}
+
+func executeInspect(t *testing.T, roots storage.Roots, reference string) string {
+ t.Helper()
+ command := NewInspectCommand(func() config.Config { return sandboxTestConfig(roots) })
+ command.SetArgs([]string{reference})
+ var output bytes.Buffer
+ command.SetOut(&output)
+ command.SetErr(&bytes.Buffer{})
+ if err := command.ExecuteContext(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ return output.String()
+}
+
+func executeList(t *testing.T, roots storage.Roots, args ...string) string {
+ t.Helper()
+ command := NewListCommand(func() config.Config { return sandboxTestConfig(roots) })
+ command.SetArgs(args)
+ var output bytes.Buffer
+ command.SetOut(&output)
+ command.SetErr(&bytes.Buffer{})
+ if err := command.ExecuteContext(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ return output.String()
+}
+
+func testSandboxRecord(t *testing.T) types.Sandbox {
+ t.Helper()
+ digest, err := types.ParseDigest("sha256:" + strings.Repeat("a", 64))
+ if err != nil {
+ t.Fatal(err)
+ }
+ created := time.Date(2026, 9, 16, 10, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
+ return types.Sandbox{
+ ID: types.SandboxID("123e4567-e89b-42d3-a456-426614174000"),
+ Config: types.SandboxConfig{Name: "box", CPUs: 2, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ ImageDigest: digest, VMM: types.VMMCloudHypervisor, State: types.SandboxStateCreated, Generation: 2,
+ CreatedAt: created, UpdatedAt: created.Add(time.Second),
+ }
+}
diff --git a/cli/sandbox/remove.go b/cli/sandbox/remove.go
new file mode 100644
index 0000000..8b2f21b
--- /dev/null
+++ b/cli/sandbox/remove.go
@@ -0,0 +1,48 @@
+package sandbox
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+// NewRemoveCommand builds the top-level sandbox removal command.
+func NewRemoveCommand(configuration configProvider) *cobra.Command {
+ asJSON := false
+ command := &cobra.Command{
+ Use: "rm SANDBOX",
+ Short: "remove a sandbox and its persistent resources",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ reference := args[0]
+ progress, err := startRemoveProgress(command, reference)
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+ service, err := core.OpenSandbox(command.Context(), configuration(), progress)
+ if err != nil {
+ return err
+ }
+ committed := false
+ defer func() {
+ closeErr := service.Close()
+ returnErr = errors.Join(returnErr, errdefs.Context(closeErr, "remove sandbox", reference, "close metadata", "inspect the sandbox before retrying", committed))
+ }()
+ removed, err := service.Remove(command.Context(), reference)
+ if err != nil {
+ return err
+ }
+ committed = true
+ if err := writeRemoveResult(progress.Output(command.OutOrStdout()), removed, asJSON); err != nil {
+ return errdefs.Context(err, "remove sandbox", reference, "output", "sandbox was deleted; do not retry", true)
+ }
+ return nil
+ },
+ }
+ command.Flags().BoolVar(&asJSON, "json", false, "print the removed sandbox as indented JSON")
+ return command
+}
diff --git a/cli/sandbox/remove_test.go b/cli/sandbox/remove_test.go
new file mode 100644
index 0000000..a65be28
--- /dev/null
+++ b/cli/sandbox/remove_test.go
@@ -0,0 +1,126 @@
+package sandbox
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/images"
+ sandboxfs "github.com/kumabox/kumabox/sandbox"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+func TestRemoveCommandClosesCreateAndImageReferenceLifecycle(t *testing.T) {
+ base := t.TempDir()
+ roots := storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ }
+ seedImage(t, roots)
+ installFakeMKFS(t, base)
+
+ firstID := executeCreate(t, roots, "box")
+ vmmPaths, err := vmm.NewPaths(roots)
+ if err != nil {
+ t.Fatal(err)
+ }
+ logDir, err := vmmPaths.LogDir(firstID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := storage.EnsureDir(logDir); err != nil {
+ t.Fatal(err)
+ }
+ logFile, err := vmmPaths.LogFile(firstID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(logFile, []byte("persistent VMM output\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ remove := NewRemoveCommand(func() config.Config { return sandboxTestConfig(roots) })
+ remove.SetArgs([]string{"box", "--json"})
+ var stdout, stderr bytes.Buffer
+ remove.SetOut(&stdout)
+ remove.SetErr(&stderr)
+ if err := remove.ExecuteContext(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ var output removeOutput
+ if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
+ t.Fatalf("decode remove output %q: %v", stdout.String(), err)
+ }
+ if output.ID != firstID.String() || output.Name != "box" || !strings.Contains(stdout.String(), "\n \"id\":") {
+ t.Fatalf("remove output = %+v, raw=%q", output, stdout.String())
+ }
+ if !strings.HasSuffix(stderr.String(), "Remove \"box\" complete\n") {
+ t.Fatalf("remove progress = %q", stderr.String())
+ }
+ paths, err := sandboxfs.NewPaths(roots)
+ if err != nil {
+ t.Fatal(err)
+ }
+ firstDir, err := paths.Dir(firstID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(firstDir); !os.IsNotExist(err) {
+ t.Fatalf("removed sandbox directory still exists: %v", err)
+ }
+ if _, err := os.Stat(logDir); !os.IsNotExist(err) {
+ t.Fatalf("removed sandbox log directory still exists: %v", err)
+ }
+
+ secondID := executeCreate(t, roots, "box")
+ if secondID == firstID {
+ t.Fatal("recreated sandbox reused immutable ID")
+ }
+ remove = NewRemoveCommand(func() config.Config { return sandboxTestConfig(roots) })
+ remove.SetArgs([]string{secondID.String()})
+ stdout.Reset()
+ stderr.Reset()
+ remove.SetOut(&stdout)
+ remove.SetErr(&stderr)
+ if err := remove.ExecuteContext(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if stdout.String() != secondID.String()+"\n" {
+ t.Fatalf("text remove output = %q", stdout.String())
+ }
+
+ state, err := core.OpenImages(t.Context(), sandboxTestConfig(roots))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := state.Close(); err != nil {
+ t.Error(err)
+ }
+ }()
+ if _, err := images.Remove(t.Context(), state.Paths, state.Catalog, "demo"); err != nil {
+ t.Fatalf("image remains pinned after sandbox removal: %v", err)
+ }
+}
+
+func executeCreate(t *testing.T, roots storage.Roots, name string) types.SandboxID {
+ t.Helper()
+ command := NewCreateCommand(func() config.Config { return sandboxTestConfig(roots) })
+ command.SetArgs([]string{"demo", "--name", name, "--cpus", "1", "--nics", "0"})
+ var stdout, stderr bytes.Buffer
+ command.SetOut(&stdout)
+ command.SetErr(&stderr)
+ if err := command.ExecuteContext(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ id, err := types.ParseSandboxID(strings.TrimSpace(stdout.String()))
+ if err != nil {
+ t.Fatalf("create output %q: %v", stdout.String(), err)
+ }
+ return id
+}
diff --git a/cli/sandbox/restore.go b/cli/sandbox/restore.go
new file mode 100644
index 0000000..03431fc
--- /dev/null
+++ b/cli/sandbox/restore.go
@@ -0,0 +1,46 @@
+package sandbox
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+// NewRestoreCommand builds the top-level native snapshot restore command.
+func NewRestoreCommand(configuration configProvider) *cobra.Command {
+ var asJSON bool
+ command := &cobra.Command{
+ Use: "restore SANDBOX SNAPSHOT",
+ Short: "restore a sandbox to a saved snapshot",
+ Args: cobra.ExactArgs(2),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ progress, err := startRestoreProgress(command, args[0])
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+ service, err := core.OpenSnapshots(command.Context(), configuration(), progress)
+ if err != nil {
+ return err
+ }
+ committed := false
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(service.Close(), "restore sandbox", args[0], "close metadata", "inspect the sandbox before retrying", committed))
+ }()
+ record, err := service.Restore(command.Context(), args[0], args[1])
+ if err != nil {
+ return err
+ }
+ committed = true
+ if err := writeSandboxResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil {
+ return errdefs.Context(err, "restore sandbox", args[0], "output", "sandbox is running; inspect it before retrying", true)
+ }
+ return nil
+ },
+ }
+ command.Flags().BoolVar(&asJSON, "json", false, "print the restored sandbox as indented JSON")
+ return command
+}
diff --git a/cli/sandbox/run.go b/cli/sandbox/run.go
new file mode 100644
index 0000000..18f7051
--- /dev/null
+++ b/cli/sandbox/run.go
@@ -0,0 +1,58 @@
+package sandbox
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+// NewRunCommand builds the top-level create-and-start command.
+func NewRunCommand(configuration configProvider) *cobra.Command {
+ options := defaultCreateOptions()
+ asJSON := false
+ command := &cobra.Command{
+ Use: "run IMAGE",
+ Short: "create and start a sandbox",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ request, err := options.request(args[0])
+ if err != nil {
+ return err
+ }
+ progress, err := startRunProgress(command, options.name)
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+
+ service, err := core.OpenSandbox(command.Context(), configuration(), progress)
+ if err != nil {
+ return err
+ }
+ committed := false
+ defer func() {
+ closeErr := service.Close()
+ returnErr = errors.Join(returnErr, errdefs.Context(
+ closeErr, "run sandbox", options.name, "close metadata",
+ "inspect the sandbox before retrying", committed,
+ ))
+ }()
+
+ record, err := service.Run(command.Context(), request)
+ if err != nil {
+ return err
+ }
+ committed = true
+ if err := writeSandboxResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil {
+ return errdefs.Context(err, "run sandbox", options.name, "output", "sandbox is running; inspect it before retrying", true)
+ }
+ return nil
+ },
+ }
+ options.addFlags(command)
+ command.Flags().BoolVar(&asJSON, "json", false, "print the running sandbox as indented JSON")
+ return command
+}
diff --git a/cli/sandbox/start.go b/cli/sandbox/start.go
new file mode 100644
index 0000000..4069cc1
--- /dev/null
+++ b/cli/sandbox/start.go
@@ -0,0 +1,48 @@
+package sandbox
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+// NewStartCommand builds the top-level sandbox start command.
+func NewStartCommand(configuration configProvider) *cobra.Command {
+ asJSON := false
+ command := &cobra.Command{
+ Use: "start SANDBOX",
+ Short: "start a created or stopped sandbox",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ reference := args[0]
+ progress, err := startStartProgress(command, reference)
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+ service, err := core.OpenSandbox(command.Context(), configuration(), progress)
+ if err != nil {
+ return err
+ }
+ committed := false
+ defer func() {
+ closeErr := service.Close()
+ returnErr = errors.Join(returnErr, errdefs.Context(closeErr, "start sandbox", reference, "close metadata", "inspect the sandbox before retrying", committed))
+ }()
+ record, err := service.Start(command.Context(), reference)
+ if err != nil {
+ return err
+ }
+ committed = true
+ if err := writeSandboxResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil {
+ return errdefs.Context(err, "start sandbox", reference, "output", "sandbox is running; inspect it before retrying", true)
+ }
+ return nil
+ },
+ }
+ command.Flags().BoolVar(&asJSON, "json", false, "print the running sandbox as indented JSON")
+ return command
+}
diff --git a/cli/sandbox/stop.go b/cli/sandbox/stop.go
new file mode 100644
index 0000000..125eb61
--- /dev/null
+++ b/cli/sandbox/stop.go
@@ -0,0 +1,48 @@
+package sandbox
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+// NewStopCommand builds the top-level sandbox stop command.
+func NewStopCommand(configuration configProvider) *cobra.Command {
+ asJSON := false
+ command := &cobra.Command{
+ Use: "stop SANDBOX",
+ Short: "stop a running or interrupted sandbox",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ reference := args[0]
+ progress, err := startStopProgress(command, reference)
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+ service, err := core.OpenSandbox(command.Context(), configuration(), progress)
+ if err != nil {
+ return err
+ }
+ committed := false
+ defer func() {
+ closeErr := service.Close()
+ returnErr = errors.Join(returnErr, errdefs.Context(closeErr, "stop sandbox", reference, "close metadata", "inspect the sandbox before retrying", committed))
+ }()
+ record, err := service.Stop(command.Context(), reference)
+ if err != nil {
+ return err
+ }
+ committed = true
+ if err := writeSandboxResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil {
+ return errdefs.Context(err, "stop sandbox", reference, "output", "sandbox is stopped; inspect it before retrying", true)
+ }
+ return nil
+ },
+ }
+ command.Flags().BoolVar(&asJSON, "json", false, "print the stopped sandbox as indented JSON")
+ return command
+}
diff --git a/cli/snapshot/command.go b/cli/snapshot/command.go
new file mode 100644
index 0000000..e75d9a5
--- /dev/null
+++ b/cli/snapshot/command.go
@@ -0,0 +1,134 @@
+// Package snapshot exposes snapshot lifecycle commands through Cobra.
+package snapshot
+
+import (
+ "errors"
+
+ "github.com/spf13/cobra"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/core"
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+type configProvider func() config.Config
+
+// NewCommand builds the snapshot command group.
+func NewCommand(configuration configProvider) *cobra.Command {
+ command := &cobra.Command{Use: "snapshot", Short: "manage sandbox snapshots"}
+ command.AddCommand(newSaveCommand(configuration), newListCommand(configuration), newInspectCommand(configuration), newRemoveCommand(configuration))
+ return command
+}
+
+func newSaveCommand(configuration configProvider) *cobra.Command {
+ var name, description string
+ var asJSON bool
+ command := &cobra.Command{
+ Use: "save SANDBOX",
+ Short: "save a live snapshot of a running sandbox",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ progress, err := newProgress(command, args[0])
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }()
+ service, err := core.OpenSnapshots(command.Context(), configuration(), progress)
+ if err != nil {
+ return err
+ }
+ committed := false
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(service.Close(), "save snapshot", args[0], "close metadata", "inspect the snapshot before retrying", committed))
+ }()
+ record, err := service.Save(command.Context(), core.SaveSnapshotRequest{
+ SandboxReference: args[0], Name: name, Description: description,
+ })
+ if err != nil {
+ return err
+ }
+ committed = true
+ return writeResult(progress.Output(command.OutOrStdout()), record, asJSON)
+ },
+ }
+ command.Flags().StringVar(&name, "name", "", "optional unique snapshot name")
+ command.Flags().StringVar(&description, "description", "", "optional snapshot description")
+ command.Flags().BoolVar(&asJSON, "json", false, "print the saved snapshot as indented JSON")
+ return command
+}
+
+func newListCommand(configuration configProvider) *cobra.Command {
+ var asJSON bool
+ command := &cobra.Command{
+ Use: "ls",
+ Aliases: []string{"list"},
+ Short: "list snapshots",
+ Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, _ []string) (returnErr error) {
+ service, err := core.OpenSnapshots(command.Context(), configuration(), nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(service.Close(), "list snapshots", "", "close metadata", "retry the query", false))
+ }()
+ records, err := service.List(command.Context())
+ if err != nil {
+ return err
+ }
+ if asJSON {
+ return writeListJSON(command.OutOrStdout(), records)
+ }
+ return writeTable(command.OutOrStdout(), records)
+ },
+ }
+ command.Flags().BoolVar(&asJSON, "json", false, "print snapshots as indented JSON")
+ return command
+}
+
+func newInspectCommand(configuration configProvider) *cobra.Command {
+ return &cobra.Command{
+ Use: "inspect SNAPSHOT",
+ Short: "show detailed snapshot information as JSON",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ service, err := core.OpenSnapshots(command.Context(), configuration(), nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(service.Close(), "inspect snapshot", args[0], "close metadata", "retry the query", false))
+ }()
+ record, err := service.Inspect(command.Context(), args[0])
+ if err != nil {
+ return err
+ }
+ return writeJSON(command.OutOrStdout(), record)
+ },
+ }
+}
+
+func newRemoveCommand(configuration configProvider) *cobra.Command {
+ var asJSON bool
+ command := &cobra.Command{
+ Use: "rm SNAPSHOT",
+ Short: "remove a snapshot",
+ Args: cobra.ExactArgs(1),
+ RunE: func(command *cobra.Command, args []string) (returnErr error) {
+ service, err := core.OpenSnapshots(command.Context(), configuration(), nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(service.Close(), "remove snapshot", args[0], "close metadata", "retry snapshot removal", true))
+ }()
+ record, err := service.Remove(command.Context(), args[0])
+ if err != nil {
+ return err
+ }
+ return writeResult(command.OutOrStdout(), record, asJSON)
+ },
+ }
+ command.Flags().BoolVar(&asJSON, "json", false, "print the removed snapshot as indented JSON")
+ return command
+}
diff --git a/cli/snapshot/output.go b/cli/snapshot/output.go
new file mode 100644
index 0000000..81c9cda
--- /dev/null
+++ b/cli/snapshot/output.go
@@ -0,0 +1,101 @@
+package snapshot
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "text/tabwriter"
+ "time"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+type output struct {
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ SandboxID string `json:"sandbox_id"`
+ SourceGeneration uint64 `json:"source_generation"`
+ ImageDigest string `json:"image_digest"`
+ VMM string `json:"vmm"`
+ Config configOutput `json:"config"`
+ Size int64 `json:"size"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type configOutput struct {
+ Name string `json:"name"`
+ CPUs uint32 `json:"cpus"`
+ Memory int64 `json:"memory"`
+ Storage int64 `json:"storage"`
+ NICs int `json:"nics"`
+ NetworkName string `json:"network_name,omitempty"`
+}
+
+func result(snapshot types.Snapshot) output {
+ return output{
+ ID: snapshot.ID.String(), Name: snapshot.Name, Description: snapshot.Description,
+ SandboxID: snapshot.SandboxID.String(), SourceGeneration: snapshot.SourceGeneration,
+ ImageDigest: snapshot.ImageDigest.String(), VMM: string(snapshot.VMM),
+ Config: configOutput{
+ Name: snapshot.Config.Name, CPUs: snapshot.Config.CPUs, Memory: snapshot.Config.Memory,
+ Storage: snapshot.Config.Storage, NICs: snapshot.Config.NICs, NetworkName: snapshot.Config.NetworkName,
+ },
+ Size: snapshot.Size, CreatedAt: snapshot.CreatedAt.UTC(),
+ }
+}
+
+func writeJSON(writer io.Writer, snapshot types.Snapshot) error {
+ encoder := json.NewEncoder(writer)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(result(snapshot))
+}
+
+func writeResult(writer io.Writer, snapshot types.Snapshot, asJSON bool) error {
+ if asJSON {
+ return writeJSON(writer, snapshot)
+ }
+ _, err := fmt.Fprintln(writer, snapshot.ID)
+ return err
+}
+
+func writeListJSON(writer io.Writer, snapshots []types.Snapshot) error {
+ results := make([]output, 0, len(snapshots))
+ for _, snapshot := range snapshots {
+ results = append(results, result(snapshot))
+ }
+ encoder := json.NewEncoder(writer)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(results)
+}
+
+func writeTable(writer io.Writer, snapshots []types.Snapshot) error {
+ table := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0)
+ if _, err := fmt.Fprintln(table, "SNAPSHOT ID\tNAME\tSANDBOX ID\tCPUS\tMEMORY\tSIZE\tDESCRIPTION\tCREATED"); err != nil {
+ return err
+ }
+ for _, snapshot := range snapshots {
+ if _, err := fmt.Fprintf(table, "%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\n",
+ snapshot.ID, snapshot.Name, snapshot.SandboxID, snapshot.Config.CPUs,
+ formatIECBytes(snapshot.Config.Memory), formatIECBytes(snapshot.Size), snapshot.Description,
+ snapshot.CreatedAt.UTC().Format(time.RFC3339),
+ ); err != nil {
+ return err
+ }
+ }
+ return table.Flush()
+}
+
+func formatIECBytes(size int64) string {
+ if size < 1024 {
+ return fmt.Sprintf("%dB", size)
+ }
+ value := float64(size)
+ for _, unit := range []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} {
+ value /= 1024
+ if value < 1024 || unit == "EiB" {
+ return fmt.Sprintf("%.1f%s", value, unit)
+ }
+ }
+ return fmt.Sprintf("%dB", size)
+}
diff --git a/cli/snapshot/output_test.go b/cli/snapshot/output_test.go
new file mode 100644
index 0000000..7147a5f
--- /dev/null
+++ b/cli/snapshot/output_test.go
@@ -0,0 +1,68 @@
+package snapshot
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestSnapshotOutputIsIndentedAndTableHasHeaders(t *testing.T) {
+ digest, err := types.ParseDigest("sha256:" + strings.Repeat("a", 64))
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := types.Snapshot{
+ ID: types.SnapshotID("223e4567-e89b-42d3-a456-426614174000"), Name: "checkpoint",
+ SandboxID: types.SandboxID("123e4567-e89b-42d3-a456-426614174000"), SourceGeneration: 4,
+ ImageDigest: digest, VMM: types.VMMCloudHypervisor,
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 2, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1, NetworkName: "default",
+ },
+ Size: 42, CreatedAt: time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC),
+ }
+ var jsonOutput bytes.Buffer
+ if err := writeJSON(&jsonOutput, record); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(jsonOutput.String(), "\n \"id\"") || !strings.Contains(jsonOutput.String(), "\"cpus\": 2") {
+ t.Fatalf("snapshot JSON = %q", jsonOutput.String())
+ }
+ var decoded output
+ if err := json.Unmarshal(jsonOutput.Bytes(), &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if decoded.Config.Name != "box" || decoded.Config.NetworkName != "default" {
+ t.Fatalf("snapshot output = %+v", decoded)
+ }
+ var table bytes.Buffer
+ if err := writeTable(&table, []types.Snapshot{record}); err != nil {
+ t.Fatal(err)
+ }
+ for _, text := range []string{"SNAPSHOT ID", "SANDBOX ID", record.ID.String(), "checkpoint", "1.0GiB"} {
+ if !strings.Contains(table.String(), text) {
+ t.Fatalf("snapshot table missing %q:\n%s", text, table.String())
+ }
+ }
+}
+
+func TestEmptySnapshotOutputsUseHeadersAndArray(t *testing.T) {
+ var table bytes.Buffer
+ if err := writeTable(&table, nil); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Count(table.String(), "\n") != 1 {
+ t.Fatalf("empty table = %q", table.String())
+ }
+ var jsonOutput bytes.Buffer
+ if err := writeListJSON(&jsonOutput, nil); err != nil {
+ t.Fatal(err)
+ }
+ if jsonOutput.String() != "[]\n" {
+ t.Fatalf("empty JSON = %q", jsonOutput.String())
+ }
+}
diff --git a/cli/snapshot/progress.go b/cli/snapshot/progress.go
new file mode 100644
index 0000000..e28c341
--- /dev/null
+++ b/cli/snapshot/progress.go
@@ -0,0 +1,64 @@
+package snapshot
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+
+ "github.com/spf13/cobra"
+
+ cliprogress "github.com/kumabox/kumabox/cli/progress"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+type progress struct {
+ mu sync.Mutex
+ renderer *cliprogress.Renderer
+ label string
+ committed bool
+}
+
+func newProgress(command *cobra.Command, reference string) (*progress, error) {
+ label := fmt.Sprintf("Snapshot %q", reference)
+ renderer, err := cliprogress.New(command.Context(), command.ErrOrStderr(), label+" · preparing snapshot")
+ if err != nil {
+ return nil, err
+ }
+ return &progress{renderer: renderer, label: label}, nil
+}
+
+func (p *progress) Status(status string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.renderer.Update(p.label + " · " + status)
+}
+
+func (p *progress) Committed(types.Snapshot) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.committed = true
+ return p.renderer.Update(p.label + " · finishing")
+}
+
+func (p *progress) Output(writer io.Writer) io.Writer { return p.renderer.Output(writer) }
+
+func (p *progress) Finish(operationErr error) error {
+ p.mu.Lock()
+ var classified *errdefs.Error
+ if errors.As(operationErr, &classified) && classified.Committed {
+ p.committed = true
+ }
+ outcome := cliprogress.Succeeded
+ if operationErr != nil || p.renderer.Err() != nil {
+ if p.committed {
+ outcome = cliprogress.CommittedWithErrors
+ } else {
+ outcome = cliprogress.Failed
+ }
+ }
+ label := p.label
+ p.mu.Unlock()
+ return p.renderer.Finish(label, outcome, "")
+}
diff --git a/cmd/agent/main.go b/cmd/agent/main.go
deleted file mode 100644
index 9517d8c..0000000
--- a/cmd/agent/main.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
-
- "github.com/kumabox/kumabox/internal/agent/server"
-)
-
-func main() {
- if len(os.Args) < 2 {
- usage()
- os.Exit(2)
- }
- switch os.Args[1] {
- case "serve":
- if err := server.Serve(); err != nil {
- fmt.Fprintf(os.Stderr, "kumabox-agent: %v\n", err)
- os.Exit(1)
- }
- case "version", "--version":
- fmt.Println(server.Version)
- default:
- usage()
- os.Exit(2)
- }
-}
-
-func usage() {
- fmt.Fprintln(os.Stderr, "usage: kumabox-agent {serve|version}")
-}
diff --git a/cmd/kumabox-agent/main.go b/cmd/kumabox-agent/main.go
new file mode 100644
index 0000000..cdb6346
--- /dev/null
+++ b/cmd/kumabox-agent/main.go
@@ -0,0 +1,52 @@
+// Command kumabox-agent serves KumaBox's host-controlled guest command channel.
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/kumabox/kumabox/agent"
+ "github.com/kumabox/kumabox/version"
+)
+
+func main() {
+ if len(os.Args) != 2 {
+ usage()
+ os.Exit(2)
+ }
+ switch os.Args[1] {
+ case "serve":
+ if err := serve(); err != nil {
+ fmt.Fprintf(os.Stderr, "kumabox-agent: %v\n", err)
+ os.Exit(1)
+ }
+ case "version", "--version":
+ fmt.Printf("kumabox-agent %s (commit %s, built %s)\n", version.Version, version.Commit, version.BuildTime)
+ default:
+ usage()
+ os.Exit(2)
+ }
+}
+
+func serve() error {
+ listener, err := agent.ListenVsock(agent.Port)
+ if err != nil {
+ return err
+ }
+ server, err := agent.NewServer(listener, log.New(os.Stderr, "kumabox-agent: ", log.LstdFlags|log.Lmsgprefix))
+ if err != nil {
+ _ = listener.Close()
+ return err
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ return server.Serve(ctx)
+}
+
+func usage() {
+ fmt.Fprintln(os.Stderr, "usage: kumabox-agent {serve|version}")
+}
diff --git a/cmd/kumabox/main.go b/cmd/kumabox/main.go
index 245792c..3398842 100644
--- a/cmd/kumabox/main.go
+++ b/cmd/kumabox/main.go
@@ -1,3 +1,8 @@
+// Command kumabox is the KumaBox command line.
+//
+// v1 has no daemon: every invocation opens the node root, does one job and
+// exits. This file only hands control to the command layer and turns the result
+// into a process exit code.
package main
import (
@@ -7,16 +12,17 @@ import (
"os/signal"
"syscall"
- "github.com/kumabox/kumabox/internal/cli"
+ "github.com/kumabox/kumabox/cli"
)
+// main propagates termination signals, prints unhandled diagnostics, and exits
+// with the status selected by the CLI after command resource cleanup has finished.
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer stop()
-
- cmd := cli.NewRootCommand()
- if err := cmd.ExecuteContext(ctx); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(cli.ExitCode(err))
+ err := cli.Execute(ctx, os.Args[1:], os.Stdout, os.Stderr)
+ if err != nil && !cli.Silent(err) {
+ fmt.Fprintf(os.Stderr, "kumabox: %v\n", err)
}
+ stop()
+ os.Exit(cli.ExitCode(err))
}
diff --git a/cmd/kumabox/main_test.go b/cmd/kumabox/main_test.go
new file mode 100644
index 0000000..57fc274
--- /dev/null
+++ b/cmd/kumabox/main_test.go
@@ -0,0 +1,162 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestMainBinaryStreamsAndExitCodes builds the real entrypoint so argument
+// parsing, signal setup, diagnostic printing, and os.Exit remain in scope.
+func TestMainBinaryStreamsAndExitCodes(t *testing.T) {
+ binary := buildBinary(t)
+ base := t.TempDir()
+ global := []string{
+ "--root-dir", filepath.Join(base, "data"),
+ "--run-dir", filepath.Join(base, "run"),
+ "--log-dir", filepath.Join(base, "log"),
+ }
+ tests := []struct {
+ name string
+ args []string
+ wantCode int
+ wantStdout string
+ stdoutContains string
+ stderrContains string
+ }{
+ {name: "version JSON", args: []string{"version", "--json"}, stdoutContains: "\n \"build_time\":"},
+ {name: "image JSON", args: append(append([]string(nil), global...), "image", "ls", "--json"), wantStdout: "[]\n"},
+ {name: "image platform validation", args: append(append([]string(nil), global...), "image", "pull", "example.invalid/demo", "--platform", "windows/amd64"), wantCode: 5, stderrContains: "INVALID_ARGUMENT"},
+ {name: "create usage", args: append(append([]string(nil), global...), "create"), wantCode: 2, stderrContains: "kumabox:"},
+ {name: "create resource validation", args: append(append([]string(nil), global...), "create", "demo", "--name", "box", "--cpus", "0"), wantCode: 5, stderrContains: "--cpus"},
+ {name: "ps usage", args: append(append([]string(nil), global...), "ps", "unexpected"), wantCode: 2, stderrContains: "kumabox:"},
+ {name: "ps output validation", args: append(append([]string(nil), global...), "ps", "--json", "--quiet"), wantCode: 5, stderrContains: "INVALID_ARGUMENT"},
+ {name: "inspect missing", args: append(append([]string(nil), global...), "inspect", "missing"), wantCode: 3, stderrContains: "NOT_FOUND"},
+ {name: "inspect flag validation", args: append(append([]string(nil), global...), "inspect", "missing", "--json"), wantCode: 2, stderrContains: "unknown flag"},
+ {name: "logs missing", args: append(append([]string(nil), global...), "logs", "missing"), wantCode: 3, stderrContains: "NOT_FOUND"},
+ {name: "logs usage", args: append(append([]string(nil), global...), "logs"), wantCode: 2, stderrContains: "kumabox:"},
+ {name: "start missing", args: append(append([]string(nil), global...), "start", "missing"), wantCode: 3, stderrContains: `Start "missing" failed`},
+ {name: "start usage", args: append(append([]string(nil), global...), "start", "one", "two"), wantCode: 2, stderrContains: "kumabox:"},
+ {name: "stop missing", args: append(append([]string(nil), global...), "stop", "missing"), wantCode: 3, stderrContains: `Stop "missing" failed`},
+ {name: "stop usage", args: append(append([]string(nil), global...), "stop", "one", "two"), wantCode: 2, stderrContains: "kumabox:"},
+ {name: "console usage", args: append(append([]string(nil), global...), "console"), wantCode: 2, stderrContains: "kumabox:"},
+ {name: "console escape validation", args: append(append([]string(nil), global...), "console", "box", "--escape-char", "^?"), wantCode: 5, stderrContains: "--escape-char"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ stdout, stderr, code := runBinary(t, binary, test.args, nil)
+ if code != test.wantCode {
+ t.Fatalf("exit code = %d, want %d; stdout=%q stderr=%q", code, test.wantCode, stdout, stderr)
+ }
+ if test.wantStdout != "" && stdout != test.wantStdout {
+ t.Fatalf("stdout = %q, want %q", stdout, test.wantStdout)
+ }
+ if test.stdoutContains != "" && !strings.Contains(stdout, test.stdoutContains) {
+ t.Fatalf("stdout = %q, want substring %q", stdout, test.stdoutContains)
+ }
+ if test.stderrContains != "" && !strings.Contains(stderr, test.stderrContains) {
+ t.Fatalf("stderr = %q, want substring %q", stderr, test.stderrContains)
+ }
+ if test.wantCode == 0 && stderr != "" {
+ t.Fatalf("successful command stderr = %q", stderr)
+ }
+ if test.wantCode != 0 && stdout != "" {
+ t.Fatalf("failed command stdout = %q", stdout)
+ }
+ if strings.ContainsAny(stderr, "\r\x1b") {
+ t.Fatalf("non-terminal stderr contains terminal controls: %q", stderr)
+ }
+ })
+ }
+}
+
+func TestMainBinaryPropagatesSignalCancellation(t *testing.T) {
+ binary := buildBinary(t)
+ directory := t.TempDir()
+ marker := filepath.Join(directory, "ready")
+ checker := filepath.Join(directory, "kumabox-check")
+ script := []byte("#!/bin/sh\n: > \"$KUMABOX_TEST_READY\"\nexec sleep 30\n")
+ if err := os.WriteFile(checker, script, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ command := exec.Command(binary, "doctor")
+ command.Env = append(os.Environ(), "PATH="+directory+string(os.PathListSeparator)+os.Getenv("PATH"), "KUMABOX_TEST_READY="+marker)
+ var stdout, stderr bytes.Buffer
+ command.Stdout, command.Stderr = &stdout, &stderr
+ if err := command.Start(); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = command.Process.Kill() })
+ wait := make(chan error, 1)
+ go func() { wait <- command.Wait() }()
+ readyTimeout := time.NewTimer(15 * time.Second)
+ defer readyTimeout.Stop()
+ readyPoll := time.NewTicker(10 * time.Millisecond)
+ defer readyPoll.Stop()
+
+ready:
+ for {
+ select {
+ case err := <-wait:
+ t.Fatalf("doctor exited before helper readiness: %v; stderr=%q", err, stderr.String())
+ case <-readyTimeout.C:
+ t.Fatal("doctor helper did not become ready")
+ case <-readyPoll.C:
+ if _, err := os.Stat(marker); err == nil {
+ break ready
+ } else if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("inspect doctor readiness: %v", err)
+ }
+ }
+ }
+ if err := command.Process.Signal(os.Interrupt); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case err := <-wait:
+ var exitErr *exec.ExitError
+ if !errors.As(err, &exitErr) || exitErr.ExitCode() == 0 {
+ t.Fatalf("signal exit error = %v", err)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("kumabox did not exit after interrupt")
+ }
+ if stdout.Len() != 0 || strings.ContainsAny(stderr.String(), "\r\x1b") {
+ t.Fatalf("signal output: stdout=%q stderr=%q", stdout.String(), stderr.String())
+ }
+}
+
+func buildBinary(t *testing.T) string {
+ t.Helper()
+ binary := filepath.Join(t.TempDir(), "kumabox")
+ command := exec.Command("go", "build", "-o", binary, ".")
+ output, err := command.CombinedOutput()
+ if err != nil {
+ t.Fatalf("build kumabox: %v\n%s", err, output)
+ }
+ return binary
+}
+
+func runBinary(t *testing.T, binary string, args, environment []string) (string, string, int) {
+ t.Helper()
+ command := exec.Command(binary, args...)
+ if environment != nil {
+ command.Env = environment
+ }
+ var stdout, stderr bytes.Buffer
+ command.Stdout, command.Stderr = &stdout, &stderr
+ err := command.Run()
+ if err == nil {
+ return stdout.String(), stderr.String(), 0
+ }
+ var exitErr *exec.ExitError
+ if !errors.As(err, &exitErr) {
+ t.Fatalf("run kumabox: %v", err)
+ }
+ return stdout.String(), stderr.String(), exitErr.ExitCode()
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..7c3e66f
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,294 @@
+// Package config loads and validates one immutable application configuration
+// snapshot for each KumaBox invocation. Modules receive their own options from
+// core and never read this package or the environment directly.
+package config
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/spf13/pflag"
+ "github.com/spf13/viper"
+
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+const environmentPrefix = "KUMABOX"
+
+// Config is the validated application configuration shared by one command.
+type Config struct {
+ // Paths contains the three non-overlapping host ownership roots.
+ Paths storage.Roots `mapstructure:"paths"`
+ // Images controls image conversion tools, concurrency, and input bounds.
+ Images Images `mapstructure:"images"`
+ // Metadata controls bounded SQLite lock and transaction waits.
+ Metadata Metadata `mapstructure:"metadata"`
+ // Sandbox controls writable disk preparation and compensation.
+ Sandbox Sandbox `mapstructure:"sandbox"`
+ // Network controls host CNI discovery and lifecycle recovery.
+ Network Network `mapstructure:"network"`
+ // VMM selects and configures process backends.
+ VMM VMM `mapstructure:"vmm"`
+}
+
+// Images contains operator-controlled image import limits.
+type Images struct {
+ // EROFSBinary is the mkfs.erofs executable name or absolute path.
+ EROFSBinary string `mapstructure:"erofs_binary"`
+ // Parallelism bounds concurrent layer reuse checks and conversions.
+ Parallelism int `mapstructure:"parallelism"`
+ // LayerSize caps one compressed source layer in bytes.
+ LayerSize int64 `mapstructure:"layer_size"`
+ // UnpackedSize caps one decompressed layer tar stream in bytes.
+ UnpackedSize int64 `mapstructure:"unpacked_size"`
+ // BootSize caps one extracted kernel or initrd in bytes.
+ BootSize int64 `mapstructure:"boot_size"`
+ // ArchiveSize caps expanded regular-file content in a local archive.
+ ArchiveSize int64 `mapstructure:"archive_size"`
+}
+
+// Metadata contains SQLite wait budgets.
+type Metadata struct {
+ // BusyTimeout is one SQLite busy-handler wait.
+ BusyTimeout time.Duration `mapstructure:"busy_timeout"`
+ // RetryLimit bounds writer acquisition and transaction execution.
+ RetryLimit time.Duration `mapstructure:"retry_limit"`
+}
+
+// Sandbox contains host disk and compensation policy.
+type Sandbox struct {
+ // Ext4Binary is the mkfs.ext4 executable name or absolute path.
+ Ext4Binary string `mapstructure:"ext4_binary"`
+ // CleanupTimeout bounds failure compensation after caller cancellation.
+ CleanupTimeout time.Duration `mapstructure:"cleanup_timeout"`
+}
+
+// Network contains host networking policy shared by provider implementations.
+type Network struct {
+ // CNI locates network configuration and plugin executables installed by the
+ // host administrator.
+ CNI CNI `mapstructure:"cni"`
+ // DNS is a comma- or semicolon-separated list injected into guest network
+ // configuration by the boot protocol.
+ DNS string `mapstructure:"dns"`
+ // Scope is an optional two-character installation identifier used in host
+ // network namespace names.
+ Scope string `mapstructure:"scope"`
+ // CleanupTimeout bounds detached compensation after caller cancellation.
+ CleanupTimeout time.Duration `mapstructure:"cleanup_timeout"`
+}
+
+// CNI contains host-owned CNI discovery paths.
+type CNI struct {
+ // ConfDir contains .conflist network definitions.
+ ConfDir string `mapstructure:"conf_dir"`
+ // BinDir contains CNI plugin executables.
+ BinDir string `mapstructure:"bin_dir"`
+}
+
+// DNSServers parses and validates the configured guest DNS server list.
+func (n Network) DNSServers() ([]string, error) {
+ if strings.TrimSpace(n.DNS) == "" {
+ return nil, nil
+ }
+ var result []string
+ for value := range strings.SplitSeq(strings.ReplaceAll(n.DNS, ";", ","), ",") {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ continue
+ }
+ if net.ParseIP(value) == nil {
+ return nil, fmt.Errorf("invalid DNS server %q", value)
+ }
+ result = append(result, value)
+ }
+ return result, nil
+}
+
+// NamespacePrefix returns the installation-specific prefix for named network
+// namespaces. An empty scope preserves the readable product default.
+func (n Network) NamespacePrefix() string {
+ if n.Scope == "" {
+ return "kumabox-"
+ }
+ return n.Scope + "-"
+}
+
+// VMM contains backend selection and host process policy.
+type VMM struct {
+ // Default selects the backend for newly created sandboxes.
+ Default types.VMMType `mapstructure:"default"`
+ // CgroupParent contains per-sandbox VMM scopes.
+ CgroupParent string `mapstructure:"cgroup_parent"`
+ // CloudHypervisor configures the Cloud Hypervisor adapter.
+ CloudHypervisor CloudHypervisor `mapstructure:"cloud_hypervisor"`
+}
+
+// CloudHypervisor contains executable and bounded lifecycle waits.
+type CloudHypervisor struct {
+ // Binary is the cloud-hypervisor executable name or absolute path.
+ Binary string `mapstructure:"binary"`
+ // StartupTimeout bounds process and API readiness.
+ StartupTimeout time.Duration `mapstructure:"startup_timeout"`
+ // StopGrace bounds the identity-checked SIGTERM to SIGKILL window.
+ StopGrace time.Duration `mapstructure:"stop_grace"`
+ // AbortGrace bounds failed-launch process termination.
+ AbortGrace time.Duration `mapstructure:"abort_grace"`
+}
+
+// Default returns the operational defaults used when no higher-precedence
+// source supplies a value.
+func Default() Config {
+ return Config{
+ Paths: storage.DefaultRoots(),
+ Images: Images{
+ EROFSBinary: "mkfs.erofs", Parallelism: min(4, max(1, runtime.NumCPU())),
+ LayerSize: 8 << 30, UnpackedSize: 16 << 30, BootSize: 512 << 20, ArchiveSize: 32 << 30,
+ },
+ Metadata: Metadata{BusyTimeout: 50 * time.Millisecond, RetryLimit: 5 * time.Second},
+ Sandbox: Sandbox{Ext4Binary: "mkfs.ext4", CleanupTimeout: 10 * time.Second},
+ Network: Network{
+ CNI: CNI{ConfDir: "/etc/cni/net.d", BinDir: "/opt/cni/bin"},
+ DNS: "8.8.8.8,1.1.1.1", CleanupTimeout: 30 * time.Second,
+ },
+ VMM: VMM{
+ Default: types.VMMCloudHypervisor, CgroupParent: "/sys/fs/cgroup/kumabox.slice",
+ CloudHypervisor: CloudHypervisor{
+ Binary: "cloud-hypervisor", StartupTimeout: 10 * time.Second,
+ StopGrace: 5 * time.Second, AbortGrace: 3 * time.Second,
+ },
+ },
+ }
+}
+
+// Validate normalizes roots and rejects incomplete or unbounded policy before
+// any module creates host resources.
+func (c *Config) Validate() error {
+ if c == nil {
+ return errors.New("config is required")
+ }
+ paths, err := c.Paths.Validate()
+ if err != nil {
+ return fmt.Errorf("paths: %w", err)
+ }
+ c.Paths = paths
+ if strings.TrimSpace(c.Images.EROFSBinary) == "" || c.Images.Parallelism <= 0 ||
+ !validSize(c.Images.LayerSize) || !validSize(c.Images.UnpackedSize) ||
+ !validSize(c.Images.BootSize) || !validSize(c.Images.ArchiveSize) {
+ return errors.New("images requires an EROFS binary, positive parallelism, and positive bounded size limits")
+ }
+ if c.Metadata.BusyTimeout <= 0 || c.Metadata.RetryLimit <= 0 {
+ return errors.New("metadata timeouts must be positive")
+ }
+ if strings.TrimSpace(c.Sandbox.Ext4Binary) == "" || c.Sandbox.CleanupTimeout <= 0 {
+ return errors.New("sandbox requires an ext4 binary and positive cleanup timeout")
+ }
+ if !filepath.IsAbs(c.Network.CNI.ConfDir) || !filepath.IsAbs(c.Network.CNI.BinDir) {
+ return errors.New("network CNI configuration and binary directories must be absolute")
+ }
+ if c.Network.CleanupTimeout <= 0 {
+ return errors.New("network cleanup timeout must be positive")
+ }
+ if _, err := c.Network.DNSServers(); err != nil {
+ return fmt.Errorf("network DNS: %w", err)
+ }
+ if c.Network.Scope != "" {
+ if len(c.Network.Scope) != 2 {
+ return errors.New("network scope must contain exactly two ASCII letters or digits")
+ }
+ for _, character := range c.Network.Scope {
+ if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') {
+ return errors.New("network scope must contain exactly two ASCII letters or digits")
+ }
+ }
+ }
+ if err := c.VMM.Default.Validate(); err != nil {
+ return fmt.Errorf("vmm default: %w", err)
+ }
+ if strings.TrimSpace(c.VMM.CgroupParent) == "" {
+ return errors.New("vmm cgroup parent must not be empty")
+ }
+ cloudHypervisor := c.VMM.CloudHypervisor
+ if strings.TrimSpace(cloudHypervisor.Binary) == "" || cloudHypervisor.StartupTimeout <= 0 ||
+ cloudHypervisor.StopGrace <= 0 || cloudHypervisor.AbortGrace <= 0 {
+ return errors.New("cloud hypervisor binary and lifecycle timeouts must be positive")
+ }
+ return nil
+}
+
+func validSize(value int64) bool { return value > 0 && value < 1<<63-1 }
+
+// Loader resolves defaults, one explicit file, environment variables, and
+// bound flags using an invocation-local Viper instance.
+type Loader struct {
+ resolver *viper.Viper
+}
+
+// NewLoader creates an isolated loader. It never searches implicit config
+// locations, so privileged commands cannot consume an unrelated working-tree file.
+func NewLoader() *Loader {
+ resolver := viper.New()
+ resolver.SetEnvPrefix(environmentPrefix)
+ resolver.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
+ resolver.AutomaticEnv()
+ defaults := Default()
+ for key, value := range map[string]any{
+ "paths.data": defaults.Paths.Data, "paths.run": defaults.Paths.Run, "paths.log": defaults.Paths.Log,
+ "images.erofs_binary": defaults.Images.EROFSBinary, "images.parallelism": defaults.Images.Parallelism,
+ "images.layer_size": defaults.Images.LayerSize, "images.unpacked_size": defaults.Images.UnpackedSize,
+ "images.boot_size": defaults.Images.BootSize, "images.archive_size": defaults.Images.ArchiveSize,
+ "metadata.busy_timeout": defaults.Metadata.BusyTimeout, "metadata.retry_limit": defaults.Metadata.RetryLimit,
+ "sandbox.ext4_binary": defaults.Sandbox.Ext4Binary, "sandbox.cleanup_timeout": defaults.Sandbox.CleanupTimeout,
+ "network.cni.conf_dir": defaults.Network.CNI.ConfDir, "network.cni.bin_dir": defaults.Network.CNI.BinDir,
+ "network.dns": defaults.Network.DNS, "network.scope": defaults.Network.Scope,
+ "network.cleanup_timeout": defaults.Network.CleanupTimeout,
+ "vmm.default": defaults.VMM.Default, "vmm.cgroup_parent": defaults.VMM.CgroupParent,
+ "vmm.cloud_hypervisor.binary": defaults.VMM.CloudHypervisor.Binary,
+ "vmm.cloud_hypervisor.startup_timeout": defaults.VMM.CloudHypervisor.StartupTimeout,
+ "vmm.cloud_hypervisor.stop_grace": defaults.VMM.CloudHypervisor.StopGrace,
+ "vmm.cloud_hypervisor.abort_grace": defaults.VMM.CloudHypervisor.AbortGrace,
+ } {
+ resolver.SetDefault(key, value)
+ }
+ return &Loader{resolver: resolver}
+}
+
+// BindFlag gives one Cobra flag precedence over environment, file, and default
+// values. Bindings must be completed before Load is called.
+func (l *Loader) BindFlag(key string, flag *pflag.Flag) error {
+ if l == nil || l.resolver == nil {
+ return errors.New("config loader is not initialized")
+ }
+ if flag == nil {
+ return fmt.Errorf("bind config key %q: flag is missing", key)
+ }
+ return l.resolver.BindPFlag(key, flag)
+}
+
+// Load reads one explicitly requested config file and resolves all registered
+// sources. An empty path deliberately skips filesystem config discovery.
+func (l *Loader) Load(path string) (Config, error) {
+ if l == nil || l.resolver == nil {
+ return Config{}, errors.New("config loader is not initialized")
+ }
+ if path != "" {
+ l.resolver.SetConfigFile(path)
+ if err := l.resolver.ReadInConfig(); err != nil {
+ return Config{}, fmt.Errorf("read config %s: %w", path, err)
+ }
+ }
+ var result Config
+ if err := l.resolver.UnmarshalExact(&result); err != nil {
+ return Config{}, fmt.Errorf("decode config: %w", err)
+ }
+ if err := result.Validate(); err != nil {
+ return Config{}, fmt.Errorf("validate config: %w", err)
+ }
+ return result, nil
+}
diff --git a/config/config_test.go b/config/config_test.go
new file mode 100644
index 0000000..f09381d
--- /dev/null
+++ b/config/config_test.go
@@ -0,0 +1,144 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/spf13/pflag"
+)
+
+func TestLoaderPrecedenceAndIsolation(t *testing.T) {
+ base := t.TempDir()
+ file := filepath.Join(base, "config.yaml")
+ contents := []byte("paths:\n data: " + filepath.Join(base, "file-data") + "\nimages:\n parallelism: 2\nvmm:\n cloud_hypervisor:\n startup_timeout: 12s\n")
+ if err := os.WriteFile(file, contents, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("KUMABOX_IMAGES_PARALLELISM", "3")
+
+ flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
+ flags.String("root-dir", "", "")
+ if err := flags.Set("root-dir", filepath.Join(base, "flag-data")); err != nil {
+ t.Fatal(err)
+ }
+ loader := NewLoader()
+ if err := loader.BindFlag("paths.data", flags.Lookup("root-dir")); err != nil {
+ t.Fatal(err)
+ }
+ got, err := loader.Load(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expected := Default()
+ expected.Paths.Data = filepath.Join(base, "flag-data")
+ if err := expected.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if got.Paths.Data != expected.Paths.Data || got.Images.Parallelism != 3 || got.VMM.CloudHypervisor.StartupTimeout != 12*time.Second {
+ t.Fatalf("resolved config = %+v", got)
+ }
+ expected = Default()
+ if err := expected.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if got.Paths.Run != expected.Paths.Run || got.Sandbox.Ext4Binary != "mkfs.ext4" {
+ t.Fatalf("defaults were not retained: %+v", got)
+ }
+
+ isolated, err := NewLoader().Load("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if isolated.Paths.Data != expected.Paths.Data {
+ t.Fatalf("loader state leaked: data root = %q", isolated.Paths.Data)
+ }
+}
+
+func TestUnchangedFlagDoesNotOverrideFile(t *testing.T) {
+ base := t.TempDir()
+ file := filepath.Join(base, "config.json")
+ dataRoot := filepath.Join(base, "file-data")
+ if err := os.WriteFile(file, []byte(`{"paths":{"data":"`+dataRoot+`"}}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
+ flags.String("root-dir", Default().Paths.Data, "")
+ loader := NewLoader()
+ if err := loader.BindFlag("paths.data", flags.Lookup("root-dir")); err != nil {
+ t.Fatal(err)
+ }
+ got, err := loader.Load(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expected := Default()
+ expected.Paths.Data = dataRoot
+ if err := expected.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if got.Paths.Data != expected.Paths.Data {
+ t.Fatalf("data root = %q, want file value %q", got.Paths.Data, expected.Paths.Data)
+ }
+}
+
+func TestLoaderRejectsInvalidInput(t *testing.T) {
+ base := t.TempDir()
+ for _, test := range []struct {
+ name string
+ path string
+ content string
+ }{
+ {name: "missing explicit file", path: filepath.Join(base, "missing.yaml")},
+ {name: "unknown key", path: filepath.Join(base, "unknown.yaml"), content: "unknown: true\n"},
+ {name: "invalid duration", path: filepath.Join(base, "duration.yaml"), content: "metadata:\n retry_limit: soon\n"},
+ {name: "invalid limit", path: filepath.Join(base, "limit.yaml"), content: "images:\n parallelism: 0\n"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ if test.content != "" {
+ if err := os.WriteFile(test.path, []byte(test.content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := NewLoader().Load(test.path); err == nil {
+ t.Fatal("Load() accepted invalid configuration")
+ }
+ })
+ }
+}
+
+func TestValidateRejectsOverlappingRoots(t *testing.T) {
+ config := Default()
+ config.Paths.Data = t.TempDir()
+ config.Paths.Run = filepath.Join(config.Paths.Data, "run")
+ config.Paths.Log = filepath.Join(t.TempDir(), "log")
+ if err := config.Validate(); err == nil {
+ t.Fatal("Validate() accepted overlapping roots")
+ }
+}
+
+func TestNetworkConfigParsesDNSAndScope(t *testing.T) {
+ config := Default()
+ config.Network.DNS = "10.0.0.2; 2001:4860:4860::8888"
+ config.Network.Scope = "k1"
+ if err := config.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ servers, err := config.Network.DNSServers()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(servers) != 2 || servers[0] != "10.0.0.2" || config.Network.NamespacePrefix() != "k1-" {
+ t.Fatalf("servers=%v prefix=%q", servers, config.Network.NamespacePrefix())
+ }
+ config.Network.Scope = "unsafe/"
+ if err := config.Validate(); err == nil {
+ t.Fatal("invalid network scope was accepted")
+ }
+ config = Default()
+ config.Network.DNS = "not-an-address"
+ if err := config.Validate(); err == nil {
+ t.Fatal("invalid DNS server was accepted")
+ }
+}
diff --git a/core/images.go b/core/images.go
new file mode 100644
index 0000000..8ffc9d8
--- /dev/null
+++ b/core/images.go
@@ -0,0 +1,132 @@
+// Package core owns application workflows that cross module boundaries and
+// assembles their concrete adapters. Module-local policies remain with modules.
+//
+// Image command assembly:
+//
+// config.Paths --> images.Paths ------------+
+// | |
+// +--> SQLite --> catalog --> ImageStore
+// |
+// source + EROFS + reporter +--> images.Importer
+package core
+
+import (
+ "context"
+ "time"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/images/catalog"
+ "github.com/kumabox/kumabox/images/erofs"
+ "github.com/kumabox/kumabox/images/source"
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/metadata/sqlite"
+ networkcni "github.com/kumabox/kumabox/network/cni"
+ sandboxcatalog "github.com/kumabox/kumabox/sandbox/catalog"
+ snapshotcatalog "github.com/kumabox/kumabox/snapshot/catalog"
+ "github.com/kumabox/kumabox/types"
+)
+
+// ImageStore owns the resources assembled for one image command.
+// Call Close after using its catalog and managed artifact paths.
+type ImageStore struct {
+ // Paths locates persistent artifacts, staging directories, and image locks.
+ Paths images.Paths
+ // Catalog exposes image metadata operations backed by the owned store.
+ Catalog images.Catalog
+ // options is the validated image policy used by lazily created adapters.
+ options config.Images
+ // store owns the database connection released by Close.
+ store metadata.Store
+}
+
+// OpenImages ensures managed directories and opens the image metadata catalog.
+// It does not probe conversion tools, so metadata queries do not require EROFS.
+func OpenImages(ctx context.Context, configuration config.Config) (*ImageStore, error) {
+ if err := configuration.Validate(); err != nil {
+ return nil, err
+ }
+ paths, err := images.NewPaths(configuration.Paths)
+ if err != nil {
+ return nil, err
+ }
+ if err := paths.Ensure(); err != nil {
+ return nil, err
+ }
+ store, err := sqlite.Open(ctx, paths.MetadataDB(), metadataCollections(), sqlite.Options{
+ BusyTimeout: configuration.Metadata.BusyTimeout,
+ RetryLimit: configuration.Metadata.RetryLimit,
+ })
+ if err != nil {
+ return nil, err
+ }
+ imageCatalog := catalog.New(store, catalog.WithImageUsage(sandboxcatalog.Usage{}))
+ return &ImageStore{Paths: paths, Catalog: imageCatalog, options: configuration.Images, store: store}, nil
+}
+
+// Close releases the metadata store after all catalog operations have finished.
+func (s *ImageStore) Close() error { return s.store.Close() }
+
+// NewImageImporter adds a converter only when an operation needs to import layers.
+func NewImageImporter(ctx context.Context, store *ImageStore, reporter images.Reporter, platform types.Platform) (*images.Importer, error) {
+ options := images.Options{Limits: imageLimits(store.options), Parallelism: store.options.Parallelism, Now: time.Now}
+ converter, err := erofs.New(ctx, platform.Architecture, erofs.Options{
+ Binary: store.options.EROFSBinary,
+ Limits: options.Limits,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return images.NewImporter(store.Paths, store.Catalog, converter, reporter, options)
+}
+
+// LocalImageOptions selects a local source without exposing adapter types to callers.
+type LocalImageOptions struct {
+ // Format is auto, docker, or oci; auto detects the source contents.
+ Format string
+ // SourceTag selects an image inside a multi-image Docker save archive.
+ SourceTag string
+}
+
+// Validate rejects unsupported formats before a command opens its metadata store.
+func (o LocalImageOptions) Validate() error {
+ _, err := source.ParseFormat(o.Format)
+ return err
+}
+
+// OpenLocalSource detects or selects the local adapter and stages archives as needed.
+// On success, the caller must invoke the returned cleanup after using the source.
+// Directory sources also return cleanup, allowing the caller to use one lifecycle.
+func (s *ImageStore) OpenLocalSource(ctx context.Context, path string, options LocalImageOptions) (images.Source, func() error, error) {
+ format, err := source.ParseFormat(options.Format)
+ if err != nil {
+ return nil, nil, err
+ }
+ return source.OpenLocal(ctx, path, s.Paths.StagingDir(), source.LocalOptions{
+ Format: format, SourceTag: options.SourceTag, Limits: imageLimits(s.options),
+ })
+}
+
+// imageLimits translates application configuration into the image module's
+// immutable stream and artifact bounds.
+func imageLimits(options config.Images) images.Limits {
+ return images.Limits{
+ LayerSize: options.LayerSize, UnpackedSize: options.UnpackedSize,
+ BootSize: options.BootSize, ArchiveSize: options.ArchiveSize,
+ }
+}
+
+// NewRegistrySource selects the registry adapter and returns the normalized local name.
+func NewRegistrySource(reference string) (images.Source, string, error) {
+ return source.NewRegistry(reference)
+}
+
+// metadataCollections declares the complete schema opened by every command.
+// Initializing all collections together prevents command order from changing
+// the database shape without an explicit migration.
+func metadataCollections() []metadata.Collection {
+ result := catalog.Collections()
+ result = append(result, sandboxcatalog.Collections()...)
+ result = append(result, networkcni.Collections()...)
+ return append(result, snapshotcatalog.Collections()...)
+}
diff --git a/core/sandbox.go b/core/sandbox.go
new file mode 100644
index 0000000..07cc4a9
--- /dev/null
+++ b/core/sandbox.go
@@ -0,0 +1,296 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/disk"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ imagecatalog "github.com/kumabox/kumabox/images/catalog"
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/metadata/sqlite"
+ "github.com/kumabox/kumabox/network"
+ "github.com/kumabox/kumabox/network/cni"
+ "github.com/kumabox/kumabox/sandbox"
+ sandboxcatalog "github.com/kumabox/kumabox/sandbox/catalog"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+// CreateSandboxRequest contains user intent before image aliases are resolved.
+type CreateSandboxRequest struct {
+ // ImageReference is an existing local image alias or manifest digest.
+ ImageReference string
+ // Config contains the immutable name and guest resource shape.
+ Config types.SandboxConfig
+ // VMM selects the runtime backend; empty uses the configured default.
+ VMM types.VMMType
+}
+
+// imageGuard is the image capability consumed by sandbox creation.
+type imageGuard interface {
+ WithAvailable(context.Context, string, func(types.Image) error) (types.Image, error)
+}
+
+// sandboxCatalog is the complete metadata capability consumed by the sandbox
+// application service. One adapter owns the aggregate and its state machine,
+// so the service receives it once instead of under several role aliases.
+type sandboxCatalog interface {
+ Reserve(context.Context, string, types.Digest, types.Sandbox) error
+ MarkCreated(context.Context, types.SandboxID, uint64, types.NetworkSetup, time.Time) (types.Sandbox, error)
+ MarkError(context.Context, types.SandboxID, uint64, types.SandboxFailure, time.Time) (types.Sandbox, error)
+ Forget(context.Context, types.SandboxID, uint64) error
+ Resolve(context.Context, string) (types.Sandbox, error)
+ List(context.Context) ([]types.Sandbox, error)
+ BeginDelete(context.Context, types.SandboxID, uint64, time.Time) (types.Sandbox, error)
+ FinalizeDelete(context.Context, types.SandboxID, uint64) error
+ BeginStart(context.Context, types.SandboxID, uint64, time.Time) (types.Sandbox, error)
+ MarkRunning(context.Context, types.SandboxID, uint64, time.Time) (types.Sandbox, error)
+ MarkStartError(context.Context, types.SandboxID, uint64, types.SandboxFailure, time.Time) (types.Sandbox, error)
+ BeginStop(context.Context, types.SandboxID, uint64, time.Time) (types.Sandbox, error)
+ MarkStopped(context.Context, types.SandboxID, uint64, types.SandboxState, time.Time) (types.Sandbox, error)
+}
+
+var (
+ _ imageGuard = (*images.Guard)(nil)
+ _ sandboxCatalog = (*sandboxcatalog.Store)(nil)
+)
+
+// SandboxReporter receives user-visible stages without controlling workflows.
+type SandboxReporter interface {
+ Status(string) error
+ Committed(types.Sandbox) error
+}
+
+// sandboxDependencies names every adapter and policy consumed by SandboxService.
+// Keeping construction package-local avoids turning test seams into public API.
+type sandboxDependencies struct {
+ // paths supplies the stable per-sandbox operation lock path.
+ paths sandbox.Paths
+ // images closes the verify/pin race with image removal.
+ images imageGuard
+ // catalog owns sandbox records, names, image pins, and state transitions.
+ catalog sandboxCatalog
+ // disks prepares and cleans sandbox-owned writable disks.
+ disks disk.Backend
+ // networks routes persisted network identities to provider adapters.
+ networks *network.Registry
+ // defaultNetwork selects the provider for newly created networked sandboxes.
+ defaultNetwork types.NetworkBackend
+ // imagePaths derives immutable artifacts after the image guard verifies them.
+ imagePaths images.Paths
+ // runtimes route persisted VMM identities to process adapters.
+ runtimes *vmm.Registry
+ // defaultVMM selects the runtime when create does not specify one.
+ defaultVMM types.VMMType
+ // cleanupTimeout bounds compensation that outlives caller cancellation.
+ cleanupTimeout time.Duration
+ // dnsServers are rendered into static guest boot network parameters.
+ dnsServers []string
+ // reporter emits progress independently of command results.
+ reporter SandboxReporter
+ // newID and now are replaceable in same-package tests.
+ newID func() (types.SandboxID, error)
+ now func() time.Time
+ // store is the shared metadata engine closed after the command finishes.
+ store metadata.Store
+}
+
+// SandboxService owns application ordering and resources for sandbox commands.
+type SandboxService struct {
+ dependencies sandboxDependencies
+}
+
+// newSandboxService validates and records the explicit capabilities needed by
+// sandbox commands. Defaults are limited to deterministic process-local seams.
+func newSandboxService(dependencies sandboxDependencies) (*SandboxService, error) {
+ if dependencies.images == nil || dependencies.catalog == nil || dependencies.disks == nil || dependencies.networks == nil || dependencies.runtimes.Len() == 0 {
+ return nil, errors.New("sandbox service adapters are incomplete")
+ }
+ if dependencies.cleanupTimeout <= 0 {
+ return nil, errors.New("sandbox cleanup timeout must be positive")
+ }
+ if _, err := dependencies.runtimes.Backend(dependencies.defaultVMM); err != nil {
+ return nil, err
+ }
+ if _, err := dependencies.networks.Provider(dependencies.defaultNetwork); err != nil {
+ return nil, err
+ }
+ if dependencies.reporter == nil {
+ dependencies.reporter = discardReporter{}
+ }
+ if dependencies.newID == nil {
+ dependencies.newID = types.NewSandboxID
+ }
+ if dependencies.now == nil {
+ dependencies.now = time.Now
+ }
+ return &SandboxService{dependencies: dependencies}, nil
+}
+
+// OpenSandbox assembles the image guard, metadata catalog, and ext4 COW adapter
+// used by sandbox commands. The caller must close the returned service.
+//
+// shared SQLite -> image catalog <---- transaction reader ---- sandbox catalog
+// | ^ |
+// +---- usage ---+---- image guard + ext4 COW ----------> service
+func OpenSandbox(ctx context.Context, configuration config.Config, reporter SandboxReporter) (*SandboxService, error) {
+ if err := configuration.Validate(); err != nil {
+ return nil, err
+ }
+ dnsServers, err := configuration.Network.DNSServers()
+ if err != nil {
+ return nil, err
+ }
+ imagePaths, err := images.NewPaths(configuration.Paths)
+ if err != nil {
+ return nil, err
+ }
+ sandboxPaths, err := sandbox.NewPaths(configuration.Paths)
+ if err != nil {
+ return nil, err
+ }
+ runtimes, err := openVMMRegistry(configuration)
+ if err != nil {
+ return nil, err
+ }
+ defaultVMM := configuration.VMM.Default
+ if _, err := runtimes.Backend(defaultVMM); err != nil {
+ return nil, err
+ }
+ disks, err := disk.NewExt4(sandboxPaths, configuration.Sandbox.Ext4Binary)
+ if err != nil {
+ return nil, err
+ }
+ if err := errors.Join(imagePaths.Ensure(), sandboxPaths.Ensure()); err != nil {
+ return nil, err
+ }
+ store, err := sqlite.Open(ctx, imagePaths.MetadataDB(), metadataCollections(), sqlite.Options{
+ BusyTimeout: configuration.Metadata.BusyTimeout,
+ RetryLimit: configuration.Metadata.RetryLimit,
+ })
+ if err != nil {
+ return nil, err
+ }
+ cacheDir, err := storage.Join(configuration.Paths.Data, "cni", "cache")
+ if err != nil {
+ return nil, errors.Join(err, store.Close())
+ }
+ cniProvider, err := cni.New(cni.Options{
+ ConfDir: configuration.Network.CNI.ConfDir,
+ BinDir: configuration.Network.CNI.BinDir,
+ CacheDir: cacheDir,
+ NamespacePrefix: configuration.Network.NamespacePrefix(),
+ CleanupTimeout: configuration.Network.CleanupTimeout,
+ }, store)
+ if err != nil {
+ return nil, errors.Join(err, store.Close())
+ }
+ networks, err := network.NewRegistry(cniProvider)
+ if err != nil {
+ return nil, errors.Join(err, store.Close())
+ }
+ imageCatalog := imagecatalog.New(store, imagecatalog.WithImageUsage(sandboxcatalog.Usage{}))
+ sandboxCatalog := sandboxcatalog.New(store, imagecatalog.Reader{})
+ service, err := newSandboxService(sandboxDependencies{
+ paths: sandboxPaths, imagePaths: imagePaths, images: images.NewGuard(imagePaths, imageCatalog),
+ catalog: sandboxCatalog, disks: disks, networks: networks, runtimes: runtimes, reporter: reporter,
+ store: store, defaultVMM: defaultVMM, defaultNetwork: types.NetworkBackendCNI,
+ cleanupTimeout: max(configuration.Sandbox.CleanupTimeout, configuration.Network.CleanupTimeout),
+ dnsServers: dnsServers,
+ })
+ if err != nil {
+ return nil, errors.Join(err, store.Close())
+ }
+ return service, nil
+}
+
+// Close releases the shared metadata engine owned by the service.
+func (s *SandboxService) Close() error {
+ if s == nil || s.dependencies.store == nil {
+ return nil
+ }
+ return s.dependencies.store.Close()
+}
+
+// networkProvider resolves the provider that owns a sandbox's durable network
+// state. Creating or retained-error records without a published setup fall
+// back to the configured creation backend so cleanup can still resume.
+func (s *SandboxService) networkProvider(record types.Sandbox) (network.Provider, bool, error) {
+ if record.Config.NICs == 0 && record.Network.Backend == "" {
+ return nil, false, nil
+ }
+ backend := record.Network.Backend
+ if backend == "" {
+ backend = s.dependencies.defaultNetwork
+ }
+ provider, err := s.dependencies.networks.Provider(backend)
+ return provider, true, err
+}
+
+// Run creates and starts one sandbox as a single application use case. Create
+// owns compensation until Created is durable; after that point a failed start
+// retains the sandbox and its failure state for inspection and retry.
+//
+// image + config -> Create -> Created -> Start -> Running
+// | |
+// +----------+-> retained on start failure
+func (s *SandboxService) Run(ctx context.Context, request CreateSandboxRequest) (types.Sandbox, error) {
+ created, err := s.Create(ctx, request)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ running, err := s.Start(ctx, created.ID.String())
+ if err != nil {
+ return created, errdefs.Context(
+ err, "run sandbox", request.Config.Name, "start",
+ "inspect the retained sandbox and VMM log before retrying", true,
+ )
+ }
+ return running, nil
+}
+
+// List returns a consistent sandbox snapshot. Unless includeAll is true, only
+// states associated with an active VMM operation are returned.
+func (s *SandboxService) List(ctx context.Context, includeAll bool) ([]types.Sandbox, error) {
+ if s == nil || s.dependencies.catalog == nil {
+ return nil, errors.New("sandbox service is not configured")
+ }
+ records, err := s.dependencies.catalog.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if includeAll {
+ return records, nil
+ }
+ active := make([]types.Sandbox, 0, len(records))
+ for _, record := range records {
+ switch record.State {
+ case types.SandboxStateStarting, types.SandboxStateRunning, types.SandboxStateStopping:
+ active = append(active, record)
+ }
+ }
+ return active, nil
+}
+
+// Inspect resolves one sandbox snapshot without changing persistent or runtime state.
+// Runtime observation will be added here when the VMM lifecycle is available.
+func (s *SandboxService) Inspect(ctx context.Context, reference string) (types.Sandbox, error) {
+ if s == nil || s.dependencies.catalog == nil {
+ return types.Sandbox{}, errors.New("sandbox service is not configured")
+ }
+ if reference == "" {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX must not be empty"))
+ }
+ return s.dependencies.catalog.Resolve(ctx, reference)
+}
+
+// discardReporter keeps reporting optional for non-CLI consumers.
+type discardReporter struct{}
+
+func (discardReporter) Status(string) error { return nil }
+func (discardReporter) Committed(types.Sandbox) error { return nil }
diff --git a/core/sandbox_runtime.go b/core/sandbox_runtime.go
new file mode 100644
index 0000000..9274864
--- /dev/null
+++ b/core/sandbox_runtime.go
@@ -0,0 +1,604 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "reflect"
+ "runtime"
+
+ "github.com/kumabox/kumabox/agent"
+ "github.com/kumabox/kumabox/errdefs"
+ filelock "github.com/kumabox/kumabox/lock/flock"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+// Start validates persistent inputs, recovers an interrupted launch when
+// possible, and commits Running only after the exact VMM reports readiness.
+//
+// resolve + lock -> verify image/COW -> Starting -> launch -> API Running
+// ^ | |
+// +---- retry ----+-------- CAS Running+
+// |
+// abort + retained Error
+func (s *SandboxService) Start(ctx context.Context, reference string) (result types.Sandbox, returnErr error) {
+ if s == nil || s.dependencies.catalog == nil || s.dependencies.images == nil || s.dependencies.disks == nil || s.dependencies.networks == nil || s.dependencies.runtimes.Len() == 0 || s.dependencies.reporter == nil || s.dependencies.now == nil {
+ return types.Sandbox{}, errors.New("sandbox service is not configured")
+ }
+ if reference == "" {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX must not be empty"))
+ }
+ if err := s.dependencies.reporter.Status("resolving sandbox"); err != nil {
+ return types.Sandbox{}, err
+ }
+ record, err := s.dependencies.catalog.Resolve(ctx, reference)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ lockPath, err := s.dependencies.paths.Lock(record.ID)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ if err := s.dependencies.reporter.Status("waiting for sandbox operation lock"); err != nil {
+ return types.Sandbox{}, err
+ }
+ lock := filelock.New(lockPath)
+ if err := lock.Lock(ctx); err != nil {
+ return types.Sandbox{}, errdefs.Context(err, "start sandbox", reference, "lock", "retry the start", false)
+ }
+ committed := false
+ defer func() {
+ if unlockErr := lock.Unlock(context.WithoutCancel(ctx)); unlockErr != nil {
+ returnErr = errdefs.Context(errors.Join(returnErr, unlockErr), "start sandbox", reference, "unlock", "inspect the sandbox before retrying", committed)
+ }
+ }()
+
+ // The first resolve selects the lock; this second resolve is authoritative.
+ record, err = s.dependencies.catalog.Resolve(ctx, record.ID.String())
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ backend, err := s.dependencies.runtimes.Backend(record.VMM)
+ if err != nil {
+ return record, err
+ }
+ beforeRecovery := record
+ result, done, err := s.recoverStart(ctx, backend, record)
+ if err != nil {
+ return types.Sandbox{}, errdefs.Context(err, "start sandbox", reference, "recover runtime", "inspect the sandbox and VMM log before retrying", false)
+ }
+ committed = result.Generation != beforeRecovery.Generation || result.State != beforeRecovery.State
+ if done {
+ committed = true
+ if err := s.dependencies.reporter.Committed(result); err != nil {
+ return result, errdefs.Context(err, "start sandbox", reference, "report", "sandbox is running; inspect it before retrying", true)
+ }
+ return result, nil
+ }
+ record = result
+ failBeforeLaunch := func(phase string, cause error) error {
+ if record.State == types.SandboxStateStarting {
+ return s.failStart(ctx, backend, record, phase, cause, vmm.Process{})
+ }
+ return errdefs.Context(cause, "start sandbox", reference, phase, "fix the validation failure and retry", committed)
+ }
+
+ if err := s.dependencies.reporter.Status("checking host runtime"); err != nil {
+ return record, failBeforeLaunch("report", err)
+ }
+ if err := backend.Preflight(); err != nil {
+ return record, failBeforeLaunch("host preflight", err)
+ }
+ if int(record.Config.CPUs) > runtime.NumCPU() {
+ return record, failBeforeLaunch("host capacity", errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, fmt.Errorf("requested %d vCPUs exceeds available host CPUs (%d)", record.Config.CPUs, runtime.NumCPU())))
+ }
+ if err := s.dependencies.reporter.Status("verifying image and sandbox disk"); err != nil {
+ return record, failBeforeLaunch("report", err)
+ }
+ var plan vmm.LaunchPlan
+ _, err = s.dependencies.images.WithAvailable(ctx, record.ImageDigest.String(), func(image types.Image) error {
+ var buildErr error
+ plan, buildErr = s.launchPlan(record, image)
+ if buildErr != nil {
+ return buildErr
+ }
+ return s.dependencies.disks.Check(ctx, record.ID, record.Config.Storage)
+ })
+ if err != nil {
+ return record, failBeforeLaunch("validate artifacts", err)
+ }
+ if err := s.dependencies.reporter.Status("committing starting state"); err != nil {
+ return record, failBeforeLaunch("report", err)
+ }
+ starting, err := s.dependencies.catalog.BeginStart(ctx, record.ID, record.Generation, s.dependencies.now().UTC())
+ if err != nil {
+ return record, errdefs.Context(err, "start sandbox", reference, "mark starting", "inspect the sandbox before retrying", committed)
+ }
+ committed = true
+ result = starting
+ plan.Generation = starting.Generation
+ if err := plan.Validate(); err != nil {
+ return starting, s.failStart(ctx, backend, starting, "build launch plan", err, vmm.Process{})
+ }
+ if err := s.recoverNetwork(ctx, starting); err != nil {
+ return starting, s.failStart(ctx, backend, starting, "recover network", err, vmm.Process{})
+ }
+ if err := s.dependencies.reporter.Status("launching " + string(backend.Type())); err != nil {
+ return starting, s.failStart(ctx, backend, starting, "report", err, vmm.Process{})
+ }
+ process, err := backend.Launch(ctx, plan)
+ if err != nil {
+ return starting, s.failStart(ctx, backend, starting, "launch VMM", err, process)
+ }
+ if err := s.dependencies.reporter.Status("committing running state"); err != nil {
+ return starting, s.failStart(ctx, backend, starting, "report", err, process)
+ }
+ running, err := s.dependencies.catalog.MarkRunning(ctx, starting.ID, starting.Generation, s.dependencies.now().UTC())
+ if err != nil {
+ return starting, s.failStart(ctx, backend, starting, "commit running", err, process)
+ }
+ result = running
+ if err := s.dependencies.reporter.Committed(running); err != nil {
+ return running, errdefs.Context(err, "start sandbox", reference, "report", "sandbox is running; inspect it before retrying", true)
+ }
+ return running, nil
+}
+
+// recoverStart reconciles durable lifecycle state with an owned process. The
+// returned boolean is true when Running is already established.
+func (s *SandboxService) recoverStart(ctx context.Context, backend vmm.Backend, record types.Sandbox) (types.Sandbox, bool, error) {
+ switch record.State {
+ case types.SandboxStateCreating, types.SandboxStateStopping, types.SandboxStateDeleting:
+ return record, false, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s in state %s cannot start", record.ID, record.State))
+ }
+ expected := record.Generation
+ if record.State == types.SandboxStateRunning {
+ if record.Generation < 2 {
+ return record, false, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("running sandbox has no Starting generation"))
+ }
+ expected--
+ }
+ if err := s.dependencies.reporter.Status("checking existing runtime"); err != nil {
+ return record, false, err
+ }
+ observation, err := backend.Observe(ctx, record.ID, expected)
+ if err != nil {
+ return record, false, err
+ }
+ switch record.State {
+ case types.SandboxStateRunning:
+ switch observation.State {
+ case vmm.ProcessRunning:
+ return record, true, nil
+ case vmm.ProcessStarting:
+ if err := backend.WaitReady(ctx, observation.Process); err != nil {
+ return record, false, err
+ }
+ return record, true, nil
+ case vmm.ProcessAbsent:
+ if err := backend.Cleanup(ctx, record.ID); err != nil {
+ return record, false, err
+ }
+ if err := s.quiesceNetwork(ctx, record); err != nil {
+ return record, false, err
+ }
+ stopped, err := s.dependencies.catalog.MarkStopped(ctx, record.ID, record.Generation, types.SandboxStateRunning, s.dependencies.now().UTC())
+ return stopped, false, err
+ }
+ case types.SandboxStateStarting:
+ switch observation.State {
+ case vmm.ProcessRunning:
+ running, err := s.dependencies.catalog.MarkRunning(ctx, record.ID, record.Generation, s.dependencies.now().UTC())
+ return running, err == nil, err
+ case vmm.ProcessStarting:
+ if err := backend.WaitReady(ctx, observation.Process); err != nil {
+ return record, false, s.failStart(ctx, backend, record, "recover VMM", err, observation.Process)
+ }
+ running, err := s.dependencies.catalog.MarkRunning(ctx, record.ID, record.Generation, s.dependencies.now().UTC())
+ if err != nil {
+ return record, false, s.failStart(ctx, backend, record, "commit recovered VMM", err, observation.Process)
+ }
+ return running, true, nil
+ case vmm.ProcessAbsent:
+ if err := backend.Cleanup(ctx, record.ID); err != nil {
+ return record, false, err
+ }
+ return record, false, nil
+ }
+ default:
+ if observation.State != vmm.ProcessAbsent {
+ return record, false, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s has a live VMM while state is %s", record.ID, record.State))
+ }
+ if err := backend.Cleanup(ctx, record.ID); err != nil {
+ return record, false, err
+ }
+ return record, false, nil
+ }
+ return record, false, errdefs.New(errdefs.ClassInternal, errdefs.CodeInternal, fmt.Errorf("unknown VMM observation %q", observation.State))
+}
+
+// launchPlan maps a pinned image and sandbox resource request into the public
+// overlay-v1 guest ABI. It does not inspect or mutate host files.
+func (s *SandboxService) launchPlan(record types.Sandbox, image types.Image) (vmm.LaunchPlan, error) {
+ if image.ManifestDigest != record.ImageDigest {
+ return vmm.LaunchPlan{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("resolved image differs from the sandbox pin"))
+ }
+ if image.Platform.OS != "linux" || image.Platform.Architecture != runtime.GOARCH {
+ return vmm.LaunchPlan{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeImageIncompatible, fmt.Errorf("image platform %s/%s cannot run on %s/%s", image.Platform.OS, image.Platform.Architecture, runtime.GOOS, runtime.GOARCH))
+ }
+ if image.Boot.Profile != types.BootProfileOverlayV1 {
+ return vmm.LaunchPlan{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeImageIncompatible, fmt.Errorf("image boot profile %q is not supported; expected %q", image.Boot.Profile, types.BootProfileOverlayV1))
+ }
+ kernel, err := s.dependencies.imagePaths.BootFile(image.Boot.KernelLayer, image.Boot.KernelFile)
+ if err != nil {
+ return vmm.LaunchPlan{}, err
+ }
+ initrd, err := s.dependencies.imagePaths.BootFile(image.Boot.InitrdLayer, image.Boot.InitrdFile)
+ if err != nil {
+ return vmm.LaunchPlan{}, err
+ }
+ cmdline, err := vmm.OverlayV1Cmdline(vmm.OverlayV1Config{
+ LayerCount: len(image.Layers), Hostname: record.Config.Name,
+ Interfaces: record.Network.Interfaces, DNSServers: s.dependencies.dnsServers,
+ })
+ if err != nil {
+ return vmm.LaunchPlan{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeImageIncompatible, err)
+ }
+ disks := make([]vmm.Disk, 0, len(image.Layers)+1)
+ for position, layer := range image.Layers {
+ disks = append(disks, vmm.Disk{Path: s.dependencies.imagePaths.EROFS(layer.SourceDigest), Serial: fmt.Sprintf("%s%d", vmm.LayerSerialPrefix, position), ReadOnly: true})
+ }
+ cow, err := s.dependencies.paths.COW(record.ID)
+ if err != nil {
+ return vmm.LaunchPlan{}, err
+ }
+ disks = append(disks, vmm.Disk{Path: cow, Serial: vmm.COWSerial})
+ return vmm.LaunchPlan{
+ SandboxID: record.ID, CPUs: record.Config.CPUs, Memory: record.Config.Memory,
+ BootProfile: image.Boot.Profile, Kernel: kernel, Initrd: initrd, Cmdline: cmdline, Disks: disks,
+ Network: record.Network,
+ }, nil
+}
+
+// recoverNetwork verifies retained host plumbing or rebuilds it with the
+// persisted guest MAC and IP identity before the VMM opens any TAP.
+func (s *SandboxService) recoverNetwork(ctx context.Context, record types.Sandbox) error {
+ provider, hasNetwork, err := s.networkProvider(record)
+ if err != nil || !hasNetwork {
+ return err
+ }
+ if record.Network.Backend == "" {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("sandbox network creation is incomplete"))
+ }
+ if err := s.dependencies.reporter.Status("recovering sandbox network"); err != nil {
+ return err
+ }
+ recovered, err := provider.Recover(ctx, record.ID, record.Config.NetworkName, record.Network.Interfaces)
+ if err != nil {
+ return err
+ }
+ if !reflect.DeepEqual(recovered, record.Network.Interfaces) {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("recovered network identity differs from persisted sandbox state"))
+ }
+ return nil
+}
+
+// quiesceNetwork lowers retained CNI-side links after process absence. Keeping
+// this inside the sandbox operation lock prevents a queued start from racing
+// with a late link-down operation.
+func (s *SandboxService) quiesceNetwork(ctx context.Context, record types.Sandbox) error {
+ provider, hasNetwork, err := s.networkProvider(record)
+ if err != nil || !hasNetwork || record.Network.Backend == "" {
+ return err
+ }
+ reportErr := s.dependencies.reporter.Status("quiescing sandbox network")
+ // Presentation failure must not leave an otherwise stoppable host link up.
+ return errors.Join(reportErr, provider.Quiesce(ctx, record.ID))
+}
+
+// failStart cleans only the exact process identity (when available) and retains
+// an Error record so the next start or removal has an explicit owner.
+func (s *SandboxService) failStart(ctx context.Context, backend vmm.Backend, starting types.Sandbox, phase string, cause error, process vmm.Process) error {
+ cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.dependencies.cleanupTimeout)
+ defer cancel()
+ var cleanupErr error
+ if process.PID > 0 {
+ cleanupErr = backend.Abort(cleanupCtx, process)
+ } else {
+ cleanupErr = backend.Cleanup(cleanupCtx, starting.ID)
+ }
+ cleanupErr = errors.Join(cleanupErr, s.quiesceNetwork(cleanupCtx, starting))
+ failureCause := errors.Join(cause, cleanupErr)
+ failure := types.SandboxFailure{Phase: phase, Message: failureCause.Error()}
+ _, markErr := s.dependencies.catalog.MarkStartError(cleanupCtx, starting.ID, starting.Generation, failure, s.dependencies.now().UTC())
+ return errdefs.Context(errors.Join(failureCause, markErr), "start sandbox", starting.Config.Name, phase, "inspect the retained error sandbox and VMM log", true)
+}
+
+// Stop terminates the exact VMM process owned by one sandbox and commits
+// Stopped only after process absence and runtime cleanup are proven.
+//
+// Running + live VMM -> Stopping -> TERM -> grace -> KILL -> cleanup -> Stopped
+// Starting/Stopping ----- retry resumes the owned process generation -----^
+// Running + no VMM --------------------- cleanup ------------------------^
+func (s *SandboxService) Stop(ctx context.Context, reference string) (result types.Sandbox, returnErr error) {
+ if s == nil || s.dependencies.catalog == nil || s.dependencies.networks == nil || s.dependencies.runtimes.Len() == 0 || s.dependencies.reporter == nil || s.dependencies.now == nil {
+ return types.Sandbox{}, errors.New("sandbox service is not configured")
+ }
+ if reference == "" {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX must not be empty"))
+ }
+ if err := s.dependencies.reporter.Status("resolving sandbox"); err != nil {
+ return types.Sandbox{}, err
+ }
+ record, err := s.dependencies.catalog.Resolve(ctx, reference)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ lockPath, err := s.dependencies.paths.Lock(record.ID)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ if err := s.dependencies.reporter.Status("waiting for sandbox operation lock"); err != nil {
+ return types.Sandbox{}, err
+ }
+ lock := filelock.New(lockPath)
+ if err := lock.Lock(ctx); err != nil {
+ return types.Sandbox{}, errdefs.Context(err, "stop sandbox", reference, "lock", "retry the stop", false)
+ }
+ committed := false
+ defer func() {
+ if unlockErr := lock.Unlock(context.WithoutCancel(ctx)); unlockErr != nil {
+ returnErr = errdefs.Context(errors.Join(returnErr, unlockErr), "stop sandbox", reference, "unlock", "inspect the sandbox before retrying", committed)
+ }
+ }()
+
+ // The first resolve selects the lock; this second resolve is authoritative.
+ record, err = s.dependencies.catalog.Resolve(ctx, record.ID.String())
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ backend, err := s.dependencies.runtimes.Backend(record.VMM)
+ if err != nil {
+ return record, err
+ }
+ result = record
+ if record.State == types.SandboxStateCreating || record.State == types.SandboxStateDeleting {
+ return record, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s in state %s cannot stop", record.ID, record.State))
+ }
+ if record.State == types.SandboxStateCreated || record.State == types.SandboxStateStopped {
+ if err := s.dependencies.reporter.Status("cleaning stale runtime state"); err != nil {
+ return record, err
+ }
+ if err := backend.Cleanup(ctx, record.ID); err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "cleanup runtime", "inspect the runtime scope before retrying", false)
+ }
+ if err := s.quiesceNetwork(ctx, record); err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "quiesce network", "retry the stop to finish network cleanup", false)
+ }
+ if err := s.dependencies.reporter.Committed(record); err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "report", "sandbox is not running", false)
+ }
+ return record, nil
+ }
+
+ processGeneration, err := stopProcessGeneration(record)
+ if err != nil {
+ return record, err
+ }
+ if err := s.dependencies.reporter.Status("checking existing runtime"); err != nil {
+ return record, err
+ }
+ process, exists, err := backend.Locate(ctx, record.ID, processGeneration)
+ if err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "observe runtime", "inspect the sandbox runtime before retrying", false)
+ }
+
+ if record.State == types.SandboxStateRunning && exists {
+ if err := s.dependencies.reporter.Status("committing stopping state"); err != nil {
+ return record, err
+ }
+ record, err = s.dependencies.catalog.BeginStop(ctx, record.ID, record.Generation, s.dependencies.now().UTC())
+ if err != nil {
+ return result, errdefs.Context(err, "stop sandbox", reference, "mark stopping", "inspect the sandbox before retrying", false)
+ }
+ result, committed = record, true
+ }
+
+ if exists {
+ if err := s.dependencies.reporter.Status("stopping " + string(backend.Type())); err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "report", "retry the stop to resume Stopping", committed)
+ }
+ if err := backend.Stop(ctx, process); err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "stop VMM", "retry the stop; the retained state preserves ownership", committed)
+ }
+ }
+ if err := s.dependencies.reporter.Status("cleaning runtime state"); err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "report", "retry the stop to finish cleanup", committed)
+ }
+ if err := backend.Cleanup(ctx, record.ID); err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "cleanup runtime", "retry the stop to finish cleanup", committed)
+ }
+ if err := s.quiesceNetwork(ctx, record); err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "quiesce network", "retry the stop to finish network cleanup", committed)
+ }
+
+ // Error retains the original start/create diagnostic after any residual VMM
+ // is gone. It can be removed or started explicitly by the next command.
+ if record.State == types.SandboxStateError {
+ if err := s.dependencies.reporter.Committed(record); err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "report", "the VMM is stopped; inspect the retained error", committed)
+ }
+ return record, nil
+ }
+ if err := s.dependencies.reporter.Status("committing stopped state"); err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "report", "retry the stop to commit process absence", committed)
+ }
+ stopped, err := s.dependencies.catalog.MarkStopped(ctx, record.ID, record.Generation, record.State, s.dependencies.now().UTC())
+ if err != nil {
+ return record, errdefs.Context(err, "stop sandbox", reference, "mark stopped", "inspect the sandbox before retrying", committed)
+ }
+ result, committed = stopped, true
+ if err := s.dependencies.reporter.Committed(stopped); err != nil {
+ return stopped, errdefs.Context(err, "stop sandbox", reference, "report", "sandbox is stopped; inspect it before retrying", true)
+ }
+ return stopped, nil
+}
+
+// stopProcessGeneration maps durable lifecycle transitions back to the
+// Starting generation stored in process identity.
+func stopProcessGeneration(record types.Sandbox) (uint64, error) {
+ var offset uint64
+ switch record.State {
+ case types.SandboxStateStarting:
+ offset = 0
+ case types.SandboxStateRunning, types.SandboxStateError:
+ offset = 1
+ case types.SandboxStateStopping:
+ offset = 2
+ default:
+ return 0, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s in state %s has no stoppable process generation", record.ID, record.State))
+ }
+ if record.Generation <= offset {
+ return 0, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("sandbox %s state %s has invalid generation %d", record.ID, record.State, record.Generation))
+ }
+ return record.Generation - offset, nil
+}
+
+// Console opens the current direct-boot PTY after proving the sandbox record
+// and VMM process refer to the same Running generation.
+//
+// resolve -> lock -> reread Running -> locate exact process -> unlock -> open PTY
+// |
+// caller owns console session
+func (s *SandboxService) Console(ctx context.Context, reference string) (io.ReadWriteCloser, error) {
+ backend, process, err := s.locateRunning(ctx, reference, "open sandbox console")
+ if err != nil {
+ return nil, err
+ }
+ connection, err := backend.Console(ctx, process)
+ if err != nil {
+ return nil, errdefs.Context(err, "open sandbox console", reference, "open PTY", "inspect the VMM log and retry", false)
+ }
+ return connection, nil
+}
+
+// SandboxLogOptions contains application-level log selection without exposing
+// a concrete backend's filesystem layout to the CLI.
+type SandboxLogOptions struct {
+ // Tail starts output at the last N lines. Zero selects the complete log.
+ Tail int
+ // Follow keeps the stream open for appended output until cancellation.
+ Follow bool
+}
+
+// Logs streams persistent VMM output for any retained sandbox state. It does
+// not hold the entity lock while following, so start, stop, and rm can progress.
+func (s *SandboxService) Logs(ctx context.Context, reference string, options SandboxLogOptions, output io.Writer) error {
+ if s == nil || s.dependencies.catalog == nil || s.dependencies.runtimes.Len() == 0 {
+ return errors.New("sandbox service is not configured")
+ }
+ if reference == "" {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX must not be empty"))
+ }
+ backendOptions := vmm.LogOptions{Tail: options.Tail, Follow: options.Follow}
+ if err := backendOptions.Validate(); err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if output == nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("log output is required"))
+ }
+ record, err := s.dependencies.catalog.Resolve(ctx, reference)
+ if err != nil {
+ return err
+ }
+ backend, err := s.dependencies.runtimes.Backend(record.VMM)
+ if err != nil {
+ return err
+ }
+ if err := backend.Logs(ctx, record.ID, backendOptions, output); err != nil {
+ return errdefs.Context(err, "read sandbox logs", reference, "stream VMM log", "start the sandbox if it has no log, or retry the stream", false)
+ }
+ return nil
+}
+
+// Exec runs one command through the guest agent after resolving an exact live
+// VMM process. The operation lock is released before network I/O and command
+// execution so stop can always make progress.
+//
+// resolve + lock -> Running generation -> locate process -> unlock
+// |
+// vsock -> agent stream -> exit code
+func (s *SandboxService) Exec(ctx context.Context, reference string, command types.Command, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ if err := command.Validate(); err != nil {
+ return 0, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ backend, process, err := s.locateRunning(ctx, reference, "execute sandbox command")
+ if err != nil {
+ return 0, err
+ }
+ connection, err := backend.DialVsock(ctx, process, agent.Port)
+ if err != nil {
+ return 0, errdefs.Context(err, "execute sandbox command", reference, "connect guest agent", "the guest agent may still be starting; retry shortly or inspect its service", false)
+ }
+ defer connection.Close() //nolint:errcheck // closing a completed read/write session cannot change the guest command result
+ exitCode, err := agent.Run(ctx, connection, command, stdin, stdout, stderr)
+ if err != nil {
+ return 0, errdefs.Context(err, "execute sandbox command", reference, "run guest command", "inspect the guest agent and retry", false)
+ }
+ return exitCode, nil
+}
+
+// locateRunning returns an identity-checked VMM generation. It holds the
+// sandbox operation lock only while persistent and process facts are resolved.
+func (s *SandboxService) locateRunning(ctx context.Context, reference, operation string) (backend vmm.Backend, process vmm.Process, returnErr error) {
+ if s == nil || s.dependencies.catalog == nil || s.dependencies.runtimes.Len() == 0 {
+ return nil, vmm.Process{}, errors.New("sandbox service is not configured")
+ }
+ if reference == "" {
+ return nil, vmm.Process{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX must not be empty"))
+ }
+ record, err := s.dependencies.catalog.Resolve(ctx, reference)
+ if err != nil {
+ return nil, vmm.Process{}, err
+ }
+ lockPath, err := s.dependencies.paths.Lock(record.ID)
+ if err != nil {
+ return nil, vmm.Process{}, err
+ }
+ lock := filelock.New(lockPath)
+ if err := lock.Lock(ctx); err != nil {
+ return nil, vmm.Process{}, errdefs.Context(err, operation, reference, "lock", "retry the operation", false)
+ }
+ defer func() {
+ if unlockErr := lock.Unlock(context.WithoutCancel(ctx)); unlockErr != nil {
+ backend = nil
+ process = vmm.Process{}
+ returnErr = errdefs.Context(errors.Join(returnErr, unlockErr), operation, reference, "unlock", "retry the operation", false)
+ }
+ }()
+
+ record, err = s.dependencies.catalog.Resolve(ctx, record.ID.String())
+ if err != nil {
+ return nil, vmm.Process{}, err
+ }
+ if record.State != types.SandboxStateRunning {
+ return nil, vmm.Process{}, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s is %s, not running", record.ID, record.State))
+ }
+ if record.Generation < 2 {
+ return nil, vmm.Process{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("running sandbox has no Starting generation"))
+ }
+ backend, err = s.dependencies.runtimes.Backend(record.VMM)
+ if err != nil {
+ return nil, vmm.Process{}, err
+ }
+ process, exists, err := backend.Locate(ctx, record.ID, record.Generation-1)
+ if err != nil {
+ return nil, vmm.Process{}, errdefs.Context(err, operation, reference, "locate VMM", "inspect the sandbox runtime", false)
+ }
+ if !exists {
+ return nil, vmm.Process{}, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("sandbox state is running but its VMM process is absent"))
+ }
+ return backend, process, nil
+}
diff --git a/core/sandbox_runtime_test.go b/core/sandbox_runtime_test.go
new file mode 100644
index 0000000..a034a80
--- /dev/null
+++ b/core/sandbox_runtime_test.go
@@ -0,0 +1,623 @@
+package core
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "net"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/kumabox/kumabox/agent"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+func TestRunCreatesAndStartsSandbox(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ record, err := service.Run(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage,
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateRunning || record.Generation != 4 {
+ t.Fatalf("running record = %+v", record)
+ }
+ want := []string{
+ "status:resolving and checking image", "verify", "reserve",
+ "status:creating sparse ext4 disk", "disk",
+ "status:committing created state", "created", "report",
+ "status:resolving sandbox", "resolve",
+ "status:waiting for sandbox operation lock", "resolve",
+ "status:checking existing runtime", "observe", "cleanup",
+ "status:checking host runtime", "preflight",
+ "status:verifying image and sandbox disk", "verify", "check",
+ "status:committing starting state", "starting",
+ "status:launching cloud-hypervisor", "launch",
+ "status:committing running state", "running", "report",
+ }
+ if !reflect.DeepEqual(*steps, want) {
+ t.Fatalf("steps = %v, want %v", *steps, want)
+ }
+}
+
+func TestRunRetainsSandboxWhenStartFails(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ failure := errors.New("VMM exited")
+ testRuntime(t, service).launchErr = failure
+ _, err := service.Run(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage,
+ },
+ })
+ if !errors.Is(err, failure) {
+ t.Fatalf("Run error = %v", err)
+ }
+ var classified *errdefs.Error
+ if !errors.As(err, &classified) || !classified.Committed || classified.Operation != "run sandbox" {
+ t.Fatalf("Run did not report retained state: %v", err)
+ }
+ record := service.dependencies.catalog.(*fakeCatalog).record
+ if record.State != types.SandboxStateError || record.Failure == nil || record.Failure.Phase != "launch VMM" {
+ t.Fatalf("retained record = %+v", record)
+ }
+ joined := strings.Join(*steps, ",")
+ if !strings.Contains(joined, "created,report,status:resolving sandbox") ||
+ !strings.Contains(joined, "launch,abort,start-error") ||
+ strings.Contains(joined, "forget") {
+ t.Fatalf("Run failure steps = %v", *steps)
+ }
+}
+
+func TestSandboxLifecycleRoutesToPersistedVMM(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ firecracker := &fakeRuntime{typ: types.VMMFirecracker, steps: steps, observation: vmm.Observation{State: vmm.ProcessAbsent}}
+ runtimes, err := vmm.NewRegistry(testRuntime(t, service), firecracker)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.dependencies.runtimes = runtimes
+ record, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ VMM: types.VMMFirecracker,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.VMM != types.VMMFirecracker {
+ t.Fatalf("VMM = %q, want %q", record.VMM, types.VMMFirecracker)
+ }
+ *steps = nil
+ if _, err := service.Start(t.Context(), "box"); err != nil {
+ t.Fatal(err)
+ }
+ if firecracker.plan.SandboxID != fixedID {
+ t.Fatalf("Firecracker did not receive launch plan: %+v", firecracker.plan)
+ }
+ if got := strings.Join(*steps, ","); !strings.Contains(got, "status:launching firecracker,launch") {
+ t.Fatalf("start was not routed through Firecracker: %v", *steps)
+ }
+}
+
+func TestStartCommitsRunningOnlyAfterLaunchReadiness(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ *steps = nil
+ record, err := service.Start(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateRunning || record.Generation != 4 {
+ t.Fatalf("running record = %+v", record)
+ }
+ runtimeAdapter := testRuntime(t, service)
+ if runtimeAdapter.plan.Generation != 3 || len(runtimeAdapter.plan.Disks) != 2 || runtimeAdapter.plan.Disks[0].Serial != "kumabox-layer0" || runtimeAdapter.plan.Disks[1].Serial != vmm.COWSerial {
+ t.Fatalf("launch plan = %+v", runtimeAdapter.plan)
+ }
+ want := []string{
+ "status:resolving sandbox", "resolve", "status:waiting for sandbox operation lock", "resolve",
+ "status:checking existing runtime", "observe", "cleanup", "status:checking host runtime", "preflight",
+ "status:verifying image and sandbox disk", "verify", "check", "status:committing starting state", "starting",
+ "status:launching cloud-hypervisor", "launch", "status:committing running state", "running", "report",
+ }
+ if !reflect.DeepEqual(*steps, want) {
+ t.Fatalf("steps = %v, want %v", *steps, want)
+ }
+}
+
+func TestStartRecoversNetworkBeforeLaunchingInItsNamespace(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1,
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+ *steps = nil
+ record, err := service.Start(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateRunning {
+ t.Fatalf("started record = %+v", record)
+ }
+ plan := testRuntime(t, service).plan
+ if plan.Network.Namespace != "/var/run/netns/kumabox-test" || len(plan.Network.Interfaces) != 1 {
+ t.Fatalf("launch network = %+v", plan.Network)
+ }
+ if got := *steps; !reflect.DeepEqual(got, []string{
+ "status:resolving sandbox", "resolve", "status:waiting for sandbox operation lock", "resolve",
+ "status:checking existing runtime", "observe", "cleanup",
+ "status:checking host runtime", "preflight",
+ "status:verifying image and sandbox disk", "verify", "check",
+ "status:committing starting state", "starting",
+ "status:recovering sandbox network", "network-recover",
+ "status:launching cloud-hypervisor", "launch",
+ "status:committing running state", "running", "report",
+ }) {
+ t.Fatalf("Start steps = %v", got)
+ }
+}
+
+func TestStartNetworkRecoveryFailureRetainsErrorAndQuiesces(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1,
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+ failure := errors.New("network recovery failed")
+ testNetwork(t, service).recoverErr = failure
+ *steps = nil
+ if _, err := service.Start(t.Context(), "box"); !errors.Is(err, failure) {
+ t.Fatalf("Start error = %v", err)
+ }
+ record := service.dependencies.catalog.(*fakeCatalog).record
+ if record.State != types.SandboxStateError || record.Failure == nil || record.Failure.Phase != "recover network" {
+ t.Fatalf("failed start record = %+v", record)
+ }
+ if got := strings.Join(*steps, ","); !strings.Contains(got,
+ "starting,status:recovering sandbox network,network-recover,cleanup,status:quiescing sandbox network,network-quiesce,start-error") {
+ t.Fatalf("recovery compensation steps = %v", *steps)
+ }
+}
+
+func TestStartRejectsRecoveredNetworkIdentityDrift(t *testing.T) {
+ service, _ := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1,
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+ networkAdapter := testNetwork(t, service)
+ networkAdapter.recovered = append([]types.NetworkInterface(nil), service.dependencies.catalog.(*fakeCatalog).record.Network.Interfaces...)
+ networkAdapter.recovered[0].MAC = "02:00:00:00:00:fe"
+ if _, err := service.Start(t.Context(), "box"); err == nil {
+ t.Fatal("Start accepted a recovered network with changed guest identity")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeArtifactCorrupt {
+ t.Fatalf("Start error = %v, want %s", err, errdefs.CodeArtifactCorrupt)
+ }
+}
+
+func TestStartRecoversRunningProcessFromStartingState(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State, catalog.record.Generation = types.SandboxStateStarting, 3
+ runtimeAdapter := testRuntime(t, service)
+ runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}}
+ *steps = nil
+ record, err := service.Start(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateRunning || record.Generation != 4 {
+ t.Fatalf("recovered record = %+v", record)
+ }
+ if strings.Contains(strings.Join(*steps, ","), "launch") || strings.Contains(strings.Join(*steps, ","), "preflight") {
+ t.Fatalf("recovery relaunched VMM: %v", *steps)
+ }
+}
+
+func TestStartFailureAbortsProcessAndRetainsError(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ failure := errors.New("VMM exited")
+ testRuntime(t, service).launchErr = failure
+ *steps = nil
+ if _, err := service.Start(t.Context(), "box"); !errors.Is(err, failure) {
+ t.Fatalf("Start error = %v", err)
+ } else {
+ var classified *errdefs.Error
+ if !errors.As(err, &classified) || !classified.Committed {
+ t.Fatalf("Start did not report retained state: %v", err)
+ }
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ if catalog.record.State != types.SandboxStateError || catalog.record.Failure == nil || catalog.record.Failure.Phase != "launch VMM" {
+ t.Fatalf("failed start record = %+v", catalog.record)
+ }
+ joined := strings.Join(*steps, ",")
+ if !strings.Contains(joined, "launch,abort,start-error") {
+ t.Fatalf("process was not aborted before Error commit: %v", *steps)
+ }
+}
+
+func TestStartRetryDoesNotLeaveStartingAfterPreflightFailure(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State, catalog.record.Generation = types.SandboxStateStarting, 3
+ failure := errors.New("KVM unavailable")
+ testRuntime(t, service).preflightErr = failure
+ *steps = nil
+ if _, err := service.Start(t.Context(), "box"); !errors.Is(err, failure) {
+ t.Fatalf("Start error = %v", err)
+ }
+ if catalog.record.State != types.SandboxStateError || catalog.record.Failure == nil || catalog.record.Failure.Phase != "host preflight" {
+ t.Fatalf("failed recovery record = %+v", catalog.record)
+ }
+ if got := strings.Join(*steps, ","); !strings.Contains(got, "observe,cleanup,status:checking host runtime,preflight,cleanup,start-error") {
+ t.Fatalf("recovery steps = %v", *steps)
+ }
+}
+
+func TestStopRecordsIntentBeforeTerminatingRunningVMM(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4
+ testRuntime(t, service).observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}}
+ *steps = nil
+ record, err := service.Stop(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateStopped || record.Generation != 6 {
+ t.Fatalf("stopped record = %+v", record)
+ }
+ want := []string{
+ "status:resolving sandbox", "resolve", "status:waiting for sandbox operation lock", "resolve",
+ "status:checking existing runtime", "locate", "status:committing stopping state", "stopping",
+ "status:stopping cloud-hypervisor", "stop", "status:cleaning runtime state", "cleanup",
+ "status:committing stopped state", "stopped", "report",
+ }
+ if !reflect.DeepEqual(*steps, want) {
+ t.Fatalf("steps = %v, want %v", *steps, want)
+ }
+}
+
+func TestStopQuiescesNetworkAfterRuntimeCleanup(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1,
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4
+ runtimeAdapter := testRuntime(t, service)
+ runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}}
+ *steps = nil
+ if _, err := service.Stop(t.Context(), "box"); err != nil {
+ t.Fatal(err)
+ }
+ if got := strings.Join(*steps, ","); !strings.Contains(got,
+ "stop,status:cleaning runtime state,cleanup,status:quiescing sandbox network,network-quiesce,status:committing stopped state,stopped") {
+ t.Fatalf("network stop ordering = %v", *steps)
+ }
+}
+
+func TestStopRetriesNetworkQuiesceFromStoppingState(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1,
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4
+ runtimeAdapter := testRuntime(t, service)
+ runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}}
+ networkAdapter := testNetwork(t, service)
+ failure := errors.New("link state failed")
+ networkAdapter.quiesceErr = failure
+ *steps = nil
+ if _, err := service.Stop(t.Context(), "box"); !errors.Is(err, failure) {
+ t.Fatalf("Stop error = %v", err)
+ }
+ if catalog.record.State != types.SandboxStateStopping || catalog.record.Generation != 5 {
+ t.Fatalf("retained record = %+v", catalog.record)
+ }
+
+ networkAdapter.quiesceErr = nil
+ runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessAbsent}
+ *steps = nil
+ record, err := service.Stop(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateStopped || record.Generation != 6 {
+ t.Fatalf("retried stop record = %+v", record)
+ }
+ if got := strings.Join(*steps, ","); strings.Contains(got, ",stop,") || !strings.Contains(got,
+ "cleanup,status:quiescing sandbox network,network-quiesce,status:committing stopped state,stopped") {
+ t.Fatalf("retried stop steps = %v", *steps)
+ }
+}
+
+func TestStopResumesStoppingAndRecoversStarting(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ state types.SandboxState
+ generation uint64
+ want uint64
+ }{
+ {name: "stopping", state: types.SandboxStateStopping, generation: 5, want: 6},
+ {name: "starting", state: types.SandboxStateStarting, generation: 3, want: 4},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State, catalog.record.Generation = test.state, test.generation
+ testRuntime(t, service).observation = vmm.Observation{State: vmm.ProcessStarting, Process: vmm.Process{PID: 42}}
+ *steps = nil
+ record, err := service.Stop(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateStopped || record.Generation != test.want {
+ t.Fatalf("stopped record = %+v", record)
+ }
+ if got := strings.Join(*steps, ","); strings.Contains(got, ",stopping,") || !strings.Contains(got, "locate,status:stopping cloud-hypervisor,stop,status:cleaning runtime state,cleanup") {
+ t.Fatalf("recovery steps = %v", *steps)
+ }
+ })
+ }
+}
+
+func TestStopConvergesAbsentRunningWithoutSignalling(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4
+ *steps = nil
+ record, err := service.Stop(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateStopped || record.Generation != 5 {
+ t.Fatalf("stopped record = %+v", record)
+ }
+ if got := strings.Join(*steps, ","); strings.Contains(got, ",stop,") || strings.Contains(got, ",stopping,") {
+ t.Fatalf("absent VMM was signalled or marked Stopping: %v", *steps)
+ }
+}
+
+func TestStopFailureRetainsRetryableStoppingState(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4
+ failure := errors.New("signal failed")
+ runtimeAdapter := testRuntime(t, service)
+ runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}}
+ runtimeAdapter.stopErr = failure
+ *steps = nil
+ if _, err := service.Stop(t.Context(), "box"); !errors.Is(err, failure) {
+ t.Fatalf("Stop error = %v", err)
+ }
+ if catalog.record.State != types.SandboxStateStopping || catalog.record.Generation != 5 {
+ t.Fatalf("retained record = %+v", catalog.record)
+ }
+ if strings.Contains(strings.Join(*steps, ","), "cleanup") {
+ t.Fatalf("runtime was cleaned before process absence: %v", *steps)
+ }
+}
+
+func TestStopCreatedIsIdempotentAndPreservesCreated(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ created, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ *steps = nil
+ record, err := service.Stop(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateCreated || record.Generation != created.Generation {
+ t.Fatalf("idempotent stop changed created record = %+v", record)
+ }
+ if got := strings.Join(*steps, ","); !strings.Contains(got, "status:cleaning stale runtime state,cleanup,report") {
+ t.Fatalf("idempotent steps = %v", *steps)
+ }
+}
+
+func TestConsoleOpensExactRunningGenerationWithoutHoldingOperationLock(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4
+ runtimeAdapter := testRuntime(t, service)
+ runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42, Generation: 3}}
+ *steps = nil
+
+ connection, err := service.Console(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := strings.Join(*steps, ","); got != "resolve,resolve,locate,console" {
+ t.Fatalf("console steps = %q", got)
+ }
+ if err := connection.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if !runtimeAdapter.console.(*fakeConsole).closed {
+ t.Fatal("caller did not own the returned console")
+ }
+}
+
+func TestConsoleRejectsNonRunningSandboxBeforeRuntimeAccess(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ *steps = nil
+ if _, err := service.Console(t.Context(), "box"); err == nil {
+ t.Fatal("Console succeeded for Created sandbox")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeStateConflict {
+ t.Fatalf("Console error = %v", err)
+ }
+ if got := strings.Join(*steps, ","); got != "resolve,resolve" {
+ t.Fatalf("non-running console touched runtime: %q", got)
+ }
+}
+
+func TestLogsRoutesPersistedBackendForInactiveSandbox(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ runtimeAdapter := testRuntime(t, service)
+ runtimeAdapter.logs = "boot output\n"
+ *steps = nil
+ var output bytes.Buffer
+ options := SandboxLogOptions{Tail: 12, Follow: true}
+ if err := service.Logs(t.Context(), "box", options, &output); err != nil {
+ t.Fatal(err)
+ }
+ if output.String() != runtimeAdapter.logs || runtimeAdapter.logOptions != (vmm.LogOptions{Tail: 12, Follow: true}) {
+ t.Fatalf("log output/options = %q, %+v", output.String(), runtimeAdapter.logOptions)
+ }
+ if got := strings.Join(*steps, ","); got != "resolve,logs" {
+ t.Fatalf("logs steps = %q", got)
+ }
+}
+
+func TestLogsRejectsNegativeTailBeforeResolvingSandbox(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ *steps = nil
+ if err := service.Logs(t.Context(), "box", SandboxLogOptions{Tail: -1}, io.Discard); err == nil {
+ t.Fatal("Logs accepted a negative tail")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeInvalidArgument {
+ t.Fatalf("Logs error = %v", err)
+ }
+ if len(*steps) != 0 {
+ t.Fatalf("invalid logs request touched adapters: %v", *steps)
+ }
+}
+
+func TestExecUsesExactRunningGenerationAndStreamsResult(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4
+ runtimeAdapter := testRuntime(t, service)
+ runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42, Generation: 3}}
+ host, guest := net.Pipe()
+ runtimeAdapter.vsock = host
+ t.Cleanup(func() { _ = guest.Close() })
+ go func() {
+ decoder := agent.NewDecoder(guest)
+ encoder := agent.NewEncoder(guest)
+ request, err := decoder.Decode()
+ if err != nil || request.Type != agent.MessageExec {
+ return
+ }
+ _, _ = decoder.Decode()
+ _ = encoder.Encode(agent.Message{Type: agent.MessageStarted, PID: 100})
+ _ = encoder.Encode(agent.Message{Type: agent.MessageStdout, Data: []byte("out")})
+ _ = encoder.Encode(agent.Message{Type: agent.MessageStderr, Data: []byte("err")})
+ _ = encoder.Encode(agent.Message{Type: agent.MessageExit, ExitCode: 17})
+ }()
+ *steps = nil
+ var stdout, stderr bytes.Buffer
+ code, err := service.Exec(t.Context(), "box", types.Command{Args: []string{"demo"}}, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if code != 17 || stdout.String() != "out" || stderr.String() != "err" {
+ t.Fatalf("result: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
+ }
+ if got := strings.Join(*steps, ","); got != "resolve,resolve,locate,vsock" {
+ t.Fatalf("exec steps = %q", got)
+ }
+}
diff --git a/core/sandbox_storage.go b/core/sandbox_storage.go
new file mode 100644
index 0000000..1399cd9
--- /dev/null
+++ b/core/sandbox_storage.go
@@ -0,0 +1,266 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "runtime"
+
+ "github.com/kumabox/kumabox/errdefs"
+ filelock "github.com/kumabox/kumabox/lock/flock"
+ "github.com/kumabox/kumabox/network"
+ "github.com/kumabox/kumabox/types"
+)
+
+// Create reserves identity and image usage before preparing private host
+// resources. Only the final generation-fenced transition publishes the
+// resolved network handoff and makes the disk startable.
+//
+// validate -> reserve -> CNI namespace + NICs -> sparse ext4 COW -> Created
+// | | |
+// +<------ detached failure cleanup <-------+
+func (s *SandboxService) Create(ctx context.Context, request CreateSandboxRequest) (result types.Sandbox, returnErr error) {
+ if s == nil || s.dependencies.images == nil || s.dependencies.catalog == nil || s.dependencies.disks == nil || s.dependencies.networks == nil || s.dependencies.runtimes.Len() == 0 || s.dependencies.reporter == nil || s.dependencies.newID == nil || s.dependencies.now == nil || s.dependencies.cleanupTimeout <= 0 {
+ return types.Sandbox{}, errors.New("sandbox service is not configured")
+ }
+ if request.ImageReference == "" {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("IMAGE must not be empty"))
+ }
+ if err := request.Config.Validate(); err != nil {
+ return types.Sandbox{}, err
+ }
+ if request.VMM == "" {
+ request.VMM = s.dependencies.defaultVMM
+ }
+ if _, err := s.dependencies.runtimes.Backend(request.VMM); err != nil {
+ return types.Sandbox{}, err
+ }
+ var networkProvider network.Provider
+ if request.Config.NICs > 0 {
+ var providerErr error
+ networkProvider, providerErr = s.dependencies.networks.Provider(s.dependencies.defaultNetwork)
+ if providerErr != nil {
+ return types.Sandbox{}, providerErr
+ }
+ }
+ if int(request.Config.CPUs) > runtime.NumCPU() { //nolint:gosec // Config validation bounds CPUs to a small positive value
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, fmt.Errorf("requested %d vCPUs exceeds available host CPUs (%d)", request.Config.CPUs, runtime.NumCPU()))
+ }
+ if err := s.dependencies.reporter.Status("resolving and checking image"); err != nil {
+ return types.Sandbox{}, err
+ }
+ id, err := s.dependencies.newID()
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ lockPath, err := s.dependencies.paths.Lock(id)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ lock := filelock.New(lockPath)
+ if err := lock.Lock(ctx); err != nil {
+ return types.Sandbox{}, errdefs.Context(err, "create sandbox", request.Config.Name, "lock", "retry the create", false)
+ }
+ defer func() {
+ unlockErr := lock.Unlock(context.WithoutCancel(ctx))
+ if unlockErr != nil {
+ committed := result.State == types.SandboxStateCreated
+ returnErr = errdefs.Context(errors.Join(returnErr, unlockErr), "create sandbox", request.Config.Name, "unlock", "inspect the sandbox before retrying", committed)
+ }
+ }()
+
+ createdAt := s.dependencies.now().UTC()
+ record := types.Sandbox{}
+ reserved := false
+ _, err = s.dependencies.images.WithAvailable(ctx, request.ImageReference, func(image types.Image) error {
+ record = types.Sandbox{
+ ID: id, Config: request.Config, ImageDigest: image.ManifestDigest,
+ VMM: request.VMM,
+ State: types.SandboxStateCreating, Generation: 1,
+ CreatedAt: createdAt, UpdatedAt: createdAt,
+ }
+ if err := s.dependencies.catalog.Reserve(ctx, request.ImageReference, image.ManifestDigest, record); err != nil {
+ return err
+ }
+ reserved = true
+ return nil
+ })
+ if err != nil {
+ if reserved {
+ return types.Sandbox{}, s.compensate(ctx, record, "image unlock", err)
+ }
+ return types.Sandbox{}, errdefs.Context(err, "create sandbox", request.Config.Name, "reserve", "check the image and sandbox name", false)
+ }
+ setup := types.NetworkSetup{}
+ if request.Config.NICs > 0 {
+ if err := s.dependencies.reporter.Status("preparing sandbox network"); err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "report", err)
+ }
+ namespace, err := networkProvider.Prepare(ctx, id)
+ if err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "network prepare", err)
+ }
+ if err := s.dependencies.reporter.Status("allocating sandbox network interfaces"); err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "report", err)
+ }
+ specs := network.AddRange(0, request.Config.NICs)
+ queues := network.QueueCount(request.Config.CPUs)
+ for index := range specs {
+ specs[index].Queues = queues
+ }
+ interfaces, err := networkProvider.Add(ctx, id, request.Config.NetworkName, specs...)
+ if err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "network add", err)
+ }
+ setup = types.NetworkSetup{Backend: networkProvider.Type(), Namespace: namespace, Interfaces: interfaces}
+ if err := setup.Validate(); err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "network result", err)
+ }
+ if len(interfaces) != request.Config.NICs {
+ return types.Sandbox{}, s.compensate(ctx, record, "network result", fmt.Errorf("network provider returned %d interfaces, expected %d", len(interfaces), request.Config.NICs))
+ }
+ record.Network = setup
+ record.Config.NetworkName = interfaces[0].Network
+ if err := s.dependencies.reporter.Status("quiescing sandbox network"); err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "report", err)
+ }
+ if err := networkProvider.Quiesce(ctx, id); err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "network quiesce", err)
+ }
+ }
+ if err := s.dependencies.reporter.Status("creating sparse ext4 disk"); err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "report", err)
+ }
+ if err := s.dependencies.disks.Prepare(ctx, id, request.Config.Storage); err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "disk", err)
+ }
+ if err := s.dependencies.reporter.Status("committing created state"); err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "report", err)
+ }
+ created, err := s.dependencies.catalog.MarkCreated(ctx, id, record.Generation, setup, s.dependencies.now().UTC())
+ if err != nil {
+ return types.Sandbox{}, s.compensate(ctx, record, "commit", err)
+ }
+ result = created
+ if err := s.dependencies.reporter.Committed(created); err != nil {
+ return created, errdefs.Context(err, "create sandbox", request.Config.Name, "report", "sandbox was created; inspect it before retrying", true)
+ }
+ return created, nil
+}
+
+// Remove records cleanup intent before deleting every owned host resource and
+// releases the name and image reference only after cleanup succeeds.
+//
+// resolve -> sandbox lock -> Deleting -> disk -> network -> logs -> finalize
+// | |
+// +-------- retry resumes ---------+
+func (s *SandboxService) Remove(ctx context.Context, reference string) (result types.Sandbox, returnErr error) {
+ if s == nil || s.dependencies.catalog == nil || s.dependencies.disks == nil || s.dependencies.networks == nil || s.dependencies.runtimes.Len() == 0 || s.dependencies.reporter == nil || s.dependencies.now == nil {
+ return types.Sandbox{}, errors.New("sandbox service is not configured")
+ }
+ if reference == "" {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX must not be empty"))
+ }
+ if err := s.dependencies.reporter.Status("resolving sandbox"); err != nil {
+ return types.Sandbox{}, err
+ }
+ record, err := s.dependencies.catalog.Resolve(ctx, reference)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ lockPath, err := s.dependencies.paths.Lock(record.ID)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ if err := s.dependencies.reporter.Status("waiting for sandbox operation lock"); err != nil {
+ return types.Sandbox{}, err
+ }
+ lock := filelock.New(lockPath)
+ if err := lock.Lock(ctx); err != nil {
+ return types.Sandbox{}, errdefs.Context(err, "remove sandbox", reference, "lock", "retry the removal", false)
+ }
+ committed := false
+ defer func() {
+ unlockErr := lock.Unlock(context.WithoutCancel(ctx))
+ if unlockErr != nil {
+ returnErr = errdefs.Context(errors.Join(returnErr, unlockErr), "remove sandbox", reference, "unlock", "inspect the sandbox removal state before retrying", committed)
+ }
+ }()
+ // The first resolve selects the lock; this second resolve supplies the
+ // authoritative generation and persisted backend for cleanup.
+ record, err = s.dependencies.catalog.Resolve(ctx, record.ID.String())
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ backend, err := s.dependencies.runtimes.Backend(record.VMM)
+ if err != nil {
+ return record, err
+ }
+ networkProvider, hasNetwork, err := s.networkProvider(record)
+ if err != nil {
+ return record, err
+ }
+ if err := s.dependencies.reporter.Status("marking sandbox for deletion"); err != nil {
+ return types.Sandbox{}, err
+ }
+ deleting, err := s.dependencies.catalog.BeginDelete(ctx, record.ID, record.Generation, s.dependencies.now().UTC())
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ committed = true
+ result = deleting
+ if err := s.dependencies.reporter.Status("removing sandbox disk"); err != nil {
+ return deleting, errdefs.Context(err, "remove sandbox", reference, "report", "retry removal to finish cleanup", true)
+ }
+ if err := s.dependencies.disks.Remove(ctx, deleting.ID); err != nil {
+ return deleting, errdefs.Context(err, "remove sandbox", reference, "disk cleanup", "retry removal to finish cleanup", true)
+ }
+ if hasNetwork {
+ if err := s.dependencies.reporter.Status("removing sandbox network"); err != nil {
+ return deleting, errdefs.Context(err, "remove sandbox", reference, "report", "retry removal to finish cleanup", true)
+ }
+ if err := networkProvider.Delete(ctx, deleting.ID); err != nil {
+ return deleting, errdefs.Context(err, "remove sandbox", reference, "network cleanup", "retry removal to finish cleanup", true)
+ }
+ }
+ if err := s.dependencies.reporter.Status("removing VMM logs"); err != nil {
+ return deleting, errdefs.Context(err, "remove sandbox", reference, "report", "retry removal to finish cleanup", true)
+ }
+ if err := backend.RemoveLogs(ctx, deleting.ID); err != nil {
+ return deleting, errdefs.Context(err, "remove sandbox", reference, "log cleanup", "retry removal to finish cleanup", true)
+ }
+ if err := s.dependencies.reporter.Status("releasing metadata and image reference"); err != nil {
+ return deleting, errdefs.Context(err, "remove sandbox", reference, "report", "retry removal to finish cleanup", true)
+ }
+ if err := s.dependencies.catalog.FinalizeDelete(ctx, deleting.ID, deleting.Generation); err != nil {
+ return deleting, errdefs.Context(err, "remove sandbox", reference, "finalize", "retry removal to finish cleanup", true)
+ }
+ if err := s.dependencies.reporter.Committed(deleting); err != nil {
+ return deleting, errdefs.Context(err, "remove sandbox", reference, "report", "sandbox was deleted; do not retry", true)
+ }
+ return deleting, nil
+}
+
+// compensate removes every potentially owned resource before forgetting the
+// Creating reservation. If cleanup cannot be proven complete, Error retains
+// the resource owner and image pin.
+func (s *SandboxService) compensate(ctx context.Context, record types.Sandbox, phase string, cause error) error {
+ cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.dependencies.cleanupTimeout)
+ defer cancel()
+ cleanupErr := s.dependencies.disks.Remove(cleanupCtx, record.ID)
+ if provider, hasNetwork, providerErr := s.networkProvider(record); providerErr != nil {
+ cleanupErr = errors.Join(cleanupErr, providerErr)
+ } else if hasNetwork {
+ cleanupErr = errors.Join(cleanupErr, provider.Delete(cleanupCtx, record.ID))
+ }
+ if cleanupErr == nil {
+ forgetErr := s.dependencies.catalog.Forget(cleanupCtx, record.ID, record.Generation)
+ if forgetErr == nil {
+ return errdefs.Context(cause, "create sandbox", record.Config.Name, phase, "fix the failure and retry", false)
+ }
+ cleanupErr = forgetErr
+ }
+ failure := types.SandboxFailure{Phase: phase, Message: errors.Join(cause, cleanupErr).Error()}
+ _, markErr := s.dependencies.catalog.MarkError(cleanupCtx, record.ID, record.Generation, failure, s.dependencies.now().UTC())
+ return errdefs.Context(errors.Join(cause, cleanupErr, markErr), "create sandbox", record.Config.Name, phase, "inspect or remove the retained error sandbox", false)
+}
diff --git a/core/sandbox_storage_test.go b/core/sandbox_storage_test.go
new file mode 100644
index 0000000..c5a6807
--- /dev/null
+++ b/core/sandbox_storage_test.go
@@ -0,0 +1,350 @@
+package core
+
+import (
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestCreateCommitsCreatedAfterDiskPreparation(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ record, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.ID != fixedID || record.State != types.SandboxStateCreated || record.Generation != 2 {
+ t.Fatalf("created record = %+v", record)
+ }
+ if record.VMM != types.VMMCloudHypervisor {
+ t.Fatalf("VMM = %q, want %q", record.VMM, types.VMMCloudHypervisor)
+ }
+ want := []string{"status:resolving and checking image", "verify", "reserve", "status:creating sparse ext4 disk", "disk", "status:committing created state", "created", "report"}
+ if !reflect.DeepEqual(*steps, want) {
+ t.Fatalf("steps = %v, want %v", *steps, want)
+ }
+}
+
+func TestCreatePublishesResolvedNetworkWithCreatedState(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ record, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 2, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 2,
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateCreated || record.Config.NetworkName != "default" ||
+ record.Network.Backend != types.NetworkBackendCNI || len(record.Network.Interfaces) != 2 {
+ t.Fatalf("created network record = %+v", record)
+ }
+ networks := testNetwork(t, service)
+ if len(networks.specs) != 2 || networks.specs[0].Queues != 4 || networks.specs[1].Queues != 4 {
+ t.Fatalf("network specs = %+v", networks.specs)
+ }
+ want := []string{
+ "status:resolving and checking image", "verify", "reserve",
+ "status:preparing sandbox network", "network-prepare",
+ "status:allocating sandbox network interfaces", "network-add",
+ "status:quiescing sandbox network", "network-quiesce",
+ "status:creating sparse ext4 disk", "disk",
+ "status:committing created state", "created", "report",
+ }
+ if !reflect.DeepEqual(*steps, want) {
+ t.Fatalf("steps = %v, want %v", *steps, want)
+ }
+}
+
+func TestCreateNetworkFailureCleansResourcesBeforeForgettingReservation(t *testing.T) {
+ failure := errors.New("CNI add failed")
+ service, steps := newTestSandboxService(t, nil)
+ networks := testNetwork(t, service)
+ networks.addErr = failure
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1,
+ },
+ }); !errors.Is(err, failure) {
+ t.Fatalf("Create error = %v", err)
+ }
+ wantTail := []string{"network-add", "remove", "network-delete", "forget"}
+ if got := (*steps)[len(*steps)-len(wantTail):]; !reflect.DeepEqual(got, wantTail) {
+ t.Fatalf("cleanup steps = %v, want %v", got, wantTail)
+ }
+}
+
+func TestCreateRetainsNetworkOwnerWhenCleanupFails(t *testing.T) {
+ addFailure := errors.New("CNI add failed")
+ deleteFailure := errors.New("CNI delete failed")
+ service, steps := newTestSandboxService(t, nil)
+ networks := testNetwork(t, service)
+ networks.addErr, networks.deleteErr = addFailure, deleteFailure
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1,
+ },
+ }); !errors.Is(err, addFailure) || !errors.Is(err, deleteFailure) {
+ t.Fatalf("Create error = %v", err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ if catalog.record.State != types.SandboxStateError || catalog.record.Failure == nil || catalog.record.Failure.Phase != "network add" {
+ t.Fatalf("retained record = %+v", catalog.record)
+ }
+ wantTail := []string{"network-add", "remove", "network-delete", "error"}
+ if got := (*steps)[len(*steps)-len(wantTail):]; !reflect.DeepEqual(got, wantTail) {
+ t.Fatalf("cleanup steps = %v, want %v", got, wantTail)
+ }
+}
+
+func TestCreateRejectsUnavailableVMMBeforeReservation(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ VMM: types.VMMFirecracker,
+ })
+ if err == nil {
+ t.Fatal("Create accepted an unavailable VMM")
+ }
+ if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeHostIncompatible {
+ t.Fatalf("Create error = %v", err)
+ }
+ if len(*steps) != 0 {
+ t.Fatalf("Create mutated state before rejecting VMM: %v", *steps)
+ }
+}
+
+func TestCreateDiskFailureRemovesDiskBeforeForgettingReservation(t *testing.T) {
+ failure := errors.New("mkfs failed")
+ service, steps := newTestSandboxService(t, failure)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); !errors.Is(err, failure) {
+ t.Fatalf("Create error = %v", err)
+ }
+ wantTail := []string{"disk", "remove", "forget"}
+ if got := (*steps)[len(*steps)-len(wantTail):]; !reflect.DeepEqual(got, wantTail) {
+ t.Fatalf("cleanup steps = %v, want %v", got, wantTail)
+ }
+}
+
+func TestCreateImageUnlockFailureCompensatesCommittedReservation(t *testing.T) {
+ failure := errors.New("image lock close failed")
+ service, steps := newTestSandboxService(t, nil)
+ guard := service.dependencies.images.(fakeGuard)
+ guard.afterUse = failure
+ service.dependencies.images = guard
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); !errors.Is(err, failure) {
+ t.Fatalf("Create error = %v", err)
+ }
+ want := []string{"status:resolving and checking image", "verify", "reserve", "remove", "forget"}
+ if !reflect.DeepEqual(*steps, want) {
+ t.Fatalf("steps = %v, want %v", *steps, want)
+ }
+}
+
+func TestCreateRetainsErrorOwnerWhenDiskCleanupFails(t *testing.T) {
+ prepareFailure := errors.New("mkfs failed")
+ removeFailure := errors.New("disk cleanup failed")
+ service, steps := newTestSandboxService(t, prepareFailure)
+ disks := service.dependencies.disks.(fakeDisk)
+ disks.remove = removeFailure
+ service.dependencies.disks = disks
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); !errors.Is(err, prepareFailure) || !errors.Is(err, removeFailure) {
+ t.Fatalf("Create error = %v", err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ if catalog.record.State != types.SandboxStateError || catalog.record.Failure == nil || catalog.record.Failure.Phase != "disk" {
+ t.Fatalf("retained record = %+v", catalog.record)
+ }
+ wantTail := []string{"disk", "remove", "error"}
+ if got := (*steps)[len(*steps)-len(wantTail):]; !reflect.DeepEqual(got, wantTail) {
+ t.Fatalf("cleanup steps = %v, want %v", got, wantTail)
+ }
+}
+
+func TestRemoveMarksDeletingBeforeDiskAndFinalizesAfterCleanup(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ *steps = nil
+ record, err := service.Remove(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.ID != fixedID || record.State != types.SandboxStateDeleting || record.Generation != 3 {
+ t.Fatalf("removed record = %+v", record)
+ }
+ want := []string{
+ "status:resolving sandbox", "resolve", "status:waiting for sandbox operation lock",
+ "resolve",
+ "status:marking sandbox for deletion", "deleting", "status:removing sandbox disk", "remove",
+ "status:removing VMM logs", "remove-logs",
+ "status:releasing metadata and image reference", "finalize", "report",
+ }
+ if !reflect.DeepEqual(*steps, want) {
+ t.Fatalf("steps = %v, want %v", *steps, want)
+ }
+}
+
+func TestRemoveFailureRetainsDeletingAndRetryFinishes(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ failure := errors.New("disk cleanup failed")
+ disks := service.dependencies.disks.(fakeDisk)
+ disks.remove = failure
+ service.dependencies.disks = disks
+ *steps = nil
+ if _, err := service.Remove(t.Context(), "box"); !errors.Is(err, failure) {
+ t.Fatalf("Remove error = %v", err)
+ } else {
+ var classified *errdefs.Error
+ if !errors.As(err, &classified) || !classified.Committed {
+ t.Fatalf("Remove did not report committed Deleting state: %v", err)
+ }
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ if catalog.record.State != types.SandboxStateDeleting || catalog.deleted {
+ t.Fatalf("retained delete record = %+v, deleted=%v", catalog.record, catalog.deleted)
+ }
+ disks.remove = nil
+ service.dependencies.disks = disks
+ *steps = nil
+ if _, err := service.Remove(t.Context(), "box"); err != nil {
+ t.Fatalf("retry Remove: %v", err)
+ }
+ if !catalog.deleted {
+ t.Fatal("retry did not finalize metadata")
+ }
+ if got := *steps; !reflect.DeepEqual(got, []string{
+ "status:resolving sandbox", "resolve", "status:waiting for sandbox operation lock",
+ "resolve",
+ "status:marking sandbox for deletion", "deleting", "status:removing sandbox disk", "remove",
+ "status:removing VMM logs", "remove-logs",
+ "status:releasing metadata and image reference", "finalize", "report",
+ }) {
+ t.Fatalf("retry steps = %v", got)
+ }
+}
+
+func TestRemoveNetworkFailureRetainsDeletingUntilRetry(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1,
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+ failure := errors.New("network cleanup failed")
+ networks := testNetwork(t, service)
+ networks.deleteErr = failure
+ *steps = nil
+ if _, err := service.Remove(t.Context(), "box"); !errors.Is(err, failure) {
+ t.Fatalf("Remove error = %v", err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ if catalog.record.State != types.SandboxStateDeleting || catalog.deleted {
+ t.Fatalf("retained delete record = %+v, deleted=%v", catalog.record, catalog.deleted)
+ }
+ if got := strings.Join(*steps, ","); !strings.Contains(got, "remove,status:removing sandbox network,network-delete") || strings.Contains(got, "finalize") {
+ t.Fatalf("network cleanup ordering = %v", *steps)
+ }
+
+ networks.deleteErr = nil
+ *steps = nil
+ if _, err := service.Remove(t.Context(), "box"); err != nil {
+ t.Fatal(err)
+ }
+ if !catalog.deleted {
+ t.Fatal("retry did not finalize metadata")
+ }
+ if got := strings.Join(*steps, ","); !strings.Contains(got, "remove,status:removing sandbox network,network-delete") || !strings.Contains(got, "finalize") {
+ t.Fatalf("retry did not repeat idempotent cleanup: %v", *steps)
+ }
+}
+
+func TestRemoveLogFailureRetainsDeletingUntilRetry(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ failure := errors.New("log cleanup failed")
+ runtimeAdapter := testRuntime(t, service)
+ runtimeAdapter.removeLogsErr = failure
+ *steps = nil
+ if _, err := service.Remove(t.Context(), "box"); !errors.Is(err, failure) {
+ t.Fatalf("Remove error = %v", err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ if catalog.record.State != types.SandboxStateDeleting || catalog.deleted {
+ t.Fatalf("retained delete record = %+v, deleted=%v", catalog.record, catalog.deleted)
+ }
+ if got := strings.Join(*steps, ","); strings.Contains(got, "finalize") || !strings.Contains(got, "remove,status:removing VMM logs,remove-logs") {
+ t.Fatalf("log cleanup ordering = %v", *steps)
+ }
+
+ runtimeAdapter.removeLogsErr = nil
+ *steps = nil
+ if _, err := service.Remove(t.Context(), "box"); err != nil {
+ t.Fatal(err)
+ }
+ if !catalog.deleted {
+ t.Fatal("retry did not finalize metadata")
+ }
+ if got := strings.Join(*steps, ","); !strings.Contains(got, "remove,status:removing VMM logs,remove-logs") || !strings.Contains(got, "finalize") {
+ t.Fatalf("retry did not repeat idempotent cleanup: %v", *steps)
+ }
+}
+
+func TestRemoveRejectsRunningSandboxBeforeDiskCleanup(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State = types.SandboxStateRunning
+ *steps = nil
+ if _, err := service.Remove(t.Context(), "box"); err == nil {
+ t.Fatal("Remove succeeded for a running sandbox")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeStateConflict {
+ t.Fatalf("Remove error code = %q, %v; want %q", code, err, errdefs.CodeStateConflict)
+ }
+ if catalog.record.State != types.SandboxStateRunning || catalog.deleted {
+ t.Fatalf("running record changed = %+v, deleted=%v", catalog.record, catalog.deleted)
+ }
+ for _, step := range *steps {
+ if step == "remove" || step == "finalize" {
+ t.Fatalf("destructive step %q ran for a running sandbox: %v", step, *steps)
+ }
+ }
+}
diff --git a/core/sandbox_test.go b/core/sandbox_test.go
new file mode 100644
index 0000000..c5b761d
--- /dev/null
+++ b/core/sandbox_test.go
@@ -0,0 +1,598 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/network"
+ "github.com/kumabox/kumabox/sandbox"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+var fixedID = types.SandboxID("123e4567-e89b-42d3-a456-426614174000")
+
+type fakeGuard struct {
+ image types.Image
+ steps *[]string
+ afterUse error
+}
+
+func (f fakeGuard) WithAvailable(ctx context.Context, _ string, use func(types.Image) error) (types.Image, error) {
+ *f.steps = append(*f.steps, "verify")
+ if err := use(f.image); err != nil {
+ return types.Image{}, err
+ }
+ return f.image, f.afterUse
+}
+
+type fakeCatalog struct {
+ steps *[]string
+ record types.Sandbox
+ deleted bool
+}
+
+func (f *fakeCatalog) Reserve(_ context.Context, _ string, _ types.Digest, record types.Sandbox) error {
+ *f.steps = append(*f.steps, "reserve")
+ f.record = record
+ return nil
+}
+
+func (f *fakeCatalog) MarkCreated(_ context.Context, _ types.SandboxID, expected uint64, setup types.NetworkSetup, updated time.Time) (types.Sandbox, error) {
+ *f.steps = append(*f.steps, "created")
+ if expected != f.record.Generation {
+ return types.Sandbox{}, errors.New("wrong generation")
+ }
+ f.record.Network = setup
+ if len(setup.Interfaces) > 0 {
+ f.record.Config.NetworkName = setup.Interfaces[0].Network
+ }
+ f.record.State, f.record.Generation, f.record.UpdatedAt = types.SandboxStateCreated, expected+1, updated
+ return f.record, nil
+}
+
+func (f *fakeCatalog) MarkError(_ context.Context, _ types.SandboxID, _ uint64, failure types.SandboxFailure, updated time.Time) (types.Sandbox, error) {
+ *f.steps = append(*f.steps, "error")
+ f.record.State, f.record.Failure, f.record.UpdatedAt = types.SandboxStateError, &failure, updated
+ f.record.Generation++
+ return f.record, nil
+}
+
+func (f *fakeCatalog) BeginStart(_ context.Context, _ types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) {
+ *f.steps = append(*f.steps, "starting")
+ if f.record.Generation != expected {
+ return types.Sandbox{}, errors.New("wrong generation")
+ }
+ if f.record.State == types.SandboxStateStarting {
+ return f.record, nil
+ }
+ f.record.State, f.record.Generation, f.record.Failure = types.SandboxStateStarting, expected+1, nil
+ f.record.UpdatedAt = updated
+ return f.record, nil
+}
+
+func (f *fakeCatalog) MarkRunning(_ context.Context, _ types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) {
+ *f.steps = append(*f.steps, "running")
+ if f.record.State != types.SandboxStateStarting || f.record.Generation != expected {
+ return types.Sandbox{}, errors.New("wrong starting generation")
+ }
+ f.record.State, f.record.Generation, f.record.UpdatedAt = types.SandboxStateRunning, expected+1, updated
+ return f.record, nil
+}
+
+func (f *fakeCatalog) MarkStartError(_ context.Context, _ types.SandboxID, expected uint64, failure types.SandboxFailure, updated time.Time) (types.Sandbox, error) {
+ *f.steps = append(*f.steps, "start-error")
+ if f.record.State != types.SandboxStateStarting || f.record.Generation != expected {
+ return types.Sandbox{}, errors.New("wrong starting generation")
+ }
+ f.record.State, f.record.Generation, f.record.Failure = types.SandboxStateError, expected+1, &failure
+ f.record.UpdatedAt = updated
+ return f.record, nil
+}
+
+func (f *fakeCatalog) BeginStop(_ context.Context, _ types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) {
+ *f.steps = append(*f.steps, "stopping")
+ if f.record.Generation != expected {
+ return types.Sandbox{}, errors.New("wrong generation")
+ }
+ if f.record.State == types.SandboxStateStopping {
+ return f.record, nil
+ }
+ if f.record.State != types.SandboxStateRunning {
+ return types.Sandbox{}, errors.New("wrong running state")
+ }
+ f.record.State, f.record.Generation, f.record.UpdatedAt = types.SandboxStateStopping, expected+1, updated
+ return f.record, nil
+}
+
+func (f *fakeCatalog) MarkStopped(_ context.Context, _ types.SandboxID, expected uint64, from types.SandboxState, updated time.Time) (types.Sandbox, error) {
+ *f.steps = append(*f.steps, "stopped")
+ if f.record.State != from || f.record.Generation != expected {
+ return types.Sandbox{}, errors.New("wrong stoppable generation")
+ }
+ f.record.State, f.record.Generation, f.record.UpdatedAt = types.SandboxStateStopped, expected+1, updated
+ return f.record, nil
+}
+
+func (f *fakeCatalog) Forget(context.Context, types.SandboxID, uint64) error {
+ *f.steps = append(*f.steps, "forget")
+ return nil
+}
+
+func (f *fakeCatalog) Resolve(_ context.Context, _ string) (types.Sandbox, error) {
+ *f.steps = append(*f.steps, "resolve")
+ if f.deleted {
+ return types.Sandbox{}, errors.New("not found")
+ }
+ return f.record, nil
+}
+
+func (f *fakeCatalog) List(context.Context) ([]types.Sandbox, error) {
+ *f.steps = append(*f.steps, "list")
+ if f.deleted || f.record.ID == "" {
+ return []types.Sandbox{}, nil
+ }
+ return []types.Sandbox{f.record}, nil
+}
+
+func (f *fakeCatalog) BeginDelete(_ context.Context, _ types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) {
+ *f.steps = append(*f.steps, "deleting")
+ if f.record.Generation != expected {
+ return types.Sandbox{}, errors.New("wrong generation")
+ }
+ if f.record.State == types.SandboxStateDeleting {
+ return f.record, nil
+ }
+ switch f.record.State {
+ case types.SandboxStateCreating, types.SandboxStateCreated, types.SandboxStateStopped, types.SandboxStateError:
+ default:
+ return types.Sandbox{}, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("sandbox must be stopped before removal"))
+ }
+ f.record.State, f.record.Generation = types.SandboxStateDeleting, f.record.Generation+1
+ f.record.Failure = nil
+ f.record.UpdatedAt = updated
+ return f.record, nil
+}
+
+func (f *fakeCatalog) FinalizeDelete(_ context.Context, _ types.SandboxID, expected uint64) error {
+ *f.steps = append(*f.steps, "finalize")
+ if f.record.State != types.SandboxStateDeleting || f.record.Generation != expected {
+ return errors.New("wrong delete generation")
+ }
+ f.deleted = true
+ return nil
+}
+
+type fakeDisk struct {
+ steps *[]string
+ prepare error
+ remove error
+}
+
+type fakeNetwork struct {
+ steps *[]string
+ prepareErr error
+ addErr error
+ recoverErr error
+ quiesceErr error
+ deleteErr error
+ namespace string
+ interfaces []types.NetworkInterface
+ recovered []types.NetworkInterface
+ specs []network.AddSpec
+}
+
+func (*fakeNetwork) Type() types.NetworkBackend { return types.NetworkBackendCNI }
+
+func (f *fakeNetwork) Prepare(context.Context, types.SandboxID) (string, error) {
+ *f.steps = append(*f.steps, "network-prepare")
+ return f.namespace, f.prepareErr
+}
+
+func (f *fakeNetwork) Add(_ context.Context, _ types.SandboxID, networkName string, specs ...network.AddSpec) ([]types.NetworkInterface, error) {
+ *f.steps = append(*f.steps, "network-add")
+ f.specs = append([]network.AddSpec(nil), specs...)
+ if f.addErr != nil {
+ return nil, f.addErr
+ }
+ if len(f.interfaces) > 0 {
+ return append([]types.NetworkInterface(nil), f.interfaces...), nil
+ }
+ if networkName == "" {
+ networkName = "default"
+ }
+ result := make([]types.NetworkInterface, 0, len(specs))
+ for _, spec := range specs {
+ result = append(result, types.NetworkInterface{
+ Index: spec.Index, Name: fmt.Sprintf("eth%d", spec.Index), TAP: fmt.Sprintf("tap%d", spec.Index),
+ MAC: fmt.Sprintf("02:00:00:00:00:%02x", spec.Index+1), Queues: spec.Queues,
+ QueueSize: network.DefaultQueueSize, Network: networkName,
+ })
+ }
+ return result, nil
+}
+
+func (*fakeNetwork) Verify(context.Context, types.SandboxID, []types.NetworkInterface) error {
+ return nil
+}
+
+func (f *fakeNetwork) Recover(_ context.Context, _ types.SandboxID, _ string, expected []types.NetworkInterface) ([]types.NetworkInterface, error) {
+ *f.steps = append(*f.steps, "network-recover")
+ if f.recovered != nil {
+ return append([]types.NetworkInterface(nil), f.recovered...), f.recoverErr
+ }
+ return append([]types.NetworkInterface(nil), expected...), f.recoverErr
+}
+
+func (f *fakeNetwork) Quiesce(context.Context, types.SandboxID) error {
+ *f.steps = append(*f.steps, "network-quiesce")
+ return f.quiesceErr
+}
+
+func (f *fakeNetwork) Unquiesce(context.Context, types.SandboxID) error {
+ *f.steps = append(*f.steps, "network-unquiesce")
+ return nil
+}
+
+func (f *fakeNetwork) Delete(context.Context, types.SandboxID) error {
+ *f.steps = append(*f.steps, "network-delete")
+ return f.deleteErr
+}
+
+func (f fakeDisk) Prepare(context.Context, types.SandboxID, int64) error {
+ *f.steps = append(*f.steps, "disk")
+ return f.prepare
+}
+
+func (f fakeDisk) Check(context.Context, types.SandboxID, int64) error {
+ *f.steps = append(*f.steps, "check")
+ return nil
+}
+
+func (f fakeDisk) Remove(context.Context, types.SandboxID) error {
+ *f.steps = append(*f.steps, "remove")
+ return f.remove
+}
+
+type fakeReporter struct{ steps *[]string }
+
+func (f fakeReporter) Status(status string) error {
+ *f.steps = append(*f.steps, "status:"+status)
+ return nil
+}
+
+func (f fakeReporter) Committed(types.Sandbox) error {
+ *f.steps = append(*f.steps, "report")
+ return nil
+}
+
+type fakeRuntime struct {
+ typ types.VMMType
+ steps *[]string
+ observation vmm.Observation
+ preflightErr error
+ launchErr error
+ stopErr error
+ plan vmm.LaunchPlan
+ console io.ReadWriteCloser
+ vsock io.ReadWriteCloser
+ logs string
+ logsErr error
+ removeLogsErr error
+ logOptions vmm.LogOptions
+ snapshotPlan vmm.SnapshotPlan
+ snapshotErr error
+ restorePlan vmm.RestorePlan
+ restoreErr error
+}
+
+func (f *fakeRuntime) Snapshot(_ context.Context, plan vmm.SnapshotPlan) error {
+ *f.steps = append(*f.steps, "snapshot")
+ f.snapshotPlan = plan
+ if f.snapshotErr != nil {
+ return f.snapshotErr
+ }
+ if err := os.WriteFile(filepath.Join(plan.Destination, "config.json"), []byte("{}"), 0o600); err != nil {
+ return err
+ }
+ for _, file := range plan.WritableFiles {
+ if err := os.WriteFile(file.Destination, []byte("cow"), 0o600); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (f *fakeRuntime) Restore(_ context.Context, plan vmm.RestorePlan) (vmm.Process, error) {
+ *f.steps = append(*f.steps, "restore")
+ f.restorePlan = plan
+ process := vmm.Process{
+ PID: 43, StartTicks: 11, BootID: "boot", SandboxID: plan.SandboxID,
+ Generation: plan.Generation, Binary: "cloud-hypervisor", APISocket: "/run/kumabox/restore.sock",
+ }
+ return process, f.restoreErr
+}
+
+func (f *fakeRuntime) Type() types.VMMType {
+ if f.typ == "" {
+ return types.VMMCloudHypervisor
+ }
+ return f.typ
+}
+
+func (f *fakeRuntime) Preflight() error {
+ *f.steps = append(*f.steps, "preflight")
+ return f.preflightErr
+}
+
+func (f *fakeRuntime) Locate(context.Context, types.SandboxID, uint64) (vmm.Process, bool, error) {
+ *f.steps = append(*f.steps, "locate")
+ return f.observation.Process, f.observation.State != vmm.ProcessAbsent, nil
+}
+
+func (f *fakeRuntime) Observe(context.Context, types.SandboxID, uint64) (vmm.Observation, error) {
+ *f.steps = append(*f.steps, "observe")
+ return f.observation, nil
+}
+
+func (f *fakeRuntime) WaitReady(context.Context, vmm.Process) error {
+ *f.steps = append(*f.steps, "ready")
+ return nil
+}
+
+func (f *fakeRuntime) Launch(_ context.Context, plan vmm.LaunchPlan) (vmm.Process, error) {
+ *f.steps = append(*f.steps, "launch")
+ f.plan = plan
+ process := vmm.Process{
+ PID: 42, StartTicks: 10, BootID: "boot", SandboxID: plan.SandboxID,
+ Generation: plan.Generation, Binary: "cloud-hypervisor", APISocket: "/run/kumabox/api.sock",
+ }
+ return process, f.launchErr
+}
+
+func (f *fakeRuntime) Abort(context.Context, vmm.Process) error {
+ *f.steps = append(*f.steps, "abort")
+ return nil
+}
+
+func (f *fakeRuntime) Stop(context.Context, vmm.Process) error {
+ *f.steps = append(*f.steps, "stop")
+ return f.stopErr
+}
+
+func (f *fakeRuntime) Console(context.Context, vmm.Process) (io.ReadWriteCloser, error) {
+ *f.steps = append(*f.steps, "console")
+ if f.console == nil {
+ f.console = &fakeConsole{}
+ }
+ return f.console, nil
+}
+
+func (f *fakeRuntime) DialVsock(context.Context, vmm.Process, uint32) (io.ReadWriteCloser, error) {
+ *f.steps = append(*f.steps, "vsock")
+ if f.vsock == nil {
+ return nil, errors.New("fake vsock is not configured")
+ }
+ return f.vsock, nil
+}
+
+func (f *fakeRuntime) Logs(_ context.Context, _ types.SandboxID, options vmm.LogOptions, output io.Writer) error {
+ *f.steps = append(*f.steps, "logs")
+ f.logOptions = options
+ if f.logsErr != nil {
+ return f.logsErr
+ }
+ _, err := io.WriteString(output, f.logs)
+ return err
+}
+
+func (f *fakeRuntime) Cleanup(context.Context, types.SandboxID) error {
+ *f.steps = append(*f.steps, "cleanup")
+ return nil
+}
+
+func (f *fakeRuntime) RemoveLogs(context.Context, types.SandboxID) error {
+ *f.steps = append(*f.steps, "remove-logs")
+ return f.removeLogsErr
+}
+
+type fakeConsole struct{ closed bool }
+
+func (*fakeConsole) Read([]byte) (int, error) { return 0, io.EOF }
+func (*fakeConsole) Write(data []byte) (int, error) { return len(data), nil }
+func (f *fakeConsole) Close() error {
+ f.closed = true
+ return nil
+}
+
+func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]string) {
+ t.Helper()
+ digest, err := types.ParseDigest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+ if err != nil {
+ t.Fatal(err)
+ }
+ base := t.TempDir()
+ roots := storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ }
+ paths, err := sandbox.NewPaths(roots)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := paths.Ensure(); err != nil {
+ t.Fatal(err)
+ }
+ steps := []string{}
+ catalog := &fakeCatalog{steps: &steps}
+ imagePaths, err := images.NewPaths(roots)
+ if err != nil {
+ t.Fatal(err)
+ }
+ image := types.Image{
+ ManifestDigest: digest, Platform: types.Platform{OS: "linux", Architecture: runtime.GOARCH},
+ Layers: []types.Layer{{SourceDigest: digest}},
+ Boot: types.Boot{Profile: types.BootProfileOverlayV1, KernelLayer: digest, InitrdLayer: digest, KernelFile: "vmlinuz", InitrdFile: "initrd.img"},
+ }
+ runtimeAdapter := &fakeRuntime{steps: &steps, observation: vmm.Observation{State: vmm.ProcessAbsent}}
+ runtimes, err := vmm.NewRegistry(runtimeAdapter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ networkAdapter := &fakeNetwork{steps: &steps, namespace: "/var/run/netns/kumabox-test"}
+ networks, err := network.NewRegistry(networkAdapter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service, err := newSandboxService(sandboxDependencies{
+ paths: paths, imagePaths: imagePaths, images: fakeGuard{image: image, steps: &steps},
+ catalog: catalog, disks: fakeDisk{steps: &steps, prepare: diskError},
+ networks: networks, runtimes: runtimes,
+ defaultVMM: types.VMMCloudHypervisor, cleanupTimeout: 10 * time.Second,
+ defaultNetwork: types.NetworkBackendCNI,
+ reporter: fakeReporter{steps: &steps},
+ newID: func() (types.SandboxID, error) { return fixedID, nil },
+ now: func() time.Time { return time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return service, &steps
+}
+
+func testNetwork(t *testing.T, service *SandboxService) *fakeNetwork {
+ t.Helper()
+ provider, err := service.dependencies.networks.Provider(types.NetworkBackendCNI)
+ if err != nil {
+ t.Fatal(err)
+ }
+ networkAdapter, ok := provider.(*fakeNetwork)
+ if !ok {
+ t.Fatalf("network provider = %T, want *fakeNetwork", provider)
+ }
+ return networkAdapter
+}
+
+func testRuntime(t *testing.T, service *SandboxService) *fakeRuntime {
+ t.Helper()
+ backend, err := service.dependencies.runtimes.Backend(types.VMMCloudHypervisor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ runtimeAdapter, ok := backend.(*fakeRuntime)
+ if !ok {
+ t.Fatalf("runtime backend = %T, want *fakeRuntime", backend)
+ }
+ return runtimeAdapter
+}
+
+func TestOpenVMMRegistryUsesConfiguredCgroupParent(t *testing.T) {
+ configuration := config.Default()
+ base := t.TempDir()
+ configuration.Paths = storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ }
+ configuration.VMM.CgroupParent = filepath.Join(base, "outside-cgroup")
+ if _, err := openVMMRegistry(configuration); err == nil {
+ t.Fatal("openVMMRegistry() ignored the configured cgroup parent")
+ }
+}
+
+func TestNewSandboxServiceValidatesNamedDependencies(t *testing.T) {
+ service, _ := newTestSandboxService(t, nil)
+ valid := service.dependencies
+ for _, test := range []struct {
+ name string
+ mutate func(*sandboxDependencies)
+ }{
+ {name: "image guard", mutate: func(dependencies *sandboxDependencies) { dependencies.images = nil }},
+ {name: "catalog", mutate: func(dependencies *sandboxDependencies) { dependencies.catalog = nil }},
+ {name: "disk backend", mutate: func(dependencies *sandboxDependencies) { dependencies.disks = nil }},
+ {name: "network provider", mutate: func(dependencies *sandboxDependencies) { dependencies.networks = nil }},
+ {name: "VMM registry", mutate: func(dependencies *sandboxDependencies) { dependencies.runtimes = nil }},
+ {name: "cleanup timeout", mutate: func(dependencies *sandboxDependencies) { dependencies.cleanupTimeout = 0 }},
+ {name: "default VMM", mutate: func(dependencies *sandboxDependencies) { dependencies.defaultVMM = types.VMMFirecracker }},
+ {name: "default network", mutate: func(dependencies *sandboxDependencies) { dependencies.defaultNetwork = "missing" }},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ dependencies := valid
+ test.mutate(&dependencies)
+ if _, err := newSandboxService(dependencies); err == nil {
+ t.Fatal("newSandboxService() accepted incomplete dependencies")
+ }
+ })
+ }
+}
+
+func TestNewSandboxServiceSuppliesProcessLocalDefaults(t *testing.T) {
+ service, _ := newTestSandboxService(t, nil)
+ dependencies := service.dependencies
+ dependencies.reporter = nil
+ dependencies.newID = nil
+ dependencies.now = nil
+ configured, err := newSandboxService(dependencies)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if configured.dependencies.reporter == nil || configured.dependencies.newID == nil || configured.dependencies.now == nil {
+ t.Fatal("newSandboxService() left process-local defaults unconfigured")
+ }
+}
+
+func TestListFiltersInactiveSandboxesUnlessAllRequested(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ *steps = nil
+ if records, err := service.List(t.Context(), false); err != nil {
+ t.Fatal(err)
+ } else if len(records) != 0 {
+ t.Fatalf("active records = %+v, want none", records)
+ }
+ if records, err := service.List(t.Context(), true); err != nil {
+ t.Fatal(err)
+ } else if len(records) != 1 || records[0].ID != fixedID {
+ t.Fatalf("all records = %+v", records)
+ }
+ catalog := service.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State = types.SandboxStateRunning
+ if records, err := service.List(t.Context(), false); err != nil {
+ t.Fatal(err)
+ } else if len(records) != 1 || records[0].State != types.SandboxStateRunning {
+ t.Fatalf("running records = %+v", records)
+ }
+}
+
+func TestInspectReturnsResolvedPersistentRecord(t *testing.T) {
+ service, steps := newTestSandboxService(t, nil)
+ if _, err := service.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ *steps = nil
+ record, err := service.Inspect(t.Context(), "box")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.ID != fixedID || record.Config.Name != "box" || record.State != types.SandboxStateCreated {
+ t.Fatalf("Inspect = %+v", record)
+ }
+ if diff := strings.Join(*steps, ","); diff != "resolve" {
+ t.Fatalf("steps = %q, want resolve", diff)
+ }
+}
diff --git a/core/snapshot.go b/core/snapshot.go
new file mode 100644
index 0000000..6584e8c
--- /dev/null
+++ b/core/snapshot.go
@@ -0,0 +1,479 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "reflect"
+ "time"
+
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/errdefs"
+ filelock "github.com/kumabox/kumabox/lock/flock"
+ "github.com/kumabox/kumabox/metadata"
+ sandboxfs "github.com/kumabox/kumabox/sandbox"
+ "github.com/kumabox/kumabox/snapshot"
+ snapshotcatalog "github.com/kumabox/kumabox/snapshot/catalog"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+// SaveSnapshotRequest contains operator labels for one live sandbox capture.
+type SaveSnapshotRequest struct {
+ // SandboxReference is the source sandbox name or complete ID.
+ SandboxReference string
+ // Name is an optional unique snapshot lookup key.
+ Name string
+ // Description is optional operator context stored with the snapshot.
+ Description string
+}
+
+// SnapshotReporter receives capture stages without controlling the workflow.
+type SnapshotReporter interface {
+ Status(string) error
+ Committed(types.Snapshot) error
+}
+
+type snapshotCatalog interface {
+ Reserve(context.Context, types.Snapshot) error
+ Commit(context.Context, types.SnapshotID, int64) (types.Snapshot, error)
+ Forget(context.Context, types.SnapshotID) error
+ Resolve(context.Context, string) (types.Snapshot, error)
+ List(context.Context) ([]types.Snapshot, error)
+ BeginDelete(context.Context, string) (types.Snapshot, error)
+ FinalizeDelete(context.Context, types.SnapshotID) error
+}
+
+// SnapshotService coordinates sandbox locking, VMM capture, artifact
+// publication, and snapshot metadata.
+type SnapshotService struct {
+ paths snapshot.Paths
+ sandboxPaths sandboxfs.Paths
+ sandboxes sandboxCatalog
+ snapshots snapshotCatalog
+ runtimes *vmm.Registry
+ reporter SnapshotReporter
+ newID func() (types.SnapshotID, error)
+ now func() time.Time
+ store metadata.Store
+ lifecycle *SandboxService
+}
+
+// OpenSnapshots assembles the local snapshot service. The caller must close it.
+func OpenSnapshots(ctx context.Context, configuration config.Config, reporter SnapshotReporter) (*SnapshotService, error) {
+ lifecycle, err := OpenSandbox(ctx, configuration, nil)
+ if err != nil {
+ return nil, err
+ }
+ snapshotPaths, err := snapshot.NewPaths(configuration.Paths)
+ if err != nil {
+ return nil, errors.Join(err, lifecycle.Close())
+ }
+ if err := snapshotPaths.Ensure(); err != nil {
+ return nil, errors.Join(err, lifecycle.Close())
+ }
+ if reporter == nil {
+ reporter = discardSnapshotReporter{}
+ }
+ return &SnapshotService{
+ paths: snapshotPaths, sandboxPaths: lifecycle.dependencies.paths,
+ sandboxes: lifecycle.dependencies.catalog, snapshots: snapshotcatalog.New(lifecycle.dependencies.store),
+ runtimes: lifecycle.dependencies.runtimes, reporter: reporter,
+ newID: types.NewSnapshotID, now: time.Now, store: lifecycle.dependencies.store, lifecycle: lifecycle,
+ }, nil
+}
+
+// Close releases the shared metadata engine.
+func (s *SnapshotService) Close() error {
+ if s == nil {
+ return nil
+ }
+ if s.lifecycle != nil {
+ return s.lifecycle.Close()
+ }
+ if s.store == nil {
+ return nil
+ }
+ return s.store.Close()
+}
+
+// Save captures native VMM state and the writable COW disk at one paused point.
+// The source resumes before artifact publication and metadata commit.
+//
+// Running -> lock -> reserve -> stage -> pause/capture/resume -> publish -> ready
+// \--- failure: clean stage + reservation ---/
+func (s *SnapshotService) Save(ctx context.Context, request SaveSnapshotRequest) (result types.Snapshot, returnErr error) {
+ if s == nil || s.sandboxes == nil || s.snapshots == nil || s.runtimes == nil || s.reporter == nil || s.newID == nil || s.now == nil {
+ return types.Snapshot{}, errors.New("snapshot service is not configured")
+ }
+ if request.SandboxReference == "" {
+ return types.Snapshot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX must not be empty"))
+ }
+ if err := s.reporter.Status("resolving sandbox"); err != nil {
+ return types.Snapshot{}, err
+ }
+ record, err := s.sandboxes.Resolve(ctx, request.SandboxReference)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ lockPath, err := s.sandboxPaths.Lock(record.ID)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ if err := s.reporter.Status("waiting for sandbox operation lock"); err != nil {
+ return types.Snapshot{}, err
+ }
+ lock := filelock.New(lockPath)
+ if err := lock.Lock(ctx); err != nil {
+ return types.Snapshot{}, errdefs.Context(err, "save snapshot", request.SandboxReference, "lock", "retry the snapshot", false)
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(lock.Unlock(context.WithoutCancel(ctx)), "save snapshot", request.SandboxReference, "unlock", "inspect the snapshot before retrying", result.ID != ""))
+ }()
+
+ record, err = s.sandboxes.Resolve(ctx, record.ID.String())
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ if record.State != types.SandboxStateRunning || record.Generation < 2 {
+ return types.Snapshot{}, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s in state %s cannot be snapshotted", record.ID, record.State))
+ }
+ backend, err := s.runtimes.Backend(record.VMM)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ snapshotter, ok := backend.(vmm.Snapshotter)
+ if !ok {
+ return types.Snapshot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, fmt.Errorf("VMM backend %q does not support snapshots", record.VMM))
+ }
+ observation, err := backend.Observe(ctx, record.ID, record.Generation-1)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ if observation.State != vmm.ProcessRunning {
+ return types.Snapshot{}, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("sandbox has no ready VMM process to snapshot"))
+ }
+ id, err := s.newID()
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ pending := types.Snapshot{
+ ID: id, Name: request.Name, Description: request.Description,
+ SandboxID: record.ID, SourceGeneration: record.Generation,
+ ImageDigest: record.ImageDigest, VMM: record.VMM, Config: record.Config,
+ CreatedAt: s.now().UTC(),
+ }
+ if err := pending.Validate(); err != nil {
+ return types.Snapshot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if err := s.reporter.Status("reserving snapshot identity"); err != nil {
+ return types.Snapshot{}, err
+ }
+ if err := s.snapshots.Reserve(ctx, pending); err != nil {
+ return types.Snapshot{}, err
+ }
+ reserved, published := true, false
+ defer func() {
+ if returnErr == nil || !reserved || published {
+ return
+ }
+ cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
+ defer cancel()
+ returnErr = errors.Join(returnErr, snapshot.IgnoreAbsence(s.paths.RemoveStage(id)), s.snapshots.Forget(cleanupCtx, id))
+ }()
+ if err := s.paths.PrepareStage(id); err != nil {
+ return types.Snapshot{}, err
+ }
+ cowSource, err := s.sandboxPaths.COW(record.ID)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ cowDestination, err := s.paths.StageCOW(id)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ stage, err := s.paths.Stage(id)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ if err := s.reporter.Status("capturing VMM and writable disk"); err != nil {
+ return types.Snapshot{}, err
+ }
+ if err := snapshotter.Snapshot(ctx, vmm.SnapshotPlan{
+ Process: observation.Process, Destination: stage,
+ WritableFiles: []vmm.SnapshotFile{{Source: cowSource, Destination: cowDestination}},
+ }); err != nil {
+ return types.Snapshot{}, errdefs.Context(err, "save snapshot", request.SandboxReference, "capture", "inspect the running sandbox and retry", false)
+ }
+ if err := s.reporter.Status("publishing snapshot artifacts"); err != nil {
+ return types.Snapshot{}, err
+ }
+ if err := s.paths.Publish(id); err != nil {
+ final, pathErr := s.paths.Dir(id)
+ _, statErr := os.Stat(final)
+ if pathErr == nil && statErr == nil {
+ published = true
+ }
+ return types.Snapshot{}, errdefs.Context(errors.Join(err, pathErr), "save snapshot", request.SandboxReference, "publish", "inspect snapshot storage before retrying", published)
+ }
+ published = true
+ size, err := s.paths.Size(id)
+ if err != nil {
+ return types.Snapshot{}, errdefs.Context(err, "save snapshot", request.SandboxReference, "measure", "inspect snapshot storage before retrying", true)
+ }
+ if err := s.reporter.Status("committing snapshot metadata"); err != nil {
+ return types.Snapshot{}, errdefs.Context(err, "save snapshot", request.SandboxReference, "report", "inspect snapshot storage before retrying", true)
+ }
+ result, err = s.snapshots.Commit(ctx, id, size)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ if err := s.reporter.Committed(result); err != nil {
+ return result, errdefs.Context(err, "save snapshot", request.SandboxReference, "report", "snapshot was saved; inspect it before retrying", true)
+ }
+ return result, nil
+}
+
+// List returns every ready snapshot.
+func (s *SnapshotService) List(ctx context.Context) ([]types.Snapshot, error) {
+ if s == nil || s.snapshots == nil {
+ return nil, errors.New("snapshot service is not configured")
+ }
+ return s.snapshots.List(ctx)
+}
+
+// Inspect resolves one ready snapshot by name or complete ID.
+func (s *SnapshotService) Inspect(ctx context.Context, reference string) (types.Snapshot, error) {
+ if s == nil || s.snapshots == nil {
+ return types.Snapshot{}, errors.New("snapshot service is not configured")
+ }
+ return s.snapshots.Resolve(ctx, reference)
+}
+
+// Remove records deletion intent before removing artifacts, then releases the
+// metadata name. A failure after intent is retryable with the same reference.
+func (s *SnapshotService) Remove(ctx context.Context, reference string) (result types.Snapshot, returnErr error) {
+ if s == nil || s.snapshots == nil {
+ return types.Snapshot{}, errors.New("snapshot service is not configured")
+ }
+ record, err := s.snapshots.BeginDelete(ctx, reference)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ lockPath, err := s.paths.Lock(record.ID)
+ if err != nil {
+ return record, err
+ }
+ lock := filelock.New(lockPath)
+ if err := lock.Lock(ctx); err != nil {
+ return record, errdefs.Context(err, "remove snapshot", reference, "lock", "retry snapshot removal", true)
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(lock.Unlock(context.WithoutCancel(ctx)), "remove snapshot", reference, "unlock", "retry snapshot removal", true))
+ }()
+ if err := snapshot.IgnoreAbsence(s.paths.Remove(record.ID)); err != nil {
+ return record, errdefs.Context(err, "remove snapshot", reference, "remove artifacts", "retry snapshot removal", true)
+ }
+ if err := s.snapshots.FinalizeDelete(ctx, record.ID); err != nil {
+ return record, err
+ }
+ return record, nil
+}
+
+// Restore replaces a stopped sandbox's writable disk and launches its native
+// VMM snapshot. A live or retained-error source is cleaned through the normal
+// stop lifecycle before replacement.
+//
+// snapshot lock -> validate + stage disk -> stop -> sandbox lock -> Starting
+// -> disk replace
+// -> VMM restore -> Running
+func (s *SnapshotService) Restore(ctx context.Context, sandboxReference, snapshotReference string) (result types.Sandbox, returnErr error) {
+ if s == nil || s.lifecycle == nil || s.snapshots == nil || s.runtimes == nil || s.reporter == nil || s.now == nil {
+ return types.Sandbox{}, errors.New("snapshot restore service is not configured")
+ }
+ if sandboxReference == "" || snapshotReference == "" {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX and SNAPSHOT must not be empty"))
+ }
+ if err := s.reporter.Status("resolving snapshot and sandbox"); err != nil {
+ return types.Sandbox{}, err
+ }
+ capture, err := s.snapshots.Resolve(ctx, snapshotReference)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ snapshotLockPath, err := s.paths.Lock(capture.ID)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ snapshotLock := filelock.New(snapshotLockPath)
+ if err := snapshotLock.Lock(ctx); err != nil {
+ return types.Sandbox{}, errdefs.Context(err, "restore sandbox", sandboxReference, "lock snapshot", "retry the restore", false)
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(snapshotLock.Unlock(context.WithoutCancel(ctx)), "restore sandbox", sandboxReference, "unlock snapshot", "inspect the sandbox before retrying", result.Generation > 0))
+ }()
+ record, err := s.sandboxes.Resolve(ctx, sandboxReference)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ if err := validateRestoreLineage(record, capture); err != nil {
+ return types.Sandbox{}, err
+ }
+ backend, err := s.runtimes.Backend(record.VMM)
+ if err != nil {
+ return record, err
+ }
+ restorer, ok := backend.(vmm.Restorer)
+ if !ok {
+ return record, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, fmt.Errorf("VMM backend %q does not support restore", record.VMM))
+ }
+ if err := s.reporter.Status("validating snapshot artifacts"); err != nil {
+ return record, err
+ }
+ snapshotDir, err := s.paths.Dir(capture.ID)
+ if err != nil {
+ return record, err
+ }
+ snapshotCOW, err := s.paths.COW(capture.ID)
+ if err != nil {
+ return record, err
+ }
+ if info, err := os.Lstat(snapshotCOW); err != nil {
+ return record, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err)
+ } else if !info.Mode().IsRegular() || info.Size() == 0 {
+ return record, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("snapshot COW is not a nonempty regular file"))
+ }
+ if validator, ok := backend.(vmm.RestoreValidator); ok {
+ if err := validator.ValidateRestore(ctx, snapshotDir); err != nil {
+ return record, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, err)
+ }
+ }
+ if err := s.reporter.Status("checking host runtime"); err != nil {
+ return record, err
+ }
+ if err := backend.Preflight(); err != nil {
+ return record, err
+ }
+ stagedCOW, err := s.paths.RestoreCOW(capture.ID, record.ID)
+ if err != nil {
+ return record, err
+ }
+ if err := ignoreNotExist(os.Remove(stagedCOW)); err != nil {
+ return record, errdefs.Context(err, "restore sandbox", sandboxReference, "clean staging disk", "inspect snapshot staging storage before retrying", false)
+ }
+ defer func() { returnErr = errors.Join(returnErr, ignoreNotExist(os.Remove(stagedCOW))) }()
+ if err := s.reporter.Status("staging snapshot writable disk"); err != nil {
+ return record, err
+ }
+ if err := storage.CopySparse(stagedCOW, snapshotCOW); err != nil {
+ return record, errdefs.Context(err, "restore sandbox", sandboxReference, "stage disk", "verify the snapshot and retry", false)
+ }
+ stoppedForRestore := false
+ defer func() {
+ if stoppedForRestore && returnErr != nil {
+ returnErr = errdefs.Context(returnErr, "restore sandbox", sandboxReference, "after stop", "inspect the stopped or retained-error sandbox before retrying", true)
+ }
+ }()
+ switch record.State {
+ case types.SandboxStateRunning, types.SandboxStateStarting, types.SandboxStateStopping, types.SandboxStateError:
+ if err := s.reporter.Status("stopping current sandbox runtime"); err != nil {
+ return types.Sandbox{}, err
+ }
+ if _, err := s.lifecycle.Stop(ctx, record.ID.String()); err != nil {
+ return types.Sandbox{}, errdefs.Context(err, "restore sandbox", sandboxReference, "stop", "inspect the sandbox before retrying", true)
+ }
+ stoppedForRestore = true
+ case types.SandboxStateStopped:
+ default:
+ return types.Sandbox{}, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s in state %s cannot be restored", record.ID, record.State))
+ }
+ sandboxLockPath, err := s.sandboxPaths.Lock(record.ID)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ if err := s.reporter.Status("waiting for sandbox operation lock"); err != nil {
+ return types.Sandbox{}, err
+ }
+ sandboxLock := filelock.New(sandboxLockPath)
+ if err := sandboxLock.Lock(ctx); err != nil {
+ return types.Sandbox{}, errdefs.Context(err, "restore sandbox", sandboxReference, "lock sandbox", "retry the restore", false)
+ }
+ committed := false
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(sandboxLock.Unlock(context.WithoutCancel(ctx)), "restore sandbox", sandboxReference, "unlock sandbox", "inspect the sandbox before retrying", committed))
+ }()
+ record, err = s.sandboxes.Resolve(ctx, record.ID.String())
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ if record.State != types.SandboxStateStopped && record.State != types.SandboxStateError {
+ return record, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s changed to state %s before restore", record.ID, record.State))
+ }
+ if err := validateRestoreLineage(record, capture); err != nil {
+ return record, err
+ }
+ if err := s.reporter.Status("committing starting state"); err != nil {
+ return record, err
+ }
+ starting, err := s.sandboxes.BeginStart(ctx, record.ID, record.Generation, s.now().UTC())
+ if err != nil {
+ return record, err
+ }
+ committed = true
+ result = starting
+ if err := s.lifecycle.recoverNetwork(ctx, starting); err != nil {
+ return starting, s.lifecycle.failStart(ctx, backend, starting, "recover network", err, vmm.Process{})
+ }
+ liveCOW, err := s.sandboxPaths.COW(record.ID)
+ if err != nil {
+ return starting, s.lifecycle.failStart(ctx, backend, starting, "resolve disk", err, vmm.Process{})
+ }
+ if err := s.reporter.Status("replacing writable disk"); err != nil {
+ return starting, s.lifecycle.failStart(ctx, backend, starting, "report", err, vmm.Process{})
+ }
+ if err := storage.Publish(stagedCOW, liveCOW); err != nil {
+ return starting, s.lifecycle.failStart(ctx, backend, starting, "replace disk", err, vmm.Process{})
+ }
+ if err := s.reporter.Status("restoring VMM state"); err != nil {
+ return starting, s.lifecycle.failStart(ctx, backend, starting, "report", err, vmm.Process{})
+ }
+ process, err := restorer.Restore(ctx, vmm.RestorePlan{
+ SandboxID: starting.ID, Generation: starting.Generation, CPUs: starting.Config.CPUs,
+ SnapshotDir: snapshotDir, Network: starting.Network,
+ })
+ if err != nil {
+ return starting, s.lifecycle.failStart(ctx, backend, starting, "restore VMM", err, process)
+ }
+ if err := s.reporter.Status("committing running state"); err != nil {
+ return starting, s.lifecycle.failStart(ctx, backend, starting, "report", err, process)
+ }
+ running, err := s.sandboxes.MarkRunning(ctx, starting.ID, starting.Generation, s.now().UTC())
+ if err != nil {
+ return starting, s.lifecycle.failStart(ctx, backend, starting, "commit running", err, process)
+ }
+ return running, nil
+}
+
+func validateRestoreLineage(sandbox types.Sandbox, capture types.Snapshot) error {
+ if capture.SandboxID != sandbox.ID {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("snapshot belongs to another sandbox"))
+ }
+ if capture.VMM != sandbox.VMM || capture.ImageDigest != sandbox.ImageDigest || !reflect.DeepEqual(capture.Config, sandbox.Config) {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("snapshot runtime configuration differs from the target sandbox"))
+ }
+ return nil
+}
+
+func ignoreNotExist(err error) error {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return err
+}
+
+type discardSnapshotReporter struct{}
+
+func (discardSnapshotReporter) Status(string) error { return nil }
+func (discardSnapshotReporter) Committed(types.Snapshot) error { return nil }
diff --git a/core/snapshot_test.go b/core/snapshot_test.go
new file mode 100644
index 0000000..5a0b01c
--- /dev/null
+++ b/core/snapshot_test.go
@@ -0,0 +1,265 @@
+package core
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/snapshot"
+ snapshotcatalog "github.com/kumabox/kumabox/snapshot/catalog"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+var fixedSnapshotID = types.SnapshotID("223e4567-e89b-42d3-a456-426614174000")
+
+type fakeSnapshotReporter struct{ steps *[]string }
+
+func (r fakeSnapshotReporter) Status(status string) error {
+ *r.steps = append(*r.steps, "snapshot-status:"+status)
+ return nil
+}
+
+func (r fakeSnapshotReporter) Committed(types.Snapshot) error {
+ *r.steps = append(*r.steps, "snapshot-report")
+ return nil
+}
+
+func newTestSnapshotService(t *testing.T) (*SnapshotService, *SandboxService, *[]string) {
+ t.Helper()
+ sandboxService, steps := newTestSandboxService(t, nil)
+ if _, err := sandboxService.Create(t.Context(), CreateSandboxRequest{
+ ImageReference: "demo",
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 2, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage,
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+ catalog := sandboxService.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State = types.SandboxStateRunning
+ catalog.record.Generation = 4
+ testRuntime(t, sandboxService).observation = vmm.Observation{
+ State: vmm.ProcessRunning,
+ Process: vmm.Process{
+ PID: 42, StartTicks: 10, BootID: "boot", SandboxID: fixedID,
+ Generation: 3, Binary: "cloud-hypervisor", APISocket: "/run/kumabox/api.sock",
+ },
+ }
+ sandboxDir, err := sandboxService.dependencies.paths.Dir(fixedID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := storage.EnsureDir(sandboxDir); err != nil {
+ t.Fatal(err)
+ }
+ cow, err := sandboxService.dependencies.paths.COW(fixedID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(cow, []byte("live-cow"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ roots := storage.Roots{
+ Data: filepath.Join(t.TempDir(), "data"), Run: filepath.Join(t.TempDir(), "run"), Log: filepath.Join(t.TempDir(), "log"),
+ }
+ paths, err := snapshot.NewPaths(roots)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := paths.Ensure(); err != nil {
+ t.Fatal(err)
+ }
+ memory, err := metadata.NewMemory(snapshotcatalog.Collections())
+ if err != nil {
+ t.Fatal(err)
+ }
+ service := &SnapshotService{
+ paths: paths, sandboxPaths: sandboxService.dependencies.paths,
+ sandboxes: catalog, snapshots: snapshotcatalog.New(memory), runtimes: sandboxService.dependencies.runtimes,
+ reporter: fakeSnapshotReporter{steps: steps}, newID: func() (types.SnapshotID, error) { return fixedSnapshotID, nil },
+ now: func() time.Time { return time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) }, store: memory,
+ lifecycle: sandboxService,
+ }
+ return service, sandboxService, steps
+}
+
+func TestRestoreStopsRunningSandboxAndResumesSnapshot(t *testing.T) {
+ service, sandboxService, steps := newTestSnapshotService(t)
+ capture, err := service.Save(t.Context(), SaveSnapshotRequest{SandboxReference: "box", Name: "checkpoint"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ *steps = nil
+ record, err := service.Restore(t.Context(), "box", capture.ID.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateRunning || record.Generation != 8 {
+ t.Fatalf("restored sandbox = %+v", record)
+ }
+ plan := testRuntime(t, sandboxService).restorePlan
+ if plan.SandboxID != fixedID || plan.Generation != 7 || plan.SnapshotDir == "" {
+ t.Fatalf("restore plan = %+v", plan)
+ }
+ wantSequence := []string{"stopping", "stop", "stopped", "starting", "restore", "running"}
+ position := 0
+ for _, step := range *steps {
+ if position < len(wantSequence) && step == wantSequence[position] {
+ position++
+ }
+ }
+ if position != len(wantSequence) {
+ t.Fatalf("restore steps = %v, missing sequence %v", *steps, wantSequence)
+ }
+}
+
+func TestRestoreFailureRetainsErrorSandbox(t *testing.T) {
+ service, sandboxService, _ := newTestSnapshotService(t)
+ capture, err := service.Save(t.Context(), SaveSnapshotRequest{SandboxReference: "box"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ failure := errors.New("restore failed")
+ testRuntime(t, sandboxService).restoreErr = failure
+ if _, err := service.Restore(t.Context(), "box", capture.ID.String()); !errors.Is(err, failure) {
+ t.Fatalf("Restore error = %v", err)
+ }
+ record := sandboxService.dependencies.catalog.(*fakeCatalog).record
+ if record.State != types.SandboxStateError || record.Failure == nil || record.Failure.Phase != "restore VMM" {
+ t.Fatalf("retained sandbox = %+v", record)
+ }
+}
+
+func TestRestoreRejectsMissingCOWBeforeStoppingSandbox(t *testing.T) {
+ service, sandboxService, steps := newTestSnapshotService(t)
+ capture, err := service.Save(t.Context(), SaveSnapshotRequest{SandboxReference: "box"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ snapshotCOW, err := service.paths.COW(capture.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(snapshotCOW); err != nil {
+ t.Fatal(err)
+ }
+ *steps = nil
+ if _, err := service.Restore(t.Context(), "box", capture.ID.String()); err == nil {
+ t.Fatal("Restore accepted a snapshot without its writable disk")
+ }
+ record := sandboxService.dependencies.catalog.(*fakeCatalog).record
+ if record.State != types.SandboxStateRunning || record.Generation != 4 {
+ t.Fatalf("sandbox changed before snapshot validation: %+v", record)
+ }
+ for _, step := range *steps {
+ if step == "stopping" || step == "stop" {
+ t.Fatalf("restore stopped the sandbox before validation: %v", *steps)
+ }
+ }
+}
+
+func TestRestoreRecoversRetainedErrorSandbox(t *testing.T) {
+ service, sandboxService, steps := newTestSnapshotService(t)
+ capture, err := service.Save(t.Context(), SaveSnapshotRequest{SandboxReference: "box"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ catalog := sandboxService.dependencies.catalog.(*fakeCatalog)
+ catalog.record.State = types.SandboxStateError
+ catalog.record.Generation = 5
+ catalog.record.Failure = &types.SandboxFailure{Phase: "previous start", Message: "failed"}
+ testRuntime(t, sandboxService).observation = vmm.Observation{State: vmm.ProcessAbsent}
+ *steps = nil
+ record, err := service.Restore(t.Context(), "box", capture.ID.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.State != types.SandboxStateRunning || record.Generation != 7 || record.Failure != nil {
+ t.Fatalf("restored sandbox = %+v", record)
+ }
+ wantSequence := []string{"cleanup", "starting", "restore", "running"}
+ position := 0
+ for _, step := range *steps {
+ if position < len(wantSequence) && step == wantSequence[position] {
+ position++
+ }
+ }
+ if position != len(wantSequence) {
+ t.Fatalf("restore steps = %v, missing sequence %v", *steps, wantSequence)
+ }
+}
+
+func TestSaveSnapshotPublishesCompleteCapture(t *testing.T) {
+ service, sandboxService, _ := newTestSnapshotService(t)
+ record, err := service.Save(t.Context(), SaveSnapshotRequest{
+ SandboxReference: "box", Name: "checkpoint/one", Description: "before upgrade",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.ID != fixedSnapshotID || record.SandboxID != fixedID || record.Name != "checkpoint/one" || record.Size != 5 {
+ t.Fatalf("snapshot = %+v", record)
+ }
+ directory, err := service.paths.Dir(record.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"config.json", "cow.raw"} {
+ if _, err := os.Stat(filepath.Join(directory, name)); err != nil {
+ t.Fatalf("snapshot artifact %s: %v", name, err)
+ }
+ }
+ plan := testRuntime(t, sandboxService).snapshotPlan
+ if plan.Process.Generation != 3 || plan.Destination == "" || len(plan.WritableFiles) != 1 {
+ t.Fatalf("snapshot plan = %+v", plan)
+ }
+ listed, err := service.List(t.Context())
+ if err != nil || len(listed) != 1 || listed[0].ID != record.ID {
+ t.Fatalf("List = %+v, %v", listed, err)
+ }
+}
+
+func TestSaveSnapshotFailureCleansReservationAndStage(t *testing.T) {
+ service, sandboxService, _ := newTestSnapshotService(t)
+ failure := errors.New("capture failed")
+ testRuntime(t, sandboxService).snapshotErr = failure
+ request := SaveSnapshotRequest{SandboxReference: "box", Name: "retryable"}
+ if _, err := service.Save(t.Context(), request); !errors.Is(err, failure) {
+ t.Fatalf("Save error = %v", err)
+ }
+ if records, err := service.List(t.Context()); err != nil || len(records) != 0 {
+ t.Fatalf("List after failure = %+v, %v", records, err)
+ }
+ stage, err := service.paths.Stage(fixedSnapshotID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(stage); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("snapshot stage remains: %v", err)
+ }
+ testRuntime(t, sandboxService).snapshotErr = nil
+ if _, err := service.Save(t.Context(), request); err != nil {
+ t.Fatalf("retry after compensation: %v", err)
+ }
+}
+
+func TestRemoveSnapshotDeletesArtifactsAndName(t *testing.T) {
+ service, _, _ := newTestSnapshotService(t)
+ record, err := service.Save(t.Context(), SaveSnapshotRequest{SandboxReference: "box", Name: "remove-me"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ removed, err := service.Remove(t.Context(), "remove-me")
+ if err != nil || removed.ID != record.ID {
+ t.Fatalf("Remove = %+v, %v", removed, err)
+ }
+ if _, err := service.Inspect(t.Context(), "remove-me"); err == nil {
+ t.Fatal("removed snapshot still resolves")
+ }
+}
diff --git a/core/vmm.go b/core/vmm.go
new file mode 100644
index 0000000..d89b619
--- /dev/null
+++ b/core/vmm.go
@@ -0,0 +1,38 @@
+package core
+
+import (
+ "fmt"
+
+ "github.com/kumabox/kumabox/cgroup"
+ "github.com/kumabox/kumabox/config"
+ "github.com/kumabox/kumabox/vmm"
+ "github.com/kumabox/kumabox/vmm/cloudhypervisor"
+)
+
+// openVMMRegistry assembles every backend enabled by this binary and freezes
+// the routing table before a sandbox workflow can use it. Adding another VMM
+// consists of constructing its adapter here and passing it to NewRegistry.
+func openVMMRegistry(configuration config.Config) (*vmm.Registry, error) {
+ paths, err := vmm.NewPaths(configuration.Paths)
+ if err != nil {
+ return nil, fmt.Errorf("initialize VMM paths: %w", err)
+ }
+ scopes, err := cgroup.New(configuration.VMM.CgroupParent)
+ if err != nil {
+ return nil, fmt.Errorf("initialize VMM cgroups: %w", err)
+ }
+ cloudHypervisor, err := cloudhypervisor.New(paths, scopes, cloudhypervisor.Options{
+ Binary: configuration.VMM.CloudHypervisor.Binary,
+ StartupTimeout: configuration.VMM.CloudHypervisor.StartupTimeout,
+ StopGrace: configuration.VMM.CloudHypervisor.StopGrace,
+ AbortGrace: configuration.VMM.CloudHypervisor.AbortGrace,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("initialize VMM cloud-hypervisor: %w", err)
+ }
+ registry, err := vmm.NewRegistry(cloudHypervisor)
+ if err != nil {
+ return nil, fmt.Errorf("initialize VMM registry: %w", err)
+ }
+ return registry, nil
+}
diff --git a/disk/disk.go b/disk/disk.go
new file mode 100644
index 0000000..337fc19
--- /dev/null
+++ b/disk/disk.go
@@ -0,0 +1,175 @@
+// Package disk prepares and removes sandbox-owned persistent disks.
+// It does not change lifecycle metadata or launch virtual machines.
+package disk
+
+import (
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/sandbox"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+const (
+ // ext4MagicOffset is the superblock offset plus the s_magic field offset.
+ ext4MagicOffset int64 = 1024 + 56
+ // ext4Magic identifies a formatted ext2/3/4 filesystem superblock.
+ ext4Magic uint16 = 0xef53
+)
+
+// Backend is the storage capability required by sandbox lifecycle workflows.
+// Implementations own disk creation, integrity checks, and idempotent cleanup;
+// they never mutate sandbox metadata.
+type Backend interface {
+ Prepare(context.Context, types.SandboxID, int64) error
+ Check(context.Context, types.SandboxID, int64) error
+ Remove(context.Context, types.SandboxID) error
+}
+
+// Ext4 prepares one sparse, private COW disk directly at its sandbox-owned path.
+type Ext4 struct {
+ // paths derives the final path from a validated sandbox ID.
+ paths sandbox.Paths
+ // mkfs is the executable name or test path invoked without a shell.
+ mkfs string
+}
+
+var _ Backend = (*Ext4)(nil)
+
+// NewExt4 creates a disk preparer using the configured mkfs.ext4 executable.
+func NewExt4(paths sandbox.Paths, binary string) (*Ext4, error) {
+ if strings.TrimSpace(binary) == "" {
+ return nil, errors.New("ext4 formatter binary is required")
+ }
+ return &Ext4{paths: paths, mkfs: binary}, nil
+}
+
+// Prepare creates and formats the final COW path. The preceding Creating record
+// owns any partial file, so this private resource needs no staging publication.
+//
+// ensure sandbox dir -> O_EXCL sparse truncate -> mkfs.ext4 -> superblock check
+func (d *Ext4) Prepare(ctx context.Context, id types.SandboxID, size int64) error {
+ if d == nil || d.mkfs == "" {
+ return errors.New("ext4 disk preparer is not configured")
+ }
+ if size < types.MinSandboxStorage {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("COW size must be at least %d bytes", types.MinSandboxStorage))
+ }
+ dir, err := d.paths.Dir(id)
+ if err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ path, err := d.paths.COW(id)
+ if err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if err := storage.EnsureDir(dir); err != nil {
+ return errdefs.Context(errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err), "prepare sandbox disk", id.String(), "directory", "check data root permissions", false)
+ }
+ root, err := os.OpenRoot(dir)
+ if err != nil {
+ return errdefs.Context(errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err), "prepare sandbox disk", id.String(), "open directory", "inspect the sandbox data directory", false)
+ }
+ file, err := root.OpenFile("cow.raw", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ return errdefs.Context(errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.Join(err, root.Close())), "prepare sandbox disk", id.String(), "create sparse file", "inspect the sandbox data directory", false)
+ }
+ truncateErr := file.Truncate(size)
+ closeErr := errors.Join(file.Close(), root.Close())
+ if err := errors.Join(truncateErr, closeErr); err != nil {
+ return errdefs.Context(errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err), "prepare sandbox disk", id.String(), "create sparse file", "remove the failed sandbox", false)
+ }
+ if _, err := exec.LookPath(d.mkfs); err != nil {
+ return errdefs.Context(errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, err), "prepare sandbox disk", id.String(), "format ext4", "install e2fsprogs or run kumabox doctor --fix", false)
+ }
+ output, err := exec.CommandContext( //nolint:gosec // executable is fixed by production construction; path is derived from validated roots and UUID
+ ctx, d.mkfs, "-F", "-m", "0", "-q", "-E", "lazy_itable_init=1,lazy_journal_init=1,discard", path,
+ ).CombinedOutput()
+ if err != nil {
+ detail := strings.TrimSpace(string(output))
+ if detail != "" {
+ err = fmt.Errorf("%w: %s", err, detail)
+ }
+ return errdefs.Context(errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err), "prepare sandbox disk", id.String(), "format ext4", "remove the failed sandbox after checking mkfs.ext4", false)
+ }
+ if err := validate(path, size); err != nil {
+ return errdefs.Context(errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, err), "prepare sandbox disk", id.String(), "validate ext4", "remove and recreate the sandbox", false)
+ }
+ return nil
+}
+
+// Check verifies that an existing sandbox COW is the expected regular ext4
+// file. It never repairs or reformats data during a lifecycle operation.
+func (d *Ext4) Check(_ context.Context, id types.SandboxID, size int64) error {
+ if d == nil {
+ return errors.New("ext4 disk store is not configured")
+ }
+ path, err := d.paths.COW(id)
+ if err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if err := validate(path, size); err != nil {
+ return errdefs.Context(
+ errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, err),
+ "check sandbox disk", id.String(), "validate ext4", "remove and recreate the sandbox", false,
+ )
+ }
+ return nil
+}
+
+// Remove deletes only the directory derived from a validated sandbox ID.
+// Missing directories are already clean.
+func (d *Ext4) Remove(_ context.Context, id types.SandboxID) error {
+ dir, err := d.paths.Dir(id)
+ if err != nil {
+ return err
+ }
+ if err := storage.CheckPath(dir); err != nil {
+ return err
+ }
+ if err := os.RemoveAll(dir); err != nil {
+ return fmt.Errorf("remove sandbox disk directory %s: %w", dir, err)
+ }
+ return nil
+}
+
+// validate checks the logical size and ext4 superblock without invoking another tool.
+func validate(path string, expectedSize int64) error {
+ if err := storage.CheckPath(path); err != nil {
+ return err
+ }
+ info, err := os.Lstat(path)
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() || info.Size() != expectedSize {
+ return fmt.Errorf("COW %s is not a regular %d-byte file", path, expectedSize)
+ }
+ root, err := os.OpenRoot(filepath.Dir(path))
+ if err != nil {
+ return err
+ }
+ file, err := root.Open(filepath.Base(path))
+ if err != nil {
+ return errors.Join(err, root.Close())
+ }
+ var magic [2]byte
+ _, readErr := file.ReadAt(magic[:], ext4MagicOffset)
+ closeErr := errors.Join(file.Close(), root.Close())
+ if err := errors.Join(readErr, closeErr); err != nil && !errors.Is(err, io.EOF) {
+ return err
+ }
+ if binary.LittleEndian.Uint16(magic[:]) != ext4Magic {
+ return fmt.Errorf("COW %s has no ext4 superblock", path)
+ }
+ return nil
+}
diff --git a/disk/disk_test.go b/disk/disk_test.go
new file mode 100644
index 0000000..ad83350
--- /dev/null
+++ b/disk/disk_test.go
@@ -0,0 +1,70 @@
+package disk
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/kumabox/kumabox/sandbox"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestExt4PreparesFinalSparsePathAndRemovesIt(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("test formatter is a POSIX shell script")
+ }
+ base := t.TempDir()
+ paths, err := sandbox.NewPaths(storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := paths.Ensure(); err != nil {
+ t.Fatal(err)
+ }
+ formatter := filepath.Join(base, "mkfs.ext4")
+ script := []byte("#!/bin/sh\nfor last do :; done\nprintf '\\123\\357' | dd of=\"$last\" bs=1 seek=1080 conv=notrunc 2>/dev/null\n")
+ if err := os.WriteFile(formatter, script, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ id := types.SandboxID("123e4567-e89b-42d3-a456-426614174000")
+ preparer, err := NewExt4(paths, formatter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if preparer.mkfs != formatter {
+ t.Fatalf("formatter = %q, want %q", preparer.mkfs, formatter)
+ }
+ if err := preparer.Prepare(t.Context(), id, types.MinSandboxStorage); err != nil {
+ t.Fatal(err)
+ }
+ cow, err := paths.COW(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ info, err := os.Stat(cow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Size() != types.MinSandboxStorage {
+ t.Fatalf("COW size = %d, want %d", info.Size(), types.MinSandboxStorage)
+ }
+ if err := preparer.Prepare(t.Context(), id, types.MinSandboxStorage); err == nil {
+ t.Fatal("second prepare replaced an owned COW")
+ }
+ if err := preparer.Remove(t.Context(), id); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(cow); !os.IsNotExist(err) {
+ t.Fatalf("COW remains after Remove: %v", err)
+ }
+}
+
+func TestNewExt4RejectsMissingFormatter(t *testing.T) {
+ if _, err := NewExt4(sandbox.Paths{}, ""); err == nil {
+ t.Fatal("NewExt4() accepted an empty formatter")
+ }
+}
diff --git a/errdefs/error.go b/errdefs/error.go
new file mode 100644
index 0000000..e51b3ec
--- /dev/null
+++ b/errdefs/error.go
@@ -0,0 +1,198 @@
+// Package errdefs carries stable failure codes, handling classes, and operation
+// context across module boundaries while preserving the original error chain.
+package errdefs
+
+import "errors"
+
+// Code is a stable machine-readable failure code.
+type Code string
+
+const (
+ // CodeNotFound indicates the requested entity or name is absent.
+ CodeNotFound Code = "NOT_FOUND"
+ // CodeNameTaken indicates a name is already bound to conflicting state.
+ CodeNameTaken Code = "NAME_TAKEN"
+ // CodeStateConflict indicates a generation or lifecycle precondition changed.
+ CodeStateConflict Code = "STATE_CONFLICT"
+ // CodeInvalidArgument indicates an argument or option violates the operation contract.
+ CodeInvalidArgument Code = "INVALID_ARGUMENT"
+ // CodeHostIncompatible indicates the host lacks a required tool or supported capability.
+ CodeHostIncompatible Code = "HOST_INCOMPATIBLE"
+ // CodeImageIncompatible indicates an image cannot satisfy the requested runtime contract.
+ CodeImageIncompatible Code = "IMAGE_INCOMPATIBLE"
+ // CodeDigestMismatch indicates content does not match its expected digest or diffID.
+ CodeDigestMismatch Code = "IMAGE_DIGEST_MISMATCH"
+ // CodeArtifactCorrupt indicates an artifact or metadata record has an invalid representation.
+ CodeArtifactCorrupt Code = "ARTIFACT_CORRUPT"
+ // CodeArtifactUnavailable indicates an artifact cannot be accessed or durably written.
+ CodeArtifactUnavailable Code = "ARTIFACT_UNAVAILABLE"
+ // CodeReferenced indicates an entity cannot be removed while live references remain.
+ CodeReferenced Code = "REFERENCED"
+ // CodeStoreBusy indicates the metadata engine cannot acquire a transaction within its budget.
+ CodeStoreBusy Code = "STORE_BUSY"
+ // CodeInternal indicates a failure has no more specific public classification.
+ CodeInternal Code = "INTERNAL"
+)
+
+// Class groups codes that share handling policy.
+type Class uint8
+
+const (
+ // ClassUnknown indicates the zero value has no handling classification.
+ ClassUnknown Class = iota
+ // ClassNotFound indicates the requested entity is absent.
+ ClassNotFound
+ // ClassInvalid indicates the caller must correct arguments or host requirements.
+ ClassInvalid
+ // ClassConflict indicates existing state prevents the requested change.
+ ClassConflict
+ // ClassUnavailable indicates a required resource is temporarily or operationally inaccessible.
+ ClassUnavailable
+ // ClassCorrupt indicates stored or supplied content violates integrity expectations.
+ ClassCorrupt
+ // ClassInternal indicates an unexpected implementation failure occurred.
+ ClassInternal
+)
+
+// Error carries stable classification and diagnostic context across layers.
+type Error struct {
+ // Class selects broad handling policy independently of the diagnostic message.
+ Class Class
+ // Code identifies the failure for automation without parsing text.
+ Code Code
+ // Operation identifies the user-visible operation that failed.
+ Operation string
+ // Entity identifies the affected image, name, or other module record.
+ Entity string
+ // Phase locates failure within the operation lifecycle.
+ Phase string
+ // Committed records that durable business state changed despite this error;
+ // callers must inspect resulting state before deciding to retry.
+ Committed bool
+ // Action suggests a recovery step for the caller.
+ Action string
+ // Cause is the direct diagnostic cause rendered to users. Context may remove
+ // an older classification from this view so the stable code is printed once.
+ Cause error
+ // wrapped preserves the complete input tree for errors.Is and errors.As when
+ // Context replaces an existing classification's presentation fields.
+ wrapped error
+}
+
+// Error renders classification and available context, including the recovery action.
+// A nil receiver is printable.
+func (e *Error) Error() string {
+ if e == nil {
+ return ""
+ }
+ message := string(e.Code)
+ if e.Operation != "" {
+ message = e.Operation + ": " + message
+ }
+ if e.Entity != "" {
+ message += " (" + e.Entity + ")"
+ }
+ if e.Phase != "" {
+ message += " at " + e.Phase
+ }
+ if e.Cause != nil {
+ message += ": " + e.Cause.Error()
+ }
+ if e.Action != "" {
+ message += "; " + e.Action
+ }
+ return message
+}
+
+// Unwrap exposes the complete original error tree to standard error inspection.
+// A newly classified error unwraps directly to Cause; a recontextualized error
+// unwraps to the input tree retained by Context.
+func (e *Error) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ if e.wrapped != nil {
+ return e.wrapped
+ }
+ return e.Cause
+}
+
+// New classifies cause at its producing boundary.
+func New(class Class, code Code, cause error) *Error {
+ if cause == nil {
+ cause = errors.New(string(code))
+ }
+ return &Error{Class: class, Code: code, Cause: cause}
+}
+
+// Context wraps err with operation context without mutating an existing Error.
+// Nonempty supplied fields override prior context, and Committed can only become
+// true. Unclassified errors receive ClassInternal/CodeInternal; nil remains nil.
+func Context(err error, operation, entity, phase, action string, committed bool) error {
+ if err == nil {
+ return nil
+ }
+ var classified *Error
+ if errors.As(err, &classified) {
+ copy := *classified
+ copy.Operation = first(operation, copy.Operation)
+ copy.Entity = first(entity, copy.Entity)
+ copy.Phase = first(phase, copy.Phase)
+ copy.Action = first(action, copy.Action)
+ copy.Committed = committed || copy.Committed
+ copy.Cause = diagnosticCause(err, classified)
+ copy.wrapped = err
+ return ©
+ }
+ return &Error{
+ Class: ClassInternal, Code: CodeInternal, Operation: operation,
+ Entity: entity, Phase: phase, Committed: committed, Action: action,
+ Cause: err,
+ }
+}
+
+// diagnosticCause removes the classification being replaced from the rendered
+// cause while retaining independent errors from an errors.Join tree. Context
+// keeps the unmodified tree separately for errors.Is and errors.As.
+func diagnosticCause(err error, classified *Error) error {
+ if err == nil {
+ return nil
+ }
+ if err == classified {
+ return classified.Cause
+ }
+
+ type multiUnwrapper interface {
+ Unwrap() []error
+ }
+ if joined, ok := err.(multiUnwrapper); ok {
+ causes := make([]error, 0, len(joined.Unwrap()))
+ for _, cause := range joined.Unwrap() {
+ causes = append(causes, diagnosticCause(cause, classified))
+ }
+ return errors.Join(causes...)
+ }
+
+ var nested *Error
+ if errors.As(err, &nested) && nested == classified {
+ return classified.Cause
+ }
+ return err
+}
+
+// CodeOf returns the stable code in err's unwrap chain.
+func CodeOf(err error) (Code, bool) {
+ var target *Error
+ if !errors.As(err, &target) {
+ return "", false
+ }
+ return target.Code, true
+}
+
+// first keeps existing diagnostic context when the wrapping boundary omits a field.
+func first(value, fallback string) string {
+ if value != "" {
+ return value
+ }
+ return fallback
+}
diff --git a/errdefs/error_test.go b/errdefs/error_test.go
new file mode 100644
index 0000000..0616455
--- /dev/null
+++ b/errdefs/error_test.go
@@ -0,0 +1,86 @@
+package errdefs
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestContextClassifiesUnclassifiedError(t *testing.T) {
+ cause := errors.New("read metadata")
+ err := Context(cause, "inspect sandbox", "box", "metadata", "retry the query", false)
+
+ var classified *Error
+ if !errors.As(err, &classified) {
+ t.Fatal("Context() did not return a classified error")
+ }
+ if classified.Class != ClassInternal || classified.Code != CodeInternal {
+ t.Fatalf("classification = (%d, %q), want (%d, %q)", classified.Class, classified.Code, ClassInternal, CodeInternal)
+ }
+ if !errors.Is(err, cause) {
+ t.Fatal("Context() did not preserve the original cause")
+ }
+ if got, want := err.Error(), "inspect sandbox: INTERNAL (box) at metadata: read metadata; retry the query"; got != want {
+ t.Fatalf("Error() = %q, want %q", got, want)
+ }
+}
+
+func TestContextReplacesPresentationWithoutDuplicatingClassification(t *testing.T) {
+ cause := errors.New("connect guest vsock")
+ original := New(ClassUnavailable, CodeArtifactUnavailable, cause)
+ first := Context(original, "connect agent", "box", "dial", "retry shortly", false)
+ second := Context(first, "execute sandbox command", "", "run", "inspect the guest agent", true)
+
+ got := second.Error()
+ if count := strings.Count(got, string(CodeArtifactUnavailable)); count != 1 {
+ t.Fatalf("Error() contains the classification %d times, want once: %q", count, got)
+ }
+ if want := "execute sandbox command: ARTIFACT_UNAVAILABLE (box) at run: connect guest vsock; inspect the guest agent"; got != want {
+ t.Fatalf("Error() = %q, want %q", got, want)
+ }
+
+ var classified *Error
+ if !errors.As(second, &classified) {
+ t.Fatal("errors.As() did not find the outer classified error")
+ }
+ if classified.Operation != "execute sandbox command" || classified.Entity != "box" || classified.Phase != "run" || !classified.Committed {
+ t.Fatalf("outer context = %#v", classified)
+ }
+ if !errors.Is(second, cause) || !errors.Is(second, original) {
+ t.Fatal("Context() did not preserve the original error chain")
+ }
+ if original.Operation != "" || original.Entity != "" || original.Phase != "" || original.Committed {
+ t.Fatalf("Context() mutated the original error: %#v", original)
+ }
+ if code, ok := CodeOf(second); !ok || code != CodeArtifactUnavailable {
+ t.Fatalf("CodeOf() = (%q, %t), want (%q, true)", code, ok, CodeArtifactUnavailable)
+ }
+}
+
+func TestContextPreservesJoinedErrors(t *testing.T) {
+ cause := errors.New("write metadata")
+ cleanup := errors.New("close metadata")
+ original := New(ClassUnavailable, CodeArtifactUnavailable, cause)
+ err := Context(errors.Join(original, cleanup), "create sandbox", "box", "commit", "inspect the sandbox", true)
+
+ got := err.Error()
+ for _, message := range []string{string(CodeArtifactUnavailable), cause.Error(), cleanup.Error()} {
+ if count := strings.Count(got, message); count != 1 {
+ t.Fatalf("Error() contains %q %d times, want once: %q", message, count, got)
+ }
+ }
+ if !errors.Is(err, original) || !errors.Is(err, cause) || !errors.Is(err, cleanup) {
+ t.Fatal("Context() did not preserve every branch of the joined error")
+ }
+
+ var classified *Error
+ if !errors.As(err, &classified) || !classified.Committed {
+ t.Fatalf("errors.As() = %#v, want committed classified error", classified)
+ }
+}
+
+func TestContextNil(t *testing.T) {
+ if err := Context(nil, "operation", "entity", "phase", "action", true); err != nil {
+ t.Fatalf("Context(nil) = %v, want nil", err)
+ }
+}
diff --git a/go.mod b/go.mod
index 5033902..c16a9c9 100644
--- a/go.mod
+++ b/go.mod
@@ -3,40 +3,55 @@ module github.com/kumabox/kumabox
go 1.24.4
require (
- github.com/containernetworking/cni v1.2.3
- github.com/google/go-containerregistry v0.19.2
- github.com/klauspost/compress v1.17.11
- github.com/pelletier/go-toml/v2 v2.2.3
- github.com/spf13/cobra v1.8.1
+ github.com/containernetworking/cni v1.3.0
+ github.com/containernetworking/plugins v1.9.1
+ github.com/gofrs/flock v0.13.0
+ github.com/google/go-containerregistry v0.20.6
+ github.com/klauspost/compress v1.18.0
+ github.com/mattn/go-isatty v0.0.20
+ github.com/mdlayher/vsock v1.2.1
+ github.com/moby/term v0.5.2
+ github.com/spf13/cobra v1.10.2
+ github.com/spf13/pflag v1.0.10
+ github.com/spf13/viper v1.21.0
github.com/vishvananda/netlink v1.3.1
github.com/vishvananda/netns v0.0.5
+ golang.org/x/sync v0.16.0
+ modernc.org/sqlite v1.38.2
)
-require golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
-
require (
+ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
- github.com/docker/cli v27.5.0+incompatible // indirect
+ github.com/docker/cli v28.2.2+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
- github.com/docker/docker-credential-helpers v0.8.2 // indirect
+ github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mdlayher/socket v0.5.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
- github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
- github.com/opencontainers/image-spec v1.1.0 // indirect
+ github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
- github.com/spf13/pflag v1.0.5 // indirect
- github.com/vbatts/tar-split v0.11.6 // indirect
- golang.org/x/sync v0.10.0
- golang.org/x/sys v0.29.0
- golang.org/x/term v0.27.0
- modernc.org/libc v1.61.13 // indirect
+ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/vbatts/tar-split v0.12.1 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
+ golang.org/x/net v0.43.0 // indirect
+ golang.org/x/sys v0.37.0 // indirect
+ golang.org/x/text v0.28.0 // indirect
+ modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
- modernc.org/memory v1.8.2 // indirect
- modernc.org/sqlite v1.35.0
+ modernc.org/memory v1.11.0 // indirect
)
diff --git a/go.sum b/go.sum
index ce56ada..1cf5386 100644
--- a/go.sum
+++ b/go.sum
@@ -1,120 +1,168 @@
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
-github.com/containernetworking/cni v1.2.3 h1:hhOcjNVUQTnzdRJ6alC5XF+wd9mfGIUaj8FuJbEslXM=
-github.com/containernetworking/cni v1.2.3/go.mod h1:DuLgF+aPd3DzcTQTtp/Nvl1Kim23oFKdm2okJzBQA5M=
-github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEmnuFjskwo=
+github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4=
+github.com/containernetworking/plugins v1.9.1 h1:8oU6WsIsU3bpnNZuvHp74a6cE1MJwbj2P7s4/yTUNlA=
+github.com/containernetworking/plugins v1.9.1/go.mod h1:fj7kS55qg3o/RgS+WGsF3+ZxwIImMPusQZKzBpcSr4c=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/docker/cli v27.5.0+incompatible h1:aMphQkcGtpHixwwhAXJT1rrK/detk2JIvDaFkLctbGM=
-github.com/docker/cli v27.5.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
+github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
-github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
+github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
+github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
-github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-containerregistry v0.19.2 h1:TannFKE1QSajsP6hPWb5oJNgKe1IKjHukIKDUmvsV6w=
-github.com/google/go-containerregistry v0.19.2/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
-github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg=
-github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
+github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
+github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
+github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
+github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
-github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
+github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
+github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ=
+github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
-github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
-github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
-github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
-github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
-github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/onsi/ginkgo/v2 v2.25.1 h1:Fwp6crTREKM+oA6Cz4MsO8RhKQzs2/gOIVOUscMAfZY=
+github.com/onsi/ginkgo/v2 v2.25.1/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk=
+github.com/onsi/gomega v1.38.1 h1:FaLA8GlcpXDwsb7m0h2A9ew2aTk3vnZMlzFgg5tz/pk=
+github.com/onsi/gomega v1.38.1/go.mod h1:LfcV8wZLvwcYRwPiJysphKAEsmcFnLMK/9c+PjvlX8g=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
-github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
-github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
-github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
-github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
+github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
-github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/vbatts/tar-split v0.11.6 h1:4SjTW5+PU11n6fZenf2IPoV8/tz3AaYHMWjf23envGs=
-github.com/vbatts/tar-split v0.11.6/go.mod h1:dqKNtesIOr2j2Qv3W/cHjnvk9I8+G7oAkFDFN6TCBEI=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
+github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
-golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
-golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
-golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
-golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
+go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
+golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
+golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
+golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
+golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
-golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
-golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
+golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
-modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
-modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
-modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
-modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
-modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
-modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
-modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
-modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
-modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
-modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
+modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
+modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
+modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
-modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
-modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw=
-modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic=
+modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
+modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
diff --git a/images/artifacts.go b/images/artifacts.go
new file mode 100644
index 0000000..d6ce454
--- /dev/null
+++ b/images/artifacts.go
@@ -0,0 +1,148 @@
+package images
+
+import (
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+// Paths derives managed image, metadata, staging and lock paths from validated roots.
+// Source digests key shared EROFS and boot artifacts; hashes of converted files
+// are stored separately in layer metadata.
+type Paths struct {
+ // roots is validated once so all derived paths share the same storage boundary.
+ roots storage.Roots
+}
+
+// NewPaths validates roots without creating directories.
+func NewPaths(roots storage.Roots) (Paths, error) {
+ validated, err := roots.Validate()
+ if err != nil {
+ return Paths{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ return Paths{roots: validated}, nil
+}
+
+// Ensure creates the managed artifact, staging and lock directories safely.
+func (p Paths) Ensure() error {
+ for _, path := range []string{p.LayersDir(), p.BootBaseDir(), p.StagingDir(), p.LocksDir()} {
+ if err := storage.EnsureDir(path); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// LayersDir is the shared EROFS directory keyed by source SHA-256 digest.
+func (p Paths) LayersDir() string { return filepath.Join(p.roots.Data, "images", "layers", "sha256") }
+
+// BootBaseDir contains per-source-layer boot artifact directories.
+func (p Paths) BootBaseDir() string { return filepath.Join(p.roots.Data, "images", "boot", "sha256") }
+
+// StagingDir contains disposable work directories for local imports.
+func (p Paths) StagingDir() string { return filepath.Join(p.roots.Data, "staging", "imports") }
+
+// LocksDir contains runtime advisory locks shared by import, verify and removal.
+func (p Paths) LocksDir() string { return filepath.Join(p.roots.Run, "locks", "images") }
+
+// MetadataDB is the SQLite metadata path used by application assembly.
+func (p Paths) MetadataDB() string { return filepath.Join(p.roots.Data, "meta", "meta.db") }
+
+// EROFS returns the managed converted filesystem path for a source digest.
+func (p Paths) EROFS(digest types.Digest) string {
+ return filepath.Join(p.LayersDir(), digest.Hex()+".erofs")
+}
+
+// BootDir returns the extracted boot directory for a source digest.
+func (p Paths) BootDir(digest types.Digest) string {
+ return filepath.Join(p.BootBaseDir(), digest.Hex())
+}
+
+// BootFile validates a boot basename before joining it to the managed directory.
+func (p Paths) BootFile(digest types.Digest, name string) (string, error) {
+ if !IsBootName(name) {
+ return "", fmt.Errorf("invalid boot artifact name %q", name)
+ }
+ return storage.Join(p.BootDir(digest), name)
+}
+
+// Kernel returns the conventional vmlinuz path; use BootFile for a selected versioned name.
+func (p Paths) Kernel(digest types.Digest) string { return filepath.Join(p.BootDir(digest), "vmlinuz") }
+
+// Initrd returns the conventional initrd.img path; use BootFile for a selected versioned name.
+func (p Paths) Initrd(digest types.Digest) string {
+ return filepath.Join(p.BootDir(digest), "initrd.img")
+}
+
+// Lock returns the advisory lock path protecting a source digest and its artifacts.
+func (p Paths) Lock(digest types.Digest) string {
+ return filepath.Join(p.LocksDir(), digest.Hex()+".lock")
+}
+
+// NewStaging creates a unique work directory; the caller must remove it after use.
+func (p Paths) NewStaging(pattern string) (string, error) {
+ if err := p.Ensure(); err != nil {
+ return "", err
+ }
+ dir, err := os.MkdirTemp(p.StagingDir(), pattern)
+ if err != nil {
+ return "", fmt.Errorf("create image staging directory: %w", err)
+ }
+ return dir, nil
+}
+
+// digestFileContext rejects unsafe paths and non-regular artifacts before hashing.
+// Cancellation is checked between reads and all file handles are closed on return.
+func digestFileContext(ctx context.Context, path string) (types.Digest, int64, error) {
+ if err := storage.CheckPath(path); err != nil {
+ return types.Digest{}, 0, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ info, err := os.Lstat(path)
+ if err != nil {
+ return types.Digest{}, 0, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err)
+ }
+ if !info.Mode().IsRegular() {
+ return types.Digest{}, 0, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("artifact %s is not a regular file", path))
+ }
+ root, err := os.OpenRoot(filepath.Dir(path))
+ if err != nil {
+ return types.Digest{}, 0, err
+ }
+ file, err := root.Open(filepath.Base(path))
+ if err != nil {
+ return types.Digest{}, 0, errors.Join(fmt.Errorf("open %s: %w", path, err), root.Close())
+ }
+ hash := sha256.New()
+ size, copyErr := io.Copy(hash, contextReader{ctx: ctx, reader: file})
+ closeErr := errors.Join(file.Close(), root.Close())
+ if err := errors.Join(copyErr, closeErr); err != nil {
+ return types.Digest{}, 0, fmt.Errorf("hash %s: %w", path, err)
+ }
+ digest, err := types.ParseDigest(fmt.Sprintf("sha256:%x", hash.Sum(nil)))
+ return digest, size, err
+}
+
+// contextReader checks cancellation between reads; it cannot interrupt a blocked
+// underlying Read, so sources must also implement their own cancellation.
+type contextReader struct {
+ // ctx stops further reads after cancellation.
+ ctx context.Context
+ // reader supplies the artifact bytes without taking ownership of its lifetime.
+ reader io.Reader
+}
+
+// Read forwards data only while the context remains active.
+func (r contextReader) Read(p []byte) (int, error) {
+ if err := r.ctx.Err(); err != nil {
+ return 0, err
+ }
+ return r.reader.Read(p)
+}
diff --git a/images/boot.go b/images/boot.go
new file mode 100644
index 0000000..fe15e39
--- /dev/null
+++ b/images/boot.go
@@ -0,0 +1,76 @@
+package images
+
+import (
+ "errors"
+ "path/filepath"
+ "strings"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+// IsBootName accepts kernel or initrd basenames and excludes .old backups.
+// It classifies names only; callers must independently require regular files.
+func IsBootName(name string) bool {
+ return filepath.Base(name) == name && !strings.HasSuffix(name, ".old") &&
+ (strings.HasPrefix(name, "vmlinuz") || strings.HasPrefix(name, "initrd.img"))
+}
+
+// SelectBoot applies layer overwrites and whiteouts to regular boot candidates.
+// Layers must be ordered from base to top. For each artifact kind the last
+// surviving candidate wins; a missing regular kernel or initrd is incompatible.
+//
+// base candidates -> opaque reset -> named whiteouts -> current regular files
+// (repeat for each layer) |
+// v
+// last kernel + last initrd
+func SelectBoot(layers []types.Layer) (types.Boot, error) {
+ // candidate retains provenance while upper layers overwrite the visible boot set.
+ type candidate struct {
+ // layer keys the managed boot directory for this surviving candidate.
+ layer types.Digest
+ // file supplies the basename and integrity facts selected for boot.
+ file types.BootFile
+ }
+ var candidates []candidate
+ for _, layer := range layers {
+ if layer.BootOpaque {
+ candidates = nil
+ }
+ for _, name := range layer.Whiteouts {
+ var kept []candidate
+ for _, c := range candidates {
+ if c.file.Name != name {
+ kept = append(kept, c)
+ }
+ }
+ candidates = kept
+ }
+ for _, file := range layer.BootFiles {
+ var kept []candidate
+ for _, c := range candidates {
+ if c.file.Name != file.Name {
+ kept = append(kept, c)
+ }
+ }
+ kept = append(kept, candidate{layer: layer.SourceDigest, file: file})
+ candidates = kept
+ }
+ }
+ var boot types.Boot
+ for _, c := range candidates {
+ if strings.HasPrefix(c.file.Name, "vmlinuz") {
+ boot.KernelLayer, boot.KernelFile = c.layer, c.file.Name
+ }
+ if strings.HasPrefix(c.file.Name, "initrd.img") {
+ boot.InitrdLayer, boot.InitrdFile = c.layer, c.file.Name
+ }
+ }
+ if boot.KernelLayer.IsZero() {
+ return types.Boot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("image is missing a regular /boot/vmlinuz* kernel"))
+ }
+ if boot.InitrdLayer.IsZero() {
+ return types.Boot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("image is missing a regular /boot/initrd.img* initrd"))
+ }
+ return boot, nil
+}
diff --git a/images/catalog.go b/images/catalog.go
new file mode 100644
index 0000000..880d036
--- /dev/null
+++ b/images/catalog.go
@@ -0,0 +1,91 @@
+package images
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+// ImportCommit contains image facts that a catalog must persist atomically.
+// Artifact files must already have been published and verified by the caller.
+type ImportCommit struct {
+ // Name is the local alias to create or bind to the same existing manifest.
+ Name string
+ // Manifest identifies the image and defines the exact layer order.
+ Manifest types.Manifest
+ // Layers contains converted metadata in the same order as Manifest.Layers.
+ Layers []types.Layer
+ // Boot must equal the overlay-aware selection derived from Layers.
+ Boot types.Boot
+ // Size must equal the sum of converted layer sizes, without overflow.
+ Size int64
+ // Created supplies a nonzero timestamp for a newly registered manifest.
+ Created time.Time
+}
+
+// Removal describes metadata already removed and artifacts eligible for deletion.
+type Removal struct {
+ // Names contains aliases deleted by the catalog transaction.
+ Names []string
+ // Layers contains source digests no longer referenced by any registered image.
+ Layers []types.Digest
+}
+
+// CatalogReader reconstructs image facts from a consistent metadata snapshot.
+type CatalogReader interface {
+ // Resolve accepts an exact alias or an unambiguous manifest digest prefix.
+ Resolve(context.Context, string) (types.Image, error)
+ // List returns committed images with their aliases and ordered layers.
+ List(context.Context) ([]types.Image, error)
+ // FindLayers returns committed mappings and rejects conflicting shared artifacts.
+ FindLayers(context.Context, []types.Digest) (map[types.Digest]types.Layer, error)
+}
+
+// CatalogWriter changes aliases, image facts and layer references atomically.
+type CatalogWriter interface {
+ // CommitImport registers validated facts; an alias bound elsewhere is a conflict.
+ CommitImport(context.Context, ImportCommit) error
+ // Remove deletes an alias, or all aliases for a digest reference, and returns
+ // unreferenced layers. expected must still match the resolved manifest.
+ Remove(context.Context, string, types.Digest) (Removal, error)
+}
+
+// Catalog combines the metadata contracts used by image management commands.
+type Catalog interface {
+ CatalogReader
+ CatalogWriter
+}
+
+// Validate checks the image facts that must be committed together.
+func (commit ImportCommit) Validate() error {
+ if commit.Name == "" || commit.Manifest.Digest.IsZero() || !commit.Manifest.Platform.Valid() || len(commit.Layers) == 0 || len(commit.Layers) != len(commit.Manifest.Layers) || commit.Created.IsZero() {
+ return errors.New("invalid image name, manifest, platform, layers or creation time")
+ }
+ var size int64
+ for pos, layer := range commit.Layers {
+ if layer.SourceDigest.IsZero() || layer.EROFSDigest.IsZero() || layer.SourceDigest != commit.Manifest.Layers[pos].Digest || layer.Size <= 0 || size > (1<<63-1)-layer.Size {
+ return errors.New("invalid layer identity, order or size")
+ }
+ size += layer.Size
+ seen := make(map[string]bool)
+ for _, file := range layer.BootFiles {
+ if !IsBootName(file.Name) || file.Digest.IsZero() || file.Size <= 0 || seen[file.Name] {
+ return errors.New("invalid boot file metadata")
+ }
+ seen[file.Name] = true
+ }
+ for _, name := range layer.Whiteouts {
+ if !IsBootName(name) {
+ return errors.New("invalid boot whiteout")
+ }
+ }
+ }
+ boot, err := SelectBoot(commit.Layers)
+ boot.Profile = commit.Manifest.BootProfile
+ if err != nil || boot != commit.Boot || size != commit.Size {
+ return errors.New("inconsistent boot selection or total image size")
+ }
+ return nil
+}
diff --git a/images/catalog/store.go b/images/catalog/store.go
new file mode 100644
index 0000000..a20ff5d
--- /dev/null
+++ b/images/catalog/store.go
@@ -0,0 +1,543 @@
+// Package catalog adapts transactional metadata storage to image management.
+// It stores manifest facts, local aliases and ordered layer references together;
+// filesystem publication and reclamation remain the images workflows' responsibility.
+package catalog
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/types"
+)
+
+var _ images.Catalog = (*Store)(nil)
+
+const (
+ // CollectionImages stores one fact record per manifest digest.
+ CollectionImages metadata.Collection = "images"
+ // CollectionNames maps each local alias to a manifest digest.
+ CollectionNames metadata.Collection = "image_names"
+ // CollectionLayers stores layer occurrences keyed by manifest and position.
+ CollectionLayers metadata.Collection = "image_layers"
+ // minimumDigestPrefix limits accidental matches from short hexadecimal names.
+ minimumDigestPrefix = 12
+)
+
+// Collections returns the collections that must be registered with the metadata engine.
+func Collections() []metadata.Collection {
+ return []metadata.Collection{CollectionImages, CollectionNames, CollectionLayers}
+}
+
+// Store implements the image catalog over an already-open metadata store.
+// It does not own or close that store, and it never reads or modifies artifact files.
+type Store struct {
+ // store supplies snapshot reads and atomic writes for all image collections.
+ store metadata.Store
+ // usage checks cross-module references before the final manifest is removed.
+ usage ImageUsage
+}
+
+// ImageUsage checks sandbox references from inside the image removal transaction.
+// Implementations must use reader directly and must not open a nested transaction.
+type ImageUsage interface {
+ InUse(context.Context, metadata.Reader, types.Digest) (bool, error)
+}
+
+// Option configures optional cross-module catalog policies.
+type Option func(*Store)
+
+// WithImageUsage prevents removal of a final manifest while another module uses it.
+func WithImageUsage(usage ImageUsage) Option {
+ return func(store *Store) { store.usage = usage }
+}
+
+// New creates an adapter for a non-nil metadata store whose schema includes
+// Collections. The caller retains ownership of the store's lifetime. A catalog
+// sharing metadata with sandboxes must supply WithImageUsage.
+func New(store metadata.Store, options ...Option) *Store {
+ result := &Store{store: store}
+ for _, option := range options {
+ option(result)
+ }
+ return result
+}
+
+// Reader resolves image facts from a transaction owned by another module.
+// It is stateless so core can connect catalogs without creating an import cycle.
+type Reader struct{}
+
+// Resolve reads an alias or digest from the supplied transaction snapshot.
+func (Reader) Resolve(ctx context.Context, reader metadata.Reader, reference string) (types.Image, error) {
+ return resolveRecord(ctx, reader, reference)
+}
+
+// imageRecord stores manifest-wide facts separately from aliases and layer order.
+type imageRecord struct {
+ // ManifestDigest must match this record's collection key.
+ ManifestDigest string `json:"manifest_digest"`
+ // OS and Architecture select the supported source platform.
+ OS string `json:"os"`
+ // Architecture is the platform instruction set, independent of the host.
+ Architecture string `json:"architecture"`
+ // BootProfile is the versioned early-userspace contract declared by the image.
+ // Missing values preserve compatibility with records written before profiles.
+ BootProfile string `json:"boot_profile,omitempty"`
+ // KernelLayer identifies the source layer owning the selected kernel.
+ KernelLayer string `json:"kernel_layer"`
+ // KernelFile is the selected regular boot basename.
+ KernelFile string `json:"kernel_file"`
+ // InitrdFile is the selected regular initrd basename.
+ InitrdFile string `json:"initrd_file"`
+ // InitrdLayer identifies the source layer owning the selected initrd.
+ InitrdLayer string `json:"initrd_layer"`
+ // Size sums converted layer occurrences in bytes.
+ Size int64 `json:"size"`
+ // CreatedAt preserves the first local registration time when aliases are added.
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// nameRecord allows multiple local aliases to refer to one manifest record.
+type nameRecord struct {
+ // ManifestDigest is the canonical key in CollectionImages.
+ ManifestDigest string `json:"manifest_digest"`
+}
+
+// layerRecord represents a manifest occurrence, not a globally unique layer row.
+// Repeated source digests retain separate positions but must agree on artifact facts.
+type layerRecord struct {
+ // ManifestDigest identifies the image containing this occurrence.
+ ManifestDigest string `json:"manifest_digest"`
+ // Position is zero-based; stored positions must be contiguous.
+ Position int `json:"position"`
+ // SourceDigest keys shared converted artifacts on disk.
+ SourceDigest string `json:"source_digest"`
+ // EROFSDigest verifies the converted filesystem bytes.
+ EROFSDigest string `json:"erofs_digest"`
+ // Size is the converted filesystem size in bytes.
+ Size int64 `json:"size"`
+ // BootFiles stores extracted regular candidates before cross-layer selection.
+ BootFiles []bootFileRecord `json:"boot_files"`
+ // Whiteouts names candidates hidden in lower layers.
+ Whiteouts []string `json:"whiteouts"`
+ // BootOpaque discards all inherited boot candidates.
+ BootOpaque bool `json:"boot_opaque"`
+}
+
+// bootFileRecord verifies an extracted boot file independently of its source tar.
+type bootFileRecord struct {
+ // Name is an accepted boot basename under the source layer's boot directory.
+ Name string `json:"name"`
+ // Digest hashes the extracted file after any kernel decompression.
+ Digest string `json:"digest"`
+ // Size is the extracted file size in bytes.
+ Size int64 `json:"size"`
+}
+
+func encodeBootFiles(files []types.BootFile) []bootFileRecord {
+ result := make([]bootFileRecord, 0, len(files))
+ for _, file := range files {
+ result = append(result, bootFileRecord{Name: file.Name, Digest: file.Digest.String(), Size: file.Size})
+ }
+ return result
+}
+
+// Resolve prefers an exact local alias, then accepts a unique manifest digest
+// prefix of at least 12 hex characters, with or without the sha256: prefix.
+func (c *Store) Resolve(ctx context.Context, reference string) (types.Image, error) {
+ var result types.Image
+ err := c.store.View(ctx, func(reader metadata.Reader) error {
+ image, err := (Reader{}).Resolve(ctx, reader, reference)
+ if err != nil {
+ return err
+ }
+ result = image
+ return nil
+ })
+ return result, errdefs.Context(err, "resolve image", reference, "metadata", "check the image name or digest", false)
+}
+
+// List loads a consistent snapshot and sorts images by full manifest digest.
+// Each image includes sorted aliases and manifest-ordered layer occurrences.
+func (c *Store) List(ctx context.Context) ([]types.Image, error) {
+ result := make([]types.Image, 0)
+ err := c.store.View(ctx, func(reader metadata.Reader) error {
+ return reader.Scan(ctx, CollectionImages, func(id string, _ []byte) error {
+ image, err := loadImage(ctx, reader, id)
+ if err != nil {
+ return err
+ }
+ result = append(result, image)
+ return nil
+ })
+ })
+ slices.SortFunc(result, func(left, right types.Image) int {
+ return strings.Compare(left.ManifestDigest.String(), right.ManifestDigest.String())
+ })
+ return result, errdefs.Context(err, "list images", "", "metadata", "inspect the metadata store", false)
+}
+
+// FindLayers returns committed mappings for requested source digests.
+// Conflicting mappings across images are corruption rather than reusable cache entries.
+func (c *Store) FindLayers(ctx context.Context, digests []types.Digest) (map[types.Digest]types.Layer, error) {
+ wanted := make(map[types.Digest]struct{}, len(digests))
+ for _, digest := range digests {
+ wanted[digest] = struct{}{}
+ }
+ result := make(map[types.Digest]types.Layer)
+ err := c.store.View(ctx, func(reader metadata.Reader) error {
+ return reader.Scan(ctx, CollectionLayers, func(_ string, raw []byte) error {
+ var record layerRecord
+ if err := json.Unmarshal(raw, &record); err != nil {
+ return corruptRecord("layer", err)
+ }
+ layer, err := decodeLayer(record)
+ if err != nil {
+ return err
+ }
+ if _, ok := wanted[layer.SourceDigest]; ok {
+ if previous, ok := result[layer.SourceDigest]; ok && !previous.Equal(layer) {
+ return corruptRecord("shared layer", errors.New("conflicting artifact metadata"))
+ }
+ result[layer.SourceDigest] = layer
+ }
+ return nil
+ })
+ })
+ return result, err
+}
+
+// CommitImport atomically binds an alias and stores validated image facts.
+// Reimporting the same manifest adds an alias without changing its creation time;
+// altered manifest facts or an alias bound to a different digest are rejected.
+func (c *Store) CommitImport(ctx context.Context, commit images.ImportCommit) error {
+ if err := commit.Validate(); err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ err := c.store.Update(ctx, func(writer metadata.Writer) error {
+ rawName, exists, err := writer.Get(ctx, CollectionNames, commit.Name)
+ if err != nil {
+ return err
+ }
+ if exists {
+ var current nameRecord
+ if err := json.Unmarshal(rawName, ¤t); err != nil {
+ return corruptRecord("name", err)
+ }
+ if current.ManifestDigest != commit.Manifest.Digest.String() {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeNameTaken, fmt.Errorf("image name %q already points to %s", commit.Name, current.ManifestDigest))
+ }
+ }
+ if _, exists, err := writer.Get(ctx, CollectionImages, commit.Manifest.Digest.String()); err != nil {
+ return err
+ } else if exists {
+ existing, err := loadImage(ctx, writer, commit.Manifest.Digest.String())
+ if err != nil {
+ return err
+ }
+ if existing.Platform != commit.Manifest.Platform || existing.Boot != commit.Boot || existing.Size != commit.Size || len(existing.Layers) != len(commit.Layers) {
+ return corruptRecord("image", errors.New("manifest facts changed"))
+ }
+ for pos, layer := range existing.Layers {
+ if !layer.Equal(commit.Layers[pos]) {
+ return corruptRecord("image", errors.New("manifest layers changed"))
+ }
+ }
+ return putJSON(ctx, writer, CollectionNames, commit.Name, nameRecord{ManifestDigest: commit.Manifest.Digest.String()})
+ }
+ record := imageRecord{
+ ManifestDigest: commit.Manifest.Digest.String(), OS: commit.Manifest.Platform.OS,
+ Architecture: commit.Manifest.Platform.Architecture, BootProfile: string(commit.Boot.Profile),
+ KernelLayer: commit.Boot.KernelLayer.String(), InitrdLayer: commit.Boot.InitrdLayer.String(), KernelFile: commit.Boot.KernelFile, InitrdFile: commit.Boot.InitrdFile,
+ Size: commit.Size, CreatedAt: commit.Created,
+ }
+ if err := putJSON(ctx, writer, CollectionImages, commit.Manifest.Digest.String(), record); err != nil {
+ return err
+ }
+ for position, layer := range commit.Layers {
+ record := layerRecord{
+ ManifestDigest: commit.Manifest.Digest.String(), Position: position,
+ SourceDigest: layer.SourceDigest.String(), EROFSDigest: layer.EROFSDigest.String(),
+ Size: layer.Size, BootFiles: encodeBootFiles(layer.BootFiles), Whiteouts: layer.Whiteouts, BootOpaque: layer.BootOpaque,
+ }
+ if err := putJSON(ctx, writer, CollectionLayers, layerKey(commit.Manifest.Digest, position), record); err != nil {
+ return err
+ }
+ }
+ return putJSON(ctx, writer, CollectionNames, commit.Name, nameRecord{ManifestDigest: commit.Manifest.Digest.String()})
+ })
+ return errdefs.Context(err, "commit image import", commit.Name, "metadata", "retry the import", false)
+}
+
+// Remove deletes one exact alias, or all aliases for a digest reference.
+// expected protects against a reference rebound while the caller waited for locks.
+// The final alias removal drops manifest rows and returns unreferenced source layers;
+// the caller, holding the corresponding artifact locks, performs file cleanup.
+func (c *Store) Remove(ctx context.Context, reference string, expected types.Digest) (images.Removal, error) {
+ var result images.Removal
+ // The transaction is the reachability boundary for shared artifacts:
+ //
+ // remove requested names -> names remain? --yes--> retain image and layers
+ // |
+ // no
+ // v
+ // remove image/layer rows -> find unreferenced digests
+ err := c.store.Update(ctx, func(writer metadata.Writer) error {
+ result = images.Removal{}
+ image, err := resolveRecord(ctx, writer, reference)
+ if err != nil {
+ return err
+ }
+ if image.ManifestDigest != expected {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeNameTaken, errors.New("image binding changed while waiting for locks; retry"))
+ }
+ digest := image.ManifestDigest.String()
+ // Exact names take precedence over digest prefixes, including hex-looking names.
+ _, isName, err := writer.Get(ctx, CollectionNames, reference)
+ if err != nil {
+ return err
+ }
+ removeAllNames := !isName
+ for _, name := range image.Names {
+ if removeAllNames || name == reference {
+ if err := writer.Delete(ctx, CollectionNames, name); err != nil {
+ return err
+ }
+ result.Names = append(result.Names, name)
+ }
+ }
+ remaining := 0
+ if err := writer.Scan(ctx, CollectionNames, func(_ string, raw []byte) error {
+ var record nameRecord
+ if err := json.Unmarshal(raw, &record); err != nil {
+ return corruptRecord("name", err)
+ }
+ if record.ManifestDigest == digest {
+ remaining++
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+ if remaining > 0 {
+ return nil
+ }
+ if c.usage != nil {
+ used, err := c.usage.InUse(ctx, writer, image.ManifestDigest)
+ if err != nil {
+ return err
+ }
+ if used {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeReferenced, fmt.Errorf("image %s is used by a sandbox", image.ManifestDigest))
+ }
+ }
+ if err := writer.Delete(ctx, CollectionImages, digest); err != nil {
+ return err
+ }
+ for position, layer := range image.Layers {
+ if err := writer.Delete(ctx, CollectionLayers, layerKey(image.ManifestDigest, position)); err != nil {
+ return err
+ }
+ used, err := layerReferenced(ctx, writer, layer.SourceDigest)
+ if err != nil {
+ return err
+ }
+ if !used {
+ result.Layers = append(result.Layers, layer.SourceDigest)
+ }
+ }
+ return nil
+ })
+ return result, errdefs.Context(err, "remove image", reference, "metadata", "inspect image references", false)
+}
+
+// resolveRecord keeps alias precedence consistent between lookup and removal,
+// so even a hex-looking exact alias never accidentally selects a different image.
+func resolveRecord(ctx context.Context, reader metadata.Reader, reference string) (types.Image, error) {
+ digestID := ""
+ if raw, ok, err := reader.Get(ctx, CollectionNames, reference); err != nil {
+ return types.Image{}, err
+ } else if ok {
+ var record nameRecord
+ if err := json.Unmarshal(raw, &record); err != nil {
+ return types.Image{}, corruptRecord("name", err)
+ }
+ digestID = record.ManifestDigest
+ } else {
+ prefix := strings.TrimPrefix(reference, "sha256:")
+ if len(prefix) < minimumDigestPrefix {
+ return types.Image{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("image %q not found", reference))
+ }
+ if err := reader.Scan(ctx, CollectionImages, func(id string, _ []byte) error {
+ if strings.HasPrefix(strings.TrimPrefix(id, "sha256:"), prefix) {
+ if digestID != "" && digestID != id {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("digest prefix %q is ambiguous", reference))
+ }
+ digestID = id
+ }
+ return nil
+ }); err != nil {
+ return types.Image{}, err
+ }
+ }
+ if digestID == "" {
+ return types.Image{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("image %q not found", reference))
+ }
+ return loadImage(ctx, reader, digestID)
+}
+
+// loadImage reconstructs and validates records within the caller's transaction.
+// Identity, contiguous positions and derived boot/size facts must agree before
+// persisted data can be exposed as a domain image.
+func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (types.Image, error) {
+ raw, ok, err := reader.Get(ctx, CollectionImages, digestID)
+ if err != nil {
+ return types.Image{}, err
+ }
+ if !ok {
+ return types.Image{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("image record %s is missing", digestID))
+ }
+ var record imageRecord
+ if err := json.Unmarshal(raw, &record); err != nil {
+ return types.Image{}, corruptRecord("image", err)
+ }
+ manifest, err := types.ParseDigest(record.ManifestDigest)
+ if err != nil {
+ return types.Image{}, corruptRecord("image digest", err)
+ }
+ kernel, err := types.ParseDigest(record.KernelLayer)
+ if err != nil {
+ return types.Image{}, corruptRecord("kernel digest", err)
+ }
+ initrd, err := types.ParseDigest(record.InitrdLayer)
+ if err != nil {
+ return types.Image{}, corruptRecord("initrd digest", err)
+ }
+ image := types.Image{
+ ManifestDigest: manifest, Platform: types.Platform{OS: record.OS, Architecture: record.Architecture},
+ Boot: types.Boot{Profile: types.BootProfile(record.BootProfile), KernelLayer: kernel, InitrdLayer: initrd, KernelFile: record.KernelFile, InitrdFile: record.InitrdFile}, Size: record.Size, CreatedAt: record.CreatedAt,
+ }
+ if err := reader.Scan(ctx, CollectionNames, func(name string, raw []byte) error {
+ var item nameRecord
+ if err := json.Unmarshal(raw, &item); err != nil {
+ return corruptRecord("name", err)
+ }
+ if item.ManifestDigest == digestID {
+ image.Names = append(image.Names, name)
+ }
+ return nil
+ }); err != nil {
+ return types.Image{}, err
+ }
+ var layerRecords []layerRecord
+ if err := reader.Scan(ctx, CollectionLayers, func(key string, raw []byte) error {
+ var item layerRecord
+ if err := json.Unmarshal(raw, &item); err != nil {
+ return corruptRecord("layer", err)
+ }
+ if item.ManifestDigest != digestID {
+ return nil
+ }
+ if item.Position < 0 || key != layerKey(manifest, item.Position) {
+ return corruptRecord("layer position", errors.New("invalid layer key or position"))
+ }
+ layerRecords = append(layerRecords, item)
+ return nil
+ }); err != nil {
+ return types.Image{}, err
+ }
+ slices.SortFunc(layerRecords, func(a, b layerRecord) int { return a.Position - b.Position })
+ for pos, item := range layerRecords {
+ if item.Position != pos {
+ return types.Image{}, corruptRecord("layer order", errors.New("noncontiguous layer positions"))
+ }
+ layer, err := decodeLayer(item)
+ if err != nil {
+ return types.Image{}, err
+ }
+ image.Layers = append(image.Layers, layer)
+ }
+ if manifest.String() != digestID {
+ return types.Image{}, corruptRecord("image identity", errors.New("record key differs from manifest digest"))
+ }
+ descriptors := make([]types.Descriptor, len(image.Layers))
+ for pos, layer := range image.Layers {
+ descriptors[pos] = types.Descriptor{Digest: layer.SourceDigest}
+ }
+ if err := (images.ImportCommit{Name: "stored", Manifest: types.Manifest{Digest: manifest, Platform: image.Platform, BootProfile: image.Boot.Profile, Layers: descriptors}, Layers: image.Layers, Boot: image.Boot, Size: image.Size, Created: image.CreatedAt}).Validate(); err != nil {
+ return types.Image{}, corruptRecord("image facts", err)
+ }
+ slices.Sort(image.Names)
+ return image, nil
+}
+
+// decodeLayer validates serialized artifact identities and boot overlay facts.
+// It verifies metadata shape only; images.Verify checks the actual files.
+func decodeLayer(record layerRecord) (types.Layer, error) {
+ source, err := types.ParseDigest(record.SourceDigest)
+ if err != nil {
+ return types.Layer{}, corruptRecord("source layer digest", err)
+ }
+ erofs, err := types.ParseDigest(record.EROFSDigest)
+ if err != nil {
+ return types.Layer{}, corruptRecord("erofs digest", err)
+ }
+ layer := types.Layer{SourceDigest: source, EROFSDigest: erofs, Size: record.Size, Whiteouts: record.Whiteouts, BootOpaque: record.BootOpaque}
+ for _, file := range record.BootFiles {
+ digest, err := types.ParseDigest(file.Digest)
+ if err != nil || !images.IsBootName(file.Name) || file.Size <= 0 {
+ return types.Layer{}, corruptRecord("boot file", errors.New("invalid name, digest or size"))
+ }
+ layer.BootFiles = append(layer.BootFiles, types.BootFile{Name: file.Name, Digest: digest, Size: file.Size})
+ }
+ if layer.SourceDigest.IsZero() || layer.EROFSDigest.IsZero() || layer.Size <= 0 {
+ return types.Layer{}, corruptRecord("layer", errors.New("invalid digest or size"))
+ }
+ for _, name := range layer.Whiteouts {
+ if !images.IsBootName(name) {
+ return types.Layer{}, corruptRecord("whiteout", errors.New("invalid boot whiteout"))
+ }
+ }
+ return layer, nil
+}
+
+func putJSON(ctx context.Context, writer metadata.Writer, collection metadata.Collection, id string, value any) error {
+ raw, err := json.Marshal(value)
+ if err != nil {
+ return fmt.Errorf("encode %s record %q: %w", collection, id, err)
+ }
+ return writer.Put(ctx, collection, id, raw)
+}
+
+func corruptRecord(kind string, cause error) error {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("decode %s metadata: %w", kind, cause))
+}
+
+// layerKey preserves distinct repeated source layers by using manifest position.
+func layerKey(manifest types.Digest, position int) string {
+ return fmt.Sprintf("%s/%08d", manifest.String(), position)
+}
+
+// layerReferenced checks remaining occurrences in the current write transaction
+// before authorizing filesystem reclamation of a shared source digest.
+func layerReferenced(ctx context.Context, reader metadata.Reader, digest types.Digest) (bool, error) {
+ referenced := false
+ err := reader.Scan(ctx, CollectionLayers, func(_ string, raw []byte) error {
+ var record layerRecord
+ if err := json.Unmarshal(raw, &record); err != nil {
+ return corruptRecord("layer", err)
+ }
+ if record.SourceDigest == digest.String() {
+ referenced = true
+ }
+ return nil
+ })
+ return referenced, err
+}
diff --git a/images/erofs/convert.go b/images/erofs/convert.go
new file mode 100644
index 0000000..4b58a9a
--- /dev/null
+++ b/images/erofs/convert.go
@@ -0,0 +1,314 @@
+// Package erofs converts verified layer tar streams into deterministic EROFS
+// artifacts and extracts regular boot candidates. It records overlay deletions
+// for images.SelectBoot instead of choosing boot files within an individual layer.
+package erofs
+
+import (
+ "archive/tar"
+ "bufio"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/types"
+)
+
+const (
+ // erofsBlockSize fixes compression cluster size for reproducible conversion.
+ erofsBlockSize = 4096
+)
+
+// Converter implements images.Converter using mkfs.erofs with fixed output options.
+// Its immutable configuration permits concurrent conversion into distinct work directories.
+type Converter struct {
+ // binary is the configured mkfs.erofs executable name or absolute path.
+ binary string
+ // architecture selects whether an extracted arm64 gzip kernel is decompressed.
+ architecture string
+ // limits bounds extracted boot files; source adapters bound the layer streams.
+ limits images.Limits
+}
+
+var _ images.Converter = (*Converter)(nil)
+
+// Options contains operator-controlled converter dependencies and bounds.
+type Options struct {
+ // Binary is the mkfs.erofs executable name or absolute path.
+ Binary string
+ // Limits bounds source streams and extracted boot files.
+ Limits images.Limits
+}
+
+// New validates the target architecture and limits and requires mkfs.erofs >= 1.8.
+// The target architecture can differ from the host running the conversion.
+func New(ctx context.Context, architecture string, options Options) (*Converter, error) {
+ if options.Binary == "" || !options.Limits.Valid() || (architecture != "amd64" && architecture != "arm64") {
+ return nil, invalidLayer("invalid converter architecture or size limits")
+ }
+ output, err := exec.CommandContext(ctx, options.Binary, "--version").CombinedOutput() //nolint:gosec // the operator supplies a fixed executable; no shell is involved
+ if err != nil {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeHostIncompatible, fmt.Errorf("probe mkfs.erofs: %w (%s)", errors.Join(err, ctx.Err()), bytes.TrimSpace(output)))
+ }
+ if err := requireEROFSVersion(string(output)); err != nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, err)
+ }
+ return &Converter{binary: options.Binary, architecture: architecture, limits: options.Limits}, nil
+}
+
+// Convert streams a decompressed tar to mkfs.erofs while extracting boot files
+// into workDir, which must already exist and belong to the caller. The caller
+// owns staging cleanup and source closure. Fixed timestamps, compression options
+// and a source-derived UUID make repeated conversion reproducible.
+//
+// verified tar -> TeeReader -> boot scan -> staged kernel/initrd
+// |
+// v
+// mkfs.erofs stdin -> staged EROFS -> hash and size
+//
+// Draining past tar EOF delivers the full stream to the child process and allows
+// source verification to finish before the generated artifact is accepted.
+func (c *Converter) Convert(ctx context.Context, descriptor types.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) {
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ outputPath := filepath.Join(workDir, descriptor.Digest.Hex()+".erofs")
+ command := exec.CommandContext( //nolint:gosec // binary is fixed and every argument is derived from validated managed paths and digests
+ ctx,
+ c.binary,
+ "--tar=f",
+ "-zlz4hc",
+ fmt.Sprintf("-C%d", erofsBlockSize),
+ "-T0", // Stable filesystem timestamps are part of the converted artifact identity.
+ "-U", deterministicUUID(descriptor.Digest),
+ outputPath,
+ )
+ stdin, err := command.StdinPipe()
+ if err != nil {
+ return images.ConvertedLayer{}, fmt.Errorf("open mkfs.erofs stdin: %w", err)
+ }
+ var commandOutput bytes.Buffer
+ command.Stdout = &commandOutput
+ command.Stderr = &commandOutput
+ if err := command.Start(); err != nil {
+ closeErr := stdin.Close()
+ return images.ConvertedLayer{}, errors.Join(fmt.Errorf("start mkfs.erofs: %w", err), closeErr)
+ }
+ stream := io.TeeReader(source, stdin)
+ bootFiles, whiteouts, opaque, scanErr := scanBoot(stream, workDir, c.architecture, c.limits.BootSize)
+ if scanErr == nil {
+ if _, err := io.Copy(io.Discard, stream); err != nil {
+ scanErr = fmt.Errorf("drain layer tar: %w", err)
+ }
+ }
+ if scanErr != nil {
+ cancel()
+ }
+ closeErr := stdin.Close()
+ waitErr := command.Wait()
+ if waitErr != nil {
+ waitErr = fmt.Errorf("mkfs.erofs: %w (%s)", waitErr, strings.TrimSpace(commandOutput.String()))
+ }
+ if err := errors.Join(scanErr, closeErr, waitErr, ctx.Err()); err != nil {
+ return images.ConvertedLayer{}, conversionError(err)
+ }
+ digest, size, err := digestPath(ctx, outputPath)
+ if err != nil {
+ return images.ConvertedLayer{}, err
+ }
+ return images.ConvertedLayer{
+ SourceDigest: descriptor.Digest, EROFSPath: outputPath, EROFSDigest: digest,
+ Size: size, BootFiles: bootFiles, Whiteouts: whiteouts, BootOpaque: opaque,
+ }, nil
+}
+
+// scanBoot extracts only regular boot candidates and records lower-layer whiteouts.
+// It never materializes arbitrary tar paths or follows archived links. Entries
+// replacing /boot or a candidate with a non-regular node hide earlier candidates.
+func scanBoot(source io.Reader, workDir, architecture string, limit int64) ([]images.StagedBootFile, []string, bool, error) {
+ reader := tar.NewReader(source)
+ var files []images.StagedBootFile
+ var whiteouts []string
+ opaque := false
+ for {
+ header, err := reader.Next()
+ if errors.Is(err, io.EOF) {
+ return files, whiteouts, opaque, nil
+ }
+ if err != nil {
+ return nil, nil, false, invalidLayer("read layer tar: %v", err)
+ }
+ clean := filepath.ToSlash(filepath.Clean(header.Name))
+ if filepath.IsAbs(header.Name) || clean == ".." || strings.HasPrefix(clean, "../") {
+ return nil, nil, false, invalidLayer("unsafe layer path %q", header.Name)
+ }
+ if clean == ".wh.boot" || (clean == "boot" && header.Typeflag != tar.TypeDir) {
+ files = nil
+ opaque = true
+ continue
+ }
+ if filepath.Dir(clean) != "boot" {
+ continue
+ }
+ base := filepath.Base(clean)
+ if base == ".wh..wh..opq" {
+ opaque = true
+ continue
+ }
+ if name, ok := strings.CutPrefix(base, ".wh."); ok {
+ if images.IsBootName(name) {
+ whiteouts = append(whiteouts, name)
+ }
+ continue
+ }
+ if !images.IsBootName(base) {
+ continue
+ }
+ if header.Typeflag != tar.TypeReg {
+ whiteouts = append(whiteouts, base)
+ // A later symlink or directory replaces a regular file from this layer too.
+ files = slices.DeleteFunc(files, func(file images.StagedBootFile) bool { return file.Name == base })
+ continue
+ }
+ destination := filepath.Join(workDir, base)
+ if err := writeBootFile(reader, destination, strings.HasPrefix(base, "vmlinuz") && architecture == "arm64", limit); err != nil {
+ return nil, nil, false, err
+ }
+ files = slices.DeleteFunc(files, func(file images.StagedBootFile) bool { return file.Name == base })
+ files = append(files, images.StagedBootFile{Name: base, Path: destination})
+ }
+}
+
+// writeBootFile bounds the final extracted size, including gzip expansion when
+// arm64 kernel decompression is requested. Other boot files preserve source bytes.
+func writeBootFile(source io.Reader, destination string, decompressKernel bool, limit int64) error {
+ output, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) //nolint:gosec // destination is a managed staging path
+ if err != nil {
+ return fmt.Errorf("create boot artifact: %w", err)
+ }
+ input := source
+ var gzipReader *gzip.Reader
+ if decompressKernel {
+ buffered := bufio.NewReader(source)
+ input = buffered
+ magic, peekErr := buffered.Peek(2)
+ if peekErr != nil && !errors.Is(peekErr, io.EOF) {
+ return errors.Join(peekErr, output.Close())
+ }
+ if len(magic) == 2 && magic[0] == 0x1f && magic[1] == 0x8b {
+ gzipReader, err = gzip.NewReader(buffered)
+ if err != nil {
+ return errors.Join(fmt.Errorf("open compressed arm64 kernel: %w", err), output.Close())
+ }
+ input = gzipReader
+ }
+ }
+ written, copyErr := io.Copy(output, io.LimitReader(input, limit+1))
+ var gzipErr error
+ if gzipReader != nil {
+ gzipErr = gzipReader.Close()
+ }
+ closeErr := output.Close()
+ if err := errors.Join(copyErr, gzipErr, closeErr); err != nil {
+ return fmt.Errorf("write boot artifact: %w", err)
+ }
+ if written == 0 || written > limit {
+ return invalidLayer("boot artifact size %d is outside limit %d", written, limit)
+ }
+ return nil
+}
+
+// requireEROFSVersion accepts a major/minor version with tar-stream support.
+// Version output may include the program name or a patch/suffix component.
+func requireEROFSVersion(output string) error {
+ fields := strings.FieldsFunc(output, func(char rune) bool {
+ return (char < '0' || char > '9') && char != '.'
+ })
+ for _, field := range fields {
+ parts := strings.Split(field, ".")
+ if len(parts) < 2 {
+ continue
+ }
+ major, majorErr := strconv.Atoi(parts[0])
+ minor, minorErr := strconv.Atoi(parts[1])
+ if majorErr != nil || minorErr != nil {
+ continue
+ }
+ if major > 1 || (major == 1 && minor >= 8) {
+ return nil
+ }
+ return fmt.Errorf("mkfs.erofs %d.%d is older than required 1.8", major, minor)
+ }
+ return fmt.Errorf("cannot parse mkfs.erofs version from %q", strings.TrimSpace(output))
+}
+
+// deterministicUUID derives stable UUID-shaped bytes from the source identity
+// to avoid mkfs.erofs generating a different filesystem identity on each run.
+func deterministicUUID(digest types.Digest) string {
+ sum := sha256.Sum256([]byte(digest.String()))
+ sum[6] = (sum[6] & 0x0f) | 0x50
+ sum[8] = (sum[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
+ sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
+}
+
+// digestPath hashes the generated staged filesystem before it is published.
+func digestPath(ctx context.Context, path string) (types.Digest, int64, error) {
+ file, err := os.Open(path) //nolint:gosec // path is a managed staging path
+ if err != nil {
+ return types.Digest{}, 0, fmt.Errorf("open generated EROFS: %w", err)
+ }
+ hash := sha256.New()
+ size, copyErr := io.Copy(hash, contextReader{ctx: ctx, reader: file})
+ closeErr := file.Close()
+ if err := errors.Join(copyErr, closeErr); err != nil {
+ return types.Digest{}, 0, fmt.Errorf("hash generated EROFS: %w", err)
+ }
+ digest, err := types.ParseDigest(fmt.Sprintf("sha256:%x", hash.Sum(nil)))
+ return digest, size, err
+}
+
+func invalidLayer(format string, args ...any) error {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf(format, args...))
+}
+
+// conversionError preserves cancellation and classified errors while distinguishing
+// compressed-data corruption from unavailable conversion artifacts.
+func conversionError(err error) error {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return err
+ }
+ if errors.Is(err, gzip.ErrChecksum) || errors.Is(err, gzip.ErrHeader) {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeDigestMismatch, err)
+ }
+ if _, ok := errdefs.CodeOf(err); ok {
+ return err
+ }
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err)
+}
+
+// contextReader observes cancellation between reads of a generated local artifact.
+type contextReader struct {
+ // ctx stops further reads after cancellation.
+ ctx context.Context
+ // reader supplies the artifact bytes without taking ownership of its lifetime.
+ reader io.Reader
+}
+
+// Read forwards data only while the context remains active.
+func (r contextReader) Read(p []byte) (int, error) {
+ if err := r.ctx.Err(); err != nil {
+ return 0, err
+ }
+ return r.reader.Read(p)
+}
diff --git a/images/erofs/convert_test.go b/images/erofs/convert_test.go
new file mode 100644
index 0000000..6f38090
--- /dev/null
+++ b/images/erofs/convert_test.go
@@ -0,0 +1,103 @@
+package erofs
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/kumabox/kumabox/images"
+)
+
+func TestNewUsesConfiguredBinaryAndLimits(t *testing.T) {
+ binary := filepath.Join(t.TempDir(), "custom-erofs")
+ if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf 'mkfs.erofs 1.8.10\\n'\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ limits := images.DefaultLimits()
+ converter, err := New(t.Context(), "amd64", Options{Binary: binary, Limits: limits})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if converter.binary != binary || converter.limits != limits {
+ t.Fatalf("converter options = binary %q, limits %+v", converter.binary, converter.limits)
+ }
+}
+
+func bootTar(t *testing.T, headers []*tar.Header) []byte {
+ t.Helper()
+ var buffer bytes.Buffer
+ writer := tar.NewWriter(&buffer)
+ for _, header := range headers {
+ if err := writer.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+ if header.Size > 0 {
+ if _, err := writer.Write(bytes.Repeat([]byte("x"), int(header.Size))); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return buffer.Bytes()
+}
+
+func TestScanBootRespectsWhiteoutsAndRegularFiles(t *testing.T) {
+ raw := bootTar(t, []*tar.Header{
+ {Name: "boot/vmlinuz-1", Typeflag: tar.TypeReg, Size: 1},
+ {Name: "boot/vmlinuz-2", Typeflag: tar.TypeReg, Size: 1},
+ {Name: "boot/vmlinuz-2", Typeflag: tar.TypeSymlink, Linkname: "vmlinuz-1"},
+ {Name: "boot/vmlinuz.old", Typeflag: tar.TypeReg, Size: 1},
+ {Name: "boot/.wh.initrd.img", Typeflag: tar.TypeReg},
+ {Name: "boot/.wh..wh..opq", Typeflag: tar.TypeReg},
+ })
+ files, whiteouts, opaque, err := scanBoot(bytes.NewReader(raw), t.TempDir(), "amd64", 1024)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(files) != 1 || files[0].Name != "vmlinuz-1" || len(whiteouts) != 2 || !opaque {
+ t.Fatalf("boot scan = %v, %v, %v", files, whiteouts, opaque)
+ }
+ raw = bootTar(t, []*tar.Header{{Name: "../boot/vmlinuz", Typeflag: tar.TypeReg, Size: 1}})
+ if _, _, _, err := scanBoot(bytes.NewReader(raw), t.TempDir(), "amd64", 1024); err == nil {
+ t.Fatal("accepted traversal")
+ }
+}
+
+func TestWriteBootFileBoundsARM64Decompression(t *testing.T) {
+ var compressed bytes.Buffer
+ writer := gzip.NewWriter(&compressed)
+ if _, err := writer.Write(bytes.Repeat([]byte("k"), 32)); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "kernel")
+ if err := writeBootFile(bytes.NewReader(compressed.Bytes()), path, true, 32); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil || len(raw) != 32 {
+ t.Fatalf("decompressed size = %d, %v", len(raw), err)
+ }
+ if err := writeBootFile(bytes.NewReader(compressed.Bytes()), path, true, 31); err == nil {
+ t.Fatal("accepted oversized kernel")
+ }
+ if err := writeBootFile(bytes.NewReader(nil), path, false, 32); err == nil {
+ t.Fatal("accepted empty kernel")
+ }
+}
+
+func TestRequireEROFSVersion(t *testing.T) {
+ if err := requireEROFSVersion("mkfs.erofs 1.8.10"); err != nil {
+ t.Fatalf("accepted version: %v", err)
+ }
+ if err := requireEROFSVersion("mkfs.erofs 1.7"); err == nil {
+ t.Fatal("accepted unsafe version")
+ }
+}
diff --git a/images/guard_test.go b/images/guard_test.go
new file mode 100644
index 0000000..112a91c
--- /dev/null
+++ b/images/guard_test.go
@@ -0,0 +1,58 @@
+package images
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+type changingResolver struct {
+ first types.Image
+ second types.Image
+ calls int
+}
+
+func (r *changingResolver) Resolve(context.Context, string) (types.Image, error) {
+ r.calls++
+ if r.calls == 1 {
+ return r.first, nil
+ }
+ return r.second, nil
+}
+
+func TestGuardRejectsImageBindingChangedWhileWaitingForLocks(t *testing.T) {
+ first, err := types.ParseDigest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := types.ParseDigest("sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
+ if err != nil {
+ t.Fatal(err)
+ }
+ base := t.TempDir()
+ paths, err := NewPaths(storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := paths.Ensure(); err != nil {
+ t.Fatal(err)
+ }
+ resolver := &changingResolver{first: types.Image{ManifestDigest: first}, second: types.Image{ManifestDigest: second}}
+ used := false
+ _, err = NewGuard(paths, resolver).WithAvailable(t.Context(), "demo", func(types.Image) error {
+ used = true
+ return nil
+ })
+ if code, _ := errdefs.CodeOf(err); code != errdefs.CodeStateConflict {
+ t.Fatalf("WithAvailable error = %v", err)
+ }
+ if used {
+ t.Fatal("consumer ran after image binding changed")
+ }
+}
diff --git a/images/import.go b/images/import.go
new file mode 100644
index 0000000..2dcafb4
--- /dev/null
+++ b/images/import.go
@@ -0,0 +1,419 @@
+// Package images owns image import, artifact verification, boot selection, and
+// removal workflows. Shared image data contracts live in types; source,
+// converter, and catalog adapters implement the interfaces consumed here.
+package images
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/sync/errgroup"
+
+ "github.com/kumabox/kumabox/errdefs"
+ filelock "github.com/kumabox/kumabox/lock/flock"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+// Source resolves an image and supplies verified, decompressed layer tar streams.
+// Implementations retain ownership of archive staging or registry state; callers
+// must keep the source alive until Import returns.
+type Source interface {
+ // Resolve selects exactly one manifest matching the requested platform.
+ Resolve(context.Context, types.Platform) (types.Manifest, error)
+ // OpenLayer returns a stream whose final read or Close may report verification
+ // failures. After successful conversion the importer drains the remaining bytes;
+ // every successfully opened stream is closed, including on conversion failure.
+ OpenLayer(context.Context, types.Descriptor) (io.ReadCloser, error)
+}
+
+// ImportCatalog provides the metadata operations needed by an import.
+type ImportCatalog interface {
+ // Resolve reads an image by its local alias or manifest identity.
+ Resolve(context.Context, string) (types.Image, error)
+ // FindLayers returns committed artifact mappings for the requested source blobs.
+ FindLayers(context.Context, []types.Digest) (map[types.Digest]types.Layer, error)
+ // CommitImport atomically registers the image, alias and ordered layer mappings.
+ CommitImport(context.Context, ImportCommit) error
+}
+
+// Converter builds layer artifacts in an importer-owned staging directory.
+// Convert may run concurrently for independent layers and must honor cancellation.
+type Converter interface {
+ // Convert consumes a decompressed tar stream and returns staged artifacts.
+ // Artifact paths must remain within the supplied work directory.
+ Convert(context.Context, types.Descriptor, io.Reader, string) (ConvertedLayer, error)
+}
+
+// ConvertedLayer contains files awaiting validation and publication by Importer.
+type ConvertedLayer struct {
+ // SourceDigest must match the descriptor that was converted.
+ SourceDigest types.Digest
+ // EROFSPath is the staged EROFS file, or a verified managed file on cache reuse.
+ EROFSPath string
+ // EROFSDigest is the expected hash of EROFSPath.
+ EROFSDigest types.Digest
+ // Size is the expected EROFS size in bytes.
+ Size int64
+ // BootFiles contains staged regular boot candidates.
+ BootFiles []StagedBootFile
+ // Whiteouts names lower-layer boot candidates hidden by this layer.
+ Whiteouts []string
+ // BootOpaque hides the entire inherited boot candidate set.
+ BootOpaque bool
+}
+
+// StagedBootFile locates a boot candidate before publication and metadata hashing.
+type StagedBootFile struct {
+ // Name is the final accepted /boot basename.
+ Name string
+ // Path is a file under import staging, or the managed path on cache reuse.
+ Path string
+}
+
+// Reporter observes completed import work. The importer serializes callbacks;
+// layer callbacks follow completion order, not necessarily manifest order.
+// Reporting errors abort work or report a failure after metadata was committed.
+type Reporter interface {
+ // Layer receives the zero-based manifest position, total count and source digest.
+ Layer(int, int, types.Digest) error
+ // Committed runs after the atomic catalog commit and readback succeed.
+ Committed(types.Image) error
+}
+
+// DiscardReporter disables progress reporting without conditional workflow logic.
+type DiscardReporter struct{}
+
+// Layer accepts a layer completion without retaining it.
+func (DiscardReporter) Layer(int, int, types.Digest) error { return nil }
+
+// Committed accepts a successful catalog commit without retaining it.
+func (DiscardReporter) Committed(types.Image) error { return nil }
+
+// Limits bound compressed input and decompressed source and boot artifacts.
+// Adapters enforce the relevant bounds while reading; all values are byte counts.
+type Limits struct {
+ // LayerSize caps each original layer blob.
+ LayerSize int64
+ // UnpackedSize caps each decompressed layer tar stream.
+ UnpackedSize int64
+ // BootSize caps each extracted boot file after optional decompression.
+ BootSize int64
+ // ArchiveSize caps the total expanded file content of a local archive.
+ ArchiveSize int64
+}
+
+// DefaultLimits returns bounds for source blobs, unpacked tar and boot artifacts.
+func DefaultLimits() Limits {
+ return Limits{LayerSize: 8 << 30, UnpackedSize: 16 << 30, BootSize: 512 << 20, ArchiveSize: 32 << 30}
+}
+
+// Valid requires positive bounds with room for a one-byte overflow probe.
+func (l Limits) Valid() bool {
+ return l.LayerSize > 0 && l.UnpackedSize > 0 && l.BootSize > 0 && l.ArchiveSize > 0 && l.LayerSize < 1<<63-1 && l.UnpackedSize < 1<<63-1 && l.BootSize < 1<<63-1 && l.ArchiveSize < 1<<63-1
+}
+
+// Options controls resource bounds, conversion concurrency and import timestamps.
+type Options struct {
+ // Limits supplies adapter bounds; an all-zero value selects DefaultLimits.
+ Limits Limits
+ // Parallelism caps concurrent layer reuse checks and conversions; it must be positive.
+ Parallelism int
+ // Now supplies the local creation timestamp and must be non-nil.
+ Now func() time.Time
+}
+
+// DefaultOptions limits conversions to at most four workers and uses the wall clock.
+func DefaultOptions() Options {
+ return Options{Limits: DefaultLimits(), Parallelism: min(4, max(1, runtime.NumCPU())), Now: time.Now}
+}
+
+// Importer coordinates format-independent conversion, shared artifacts and metadata.
+// Sources and converters enforce stream limits; the importer validates identities
+// and artifacts before committing their catalog mappings.
+type Importer struct {
+ // paths defines shared artifacts, per-import staging and source digest locks.
+ paths Paths
+ // catalog owns the atomic registration and alias reachability boundary.
+ catalog ImportCatalog
+ // converter builds artifacts outside locks so slow source reads cannot block deletion.
+ converter Converter
+ // reporter receives serialized layer completions and the committed result.
+ reporter Reporter
+ // reportMu serializes callbacks from concurrent conversion workers.
+ reportMu sync.Mutex
+ // options fixes this importer's limits, worker count and creation clock.
+ options Options
+}
+
+// NewImporter validates workflow dependencies and options. A nil reporter discards
+// progress; zero Limits selects defaults, while parallelism and clock are required.
+func NewImporter(paths Paths, catalog ImportCatalog, converter Converter, reporter Reporter, options Options) (*Importer, error) {
+ if options.Limits == (Limits{}) {
+ options.Limits = DefaultLimits()
+ }
+ if !options.Limits.Valid() {
+ return nil, invalidImage("invalid import size limits")
+ }
+ if catalog == nil || converter == nil || options.Parallelism <= 0 || options.Now == nil {
+ return nil, errors.New("image catalog, converter, parallelism and clock are required")
+ }
+ if reporter == nil {
+ reporter = DiscardReporter{}
+ }
+ return &Importer{paths: paths, catalog: catalog, converter: converter, reporter: reporter, options: options}, nil
+}
+
+// Import registers name for a resolved image, reusing only verified committed layers.
+// Source resolution and conversion happen outside artifact locks. Publication and
+// the catalog transaction hold every source digest lock to coordinate with removal.
+// Errors after commit carry errdefs.Error.Committed so callers can distinguish a
+// persisted image from an import that must be retried.
+//
+// resolve -> reuse/convert in staging -> lock all source digests
+// |
+// v
+// recheck -> publish -> verify -> catalog commit
+// |
+// v
+// report -> unlock -> staging cleanup
+func (i *Importer) Import(ctx context.Context, name string, platform types.Platform, source Source) (result types.Image, returnErr error) {
+ if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\r\n\t") || source == nil || !platform.Valid() {
+ return types.Image{}, invalidImage("image name, supported platform and source are required")
+ }
+ if err := ctx.Err(); err != nil {
+ return types.Image{}, err
+ }
+ if err := i.paths.Ensure(); err != nil {
+ return types.Image{}, errdefs.Context(err, "import image", name, "prepare", "check managed directory permissions", false)
+ }
+ manifest, err := source.Resolve(ctx, platform)
+ if err != nil {
+ return types.Image{}, errdefs.Context(err, "import image", name, "resolve", "check the image source and platform", false)
+ }
+ if manifest.Digest.IsZero() || manifest.Platform != platform || len(manifest.Layers) == 0 {
+ return types.Image{}, invalidImage("invalid image manifest or platform")
+ }
+ digests := make([]types.Digest, len(manifest.Layers))
+ for position, descriptor := range manifest.Layers {
+ if descriptor.Digest.IsZero() || descriptor.Size < 0 || descriptor.Size > i.options.Limits.LayerSize {
+ return types.Image{}, invalidImage("invalid layer descriptor")
+ }
+ digests[position] = descriptor.Digest
+ }
+ known, err := i.catalog.FindLayers(ctx, digests)
+ if err != nil {
+ return types.Image{}, err
+ }
+ staging, err := i.paths.NewStaging("image-*")
+ if err != nil {
+ return types.Image{}, err
+ }
+ committed := false
+ defer func() {
+ if err := removeStaging(staging); err != nil {
+ returnErr = errors.Join(returnErr, errdefs.Context(err, "import image", name, "cleanup", "remove orphan staging", committed))
+ }
+ }()
+ converted := make([]ConvertedLayer, len(manifest.Layers))
+ group, groupCtx := errgroup.WithContext(ctx)
+ group.SetLimit(i.options.Parallelism)
+ for position, descriptor := range manifest.Layers {
+ group.Go(func() error {
+ if layer, ok := known[descriptor.Digest]; ok && verifyLayer(groupCtx, i.paths, layer) == nil {
+ converted[position] = cachedArtifact(i.paths, layer)
+ } else {
+ workDir := filepath.Join(staging, fmt.Sprintf("layer-%08d", position))
+ if err := os.Mkdir(workDir, 0o750); err != nil {
+ return err
+ }
+ artifact, err := i.convert(groupCtx, source, descriptor, workDir)
+ if err != nil {
+ return err
+ }
+ converted[position] = artifact
+ }
+ i.reportMu.Lock()
+ defer i.reportMu.Unlock()
+ return i.reporter.Layer(position, len(manifest.Layers), descriptor.Digest)
+ })
+ }
+ if err := group.Wait(); err != nil {
+ return types.Image{}, errdefs.Context(err, "import image", name, "convert", "fix source or converter and retry", false)
+ }
+ lockPaths := make([]string, len(digests))
+ for pos, digest := range digests {
+ lockPaths[pos] = i.paths.Lock(digest)
+ }
+ var locks filelock.Set
+ if err := locks.Lock(ctx, lockPaths...); err != nil {
+ return types.Image{}, err
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, errdefs.Context(locks.Unlock(context.WithoutCancel(ctx)), "import image", name, "unlock", "inspect runtime locks", committed))
+ }()
+ // Conversion is slow; metadata and files may have changed while we were staging.
+ current, err := i.catalog.FindLayers(ctx, digests)
+ if err != nil {
+ return types.Image{}, err
+ }
+ layers := make([]types.Layer, len(converted))
+ for pos, artifact := range converted {
+ if err := ctx.Err(); err != nil {
+ return types.Image{}, err
+ }
+ if layer, ok := current[artifact.SourceDigest]; ok && verifyLayer(ctx, i.paths, layer) == nil {
+ layers[pos] = layer
+ continue
+ }
+ // A cache hit may have been removed meanwhile. Retry without downloading under a lock.
+ if artifact.EROFSPath == i.paths.EROFS(artifact.SourceDigest) {
+ return types.Image{}, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("cached layer changed during import; retry"))
+ }
+ layer, err := i.publishLayer(ctx, artifact, staging)
+ if err != nil {
+ return types.Image{}, errdefs.Context(err, "import image", name, "publish", "retry the import", false)
+ }
+ if old, exists := current[layer.SourceDigest]; exists && !old.Equal(layer) {
+ return types.Image{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("rebuilt layer differs from committed metadata"))
+ }
+ current[layer.SourceDigest] = layer
+ layers[pos] = layer
+ }
+ boot, err := SelectBoot(layers)
+ if err != nil {
+ return types.Image{}, err
+ }
+ // The source declaration and selected artifacts form one boot contract. An
+ // empty profile remains empty for images imported before profiles existed.
+ boot.Profile = manifest.BootProfile
+ var total int64
+ for _, layer := range layers {
+ total += layer.Size
+ }
+ // Revalidate every final artifact while holding all digest locks, before the transaction.
+ for _, layer := range layers {
+ if err := verifyLayer(ctx, i.paths, layer); err != nil {
+ return types.Image{}, err
+ }
+ }
+ commit := ImportCommit{Name: name, Manifest: manifest, Layers: layers, Boot: boot, Size: total, Created: i.options.Now().UTC()}
+ if err := i.catalog.CommitImport(ctx, commit); err != nil {
+ return types.Image{}, errdefs.Context(err, "import image", name, "catalog commit", "retry; unregistered artifacts will be rebuilt", false)
+ }
+ committed = true
+ result, err = i.catalog.Resolve(ctx, name)
+ if err != nil {
+ return types.Image{}, errdefs.Context(err, "import image", name, "read committed image", "run image verify", true)
+ }
+ i.reportMu.Lock()
+ defer i.reportMu.Unlock()
+ return result, errdefs.Context(i.reporter.Committed(result), "import image", name, "report", "image is committed; run image inspect", true)
+}
+
+// convert drains the source after tar processing so trailing hash, compression
+// and size checks cannot be bypassed by a converter that stops at tar EOF.
+func (i *Importer) convert(ctx context.Context, source Source, descriptor types.Descriptor, workDir string) (ConvertedLayer, error) {
+ reader, err := source.OpenLayer(ctx, descriptor)
+ if err != nil {
+ return ConvertedLayer{}, err
+ }
+ artifact, convertErr := i.converter.Convert(ctx, descriptor, reader, workDir)
+ // A converter may stop at the end of tar before the underlying compressed stream ends.
+ if convertErr == nil {
+ _, convertErr = io.Copy(io.Discard, contextReader{ctx: ctx, reader: reader})
+ }
+ if err := errors.Join(convertErr, reader.Close()); err != nil {
+ return ConvertedLayer{}, fmt.Errorf("convert layer %s: %w", descriptor.Digest, err)
+ }
+ if artifact.SourceDigest != descriptor.Digest || artifact.EROFSDigest.IsZero() {
+ return ConvertedLayer{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeDigestMismatch, errors.New("converter returned an invalid artifact identity"))
+ }
+ return artifact, nil
+}
+
+// cachedArtifact adapts verified committed files to the staging result shape.
+// Their managed paths let publication detect a cache entry removed during staging.
+func cachedArtifact(paths Paths, layer types.Layer) ConvertedLayer {
+ artifact := ConvertedLayer{SourceDigest: layer.SourceDigest, EROFSPath: paths.EROFS(layer.SourceDigest), EROFSDigest: layer.EROFSDigest, Size: layer.Size, Whiteouts: layer.Whiteouts, BootOpaque: layer.BootOpaque}
+ for _, file := range layer.BootFiles {
+ artifact.BootFiles = append(artifact.BootFiles, StagedBootFile{Name: file.Name, Path: filepath.Join(paths.BootDir(layer.SourceDigest), file.Name)})
+ }
+ return artifact
+}
+
+// publishLayer validates staged hashes and committed mappings before replacing
+// shared files. The caller must hold the source digest lock throughout publication.
+func (i *Importer) publishLayer(ctx context.Context, artifact ConvertedLayer, staging string) (types.Layer, error) {
+ layer := types.Layer{SourceDigest: artifact.SourceDigest, EROFSDigest: artifact.EROFSDigest, Size: artifact.Size, Whiteouts: artifact.Whiteouts, BootOpaque: artifact.BootOpaque}
+ // Do not trust a file merely because it already exists: only committed metadata authorizes reuse.
+ actual, size, err := stagedDigest(ctx, staging, artifact.EROFSPath)
+ if err != nil {
+ return types.Layer{}, err
+ }
+ if actual != artifact.EROFSDigest || size != artifact.Size || size <= 0 {
+ return types.Layer{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("staged EROFS does not match converter result"))
+ }
+ for _, file := range artifact.BootFiles {
+ if !IsBootName(file.Name) {
+ return types.Layer{}, invalidImage("invalid boot file name")
+ }
+ digest, size, err := stagedDigest(ctx, staging, file.Path)
+ if err != nil {
+ return types.Layer{}, err
+ }
+ if size == 0 {
+ return types.Layer{}, invalidImage("empty boot file %s", file.Name)
+ }
+ layer.BootFiles = append(layer.BootFiles, types.BootFile{Name: file.Name, Digest: digest, Size: size})
+ }
+ // Check against every existing committed mapping BEFORE replacing shared files.
+ known, err := i.catalog.FindLayers(ctx, []types.Digest{layer.SourceDigest})
+ if err != nil {
+ return types.Layer{}, err
+ }
+ if old, exists := known[layer.SourceDigest]; exists && !old.Equal(layer) {
+ return types.Layer{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("rebuilt layer differs from committed metadata"))
+ }
+ if err := storage.Publish(artifact.EROFSPath, i.paths.EROFS(artifact.SourceDigest)); err != nil {
+ return types.Layer{}, err
+ }
+ for _, file := range artifact.BootFiles {
+ final, err := i.paths.BootFile(artifact.SourceDigest, file.Name)
+ if err != nil {
+ return types.Layer{}, err
+ }
+ if err := storage.Publish(file.Path, final); err != nil {
+ return types.Layer{}, err
+ }
+ }
+ return layer, nil
+}
+
+// stagedDigest rejects converter paths outside this import before hashing files.
+func stagedDigest(ctx context.Context, staging, path string) (types.Digest, int64, error) {
+ rel, err := filepath.Rel(staging, path)
+ if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return types.Digest{}, 0, invalidImage("converter artifact escapes staging")
+ }
+ return digestFileContext(ctx, path)
+}
+
+func invalidImage(format string, args ...any) error {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf(format, args...))
+}
+
+func removeStaging(path string) error {
+ if err := os.RemoveAll(path); err != nil {
+ return fmt.Errorf("remove staging %s: %w", path, err)
+ }
+ return nil
+}
diff --git a/images/import_test.go b/images/import_test.go
new file mode 100644
index 0000000..b3e8cdd
--- /dev/null
+++ b/images/import_test.go
@@ -0,0 +1,447 @@
+package images_test
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/images"
+ imagecatalog "github.com/kumabox/kumabox/images/catalog"
+ "github.com/kumabox/kumabox/metadata"
+ metadatasqlite "github.com/kumabox/kumabox/metadata/sqlite"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestImporterReusesLayerAndRemovesAliases(t *testing.T) {
+ base := t.TempDir()
+ roots := storage.Roots{
+ Data: filepath.Join(base, "data"),
+ Run: filepath.Join(base, "run"),
+ Log: filepath.Join(base, "log"),
+ }
+ paths, err := images.NewPaths(roots)
+ if err != nil {
+ t.Fatalf("NewPaths: %v", err)
+ }
+ if err := paths.Ensure(); err != nil {
+ t.Fatalf("Ensure: %v", err)
+ }
+ store, err := metadatasqlite.Open(t.Context(), paths.MetadataDB(), imagecatalog.Collections(), metadatasqlite.DefaultOptions())
+ if err != nil {
+ t.Fatalf("Open metadata: %v", err)
+ }
+ defer func() {
+ if err := store.Close(); err != nil {
+ t.Errorf("Close: %v", err)
+ }
+ }()
+ layerDigest := testDigest(t, "1")
+ manifestDigest := testDigest(t, "2")
+ source := fakeSource{manifest: types.Manifest{
+ Digest: manifestDigest, Platform: types.Platform{OS: "linux", Architecture: "amd64"},
+ Layers: []types.Descriptor{{Digest: layerDigest, Size: 3}},
+ }}
+ converter := &fakeConverter{}
+ catalog := imagecatalog.New(store)
+ importer, err := images.NewImporter(paths, catalog, converter, nil, images.Options{Parallelism: 1, Now: func() time.Time { return time.Unix(1, 0) }})
+ if err != nil {
+ t.Fatalf("NewImporter: %v", err)
+ }
+ if _, err := importer.Import(t.Context(), "first", source.manifest.Platform, source); err != nil {
+ t.Fatalf("first Import: %v", err)
+ }
+ if _, err := importer.Import(t.Context(), "second", source.manifest.Platform, source); err != nil {
+ t.Fatalf("second Import: %v", err)
+ }
+ if converter.Calls() != 1 {
+ t.Fatalf("converter calls = %d, want 1", converter.Calls())
+ }
+ image, err := images.Verify(t.Context(), paths, catalog, "second")
+ if err != nil {
+ t.Fatalf("Verify: %v", err)
+ }
+ if len(image.Names) != 2 {
+ t.Fatalf("names = %v", image.Names)
+ }
+ if _, err := images.Remove(t.Context(), paths, catalog, "first"); err != nil {
+ t.Fatalf("remove first alias: %v", err)
+ }
+ if _, err := images.Verify(t.Context(), paths, catalog, "second"); err != nil {
+ t.Fatal("shared layer removed with remaining alias")
+ }
+ if _, err := images.Remove(t.Context(), paths, catalog, "second"); err != nil {
+ t.Fatalf("remove final alias: %v", err)
+ }
+ if _, err := os.Stat(paths.EROFS(layerDigest)); !os.IsNotExist(err) {
+ t.Fatalf("layer still exists: %v", err)
+ }
+}
+
+type fakeSource struct {
+ manifest types.Manifest
+}
+
+func (f fakeSource) Resolve(context.Context, types.Platform) (types.Manifest, error) {
+ return f.manifest, nil
+}
+
+func (f fakeSource) OpenLayer(context.Context, types.Descriptor) (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewReader([]byte("tar"))), nil
+}
+
+type fakeConverter struct {
+ mu sync.Mutex
+ calls int
+}
+
+func (f *fakeConverter) Convert(ctx context.Context, descriptor types.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) {
+ if _, err := io.Copy(io.Discard, source); err != nil {
+ return images.ConvertedLayer{}, err
+ }
+ f.mu.Lock()
+ f.calls++
+ f.mu.Unlock()
+ erofs := filepath.Join(workDir, "layer.erofs")
+ kernel := filepath.Join(workDir, "vmlinuz")
+ initrd := filepath.Join(workDir, "initrd.img")
+ for path, data := range map[string][]byte{erofs: []byte("erofs"), kernel: []byte("kernel"), initrd: []byte("initrd")} {
+ if err := os.WriteFile(path, data, 0o640); err != nil {
+ return images.ConvertedLayer{}, err
+ }
+ }
+ product, err := types.ParseDigest(fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("erofs"))))
+ if err != nil {
+ return images.ConvertedLayer{}, err
+ }
+ return images.ConvertedLayer{
+ SourceDigest: descriptor.Digest, EROFSPath: erofs, EROFSDigest: product,
+ Size: 5, BootFiles: []images.StagedBootFile{{Name: "vmlinuz", Path: kernel}, {Name: "initrd.img", Path: initrd}},
+ }, nil
+}
+
+func (f *fakeConverter) Calls() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.calls
+}
+
+func testDigest(t *testing.T, digit string) types.Digest {
+ t.Helper()
+ digest, err := types.ParseDigest(fmt.Sprintf("sha256:%s", bytes.Repeat([]byte(digit), 64)))
+ if err != nil {
+ t.Fatalf("ParseDigest: %v", err)
+ }
+ return digest
+}
+
+func testImportState(t *testing.T, store metadata.Store) (images.Paths, *imagecatalog.Store) {
+ t.Helper()
+ base := t.TempDir()
+ paths, err := images.NewPaths(storage.Roots{Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log")})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := paths.Ensure(); err != nil {
+ t.Fatal(err)
+ }
+ if store == nil {
+ memory, err := metadata.NewMemory(imagecatalog.Collections())
+ if err != nil {
+ t.Fatal(err)
+ }
+ store = memory
+ }
+ t.Cleanup(func() {
+ if err := store.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ return paths, imagecatalog.New(store)
+}
+
+func testManifest(t *testing.T, manifestDigit string) types.Manifest {
+ t.Helper()
+ return types.Manifest{Digest: testDigest(t, manifestDigit), Platform: types.Platform{OS: "linux", Architecture: "amd64"}, Layers: []types.Descriptor{{Digest: testDigest(t, "1"), Size: 3}}}
+}
+
+func testImporter(t *testing.T, paths images.Paths, catalog images.Catalog, converter images.Converter) *images.Importer {
+ t.Helper()
+ importer, err := images.NewImporter(paths, catalog, converter, nil, images.Options{Parallelism: 2, Now: func() time.Time { return time.Unix(10, 0) }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return importer
+}
+
+func TestImporterRepairsCorruptionAndPreservesCreationTime(t *testing.T) {
+ paths, catalog := testImportState(t, nil)
+ converter := &fakeConverter{}
+ importer := testImporter(t, paths, catalog, converter)
+ manifest := testManifest(t, "2")
+ first, err := importer.Import(t.Context(), "aaaaaaaaaaaa", manifest.Platform, fakeSource{manifest: manifest})
+ if err != nil {
+ t.Fatal(err)
+ }
+ importer, err = images.NewImporter(paths, catalog, converter, nil, images.Options{Parallelism: 2, Now: func() time.Time { return time.Unix(20, 0) }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Same length corruption must be detected by content digest, not stat.
+ if err := os.WriteFile(paths.Kernel(manifest.Layers[0].Digest), []byte("broken"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := images.Verify(t.Context(), paths, catalog, "aaaaaaaaaaaa"); err == nil {
+ t.Fatal("verify accepted corrupt kernel")
+ }
+ second, err := importer.Import(t.Context(), "alias", manifest.Platform, fakeSource{manifest: manifest})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !second.CreatedAt.Equal(first.CreatedAt) || converter.Calls() != 2 {
+ t.Fatalf("repeat import = created %s, conversions %d", second.CreatedAt, converter.Calls())
+ }
+ if _, err := images.Verify(t.Context(), paths, catalog, "alias"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := images.Remove(t.Context(), paths, catalog, "aaaaaaaaaaaa"); err != nil {
+ t.Fatal(err)
+ }
+ remaining, err := catalog.Resolve(t.Context(), "alias")
+ if err != nil || len(remaining.Names) != 1 {
+ t.Fatalf("hex-looking name removed aliases: %v, %v", remaining.Names, err)
+ }
+}
+
+type gatedConverter struct {
+ fakeConverter
+ started chan struct{}
+ release chan struct{}
+}
+
+func (f *gatedConverter) Convert(ctx context.Context, descriptor types.Descriptor, reader io.Reader, workDir string) (images.ConvertedLayer, error) {
+ select {
+ case f.started <- struct{}{}:
+ case <-ctx.Done():
+ return images.ConvertedLayer{}, ctx.Err()
+ }
+ select {
+ case <-f.release:
+ case <-ctx.Done():
+ return images.ConvertedLayer{}, ctx.Err()
+ }
+ return f.fakeConverter.Convert(ctx, descriptor, reader, workDir)
+}
+
+func TestImporterConcurrentSharedLayerAndLastReferenceRemoval(t *testing.T) {
+ paths, catalog := testImportState(t, nil)
+ converter := &gatedConverter{started: make(chan struct{}, 2), release: make(chan struct{})}
+ importer := testImporter(t, paths, catalog, converter)
+ results := make(chan error, 2)
+ for _, digit := range []string{"2", "3"} {
+ manifest := testManifest(t, digit)
+ go func() {
+ _, err := importer.Import(t.Context(), digit, manifest.Platform, fakeSource{manifest: manifest})
+ results <- err
+ }()
+ }
+ for range 2 {
+ select {
+ case <-converter.started:
+ case <-time.After(5 * time.Second):
+ t.Fatal("conversion blocked on publication lock")
+ }
+ }
+ close(converter.release)
+ for range 2 {
+ if err := <-results; err != nil {
+ t.Fatal(err)
+ }
+ }
+ items, err := catalog.List(t.Context())
+ if err != nil || len(items) != 2 {
+ t.Fatalf("images = %d, %v", len(items), err)
+ }
+ if _, err := images.Remove(t.Context(), paths, catalog, "2"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := images.Verify(t.Context(), paths, catalog, "3"); err != nil {
+ t.Fatalf("shared layer deleted: %v", err)
+ }
+ if _, err := images.Remove(t.Context(), paths, catalog, "3"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(paths.EROFS(testDigest(t, "1"))); !os.IsNotExist(err) {
+ t.Fatal("unreferenced layer retained")
+ }
+ entries, err := os.ReadDir(paths.StagingDir())
+ if err != nil || len(entries) != 0 {
+ t.Fatalf("staging = %v, %v", entries, err)
+ }
+}
+
+type failingStore struct {
+ metadata.Store
+ fail bool
+ failure error
+}
+
+func (s *failingStore) Update(ctx context.Context, fn func(metadata.Writer) error) error {
+ if s.fail {
+ return s.failure
+ }
+ return s.Store.Update(ctx, fn)
+}
+
+func TestImporterCommitFailureLeavesInvisibleOrphansAndRetryRebuilds(t *testing.T) {
+ memory, err := metadata.NewMemory(imagecatalog.Collections())
+ if err != nil {
+ t.Fatal(err)
+ }
+ failure := errors.New("injected commit failure")
+ store := &failingStore{Store: memory, fail: true, failure: failure}
+ paths, catalog := testImportState(t, store)
+ converter := &fakeConverter{}
+ importer := testImporter(t, paths, catalog, converter)
+ manifest := testManifest(t, "2")
+ if _, err := importer.Import(t.Context(), "tiny", manifest.Platform, fakeSource{manifest: manifest}); !errors.Is(err, failure) {
+ t.Fatalf("commit error = %v", err)
+ }
+ items, err := catalog.List(t.Context())
+ if err != nil || len(items) != 0 {
+ t.Fatalf("failed import became visible: %v, %v", items, err)
+ }
+ // A final orphan is not authorized by metadata and must be replaced, including boot files.
+ if err := os.WriteFile(paths.Kernel(testDigest(t, "1")), []byte("orphan"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ store.fail = false
+ if _, err := importer.Import(t.Context(), "tiny", manifest.Platform, fakeSource{manifest: manifest}); err != nil {
+ t.Fatal(err)
+ }
+ if converter.Calls() != 2 {
+ t.Fatalf("retry trusted orphan; conversions = %d", converter.Calls())
+ }
+ if _, err := images.Verify(t.Context(), paths, catalog, "tiny"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+type badConverter struct {
+ fakeConverter
+ fail error
+ omitBoot bool
+ escape string
+}
+
+func (f *badConverter) Convert(ctx context.Context, descriptor types.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) {
+ if f.fail != nil {
+ return images.ConvertedLayer{}, f.fail
+ }
+ artifact, err := f.fakeConverter.Convert(ctx, descriptor, source, workDir)
+ if f.omitBoot {
+ artifact.BootFiles = nil
+ }
+ if f.escape != "" {
+ artifact.EROFSPath = f.escape
+ }
+ return artifact, err
+}
+
+func TestImporterFailureAndCancellationDoNotCommit(t *testing.T) {
+ for _, name := range []string{"converter", "missing boot", "cancel", "escape"} {
+ t.Run(name, func(t *testing.T) {
+ paths, catalog := testImportState(t, nil)
+ converter := &badConverter{}
+ ctx := t.Context()
+ switch name {
+ case "converter":
+ converter.fail = errors.New("conversion failed")
+ case "missing boot":
+ converter.omitBoot = true
+ case "cancel":
+ canceled, cancel := context.WithCancel(ctx)
+ cancel()
+ ctx = canceled
+ case "escape":
+ outside := filepath.Join(t.TempDir(), "outside")
+ if err := os.WriteFile(outside, []byte("erofs"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ converter.escape = outside
+ }
+ importer := testImporter(t, paths, catalog, converter)
+ manifest := testManifest(t, "2")
+ if _, err := importer.Import(ctx, "tiny", manifest.Platform, fakeSource{manifest: manifest}); err == nil {
+ t.Fatal("bad import succeeded")
+ }
+ items, err := catalog.List(t.Context())
+ if err != nil || len(items) != 0 {
+ t.Fatalf("bad import committed: %v, %v", items, err)
+ }
+ entries, err := os.ReadDir(paths.StagingDir())
+ if err != nil || len(entries) != 0 {
+ t.Fatalf("bad import left staging: %v, %v", entries, err)
+ }
+ })
+ }
+}
+
+func TestSelectBootAppliesOverwritesAndWhiteouts(t *testing.T) {
+ first, second := testDigest(t, "1"), testDigest(t, "2")
+ layers := []types.Layer{
+ {SourceDigest: first, BootFiles: []types.BootFile{{Name: "vmlinuz-1"}, {Name: "vmlinuz-2"}, {Name: "initrd.img"}}},
+ {SourceDigest: second, Whiteouts: []string{"vmlinuz-2"}, BootFiles: []types.BootFile{{Name: "initrd.img"}}},
+ }
+ boot, err := images.SelectBoot(layers)
+ if err != nil || boot.KernelFile != "vmlinuz-1" || boot.KernelLayer != first || boot.InitrdLayer != second {
+ t.Fatalf("merged boot = %+v, %v", boot, err)
+ }
+ layers[1].BootOpaque = true
+ if _, err := images.SelectBoot(layers); err == nil {
+ t.Fatal("opaque layer retained older kernel")
+ }
+}
+
+func TestImporterCancellationDuringConversionCanRetry(t *testing.T) {
+ paths, catalog := testImportState(t, nil)
+ converter := &gatedConverter{started: make(chan struct{}, 1), release: make(chan struct{})}
+ importer := testImporter(t, paths, catalog, converter)
+ manifest := testManifest(t, "2")
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ done := make(chan error, 1)
+ go func() {
+ _, err := importer.Import(ctx, "tiny", manifest.Platform, fakeSource{manifest: manifest})
+ done <- err
+ }()
+ select {
+ case <-converter.started:
+ case <-time.After(5 * time.Second):
+ t.Fatal("conversion did not start")
+ }
+ cancel()
+ if err := <-done; !errors.Is(err, context.Canceled) {
+ t.Fatalf("in-flight cancellation = %v", err)
+ }
+ items, err := catalog.List(t.Context())
+ if err != nil || len(items) != 0 {
+ t.Fatalf("canceled import committed: %v, %v", items, err)
+ }
+ entries, err := os.ReadDir(paths.StagingDir())
+ if err != nil || len(entries) != 0 {
+ t.Fatalf("canceled staging = %v, %v", entries, err)
+ }
+ close(converter.release)
+ if _, err := importer.Import(t.Context(), "tiny", manifest.Platform, fakeSource{manifest: manifest}); err != nil {
+ t.Fatalf("retry = %v", err)
+ }
+}
diff --git a/images/remove.go b/images/remove.go
new file mode 100644
index 0000000..0e3719c
--- /dev/null
+++ b/images/remove.go
@@ -0,0 +1,61 @@
+package images
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+
+ filelock "github.com/kumabox/kumabox/lock/flock"
+)
+
+// RemovalCatalog exposes only the metadata operations needed for safe deletion.
+type RemovalCatalog interface {
+ ImageResolver
+ // Remove atomically deletes references only if the manifest binding is unchanged.
+ Remove(context.Context, string, types.Digest) (Removal, error)
+}
+
+// Remove drops an alias or a manifest and deletes only layers no longer referenced
+// by the catalog. It holds source digest locks across metadata removal and file
+// cleanup so an importer cannot reuse files while they are being deleted.
+// Cleanup failures are reported as committed: removed metadata is not restored.
+//
+// resolve -> lock layers -> remove metadata -> delete unreferenced files -> unlock
+// (atomic) (best effort)
+func Remove(ctx context.Context, paths Paths, catalog RemovalCatalog, reference string) (result Removal, returnErr error) {
+ image, err := catalog.Resolve(ctx, reference)
+ if err != nil {
+ return Removal{}, err
+ }
+ lockPaths := make([]string, len(image.Layers))
+ for position, layer := range image.Layers {
+ lockPaths[position] = paths.Lock(layer.SourceDigest)
+ }
+ var locks filelock.Set
+ if err := locks.Lock(ctx, lockPaths...); err != nil {
+ return Removal{}, fmt.Errorf("lock image layers: %w", err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, locks.Unlock(context.WithoutCancel(ctx))) }()
+ result, err = catalog.Remove(ctx, reference, image.ManifestDigest)
+ if err != nil {
+ return Removal{}, err
+ }
+ var cleanup []error
+ for _, digest := range result.Layers {
+ for _, path := range []string{paths.EROFS(digest), paths.BootDir(digest)} {
+ if err := storage.CheckPath(path); err != nil {
+ cleanup = append(cleanup, err)
+ continue
+ }
+ if err := os.RemoveAll(path); err != nil {
+ cleanup = append(cleanup, fmt.Errorf("remove image artifact %s: %w", path, err))
+ }
+ }
+ }
+ return result, errdefs.Context(errors.Join(cleanup...), "remove image", reference, "cleanup", "metadata removed; orphan artifacts can be reclaimed", true)
+}
diff --git a/images/source/archive.go b/images/source/archive.go
new file mode 100644
index 0000000..2ea27a8
--- /dev/null
+++ b/images/source/archive.go
@@ -0,0 +1,186 @@
+package source
+
+import (
+ "archive/tar"
+ "bufio"
+ "compress/gzip"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/storage"
+)
+
+// maxArchiveEntries bounds work from archives containing many tiny entries.
+const maxArchiveEntries = 1 << 20
+
+// NewArchive stages a tar or gzip-compressed OCI layout using default limits.
+// Use OpenLocal for format detection or Docker save archives. The caller must run
+// the returned cleanup after consuming the source; use NewArchiveContext to cancel.
+func NewArchive(path, stagingRoot string) (images.Source, func() error, error) {
+ return NewArchiveContext(context.TODO(), path, stagingRoot, images.DefaultLimits())
+}
+
+// NewArchiveContext stages a bounded OCI archive and returns source ownership
+// separately from the cleanup function. Construction failure removes staging.
+func NewArchiveContext(ctx context.Context, path, stagingRoot string, limits images.Limits) (images.Source, func() error, error) {
+ dir, cleanup, err := stageArchive(ctx, path, stagingRoot, limits)
+ if err != nil {
+ return nil, nil, err
+ }
+ source, err := NewLayoutWithLimits(dir, limits)
+ if err != nil {
+ return nil, nil, errors.Join(err, cleanup())
+ }
+ return source, cleanup, nil
+}
+
+// stageArchive extracts into a private temporary directory under stagingRoot.
+// Cleanup is returned only on success; partial extraction is removed on failure.
+func stageArchive(ctx context.Context, path, stagingRoot string, limits images.Limits) (string, func() error, error) {
+ if !limits.Valid() {
+ return "", nil, invalidSource("archive size limits must be positive and bounded")
+ }
+ if err := ctx.Err(); err != nil {
+ return "", nil, err
+ }
+ if err := storage.EnsureDir(stagingRoot); err != nil {
+ return "", nil, err
+ }
+ dir, err := os.MkdirTemp(stagingRoot, "image-archive-*")
+ if err != nil {
+ return "", nil, fmt.Errorf("create image archive staging: %w", err)
+ }
+ cleanup := func() error { return os.RemoveAll(dir) }
+ if err := extractArchiveContext(ctx, path, dir, limits.ArchiveSize); err != nil {
+ return "", nil, errors.Join(err, cleanup())
+ }
+ return dir, cleanup, nil
+}
+
+// extractArchive is the default-budget, non-cancelable extraction helper.
+func extractArchive(path, destination string) error {
+ return extractArchiveContext(context.TODO(), path, destination, images.DefaultLimits().ArchiveSize)
+}
+
+// extractArchiveContext accepts plain tar or gzip by magic bytes, allowing only
+// directories and newly created regular files. Path validation and os.Root keep
+// file writes within destination; O_EXCL rejects duplicate file destinations.
+// Both declared payload bytes and the full decoded tar stream are bounded.
+//
+// file --> magic detection --> optional gzip --> decoded byte budget --> tar
+// |
+// entry count + path + type + size checks <----------+
+// |
+// exclusive regular file writes
+//
+// The EOF drain includes trailing gzip data in the byte budget and checksum.
+func extractArchiveContext(ctx context.Context, path, destination string, limit int64) (returnErr error) {
+ if limit <= 0 {
+ return invalidSource("archive size limit must be positive")
+ }
+ file, err := openLocal(ctx, filepath.Dir(path), filepath.Base(path))
+ if err != nil {
+ return sourceError(err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, file.Close()) }()
+ buffered := bufio.NewReader(&contextInput{ctx: ctx, source: file})
+ var source io.Reader = buffered
+ magic, err := buffered.Peek(2)
+ if err != nil && !errors.Is(err, io.EOF) {
+ return err
+ }
+ if len(magic) == 2 && magic[0] == 0x1f && magic[1] == 0x8b {
+ decoder, err := gzip.NewReader(buffered)
+ if err != nil {
+ return invalidSource("open compressed image archive: %v", err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, decoder.Close()) }()
+ source = decoder
+ }
+ bounded := &io.LimitedReader{R: &contextInput{ctx: ctx, source: source}, N: limit + 1}
+ reader := tar.NewReader(bounded)
+ var total int64
+ for count := 0; ; count++ {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if count >= maxArchiveEntries {
+ return invalidSource("image archive entry count exceeds limit")
+ }
+ header, err := reader.Next()
+ if errors.Is(err, io.EOF) {
+ if _, err := io.Copy(io.Discard, bounded); err != nil {
+ return err
+ }
+ if bounded.N == 0 {
+ return invalidSource("unpacked image archive exceeds %d bytes", limit)
+ }
+ return nil
+ }
+ if err != nil {
+ return invalidSource("read image archive: %v", err)
+ }
+ clean := filepath.Clean(header.Name)
+ if clean == "." && header.Typeflag == tar.TypeDir {
+ continue
+ }
+ if clean == "." || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
+ return invalidSource("unsafe image archive path %q", header.Name)
+ }
+ target, err := storage.Join(destination, clean)
+ if err != nil {
+ return err
+ }
+ if header.Size < 0 || header.Size > limit-total {
+ return invalidSource("image archive exceeds size limit")
+ }
+ total += header.Size
+ switch header.Typeflag {
+ case tar.TypeDir:
+ if err := storage.EnsureDir(target); err != nil {
+ return err
+ }
+ case tar.TypeReg:
+ if err := storage.EnsureDir(filepath.Dir(target)); err != nil {
+ return err
+ }
+ root, err := os.OpenRoot(destination)
+ if err != nil {
+ return err
+ }
+ output, err := root.OpenFile(clean, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ return errors.Join(err, root.Close())
+ }
+ _, copyErr := io.CopyN(output, reader, header.Size)
+ if err := errors.Join(copyErr, output.Close(), root.Close()); err != nil {
+ return fmt.Errorf("extract image archive file: %w", err)
+ }
+ default:
+ return invalidSource("unsupported image archive entry %q type %d", header.Name, header.Typeflag)
+ }
+ }
+}
+
+// contextInput stops subsequent reads after cancellation; it cannot interrupt an
+// underlying Read already in progress, which must support cancellation itself.
+type contextInput struct {
+ // ctx is checked immediately before each read.
+ ctx context.Context
+ // source supplies archive, file, or decoded bytes.
+ source io.Reader
+}
+
+// Read checks cancellation before delegating to the underlying stream.
+func (r *contextInput) Read(p []byte) (int, error) {
+ if err := r.ctx.Err(); err != nil {
+ return 0, err
+ }
+ return r.source.Read(p)
+}
diff --git a/images/source/archive_test.go b/images/source/archive_test.go
new file mode 100644
index 0000000..c9b3c35
--- /dev/null
+++ b/images/source/archive_test.go
@@ -0,0 +1,77 @@
+package source
+
+import (
+ "archive/tar"
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/kumabox/kumabox/images"
+)
+
+func TestExtractArchiveRejectsTraversal(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "bad.tar")
+ file, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writer := tar.NewWriter(file)
+ if err := writer.WriteHeader(&tar.Header{Name: "../escape", Mode: 0o600, Size: 1, Typeflag: tar.TypeReg}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := writer.Write([]byte("x")); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := extractArchive(path, t.TempDir()); err == nil {
+ t.Fatal("extractArchive accepted traversal")
+ }
+}
+
+func TestArchiveRejectsLinksAndCleansFailedExtraction(t *testing.T) {
+ for _, flag := range []byte{tar.TypeSymlink, tar.TypeLink} {
+ t.Run(fmt.Sprint(flag), func(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "bad.tar")
+ raw := bootTar(t, []*tar.Header{{Name: "escape", Typeflag: flag, Linkname: "../outside"}})
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ staging := filepath.Join(root, "staging")
+ if _, _, err := NewArchiveContext(t.Context(), path, staging, images.DefaultLimits()); err == nil {
+ t.Fatal("accepted archive link")
+ }
+ entries, err := os.ReadDir(staging)
+ if err != nil || len(entries) != 0 {
+ t.Fatalf("failed extraction left staging: %v, %v", entries, err)
+ }
+ })
+ }
+}
+
+func bootTar(t *testing.T, headers []*tar.Header) []byte {
+ t.Helper()
+ var buffer bytes.Buffer
+ writer := tar.NewWriter(&buffer)
+ for _, header := range headers {
+ if err := writer.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+ if header.Size > 0 {
+ if _, err := writer.Write(bytes.Repeat([]byte("x"), int(header.Size))); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return buffer.Bytes()
+}
diff --git a/images/source/docker.go b/images/source/docker.go
new file mode 100644
index 0000000..8697be3
--- /dev/null
+++ b/images/source/docker.go
@@ -0,0 +1,246 @@
+package source
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/go-containerregistry/pkg/name"
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ "github.com/google/go-containerregistry/pkg/v1/partial"
+ mediatypes "github.com/google/go-containerregistry/pkg/v1/types"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/types"
+)
+
+// dockerEntry is one image record in Docker save's manifest.json.
+type dockerEntry struct {
+ // Config names a config object by its content hash.
+ Config string `json:"Config"`
+ // RepoTags supplies optional source-tag selectors.
+ RepoTags []string `json:"RepoTags"`
+ // Layers records rootfs objects in base-to-top order.
+ Layers []string `json:"Layers"`
+}
+
+// newDockerSource normalizes an optional tag and defers image selection to Resolve.
+func newDockerSource(path string, options LocalOptions) (images.Source, error) {
+ if options.SourceTag != "" {
+ tag, err := name.NewTag(options.SourceTag)
+ if err != nil {
+ return nil, invalidSource("invalid Docker source tag %q", options.SourceTag)
+ }
+ options.SourceTag = tag.Name()
+ }
+ source := &resolvedSource{limits: options.Limits}
+ source.resolve = func(ctx context.Context, platform types.Platform) (v1.Image, error) {
+ entry, config, err := selectDockerEntry(ctx, path, platform, options.SourceTag)
+ if err != nil {
+ return nil, err
+ }
+ return dockerImageFromEntry(ctx, path, entry, config, options.Limits)
+ }
+ return source, nil
+}
+
+// selectDockerEntry requires exactly one tag/platform match. It verifies each
+// relevant config identity before trusting platform or rootfs layer ordering.
+func selectDockerEntry(ctx context.Context, path string, platform types.Platform, sourceTag string) (dockerEntry, []byte, error) {
+ raw, err := readLocal(ctx, path, "manifest.json", maxMetadataSize)
+ if err != nil {
+ return dockerEntry{}, nil, err
+ }
+ var entries []dockerEntry
+ if err := json.Unmarshal(raw, &entries); err != nil || len(entries) == 0 {
+ return dockerEntry{}, nil, invalidSource("invalid docker save manifest.json")
+ }
+ var selected dockerEntry
+ var selectedConfig []byte
+ var count int
+ for _, entry := range entries {
+ if err := ctx.Err(); err != nil {
+ return dockerEntry{}, nil, err
+ }
+ if sourceTag != "" && !dockerTagMatches(entry.RepoTags, sourceTag) {
+ continue
+ }
+ config, err := readDockerConfig(ctx, path, entry.Config)
+ if err != nil {
+ return dockerEntry{}, nil, err
+ }
+ parsed, err := v1.ParseConfigFile(bytes.NewReader(config))
+ if err != nil {
+ return dockerEntry{}, nil, invalidSource("invalid Docker image config: %v", err)
+ }
+ if parsed.OS != platform.OS || parsed.Architecture != platform.Architecture {
+ continue
+ }
+ if parsed.RootFS.Type != "layers" || len(parsed.RootFS.DiffIDs) != len(entry.Layers) {
+ return dockerEntry{}, nil, invalidSource("Docker config rootfs does not match archive layers")
+ }
+ count++
+ selected, selectedConfig = entry, config
+ }
+ if count == 0 {
+ return dockerEntry{}, nil, invalidSource("no Docker image matches platform %s/%s and source tag %q", platform.OS, platform.Architecture, sourceTag)
+ }
+ if count != 1 {
+ return dockerEntry{}, nil, invalidSource("Docker archive has %d matching images; select one with --source-tag", count)
+ }
+ return selected, selectedConfig, nil
+}
+
+// dockerTagMatches compares normalized tags, ignoring malformed archive tags.
+func dockerTagMatches(tags []string, wanted string) bool {
+ for _, value := range tags {
+ tag, err := name.NewTag(value)
+ if err == nil && tag.Name() == wanted {
+ return true
+ }
+ }
+ return false
+}
+
+// archiveObjectName rejects absolute paths and parent traversal in Docker metadata.
+func archiveObjectName(value string) (string, error) {
+ clean := filepath.Clean(value)
+ if clean == "." || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
+ return "", invalidSource("unsafe image archive object path %q", value)
+ }
+ return clean, nil
+}
+
+// readDockerConfig verifies bounded config bytes against the hash in their filename.
+func readDockerConfig(ctx context.Context, path, object string) ([]byte, error) {
+ object, err := archiveObjectName(object)
+ if err != nil {
+ return nil, err
+ // docker save names configs by their content hash (legacy .json names,
+ }
+ // sha256:hex names, or modern blobs/sha256/hex paths).
+ hex := strings.TrimPrefix(strings.TrimSuffix(filepath.Base(object), ".json"), "sha256:")
+ digest, err := types.ParseDigest("sha256:" + hex)
+ if err != nil {
+ return nil, invalidSource("Docker config filename must contain its sha256 digest")
+ }
+ raw, err := readLocal(ctx, path, object, maxMetadataSize)
+ if err != nil {
+ return nil, err
+ }
+ if err := checkBytes(raw, v1.Hash{Algorithm: "sha256", Hex: digest.Hex()}, int64(len(raw))); err != nil {
+ return nil, err
+ }
+ return raw, nil
+}
+
+// dockerImageFromEntry normalizes the selected Docker entry into an OCI image.
+// Docker archives do not retain a registry manifest. Build a deterministic
+// manifest from the config and ordered layer descriptors, then reuse the same
+// digest/diffID validation and streaming as every other resolvedSource.
+// The synthetic digest need not equal the original registry manifest digest.
+//
+// manifest.json --> tag + platform match --> verified config
+// |
+// ordered layer files --> hash + media type -----+--> synthetic OCI manifest
+// |
+// shared resolvedSource checks
+func dockerImageFromEntry(ctx context.Context, path string, entry dockerEntry, config []byte, limits images.Limits) (v1.Image, error) {
+ configHash := v1.Hash{Algorithm: "sha256", Hex: fmt.Sprintf("%x", sha256.Sum256(config))}
+ manifest := v1.Manifest{
+ SchemaVersion: 2,
+ MediaType: mediatypes.OCIManifestSchema1,
+ Config: v1.Descriptor{MediaType: mediatypes.OCIConfigJSON, Digest: configHash, Size: int64(len(config))},
+ Layers: make([]v1.Descriptor, 0, len(entry.Layers)),
+ }
+ layers := make(map[v1.Hash]*fileLayer, len(entry.Layers))
+ for _, object := range entry.Layers {
+ object, err := archiveObjectName(object)
+ if err != nil {
+ return nil, err
+ }
+ descriptor, err := describeArchiveLayer(ctx, path, object, limits.LayerSize)
+ if err != nil {
+ return nil, err
+ }
+ manifest.Layers = append(manifest.Layers, descriptor)
+ layers[descriptor.Digest] = &fileLayer{ctx: ctx, path: path, object: object, descriptor: descriptor}
+ }
+ raw, err := json.Marshal(manifest)
+ if err != nil {
+ return nil, err
+ }
+ return partial.CompressedToImage(&dockerImage{config: config, manifest: raw, layers: layers})
+}
+
+// describeArchiveLayer hashes bounded encoded bytes and detects compression by
+// magic rather than filename. Modern content-addressed blob paths must match the
+// computed hash; legacy layer paths obtain their identity from this hash pass.
+func describeArchiveLayer(ctx context.Context, path, object string, limit int64) (v1.Descriptor, error) {
+ reader, err := openLocal(ctx, path, object)
+ if err != nil {
+ return v1.Descriptor{}, err
+ }
+ buffered := bufio.NewReader(reader)
+ magic, peekErr := buffered.Peek(4)
+ media := mediatypes.OCIUncompressedLayer
+ if len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b {
+ media = mediatypes.OCILayer
+ } else if len(magic) == 4 && bytes.Equal(magic, []byte{0x28, 0xb5, 0x2f, 0xfd}) {
+ media = mediatypes.OCILayerZStd
+ }
+ if peekErr != nil && !errors.Is(peekErr, io.EOF) {
+ return v1.Descriptor{}, errors.Join(peekErr, reader.Close())
+ }
+ digest, size, readErr := v1.SHA256(io.LimitReader(buffered, limit+1))
+ if err := errors.Join(readErr, reader.Close()); err != nil {
+ return v1.Descriptor{}, err
+ }
+ if size > limit {
+ return v1.Descriptor{}, invalidSource("Docker layer exceeds %d bytes", limit)
+ }
+ if strings.HasPrefix(filepath.ToSlash(object), "blobs/sha256/") && filepath.Base(object) != digest.Hex {
+ return v1.Descriptor{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeDigestMismatch, errors.New("docker layer blob digest mismatch"))
+ }
+ return v1.Descriptor{MediaType: media, Digest: digest, Size: size}, nil
+}
+
+// dockerImage supplies the normalized metadata and lazy file layer adapters.
+type dockerImage struct {
+ // config retains the verified Docker config unchanged.
+ config []byte
+ // manifest is the deterministic serialized OCI manifest.
+ manifest []byte
+ // layers indexes encoded objects by computed digest.
+ layers map[v1.Hash]*fileLayer
+}
+
+var _ partial.CompressedImageCore = (*dockerImage)(nil)
+
+// MediaType reports the synthetic OCI manifest format.
+func (i *dockerImage) MediaType() (mediatypes.MediaType, error) {
+ return mediatypes.OCIManifestSchema1, nil
+}
+
+// RawConfigFile returns an independent copy of the verified source config.
+func (i *dockerImage) RawConfigFile() ([]byte, error) { return bytes.Clone(i.config), nil }
+
+// RawManifest returns an independent copy of the synthetic manifest bytes.
+func (i *dockerImage) RawManifest() ([]byte, error) { return bytes.Clone(i.manifest), nil }
+
+// LayerByDigest rejects objects not included in the selected Docker image.
+func (i *dockerImage) LayerByDigest(hash v1.Hash) (partial.CompressedLayer, error) {
+ layer, ok := i.layers[hash]
+ if !ok {
+ return nil, invalidSource("Docker layer %s not found", hash)
+ }
+ return layer, nil
+}
diff --git a/images/source/docker_test.go b/images/source/docker_test.go
new file mode 100644
index 0000000..d6674e3
--- /dev/null
+++ b/images/source/docker_test.go
@@ -0,0 +1,336 @@
+package source
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "maps"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ "github.com/klauspost/compress/zstd"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/types"
+)
+
+func encodeJSON(t *testing.T, value any) []byte {
+ t.Helper()
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+}
+
+func fixtureDockerEntry(t *testing.T, architecture, tag, compression string) (dockerEntry, map[string][]byte, [][]byte) {
+ t.Helper()
+ unpacked := [][]byte{
+ bootTar(t, []*tar.Header{{Name: "boot/vmlinuz-1", Typeflag: tar.TypeReg, Size: 3}}),
+ bootTar(t, []*tar.Header{{Name: "boot/initrd.img-1", Typeflag: tar.TypeReg, Size: 7}}),
+ }
+ entry := dockerEntry{RepoTags: []string{tag}}
+ objects := map[string][]byte{}
+ config := v1.ConfigFile{
+ OS: "linux", Architecture: architecture, RootFS: v1.RootFS{Type: "layers"},
+ Config: v1.Config{Env: []string{"FIXTURE=" + tag}, Labels: map[string]string{types.ImageBootProfileLabel: string(types.BootProfileOverlayV1)}},
+ }
+ for index, raw := range unpacked {
+ config.RootFS.DiffIDs = append(config.RootFS.DiffIDs, v1.Hash{Algorithm: "sha256", Hex: fmt.Sprintf("%x", sha256.Sum256(raw))})
+ var buffer bytes.Buffer
+ switch compression {
+ case "gzip":
+ writer := gzip.NewWriter(&buffer)
+ if _, err := writer.Write(raw); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ raw = buffer.Bytes()
+ case "zstd":
+ writer, err := zstd.NewWriter(&buffer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := writer.Write(raw); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ raw = buffer.Bytes()
+ }
+ object := fmt.Sprintf("layer-%d/layer.tar", index)
+ objects[object] = raw
+ entry.Layers = append(entry.Layers, object)
+ }
+ setDockerConfig(t, &entry, objects, encodeJSON(t, config))
+ return entry, objects, unpacked
+}
+
+func setDockerConfig(t *testing.T, entry *dockerEntry, objects map[string][]byte, raw []byte) {
+ t.Helper()
+ delete(objects, entry.Config)
+ entry.Config = fmt.Sprintf("%x.json", sha256.Sum256(raw))
+ objects[entry.Config] = raw
+}
+
+func writeImageArchive(t *testing.T, objects map[string][]byte, compressed bool) string {
+ t.Helper()
+ var buffer bytes.Buffer
+ var output io.Writer = &buffer
+ zipper := gzip.NewWriter(&buffer)
+ if compressed {
+ output = zipper
+ }
+ writer := tar.NewWriter(output)
+ keys := make([]string, 0, len(objects))
+ for key := range objects {
+ keys = append(keys, key)
+ }
+ slices.Sort(keys)
+ for _, key := range keys {
+ raw := objects[key]
+ if err := writer.WriteHeader(&tar.Header{Name: key, Typeflag: tar.TypeReg, Size: int64(len(raw)), Mode: 0o600}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := writer.Write(raw); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if compressed {
+ if err := zipper.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ path := filepath.Join(t.TempDir(), "image.bin")
+ if err := os.WriteFile(path, buffer.Bytes(), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func readSourceLayers(ctx context.Context, source images.Source, platform types.Platform) (types.Manifest, [][]byte, error) {
+ manifest, err := source.Resolve(ctx, platform)
+ if err != nil {
+ return types.Manifest{}, nil, err
+ }
+ var layers [][]byte
+ for _, descriptor := range manifest.Layers {
+ reader, err := source.OpenLayer(ctx, descriptor)
+ if err != nil {
+ return manifest, nil, err
+ }
+ raw, readErr := io.ReadAll(reader)
+ if err := errors.Join(readErr, reader.Close()); err != nil {
+ return manifest, nil, err
+ }
+ layers = append(layers, raw)
+ }
+ return manifest, layers, nil
+}
+
+func TestDockerSourcePreservesLayersAndIdentity(t *testing.T) {
+ for _, compression := range []string{"raw", "gzip", "zstd"} {
+ t.Run(compression, func(t *testing.T) {
+ t.Parallel()
+ entry, objects, expected := fixtureDockerEntry(t, "amd64", "example/demo:one", compression)
+ objects["manifest.json"] = encodeJSON(t, []dockerEntry{entry})
+ path := writeImageArchive(t, objects, false)
+ source, cleanup, err := OpenLocal(t.Context(), path, t.TempDir(), LocalOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := cleanup(); err != nil {
+ t.Error(err)
+ }
+ })
+ platform := types.Platform{OS: "linux", Architecture: "amd64"}
+ manifest, layers, err := readSourceLayers(t.Context(), source, platform)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if manifest.BootProfile != types.BootProfileOverlayV1 {
+ t.Fatalf("boot profile = %q", manifest.BootProfile)
+ }
+ if len(layers) != len(expected) {
+ t.Fatalf("layer count = %d, want %d", len(layers), len(expected))
+ }
+ for index, raw := range layers {
+ if !bytes.Equal(raw, expected[index]) {
+ t.Fatalf("layer %d changed or reordered", index)
+ }
+ }
+ // Repacking, renaming the input, and changing RepoTags must not
+ // change the identity of the same config and ordered layers.
+ entry.RepoTags = []string{"example/demo:alias"}
+ objects["manifest.json"] = encodeJSON(t, []dockerEntry{entry})
+ second, cleanSecond, err := OpenLocal(t.Context(), writeImageArchive(t, objects, true), t.TempDir(), LocalOptions{Format: FormatDocker})
+ if err != nil {
+ t.Fatal(err)
+ }
+ secondManifest, resolveErr := second.Resolve(t.Context(), platform)
+ if err := errors.Join(resolveErr, cleanSecond()); err != nil {
+ t.Fatal(err)
+ }
+ if secondManifest.Digest != manifest.Digest {
+ t.Fatalf("repacked identity = %s, want %s", secondManifest.Digest, manifest.Digest)
+ }
+ })
+ }
+}
+
+func TestDockerSourceSelectsTagAndPlatform(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ tag string
+ architecture string
+ wantError bool
+ }{
+ {name: "ambiguous", architecture: "amd64", wantError: true},
+ {name: "tag", tag: "example/demo:two", architecture: "amd64"},
+ {name: "canonical tag", tag: "docker.io/example/demo:two", architecture: "amd64"},
+ {name: "platform", architecture: "arm64"},
+ {name: "missing tag", tag: "example/demo:missing", architecture: "amd64", wantError: true},
+ {name: "wrong platform", tag: "example/demo:two", architecture: "arm64", wantError: true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+ objects := map[string][]byte{}
+ var entries []dockerEntry
+ for _, image := range []struct{ architecture, tag string }{{"amd64", "example/demo:one"}, {"amd64", "example/demo:two"}, {"arm64", "example/demo:arm"}} {
+ entry, files, _ := fixtureDockerEntry(t, image.architecture, image.tag, "raw")
+ entries = append(entries, entry)
+ maps.Copy(objects, files)
+ }
+ objects["manifest.json"] = encodeJSON(t, entries)
+ source, cleanup, err := OpenLocal(t.Context(), writeImageArchive(t, objects, false), t.TempDir(), LocalOptions{SourceTag: test.tag})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _, readErr := readSourceLayers(t.Context(), source, types.Platform{OS: "linux", Architecture: test.architecture})
+ if err := cleanup(); err != nil {
+ t.Fatal(err)
+ }
+ if test.wantError {
+ if code, _ := errdefs.CodeOf(readErr); code != errdefs.CodeInvalidArgument {
+ t.Fatalf("selection error = %v", readErr)
+ }
+ } else if readErr != nil {
+ t.Fatal(readErr)
+ }
+ })
+ }
+}
+
+func TestDockerSourceRejectsCorruptionAndUnsafeReferences(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ code errdefs.Code
+ }{
+ {name: "config digest", code: errdefs.CodeDigestMismatch},
+ {name: "layer diffID", code: errdefs.CodeDigestMismatch},
+ {name: "config traversal", code: errdefs.CodeInvalidArgument},
+ {name: "layer traversal", code: errdefs.CodeInvalidArgument},
+ {name: "absolute layer", code: errdefs.CodeInvalidArgument},
+ {name: "layer count", code: errdefs.CodeInvalidArgument},
+ {name: "missing layer", code: errdefs.CodeArtifactUnavailable},
+ {name: "layer limit", code: errdefs.CodeInvalidArgument},
+ {name: "unpacked limit", code: errdefs.CodeInvalidArgument},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+ entry, objects, _ := fixtureDockerEntry(t, "amd64", "example/demo:one", "raw")
+ limits := images.DefaultLimits()
+ switch test.name {
+ case "config digest":
+ objects[entry.Config] = append(objects[entry.Config], '\n')
+ case "layer diffID":
+ objects[entry.Layers[0]][512] ^= 1
+ case "config traversal":
+ entry.Config = "../" + entry.Config
+ case "layer traversal":
+ entry.Layers[0] = "../escape"
+ case "absolute layer":
+ entry.Layers[0] = "/etc/passwd"
+ case "layer count":
+ entry.Layers = entry.Layers[:1]
+ case "missing layer":
+ delete(objects, entry.Layers[0])
+ case "layer limit":
+ limits.LayerSize = 32
+ case "unpacked limit":
+ limits.UnpackedSize = 32
+ }
+ objects["manifest.json"] = encodeJSON(t, []dockerEntry{entry})
+ staging := t.TempDir()
+ source, cleanup, err := OpenLocal(t.Context(), writeImageArchive(t, objects, false), staging, LocalOptions{Limits: limits})
+ if err == nil {
+ _, _, err = readSourceLayers(t.Context(), source, types.Platform{OS: "linux", Architecture: "amd64"})
+ if cleanupErr := cleanup(); cleanupErr != nil {
+ t.Fatal(cleanupErr)
+ }
+ }
+ if code, _ := errdefs.CodeOf(err); code != test.code {
+ t.Fatalf("error = %v, want %s", err, test.code)
+ }
+ files, err := os.ReadDir(staging)
+ if err != nil || len(files) != 0 {
+ t.Fatalf("staging leaked: %v, %v", files, err)
+ }
+ })
+ }
+}
+
+func TestDockerSourceBlobPaths(t *testing.T) {
+ for _, corrupt := range []bool{false, true} {
+ t.Run(fmt.Sprintf("corrupt=%v", corrupt), func(t *testing.T) {
+ t.Parallel()
+ entry, objects, expected := fixtureDockerEntry(t, "amd64", "example/demo:one", "gzip")
+ config := objects[entry.Config]
+ delete(objects, entry.Config)
+ entry.Config = fmt.Sprintf("blobs/sha256/%x", sha256.Sum256(config))
+ objects[entry.Config] = config
+ for index, object := range entry.Layers {
+ raw := objects[object]
+ delete(objects, object)
+ entry.Layers[index] = fmt.Sprintf("blobs/sha256/%x", sha256.Sum256(raw))
+ objects[entry.Layers[index]] = raw
+ }
+ if corrupt {
+ objects[entry.Layers[0]][10] ^= 1
+ }
+ objects["manifest.json"] = encodeJSON(t, []dockerEntry{entry})
+ source, cleanup, err := OpenLocal(t.Context(), writeImageArchive(t, objects, false), t.TempDir(), LocalOptions{Format: FormatDocker})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, layers, readErr := readSourceLayers(t.Context(), source, types.Platform{OS: "linux", Architecture: "amd64"})
+ if err := cleanup(); err != nil {
+ t.Fatal(err)
+ }
+ if corrupt {
+ if code, _ := errdefs.CodeOf(readErr); code != errdefs.CodeDigestMismatch {
+ t.Fatalf("blob corruption error = %v", readErr)
+ }
+ } else if readErr != nil || len(layers) != len(expected) || !bytes.Equal(layers[0], expected[0]) || !bytes.Equal(layers[1], expected[1]) {
+ t.Fatalf("blob layers changed: %v", readErr)
+ }
+ })
+ }
+}
diff --git a/images/source/local.go b/images/source/local.go
new file mode 100644
index 0000000..1555df6
--- /dev/null
+++ b/images/source/local.go
@@ -0,0 +1,226 @@
+package source
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ "github.com/google/go-containerregistry/pkg/v1/partial"
+ mediatypes "github.com/google/go-containerregistry/pkg/v1/types"
+
+ "github.com/kumabox/kumabox/images"
+)
+
+// Format identifies local image metadata conventions, independently of tar compression.
+type Format string
+
+const (
+ // FormatAuto detects OCI metadata first, then Docker save metadata.
+ FormatAuto Format = "auto"
+ // FormatOCI selects an OCI image layout, including one staged from an archive.
+ FormatOCI Format = "oci"
+ // FormatDocker selects Docker save metadata; Docker export archives are unsupported.
+ FormatDocker Format = "docker"
+)
+
+// ParseFormat validates the CLI format vocabulary; an empty value means auto.
+func ParseFormat(value string) (Format, error) {
+ format := Format(value)
+ switch format {
+ case "", FormatAuto:
+ return FormatAuto, nil
+ case FormatOCI, FormatDocker:
+ return format, nil
+ default:
+ return "", invalidSource("unsupported image format %q; use auto, oci, or docker", value)
+ }
+}
+
+// LocalOptions controls local format selection and source resource budgets.
+type LocalOptions struct {
+ // Format selects metadata explicitly or requests detection with FormatAuto.
+ Format Format
+ // SourceTag selects one tagged Docker entry and is rejected for OCI layouts.
+ SourceTag string
+ // Limits bounds metadata-adjacent source content; zero uses images.DefaultLimits.
+ Limits images.Limits
+}
+
+// OpenLocal owns format selection and archive staging. The caller must clean up
+// after it finishes reading the source, including when Resolve or import fails.
+// The returned cleanup function owns only staging created by this call; directory
+// inputs remain caller-owned. Failure cleans staging before returning.
+//
+// input --> directory? -- yes --> select metadata --> images.Source
+// | ^
+// no |
+// +--> bounded staging ---+
+// (cleanup after source consumption)
+func OpenLocal(ctx context.Context, path, stagingRoot string, options LocalOptions) (images.Source, func() error, error) {
+ format, err := ParseFormat(string(options.Format))
+ if err != nil {
+ return nil, nil, err
+ }
+ options.Format = format
+ if options.Limits == (images.Limits{}) {
+ options.Limits = images.DefaultLimits()
+ }
+ if !options.Limits.Valid() {
+ return nil, nil, invalidSource("image size limits must be positive and bounded")
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, nil, err
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return nil, nil, fmt.Errorf("inspect image source: %w", err)
+ }
+ cleanup := func() error { return nil }
+ if !info.IsDir() {
+ path, cleanup, err = stageArchive(ctx, path, stagingRoot, options.Limits)
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+ source, err := openLocalFormat(ctx, path, options)
+ if err != nil {
+ return nil, nil, errors.Join(err, cleanup())
+ }
+ return source, cleanup, nil
+}
+
+// openLocalFormat dispatches one selected format without validation fallback.
+func openLocalFormat(ctx context.Context, path string, options LocalOptions) (images.Source, error) {
+ format := options.Format
+ if format == FormatAuto {
+ var err error
+ format, err = detectLocalFormat(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+ }
+ switch format {
+ case FormatOCI:
+ if options.SourceTag != "" {
+ return nil, invalidSource("--source-tag selects a Docker image; use --format docker")
+ }
+ return NewLayoutWithLimits(path, options.Limits)
+ case FormatDocker:
+ return newDockerSource(path, options)
+ default:
+ return nil, invalidSource("unsupported image format %q", format)
+ }
+}
+
+// detectLocalFormat checks regular metadata markers in priority order.
+// Modern Docker saves can contain both formats. Prefer OCI metadata and never
+// fall back to another format after a recognized source fails validation.
+func detectLocalFormat(ctx context.Context, path string) (Format, error) {
+ for _, candidate := range []struct {
+ // format is selected when its marker is present.
+ format Format
+ // marker is metadata that must be a regular file.
+ marker string
+ }{{FormatOCI, "oci-layout"}, {FormatDocker, "manifest.json"}} {
+ if err := ctx.Err(); err != nil {
+ return "", err
+ }
+ info, err := os.Lstat(filepath.Join(path, candidate.marker))
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return "", fmt.Errorf("detect image format: %w", err)
+ }
+ if !info.Mode().IsRegular() {
+ return "", invalidSource("image format marker %s is not a regular file", candidate.marker)
+ }
+ return candidate.format, nil
+ }
+ return "", invalidSource("unrecognized image format; expected an OCI layout/archive or a docker save archive")
+}
+
+// readLocal bounds metadata before allocation and closes the file and root.
+// os.Root confines path resolution even if paths change concurrently; it does not
+// make the contents inside the root immutable.
+func readLocal(ctx context.Context, path, name string, limit int64) ([]byte, error) {
+ reader, err := openLocal(ctx, path, name)
+ if err != nil {
+ return nil, err
+ }
+ raw, readErr := io.ReadAll(io.LimitReader(reader, limit+1))
+ if err := errors.Join(readErr, reader.Close()); err != nil {
+ return nil, err
+ }
+ if int64(len(raw)) > limit {
+ return nil, invalidSource("image metadata exceeds %d bytes", limit)
+ }
+ return raw, nil
+}
+
+// openLocal opens a regular object through os.Root, preventing relative paths or
+// symlink traversal from escaping the source root. Close owns both file and root.
+func openLocal(ctx context.Context, path, name string) (io.ReadCloser, error) {
+ root, err := os.OpenRoot(path)
+ if err != nil {
+ return nil, err
+ }
+ file, err := root.Open(name)
+ if err != nil {
+ return nil, errors.Join(err, root.Close())
+ }
+ info, err := file.Stat()
+ if err != nil || !info.Mode().IsRegular() {
+ if err == nil {
+ err = invalidSource("image source object is not a regular file")
+ }
+ return nil, errors.Join(err, file.Close(), root.Close())
+ }
+ return &localReader{Reader: &contextInput{ctx: ctx, source: file}, file: file, root: root}, nil
+}
+
+// localReader keeps the source root alive for the lifetime of an open object.
+type localReader struct {
+ // Reader checks cancellation before reading the file.
+ io.Reader
+ // file is the regular object opened within root.
+ file *os.File
+ // root anchors path resolution until Close.
+ root *os.Root
+}
+
+// Close releases the object and its root while preserving both errors.
+func (r *localReader) Close() error { return errors.Join(r.file.Close(), r.root.Close()) }
+
+// fileLayer adapts an on-disk layer to the library's encoded-layer contract.
+// Content verification is performed by resolvedSource when the stream is read.
+type fileLayer struct {
+ // path is the layout or staging root.
+ path string
+ // object is a relative layer path within path.
+ object string
+ // descriptor records encoded media type, digest, and size.
+ descriptor v1.Descriptor
+ // ctx belongs to the resolution that selected this layer.
+ ctx context.Context
+}
+
+var _ partial.CompressedLayer = (*fileLayer)(nil)
+
+// Digest returns the encoded content identity recorded in the descriptor.
+func (l *fileLayer) Digest() (v1.Hash, error) { return l.descriptor.Digest, nil }
+
+// Size returns the declared encoded byte count for later stream validation.
+func (l *fileLayer) Size() (int64, error) { return l.descriptor.Size, nil }
+
+// MediaType identifies the decoder required for the stored object.
+func (l *fileLayer) MediaType() (mediatypes.MediaType, error) { return l.descriptor.MediaType, nil }
+
+// Compressed opens encoded bytes inside the source root; the caller owns Close.
+func (l *fileLayer) Compressed() (io.ReadCloser, error) {
+ return openLocal(l.ctx, l.path, l.object)
+}
diff --git a/images/source/local_test.go b/images/source/local_test.go
new file mode 100644
index 0000000..2aa7ddb
--- /dev/null
+++ b/images/source/local_test.go
@@ -0,0 +1,204 @@
+package source
+
+import (
+ "context"
+ "errors"
+ "io/fs"
+ "maps"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestParseFormat(t *testing.T) {
+ for _, test := range []struct {
+ value string
+ want Format
+ }{
+ {value: "", want: FormatAuto},
+ {value: "auto", want: FormatAuto},
+ {value: "docker", want: FormatDocker},
+ {value: "oci", want: FormatOCI},
+ {value: "tar"},
+ {value: "docker-archive"},
+ } {
+ t.Run("format="+test.value, func(t *testing.T) {
+ got, err := ParseFormat(test.value)
+ if test.want == "" {
+ if code, _ := errdefs.CodeOf(err); code != errdefs.CodeInvalidArgument {
+ t.Fatalf("error = %v", err)
+ }
+ } else if err != nil || got != test.want {
+ t.Fatalf("format = %q, %v, want %q", got, err, test.want)
+ }
+ })
+ }
+}
+
+func fixtureOCIObjects(t *testing.T) map[string][]byte {
+ t.Helper()
+ objects := map[string][]byte{}
+ root := "../../testdata/oci-layout"
+ if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
+ if err != nil || entry.IsDir() {
+ return err
+ }
+ object, err := filepath.Rel(root, path)
+ if err != nil {
+ return err
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ objects[object] = raw
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return objects
+}
+
+func TestOpenLocalFormats(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ format Format
+ docker bool
+ directory bool
+ compressed bool
+ wantError bool
+ }{
+ {name: "OCI directory", directory: true},
+ {name: "explicit OCI directory", directory: true, format: FormatOCI},
+ {name: "OCI tar"},
+ {name: "OCI gzip", compressed: true},
+ {name: "explicit OCI archive", format: FormatOCI},
+ {name: "Docker tar", docker: true},
+ {name: "Docker gzip", docker: true, compressed: true},
+ {name: "explicit Docker", docker: true, format: FormatDocker},
+ {name: "Docker forced as OCI", docker: true, format: FormatOCI, wantError: true},
+ {name: "OCI forced as Docker", format: FormatDocker, wantError: true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+ objects := fixtureOCIObjects(t)
+ if test.docker {
+ entry, files, _ := fixtureDockerEntry(t, "amd64", "example/demo:one", "raw")
+ objects = files
+ objects["manifest.json"] = encodeJSON(t, []dockerEntry{entry})
+ }
+ path := "../../testdata/oci-layout"
+ if !test.directory {
+ path = writeImageArchive(t, objects, test.compressed)
+ }
+ staging := t.TempDir()
+ source, cleanup, err := OpenLocal(t.Context(), path, staging, LocalOptions{Format: test.format})
+ if err == nil {
+ _, _, err = readSourceLayers(t.Context(), source, types.Platform{OS: "linux", Architecture: "amd64"})
+ if cleanupErr := cleanup(); cleanupErr != nil {
+ t.Fatal(cleanupErr)
+ }
+ }
+ if test.wantError {
+ if err == nil {
+ t.Fatal("accepted mismatched format")
+ }
+ } else if err != nil {
+ t.Fatal(err)
+ }
+ files, err := os.ReadDir(staging)
+ if err != nil || len(files) != 0 {
+ t.Fatalf("staging leaked: %v, %v", files, err)
+ }
+ })
+ }
+}
+
+func TestOpenLocalPrefersOCIWithoutFallback(t *testing.T) {
+ objects := fixtureOCIObjects(t)
+ entry, dockerObjects, _ := fixtureDockerEntry(t, "amd64", "example/demo:one", "raw")
+ maps.Copy(objects, dockerObjects)
+ objects["manifest.json"] = encodeJSON(t, []dockerEntry{entry})
+ platform := types.Platform{OS: "linux", Architecture: "amd64"}
+ fixture, err := NewLayout("../../testdata/oci-layout")
+ if err != nil {
+ t.Fatal(err)
+ }
+ expected, err := fixture.Resolve(t.Context(), platform)
+ if err != nil {
+ t.Fatal(err)
+ }
+ source, cleanup, err := OpenLocal(t.Context(), writeImageArchive(t, objects, false), t.TempDir(), LocalOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest, resolveErr := source.Resolve(t.Context(), platform)
+ if err := errors.Join(resolveErr, cleanup()); err != nil {
+ t.Fatal(err)
+ }
+ if manifest.Digest != expected.Digest {
+ t.Fatal("auto detection did not prefer OCI metadata")
+ }
+ objects["oci-layout"] = []byte("broken")
+ source, cleanup, err = OpenLocal(t.Context(), writeImageArchive(t, objects, false), t.TempDir(), LocalOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, resolveErr = source.Resolve(t.Context(), platform)
+ if err := cleanup(); err != nil {
+ t.Fatal(err)
+ }
+ if resolveErr == nil {
+ t.Fatal("corrupt OCI metadata silently fell back to Docker")
+ }
+}
+
+func TestOpenLocalRejectsUnknownAndBoundsArchives(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ options LocalOptions
+ }{
+ {name: "docker export is not docker save"},
+ {name: "archive size", options: LocalOptions{Limits: images.Limits{LayerSize: 1024, UnpackedSize: 1024, BootSize: 1024, ArchiveSize: 64}}},
+ {name: "invalid limits", options: LocalOptions{Limits: images.Limits{ArchiveSize: 64}}},
+ {name: "invalid format", options: LocalOptions{Format: "tar"}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ path := writeImageArchive(t, map[string][]byte{"etc/os-release": []byte("fixture")}, true)
+ staging := t.TempDir()
+ _, _, err := OpenLocal(t.Context(), path, staging, test.options)
+ if code, _ := errdefs.CodeOf(err); code != errdefs.CodeInvalidArgument {
+ t.Fatalf("error = %v", err)
+ }
+ files, err := os.ReadDir(staging)
+ if err != nil || len(files) != 0 {
+ t.Fatalf("staging leaked: %v, %v", files, err)
+ }
+ })
+ }
+}
+
+func TestOpenLocalCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ if _, _, err := OpenLocal(ctx, "missing", t.TempDir(), LocalOptions{}); !errors.Is(err, context.Canceled) {
+ t.Fatalf("open cancellation = %v", err)
+ }
+ entry, objects, _ := fixtureDockerEntry(t, "amd64", "example/demo:one", "raw")
+ objects["manifest.json"] = encodeJSON(t, []dockerEntry{entry})
+ source, cleanup, err := OpenLocal(t.Context(), writeImageArchive(t, objects, false), t.TempDir(), LocalOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, resolveErr := source.Resolve(ctx, types.Platform{OS: "linux", Architecture: "amd64"})
+ if err := cleanup(); err != nil {
+ t.Fatal(err)
+ }
+ if !errors.Is(resolveErr, context.Canceled) {
+ t.Fatalf("resolve cancellation = %v", resolveErr)
+ }
+}
diff --git a/images/source/oci.go b/images/source/oci.go
new file mode 100644
index 0000000..0220b6e
--- /dev/null
+++ b/images/source/oci.go
@@ -0,0 +1,326 @@
+package source
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ "github.com/google/go-containerregistry/pkg/v1/partial"
+ mediatypes "github.com/google/go-containerregistry/pkg/v1/types"
+
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/types"
+)
+
+// NewLayout opens an OCI image layout using default source limits. Validation
+// and platform selection occur during Resolve; the directory remains caller-owned.
+func NewLayout(path string) (images.Source, error) {
+ return NewLayoutWithLimits(path, images.DefaultLimits())
+}
+
+// NewLayoutWithLimits opens a caller-owned OCI directory with explicit budgets.
+// Resolve rejects symlinks and special files, validates layout metadata, and
+// requires one image matching the requested OS and architecture. Object reads use
+// os.Root confinement in addition to the initial directory walk.
+func NewLayoutWithLimits(path string, limits images.Limits) (images.Source, error) {
+ if !limits.Valid() {
+ return nil, invalidSource("OCI size limits must be positive and bounded")
+ }
+ source := &resolvedSource{limits: limits}
+ source.resolve = func(ctx context.Context, platform types.Platform) (v1.Image, error) {
+ if err := filepath.WalkDir(path, func(path string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if entry.Type()&os.ModeSymlink != 0 {
+ return invalidSource("OCI layout contains symlink %s", path)
+ }
+ if !entry.IsDir() && !entry.Type().IsRegular() {
+ return invalidSource("OCI layout contains special file %s", path)
+ }
+ if filepath.Base(path) == "index.json" || filepath.Base(path) == "oci-layout" {
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if info.Size() > maxMetadataSize {
+ return invalidSource("OCI metadata exceeds size limit")
+ }
+ }
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+ layoutRaw, err := readLocal(ctx, path, "oci-layout", maxMetadataSize)
+ if err != nil {
+ return nil, err
+ }
+ var layoutVersion struct {
+ // Version must match the supported OCI layout version 1.0.0.
+ Version string `json:"imageLayoutVersion"`
+ }
+ if err := json.Unmarshal(layoutRaw, &layoutVersion); err != nil || layoutVersion.Version != "1.0.0" {
+ return nil, invalidSource("invalid OCI layout version")
+ }
+ indexRaw, err := readLocal(ctx, path, "index.json", maxMetadataSize)
+ if err != nil {
+ return nil, err
+ }
+ index := &localIndex{path: path, raw: indexRaw, ctx: ctx}
+ return imageForPlatform(index, platform)
+ }
+ return source, nil
+}
+
+// imageForPlatform rejects absent or ambiguous matches rather than choosing by order.
+func imageForPlatform(index v1.ImageIndex, platform types.Platform) (v1.Image, error) {
+ candidates, err := platformCandidates(index, platform, 0)
+ if err != nil {
+ return nil, err
+ }
+ if len(candidates) != 1 {
+ return nil, invalidSource("OCI layout has %d images for %s/%s; expected exactly one", len(candidates), platform.OS, platform.Architecture)
+ }
+ return candidates[0], nil
+}
+
+// platformCandidates descends bounded OCI indices and verifies each traversed
+// descriptor against object bytes. Platform hints filter branches; image configs
+// determine the actual platform before a leaf becomes a candidate.
+//
+// index --> descriptor validation --> platform hint matches?
+// |
+// +-----------------+------------------+
+// | |
+// nested index image manifest
+// | |
+// recurse (depth bound) config platform check
+// +-----------------+------------------+
+// |
+// exactly one candidate
+func platformCandidates(index v1.ImageIndex, platform types.Platform, depth int) ([]v1.Image, error) {
+ if depth > 16 {
+ return nil, invalidSource("OCI index nesting exceeds limit")
+ }
+ manifest, err := index.IndexManifest()
+ if err != nil {
+ return nil, invalidSource("read OCI index: %v", err)
+ }
+ if manifest.SchemaVersion != 2 {
+ return nil, invalidSource("unsupported OCI index schema version")
+ }
+ var candidates []v1.Image
+ for _, descriptor := range manifest.Manifests {
+ if err := validateDescriptor(descriptor, maxMetadataSize); err != nil {
+ return nil, err
+ }
+ if descriptor.Platform != nil && (descriptor.Platform.OS != platform.OS || descriptor.Platform.Architecture != platform.Architecture) {
+ continue
+ }
+ switch descriptor.MediaType {
+ case mediatypes.OCIImageIndex, mediatypes.DockerManifestList:
+ nested, err := index.ImageIndex(descriptor.Digest)
+ if err != nil {
+ return nil, err
+ }
+ raw, err := nested.RawManifest()
+ if err != nil {
+ return nil, err
+ }
+ if err := checkBytes(raw, descriptor.Digest, descriptor.Size); err != nil {
+ return nil, err
+ }
+ images, err := platformCandidates(nested, platform, depth+1)
+ if err != nil {
+ return nil, err
+ }
+ candidates = append(candidates, images...)
+ case mediatypes.OCIManifestSchema1, mediatypes.DockerManifestSchema2:
+ image, err := index.Image(descriptor.Digest)
+ if err != nil {
+ return nil, err
+ }
+ raw, err := image.RawManifest()
+ if err != nil {
+ return nil, err
+ }
+ if err := checkBytes(raw, descriptor.Digest, descriptor.Size); err != nil {
+ return nil, err
+ }
+ manifest, err := v1.ParseManifest(bytes.NewReader(raw))
+ if err != nil {
+ return nil, err
+ }
+ if err := validateDescriptor(manifest.Config, maxMetadataSize); err != nil {
+ return nil, err
+ }
+ configRaw, err := image.RawConfigFile()
+ if err != nil {
+ return nil, err
+ }
+ if err := checkBytes(configRaw, manifest.Config.Digest, manifest.Config.Size); err != nil {
+ return nil, err
+ }
+ config, err := v1.ParseConfigFile(bytes.NewReader(configRaw))
+ if err != nil {
+ return nil, err
+ }
+ if config.OS == platform.OS && config.Architecture == platform.Architecture {
+ candidates = append(candidates, image)
+ }
+ default:
+ return nil, invalidSource("unsupported OCI descriptor media type %s", descriptor.MediaType)
+ }
+ }
+ return candidates, nil
+}
+
+// blobName converts a supported digest into the confined OCI blob path.
+func blobName(hash v1.Hash) (string, error) {
+ digest, err := types.ParseDigest(hash.String())
+ if err != nil {
+ return "", invalidSource("invalid OCI blob digest: %v", err)
+ }
+ return "blobs/sha256/" + digest.Hex(), nil
+}
+
+// localIndex adapts bounded index bytes and lazily resolves child blob objects.
+type localIndex struct {
+ // path is the caller-owned layout or private staging root.
+ path string
+ // raw contains the already bounded index metadata.
+ raw []byte
+ // ctx propagates cancellation to child object reads.
+ ctx context.Context
+}
+
+// MediaType identifies this adapter as an OCI image index.
+func (i *localIndex) MediaType() (mediatypes.MediaType, error) { return mediatypes.OCIImageIndex, nil }
+
+// Digest derives index identity from its exact serialized metadata bytes.
+func (i *localIndex) Digest() (v1.Hash, error) { return partial.Digest(i) }
+
+// Size reports the index byte count for descriptor validation.
+func (i *localIndex) Size() (int64, error) { return int64(len(i.raw)), nil }
+
+// RawManifest returns a copy so callers cannot mutate retained index bytes.
+func (i *localIndex) RawManifest() ([]byte, error) { return bytes.Clone(i.raw), nil }
+
+// IndexManifest parses the retained bytes for descriptor traversal.
+func (i *localIndex) IndexManifest() (*v1.IndexManifest, error) {
+ return v1.ParseIndexManifest(bytes.NewReader(i.raw))
+}
+
+// descriptor restricts child lookup to a bounded descriptor declared by this index.
+func (i *localIndex) descriptor(hash v1.Hash) (v1.Descriptor, error) {
+ manifest, err := i.IndexManifest()
+ if err != nil {
+ return v1.Descriptor{}, err
+ }
+ for _, descriptor := range manifest.Manifests {
+ if descriptor.Digest == hash {
+ return descriptor, validateDescriptor(descriptor, maxMetadataSize)
+ }
+ }
+ return v1.Descriptor{}, fmt.Errorf("OCI descriptor %s not found", hash)
+}
+
+// Image opens and verifies a child manifest before adapting its lazy layers.
+func (i *localIndex) Image(hash v1.Hash) (v1.Image, error) {
+ descriptor, err := i.descriptor(hash)
+ if err != nil {
+ return nil, err
+ }
+ name, err := blobName(hash)
+ if err != nil {
+ return nil, err
+ }
+ raw, err := readLocal(i.ctx, i.path, name, maxMetadataSize)
+ if err != nil {
+ return nil, err
+ }
+ if err := checkBytes(raw, descriptor.Digest, descriptor.Size); err != nil {
+ return nil, err
+ }
+ return partial.CompressedToImage(&localImage{path: i.path, raw: raw, descriptor: descriptor, ctx: i.ctx})
+}
+
+// ImageIndex opens and verifies a nested index within the same root.
+func (i *localIndex) ImageIndex(hash v1.Hash) (v1.ImageIndex, error) {
+ descriptor, err := i.descriptor(hash)
+ if err != nil {
+ return nil, err
+ }
+ name, err := blobName(hash)
+ if err != nil {
+ return nil, err
+ }
+ raw, err := readLocal(i.ctx, i.path, name, maxMetadataSize)
+ if err != nil {
+ return nil, err
+ }
+ if err := checkBytes(raw, descriptor.Digest, descriptor.Size); err != nil {
+ return nil, err
+ }
+ return &localIndex{path: i.path, raw: raw, ctx: i.ctx}, nil
+}
+
+// localImage keeps verified manifest bytes while config and layer files stay lazy.
+type localImage struct {
+ // path anchors all child object reads.
+ path string
+ // raw contains manifest bytes verified against descriptor.
+ raw []byte
+ // descriptor records the parent index's image identity.
+ descriptor v1.Descriptor
+ // ctx propagates cancellation to local file reads.
+ ctx context.Context
+}
+
+// MediaType preserves the manifest media type declared by the parent index.
+func (i *localImage) MediaType() (mediatypes.MediaType, error) { return i.descriptor.MediaType, nil }
+
+// RawManifest returns a copy of the previously verified manifest bytes.
+func (i *localImage) RawManifest() ([]byte, error) { return bytes.Clone(i.raw), nil }
+
+// RawConfigFile bounds the declared config object; callers verify its digest.
+func (i *localImage) RawConfigFile() ([]byte, error) {
+ manifest, err := v1.ParseManifest(bytes.NewReader(i.raw))
+ if err != nil {
+ return nil, err
+ }
+ if err := validateDescriptor(manifest.Config, maxMetadataSize); err != nil {
+ return nil, err
+ }
+ name, err := blobName(manifest.Config.Digest)
+ if err != nil {
+ return nil, err
+ }
+ return readLocal(i.ctx, i.path, name, maxMetadataSize)
+}
+
+// LayerByDigest adapts only layers declared by this manifest through fileLayer.
+func (i *localImage) LayerByDigest(hash v1.Hash) (partial.CompressedLayer, error) {
+ manifest, err := v1.ParseManifest(bytes.NewReader(i.raw))
+ if err != nil {
+ return nil, err
+ }
+ for _, descriptor := range manifest.Layers {
+ if descriptor.Digest == hash {
+ object, err := blobName(hash)
+ if err != nil {
+ return nil, err
+ }
+ return &fileLayer{path: i.path, object: object, descriptor: descriptor, ctx: i.ctx}, nil
+ }
+ }
+ return nil, fmt.Errorf("OCI layer %s not found", hash)
+}
diff --git a/images/source/registry.go b/images/source/registry.go
new file mode 100644
index 0000000..e98d088
--- /dev/null
+++ b/images/source/registry.go
@@ -0,0 +1,133 @@
+package source
+
+import (
+ "context"
+ "errors"
+ "io"
+ "strings"
+
+ "github.com/google/go-containerregistry/pkg/authn"
+ "github.com/google/go-containerregistry/pkg/name"
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ "github.com/google/go-containerregistry/pkg/v1/remote"
+ "github.com/google/go-containerregistry/pkg/v1/remote/transport"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/types"
+)
+
+// NewRegistry parses a registry reference and returns its normalized storage name.
+// Resolve performs platform-specific requests using the default credential
+// keychain; blob reads remain lazy. User-facing errors omit transport diagnostics
+// that may contain credentials, while Unwrap preserves their causes.
+func NewRegistry(reference string) (images.Source, string, error) {
+ // URL userinfo must never reach parser diagnostics or stored image names.
+ if strings.Contains(reference, "://") {
+ return nil, "", invalidSource("use an OCI reference without a URL scheme or credentials")
+ }
+ parsed, err := name.ParseReference(reference)
+ if err != nil {
+ return nil, "", errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, &safeRegistryError{cause: err, message: "invalid OCI registry reference"})
+ }
+ source := &resolvedSource{limits: images.DefaultLimits()}
+ source.resolve = func(ctx context.Context, platform types.Platform) (v1.Image, error) {
+ image, err := remote.Image(parsed, remote.WithContext(ctx), remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithPlatform(v1.Platform{OS: platform.OS, Architecture: platform.Architecture}))
+ if err != nil {
+ return nil, registryError(err)
+ }
+ return ®istryImage{Image: image}, nil
+ }
+ return source, parsed.String(), nil
+}
+
+// safeRegistryError separates a safe display message from diagnostic error identity.
+type safeRegistryError struct {
+ // cause remains accessible to errors.Is and errors.As.
+ cause error
+ // message is controlled locally rather than copied from transport.
+ message string
+}
+
+// Error exposes only the locally supplied, credential-safe message.
+func (e *safeRegistryError) Error() string { return e.message }
+
+// Unwrap retains the underlying cause without displaying its text.
+func (e *safeRegistryError) Unwrap() error { return e.cause }
+
+// registryError preserves cancellation, maps HTTP 404 to not found, and wraps
+// other failures with a safe availability message.
+func registryError(err error) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return err
+ }
+ var transportErr *transport.Error
+ if errors.As(err, &transportErr) && transportErr.StatusCode == 404 {
+ return errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, &safeRegistryError{cause: err, message: "registry image or blob not found"})
+ }
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, &safeRegistryError{cause: err, message: "registry request failed; check connectivity and credentials"})
+}
+
+// registryImage sanitizes lazy metadata and layer lookup failures because registry
+// I/O continues after remote.Image returns.
+type registryImage struct {
+ // Image retains library behavior for operations not overridden here.
+ v1.Image
+}
+
+// RawManifest reads manifest bytes with sanitized registry errors.
+func (i *registryImage) RawManifest() ([]byte, error) {
+ b, e := i.Image.RawManifest()
+ return b, registryError(e)
+}
+
+// RawConfigFile reads config bytes with sanitized registry errors.
+func (i *registryImage) RawConfigFile() ([]byte, error) {
+ b, e := i.Image.RawConfigFile()
+ return b, registryError(e)
+}
+
+// LayerByDigest wraps lazy layer reads as well as lookup failures.
+func (i *registryImage) LayerByDigest(h v1.Hash) (v1.Layer, error) {
+ layer, err := i.Image.LayerByDigest(h)
+ if err != nil {
+ return nil, registryError(err)
+ }
+ return ®istryLayer{Layer: layer}, nil
+}
+
+// registryLayer extends safe error presentation to encoded layer downloads.
+type registryLayer struct {
+ // Layer supplies the underlying registry-backed layer operations.
+ v1.Layer
+}
+
+// Compressed wraps stream reads and cleanup, not just the opening request.
+func (l *registryLayer) Compressed() (io.ReadCloser, error) {
+ reader, err := l.Layer.Compressed()
+ if err != nil {
+ return nil, registryError(err)
+ }
+ return ®istryReader{ReadCloser: reader}, nil
+}
+
+// registryReader sanitizes failures that occur after an HTTP response is opened.
+type registryReader struct {
+ // ReadCloser owns the original registry response stream.
+ io.ReadCloser
+}
+
+// Read preserves EOF so streaming digest checks can finish normally.
+func (r *registryReader) Read(p []byte) (int, error) {
+ n, err := r.ReadCloser.Read(p)
+ if errors.Is(err, io.EOF) {
+ return n, err
+ }
+ return n, registryError(err)
+}
+
+// Close sanitizes transport failures while releasing the response stream.
+func (r *registryReader) Close() error { return registryError(r.ReadCloser.Close()) }
diff --git a/images/source/source.go b/images/source/source.go
new file mode 100644
index 0000000..50dbb8b
--- /dev/null
+++ b/images/source/source.go
@@ -0,0 +1,320 @@
+// Package source adapts registry images, OCI layouts, and Docker save archives to
+// the images.Source contract. Metadata selection and validation happen during
+// Resolve; layer content is checked while the importer consumes OpenLayer streams.
+// It owns source decoding and staging, while images owns artifact publication.
+package source
+
+import (
+ "bufio"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "hash"
+ "io"
+ "sync"
+
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ mediatypes "github.com/google/go-containerregistry/pkg/v1/types"
+ "github.com/klauspost/compress/zstd"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/types"
+)
+
+// maxMetadataSize bounds accepted manifest, config, and index object sizes.
+const maxMetadataSize = 16 << 20
+
+// resolvedLayer connects a stored layer descriptor to its expected unpacked hash.
+type resolvedLayer struct {
+ // layer supplies the encoded bytes through the source adapter.
+ layer v1.Layer
+ // diffID is the config hash of the decoded tar stream.
+ diffID types.Digest
+ // mediaType selects gzip, zstd, or raw decoding.
+ mediaType mediatypes.MediaType
+}
+
+// resolvedSource shares metadata and streaming checks across all source formats.
+type resolvedSource struct {
+ // resolve selects the format-specific image.
+ resolve func(context.Context, types.Platform) (v1.Image, error)
+ // mu protects replacement and lookup of the resolved layer map.
+ mu sync.RWMutex
+ // layers is populated only after successful metadata validation.
+ layers map[types.Digest]resolvedLayer
+ // limits bounds encoded and unpacked layer content.
+ limits images.Limits
+}
+
+var _ images.Source = (*resolvedSource)(nil)
+
+// Resolve validates the selected manifest, config, platform, and layer descriptors
+// before publishing the layer lookup used by OpenLayer. Encoded digest, size, and
+// unpacked diffID are checked during layer consumption, even when an adapter has
+// already inspected encoded objects while constructing the image.
+func (s *resolvedSource) Resolve(ctx context.Context, platform types.Platform) (types.Manifest, error) {
+ if err := ctx.Err(); err != nil {
+ return types.Manifest{}, err
+ }
+ image, err := s.resolve(ctx, platform)
+ if err != nil {
+ return types.Manifest{}, sourceError(err)
+ }
+ raw, err := image.RawManifest()
+ if err != nil {
+ return types.Manifest{}, sourceError(err)
+ }
+ if len(raw) > maxMetadataSize {
+ return types.Manifest{}, invalidSource("manifest exceeds metadata limit")
+ }
+ manifest, err := v1.ParseManifest(bytes.NewReader(raw))
+ if err != nil {
+ return types.Manifest{}, invalidSource("invalid OCI manifest: %v", err)
+ }
+ manifestHash, err := image.Digest()
+ if err != nil {
+ return types.Manifest{}, sourceError(err)
+ }
+ if err := checkBytes(raw, manifestHash, int64(len(raw))); err != nil {
+ return types.Manifest{}, err
+ }
+ digest, err := types.ParseDigest(manifestHash.String())
+ if err != nil {
+ return types.Manifest{}, invalidSource("invalid manifest digest: %v", err)
+ }
+ if manifest.SchemaVersion != 2 {
+ return types.Manifest{}, invalidSource("unsupported OCI manifest schema version")
+ }
+ if manifest.Config.MediaType != mediatypes.OCIConfigJSON && manifest.Config.MediaType != mediatypes.DockerConfigJSON {
+ return types.Manifest{}, invalidSource("unsupported OCI config media type")
+ }
+ if err := validateDescriptor(manifest.Config, maxMetadataSize); err != nil {
+ return types.Manifest{}, err
+ }
+ configRaw, err := image.RawConfigFile()
+ if err != nil {
+ return types.Manifest{}, sourceError(err)
+ }
+ if err := checkBytes(configRaw, manifest.Config.Digest, manifest.Config.Size); err != nil {
+ return types.Manifest{}, err
+ }
+ config, err := v1.ParseConfigFile(bytes.NewReader(configRaw))
+ if err != nil {
+ return types.Manifest{}, invalidSource("invalid OCI config: %v", err)
+ }
+ if config.OS != platform.OS || config.Architecture != platform.Architecture {
+ return types.Manifest{}, invalidSource("image platform %s/%s does not match %s/%s", config.OS, config.Architecture, platform.OS, platform.Architecture)
+ }
+ if config.RootFS.Type != "layers" || len(config.RootFS.DiffIDs) != len(manifest.Layers) {
+ return types.Manifest{}, invalidSource("config rootfs does not match manifest layers")
+ }
+ bootProfile := types.BootProfile(config.Config.Labels[types.ImageBootProfileLabel])
+ layers := make(map[types.Digest]resolvedLayer)
+ descriptors := make([]types.Descriptor, len(manifest.Layers))
+ for position, desc := range manifest.Layers {
+ if err := validateDescriptor(desc, s.limits.LayerSize); err != nil {
+ return types.Manifest{}, err
+ }
+ switch desc.MediaType {
+ case mediatypes.OCILayer, mediatypes.OCIUncompressedLayer, mediatypes.OCILayerZStd, mediatypes.DockerLayer, mediatypes.DockerUncompressedLayer:
+ default:
+ return types.Manifest{}, invalidSource("unsupported layer media type %s", desc.MediaType)
+ }
+ digest, err := types.ParseDigest(desc.Digest.String())
+ if err != nil {
+ return types.Manifest{}, invalidSource("invalid layer digest: %v", err)
+ }
+ diffID, err := types.ParseDigest(config.RootFS.DiffIDs[position].String())
+ if err != nil {
+ return types.Manifest{}, invalidSource("invalid layer diffID: %v", err)
+ }
+ layer, err := image.LayerByDigest(desc.Digest)
+ if err != nil {
+ return types.Manifest{}, sourceError(err)
+ }
+ if existing, ok := layers[digest]; ok && existing.diffID != diffID {
+ return types.Manifest{}, invalidSource("repeated layer has inconsistent diffID")
+ }
+ layers[digest] = resolvedLayer{layer: layer, diffID: diffID, mediaType: desc.MediaType}
+ descriptors[position] = types.Descriptor{Digest: digest, Size: desc.Size}
+ }
+ s.mu.Lock()
+ s.layers = layers
+ s.mu.Unlock()
+ return types.Manifest{Digest: digest, Platform: platform, BootProfile: bootProfile, Layers: descriptors}, nil
+}
+
+// OpenLayer opens a previously resolved layer as a decoded tar stream. The caller
+// must consume it to EOF to complete both hash checks, then close it on all paths.
+// Closing an unread stream releases resources without validating the remainder.
+//
+// encoded bytes --> size + digest check --> decoder --> limit + diffID check
+// ^ |
+// +--- drain encoded remainder <---+ EOF
+func (s *resolvedSource) OpenLayer(ctx context.Context, descriptor types.Descriptor) (io.ReadCloser, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ s.mu.RLock()
+ layer, ok := s.layers[descriptor.Digest]
+ s.mu.RUnlock()
+ if !ok {
+ return nil, invalidSource("layer %s was not resolved", descriptor.Digest)
+ }
+ raw, err := layer.layer.Compressed()
+ if err != nil {
+ return nil, sourceError(err)
+ }
+ compressed := &checkedReader{ctx: ctx, reader: raw, hash: sha256.New(), expected: descriptor.Digest, limit: s.limits.LayerSize, size: descriptor.Size}
+ buffered := bufio.NewReader(compressed)
+ var input io.Reader = buffered
+ closeDecoder := func() error { return nil }
+ switch layer.mediaType {
+ case mediatypes.OCILayer, mediatypes.DockerLayer:
+ decoder, err := gzip.NewReader(buffered)
+ if err != nil {
+ return nil, errors.Join(sourceError(err), raw.Close())
+ }
+ input, closeDecoder = decoder, decoder.Close
+ case mediatypes.OCILayerZStd:
+ decoder, err := zstd.NewReader(buffered, zstd.WithDecoderMaxMemory(uint64(max(1, s.limits.UnpackedSize))), zstd.WithDecoderConcurrency(1))
+ if err != nil {
+ return nil, errors.Join(sourceError(err), raw.Close())
+ }
+ input = decoder
+ closeDecoder = func() error { decoder.Close(); return nil }
+ }
+ unpacked := &checkedReader{ctx: ctx, reader: input, hash: sha256.New(), expected: layer.diffID, limit: s.limits.UnpackedSize, size: -1}
+ return &layerReader{unpacked: unpacked, buffered: buffered, raw: raw, closeDecoder: closeDecoder}, nil
+}
+
+// checkedReader enforces a stream budget and verifies its identity only at EOF.
+// A terminal error is retained so retrying Read cannot bypass a failed check.
+type checkedReader struct {
+ // ctx is checked before each underlying read.
+ ctx context.Context
+ // reader supplies encoded bytes or the decoded tar stream.
+ reader io.Reader
+ // hash accumulates every byte returned by reader.
+ hash hash.Hash
+ // expected is the stored digest or unpacked diffID.
+ expected types.Digest
+ // limit is the maximum byte count; Read probes one extra byte for overflow.
+ limit int64
+ // size is the declared byte count, or -1 when no count is declared.
+ size int64
+ // read tracks bytes consumed for the size and budget checks.
+ read int64
+ // lastErr prevents reads after EOF, cancellation, or corruption.
+ lastErr error
+}
+
+// Read detects limit violations immediately and digest or size mismatches at EOF.
+func (r *checkedReader) Read(p []byte) (n int, returnErr error) {
+ if r.lastErr != nil {
+ return 0, r.lastErr
+ }
+ defer func() {
+ if returnErr != nil {
+ r.lastErr = returnErr
+ }
+ }()
+ if err := r.ctx.Err(); err != nil {
+ return 0, err
+ }
+ if int64(len(p)) > r.limit-r.read+1 {
+ p = p[:r.limit-r.read+1]
+ }
+ n, err := r.reader.Read(p)
+ r.read += int64(n)
+ if _, hashErr := r.hash.Write(p[:n]); hashErr != nil {
+ return n, hashErr
+ }
+ if r.read > r.limit {
+ return n, invalidSource("layer exceeds %d bytes", r.limit)
+ }
+ if errors.Is(err, io.EOF) {
+ if fmt.Sprintf("sha256:%x", r.hash.Sum(nil)) != r.expected.String() || (r.size >= 0 && r.size != r.read) {
+ return n, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeDigestMismatch, fmt.Errorf("layer %s digest or size mismatch", r.expected))
+ }
+ }
+ if err != nil && !errors.Is(err, io.EOF) {
+ err = sourceError(err)
+ }
+ return n, err
+}
+
+// layerReader couples decoder ownership with encoded and unpacked verification.
+type layerReader struct {
+ // unpacked verifies the decoded stream's diffID.
+ unpacked *checkedReader
+ // buffered retains encoded bytes read ahead by the decoder.
+ buffered *bufio.Reader
+ // raw owns the underlying file or registry response.
+ raw io.ReadCloser
+ // closeDecoder releases gzip or zstd state, if present.
+ closeDecoder func() error
+}
+
+// Read drains encoded read-ahead at decoded EOF so its digest check also completes.
+func (r *layerReader) Read(p []byte) (int, error) {
+ n, err := r.unpacked.Read(p)
+ if errors.Is(err, io.EOF) {
+ if _, drainErr := io.Copy(io.Discard, r.buffered); drainErr != nil {
+ return n, drainErr
+ }
+ }
+ return n, err
+}
+
+// Close releases both decoder and input, preserving either cleanup failure.
+func (r *layerReader) Close() error { return errors.Join(r.closeDecoder(), r.raw.Close()) }
+
+// validateDescriptor accepts bounded SHA-256 objects and rejects external URLs.
+func validateDescriptor(desc v1.Descriptor, limit int64) error {
+ if _, err := types.ParseDigest(desc.Digest.String()); err != nil {
+ return invalidSource("invalid descriptor: %v", err)
+ }
+ if desc.Size < 0 || desc.Size > limit {
+ return invalidSource("descriptor size %d exceeds limit %d", desc.Size, limit)
+ }
+ if len(desc.URLs) != 0 {
+ return invalidSource("external descriptor URLs are unsupported")
+ }
+ return nil
+}
+
+// checkBytes verifies a bounded metadata object against its declared identity.
+func checkBytes(raw []byte, expected v1.Hash, size int64) error {
+ if expected.Algorithm != "sha256" || fmt.Sprintf("%x", sha256.Sum256(raw)) != expected.Hex || int64(len(raw)) != size {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeDigestMismatch, fmt.Errorf("OCI object %s digest or size mismatch", expected))
+ }
+ return nil
+}
+
+// invalidSource classifies malformed or unsupported input as an argument error.
+func invalidSource(format string, args ...any) error {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf(format, args...))
+}
+
+// sourceError preserves cancellation and classified errors, distinguishes gzip
+// corruption, and treats other source I/O failures as unavailable artifacts.
+func sourceError(err error) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return err
+ }
+ if errors.Is(err, gzip.ErrChecksum) || errors.Is(err, gzip.ErrHeader) {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeDigestMismatch, err)
+ }
+ if _, ok := errdefs.CodeOf(err); ok {
+ return err
+ }
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err)
+}
diff --git a/images/source/source_test.go b/images/source/source_test.go
new file mode 100644
index 0000000..ec47ef3
--- /dev/null
+++ b/images/source/source_test.go
@@ -0,0 +1,307 @@
+package source
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/google/go-containerregistry/pkg/name"
+ "github.com/google/go-containerregistry/pkg/registry"
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ "github.com/google/go-containerregistry/pkg/v1/layout"
+ "github.com/google/go-containerregistry/pkg/v1/remote"
+ mediatypes "github.com/google/go-containerregistry/pkg/v1/types"
+ "github.com/klauspost/compress/zstd"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ "github.com/kumabox/kumabox/types"
+)
+
+func copyFixture(t *testing.T) string {
+ t.Helper()
+ root := t.TempDir()
+ if err := os.CopyFS(root, os.DirFS("../../testdata/oci-layout")); err != nil {
+ t.Fatal(err)
+ }
+ return root
+}
+
+func TestSourceChecksOCIObjects(t *testing.T) {
+ for _, kind := range []string{"valid", "manifest", "config", "layer", "platform", "symlink"} {
+ t.Run(kind, func(t *testing.T) {
+ root := copyFixture(t)
+ indexSource, err := NewLayout(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ source := indexSource.(*resolvedSource)
+ platform := types.Platform{OS: "linux", Architecture: "amd64"}
+ resolved, err := source.resolve(t.Context(), platform)
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest, err := resolved.Manifest()
+ if err != nil {
+ t.Fatal(err)
+ }
+ digest, err := resolved.Digest()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var object string
+ switch kind {
+ case "manifest":
+ object = digest.Hex
+ case "config":
+ object = manifest.Config.Digest.Hex
+ case "layer":
+ object = manifest.Layers[0].Digest.Hex
+ case "platform":
+ platform.Architecture = "arm64"
+ case "symlink":
+ if err := os.Symlink(t.TempDir(), filepath.Join(root, "linked")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if object != "" {
+ path := filepath.Join(root, "blobs", "sha256", object)
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if kind == "layer" {
+ raw[9] ^= 1
+ } else {
+ raw = append(raw, '\n')
+ }
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := source.Resolve(t.Context(), platform)
+ if err == nil {
+ reader, openErr := source.OpenLayer(t.Context(), got.Layers[0])
+ err = openErr
+ if openErr == nil {
+ _, readErr := io.Copy(io.Discard, reader)
+ err = errors.Join(readErr, reader.Close())
+ }
+ }
+ if kind == "valid" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ expected := errdefs.CodeDigestMismatch
+ if kind == "platform" || kind == "symlink" {
+ expected = errdefs.CodeInvalidArgument
+ }
+ if code, ok := errdefs.CodeOf(err); !ok || code != expected {
+ t.Fatalf("error = %v, want %s", err, expected)
+ }
+ })
+ }
+}
+
+func TestSourceBoundsDecompressionAndCancellation(t *testing.T) {
+ limits := images.DefaultLimits()
+ limits.UnpackedSize = 64
+ source, err := NewLayoutWithLimits(copyFixture(t), limits)
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest, err := source.Resolve(t.Context(), types.Platform{OS: "linux", Architecture: "amd64"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ reader, err := source.OpenLayer(t.Context(), manifest.Layers[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, readErr := io.Copy(io.Discard, reader)
+ err = errors.Join(readErr, reader.Close())
+ if code, _ := errdefs.CodeOf(err); code != errdefs.CodeInvalidArgument {
+ t.Fatalf("limit error = %v", err)
+ }
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ if _, err := source.OpenLayer(ctx, manifest.Layers[0]); !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancellation = %v", err)
+ }
+}
+
+func TestRegistryErrorsDoNotExposeCredentials(t *testing.T) {
+ for _, reference := range []string{"https://user:secret@example.com/image", "user:secret@example.com/image"} {
+ _, _, err := NewRegistry(reference)
+ if err == nil || bytes.Contains([]byte(err.Error()), []byte("secret")) {
+ t.Fatalf("unsafe parser error = %v", err)
+ }
+ }
+ cause := errors.New("Authorization: Bearer secret")
+ err := registryError(cause)
+ if bytes.Contains([]byte(err.Error()), []byte("secret")) || !errors.Is(err, cause) {
+ t.Fatalf("unsafe registry error = %v", err)
+ }
+}
+
+func writeLayout(t *testing.T, compressed, unpacked []byte, media mediatypes.MediaType) string {
+ t.Helper()
+ root := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(root, "blobs", "sha256"), 0o750); err != nil {
+ t.Fatal(err)
+ }
+ put := func(raw []byte, media mediatypes.MediaType) v1.Descriptor {
+ digest := fmt.Sprintf("%x", sha256.Sum256(raw))
+ if err := os.WriteFile(filepath.Join(root, "blobs", "sha256", digest), raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return v1.Descriptor{Digest: v1.Hash{Algorithm: "sha256", Hex: digest}, Size: int64(len(raw)), MediaType: media}
+ }
+ encode := func(value any) []byte {
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+ }
+ layer := put(compressed, media)
+ config := put(encode(v1.ConfigFile{
+ Architecture: "amd64", OS: "linux",
+ RootFS: v1.RootFS{Type: "layers", DiffIDs: []v1.Hash{{Algorithm: "sha256", Hex: fmt.Sprintf("%x", sha256.Sum256(unpacked))}}},
+ Config: v1.Config{Labels: map[string]string{types.ImageBootProfileLabel: string(types.BootProfileOverlayV1)}},
+ }), mediatypes.OCIConfigJSON)
+ manifest := put(encode(v1.Manifest{SchemaVersion: 2, MediaType: mediatypes.OCIManifestSchema1, Config: config, Layers: []v1.Descriptor{layer}}), mediatypes.OCIManifestSchema1)
+ if err := os.WriteFile(filepath.Join(root, "index.json"), encode(v1.IndexManifest{SchemaVersion: 2, MediaType: mediatypes.OCIImageIndex, Manifests: []v1.Descriptor{manifest}}), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "oci-layout"), []byte(`{"imageLayoutVersion":"1.0.0"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return root
+}
+
+func TestSourceSupportsLayerCompressionAndChecksDiffID(t *testing.T) {
+ unpacked := bootTar(t, []*tar.Header{{Name: "boot/vmlinuz", Typeflag: tar.TypeReg, Size: 8}})
+ for _, media := range []mediatypes.MediaType{mediatypes.OCIUncompressedLayer, mediatypes.OCILayer, mediatypes.OCILayerZStd} {
+ t.Run(string(media), func(t *testing.T) {
+ compressed := unpacked
+ switch media {
+ case mediatypes.OCILayer:
+ var buffer bytes.Buffer
+ writer := gzip.NewWriter(&buffer)
+ if _, err := writer.Write(unpacked); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ compressed = buffer.Bytes()
+ case mediatypes.OCILayerZStd:
+ writer, err := zstd.NewWriter(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ compressed = writer.EncodeAll(unpacked, nil)
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, valid := range []bool{true, false} {
+ diff := unpacked
+ if !valid {
+ diff = []byte("incorrect diffID")
+ }
+ source, err := NewLayout(writeLayout(t, compressed, diff, media))
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest, err := source.Resolve(t.Context(), types.Platform{OS: "linux", Architecture: "amd64"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if manifest.BootProfile != types.BootProfileOverlayV1 {
+ t.Fatalf("boot profile = %q", manifest.BootProfile)
+ }
+ reader, err := source.OpenLayer(t.Context(), manifest.Layers[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, readErr := io.ReadAll(reader)
+ err = errors.Join(readErr, reader.Close())
+ if valid {
+ if err != nil || !bytes.Equal(got, unpacked) {
+ t.Fatalf("decoded layer differs: %v", err)
+ }
+ } else if code, _ := errdefs.CodeOf(err); code != errdefs.CodeDigestMismatch {
+ t.Fatalf("wrong diffID accepted: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestRegistrySourcePullsFromHTTPRegistry(t *testing.T) {
+ server := httptest.NewServer(registry.New(registry.Logger(log.New(io.Discard, "", 0))))
+ defer server.Close()
+ ref, err := name.NewTag(strings.TrimPrefix(server.URL, "http://")+"/tiny:v1", name.Insecure)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fixture, err := layout.FromPath("../../testdata/oci-layout")
+ if err != nil {
+ t.Fatal(err)
+ }
+ index, err := fixture.ImageIndex()
+ if err != nil {
+ t.Fatal(err)
+ }
+ image, err := imageForPlatform(index, types.Platform{OS: "linux", Architecture: "amd64"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := remote.Write(ref, image, remote.WithContext(t.Context())); err != nil {
+ t.Fatal(err)
+ }
+ source, normalized, err := NewRegistry(ref.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if normalized != ref.String() {
+ t.Fatalf("reference = %s", normalized)
+ }
+ manifest, err := source.Resolve(t.Context(), types.Platform{OS: "linux", Architecture: "amd64"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ reader, err := source.OpenLayer(t.Context(), manifest.Layers[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, readErr := io.Copy(io.Discard, reader)
+ if err := errors.Join(readErr, reader.Close()); err != nil {
+ t.Fatal(err)
+ }
+ missing, _, err := NewRegistry(strings.TrimPrefix(server.URL, "http://") + "/absent:v1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := missing.Resolve(t.Context(), manifest.Platform); err == nil {
+ t.Fatal("missing registry image succeeded")
+ } else if code, _ := errdefs.CodeOf(err); code != errdefs.CodeNotFound {
+ t.Fatalf("missing image = %v", err)
+ }
+}
diff --git a/images/verify.go b/images/verify.go
new file mode 100644
index 0000000..71f7f30
--- /dev/null
+++ b/images/verify.go
@@ -0,0 +1,180 @@
+package images
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/kumabox/kumabox/errdefs"
+ filelock "github.com/kumabox/kumabox/lock/flock"
+ "github.com/kumabox/kumabox/types"
+)
+
+// ImageResolver is the read-only catalog contract required for verification.
+type ImageResolver interface {
+ // Resolve reads aliases or manifest references from committed metadata.
+ Resolve(context.Context, string) (types.Image, error)
+}
+
+// Guard holds image artifact locks while a consumer checks and commits a reference.
+type Guard struct {
+ // paths locates artifacts and the locks shared with import and removal.
+ paths Paths
+ // catalog resolves image facts before and after lock acquisition.
+ catalog ImageResolver
+}
+
+// NewGuard constructs an image guard for lifecycle consumers.
+func NewGuard(paths Paths, catalog ImageResolver) *Guard {
+ return &Guard{paths: paths, catalog: catalog}
+}
+
+// WithAvailable checks regular-file presence and size while holding layer locks
+// across use. Full content hashing remains the explicit Verify operation so create
+// latency does not grow with total image bytes.
+func (g *Guard) WithAvailable(ctx context.Context, reference string, use func(types.Image) error) (types.Image, error) {
+ return g.withLocked(ctx, reference, availableImage, use)
+}
+
+// withLocked closes the remove/use race by resolving again after lock acquisition
+// and retaining those locks until the consumer commits its reference.
+func (g *Guard) withLocked(ctx context.Context, reference string, check func(context.Context, Paths, types.Image) error, use func(types.Image) error) (result types.Image, returnErr error) {
+ if g == nil || g.catalog == nil || use == nil {
+ return types.Image{}, errors.New("image guard is not configured")
+ }
+ image, err := g.catalog.Resolve(ctx, reference)
+ if err != nil {
+ return types.Image{}, err
+ }
+ expected := image.ManifestDigest
+ lockPaths := make([]string, len(image.Layers))
+ for pos, layer := range image.Layers {
+ lockPaths[pos] = g.paths.Lock(layer.SourceDigest)
+ }
+ var locks filelock.Set
+ if err := locks.Lock(ctx, lockPaths...); err != nil {
+ return types.Image{}, fmt.Errorf("lock image layers: %w", err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, locks.Unlock(context.WithoutCancel(ctx))) }()
+ image, err = g.catalog.Resolve(ctx, reference)
+ if err != nil {
+ return types.Image{}, err
+ }
+ if image.ManifestDigest != expected {
+ return types.Image{}, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("image binding changed while waiting for layer locks; retry"))
+ }
+ if err := check(ctx, g.paths, image); err != nil {
+ return types.Image{}, err
+ }
+ if err := use(image); err != nil {
+ return types.Image{}, err
+ }
+ return image, nil
+}
+
+// Verify hashes every EROFS and extracted boot artifact and checks derived image
+// facts. It holds source digest locks to coordinate with publication and deletion.
+// The reference is resolved again after waiting for locks to detect removal.
+func Verify(ctx context.Context, paths Paths, catalog ImageResolver, reference string) (result types.Image, returnErr error) {
+ image, err := NewGuard(paths, catalog).withLocked(ctx, reference, verifyImage, func(types.Image) error { return nil })
+ return image, errdefs.Context(err, "verify image", reference, "artifacts", "re-import the image", false)
+}
+
+// availableImage checks bounded metadata and filesystem facts without reading full artifacts.
+func availableImage(ctx context.Context, paths Paths, image types.Image) error {
+ for _, layer := range image.Layers {
+ if err := availableFile(ctx, paths.EROFS(layer.SourceDigest), layer.Size); err != nil {
+ return err
+ }
+ for _, file := range layer.BootFiles {
+ path, err := paths.BootFile(layer.SourceDigest, file.Name)
+ if err != nil {
+ return err
+ }
+ if err := availableFile(ctx, path, file.Size); err != nil {
+ return err
+ }
+ }
+ }
+ return validateFacts(image)
+}
+
+// availableFile rejects missing, replaced, or truncated managed artifacts.
+func availableFile(ctx context.Context, path string, expectedSize int64) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ info, err := os.Lstat(path)
+ if err != nil {
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err)
+ }
+ if !info.Mode().IsRegular() || info.Size() != expectedSize {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("artifact %s is not a regular %d-byte file", path, expectedSize))
+ }
+ return nil
+}
+
+// verifyImage performs the explicit byte-for-byte integrity operation.
+func verifyImage(ctx context.Context, paths Paths, image types.Image) error {
+ for _, layer := range image.Layers {
+ if err := verifyLayer(ctx, paths, layer); err != nil {
+ return err
+ }
+ }
+ return validateFacts(image)
+}
+
+// validateFacts proves that ordered layers still derive the committed boot and size.
+func validateFacts(image types.Image) error {
+ var total int64
+ for _, layer := range image.Layers {
+ total += layer.Size
+ }
+ boot, err := SelectBoot(image.Layers)
+ // Profile is declared by the image config rather than derived from layer
+ // filenames, so preserve the committed declaration for the consistency check.
+ boot.Profile = image.Boot.Profile
+ if err != nil || boot != image.Boot || total != image.Size {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("image layer mapping or boot selection is inconsistent"))
+ }
+ return nil
+}
+
+// verifyLayer proves that a committed mapping still matches all managed files.
+// Imports use the same check before authorizing cache reuse.
+func verifyLayer(ctx context.Context, paths Paths, layer types.Layer) error {
+ if layer.SourceDigest.IsZero() || layer.EROFSDigest.IsZero() || layer.Size <= 0 {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("invalid layer metadata"))
+ }
+ if err := verifyFile(ctx, paths.EROFS(layer.SourceDigest), layer.EROFSDigest, layer.Size); err != nil {
+ return err
+ }
+ seen := make(map[string]bool)
+ for _, file := range layer.BootFiles {
+ path, err := paths.BootFile(layer.SourceDigest, file.Name)
+ if err != nil {
+ return err
+ }
+ if seen[file.Name] || file.Digest.IsZero() || file.Size <= 0 {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("invalid boot file metadata"))
+ }
+ seen[file.Name] = true
+ if err := verifyFile(ctx, path, file.Digest, file.Size); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// verifyFile requires both identity and byte size to match committed metadata.
+func verifyFile(ctx context.Context, path string, expected types.Digest, expectedSize int64) error {
+ digest, size, err := digestFileContext(ctx, path)
+ if err != nil {
+ return err
+ }
+ if digest != expected || size != expectedSize {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("artifact %s does not match metadata", path))
+ }
+ return nil
+}
diff --git a/internal/agent/client/client.go b/internal/agent/client/client.go
deleted file mode 100644
index 2994ddb..0000000
--- a/internal/agent/client/client.go
+++ /dev/null
@@ -1,297 +0,0 @@
-// Package client implements the host-side KumaBox guest agent protocol.
-package client
-
-import (
- "bufio"
- "context"
- "crypto/rand"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
- "github.com/kumabox/kumabox/internal/fileutil"
-)
-
-const (
- AgentPort = protocol.AgentPort
- hybridVsockReplyMax = 256
- DefaultPingTimeout = 60 * time.Second
- CapabilityExec = protocol.CapabilityExec
- CapabilityExecTTY = protocol.CapabilityExecTTY
- CapabilityIdentity = protocol.CapabilityIdentity
- CapabilityReseed = protocol.CapabilityReseed
- ReseedEntropyBytes = 32
-)
-
-var ErrNotReady = errors.New("AGENT_NOT_READY")
-
-type PingPongResponse struct {
- OK bool `json:"ok"`
- Version string `json:"version,omitempty"`
- OS string `json:"os,omitempty"`
- Hostname string `json:"hostname,omitempty"`
- Capabilities []string `json:"capabilities,omitempty"`
- Error string `json:"error,omitempty"`
-}
-
-func (r *PingPongResponse) Supports(capability protocol.Capability) bool {
- if r == nil {
- return false
- }
- for _, candidate := range r.Capabilities {
- if candidate == string(capability) {
- return true
- }
- }
- return false
-}
-
-type ExecRequest struct {
- Args []string `json:"args"`
- Env []string `json:"env,omitempty"`
- WorkDir string `json:"workdir,omitempty"`
- Stdin []byte `json:"stdin,omitempty"`
- User string `json:"user,omitempty"`
-}
-
-type ExecResponse struct {
- OK bool `json:"ok"`
- ExitCode int `json:"exitCode"`
- Stdout []byte `json:"stdout,omitempty"`
- Stderr []byte `json:"stderr,omitempty"`
- Error string `json:"error,omitempty"`
-}
-
-// TTYSize is a terminal window size update for an interactive exec session.
-type TTYSize struct {
- Rows uint16
- Columns uint16
-}
-
-// TTYOptions controls terminal updates exchanged during an interactive exec.
-type TTYOptions struct {
- Rows uint16
- Columns uint16
- Resize <-chan TTYSize
- Signals <-chan string
-}
-
-// IdentityRequest describes the host-assigned identity a restored clone must
-// apply after snapshot NICs have been replaced.
-type IdentityRequest struct {
- Hostname string `json:"hostname"`
- Interfaces []InterfaceIdentity `json:"interfaces,omitempty"`
-}
-
-type InterfaceIdentity struct {
- Name string `json:"name"`
- MAC string `json:"mac"`
- IP string `json:"ip,omitempty"`
- Prefix int `json:"prefix,omitempty"`
- Gateway string `json:"gateway,omitempty"`
- DNS []string `json:"dns,omitempty"`
-}
-
-type IdentityResponse struct {
- OK bool `json:"ok"`
- Error string `json:"error,omitempty"`
-}
-
-// ReseedResponse reports whether the guest accepted fresh host entropy.
-type ReseedResponse struct {
- OK bool `json:"ok"`
- Error string `json:"error,omitempty"`
-}
-
-func Ping(ctx context.Context, socketPath string) (*PingPongResponse, error) {
- var (
- attempts int
- firstErr error
- lastErr error
- )
- for {
- attempts++
- resp, err := pingOnce(ctx, socketPath)
- if err == nil {
- return resp, nil
- }
- if firstErr == nil {
- firstErr = err
- }
- lastErr = err
- select {
- case <-ctx.Done():
- return nil, fmt.Errorf(
- "%w: attempts=%d first=%v last=%v: %v",
- ErrNotReady,
- attempts,
- firstErr,
- lastErr,
- ctx.Err(),
- )
- case <-time.After(time.Second):
- }
- }
-}
-
-func pingOnce(ctx context.Context, socketPath string) (*PingPongResponse, error) {
- var resp PingPongResponse
- if err := roundTrip(ctx, socketPath, map[string]any{"type": protocol.RequestPing}, &resp); err != nil {
- return nil, err
- }
- if !resp.OK {
- if resp.Error == "" {
- resp.Error = "guest agent returned not ok"
- }
- return &resp, fmt.Errorf("%w: %s", ErrNotReady, resp.Error)
- }
- return &resp, nil
-}
-
-func Exec(ctx context.Context, socketPath string, req ExecRequest) (*ExecResponse, error) {
- if len(req.Args) == 0 || req.Args[0] == "" {
- return nil, fmt.Errorf("AGENT_EXEC_INVALID: command must not be empty")
- }
- wireReq := struct {
- Type protocol.RequestType `json:"type"`
- ExecRequest
- }{
- Type: protocol.RequestExec,
- ExecRequest: req,
- }
- var resp ExecResponse
- if err := roundTrip(ctx, socketPath, wireReq, &resp); err != nil {
- return nil, err
- }
- if !resp.OK && resp.Error == "" {
- resp.Error = "guest agent exec returned not ok"
- }
- return &resp, nil
-}
-
-// ConfigureIdentity applies clone-specific guest hostname and network state.
-func ConfigureIdentity(ctx context.Context, socketPath string, req IdentityRequest) (*IdentityResponse, error) {
- wireReq := struct {
- Type protocol.RequestType `json:"type"`
- IdentityRequest
- }{Type: protocol.RequestIdentity, IdentityRequest: req}
- var resp IdentityResponse
- if err := roundTrip(ctx, socketPath, wireReq, &resp); err != nil {
- return nil, err
- }
- if !resp.OK {
- if resp.Error == "" {
- resp.Error = "guest agent identity update returned not ok"
- }
- return &resp, fmt.Errorf("AGENT_IDENTITY_FAILED: %s", resp.Error)
- }
- return &resp, nil
-}
-
-// Reseed injects one-time host entropy into the guest and optionally replaces
-// its machine ID. Entropy is generated for each call and never persisted.
-func Reseed(ctx context.Context, socketPath string, regenerateMachineID bool) (*ReseedResponse, error) {
- entropy := make([]byte, ReseedEntropyBytes)
- if _, err := rand.Read(entropy); err != nil {
- return nil, fmt.Errorf("AGENT_RESEED_FAILED: generate entropy: %w", err)
- }
- defer clear(entropy)
- wireReq := struct {
- Type protocol.RequestType `json:"type"`
- Entropy []byte `json:"entropy"`
- RegenerateMachineID bool `json:"regenerateMachineId,omitempty"`
- }{
- Type: protocol.RequestReseed,
- Entropy: entropy,
- RegenerateMachineID: regenerateMachineID,
- }
- var resp ReseedResponse
- if err := roundTrip(ctx, socketPath, wireReq, &resp); err != nil {
- return nil, err
- }
- if !resp.OK {
- if resp.Error == "" {
- resp.Error = "guest agent reseed returned not ok"
- }
- return &resp, fmt.Errorf("AGENT_RESEED_FAILED: %s", resp.Error)
- }
- return &resp, nil
-}
-
-func roundTrip(ctx context.Context, socketPath string, req any, resp any) (err error) {
- conn, err := dialHybridVsock(ctx, socketPath, AgentPort)
- if err != nil {
- return fmt.Errorf("%w: dial guest agent: %v", ErrNotReady, err)
- }
- defer fileutil.CloseAndJoin(&err, conn, "close guest agent connection")
- stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
- defer stop()
-
- raw, err := json.Marshal(req)
- if err != nil {
- return fmt.Errorf("AGENT_REQUEST_INVALID: %w", err)
- }
- if _, err := conn.Write(append(raw, '\n')); err != nil {
- return fmt.Errorf("%w: write request: %v", ErrNotReady, err)
- }
- line, err := bufio.NewReader(conn).ReadBytes('\n')
- if err != nil {
- return fmt.Errorf("%w: read response: %v", ErrNotReady, err)
- }
- if err := json.Unmarshal(line, resp); err != nil {
- return fmt.Errorf("%w: decode response: %v", ErrNotReady, err)
- }
- return nil
-}
-
-func dialHybridVsock(ctx context.Context, socketPath string, port uint32) (io.ReadWriteCloser, error) {
- var d net.Dialer
- conn, err := d.DialContext(ctx, "unix", socketPath)
- if err != nil {
- return nil, err
- }
- stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
- defer stop()
- if _, err := fmt.Fprintf(conn, "CONNECT %d\n", port); err != nil {
- _ = conn.Close()
- return nil, fmt.Errorf("write CONNECT: %w", err)
- }
- reply, err := readHybridVsockReply(conn)
- if err != nil {
- _ = conn.Close()
- if ctxErr := ctx.Err(); ctxErr != nil {
- return nil, ctxErr
- }
- return nil, fmt.Errorf("read CONNECT reply: %w", err)
- }
- if !strings.HasPrefix(reply, "OK ") {
- _ = conn.Close()
- return nil, fmt.Errorf("hybrid vsock CONNECT %d: %s", port, strings.TrimSpace(reply))
- }
- return conn, nil
-}
-
-func readHybridVsockReply(r io.Reader) (string, error) {
- buf := make([]byte, 0, 32)
- one := make([]byte, 1)
- for {
- n, err := r.Read(one)
- if n > 0 {
- buf = append(buf, one[0])
- if one[0] == '\n' {
- return string(buf), nil
- }
- if len(buf) >= hybridVsockReplyMax {
- return "", fmt.Errorf("reply line exceeds %d bytes", hybridVsockReplyMax)
- }
- }
- if err != nil {
- return "", err
- }
- }
-}
diff --git a/internal/agent/client/client_test.go b/internal/agent/client/client_test.go
deleted file mode 100644
index 932c14e..0000000
--- a/internal/agent/client/client_test.go
+++ /dev/null
@@ -1,524 +0,0 @@
-package client
-
-import (
- "bufio"
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
-)
-
-func TestPingUsesHybridVsockHandshake(t *testing.T) {
- t.Parallel()
-
- socketPath := testSocketPath(t)
- ln, err := net.Listen("unix", socketPath)
- if err != nil {
- t.Fatal(err)
- }
- defer ln.Close() //nolint:errcheck
-
- errCh := make(chan error, 1)
- go func() {
- conn, err := ln.Accept()
- if err != nil {
- errCh <- err
- return
- }
- defer conn.Close() //nolint:errcheck
- reader := bufio.NewReader(conn)
- line, err := reader.ReadString('\n')
- if err != nil {
- errCh <- err
- return
- }
- if line != "CONNECT 1024\n" {
- errCh <- errors.New("unexpected CONNECT line: " + line)
- return
- }
- if _, err := conn.Write([]byte("OK 1024\n")); err != nil {
- errCh <- err
- return
- }
- line, err = reader.ReadString('\n')
- if err != nil {
- errCh <- err
- return
- }
- if strings.TrimSpace(line) != `{"type":"ping"}` {
- errCh <- errors.New("unexpected ping line: " + line)
- return
- }
- _, err = conn.Write([]byte(`{"ok":true,"version":"test","os":"linux","hostname":"guest","capabilities":["exec","identity"]}` + "\n"))
- errCh <- err
- }()
-
- resp, err := Ping(context.Background(), socketPath)
- if err != nil {
- t.Fatal(err)
- }
- if resp.Version != "test" || resp.OS != "linux" || resp.Hostname != "guest" {
- t.Fatalf("response = %+v", resp)
- }
- if !resp.Supports(CapabilityIdentity) {
- t.Fatalf("capabilities = %v, want identity", resp.Capabilities)
- }
- if err := <-errCh; err != nil {
- t.Fatal(err)
- }
-}
-
-func TestExecStreamForwardsInputOutputAndExitCode(t *testing.T) {
- t.Parallel()
-
- socketPath := testSocketPath(t)
- ln, err := net.Listen("unix", socketPath)
- if err != nil {
- t.Fatal(err)
- }
- defer ln.Close() //nolint:errcheck
-
- serverErr := make(chan error, 1)
- go func() {
- conn, acceptErr := ln.Accept()
- if acceptErr != nil {
- serverErr <- acceptErr
- return
- }
- defer conn.Close() //nolint:errcheck
- reader := bufio.NewReader(conn)
- line, readErr := reader.ReadString('\n')
- if readErr != nil || line != "CONNECT 1024\n" {
- serverErr <- errors.New("invalid CONNECT")
- return
- }
- if _, writeErr := conn.Write([]byte("OK 1024\n")); writeErr != nil {
- serverErr <- writeErr
- return
- }
- decoder := protocol.NewDecoder(reader)
- execFrame, frameErr := decoder.ReadFrame()
- if frameErr != nil || execFrame.Type != protocol.FrameExec || execFrame.Env["FOO"] != "bar" {
- serverErr <- fmt.Errorf("exec frame = %+v, error = %v", execFrame, frameErr)
- return
- }
- if err := protocol.WriteFrame(conn, protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameReady, ID: execFrame.ID}); err != nil {
- serverErr <- err
- return
- }
- var input bytes.Buffer
- for {
- frame, readErr := decoder.ReadFrame()
- if readErr != nil {
- serverErr <- readErr
- return
- }
- if frame.Type != protocol.FrameStdin {
- serverErr <- fmt.Errorf("unexpected frame: %+v", frame)
- return
- }
- input.Write(frame.Data)
- if frame.End {
- break
- }
- }
- if err := protocol.WriteFrame(conn, protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameStdout, ID: execFrame.ID, Stream: protocol.StreamStdout, Data: input.Bytes()}); err != nil {
- serverErr <- err
- return
- }
- if err := protocol.WriteFrame(conn, protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameStderr, ID: execFrame.ID, Stream: protocol.StreamStderr, Data: []byte("warning\n")}); err != nil {
- serverErr <- err
- return
- }
- serverErr <- protocol.WriteFrame(conn, protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameExit, ID: execFrame.ID, ExitCode: 9})
- }()
-
- var stdout, stderr bytes.Buffer
- code, err := ExecStream(context.Background(), socketPath, ExecRequest{
- Args: []string{"cat"},
- Env: []string{"FOO=bar"},
- }, strings.NewReader("hello"), &stdout, &stderr)
- if err != nil {
- t.Fatal(err)
- }
- if code != 9 || stdout.String() != "hello" || stderr.String() != "warning\n" {
- t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
- }
- if err := <-serverErr; err != nil {
- t.Fatal(err)
- }
-}
-
-func TestExecStreamReturnsWhenGuestExitsBeforeBlockingInput(t *testing.T) {
- t.Parallel()
-
- socketPath := testSocketPath(t)
- ln, err := net.Listen("unix", socketPath)
- if err != nil {
- t.Fatal(err)
- }
- defer ln.Close() //nolint:errcheck
-
- serverErr := serveAgentExit(t, ln, false, 7)
- stdin := newBlockingReader()
- done := make(chan struct{})
- var code int
- var execErr error
- go func() {
- code, execErr = ExecStream(t.Context(), socketPath, ExecRequest{Args: []string{"true"}}, stdin, io.Discard, io.Discard)
- close(done)
- }()
-
- stdin.waitUntilRead(t)
- select {
- case <-done:
- case <-time.After(time.Second):
- t.Fatal("ExecStream waited for blocking stdin after guest exit")
- }
- stdin.release()
- if execErr != nil || code != 7 {
- t.Fatalf("ExecStream() = code %d, error %v", code, execErr)
- }
- if err := <-serverErr; err != nil {
- t.Fatal(err)
- }
-}
-
-func TestExecTTYReturnsWhenGuestExitsBeforeBlockingInput(t *testing.T) {
- t.Parallel()
-
- socketPath := testSocketPath(t)
- ln, err := net.Listen("unix", socketPath)
- if err != nil {
- t.Fatal(err)
- }
- defer ln.Close() //nolint:errcheck
-
- serverErr := serveAgentExit(t, ln, true, 3)
- stdin := newBlockingReader()
- done := make(chan struct{})
- var code int
- var execErr error
- go func() {
- code, execErr = ExecTTY(t.Context(), socketPath, ExecRequest{Args: []string{"true"}}, stdin, io.Discard, TTYOptions{})
- close(done)
- }()
-
- stdin.waitUntilRead(t)
- select {
- case <-done:
- case <-time.After(time.Second):
- t.Fatal("ExecTTY waited for blocking stdin after guest exit")
- }
- stdin.release()
- if execErr != nil || code != 3 {
- t.Fatalf("ExecTTY() = code %d, error %v", code, execErr)
- }
- if err := <-serverErr; err != nil {
- t.Fatal(err)
- }
-}
-
-type blockingReader struct {
- started chan struct{}
- unblock chan struct{}
-}
-
-func newBlockingReader() *blockingReader {
- return &blockingReader{started: make(chan struct{}), unblock: make(chan struct{})}
-}
-
-func (r *blockingReader) Read([]byte) (int, error) {
- select {
- case <-r.started:
- default:
- close(r.started)
- }
- <-r.unblock
- return 0, io.EOF
-}
-
-func (r *blockingReader) waitUntilRead(t *testing.T) {
- t.Helper()
- select {
- case <-r.started:
- case <-time.After(time.Second):
- t.Fatal("stdin was not read")
- }
-}
-
-func (r *blockingReader) release() {
- close(r.unblock)
-}
-
-func serveAgentExit(t *testing.T, ln net.Listener, tty bool, exitCode int) <-chan error {
- t.Helper()
- errs := make(chan error, 1)
- go func() {
- conn, err := ln.Accept()
- if err != nil {
- errs <- err
- return
- }
- defer conn.Close() //nolint:errcheck
- reader := bufio.NewReader(conn)
- line, err := reader.ReadString('\n')
- if err != nil || line != "CONNECT 1024\n" {
- errs <- fmt.Errorf("read CONNECT: line %q: %w", line, err)
- return
- }
- if _, err := conn.Write([]byte("OK 1024\n")); err != nil {
- errs <- err
- return
- }
- execFrame, err := protocol.NewDecoder(reader).ReadFrame()
- if err != nil {
- errs <- err
- return
- }
- if execFrame.Type != protocol.FrameExec || execFrame.TTY != tty {
- errs <- fmt.Errorf("unexpected exec frame: %+v", execFrame)
- return
- }
- errs <- protocol.WriteFrame(conn, protocol.Frame{
- Version: protocol.VersionV1,
- Type: protocol.FrameExit,
- ID: execFrame.ID,
- ExitCode: exitCode,
- })
- }()
- return errs
-}
-
-func TestPingPongResponseSupportsRejectsMissingCapability(t *testing.T) {
- t.Parallel()
-
- if (*PingPongResponse)(nil).Supports(CapabilityIdentity) {
- t.Fatal("nil response reported identity support")
- }
- resp := &PingPongResponse{Capabilities: []string{"exec"}}
- if resp.Supports(CapabilityIdentity) {
- t.Fatalf("capabilities = %v, unexpectedly support identity", resp.Capabilities)
- }
-}
-
-func TestExecUsesHybridVsockHandshake(t *testing.T) {
- t.Parallel()
-
- socketPath := testSocketPath(t)
- ln, err := net.Listen("unix", socketPath)
- if err != nil {
- t.Fatal(err)
- }
- defer ln.Close() //nolint:errcheck
-
- errCh := make(chan error, 1)
- go func() {
- conn, err := ln.Accept()
- if err != nil {
- errCh <- err
- return
- }
- defer conn.Close() //nolint:errcheck
- reader := bufio.NewReader(conn)
- line, err := reader.ReadString('\n')
- if err != nil {
- errCh <- err
- return
- }
- if line != "CONNECT 1024\n" {
- errCh <- errors.New("unexpected CONNECT line: " + line)
- return
- }
- if _, err := conn.Write([]byte("OK 1024\n")); err != nil {
- errCh <- err
- return
- }
- line, err = reader.ReadString('\n')
- if err != nil {
- errCh <- err
- return
- }
- var req struct {
- Type string `json:"type"`
- Args []string `json:"args"`
- Env []string `json:"env"`
- }
- if err := json.Unmarshal([]byte(line), &req); err != nil {
- errCh <- err
- return
- }
- if req.Type != "exec" || len(req.Args) != 2 || req.Args[0] != "echo" || req.Args[1] != "ok" || len(req.Env) != 1 {
- errCh <- errors.New("unexpected exec request: " + line)
- return
- }
- _, err = conn.Write([]byte(`{"ok":true,"exitCode":0,"stdout":"b2sK"}` + "\n"))
- errCh <- err
- }()
-
- resp, err := Exec(context.Background(), socketPath, ExecRequest{
- Args: []string{"echo", "ok"},
- Env: []string{"FOO=bar"},
- })
- if err != nil {
- t.Fatal(err)
- }
- if resp.ExitCode != 0 || string(resp.Stdout) != "ok\n" {
- t.Fatalf("response = %+v", resp)
- }
- if err := <-errCh; err != nil {
- t.Fatal(err)
- }
-}
-
-func TestPingMissingSocketReportsNotReady(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
- defer cancel()
- _, err := Ping(ctx, filepath.Join(t.TempDir(), "missing.uds"))
- if !errors.Is(err, ErrNotReady) {
- t.Fatalf("error = %v, want ErrNotReady", err)
- }
- if !os.IsNotExist(errors.Unwrap(err)) && !strings.Contains(err.Error(), "dial guest agent") {
- t.Fatalf("unexpected error detail: %v", err)
- }
- if !strings.Contains(err.Error(), "attempts=1") ||
- !strings.Contains(err.Error(), "first=") ||
- !strings.Contains(err.Error(), "last=") {
- t.Fatalf("error lacks attempt history: %v", err)
- }
-}
-
-func TestConfigureIdentityCancelsStalledResponse(t *testing.T) {
- t.Parallel()
-
- socketPath := testSocketPath(t)
- ln, err := net.Listen("unix", socketPath)
- if err != nil {
- t.Fatal(err)
- }
- defer ln.Close() //nolint:errcheck
-
- requestReceived := make(chan struct{})
- go func() {
- conn, acceptErr := ln.Accept()
- if acceptErr != nil {
- return
- }
- defer conn.Close() //nolint:errcheck
- reader := bufio.NewReader(conn)
- if _, readErr := reader.ReadString('\n'); readErr != nil {
- return
- }
- if _, writeErr := conn.Write([]byte("OK 1024\n")); writeErr != nil {
- return
- }
- if _, readErr := reader.ReadString('\n'); readErr != nil {
- return
- }
- close(requestReceived)
- _, _ = reader.ReadByte()
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
- defer cancel()
- started := time.Now()
- _, err = ConfigureIdentity(ctx, socketPath, IdentityRequest{Hostname: "clone"})
- if err == nil || !errors.Is(err, ErrNotReady) {
- t.Fatalf("error = %v, want ErrNotReady", err)
- }
- if elapsed := time.Since(started); elapsed > time.Second {
- t.Fatalf("stalled request ignored context for %s", elapsed)
- }
- select {
- case <-requestReceived:
- default:
- t.Fatal("identity request was not received")
- }
-}
-
-func TestReseedSendsFreshEntropyAndMachineIDPolicy(t *testing.T) {
- t.Parallel()
-
- socketPath := testSocketPath(t)
- ln, err := net.Listen("unix", socketPath)
- if err != nil {
- t.Fatal(err)
- }
- defer ln.Close() //nolint:errcheck
-
- errCh := make(chan error, 1)
- go func() {
- conn, acceptErr := ln.Accept()
- if acceptErr != nil {
- errCh <- acceptErr
- return
- }
- defer conn.Close() //nolint:errcheck
- reader := bufio.NewReader(conn)
- if line, readErr := reader.ReadString('\n'); readErr != nil || line != "CONNECT 1024\n" {
- errCh <- fmt.Errorf("CONNECT line = %q, error = %v", line, readErr)
- return
- }
- if _, writeErr := conn.Write([]byte("OK 1024\n")); writeErr != nil {
- errCh <- writeErr
- return
- }
- line, readErr := reader.ReadBytes('\n')
- if readErr != nil {
- errCh <- readErr
- return
- }
- var req struct {
- Type protocol.RequestType `json:"type"`
- Entropy []byte `json:"entropy"`
- RegenerateMachineID bool `json:"regenerateMachineId"`
- }
- if decodeErr := json.Unmarshal(line, &req); decodeErr != nil {
- errCh <- decodeErr
- return
- }
- if req.Type != protocol.RequestReseed || len(req.Entropy) != ReseedEntropyBytes || !req.RegenerateMachineID {
- errCh <- fmt.Errorf("reseed request = %+v", req)
- return
- }
- if bytes.Equal(req.Entropy, make([]byte, ReseedEntropyBytes)) {
- errCh <- errors.New("reseed entropy is all zero")
- return
- }
- _, writeErr := conn.Write([]byte("{\"ok\":true}\n"))
- errCh <- writeErr
- }()
-
- resp, err := Reseed(context.Background(), socketPath, true)
- if err != nil {
- t.Fatal(err)
- }
- if !resp.OK {
- t.Fatalf("response = %+v", resp)
- }
- if err := <-errCh; err != nil {
- t.Fatal(err)
- }
-}
-
-func testSocketPath(t *testing.T) string {
- t.Helper()
- dir, err := os.MkdirTemp("/tmp", "kb-agent-test-*")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = os.RemoveAll(dir) })
- return filepath.Join(dir, "v.sock")
-}
diff --git a/internal/agent/client/stream.go b/internal/agent/client/stream.go
deleted file mode 100644
index eeae051..0000000
--- a/internal/agent/client/stream.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package client
-
-import (
- "context"
- "fmt"
- "io"
- "strings"
- "sync"
- "time"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
-)
-
-const streamChunkSize = 32 * 1024
-
-// ExecStream runs a non-TTY command while forwarding its three standard
-// streams. The returned code is the guest process exit code.
-func ExecStream(ctx context.Context, socketPath string, req ExecRequest, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
- if len(req.Args) == 0 || req.Args[0] == "" {
- return 127, fmt.Errorf("AGENT_EXEC_INVALID: command must not be empty")
- }
- if stdout == nil {
- stdout = io.Discard
- }
- if stderr == nil {
- stderr = io.Discard
- }
- conn, err := dialHybridVsock(ctx, socketPath, AgentPort)
- if err != nil {
- return 127, fmt.Errorf("%w: dial guest agent: %v", ErrNotReady, err)
- }
- defer conn.Close() //nolint:errcheck
- stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
- defer stop()
- sessionCtx, cancelSession := context.WithCancel(ctx)
- defer cancelSession()
-
- id := fmt.Sprintf("exec-%d", time.Now().UnixNano())
- writer := &lockedFrameWriter{writer: conn}
- env, err := environmentMap(req.Env)
- if err != nil {
- return 127, err
- }
- if err := writer.Write(protocol.Frame{
- Version: protocol.VersionV1,
- Type: protocol.FrameExec,
- ID: id,
- Args: req.Args,
- Env: env,
- WorkDir: req.WorkDir,
- User: req.User,
- }); err != nil {
- return 127, fmt.Errorf("%w: write exec frame: %v", ErrNotReady, err)
- }
-
- inputResults := make(chan error, 1)
- go func() {
- select {
- case inputResults <- streamInput(sessionCtx, writer, id, stdin):
- case <-sessionCtx.Done():
- }
- }()
-
- frames, frameErrors := readFrameStream(sessionCtx, conn)
- for {
- var frame protocol.Frame
- select {
- case <-ctx.Done():
- return 127, ctx.Err()
- case inputErr := <-inputResults:
- if inputErr != nil && ctx.Err() == nil {
- return 127, fmt.Errorf("stream guest stdin: %w", inputErr)
- }
- inputResults = nil
- continue
- case readErr := <-frameErrors:
- return 127, fmt.Errorf("%w: read stream: %v", ErrNotReady, readErr)
- case frame = <-frames:
- }
- if frame.ID != id {
- return 127, fmt.Errorf("AGENT_INVALID_FRAME: unexpected exec id %q", frame.ID)
- }
- switch frame.Type {
- case protocol.FrameReady:
- continue
- case protocol.FrameStdout:
- if _, err := stdout.Write(frame.Data); err != nil {
- return 127, fmt.Errorf("write guest stdout: %w", err)
- }
- case protocol.FrameStderr:
- if _, err := stderr.Write(frame.Data); err != nil {
- return 127, fmt.Errorf("write guest stderr: %w", err)
- }
- case protocol.FrameError:
- return 127, fmt.Errorf("%s: %s", frame.Code, frame.Message)
- case protocol.FrameExit:
- return frame.ExitCode, nil
- default:
- return 127, fmt.Errorf("AGENT_INVALID_FRAME: unexpected frame %q", frame.Type)
- }
- }
-}
-
-func readFrameStream(ctx context.Context, reader io.Reader) (<-chan protocol.Frame, <-chan error) {
- frames := make(chan protocol.Frame)
- errs := make(chan error, 1)
- go func() {
- decoder := protocol.NewDecoder(reader)
- for {
- frame, err := decoder.ReadFrame()
- if err != nil {
- select {
- case errs <- err:
- case <-ctx.Done():
- }
- return
- }
- select {
- case frames <- frame:
- case <-ctx.Done():
- return
- }
- }
- }()
- return frames, errs
-}
-
-func streamInput(ctx context.Context, writer *lockedFrameWriter, id string, stdin io.Reader) error {
- if stdin == nil {
- return writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameStdin, ID: id, Stream: protocol.StreamStdin, End: true})
- }
- buf := make([]byte, streamChunkSize)
- for {
- n, err := stdin.Read(buf)
- if n > 0 {
- data := append([]byte(nil), buf[:n]...)
- if writeErr := writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameStdin, ID: id, Stream: protocol.StreamStdin, Data: data}); writeErr != nil {
- return writeErr
- }
- }
- if err == io.EOF {
- return writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameStdin, ID: id, Stream: protocol.StreamStdin, End: true})
- }
- if err != nil {
- return err
- }
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
- }
-}
-
-func environmentMap(values []string) (map[string]string, error) {
- if len(values) == 0 {
- return nil, nil
- }
- result := make(map[string]string, len(values))
- for _, value := range values {
- key, item, ok := strings.Cut(value, "=")
- if !ok || key == "" {
- return nil, fmt.Errorf("AGENT_EXEC_INVALID: environment must be KEY=VALUE, got %q", value)
- }
- result[key] = item
- }
- return result, nil
-}
-
-type lockedFrameWriter struct {
- mu sync.Mutex
- writer io.Writer
-}
-
-func (w *lockedFrameWriter) Write(frame protocol.Frame) error {
- w.mu.Lock()
- defer w.mu.Unlock()
- return protocol.WriteFrame(w.writer, frame)
-}
diff --git a/internal/agent/client/tty.go b/internal/agent/client/tty.go
deleted file mode 100644
index 78c8304..0000000
--- a/internal/agent/client/tty.go
+++ /dev/null
@@ -1,109 +0,0 @@
-package client
-
-import (
- "context"
- "fmt"
- "io"
- "time"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
-)
-
-// ExecTTY runs a command attached to a guest PTY. PTY output is exposed as a
-// single stdout stream because a terminal intentionally merges stdout/stderr.
-func ExecTTY(ctx context.Context, socketPath string, req ExecRequest, stdin io.Reader, stdout io.Writer, options TTYOptions) (int, error) {
- if len(req.Args) == 0 || req.Args[0] == "" {
- return 127, fmt.Errorf("AGENT_EXEC_INVALID: command must not be empty")
- }
- if stdout == nil {
- stdout = io.Discard
- }
- conn, err := dialHybridVsock(ctx, socketPath, AgentPort)
- if err != nil {
- return 127, fmt.Errorf("%w: dial guest agent: %v", ErrNotReady, err)
- }
- defer conn.Close() //nolint:errcheck
- stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
- defer stop()
- sessionCtx, cancelSession := context.WithCancel(ctx)
- defer cancelSession()
-
- id := fmt.Sprintf("exec-tty-%d", time.Now().UnixNano())
- writer := &lockedFrameWriter{writer: conn}
- env, err := environmentMap(req.Env)
- if err != nil {
- return 127, err
- }
- if err := writer.Write(protocol.Frame{
- Version: protocol.VersionV1,
- Type: protocol.FrameExec,
- ID: id,
- Args: req.Args,
- Env: env,
- WorkDir: req.WorkDir,
- User: req.User,
- TTY: true,
- Rows: options.Rows,
- Columns: options.Columns,
- }); err != nil {
- return 127, fmt.Errorf("%w: write tty exec frame: %v", ErrNotReady, err)
- }
-
- inputResults := make(chan error, 1)
- go func() {
- select {
- case inputResults <- streamInput(sessionCtx, writer, id, stdin):
- case <-sessionCtx.Done():
- }
- }()
- frames, frameErrors := readFrameStream(sessionCtx, conn)
-
- resize := options.Resize
- signals := options.Signals
- for {
- select {
- case <-ctx.Done():
- return 127, ctx.Err()
- case frame := <-frames:
- if frame.ID != id {
- return 127, fmt.Errorf("AGENT_INVALID_FRAME: unexpected exec id %q", frame.ID)
- }
- switch frame.Type {
- case protocol.FrameReady:
- case protocol.FrameStdout, protocol.FrameStderr:
- if _, err := stdout.Write(frame.Data); err != nil {
- return 127, fmt.Errorf("write guest tty output: %w", err)
- }
- case protocol.FrameError:
- return 127, fmt.Errorf("%s: %s", frame.Code, frame.Message)
- case protocol.FrameExit:
- return frame.ExitCode, nil
- default:
- return 127, fmt.Errorf("AGENT_INVALID_FRAME: unexpected frame %q", frame.Type)
- }
- case err := <-frameErrors:
- return 127, fmt.Errorf("%w: read tty stream: %v", ErrNotReady, err)
- case err := <-inputResults:
- if err != nil && ctx.Err() == nil {
- return 127, fmt.Errorf("stream tty input: %w", err)
- }
- inputResults = nil
- case size, ok := <-resize:
- if !ok {
- resize = nil
- continue
- }
- if err := writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameResize, ID: id, Rows: size.Rows, Columns: size.Columns}); err != nil {
- return 127, err
- }
- case signal, ok := <-signals:
- if !ok {
- signals = nil
- continue
- }
- if err := writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameSignal, ID: id, Signal: signal}); err != nil {
- return 127, err
- }
- }
- }
-}
diff --git a/internal/agent/protocol/frame.go b/internal/agent/protocol/frame.go
deleted file mode 100644
index 25d4aac..0000000
--- a/internal/agent/protocol/frame.go
+++ /dev/null
@@ -1,230 +0,0 @@
-package protocol
-
-import (
- "bufio"
- "bytes"
- "encoding/json"
- "fmt"
- "io"
-)
-
-const (
- // VersionV1 is the first version of the streaming agent wire protocol.
- VersionV1 = "kumabox.agent.v1"
-
- // MaxFrameBytes bounds one JSON frame. Stream data must be chunked by the
- // sender instead of allowing a single unbounded allocation.
- MaxFrameBytes = 1 << 20
-)
-
-// FrameType identifies a message in a streaming agent session.
-type FrameType string
-
-const (
- FramePing FrameType = "ping"
- FrameExec FrameType = "exec"
- FrameStdin FrameType = "stdin"
- FrameStdout FrameType = "stdout"
- FrameStderr FrameType = "stderr"
- FrameResize FrameType = "resize"
- FrameSignal FrameType = "signal"
- FrameExit FrameType = "exit"
- FrameError FrameType = "error"
- FrameReady FrameType = "ready"
-)
-
-// Stream identifies the direction or logical stream carrying frame data.
-type Stream string
-
-const (
- StreamStdin Stream = "stdin"
- StreamStdout Stream = "stdout"
- StreamStderr Stream = "stderr"
-)
-
-// ErrorCode is a stable machine-readable protocol error.
-type ErrorCode string
-
-func (e ErrorCode) Error() string { return string(e) }
-
-const (
- ErrorInvalidFrame ErrorCode = "INVALID_FRAME"
- ErrorUnsupportedVersion ErrorCode = "UNSUPPORTED_VERSION"
- ErrorUnsupportedFrame ErrorCode = "UNSUPPORTED_FRAME"
- ErrorInvalidRequest ErrorCode = "INVALID_REQUEST"
- ErrorExecFailed ErrorCode = "EXEC_FAILED"
- ErrorAgentUnavailable ErrorCode = "AGENT_UNAVAILABLE"
- ErrorCapabilityMissing ErrorCode = "CAPABILITY_MISSING"
- ErrorUserUnsupported ErrorCode = "USER_UNSUPPORTED"
- ErrorEnvDenied ErrorCode = "ENV_DENIED"
- ErrorExecTimeout ErrorCode = "EXEC_TIMEOUT"
- ErrorOutputLimit ErrorCode = "OUTPUT_LIMIT"
-)
-
-// Frame is the typed wire envelope for a streaming agent session. Fields are
-// intentionally concrete so callers do not need to pass unvalidated JSON
-// fragments between the host and guest.
-type Frame struct {
- Version string `json:"version"`
- Type FrameType `json:"type"`
- ID string `json:"id,omitempty"`
-
- Args []string `json:"args,omitempty"`
- Env map[string]string `json:"env,omitempty"`
- WorkDir string `json:"workdir,omitempty"`
- User string `json:"user,omitempty"`
- TTY bool `json:"tty,omitempty"`
-
- Stream Stream `json:"stream,omitempty"`
- Data []byte `json:"data,omitempty"`
- End bool `json:"end,omitempty"`
-
- Rows uint16 `json:"rows,omitempty"`
- Columns uint16 `json:"columns,omitempty"`
- Signal string `json:"signal,omitempty"`
-
- ExitCode int `json:"exitCode,omitempty"`
- Code ErrorCode `json:"code,omitempty"`
- Message string `json:"message,omitempty"`
-}
-
-// Validate checks the common envelope and the fields required by each frame.
-func (f Frame) Validate() error {
- if f.Version != VersionV1 {
- return fmt.Errorf("%w: %q", ErrorUnsupportedVersion, f.Version)
- }
- if !knownFrameType(f.Type) {
- return fmt.Errorf("%w: %q", ErrorUnsupportedFrame, f.Type)
- }
- if f.Type != FramePing && f.ID == "" {
- return fmt.Errorf("%w: frame %q requires id", ErrorInvalidFrame, f.Type)
- }
- switch f.Type {
- case FrameExec:
- if len(f.Args) == 0 || f.Args[0] == "" {
- return fmt.Errorf("%w: exec args must not be empty", ErrorInvalidRequest)
- }
- case FrameStdin:
- if f.Stream != StreamStdin {
- return fmt.Errorf("%w: frame %q requires stdin stream", ErrorInvalidFrame, f.Type)
- }
- case FrameStdout:
- if f.Stream != StreamStdout {
- return fmt.Errorf("%w: frame %q requires stdout stream", ErrorInvalidFrame, f.Type)
- }
- case FrameStderr:
- if f.Stream != StreamStderr {
- return fmt.Errorf("%w: frame %q requires stderr stream", ErrorInvalidFrame, f.Type)
- }
- case FrameResize:
- if f.Rows == 0 || f.Columns == 0 {
- return fmt.Errorf("%w: resize dimensions must be non-zero", ErrorInvalidRequest)
- }
- case FrameSignal:
- if f.Signal == "" {
- return fmt.Errorf("%w: signal must not be empty", ErrorInvalidRequest)
- }
- case FrameExit:
- if f.ExitCode < 0 {
- return fmt.Errorf("%w: exit code must not be negative", ErrorInvalidFrame)
- }
- case FrameError:
- if f.Code == "" || f.Message == "" {
- return fmt.Errorf("%w: error frame requires code and message", ErrorInvalidFrame)
- }
- }
- return nil
-}
-
-// WriteFrame writes one newline-delimited JSON frame.
-func WriteFrame(w io.Writer, frame Frame) error {
- if err := frame.Validate(); err != nil {
- return err
- }
- raw, err := json.Marshal(frame)
- if err != nil {
- return fmt.Errorf("encode agent frame: %w", err)
- }
- if len(raw)+1 > MaxFrameBytes {
- return fmt.Errorf("%w: frame is %d bytes, maximum is %d", ErrorInvalidFrame, len(raw)+1, MaxFrameBytes)
- }
- raw = append(raw, '\n')
- for len(raw) > 0 {
- n, err := w.Write(raw)
- if err != nil {
- return fmt.Errorf("write agent frame: %w", err)
- }
- if n == 0 {
- return io.ErrShortWrite
- }
- raw = raw[n:]
- }
- return nil
-}
-
-// Decoder reads consecutive frames from one stream without losing bytes that
-// belong to the following frame.
-type Decoder struct {
- reader *bufio.Reader
-}
-
-// NewDecoder creates a bounded streaming frame decoder.
-func NewDecoder(r io.Reader) *Decoder {
- return &Decoder{reader: bufio.NewReader(r)}
-}
-
-// ReadFrame reads and validates the next frame.
-func (d *Decoder) ReadFrame() (Frame, error) {
- if d == nil || d.reader == nil {
- return Frame{}, fmt.Errorf("%w: nil decoder", ErrorInvalidFrame)
- }
- return readFrame(d.reader)
-}
-
-// ReadFrame reads one newline-delimited JSON frame and validates it before
-// returning it to the caller. Use NewDecoder when reading more than one frame
-// from the same stream.
-func ReadFrame(r io.Reader) (Frame, error) {
- return NewDecoder(r).ReadFrame()
-}
-
-// readFrame is split out so a session can reuse one buffered reader without
-// losing bytes belonging to the next frame.
-func readFrame(r *bufio.Reader) (Frame, error) {
- var line bytes.Buffer
- for {
- part, err := r.ReadSlice('\n')
- line.Write(part)
- if line.Len() > MaxFrameBytes {
- return Frame{}, fmt.Errorf("%w: frame exceeds %d bytes", ErrorInvalidFrame, MaxFrameBytes)
- }
- if err == nil {
- break
- }
- if err != bufio.ErrBufferFull {
- return Frame{}, fmt.Errorf("read agent frame: %w", err)
- }
- }
- raw := line.Bytes()
- if len(raw) == 0 || raw[len(raw)-1] != '\n' {
- return Frame{}, fmt.Errorf("%w: frame is not newline terminated", ErrorInvalidFrame)
- }
- var frame Frame
- if err := json.Unmarshal(raw[:len(raw)-1], &frame); err != nil {
- return Frame{}, fmt.Errorf("%w: decode JSON: %v", ErrorInvalidFrame, err)
- }
- if err := frame.Validate(); err != nil {
- return Frame{}, err
- }
- return frame, nil
-}
-
-func knownFrameType(frameType FrameType) bool {
- switch frameType {
- case FramePing, FrameExec, FrameStdin, FrameStdout, FrameStderr,
- FrameResize, FrameSignal, FrameExit, FrameError, FrameReady:
- return true
- default:
- return false
- }
-}
diff --git a/internal/agent/protocol/frame_test.go b/internal/agent/protocol/frame_test.go
deleted file mode 100644
index e468591..0000000
--- a/internal/agent/protocol/frame_test.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package protocol
-
-import (
- "bytes"
- "strings"
- "testing"
-)
-
-func TestFrameRoundTrip(t *testing.T) {
- t.Parallel()
-
- want := Frame{
- Version: VersionV1,
- Type: FrameExec,
- ID: "exec-1",
- Args: []string{"sh", "-c", "echo ok"},
- Env: map[string]string{"FOO": "bar"},
- WorkDir: "/tmp",
- User: "agent",
- TTY: true,
- }
- var buf bytes.Buffer
- if err := WriteFrame(&buf, want); err != nil {
- t.Fatal(err)
- }
- got, err := ReadFrame(&buf)
- if err != nil {
- t.Fatal(err)
- }
- if got.Version != want.Version || got.Type != want.Type || got.ID != want.ID || got.User != want.User || !got.TTY {
- t.Fatalf("frame = %+v, want %+v", got, want)
- }
- if got.Env["FOO"] != "bar" || len(got.Args) != 3 {
- t.Fatalf("frame payload = %+v", got)
- }
-}
-
-func TestFrameRoundTripPreservesStdinEnd(t *testing.T) {
- t.Parallel()
-
- var buf bytes.Buffer
- want := Frame{Version: VersionV1, Type: FrameStdin, ID: "exec-1", Stream: StreamStdin, End: true}
- if err := WriteFrame(&buf, want); err != nil {
- t.Fatal(err)
- }
- got, err := ReadFrame(&buf)
- if err != nil {
- t.Fatal(err)
- }
- if !got.End || got.Stream != StreamStdin {
- t.Fatalf("frame = %+v", got)
- }
-}
-
-func TestFrameValidation(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- frame Frame
- want ErrorCode
- }{
- {name: "version", frame: Frame{Version: "v0", Type: FramePing}, want: ErrorUnsupportedVersion},
- {name: "type", frame: Frame{Version: VersionV1, Type: "wat"}, want: ErrorUnsupportedFrame},
- {name: "id", frame: Frame{Version: VersionV1, Type: FrameExec}, want: ErrorInvalidFrame},
- {name: "args", frame: Frame{Version: VersionV1, Type: FrameExec, ID: "1"}, want: ErrorInvalidRequest},
- {name: "resize", frame: Frame{Version: VersionV1, Type: FrameResize, ID: "1"}, want: ErrorInvalidRequest},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := tt.frame.Validate()
- if err == nil || !strings.Contains(err.Error(), string(tt.want)) {
- t.Fatalf("Validate() error = %v, want %s", err, tt.want)
- }
- })
- }
-}
-
-func TestReadFrameRejectsOversizedFrame(t *testing.T) {
- t.Parallel()
-
- input := strings.Repeat("x", MaxFrameBytes) + "\n"
- _, err := ReadFrame(strings.NewReader(input))
- if err == nil || !strings.Contains(err.Error(), string(ErrorInvalidFrame)) {
- t.Fatalf("ReadFrame() error = %v, want invalid frame", err)
- }
-}
-
-func TestWriteFrameHandlesShortWriter(t *testing.T) {
- t.Parallel()
-
- var buf shortWriter
- err := WriteFrame(&buf, Frame{Version: VersionV1, Type: FramePing})
- if err != nil {
- t.Fatal(err)
- }
- if !strings.HasSuffix(buf.String(), "\n") {
- t.Fatalf("encoded frame = %q", buf.String())
- }
-}
-
-func TestDecoderPreservesFollowingFrame(t *testing.T) {
- t.Parallel()
-
- input := `{"version":"kumabox.agent.v1","type":"ping"}
-{"version":"kumabox.agent.v1","type":"ready","id":"1"}
-`
- decoder := NewDecoder(strings.NewReader(input))
- first, err := decoder.ReadFrame()
- if err != nil || first.Type != FramePing {
- t.Fatalf("first frame = %+v, error = %v", first, err)
- }
- second, err := decoder.ReadFrame()
- if err != nil || second.Type != FrameReady || second.ID != "1" {
- t.Fatalf("second frame = %+v, error = %v", second, err)
- }
-}
-
-type shortWriter struct {
- data []byte
-}
-
-func (w *shortWriter) Write(p []byte) (int, error) {
- if len(p) == 0 {
- return 0, nil
- }
- w.data = append(w.data, p[0])
- return 1, nil
-}
-
-func (w *shortWriter) String() string { return string(w.data) }
diff --git a/internal/agent/protocol/protocol.go b/internal/agent/protocol/protocol.go
deleted file mode 100644
index 1b6a56c..0000000
--- a/internal/agent/protocol/protocol.go
+++ /dev/null
@@ -1,31 +0,0 @@
-// Package protocol defines the stable wire vocabulary shared by the host and
-// guest agent implementations.
-package protocol
-
-// RequestType identifies an agent request on the vsock stream.
-type RequestType string
-
-const (
- RequestPing RequestType = "ping"
- RequestExec RequestType = "exec"
- RequestIdentity RequestType = "identity"
- RequestReseed RequestType = "reseed"
-)
-
-// Capability identifies an operation advertised by the guest agent.
-type Capability string
-
-const (
- CapabilityIdentity Capability = "identity"
- CapabilityReseed Capability = "reseed"
-)
-
-const (
- CapabilityPingPong Capability = "ping-pong"
- CapabilityExec Capability = "exec"
- CapabilityExecStream Capability = "exec-stream"
- CapabilityExecTTY Capability = "exec-tty"
-)
-
-// AgentPort is the vsock port used by the guest agent.
-const AgentPort uint32 = 1024
diff --git a/internal/agent/server/identity_linux.go b/internal/agent/server/identity_linux.go
deleted file mode 100644
index c9d1d40..0000000
--- a/internal/agent/server/identity_linux.go
+++ /dev/null
@@ -1,125 +0,0 @@
-//go:build linux
-
-package server
-
-import (
- "fmt"
- "net"
- "os"
- "strings"
- "syscall"
-
- "github.com/vishvananda/netlink"
-)
-
-func applyIdentity(req identityRequest) error {
- if req.Hostname == "" || strings.ContainsAny(req.Hostname, "/\x00\n") {
- return fmt.Errorf("invalid hostname")
- }
- if err := syscall.Sethostname([]byte(req.Hostname)); err != nil {
- return fmt.Errorf("set hostname: %w", err)
- }
- if err := os.WriteFile("/etc/hostname", []byte(req.Hostname+"\n"), 0o644); err != nil {
- return fmt.Errorf("persist hostname: %w", err)
- }
- return applyNetworkIdentity(req.Interfaces)
-}
-
-// applyNetworkIdentity changes networkd state only when the clone has guest
-// NICs. A networkless clone still needs a unique hostname, but reloading
-// networkd in that case is unrelated work during the restore critical path.
-func applyNetworkIdentity(identities []interfaceIdentity) error {
- if len(identities) == 0 {
- return nil
- }
- interfaceNames, err := persistNetworkdIdentity(identities)
- if err != nil {
- return fmt.Errorf("persist network identity: %w", err)
- }
- if err := reloadNetworkd(); err != nil {
- return fmt.Errorf("reload network identity: %w", err)
- }
- for index, identity := range identities {
- if err := configureInterface(index, identity); err != nil {
- return err
- }
- }
- if err := reconfigureNetworkd(interfaceNames); err != nil {
- return fmt.Errorf("reconfigure network identity: %w", err)
- }
- return nil
-}
-
-func configureInterface(index int, identity interfaceIdentity) error {
- link, err := linkByMAC(identity.MAC)
- if err != nil {
- return fmt.Errorf("configure interface %d: %w", index, err)
- }
- if identity.Name != "" && link.Attrs().Name != identity.Name {
- if err := netlink.LinkSetDown(link); err != nil {
- return fmt.Errorf("set %s down: %w", link.Attrs().Name, err)
- }
- if err := netlink.LinkSetName(link, identity.Name); err != nil {
- return fmt.Errorf("rename %s to %s: %w", link.Attrs().Name, identity.Name, err)
- }
- link, err = netlink.LinkByName(identity.Name)
- if err != nil {
- return fmt.Errorf("resolve renamed interface %s: %w", identity.Name, err)
- }
- }
- if err := flushAddresses(link); err != nil {
- return err
- }
- if identity.IP != "" {
- address, err := netlink.ParseAddr(fmt.Sprintf("%s/%d", identity.IP, identity.Prefix))
- if err != nil {
- return fmt.Errorf("parse address for %s: %w", link.Attrs().Name, err)
- }
- if err := netlink.AddrReplace(link, address); err != nil {
- return fmt.Errorf("set address on %s: %w", link.Attrs().Name, err)
- }
- }
- if err := netlink.LinkSetUp(link); err != nil {
- return fmt.Errorf("set %s up: %w", link.Attrs().Name, err)
- }
- if identity.Gateway != "" {
- gateway := net.ParseIP(identity.Gateway)
- if gateway == nil {
- return fmt.Errorf("invalid gateway %s", identity.Gateway)
- }
- route := &netlink.Route{LinkIndex: link.Attrs().Index, Gw: gateway, Priority: 100 + index}
- if err := netlink.RouteReplace(route); err != nil {
- return fmt.Errorf("set default route on %s: %w", link.Attrs().Name, err)
- }
- }
- return nil
-}
-
-func linkByMAC(mac string) (netlink.Link, error) {
- if _, err := net.ParseMAC(mac); err != nil {
- return nil, fmt.Errorf("invalid MAC %s", mac)
- }
- links, err := netlink.LinkList()
- if err != nil {
- return nil, fmt.Errorf("list links: %w", err)
- }
- for _, link := range links {
- if strings.EqualFold(link.Attrs().HardwareAddr.String(), mac) {
- return link, nil
- }
- }
- return nil, fmt.Errorf("interface with MAC %s not found", mac)
-}
-
-func flushAddresses(link netlink.Link) error {
- addresses, err := netlink.AddrList(link, netlink.FAMILY_ALL)
- if err != nil {
- return fmt.Errorf("list addresses on %s: %w", link.Attrs().Name, err)
- }
- for i := range addresses {
- if err := netlink.AddrDel(link, &addresses[i]); err != nil {
- return fmt.Errorf("remove address from %s: %w", link.Attrs().Name, err)
- }
- }
- return nil
-}
diff --git a/internal/agent/server/identity_linux_test.go b/internal/agent/server/identity_linux_test.go
deleted file mode 100644
index 3c70ee0..0000000
--- a/internal/agent/server/identity_linux_test.go
+++ /dev/null
@@ -1,23 +0,0 @@
-//go:build linux
-
-package server
-
-import "testing"
-
-func TestApplyNetworkIdentitySkipsNetworklessClone(t *testing.T) {
- originalStateDir := networkdStateDir
- originalRun := runNetworkctl
- networkdStateDir = t.TempDir()
- runNetworkctl = func(args ...string) error {
- t.Fatalf("networkctl unexpectedly invoked: %v", args)
- return nil
- }
- t.Cleanup(func() {
- networkdStateDir = originalStateDir
- runNetworkctl = originalRun
- })
-
- if err := applyNetworkIdentity(nil); err != nil {
- t.Fatalf("apply networkless identity: %v", err)
- }
-}
diff --git a/internal/agent/server/identity_networkd.go b/internal/agent/server/identity_networkd.go
deleted file mode 100644
index 1255715..0000000
--- a/internal/agent/server/identity_networkd.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package server
-
-import (
- "errors"
- "fmt"
- "net"
- "os"
- "os/exec"
- "path/filepath"
- "sort"
- "strings"
-)
-
-const networkdIdentityPrefix = "05-kumabox-identity-"
-
-var (
- networkdConfigDir = "/etc/systemd/network"
- networkdStateDir = "/run/systemd/netif"
- runNetworkctl = func(args ...string) error {
- output, err := exec.Command("networkctl", args...).CombinedOutput()
- if err != nil {
- return fmt.Errorf("networkctl %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
- }
- return nil
- }
-)
-
-func persistNetworkdIdentity(identities []interfaceIdentity) ([]string, error) {
- if len(identities) == 0 {
- return nil, nil
- }
- if err := os.MkdirAll(networkdConfigDir, 0o755); err != nil {
- return nil, fmt.Errorf("create networkd config directory: %w", err)
- }
-
- desired := make(map[string]struct{}, len(identities))
- names := make([]string, 0, len(identities))
- for index, identity := range identities {
- content, filename, err := renderNetworkdIdentity(identity)
- if err != nil {
- return nil, fmt.Errorf("interface %d: %w", index, err)
- }
- path := filepath.Join(networkdConfigDir, filename)
- if err := writeAtomic(path, []byte(content), 0o644); err != nil {
- return nil, fmt.Errorf("write %s: %w", path, err)
- }
- desired[filename] = struct{}{}
- names = append(names, identity.Name)
- }
- if err := removeStaleNetworkdIdentities(desired); err != nil {
- return nil, err
- }
- return names, nil
-}
-
-func renderNetworkdIdentity(identity interfaceIdentity) (string, string, error) {
- mac, err := net.ParseMAC(identity.MAC)
- if err != nil {
- return "", "", fmt.Errorf("invalid MAC %s", identity.MAC)
- }
- if identity.Name == "" || strings.ContainsAny(identity.Name, "/\x00\n") {
- return "", "", fmt.Errorf("invalid interface name %q", identity.Name)
- }
- if identity.IP == "" || identity.Prefix < 1 || identity.Prefix > 32 || net.ParseIP(identity.IP).To4() == nil {
- return "", "", fmt.Errorf("invalid IPv4 address %s/%d", identity.IP, identity.Prefix)
- }
- if identity.Gateway != "" && net.ParseIP(identity.Gateway).To4() == nil {
- return "", "", fmt.Errorf("invalid gateway %s", identity.Gateway)
- }
- for _, dns := range identity.DNS {
- if net.ParseIP(dns) == nil {
- return "", "", fmt.Errorf("invalid DNS server %s", dns)
- }
- }
-
- canonicalMAC := strings.ToLower(mac.String())
- filename := networkdIdentityPrefix + strings.ReplaceAll(canonicalMAC, ":", "") + ".network"
- var content strings.Builder
- fmt.Fprintf(&content, "[Match]\nMACAddress=%s\n\n", canonicalMAC)
- content.WriteString("[Network]\nDHCP=no\nLinkLocalAddressing=ipv6\n")
- fmt.Fprintf(&content, "Address=%s/%d\n", identity.IP, identity.Prefix)
- if identity.Gateway != "" {
- fmt.Fprintf(&content, "Gateway=%s\n", identity.Gateway)
- }
- for _, dns := range identity.DNS {
- fmt.Fprintf(&content, "DNS=%s\n", dns)
- }
- return content.String(), filename, nil
-}
-
-func removeStaleNetworkdIdentities(desired map[string]struct{}) error {
- entries, err := os.ReadDir(networkdConfigDir)
- if err != nil {
- return fmt.Errorf("list networkd identity configs: %w", err)
- }
- for _, entry := range entries {
- if entry.IsDir() || !strings.HasPrefix(entry.Name(), networkdIdentityPrefix) {
- continue
- }
- if _, ok := desired[entry.Name()]; ok {
- continue
- }
- if err := os.Remove(filepath.Join(networkdConfigDir, entry.Name())); err != nil {
- return fmt.Errorf("remove stale networkd identity %s: %w", entry.Name(), err)
- }
- }
- return nil
-}
-
-func reloadNetworkd() error {
- if _, err := os.Stat(networkdStateDir); err != nil {
- if errors.Is(err, os.ErrNotExist) {
- return nil
- }
- return fmt.Errorf("inspect networkd state: %w", err)
- }
- return runNetworkctl("reload")
-}
-
-func reconfigureNetworkd(interfaceNames []string) error {
- if _, err := os.Stat(networkdStateDir); err != nil {
- if errors.Is(err, os.ErrNotExist) {
- return nil
- }
- return fmt.Errorf("inspect networkd state: %w", err)
- }
- sort.Strings(interfaceNames)
- for _, name := range interfaceNames {
- if err := runNetworkctl("reconfigure", name); err != nil {
- return err
- }
- }
- return nil
-}
-
-func writeAtomic(path string, content []byte, mode os.FileMode) (retErr error) {
- tmp, err := os.CreateTemp(filepath.Dir(path), ".kumabox-network-*")
- if err != nil {
- return err
- }
- tmpPath := tmp.Name()
- closed := false
- defer func() {
- if !closed {
- if err := tmp.Close(); err != nil && retErr == nil {
- retErr = err
- }
- }
- if err := os.Remove(tmpPath); err != nil && !errors.Is(err, os.ErrNotExist) && retErr == nil {
- retErr = err
- }
- }()
- if err := tmp.Chmod(mode); err != nil {
- return err
- }
- if _, err := tmp.Write(content); err != nil {
- return err
- }
- if err := tmp.Sync(); err != nil {
- return err
- }
- if err := tmp.Close(); err != nil {
- return err
- }
- closed = true
- if err := os.Rename(tmpPath, path); err != nil {
- return err
- }
- return nil
-}
diff --git a/internal/agent/server/identity_networkd_test.go b/internal/agent/server/identity_networkd_test.go
deleted file mode 100644
index 96d7f43..0000000
--- a/internal/agent/server/identity_networkd_test.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package server
-
-import (
- "os"
- "path/filepath"
- "reflect"
- "strings"
- "testing"
-)
-
-func TestPersistNetworkdIdentity(t *testing.T) {
- originalDir := networkdConfigDir
- networkdConfigDir = t.TempDir()
- t.Cleanup(func() { networkdConfigDir = originalDir })
-
- stale := filepath.Join(networkdConfigDir, networkdIdentityPrefix+"stale.network")
- if err := os.WriteFile(stale, []byte("stale"), 0o644); err != nil {
- t.Fatal(err)
- }
- identities := []interfaceIdentity{{
- Name: "eth0", MAC: "FA:49:C4:3E:C0:85", IP: "10.88.0.3", Prefix: 16,
- Gateway: "10.88.0.1", DNS: []string{"1.1.1.1", "8.8.8.8"},
- }}
-
- names, err := persistNetworkdIdentity(identities)
- if err != nil {
- t.Fatal(err)
- }
- if !reflect.DeepEqual(names, []string{"eth0"}) {
- t.Fatalf("interface names = %v", names)
- }
- if _, err := os.Stat(stale); !os.IsNotExist(err) {
- t.Fatalf("stale identity remains: %v", err)
- }
- path := filepath.Join(networkdConfigDir, networkdIdentityPrefix+"fa49c43ec085.network")
- content, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- for _, want := range []string{
- "MACAddress=fa:49:c4:3e:c0:85", "DHCP=no", "Address=10.88.0.3/16",
- "Gateway=10.88.0.1", "DNS=1.1.1.1", "DNS=8.8.8.8",
- } {
- if !strings.Contains(string(content), want) {
- t.Fatalf("network config %q does not contain %q", content, want)
- }
- }
-}
-
-func TestRenderNetworkdIdentityRejectsInvalidInput(t *testing.T) {
- tests := []struct {
- name string
- identity interfaceIdentity
- }{
- {name: "MAC", identity: interfaceIdentity{Name: "eth0", MAC: "bad", IP: "10.88.0.3", Prefix: 16}},
- {name: "name", identity: interfaceIdentity{Name: "", MAC: "02:00:00:00:00:01", IP: "10.88.0.3", Prefix: 16}},
- {name: "IP", identity: interfaceIdentity{Name: "eth0", MAC: "02:00:00:00:00:01", IP: "bad", Prefix: 16}},
- {name: "prefix", identity: interfaceIdentity{Name: "eth0", MAC: "02:00:00:00:00:01", IP: "10.88.0.3", Prefix: 33}},
- {name: "gateway", identity: interfaceIdentity{Name: "eth0", MAC: "02:00:00:00:00:01", IP: "10.88.0.3", Prefix: 16, Gateway: "bad"}},
- {name: "DNS", identity: interfaceIdentity{Name: "eth0", MAC: "02:00:00:00:00:01", IP: "10.88.0.3", Prefix: 16, DNS: []string{"bad"}}},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if _, _, err := renderNetworkdIdentity(tt.identity); err == nil {
- t.Fatal("invalid identity was accepted")
- }
- })
- }
-}
-
-func TestReconfigureNetworkdUsesStableInterfaceOrder(t *testing.T) {
- originalStateDir := networkdStateDir
- originalRun := runNetworkctl
- networkdStateDir = t.TempDir()
- var calls [][]string
- runNetworkctl = func(args ...string) error {
- calls = append(calls, append([]string(nil), args...))
- return nil
- }
- t.Cleanup(func() {
- networkdStateDir = originalStateDir
- runNetworkctl = originalRun
- })
-
- if err := reloadNetworkd(); err != nil {
- t.Fatal(err)
- }
- if err := reconfigureNetworkd([]string{"eth1", "eth0"}); err != nil {
- t.Fatal(err)
- }
- want := [][]string{{"reload"}, {"reconfigure", "eth0"}, {"reconfigure", "eth1"}}
- if !reflect.DeepEqual(calls, want) {
- t.Fatalf("networkctl calls = %v, want %v", calls, want)
- }
-}
diff --git a/internal/agent/server/identity_other.go b/internal/agent/server/identity_other.go
deleted file mode 100644
index 552f9ad..0000000
--- a/internal/agent/server/identity_other.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !linux
-
-package server
-
-import "fmt"
-
-func applyIdentity(identityRequest) error {
- return fmt.Errorf("identity configuration is only supported on Linux")
-}
diff --git a/internal/agent/server/policy.go b/internal/agent/server/policy.go
deleted file mode 100644
index 693e79d..0000000
--- a/internal/agent/server/policy.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package server
-
-import (
- "fmt"
- "os"
- "strconv"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
-)
-
-const (
- defaultExecTimeout = 5 * time.Minute
- defaultMaxOutput = 16 << 20
- maxExecTimeout = 24 * time.Hour
- maxMaxOutput = 1 << 30
-)
-
-type execPolicy struct {
- timeout time.Duration
- maxOutput int64
- deniedEnv map[string]struct{}
-}
-
-func defaultPolicy() execPolicy {
- return execPolicy{timeout: defaultExecTimeout, maxOutput: defaultMaxOutput, deniedEnv: map[string]struct{}{
- "LD_PRELOAD": {}, "LD_LIBRARY_PATH": {},
- }}
-}
-
-func policyFromEnvironment() execPolicy {
- policy := defaultPolicy()
- if value := os.Getenv("KUMABOX_AGENT_EXEC_TIMEOUT"); value != "" {
- if duration, err := time.ParseDuration(value); err == nil && duration > 0 && duration <= maxExecTimeout {
- policy.timeout = duration
- }
- }
- if value := os.Getenv("KUMABOX_AGENT_MAX_OUTPUT_BYTES"); value != "" {
- if limit, err := strconv.ParseInt(value, 10, 64); err == nil && limit > 0 && limit <= maxMaxOutput {
- policy.maxOutput = limit
- }
- }
- if value := os.Getenv("KUMABOX_AGENT_DENY_ENV"); value != "" {
- policy.deniedEnv = make(map[string]struct{})
- for _, key := range strings.Split(value, ",") {
- key = strings.TrimSpace(key)
- if key != "" {
- policy.deniedEnv[key] = struct{}{}
- }
- }
- }
- return policy
-}
-
-func validateUser(user string) error {
- if user == "" || user == "root" {
- return nil
- }
- return fmt.Errorf("%w: only root is supported", protocol.ErrorUserUnsupported)
-}
-
-func validateEnvironment(values map[string]string, denied map[string]struct{}) error {
- for key := range values {
- if key == "" || strings.ContainsAny(key, "=\x00") || strings.ContainsRune(values[key], '\x00') {
- return fmt.Errorf("%w: invalid environment key", protocol.ErrorEnvDenied)
- }
- if _, blocked := denied[key]; blocked {
- return fmt.Errorf("%w: environment %q is denied", protocol.ErrorEnvDenied, key)
- }
- }
- return nil
-}
diff --git a/internal/agent/server/policy_test.go b/internal/agent/server/policy_test.go
deleted file mode 100644
index abb1ee1..0000000
--- a/internal/agent/server/policy_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package server
-
-import (
- "encoding/json"
- "strings"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
-)
-
-func TestPolicyRejectsUnsupportedUserAndSensitiveEnvironment(t *testing.T) {
- if err := validateUser("nobody"); err == nil || !strings.Contains(err.Error(), string(protocol.ErrorUserUnsupported)) {
- t.Fatalf("user error = %v", err)
- }
- if err := validateUser("root"); err != nil {
- t.Fatalf("root user rejected: %v", err)
- }
- if err := validateEnvironment(map[string]string{"LD_PRELOAD": "evil.so"}, defaultPolicy().deniedEnv); err == nil || !strings.Contains(err.Error(), string(protocol.ErrorEnvDenied)) {
- t.Fatalf("environment error = %v", err)
- }
-}
-
-func TestPolicyLimitsLegacyExecDurationAndOutput(t *testing.T) {
- original := agentPolicy
- agentPolicy = execPolicy{timeout: 20 * time.Millisecond, maxOutput: 8, deniedEnv: map[string]struct{}{}}
- defer func() { agentPolicy = original }()
-
- conn := &memoryConn{reader: strings.NewReader(`{"type":"exec","args":["sh","-c","sleep 1; printf 1234567890"]}` + "\n")}
- handleConn(conn)
- var response execResponse
- if err := json.Unmarshal(conn.writer.Bytes(), &response); err != nil {
- t.Fatal(err)
- }
- if response.OK || response.ExitCode != 124 || !strings.Contains(response.Error, "EXEC_TIMEOUT") {
- t.Fatalf("response = %+v", response)
- }
-}
diff --git a/internal/agent/server/process_linux.go b/internal/agent/server/process_linux.go
deleted file mode 100644
index 97665aa..0000000
--- a/internal/agent/server/process_linux.go
+++ /dev/null
@@ -1,34 +0,0 @@
-//go:build linux
-
-package server
-
-import (
- "context"
- "os/exec"
- "syscall"
-)
-
-func monitorProcess(ctx context.Context, cmd *exec.Cmd) chan struct{} {
- done := make(chan struct{})
- go func() {
- select {
- case <-ctx.Done():
- killProcessTree(cmd)
- case <-done:
- }
- }()
- return done
-}
-
-func configureProcess(cmd *exec.Cmd) {
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
-}
-
-func killProcessTree(cmd *exec.Cmd) {
- if cmd == nil || cmd.Process == nil {
- return
- }
- if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
- _ = cmd.Process.Kill()
- }
-}
diff --git a/internal/agent/server/process_other.go b/internal/agent/server/process_other.go
deleted file mode 100644
index 3bf2136..0000000
--- a/internal/agent/server/process_other.go
+++ /dev/null
@@ -1,28 +0,0 @@
-//go:build !linux
-
-package server
-
-import (
- "context"
- "os/exec"
-)
-
-func monitorProcess(ctx context.Context, cmd *exec.Cmd) chan struct{} {
- done := make(chan struct{})
- go func() {
- select {
- case <-ctx.Done():
- killProcessTree(cmd)
- case <-done:
- }
- }()
- return done
-}
-
-func configureProcess(_ *exec.Cmd) {}
-
-func killProcessTree(cmd *exec.Cmd) {
- if cmd != nil && cmd.Process != nil {
- _ = cmd.Process.Kill()
- }
-}
diff --git a/internal/agent/server/pty_linux.go b/internal/agent/server/pty_linux.go
deleted file mode 100644
index 533bd1f..0000000
--- a/internal/agent/server/pty_linux.go
+++ /dev/null
@@ -1,230 +0,0 @@
-//go:build linux
-
-package server
-
-import (
- "bufio"
- "context"
- "errors"
- "fmt"
- "io"
- "os"
- "os/exec"
- "strings"
- "syscall"
- "time"
-
- "golang.org/x/sys/unix"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
-)
-
-// Linux does not expose these PTY ioctls from x/sys on every supported
-// version, so keep the kernel ABI values together with the PTY implementation.
-const (
- ptyGetNumber = 0x80045430
- ptyUnlock = 0x40045431
-)
-
-func handleTTYExec(reader *bufio.Reader, rw io.ReadWriter, request protocol.Frame) {
- if err := validateUser(request.User); err != nil {
- writeStreamError(rw, request.ID, protocol.ErrorUserUnsupported, err.Error())
- return
- }
- if err := validateEnvironment(request.Env, agentPolicy.deniedEnv); err != nil {
- writeStreamError(rw, request.ID, protocol.ErrorEnvDenied, err.Error())
- return
- }
- master, slave, err := openPTY(request.Rows, request.Columns)
- if err != nil {
- writeStreamError(rw, request.ID, protocol.ErrorExecFailed, fmt.Sprintf("open PTY: %v", err))
- return
- }
- defer master.Close() //nolint:errcheck
- defer slave.Close() //nolint:errcheck
-
- ctx, cancel := context.WithTimeout(context.Background(), agentPolicy.timeout)
- defer cancel()
- cmd := exec.CommandContext(ctx, request.Args[0], request.Args[1:]...) //nolint:gosec
- cmd.WaitDelay = 2 * time.Second
- cmd.Dir = request.WorkDir
- cmd.Env = mergeEnvironment(request.Env)
- cmd.Stdin = slave
- cmd.Stdout = slave
- cmd.Stderr = slave
- // Ctty is an index into the child's stdin/stdout/stderr file list, not
- // the parent's PTY file descriptor.
- // setsid creates a new session whose process group is led by the child;
- // that gives killProcessTree a dedicated negative-PID target without the
- // incompatible Setpgid-after-Setsid combination.
- cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
- if err := cmd.Start(); err != nil {
- writeStreamError(rw, request.ID, protocol.ErrorExecFailed, err.Error())
- return
- }
- _ = slave.Close()
-
- writer := &streamWriter{writer: rw}
- if err := writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameReady, ID: request.ID}); err != nil {
- _ = cmd.Process.Kill()
- return
- }
-
- frames := make(chan protocol.Frame, 1)
- frameErrs := make(chan error, 1)
- go func() {
- decoder := protocol.NewDecoder(reader)
- for {
- frame, readErr := decoder.ReadFrame()
- if readErr != nil {
- frameErrs <- readErr
- return
- }
- frames <- frame
- }
- }()
-
- outputErrs := make(chan error, 1)
- outputDone := make(chan error, 1)
- output := &outputBudget{limit: agentPolicy.maxOutput}
- go func() {
- buf := make([]byte, streamChunkSize)
- for {
- n, readErr := master.Read(buf)
- if n > 0 {
- if _, writeErr := (&streamOutputWriter{writer: writer, id: request.ID, frameType: protocol.FrameStdout, stream: protocol.StreamStdout, budget: output}).Write(buf[:n]); writeErr != nil {
- outputErrs <- writeErr
- outputDone <- writeErr
- return
- }
- }
- if readErr != nil {
- if errors.Is(readErr, syscall.EIO) || errors.Is(readErr, io.EOF) {
- outputDone <- nil
- } else {
- outputErrs <- readErr
- outputDone <- readErr
- }
- return
- }
- }
- }()
-
- waitErrs := make(chan error, 1)
- go func() { waitErrs <- cmd.Wait() }()
- for {
- select {
- case <-ctx.Done():
- killProcessTree(cmd)
- if ctx.Err() == context.DeadlineExceeded {
- writeStreamError(rw, request.ID, protocol.ErrorExecTimeout, "execution exceeded policy timeout")
- }
- return
- case frame := <-frames:
- if frame.ID != request.ID {
- _ = cmd.Process.Kill()
- return
- }
- switch frame.Type {
- case protocol.FrameStdin:
- data := frame.Data
- if frame.End {
- data = append(data, 4) // terminal EOF (Ctrl-D)
- }
- if _, err := master.Write(data); err != nil {
- _ = cmd.Process.Kill()
- return
- }
- case protocol.FrameResize:
- if err := resizePTY(master, frame.Rows, frame.Columns); err != nil {
- writeStreamError(rw, request.ID, protocol.ErrorInvalidRequest, err.Error())
- }
- case protocol.FrameSignal:
- if err := signalProcess(cmd, frame.Signal); err != nil {
- writeStreamError(rw, request.ID, protocol.ErrorInvalidRequest, err.Error())
- }
- default:
- _ = cmd.Process.Kill()
- return
- }
- case <-frameErrs:
- _ = cmd.Process.Kill()
- return
- case outputErr := <-outputErrs:
- if outputErr != nil {
- killProcessTree(cmd)
- if output.exceeded() {
- writeStreamError(rw, request.ID, protocol.ErrorOutputLimit, "execution output exceeded policy limit")
- }
- return
- }
- case waitErr := <-waitErrs:
- <-outputDone
- exitCode := 0
- if waitErr != nil {
- exitCode = commandExitCode(waitErr)
- }
- _ = writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameExit, ID: request.ID, ExitCode: exitCode})
- return
- }
- }
-}
-
-func openPTY(rows, columns uint16) (*os.File, *os.File, error) {
- masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
- if err != nil {
- return nil, nil, err
- }
- closeMaster := true
- defer func() {
- if closeMaster {
- _ = unix.Close(masterFD)
- }
- }()
- if err := unix.IoctlSetPointerInt(masterFD, ptyUnlock, 0); err != nil {
- return nil, nil, err
- }
- ptyNumber, err := unix.IoctlGetInt(masterFD, ptyGetNumber)
- if err != nil {
- return nil, nil, err
- }
- slaveFD, err := unix.Open(fmt.Sprintf("/dev/pts/%d", ptyNumber), unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
- if err != nil {
- return nil, nil, err
- }
- master := os.NewFile(uintptr(masterFD), "/dev/ptmx")
- slave := os.NewFile(uintptr(slaveFD), fmt.Sprintf("/dev/pts/%d", ptyNumber))
- if err := resizePTY(master, rows, columns); err != nil {
- _ = master.Close()
- _ = slave.Close()
- return nil, nil, err
- }
- closeMaster = false
- return master, slave, nil
-}
-
-func resizePTY(master *os.File, rows, columns uint16) error {
- if rows == 0 || columns == 0 {
- return fmt.Errorf("PTY dimensions must be non-zero")
- }
- return unix.IoctlSetWinsize(int(master.Fd()), unix.TIOCSWINSZ, &unix.Winsize{Row: rows, Col: columns})
-}
-
-func signalProcess(cmd *exec.Cmd, name string) error {
- var signal syscall.Signal
- switch strings.ToUpper(strings.TrimPrefix(name, "SIG")) {
- case "INT":
- signal = syscall.SIGINT
- case "TERM":
- signal = syscall.SIGTERM
- case "HUP":
- signal = syscall.SIGHUP
- case "WINCH":
- signal = syscall.SIGWINCH
- case "QUIT":
- signal = syscall.SIGQUIT
- default:
- return fmt.Errorf("unsupported signal %q", name)
- }
- return cmd.Process.Signal(signal)
-}
diff --git a/internal/agent/server/pty_linux_test.go b/internal/agent/server/pty_linux_test.go
deleted file mode 100644
index f0eb9c6..0000000
--- a/internal/agent/server/pty_linux_test.go
+++ /dev/null
@@ -1,56 +0,0 @@
-//go:build linux
-
-package server
-
-import (
- "bytes"
- "net"
- "testing"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
-)
-
-func TestHandleConnTTYExecUsesPTYAndMergesOutput(t *testing.T) {
- serverConn, clientConn := net.Pipe()
- defer clientConn.Close() //nolint:errcheck
- serverDone := make(chan struct{})
- go func() {
- handleConn(serverConn)
- _ = serverConn.Close()
- close(serverDone)
- }()
-
- request := protocol.Frame{
- Version: protocol.VersionV1,
- Type: protocol.FrameExec,
- ID: "tty-test",
- Args: []string{"sh", "-c", "printf out; printf err >&2"},
- TTY: true,
- Rows: 24,
- Columns: 80,
- }
- if err := protocol.WriteFrame(clientConn, request); err != nil {
- t.Fatal(err)
- }
- decoder := protocol.NewDecoder(clientConn)
- var output bytes.Buffer
- exitCode := -1
- for exitCode < 0 {
- frame, err := decoder.ReadFrame()
- if err != nil {
- t.Fatalf("read agent frame: %v (output=%q, exit=%d)", err, output.String(), exitCode)
- }
- switch frame.Type {
- case protocol.FrameStdout:
- output.Write(frame.Data)
- case protocol.FrameError:
- t.Fatalf("guest PTY failed: %s: %s", frame.Code, frame.Message)
- case protocol.FrameExit:
- exitCode = frame.ExitCode
- }
- }
- if exitCode != 0 || output.String() != "outerr" {
- t.Fatalf("exit=%d output=%q", exitCode, output.String())
- }
- <-serverDone
-}
diff --git a/internal/agent/server/pty_other.go b/internal/agent/server/pty_other.go
deleted file mode 100644
index 6f81676..0000000
--- a/internal/agent/server/pty_other.go
+++ /dev/null
@@ -1,14 +0,0 @@
-//go:build !linux
-
-package server
-
-import (
- "bufio"
- "io"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
-)
-
-func handleTTYExec(_ *bufio.Reader, rw io.ReadWriter, request protocol.Frame) {
- writeStreamError(rw, request.ID, protocol.ErrorCapabilityMissing, "TTY exec is only supported by the Linux guest agent")
-}
diff --git a/internal/agent/server/reseed_linux.go b/internal/agent/server/reseed_linux.go
deleted file mode 100644
index 568049f..0000000
--- a/internal/agent/server/reseed_linux.go
+++ /dev/null
@@ -1,122 +0,0 @@
-//go:build linux
-
-package server
-
-import (
- "crypto/rand"
- "encoding/binary"
- "encoding/hex"
- "errors"
- "fmt"
- "io/fs"
- "os"
- "unsafe"
-
- "golang.org/x/sys/unix"
-)
-
-const (
- agentReseedEntropyBytes = 32
- machineIDBytes = 16
-
- urandomPath = "/dev/urandom"
- systemdRandomSeed = "/var/lib/systemd/random-seed"
- machineIDPath = "/etc/machine-id"
- dbusMachineIDPath = "/var/lib/dbus/machine-id"
-)
-
-func applyReseed(req reseedRequest) error {
- var errs []error
- if err := reseedKernel(req.Entropy); err != nil {
- errs = append(errs, err)
- }
- if err := os.Remove(systemdRandomSeed); err != nil && !errors.Is(err, fs.ErrNotExist) {
- errs = append(errs, fmt.Errorf("remove systemd random seed: %w", err))
- }
- if req.RegenerateMachineID {
- if err := regenerateMachineID(); err != nil {
- errs = append(errs, err)
- }
- }
- return errors.Join(errs...)
-}
-
-func reseedKernel(entropy []byte) error {
- fd, err := unix.Open(urandomPath, unix.O_WRONLY, 0)
- if err != nil {
- return fmt.Errorf("open %s: %w", urandomPath, err)
- }
- var errs []error
- if err := addKernelEntropy(fd, entropy); err != nil {
- errs = append(errs, err)
- }
- if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), unix.RNDRESEEDCRNG, 0); errno != 0 {
- errs = append(errs, fmt.Errorf("reseed CRNG: %w", errno))
- }
- if err := unix.Close(fd); err != nil {
- errs = append(errs, fmt.Errorf("close %s: %w", urandomPath, err))
- }
- return errors.Join(errs...)
-}
-
-func addKernelEntropy(fd int, entropy []byte) error {
- buffer := make([]byte, 8+len(entropy))
- defer clear(buffer)
- binary.NativeEndian.PutUint32(buffer[0:4], uint32(len(entropy)*8)) //nolint:gosec // request size is fixed at 32 bytes
- binary.NativeEndian.PutUint32(buffer[4:8], uint32(len(entropy))) //nolint:gosec // request size is fixed at 32 bytes
- copy(buffer[8:], entropy)
- if _, _, errno := unix.Syscall(
- unix.SYS_IOCTL,
- uintptr(fd),
- unix.RNDADDENTROPY,
- uintptr(unsafe.Pointer(&buffer[0])), //nolint:gosec // ioctl requires rand_pool_info memory layout
- ); errno != 0 {
- return fmt.Errorf("add entropy: %w", errno)
- }
- return nil
-}
-
-func regenerateMachineID() error {
- if _, err := os.Stat(machineIDPath); err != nil {
- if errors.Is(err, fs.ErrNotExist) {
- return nil
- }
- return fmt.Errorf("stat %s: %w", machineIDPath, err)
- }
- id, err := randomMachineID()
- if err != nil {
- return err
- }
- if err := os.WriteFile(machineIDPath, []byte(id), 0o444); err != nil { //nolint:gosec // machine-id is conventionally world-readable
- return fmt.Errorf("write %s: %w", machineIDPath, err)
- }
- if err := dropStaleDBusMachineID(dbusMachineIDPath); err != nil {
- auditLog.Printf("reseed warning: drop stale D-Bus machine ID: %v", err)
- }
- return nil
-}
-
-func randomMachineID() (string, error) {
- raw := make([]byte, machineIDBytes)
- if _, err := rand.Read(raw); err != nil {
- return "", fmt.Errorf("generate machine ID: %w", err)
- }
- return hex.EncodeToString(raw) + "\n", nil
-}
-
-func dropStaleDBusMachineID(path string) error {
- info, err := os.Lstat(path)
- if err != nil {
- if errors.Is(err, fs.ErrNotExist) {
- return nil
- }
- return fmt.Errorf("inspect %s: %w", path, err)
- }
- if !info.Mode().IsRegular() {
- return nil
- }
- if err := os.Remove(path); err != nil {
- return fmt.Errorf("remove %s: %w", path, err)
- }
- return nil
-}
diff --git a/internal/agent/server/reseed_linux_test.go b/internal/agent/server/reseed_linux_test.go
deleted file mode 100644
index 21b90df..0000000
--- a/internal/agent/server/reseed_linux_test.go
+++ /dev/null
@@ -1,60 +0,0 @@
-//go:build linux
-
-package server
-
-import (
- "errors"
- "io/fs"
- "os"
- "path/filepath"
- "regexp"
- "testing"
-)
-
-var machineIDPattern = regexp.MustCompile(`^[0-9a-f]{32}\n$`)
-
-func TestRandomMachineIDIsCanonicalAndUnique(t *testing.T) {
- first, err := randomMachineID()
- if err != nil {
- t.Fatal(err)
- }
- second, err := randomMachineID()
- if err != nil {
- t.Fatal(err)
- }
- if !machineIDPattern.MatchString(first) || !machineIDPattern.MatchString(second) || first == second {
- t.Fatalf("machine IDs = %q and %q", first, second)
- }
-}
-
-func TestDropStaleDBusMachineID(t *testing.T) {
- t.Run("regular file", func(t *testing.T) {
- path := filepath.Join(t.TempDir(), "machine-id")
- if err := os.WriteFile(path, []byte("old\n"), 0o444); err != nil {
- t.Fatal(err)
- }
- if err := dropStaleDBusMachineID(path); err != nil {
- t.Fatal(err)
- }
- if _, err := os.Lstat(path); !errors.Is(err, fs.ErrNotExist) {
- t.Fatalf("regular file remains: %v", err)
- }
- })
- t.Run("symlink", func(t *testing.T) {
- dir := t.TempDir()
- target := filepath.Join(dir, "target")
- if err := os.WriteFile(target, []byte("id\n"), 0o444); err != nil {
- t.Fatal(err)
- }
- link := filepath.Join(dir, "machine-id")
- if err := os.Symlink(target, link); err != nil {
- t.Fatal(err)
- }
- if err := dropStaleDBusMachineID(link); err != nil {
- t.Fatal(err)
- }
- if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
- t.Fatalf("symlink was not preserved: info=%v error=%v", info, err)
- }
- })
-}
diff --git a/internal/agent/server/reseed_other.go b/internal/agent/server/reseed_other.go
deleted file mode 100644
index 810950c..0000000
--- a/internal/agent/server/reseed_other.go
+++ /dev/null
@@ -1,11 +0,0 @@
-//go:build !linux
-
-package server
-
-import "fmt"
-
-const agentReseedEntropyBytes = 32
-
-func applyReseed(reseedRequest) error {
- return fmt.Errorf("reseed is only supported on Linux")
-}
diff --git a/internal/agent/server/server.go b/internal/agent/server/server.go
deleted file mode 100644
index 93b354a..0000000
--- a/internal/agent/server/server.go
+++ /dev/null
@@ -1,528 +0,0 @@
-// Package server implements the guest-side KumaBox agent service.
-package server
-
-import (
- "bufio"
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "log"
- "os"
- "os/exec"
- "runtime"
- "strings"
- "sync"
- "time"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
-)
-
-const (
- Version = "0.3.3"
- Port = protocol.AgentPort
-)
-
-var capabilities = []string{
- string(protocol.CapabilityPingPong),
- string(protocol.CapabilityExec),
- string(protocol.CapabilityExecStream),
- string(protocol.CapabilityExecTTY),
- string(protocol.CapabilityIdentity),
- string(protocol.CapabilityReseed),
-}
-
-var agentPolicy = policyFromEnvironment()
-
-var auditLog = log.New(os.Stderr, "kumabox-agent: ", log.LstdFlags)
-
-const streamChunkSize = 32 * 1024
-
-type pingRequest struct {
- Type protocol.RequestType `json:"type"`
-}
-
-type pingResponse struct {
- OK bool `json:"ok"`
- Version string `json:"version,omitempty"`
- OS string `json:"os,omitempty"`
- Hostname string `json:"hostname,omitempty"`
- Capabilities []string `json:"capabilities,omitempty"`
- Error string `json:"error,omitempty"`
-}
-
-type execRequest struct {
- Type protocol.RequestType `json:"type"`
- Args []string `json:"args"`
- Env []string `json:"env,omitempty"`
- WorkDir string `json:"workdir,omitempty"`
- Stdin []byte `json:"stdin,omitempty"`
- User string `json:"user,omitempty"`
-}
-
-type execResponse struct {
- OK bool `json:"ok"`
- ExitCode int `json:"exitCode"`
- Stdout []byte `json:"stdout,omitempty"`
- Stderr []byte `json:"stderr,omitempty"`
- Error string `json:"error,omitempty"`
-}
-
-type identityRequest struct {
- Type protocol.RequestType `json:"type"`
- Hostname string `json:"hostname"`
- Interfaces []interfaceIdentity `json:"interfaces,omitempty"`
-}
-
-type interfaceIdentity struct {
- Name string `json:"name"`
- MAC string `json:"mac"`
- IP string `json:"ip,omitempty"`
- Prefix int `json:"prefix,omitempty"`
- Gateway string `json:"gateway,omitempty"`
- DNS []string `json:"dns,omitempty"`
-}
-
-type identityResponse struct {
- OK bool `json:"ok"`
- Error string `json:"error,omitempty"`
-}
-
-type reseedRequest struct {
- Type protocol.RequestType `json:"type"`
- Entropy []byte `json:"entropy"`
- RegenerateMachineID bool `json:"regenerateMachineId,omitempty"`
-}
-
-type reseedResponse struct {
- OK bool `json:"ok"`
- Error string `json:"error,omitempty"`
-}
-
-var configureIdentity = applyIdentity
-var reseedGuest = applyReseed
-
-func Serve() error {
- return serveVsock(Port, handleConn)
-}
-
-func handleConn(rw io.ReadWriter) {
- reader := bufio.NewReader(rw)
- line, err := reader.ReadString('\n')
- if err != nil {
- writeResponse(rw, pingResponse{OK: false, Error: err.Error()})
- return
- }
- var envelope struct {
- Version string `json:"version"`
- Type string `json:"type"`
- }
- if err := json.Unmarshal([]byte(line), &envelope); err == nil && envelope.Version == protocol.VersionV1 {
- var first protocol.Frame
- if err := json.Unmarshal([]byte(line), &first); err != nil {
- writeStreamError(rw, "unknown", protocol.ErrorInvalidFrame, "invalid JSON frame")
- return
- }
- if err := first.Validate(); err != nil {
- writeStreamError(rw, first.ID, protocol.ErrorInvalidFrame, err.Error())
- return
- }
- if first.Type != protocol.FrameExec {
- writeStreamError(rw, first.ID, protocol.ErrorInvalidRequest, "first stream frame must be exec")
- return
- }
- handleStreamExec(reader, rw, first)
- return
- }
- var req pingRequest
- if err := json.Unmarshal([]byte(line), &req); err != nil {
- writeResponse(rw, pingResponse{OK: false, Error: "invalid request"})
- return
- }
- switch protocol.RequestType(strings.ToLower(string(req.Type))) {
- case protocol.RequestPing:
- handlePingPong(rw)
- case protocol.RequestExec:
- handleExec(rw, []byte(line))
- case protocol.RequestIdentity:
- handleIdentity(rw, []byte(line))
- case protocol.RequestReseed:
- handleReseed(rw, []byte(line))
- default:
- writeResponse(rw, pingResponse{OK: false, Error: "unsupported request"})
- }
-}
-
-func handleStreamExec(reader *bufio.Reader, rw io.ReadWriter, request protocol.Frame) {
- if request.TTY {
- handleTTYExec(reader, rw, request)
- return
- }
- if err := validateUser(request.User); err != nil {
- writeStreamError(rw, request.ID, protocol.ErrorUserUnsupported, err.Error())
- return
- }
- if err := validateEnvironment(request.Env, agentPolicy.deniedEnv); err != nil {
- writeStreamError(rw, request.ID, protocol.ErrorEnvDenied, err.Error())
- return
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), agentPolicy.timeout)
- defer cancel()
- cmd := exec.CommandContext(ctx, request.Args[0], request.Args[1:]...) //nolint:gosec
- cmd.WaitDelay = 2 * time.Second
- configureProcess(cmd)
- cmd.Dir = request.WorkDir
- cmd.Env = mergeEnvironment(request.Env)
- writer := &streamWriter{writer: rw}
- output := &outputBudget{limit: agentPolicy.maxOutput}
- cmd.Stdout = &streamOutputWriter{writer: writer, id: request.ID, frameType: protocol.FrameStdout, stream: protocol.StreamStdout, budget: output}
- cmd.Stderr = &streamOutputWriter{writer: writer, id: request.ID, frameType: protocol.FrameStderr, stream: protocol.StreamStderr, budget: output}
- stdin, err := cmd.StdinPipe()
- if err != nil {
- writeStreamError(rw, request.ID, protocol.ErrorExecFailed, err.Error())
- return
- }
- if err := cmd.Start(); err != nil {
- writeStreamError(rw, request.ID, protocol.ErrorExecFailed, err.Error())
- return
- }
- processDone := monitorProcess(ctx, cmd)
- defer close(processDone)
- startedAt := time.Now()
- auditLog.Printf("exec start command=%q user=%q", request.Args[0], effectiveUser(request.User))
-
- if err := writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameReady, ID: request.ID}); err != nil {
- _ = cmd.Process.Kill()
- return
- }
-
- decoder := protocol.NewDecoder(reader)
- inputClosed := false
- for !inputClosed {
- frame, readErr := decoder.ReadFrame()
- if readErr != nil {
- _ = cmd.Process.Kill()
- return
- }
- if frame.ID != request.ID {
- _ = writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameError, ID: request.ID, Code: protocol.ErrorInvalidFrame, Message: "stdin frame has unexpected exec id"})
- _ = cmd.Process.Kill()
- return
- }
- switch frame.Type {
- case protocol.FrameStdin:
- if _, writeErr := stdin.Write(frame.Data); writeErr != nil {
- _ = cmd.Process.Kill()
- return
- }
- if frame.End {
- _ = stdin.Close()
- inputClosed = true
- }
- default:
- _ = writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameError, ID: request.ID, Code: protocol.ErrorInvalidFrame, Message: "non-stdin frame received before stdin ended"})
- _ = cmd.Process.Kill()
- return
- }
- }
-
- waitErr := cmd.Wait()
- if ctx.Err() != nil {
- killProcessTree(cmd)
- }
- exitCode := 0
- if waitErr != nil {
- exitCode = commandExitCode(waitErr)
- }
- if ctx.Err() == context.DeadlineExceeded {
- exitCode = 124
- _ = writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameError, ID: request.ID, Code: protocol.ErrorExecTimeout, Message: "execution exceeded policy timeout"})
- }
- if output.exceeded() {
- exitCode = 124
- _ = writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameError, ID: request.ID, Code: protocol.ErrorOutputLimit, Message: "execution output exceeded policy limit"})
- }
- auditLog.Printf("exec end command=%q user=%q exit=%d duration_ms=%d", request.Args[0], effectiveUser(request.User), exitCode, time.Since(startedAt).Milliseconds())
- _ = writer.Write(protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameExit, ID: request.ID, ExitCode: exitCode})
-}
-
-func mergeEnvironment(values map[string]string) []string {
- if len(values) == 0 {
- return os.Environ()
- }
- merged := make(map[string]string, len(os.Environ())+len(values))
- for _, pair := range os.Environ() {
- key, value, ok := strings.Cut(pair, "=")
- if ok {
- if _, exists := merged[key]; !exists {
- merged[key] = value
- }
- }
- }
- for key, value := range values {
- merged[key] = value
- }
- result := make([]string, 0, len(merged))
- for key, value := range merged {
- result = append(result, key+"="+value)
- }
- return result
-}
-
-func effectiveUser(user string) string {
- if user == "" {
- return "root"
- }
- return user
-}
-
-func commandExitCode(err error) int {
- if exitErr, ok := err.(*exec.ExitError); ok {
- if code := exitErr.ExitCode(); code >= 0 {
- return code
- }
- return 128
- }
- return 127
-}
-
-type streamWriter struct {
- mu sync.Mutex
- writer io.Writer
-}
-
-func (w *streamWriter) Write(frame protocol.Frame) error {
- w.mu.Lock()
- defer w.mu.Unlock()
- return protocol.WriteFrame(w.writer, frame)
-}
-
-type streamOutputWriter struct {
- writer *streamWriter
- id string
- frameType protocol.FrameType
- stream protocol.Stream
- budget *outputBudget
-}
-
-func (w *streamOutputWriter) Write(data []byte) (int, error) {
- if !w.budget.reserve(int64(len(data))) {
- return 0, fmt.Errorf("%w: maximum output is %d bytes", protocol.ErrorOutputLimit, w.budget.limit)
- }
- total := 0
- for len(data) > 0 {
- chunkSize := min(len(data), streamChunkSize)
- chunk := append([]byte(nil), data[:chunkSize]...)
- if err := w.writer.Write(protocol.Frame{
- Version: protocol.VersionV1,
- Type: w.frameType,
- ID: w.id,
- Stream: w.stream,
- Data: chunk,
- }); err != nil {
- return total, err
- }
- total += chunkSize
- data = data[chunkSize:]
- }
- return total, nil
-}
-
-type outputBudget struct {
- mu sync.Mutex
- limit int64
- used int64
- wasExceeded bool
-}
-
-func (b *outputBudget) reserve(size int64) bool {
- b.mu.Lock()
- defer b.mu.Unlock()
- if b.used+size > b.limit {
- b.wasExceeded = true
- return false
- }
- b.used += size
- return true
-}
-
-func (b *outputBudget) exceeded() bool {
- b.mu.Lock()
- defer b.mu.Unlock()
- return b.wasExceeded
-}
-
-func writeStreamError(w io.Writer, id string, code protocol.ErrorCode, message string) {
- if id == "" {
- id = "unknown"
- }
- _ = protocol.WriteFrame(w, protocol.Frame{
- Version: protocol.VersionV1,
- Type: protocol.FrameError,
- ID: id,
- Code: code,
- Message: message,
- })
-}
-
-func handleIdentity(w io.Writer, raw []byte) {
- var req identityRequest
- if err := json.Unmarshal(raw, &req); err != nil {
- writeResponse(w, identityResponse{OK: false, Error: "invalid identity request"})
- return
- }
- auditLog.Printf("identity start hostname=%q interfaces=%d", req.Hostname, len(req.Interfaces))
- if err := configureIdentity(req); err != nil {
- auditLog.Printf("identity failed hostname=%q: %v", req.Hostname, err)
- writeResponse(w, identityResponse{OK: false, Error: err.Error()})
- return
- }
- writeResponse(w, identityResponse{OK: true})
- auditLog.Printf("identity complete hostname=%q", req.Hostname)
-}
-
-func handleReseed(w io.Writer, raw []byte) {
- var req reseedRequest
- if err := json.Unmarshal(raw, &req); err != nil {
- writeResponse(w, reseedResponse{OK: false, Error: "invalid reseed request"})
- return
- }
- if len(req.Entropy) != agentReseedEntropyBytes {
- clear(req.Entropy)
- writeResponse(w, reseedResponse{OK: false, Error: fmt.Sprintf("reseed entropy must be %d bytes", agentReseedEntropyBytes)})
- return
- }
- auditLog.Printf("reseed start regenerate_machine_id=%t", req.RegenerateMachineID)
- err := reseedGuest(req)
- clear(req.Entropy)
- if err != nil {
- auditLog.Printf("reseed failed: %v", err)
- writeResponse(w, reseedResponse{OK: false, Error: err.Error()})
- return
- }
- writeResponse(w, reseedResponse{OK: true})
- auditLog.Printf("reseed complete regenerate_machine_id=%t", req.RegenerateMachineID)
-}
-
-func handlePingPong(w io.Writer) {
- hostname, _ := os.Hostname()
- writeResponse(w, pingResponse{
- OK: true,
- Version: Version,
- OS: runtime.GOOS,
- Hostname: hostname,
- Capabilities: append([]string(nil), capabilities...),
- })
-}
-
-func handleExec(w io.Writer, raw []byte) {
- var req execRequest
- if err := json.Unmarshal(raw, &req); err != nil {
- writeResponse(w, execResponse{OK: false, ExitCode: 127, Error: "invalid exec request"})
- return
- }
- if len(req.Args) == 0 || req.Args[0] == "" {
- writeResponse(w, execResponse{OK: false, ExitCode: 127, Error: "exec args must not be empty"})
- return
- }
- if err := validateUser(req.User); err != nil {
- writeResponse(w, execResponse{OK: false, ExitCode: 126, Error: err.Error()})
- return
- }
- env, err := environmentMapFromPairs(req.Env)
- if err != nil {
- writeResponse(w, execResponse{OK: false, ExitCode: 126, Error: err.Error()})
- return
- }
- if err := validateEnvironment(env, agentPolicy.deniedEnv); err != nil {
- writeResponse(w, execResponse{OK: false, ExitCode: 126, Error: err.Error()})
- return
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), agentPolicy.timeout)
- defer cancel()
- cmd := exec.CommandContext(ctx, req.Args[0], req.Args[1:]...) //nolint:gosec
- cmd.WaitDelay = 2 * time.Second
- configureProcess(cmd)
- cmd.Dir = req.WorkDir
- cmd.Env = mergeEnvironment(env)
- cmd.Stdin = bytes.NewReader(req.Stdin)
- stdout := &limitedBuffer{limit: agentPolicy.maxOutput}
- stderr := &limitedBuffer{limit: agentPolicy.maxOutput}
- cmd.Stdout = stdout
- cmd.Stderr = stderr
-
- resp := execResponse{OK: true}
- startedAt := time.Now()
- auditLog.Printf("exec start command=%q user=%q", req.Args[0], effectiveUser(req.User))
- if startErr := cmd.Start(); startErr != nil {
- resp.OK = false
- resp.Error = startErr.Error()
- resp.ExitCode = 127
- } else {
- processDone := monitorProcess(ctx, cmd)
- waitErr := cmd.Wait()
- close(processDone)
- if waitErr != nil {
- resp.OK = false
- resp.Error = waitErr.Error()
- if exitErr, ok := waitErr.(*exec.ExitError); ok {
- resp.ExitCode = exitErr.ExitCode()
- } else {
- resp.ExitCode = 127
- }
- }
- }
- if ctx.Err() == context.DeadlineExceeded {
- resp.OK, resp.ExitCode, resp.Error = false, 124, "EXEC_TIMEOUT: execution exceeded policy timeout"
- }
- if stdout.exceeded || stderr.exceeded {
- resp.OK, resp.ExitCode, resp.Error = false, 124, "OUTPUT_LIMIT: execution output exceeded policy limit"
- }
- resp.Stdout = stdout.Bytes()
- resp.Stderr = stderr.Bytes()
- auditLog.Printf("exec end command=%q user=%q exit=%d duration_ms=%d", req.Args[0], effectiveUser(req.User), resp.ExitCode, time.Since(startedAt).Milliseconds())
- writeResponse(w, resp)
-}
-
-func environmentMapFromPairs(values []string) (map[string]string, error) {
- result := make(map[string]string, len(values))
- for _, pair := range values {
- key, value, ok := strings.Cut(pair, "=")
- if !ok || key == "" || strings.ContainsRune(key, '\x00') || strings.ContainsRune(value, '\x00') {
- return nil, fmt.Errorf("%w: environment must be KEY=VALUE", protocol.ErrorEnvDenied)
- }
- result[key] = value
- }
- return result, nil
-}
-
-type limitedBuffer struct {
- bytes.Buffer
- limit int64
- exceeded bool
-}
-
-func (b *limitedBuffer) Write(data []byte) (int, error) {
- remaining := b.limit - int64(b.Len())
- if remaining <= 0 {
- b.exceeded = true
- return 0, fmt.Errorf("%w", protocol.ErrorOutputLimit)
- }
- if int64(len(data)) > remaining {
- data = data[:int(remaining)]
- b.exceeded = true
- }
- return b.Buffer.Write(data)
-}
-
-func writeResponse(w io.Writer, resp any) {
- raw, err := json.Marshal(resp)
- if err != nil {
- _, _ = fmt.Fprintln(w, `{"ok":false,"error":"encode response"}`)
- return
- }
- _, _ = w.Write(append(raw, '\n'))
-}
diff --git a/internal/agent/server/server_test.go b/internal/agent/server/server_test.go
deleted file mode 100644
index f2c7040..0000000
--- a/internal/agent/server/server_test.go
+++ /dev/null
@@ -1,224 +0,0 @@
-package server
-
-import (
- "bytes"
- "encoding/json"
- "net"
- "slices"
- "strings"
- "testing"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
-)
-
-type memoryConn struct {
- reader *strings.Reader
- writer bytes.Buffer
-}
-
-func (c *memoryConn) Read(p []byte) (int, error) {
- return c.reader.Read(p)
-}
-
-func (c *memoryConn) Write(p []byte) (int, error) {
- return c.writer.Write(p)
-}
-
-func TestHandleConnRespondsToPingPong(t *testing.T) {
- t.Parallel()
-
- conn := &memoryConn{reader: strings.NewReader(`{"type":"ping"}` + "\n")}
- handleConn(conn)
-
- var resp pingResponse
- if err := json.Unmarshal(conn.writer.Bytes(), &resp); err != nil {
- t.Fatal(err)
- }
- if !resp.OK || resp.Version != Version || resp.OS == "" || resp.Hostname == "" {
- t.Fatalf("response = %+v", resp)
- }
- for _, capability := range []string{"ping-pong", "exec", "exec-stream", "exec-tty", "identity", "reseed"} {
- if !slices.Contains(resp.Capabilities, capability) {
- t.Fatalf("capabilities = %v, want %s", resp.Capabilities, capability)
- }
- }
- if slices.Contains(resp.Capabilities, "freeze") || slices.Contains(resp.Capabilities, "thaw") {
- t.Fatalf("capabilities = %v, freeze/thaw must not be advertised", resp.Capabilities)
- }
- if !slices.Contains(resp.Capabilities, "exec-stream") {
- t.Fatalf("capabilities = %v, want exec-stream", resp.Capabilities)
- }
-}
-
-func TestHandleConnStreamExecForwardsStreamsAndExit(t *testing.T) {
- t.Parallel()
-
- serverConn, clientConn := net.Pipe()
- defer clientConn.Close() //nolint:errcheck
- serverDone := make(chan struct{})
- go func() {
- handleConn(serverConn)
- _ = serverConn.Close()
- close(serverDone)
- }()
-
- request := protocolFrameExec("sh", "-c", "cat; printf err >&2; exit 7")
- writeDone := make(chan error, 1)
- go func() {
- if err := protocol.WriteFrame(clientConn, request); err != nil {
- writeDone <- err
- return
- }
- if err := protocol.WriteFrame(clientConn, protocolFrameStdin(request.ID, []byte("hello"), false)); err != nil {
- writeDone <- err
- return
- }
- writeDone <- protocol.WriteFrame(clientConn, protocolFrameStdin(request.ID, nil, true))
- }()
-
- decoder := protocol.NewDecoder(clientConn)
- var stdout, stderr bytes.Buffer
- exitCode := -1
- for exitCode < 0 {
- frame, err := decoder.ReadFrame()
- if err != nil {
- t.Fatal(err)
- }
- switch frame.Type {
- case protocol.FrameStdout:
- stdout.Write(frame.Data)
- case protocol.FrameStderr:
- stderr.Write(frame.Data)
- case protocol.FrameExit:
- exitCode = frame.ExitCode
- }
- }
- if err := <-writeDone; err != nil {
- t.Fatal(err)
- }
- if exitCode != 7 || stdout.String() != "hello" || stderr.String() != "err" {
- t.Fatalf("exit=%d stdout=%q stderr=%q", exitCode, stdout.String(), stderr.String())
- }
- <-serverDone
-}
-
-func protocolFrameExec(args ...string) protocol.Frame {
- return protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameExec, ID: "stream-test", Args: args}
-}
-
-func protocolFrameStdin(id string, data []byte, end bool) protocol.Frame {
- return protocol.Frame{Version: protocol.VersionV1, Type: protocol.FrameStdin, ID: id, Stream: protocol.StreamStdin, Data: data, End: end}
-}
-
-func TestHandleConnRejectsUnsupportedRequest(t *testing.T) {
- t.Parallel()
-
- conn := &memoryConn{reader: strings.NewReader(`{"type":"unknown"}` + "\n")}
- handleConn(conn)
-
- var resp pingResponse
- if err := json.Unmarshal(conn.writer.Bytes(), &resp); err != nil {
- t.Fatal(err)
- }
- if resp.OK || resp.Error == "" {
- t.Fatalf("response = %+v", resp)
- }
-}
-
-func TestHandleConnExecRunsCommand(t *testing.T) {
- t.Parallel()
-
- conn := &memoryConn{reader: strings.NewReader(`{"type":"exec","args":["sh","-c","cat; printf %s \"$FOO\""],"env":["FOO=bar"],"stdin":"aGVsbG8K"}` + "\n")}
- handleConn(conn)
-
- var resp execResponse
- if err := json.Unmarshal(conn.writer.Bytes(), &resp); err != nil {
- t.Fatal(err)
- }
- if !resp.OK || resp.ExitCode != 0 || string(resp.Stdout) != "hello\nbar" {
- t.Fatalf("response = %+v stdout=%q", resp, resp.Stdout)
- }
-}
-
-func TestHandleConnExecReportsExitCode(t *testing.T) {
- t.Parallel()
-
- conn := &memoryConn{reader: strings.NewReader(`{"type":"exec","args":["sh","-c","echo err >&2; exit 7"]}` + "\n")}
- handleConn(conn)
-
- var resp execResponse
- if err := json.Unmarshal(conn.writer.Bytes(), &resp); err != nil {
- t.Fatal(err)
- }
- if resp.OK || resp.ExitCode != 7 || string(resp.Stderr) != "err\n" {
- t.Fatalf("response = %+v stderr=%q", resp, resp.Stderr)
- }
-}
-
-func TestHandleConnConfiguresIdentity(t *testing.T) {
- original := configureIdentity
- defer func() { configureIdentity = original }()
- var got identityRequest
- configureIdentity = func(req identityRequest) error {
- got = req
- return nil
- }
- request := `{"type":"identity","hostname":"clone","interfaces":[{"name":"eth0","mac":"02:00:00:00:00:01","ip":"10.88.0.3","prefix":16}]}` + "\n"
- conn := &memoryConn{reader: strings.NewReader(request)}
- handleConn(conn)
- if got.Hostname != "clone" || len(got.Interfaces) != 1 || got.Interfaces[0].IP != "10.88.0.3" {
- t.Fatalf("identity request = %+v", got)
- }
- var decoded identityResponse
- if err := json.Unmarshal(conn.writer.Bytes(), &decoded); err != nil {
- t.Fatal(err)
- }
- if !decoded.OK {
- t.Fatalf("identity response = %+v", decoded)
- }
-}
-
-func TestHandleConnReseedsGuest(t *testing.T) {
- original := reseedGuest
- defer func() { reseedGuest = original }()
- var got reseedRequest
- reseedGuest = func(req reseedRequest) error {
- got = req
- return nil
- }
- entropy := bytes.Repeat([]byte{0x5a}, agentReseedEntropyBytes)
- raw, err := json.Marshal(reseedRequest{
- Type: protocol.RequestReseed,
- Entropy: entropy,
- RegenerateMachineID: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- conn := &memoryConn{reader: strings.NewReader(string(raw) + "\n")}
- handleConn(conn)
- if !got.RegenerateMachineID || !bytes.Equal(got.Entropy, make([]byte, agentReseedEntropyBytes)) {
- t.Fatalf("reseed request was not handled and cleared: %+v", got)
- }
- var resp reseedResponse
- if err := json.Unmarshal(conn.writer.Bytes(), &resp); err != nil {
- t.Fatal(err)
- }
- if !resp.OK {
- t.Fatalf("reseed response = %+v", resp)
- }
-}
-
-func TestHandleConnRejectsInvalidReseedEntropy(t *testing.T) {
- t.Parallel()
-
- conn := &memoryConn{reader: strings.NewReader(`{"type":"reseed","entropy":"AQI="}` + "\n")}
- handleConn(conn)
- var resp reseedResponse
- if err := json.Unmarshal(conn.writer.Bytes(), &resp); err != nil {
- t.Fatal(err)
- }
- if resp.OK || !strings.Contains(resp.Error, "32 bytes") {
- t.Fatalf("reseed response = %+v", resp)
- }
-}
diff --git a/internal/agent/server/vsock_linux.go b/internal/agent/server/vsock_linux.go
deleted file mode 100644
index bb7f98b..0000000
--- a/internal/agent/server/vsock_linux.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package server
-
-import (
- "fmt"
- "io"
- "os"
- "time"
-
- "golang.org/x/sys/unix"
-)
-
-const vsockListenRetryInterval = time.Second
-
-var (
- serveVsockAttempt = serveVsockOnce
- waitVsockRetry = time.Sleep
-)
-
-type fdConn struct {
- file *os.File
-}
-
-func (c *fdConn) Read(p []byte) (int, error) {
- return c.file.Read(p)
-}
-
-func (c *fdConn) Write(p []byte) (int, error) {
- return c.file.Write(p)
-}
-
-func (c *fdConn) Close() error {
- return c.file.Close()
-}
-
-func serveVsock(port uint32, handler func(io.ReadWriter)) error {
- for attempt := 1; ; attempt++ {
- err := serveVsockAttempt(port, handler)
- if err == nil {
- return nil
- }
- auditLog.Printf("vsock listener attempt=%d failed: %v; retrying in %s", attempt, err, vsockListenRetryInterval)
- waitVsockRetry(vsockListenRetryInterval)
- }
-}
-
-func serveVsockOnce(port uint32, handler func(io.ReadWriter)) error {
- fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM, 0)
- if err != nil {
- return fmt.Errorf("create vsock socket: %w", err)
- }
- defer unix.Close(fd) //nolint:errcheck
-
- if err := unix.Bind(fd, &unix.SockaddrVM{
- CID: unix.VMADDR_CID_ANY,
- Port: port,
- }); err != nil {
- return fmt.Errorf("bind vsock port %d: %w", port, err)
- }
- if err := unix.Listen(fd, 128); err != nil {
- return fmt.Errorf("listen vsock port %d: %w", port, err)
- }
- auditLog.Printf("vsock listener ready port=%d", port)
-
- for {
- connFD, _, err := unix.Accept(fd)
- if err != nil {
- if err == unix.EINTR {
- continue
- }
- return fmt.Errorf("accept vsock: %w", err)
- }
- go func() {
- conn := &fdConn{file: os.NewFile(uintptr(connFD), "vsock-agent")}
- defer func() { _ = conn.Close() }()
- handler(conn)
- }()
- }
-}
diff --git a/internal/agent/server/vsock_linux_test.go b/internal/agent/server/vsock_linux_test.go
deleted file mode 100644
index 1d16163..0000000
--- a/internal/agent/server/vsock_linux_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-//go:build linux
-
-package server
-
-import (
- "errors"
- "io"
- "testing"
- "time"
-)
-
-func TestServeVsockRetriesListenerFailure(t *testing.T) {
- originalAttempt := serveVsockAttempt
- originalWait := waitVsockRetry
- defer func() {
- serveVsockAttempt = originalAttempt
- waitVsockRetry = originalWait
- }()
-
- attempts := 0
- serveVsockAttempt = func(uint32, func(io.ReadWriter)) error {
- attempts++
- if attempts < 3 {
- return errors.New("restored listener is stale")
- }
- return nil
- }
- var delays []time.Duration
- waitVsockRetry = func(delay time.Duration) {
- delays = append(delays, delay)
- }
-
- if err := serveVsock(Port, func(io.ReadWriter) {}); err != nil {
- t.Fatal(err)
- }
- if attempts != 3 {
- t.Fatalf("listener attempts = %d, want 3", attempts)
- }
- if len(delays) != 2 || delays[0] != vsockListenRetryInterval || delays[1] != vsockListenRetryInterval {
- t.Fatalf("retry delays = %v", delays)
- }
-}
diff --git a/internal/agent/server/vsock_other.go b/internal/agent/server/vsock_other.go
deleted file mode 100644
index 0bae298..0000000
--- a/internal/agent/server/vsock_other.go
+++ /dev/null
@@ -1,13 +0,0 @@
-//go:build !linux
-
-package server
-
-import (
- "fmt"
- "io"
- "runtime"
-)
-
-func serveVsock(_ uint32, _ func(io.ReadWriter)) error {
- return fmt.Errorf("vsock agent is only supported on Linux guests, got %s", runtime.GOOS)
-}
diff --git a/internal/backend/backend.go b/internal/backend/backend.go
deleted file mode 100644
index 6677865..0000000
--- a/internal/backend/backend.go
+++ /dev/null
@@ -1,150 +0,0 @@
-// Package backend defines the VMM lifecycle boundary used by runtime.
-//
-// Runtime owns VM state transitions and cleanup policy. Backend implementations
-// own rendering, starting, stopping, and observing the concrete VMM process.
-package backend
-
-import (
- "context"
- "io"
- "time"
-
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// StateController exposes live VMM state transitions that do not create or
-// terminate the backend process.
-type StateController interface {
- PauseVM(context.Context, *vm.VMRecord) error
- ResumeVM(context.Context, *vm.VMRecord) error
-}
-
-// ConsoleController opens the live guest console stream for an interactive VM.
-type ConsoleController interface {
- OpenConsole(context.Context, *vm.VMRecord) (io.ReadWriteCloser, error)
-}
-
-// DiskSpec identifies an externally owned raw disk to hot-plug.
-type DiskSpec struct {
- Path string
- Name string
- ReadOnly bool
- DirectIO *bool
-}
-
-type AttachedDisk struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Path string `json:"path"`
- ReadOnly bool `json:"readonly,omitempty"`
-}
-
-// DiskController is implemented by backends that support runtime virtio-blk
-// hotplug. The backing file is never owned by the controller.
-type DiskController interface {
- AttachDisk(context.Context, *vm.VMRecord, DiskSpec) (AttachedDisk, error)
- DetachDisk(context.Context, *vm.VMRecord, string) error
- ListDisks(context.Context, *vm.VMRecord) ([]AttachedDisk, error)
-}
-
-// NetworkController changes virtio-net devices on a running VM.
-type NetworkController interface {
- AttachNetwork(context.Context, *vm.VMRecord, kbnetwork.Config) error
- DetachNetwork(context.Context, *vm.VMRecord, kbnetwork.Config) error
-}
-
-type FilesystemSpec struct {
- Socket, Tag string
- NumQueues, QueueSize int
-}
-type AttachedFilesystem struct{ ID, Tag, Socket string }
-type FilesystemController interface {
- AttachFilesystem(context.Context, *vm.VMRecord, FilesystemSpec) (AttachedFilesystem, error)
- DetachFilesystem(context.Context, *vm.VMRecord, string) error
- ListFilesystems(context.Context, *vm.VMRecord) ([]AttachedFilesystem, error)
-}
-
-type PCIDeviceSpec struct{ PCI, ID string }
-type AttachedPCIDevice struct{ ID, PCI string }
-type PCIDeviceController interface {
- AttachPCIDevice(context.Context, *vm.VMRecord, PCIDeviceSpec) (AttachedPCIDevice, error)
- DetachPCIDevice(context.Context, *vm.VMRecord, string) error
- ListPCIDevices(context.Context, *vm.VMRecord) ([]AttachedPCIDevice, error)
-}
-
-// DeviceState is the backend's live view of runtime-hotplugged devices.
-type DeviceState struct {
- Disks []AttachedDisk
- Filesystems []AttachedFilesystem
- PCIDevices []AttachedPCIDevice
-}
-
-// DeviceInspector reads live device state without changing the VM.
-type DeviceInspector interface {
- InspectDevices(context.Context, *vm.VMRecord) (DeviceState, error)
-}
-
-// NativeSnapshotter captures backend-owned memory, device, and VM state into
-// an existing empty directory while the VM is paused.
-type NativeSnapshotter interface {
- SnapshotVM(context.Context, *vm.VMRecord, string) error
-}
-
-// NativeRestorer recreates a backend process from validated native state.
-// Runtime owns snapshot leases, writable disk replacement, and durable VM
-// state transitions; implementations own backend-specific config patching and
-// the restore/resume API sequence.
-type NativeRestorer interface {
- RestoreVM(context.Context, *vm.VMRecord, string, string) (*StartResult, error)
-}
-
-// NativeCloner restores native state into a newly allocated VM identity and
-// replaces snapshot network devices before vCPUs resume.
-type NativeCloner interface {
- CloneVM(context.Context, *vm.VMRecord, string, string) (*StartResult, error)
-}
-
-// NativeHost describes host and backend properties that constrain whether a
-// native snapshot can be restored safely.
-type NativeHost struct {
- BackendName string
- BackendVersion string
- SnapshotFormat string
- Architecture string
- CPUVendor string
- CPUFeatures []string
- RestoreModes []string
-}
-
-// NativeHostInspector reports the compatibility boundary for native backend
-// state captured or restored on the current host.
-type NativeHostInspector interface {
- InspectNativeHost(context.Context, *vm.VMRecord) (NativeHost, error)
-}
-
-// Lifecycle is the backend contract required by runtime.
-//
-// Implementations must make ObserveVM cheap and side-effect free because runtime
-// calls it during inspect/list reconciliation.
-type Lifecycle interface {
- RenderConfig(*vm.VMRecord) error
- StartVM(*vm.VMRecord) (*StartResult, error)
- StopVM(*vm.VMRecord, StopOptions) (*StopResult, error)
- ObserveVM(*vm.VMRecord) vm.Observation
-}
-
-// StartResult contains process identity returned after a successful start.
-type StartResult struct {
- PID int
- APISocket string
-}
-
-// StopOptions controls graceful versus forced backend termination.
-type StopOptions struct {
- Timeout time.Duration
- Force bool
-}
-
-// StopResult is reserved for backend-specific stop details.
-type StopResult struct{}
diff --git a/internal/backend/cloudhypervisor/api.go b/internal/backend/cloudhypervisor/api.go
deleted file mode 100644
index a7bf968..0000000
--- a/internal/backend/cloudhypervisor/api.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package cloudhypervisor
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/fileutil"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const (
- apiBaseURL = "http://localhost/api/v1/"
- apiErrorBodySize = 64 << 10
- nativeSnapshotTimeout = 10 * time.Minute
-)
-
-// APIError preserves the backend status and response body for diagnostics.
-type APIError struct {
- Operation string
- StatusCode int
- Status string
- Message string
-}
-
-func (e *APIError) Error() string {
- if e.Message == "" {
- return fmt.Sprintf("BACKEND_API_ERROR: %s returned %s", e.Operation, e.Status)
- }
- return fmt.Sprintf("BACKEND_API_ERROR: %s returned %s: %s", e.Operation, e.Status, e.Message)
-}
-
-type vmInfo struct {
- State string `json:"state"`
- DeviceTree map[string]json.RawMessage `json:"device_tree"`
- Config vmInfoConfig `json:"config"`
-}
-
-type vmInfoConfig struct {
- Disks []vmInfoDisk `json:"disks"`
- Fs []vmInfoFS `json:"fs"`
- Devices []vmInfoDevice `json:"devices"`
- Console vmInfoConsole `json:"console"`
-}
-type vmInfoDisk struct {
- ID string `json:"id"`
- Path string `json:"path"`
- ReadOnly bool `json:"readonly"`
- Serial string `json:"serial"`
-}
-type vmInfoFS struct {
- ID string `json:"id"`
- Tag string `json:"tag"`
- Socket string `json:"socket"`
-}
-type vmInfoDevice struct {
- ID string `json:"id"`
- Path string `json:"path"`
-}
-
-type vmInfoConsole struct {
- Mode string `json:"mode"`
- File string `json:"file"`
-}
-
-func socketHTTPClient(socketPath string, timeout time.Duration) *http.Client {
- return &http.Client{
- Timeout: timeout,
- Transport: &http.Transport{
- DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
- return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
- },
- },
- }
-}
-
-func doAPIOnce(ctx context.Context, socketPath string, timeout time.Duration, method, endpoint string, body []byte, successCodes ...int) ([]byte, error) {
- client := socketHTTPClient(socketPath, timeout)
- defer client.CloseIdleConnections()
- return doAPIOnceWithClient(ctx, client, method, endpoint, body, successCodes...)
-}
-
-func doAPIOnceWithClient(ctx context.Context, client *http.Client, method, endpoint string, body []byte, successCodes ...int) (responseBody []byte, err error) {
- req, err := http.NewRequestWithContext(ctx, method, apiBaseURL+endpoint, bytes.NewReader(body))
- if err != nil {
- return nil, fmt.Errorf("create %s request: %w", endpoint, err)
- }
- if len(body) > 0 {
- req.Header.Set("Content-Type", "application/json")
- }
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("BACKEND_API_UNAVAILABLE: %s: %w", endpoint, err)
- }
- defer fileutil.CloseAndJoin(&err, resp.Body, "close backend API response")
-
- responseBody, err = io.ReadAll(io.LimitReader(resp.Body, apiErrorBodySize+1))
- if err != nil {
- return nil, fmt.Errorf("read %s response: %w", endpoint, err)
- }
- if len(responseBody) > apiErrorBodySize {
- return nil, fmt.Errorf("BACKEND_API_ERROR: %s response exceeds %d bytes", endpoint, apiErrorBodySize)
- }
- for _, code := range successCodes {
- if resp.StatusCode == code {
- return responseBody, nil
- }
- }
- return nil, &APIError{
- Operation: endpoint,
- StatusCode: resp.StatusCode,
- Status: resp.Status,
- Message: strings.TrimSpace(string(responseBody)),
- }
-}
-
-func queryVMInfo(ctx context.Context, socketPath string, timeout time.Duration) (*vmInfo, error) {
- client := socketHTTPClient(socketPath, timeout)
- defer client.CloseIdleConnections()
- return queryVMInfoWithClient(ctx, client)
-}
-
-func queryVMInfoWithClient(ctx context.Context, client *http.Client) (*vmInfo, error) {
- raw, err := doAPIOnceWithClient(ctx, client, http.MethodGet, apiVMInfo, nil, http.StatusOK)
- if err != nil {
- return nil, err
- }
- var info vmInfo
- if err := json.Unmarshal(raw, &info); err != nil {
- return nil, fmt.Errorf("decode vm.info response: %w", err)
- }
- if info.State == "" {
- return nil, errors.New("vm.info response has no state")
- }
- return &info, nil
-}
-
-func stateTransition(ctx context.Context, rec *vm.VMRecord, endpoint, target string) error {
- if rec == nil {
- return errors.New("VM record is nil")
- }
- apiSocket, timeout, err := backendAPIConfig(rec)
- if err != nil {
- return err
- }
- client := socketHTTPClient(apiSocket, timeout)
- defer client.CloseIdleConnections()
- return stateTransitionWithClient(ctx, client, endpoint, target)
-}
-
-func stateTransitionWithClient(ctx context.Context, client *http.Client, endpoint, target string) error {
- info, err := queryVMInfoWithClient(ctx, client)
- if err != nil {
- return err
- }
- if strings.EqualFold(info.State, target) {
- return nil
- }
- _, err = doAPIOnceWithClient(ctx, client, http.MethodPut, endpoint, nil, http.StatusNoContent)
- if err == nil || alreadyInState(err, target) {
- return nil
- }
- return err
-}
-
-func alreadyInState(err error, state string) bool {
- var apiErr *APIError
- if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusInternalServerError {
- return false
- }
- want := fmt.Sprintf("InvalidStateTransition(%s, %s)", state, state)
- return strings.Contains(apiErr.Message, want)
-}
-
-func backendAPIConfig(rec *vm.VMRecord) (string, time.Duration, error) {
- cfg, err := readRenderedConfig(rec.Config)
- if err != nil {
- return "", 0, fmt.Errorf("read backend config: %w", err)
- }
- apiSocket := rec.APISocket
- if apiSocket == "" {
- apiSocket = cfg.APISocket
- }
- if apiSocket == "" {
- return "", 0, errors.New("BACKEND_API_UNAVAILABLE: VM has no API socket")
- }
- timeout := time.Duration(cfg.APITimeoutMs) * time.Millisecond
- if timeout <= 0 {
- timeout = 5 * time.Second
- }
- return apiSocket, timeout, nil
-}
-
-func putJSONOnce(ctx context.Context, socketPath string, timeout time.Duration, endpoint string, payload any, successCodes ...int) error {
- body, err := json.Marshal(payload)
- if err != nil {
- return fmt.Errorf("encode %s request: %w", endpoint, err)
- }
- _, err = doAPIOnce(ctx, socketPath, timeout, http.MethodPut, endpoint, body, successCodes...)
- return err
-}
diff --git a/internal/backend/cloudhypervisor/api_contract.go b/internal/backend/cloudhypervisor/api_contract.go
deleted file mode 100644
index 2e08971..0000000
--- a/internal/backend/cloudhypervisor/api_contract.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package cloudhypervisor
-
-// Cloud Hypervisor API operation names are part of the backend protocol.
-const (
- apiVMInfo = "vm.info"
- apiVMSnapshot = "vm.snapshot"
- apiVMRestore = "vm.restore"
- apiVMResume = "vm.resume"
- apiVMPause = "vm.pause"
- apiVMShutdown = "vm.shutdown"
- apiVMRemoveDevice = "vm.remove-device"
- apiVMAddNet = "vm.add-net"
- apiVMAddDisk = "vm.add-disk"
- apiVMAddFS = "vm.add-fs"
- apiVMAddDevice = "vm.add-device"
-
- backendStateRunning = "Running"
- backendStatePaused = "Paused"
-)
diff --git a/internal/backend/cloudhypervisor/api_test.go b/internal/backend/cloudhypervisor/api_test.go
deleted file mode 100644
index 75a967a..0000000
--- a/internal/backend/cloudhypervisor/api_test.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
- "strings"
- "testing"
-)
-
-type roundTripFunc func(*http.Request) (*http.Response, error)
-
-func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
- return f(req)
-}
-
-func TestStateTransitionUsesVMInfoAndIsIdempotent(t *testing.T) {
- state := "Running"
- pauseCalls := 0
- client := apiTestClient(func(req *http.Request) (*http.Response, error) {
- switch req.URL.Path {
- case "/api/v1/vm.info":
- return apiResponse(http.StatusOK, fmt.Sprintf(`{"state":%q}`, state)), nil
- case "/api/v1/vm.pause":
- pauseCalls++
- state = "Paused"
- return apiResponse(http.StatusNoContent, ""), nil
- default:
- return apiResponse(http.StatusNotFound, "not found"), nil
- }
- })
- if err := stateTransitionWithClient(context.Background(), client, "vm.pause", "Paused"); err != nil {
- t.Fatal(err)
- }
- if err := stateTransitionWithClient(context.Background(), client, "vm.pause", "Paused"); err != nil {
- t.Fatal(err)
- }
- if pauseCalls != 1 {
- t.Fatalf("vm.pause calls = %d, want 1", pauseCalls)
- }
-}
-
-func TestStateTransitionReportsBackendAPIError(t *testing.T) {
- client := apiTestClient(func(req *http.Request) (*http.Response, error) {
- if req.URL.Path == "/api/v1/vm.info" {
- return apiResponse(http.StatusOK, `{"state":"Running"}`), nil
- }
- return apiResponse(http.StatusInternalServerError, "pause denied\n"), nil
- })
- err := stateTransitionWithClient(context.Background(), client, "vm.pause", "Paused")
- if err == nil || err.Error() != "BACKEND_API_ERROR: vm.pause returned 500 Internal Server Error: pause denied" {
- t.Fatalf("PauseVM() error = %v", err)
- }
-}
-
-func apiTestClient(fn roundTripFunc) *http.Client {
- return &http.Client{Transport: fn}
-}
-
-func apiResponse(code int, body string) *http.Response {
- return &http.Response{
- StatusCode: code,
- Status: fmt.Sprintf("%d %s", code, http.StatusText(code)),
- Body: io.NopCloser(strings.NewReader(body)),
- Header: make(http.Header),
- }
-}
diff --git a/internal/backend/cloudhypervisor/backend.go b/internal/backend/cloudhypervisor/backend.go
deleted file mode 100644
index d2ea620..0000000
--- a/internal/backend/cloudhypervisor/backend.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package cloudhypervisor
-
-import (
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-var _ backend.Lifecycle = Backend{}
-var _ backend.StateController = Backend{}
-var _ backend.NativeSnapshotter = Backend{}
-var _ backend.NativeHostInspector = Backend{}
-var _ backend.NativeRestorer = Backend{}
-var _ backend.NativeCloner = Backend{}
-var _ backend.DiskController = Backend{}
-var _ backend.FilesystemController = Backend{}
-var _ backend.PCIDeviceController = Backend{}
-var _ backend.ConsoleController = Backend{}
-var _ backend.DeviceInspector = Backend{}
-
-type Backend struct {
- renderer Renderer
- starter Starter
- stopper Stopper
-}
-
-func NewBackend(cfg config.Config) Backend {
- return Backend{
- renderer: NewRenderer(cfg),
- starter: NewStarter(),
- stopper: NewStopper(),
- }
-}
-
-func (b Backend) RenderConfig(rec *vm.VMRecord) error {
- return b.renderer.RenderConfig(rec)
-}
-
-func (b Backend) StartVM(rec *vm.VMRecord) (*backend.StartResult, error) {
- return b.starter.StartConfig(rec.Config)
-}
-
-func (b Backend) ObserveVM(rec *vm.VMRecord) vm.Observation {
- return ObserveVM(rec)
-}
diff --git a/internal/backend/cloudhypervisor/compatibility.go b/internal/backend/cloudhypervisor/compatibility.go
deleted file mode 100644
index 72cbe2b..0000000
--- a/internal/backend/cloudhypervisor/compatibility.go
+++ /dev/null
@@ -1,160 +0,0 @@
-package cloudhypervisor
-
-import (
- "bufio"
- "bytes"
- "context"
- "errors"
- "fmt"
- "io"
- "os"
- "os/exec"
- "runtime"
- "slices"
- "sort"
- "strings"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const nativeSnapshotFormat = "cloud-hypervisor-native-v1"
-
-func (b Backend) InspectNativeHost(ctx context.Context, rec *vm.VMRecord) (backend.NativeHost, error) {
- binary := b.renderer.cfg.Backend.CloudHypervisor.Binary
- if rec != nil && rec.Config != "" {
- if cfg, err := readRenderedConfig(rec.Config); err == nil {
- binary = cfg.Binary
- }
- }
- if binary == "" {
- return backend.NativeHost{}, fmt.Errorf("cloud hypervisor binary is empty")
- }
- output, err := exec.CommandContext(ctx, binary, "--version").CombinedOutput() //nolint:gosec
- if err != nil {
- return backend.NativeHost{}, fmt.Errorf("inspect cloud-hypervisor version: %w: %s", err, strings.TrimSpace(string(output)))
- }
- vendor, features := linuxCPUIdentity()
- version := parseBackendVersion(string(output))
- modes := inspectRestoreModes(binary)
- if !containsRestoreMode(modes, "ondemand") && cloudHypervisorSupportsOnDemand(version) {
- // Release binaries may omit the Rust source strings used by the
- // best-effort scanner below. v51.1 introduced the stable OnDemand
- // restore request, so do not reject a valid request because of that
- // missing diagnostic string.
- modes = append(modes, "ondemand")
- sort.Strings(modes)
- }
- return backend.NativeHost{
- BackendName: "cloud-hypervisor", BackendVersion: version,
- SnapshotFormat: nativeSnapshotFormat, Architecture: runtime.GOARCH,
- CPUVendor: vendor, CPUFeatures: features, RestoreModes: modes,
- }, nil
-}
-
-func containsRestoreMode(modes []string, wanted string) bool {
- return slices.Contains(modes, wanted)
-}
-
-func cloudHypervisorSupportsOnDemand(version string) bool {
- major, minor, ok := parseVersionParts(version)
- return ok && (major > 51 || (major == 51 && minor >= 1))
-}
-
-func parseVersionParts(version string) (int, int, bool) {
- var major, minor int
- if _, err := fmt.Sscanf(version, "%d.%d", &major, &minor); err != nil {
- return 0, 0, false
- }
- return major, minor, true
-}
-
-func inspectRestoreModes(binary string) []string {
- modes := []string{"copy"}
- path, err := exec.LookPath(binary)
- if err != nil {
- return modes
- }
- file, err := os.Open(path) //nolint:gosec
- if err != nil {
- return modes
- }
- // Older builds ignore unknown restore JSON fields. Schema markers embedded
- // in the Rust binary let preflight fail before any destructive VM mutation.
- const overlap = 64
- buffer := make([]byte, 64<<10)
- window := make([]byte, 0, len(buffer)+overlap)
- var hasField, hasOnDemand, hasMmapSyntax bool
- for {
- n, readErr := file.Read(buffer)
- if n > 0 {
- window = append(window, buffer[:n]...)
- hasField = hasField || bytes.Contains(window, []byte("memory_restore_mode"))
- hasOnDemand = hasOnDemand || bytes.Contains(window, []byte("OnDemand"))
- // "Mmap" appears in unrelated memory and device code in builds that
- // only accept Copy and OnDemand. Require the restore parser's exact
- // mode-list marker before advertising the optional mmap protocol.
- hasMmapSyntax = hasMmapSyntax || bytes.Contains(window, []byte("memory_restore_mode=copy|ondemand|mmap"))
- if len(window) > overlap {
- window = append(window[:0], window[len(window)-overlap:]...)
- }
- if hasField && hasOnDemand && hasMmapSyntax {
- break
- }
- }
- if readErr != nil {
- if !errors.Is(readErr, io.EOF) {
- return modes
- }
- break
- }
- }
- if err := file.Close(); err != nil {
- return modes
- }
- if hasField && hasOnDemand {
- modes = append(modes, "ondemand")
- }
- if hasField && hasMmapSyntax {
- modes = append(modes, "mmap")
- }
- return modes
-}
-
-func parseBackendVersion(output string) string {
- fields := strings.Fields(strings.TrimSpace(output))
- if len(fields) == 0 {
- return "unknown"
- }
- return strings.TrimPrefix(fields[len(fields)-1], "v")
-}
-
-func linuxCPUIdentity() (string, []string) {
- file, err := os.Open("/proc/cpuinfo") //nolint:gosec
- if err != nil {
- return "unknown", nil
- }
- vendor := "unknown"
- var features []string
- scanner := bufio.NewScanner(file)
- for scanner.Scan() {
- key, value, ok := strings.Cut(scanner.Text(), ":")
- if !ok {
- continue
- }
- switch strings.TrimSpace(key) {
- case "vendor_id":
- vendor = strings.TrimSpace(value)
- case "flags":
- features = strings.Fields(value)
- }
- if vendor != "unknown" && len(features) > 0 {
- break
- }
- }
- if err := file.Close(); err != nil {
- return "unknown", nil
- }
- sort.Strings(features)
- return vendor, features
-}
diff --git a/internal/backend/cloudhypervisor/compatibility_test.go b/internal/backend/cloudhypervisor/compatibility_test.go
deleted file mode 100644
index 0a8e9e2..0000000
--- a/internal/backend/cloudhypervisor/compatibility_test.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package cloudhypervisor
-
-import (
- "os"
- "path/filepath"
- "slices"
- "testing"
-)
-
-func TestInspectRestoreModesRequiresBinarySchemaMarkers(t *testing.T) {
- tests := []struct {
- name string
- content string
- want []string
- }{
- {name: "copy only", content: "cloud-hypervisor", want: []string{"copy"}},
- {name: "all modes", content: "memory_restore_mode OnDemand memory_restore_mode=copy|ondemand|mmap", want: []string{"copy", "ondemand", "mmap"}},
- {name: "unrelated mmap marker", content: "memory_restore_mode OnDemand InvalidDeviceExcludeMmapBar", want: []string{"copy", "ondemand"}},
- {name: "enum without field", content: "OnDemand Mmap", want: []string{"copy"}},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- path := filepath.Join(t.TempDir(), "cloud-hypervisor")
- if err := os.WriteFile(path, []byte(tt.content), 0o700); err != nil {
- t.Fatal(err)
- }
- if got := inspectRestoreModes(path); !slices.Equal(got, tt.want) {
- t.Fatalf("restore modes = %v, want %v", got, tt.want)
- }
- })
- }
-}
diff --git a/internal/backend/cloudhypervisor/config.go b/internal/backend/cloudhypervisor/config.go
deleted file mode 100644
index ee9f587..0000000
--- a/internal/backend/cloudhypervisor/config.go
+++ /dev/null
@@ -1,626 +0,0 @@
-// Package cloudhypervisor implements KumaBox's Cloud Hypervisor backend.
-//
-// The backend renders an auditable JSON config beside the VM runtime files and
-// then starts the cloud-hypervisor process with the corresponding CLI arguments.
-package cloudhypervisor
-
-import (
- "errors"
- "fmt"
- "net"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/fileutil"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/vm"
- "github.com/kumabox/kumabox/internal/vm/nocloud"
-)
-
-const defaultKernelCmdline = "console=ttyS0 reboot=k panic=1 root=/dev/vda rw"
-
-// Config is the rendered Cloud Hypervisor launch plan.
-//
-// It is written to the VM run directory before start so users and verification
-// scripts can inspect exactly which disks, networks, sockets, and logs were
-// handed to the VMM.
-type Config struct {
- Binary string `json:"binary"`
- APISocket string `json:"apiSocket"`
- APITimeoutMs int `json:"apiTimeoutMs"`
- PIDFile string `json:"pidFile"`
- StdoutLog string `json:"stdoutLog"`
- StderrLog string `json:"stderrLog"`
- NetnsPath string `json:"netnsPath,omitempty"`
- Kernel *Kernel `json:"kernel,omitempty"`
- Initramfs *Initramfs `json:"initramfs,omitempty"`
- Firmware *Firmware `json:"firmware,omitempty"`
- CPUs CPUs `json:"cpus"`
- Memory Memory `json:"memory"`
- Disks []Disk `json:"disks"`
- Nets []Net `json:"nets,omitempty"`
- Vsock *Vsock `json:"vsock,omitempty"`
- Serial Serial `json:"serial"`
- Console Console `json:"console"`
- Args []string `json:"args"`
- Annotations Annotations `json:"annotations"`
-}
-
-type Kernel struct {
- Path string `json:"path"`
- Cmdline string `json:"cmdline"`
-}
-
-type Initramfs struct {
- Path string `json:"path"`
-}
-
-type Firmware struct {
- Path string `json:"path"`
-}
-
-type CPUs struct {
- Boot int `json:"boot"`
-}
-
-type Memory struct {
- Size int64 `json:"size"`
- Shared bool `json:"shared,omitempty"`
-}
-
-// Disk is one block device passed to Cloud Hypervisor.
-type Disk struct {
- Path string `json:"path"`
- Readonly bool `json:"readonly"`
- DirectIO bool `json:"direct,omitempty"`
- Sparse bool `json:"sparse,omitempty"`
- ImageType string `json:"imageType,omitempty"`
- BackingFiles bool `json:"backingFiles,omitempty"`
- NumQueues int `json:"numQueues,omitempty"`
- QueueSize int `json:"queueSize,omitempty"`
- QueueAffinity []QueueAffinity `json:"queueAffinity,omitempty"`
- Serial string `json:"serial,omitempty"`
-}
-
-type QueueAffinity struct {
- QueueIndex int `json:"queueIndex"`
- HostCPUs []int `json:"hostCPUs"`
-}
-
-// Net is one virtio-net device backed by a host TAP interface.
-type Net struct {
- TAP string `json:"tap"`
- MAC string `json:"mac"`
- NumQueues int `json:"numQueues"`
- QueueSize int `json:"queueSize"`
- OffloadTSO bool `json:"offloadTSO"`
- OffloadUFO bool `json:"offloadUFO"`
- OffloadCsum bool `json:"offloadCsum"`
-}
-
-type Vsock struct {
- CID uint32 `json:"cid"`
- Socket string `json:"socket"`
-}
-
-type Serial struct {
- Path string `json:"path"`
-}
-
-type Console struct {
- Mode string `json:"mode"`
-}
-
-type Annotations struct {
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
-}
-
-// Renderer writes Cloud Hypervisor config and first-boot nocloud.
-type Renderer struct {
- cfg config.Config
-}
-
-// NewRenderer returns a renderer using the supplied process configuration.
-func NewRenderer(cfg config.Config) Renderer {
- return Renderer{cfg: cfg}
-}
-
-// RenderConfig writes all files required before starting Cloud Hypervisor.
-//
-// For cloud-image boots it also regenerates the NoCloud CIDATA disk from the
-// VM's current network configs, so the guest sees the same IP/MAC assignment
-// that Cloud Hypervisor receives.
-func (r Renderer) RenderConfig(rec *vm.VMRecord) error {
- if rec == nil {
- return fmt.Errorf("VM record is nil")
- }
- if err := os.MkdirAll(rec.RunDir, 0o755); err != nil {
- return fmt.Errorf("create VM run dir: %w", err)
- }
- if err := os.MkdirAll(rec.LogDir, 0o755); err != nil {
- return fmt.Errorf("create VM log dir: %w", err)
- }
- if meta := activeMetadata(rec); meta != nil && meta.Type == "nocloud" {
- if err := nocloud.WriteNoCloud(meta.CidataDir, meta.CidataDisk, nocloud.Config{
- InstanceID: rec.ID,
- Hostname: rec.Name,
- Username: "kumabox",
- Networks: metadataNetworks(rec),
- Mounts: metadataMounts(rec),
- }); err != nil {
- return fmt.Errorf("render NoCloud metadata: %w", err)
- }
- }
- if err := validateNetworkQueues(rec); err != nil {
- return err
- }
-
- rendered := NewConfig(r.cfg, rec)
- if err := fileutil.WriteJSONAtomic(rec.Config, rendered, ".cloud-hypervisor-*.tmp"); err != nil {
- return fmt.Errorf("write Cloud Hypervisor config: %w", err)
- }
- return nil
-}
-
-func metadataMounts(rec *vm.VMRecord) []nocloud.Mount {
- if rec == nil {
- return nil
- }
- mounts := make([]nocloud.Mount, 0)
- for _, storage := range rec.StorageConfigs {
- if storage.EffectiveRole() != vm.StorageRoleData || storage.MountPoint == "" || storage.Filesystem == "" || storage.Filesystem == vm.FilesystemNone {
- continue
- }
- mounts = append(mounts, nocloud.Mount{
- Device: "/dev/disk/by-id/virtio-" + storage.Serial,
- MountPoint: storage.MountPoint,
- Filesystem: storage.Filesystem,
- Options: "defaults,nofail",
- })
- }
- return mounts
-}
-
-func validateNetworkQueues(rec *vm.VMRecord) error {
- for _, nc := range rec.NetworkConfigs {
- if nc.NumQueues > 0 && nc.NumQueues < 2 {
- return fmt.Errorf("network %s numQueues must be at least 2", nc.ID)
- }
- }
- return nil
-}
-
-func metadataNetworks(rec *vm.VMRecord) []nocloud.Network {
- networks := make([]nocloud.Network, 0, len(rec.NetworkConfigs))
- for _, nc := range rec.NetworkConfigs {
- if nc.MAC == "" || nc.Network == nil || nc.Network.IP == "" {
- continue
- }
- networks = append(networks, nocloud.Network{
- MAC: nc.MAC,
- IP: nc.Network.IP,
- Prefix: nc.Network.Prefix,
- Gateway: nc.Network.Gateway,
- DNS: append([]string(nil), nc.Network.DNS...),
- })
- }
- return networks
-}
-
-// NewConfig derives Cloud Hypervisor arguments from a VM record.
-//
-// The function is pure with respect to the filesystem; Renderer.RenderConfig is
-// responsible for writing the returned config and any metadata sidecars.
-func NewConfig(cfg config.Config, rec *vm.VMRecord) Config {
- apiSocket := filepath.Join(rec.RunDir, "ch.sock")
- stdoutLog := filepath.Join(rec.LogDir, "cloud-hypervisor.stdout.log")
- stderrLog := filepath.Join(rec.LogDir, "cloud-hypervisor.stderr.log")
- cpus := vmCPUs(rec)
-
- args := []string{
- "--api-socket", apiSocket,
- "--cpus", fmt.Sprintf("boot=%d", cpus),
- "--memory", memoryArg(rec),
- }
- cmdline := kernelCmdline(rec)
- if rec.Firmware != "" {
- args = append(args, "--firmware", rec.Firmware)
- } else {
- args = append(args,
- "--kernel", rec.Kernel,
- "--initramfs", rec.Initrd,
- "--cmdline", cmdline,
- )
- }
- disks := newDisks(cfg, rec)
- if len(disks) > 0 {
- args = append(args, "--disk")
- for _, disk := range disks {
- args = append(args, diskArg(disk))
- }
- }
- if rec.Firmware == "" {
- args = append(args, "--serial", "off", "--console", "pty")
- } else {
- args = append(args, "--serial", "file="+filepath.Join(rec.LogDir, "console.log"), "--console", "off")
- }
- nets := newNets(rec)
- if len(nets) > 0 {
- args = append(args, "--net")
- }
- for _, net := range nets {
- netArg := fmt.Sprintf("tap=%s,mac=%s", net.TAP, net.MAC)
- if net.NumQueues > 0 {
- netArg += fmt.Sprintf(",num_queues=%d", net.NumQueues)
- }
- if net.QueueSize > 0 {
- netArg += fmt.Sprintf(",queue_size=%d", net.QueueSize)
- }
- if net.OffloadTSO {
- netArg += ",offload_tso=on"
- }
- if net.OffloadUFO {
- netArg += ",offload_ufo=on"
- }
- if net.OffloadCsum {
- netArg += ",offload_csum=on"
- }
- args = append(args, netArg)
- }
- vsock := newVsock(rec)
- if vsock != nil {
- args = append(args, "--vsock", fmt.Sprintf("cid=%d,socket=%s", vsock.CID, vsock.Socket))
- }
-
- rendered := Config{
- Binary: cfg.Backend.CloudHypervisor.Binary,
- APISocket: apiSocket,
- APITimeoutMs: cfg.Backend.CloudHypervisor.APISocketTimeoutMS,
- PIDFile: filepath.Join(rec.RunDir, "ch.pid"),
- StdoutLog: stdoutLog,
- StderrLog: stderrLog,
- NetnsPath: netnsPath(rec),
- CPUs: CPUs{Boot: cpus},
- Memory: Memory{Size: vmMemoryBytes(rec), Shared: rec.SharedMemory},
- Disks: disks,
- Nets: nets,
- Vsock: vsock,
- Console: Console{Mode: consoleMode(rec)},
- Args: args,
- Annotations: Annotations{
- VMID: rec.ID,
- VMName: rec.Name,
- },
- }
- if rec.Firmware != "" {
- rendered.Firmware = &Firmware{Path: rec.Firmware}
- } else {
- rendered.Kernel = &Kernel{
- Path: rec.Kernel,
- Cmdline: cmdline,
- }
- rendered.Initramfs = &Initramfs{Path: rec.Initrd}
- }
- return rendered
-}
-
-// ValidateConfig checks the pure launch plan without touching host resources.
-func ValidateConfig(launch Config) error {
- if launch.Binary == "" {
- return errors.New("cloud-hypervisor binary is empty")
- }
- if launch.APISocket == "" || launch.PIDFile == "" {
- return errors.New("cloud-hypervisor runtime paths are incomplete")
- }
- if launch.CPUs.Boot <= 0 || launch.Memory.Size <= 0 {
- return errors.New("cloud-hypervisor CPU and memory must be positive")
- }
- if launch.Firmware != nil && launch.Firmware.Path == "" {
- return errors.New("cloud-hypervisor firmware path is empty")
- }
- if launch.Firmware == nil && (launch.Kernel == nil || launch.Initramfs == nil || launch.Kernel.Path == "" || launch.Initramfs.Path == "") {
- return errors.New("cloud-hypervisor boot configuration is incomplete")
- }
- for _, disk := range launch.Disks {
- if disk.Path == "" {
- return errors.New("cloud-hypervisor disk path is empty")
- }
- }
- for _, network := range launch.Nets {
- if network.TAP == "" || network.MAC == "" {
- return errors.New("cloud-hypervisor network configuration is incomplete")
- }
- }
- return nil
-}
-
-func newVsock(rec *vm.VMRecord) *Vsock {
- if rec == nil || rec.VsockSocket == "" {
- return nil
- }
- return &Vsock{
- CID: 3,
- Socket: rec.VsockSocket,
- }
-}
-
-func vmCPUs(rec *vm.VMRecord) int {
- if rec == nil || rec.CPUs <= 0 {
- return 1
- }
- return rec.CPUs
-}
-
-func vmMemoryBytes(rec *vm.VMRecord) int64 {
- return rec.EffectiveMemoryBytes()
-}
-
-func memoryArg(rec *vm.VMRecord) string {
- value := fmt.Sprintf("size=%d", vmMemoryBytes(rec))
- if rec.SharedMemory {
- value += ",shared=on"
- }
- return value
-}
-
-func netnsPath(rec *vm.VMRecord) string {
- for _, nc := range rec.NetworkConfigs {
- if nc.NetnsPath != "" {
- return nc.NetnsPath
- }
- }
- return ""
-}
-
-func newNets(rec *vm.VMRecord) []Net {
- nets := make([]Net, 0, len(rec.NetworkConfigs))
- for _, nc := range rec.NetworkConfigs {
- if nc.TAP == "" {
- continue
- }
- nets = append(nets, Net{
- TAP: nc.TAP,
- MAC: nc.MAC,
- NumQueues: nc.NumQueues,
- QueueSize: nc.QueueSize,
- OffloadTSO: true,
- OffloadUFO: true,
- OffloadCsum: true,
- })
- }
- return nets
-}
-
-func newDisks(cfg config.Config, rec *vm.VMRecord) []Disk {
- disks := launchDisks(cfg, rec)
- if meta := activeMetadata(rec); meta != nil && meta.CidataDisk != "" {
- disks = append(disks, configureDisk(cfg, rec, Disk{
- Path: meta.CidataDisk,
- Readonly: true,
- ImageType: vm.FormatRaw,
- }, nil))
- }
- return disks
-}
-
-func launchDisks(cfg config.Config, rec *vm.VMRecord) []Disk {
- if len(rec.StorageConfigs) > 0 {
- disks := make([]Disk, 0, len(rec.StorageConfigs))
- for _, storageCfg := range rec.StorageConfigs {
- imageType := storageCfg.EffectiveFormat()
- disks = append(disks, configureDisk(cfg, rec, Disk{
- Path: storageCfg.Path,
- Readonly: storageCfg.Readonly,
- ImageType: imageType,
- BackingFiles: imageType == vm.FormatQCOW2 && !storageCfg.Readonly,
- Serial: storageCfg.Serial,
- }, &storageCfg))
- }
- return disks
- }
- return []Disk{configureDisk(cfg, rec, newRootDisk(rec), nil)}
-}
-
-func configureDisk(cfg config.Config, rec *vm.VMRecord, disk Disk, storage *vm.StorageConfig) Disk {
- disk.NumQueues = vmCPUs(rec)
- disk.QueueSize = cfg.Backend.CloudHypervisor.DiskQueueSize
- if disk.Readonly {
- return disk
- }
- disk.DirectIO = !cfg.Backend.CloudHypervisor.NoDirectIO
- if storage != nil && storage.DirectIO != nil {
- disk.DirectIO = *storage.DirectIO
- }
- disk.Sparse = disk.ImageType != vm.FormatQCOW2
- if disk.NumQueues > 1 {
- disk.QueueAffinity = make([]QueueAffinity, disk.NumQueues)
- for queue := range disk.QueueAffinity {
- disk.QueueAffinity[queue] = QueueAffinity{QueueIndex: queue, HostCPUs: []int{queue}}
- }
- }
- return disk
-}
-
-func diskArg(disk Disk) string {
- arg := "path=" + disk.Path
- if disk.Readonly {
- arg += ",readonly=on"
- }
- if disk.DirectIO {
- arg += ",direct=on"
- }
- if disk.Sparse {
- arg += ",sparse=on"
- }
- if disk.ImageType != "" {
- arg += ",image_type=" + disk.ImageType
- }
- if disk.BackingFiles {
- arg += ",backing_files=on"
- }
- if disk.NumQueues > 0 {
- arg += fmt.Sprintf(",num_queues=%d", disk.NumQueues)
- }
- if disk.QueueSize > 0 {
- arg += fmt.Sprintf(",queue_size=%d", disk.QueueSize)
- }
- if len(disk.QueueAffinity) > 0 {
- arg += ",queue_affinity=" + queueAffinityArg(disk.QueueAffinity)
- }
- if disk.Serial != "" {
- arg += ",serial=" + disk.Serial
- }
- return arg
-}
-
-func queueAffinityArg(affinities []QueueAffinity) string {
- parts := make([]string, len(affinities))
- for i, affinity := range affinities {
- cpus := make([]string, len(affinity.HostCPUs))
- for j, cpu := range affinity.HostCPUs {
- cpus[j] = fmt.Sprintf("%d", cpu)
- }
- parts[i] = fmt.Sprintf("%d@[%s]", affinity.QueueIndex, strings.Join(cpus, ","))
- }
- return "[" + strings.Join(parts, ",") + "]"
-}
-
-func kernelCmdline(rec *vm.VMRecord) string {
- cmdline := rec.KernelCmdline
- if cmdline == "" {
- cmdline = defaultKernelCmdline
- }
- if rec.Firmware == "" {
- cmdline = directBootConsoleCmdline(cmdline)
- }
- layers := make([]string, 0)
- cow := ""
- for _, cfg := range rec.StorageConfigs {
- switch cfg.EffectiveRole() {
- case vm.StorageRoleLayer:
- if cfg.Serial != "" {
- layers = append(layers, cfg.Serial)
- }
- case vm.StorageRoleCOW:
- cow = cfg.Serial
- }
- }
- for left, right := 0, len(layers)-1; left < right; left, right = left+1, right-1 {
- layers[left], layers[right] = layers[right], layers[left]
- }
- cmdline = strings.ReplaceAll(cmdline, "{{layers}}", strings.Join(layers, ","))
- cmdline = strings.ReplaceAll(cmdline, "{{cow}}", cow)
- if len(rec.StorageConfigs) > 0 {
- cmdline += directBootNetworkCmdline(rec)
- }
- return cmdline
-}
-
-func directBootConsoleCmdline(cmdline string) string {
- fields := strings.Fields(cmdline)
- for index, field := range fields {
- if strings.HasPrefix(field, "console=") {
- fields[index] = "console=hvc0"
- return strings.Join(fields, " ")
- }
- }
- return strings.Join(append([]string{"console=hvc0"}, fields...), " ")
-}
-
-func consoleMode(rec *vm.VMRecord) string {
- if rec != nil && rec.Firmware == "" {
- return "pty"
- }
- return "off"
-}
-
-func directBootNetworkCmdline(rec *vm.VMRecord) string {
- var b strings.Builder
- if rec.Name != "" {
- b.WriteString(" kumabox.hostname=")
- b.WriteString(rec.Name)
- }
- if len(rec.NetworkConfigs) == 0 {
- return b.String()
- }
- b.WriteString(" net.ifnames=0")
- for i, nc := range rec.NetworkConfigs {
- if nc.Network == nil || nc.Network.IP == "" {
- continue
- }
- b.WriteString(" ip=")
- b.WriteString(nc.Network.IP)
- b.WriteString("::")
- b.WriteString(nc.Network.Gateway)
- b.WriteString(":")
- b.WriteString(prefixNetmask(nc.Network.Prefix))
- b.WriteString(":")
- b.WriteString(rec.Name)
- b.WriteString(":")
- b.WriteString(guestNICName(nc.IfName, i))
- b.WriteString(":off")
- for _, dns := range firstDNS(nc.Network.DNS, 2) {
- b.WriteString(":")
- b.WriteString(dns)
- }
- }
- return b.String()
-}
-
-func guestNICName(ifName string, index int) string {
- if ifName != "" {
- return ifName
- }
- return kbnetwork.GuestInterfaceName(index)
-}
-
-func prefixNetmask(prefix int) string {
- mask := net.CIDRMask(prefix, 32)
- if mask == nil {
- return "255.255.255.0"
- }
- return net.IP(mask).String()
-}
-
-func firstDNS(values []string, max int) []string {
- out := make([]string, 0, max)
- for _, value := range values {
- if value == "" {
- continue
- }
- out = append(out, value)
- if len(out) == max {
- break
- }
- }
- return out
-}
-
-func activeMetadata(rec *vm.VMRecord) *vm.Metadata {
- if rec == nil || rec.FirstBooted {
- return nil
- }
- return rec.Metadata
-}
-
-func newRootDisk(rec *vm.VMRecord) Disk {
- disk := Disk{Path: rec.RootDisk, Readonly: false}
- if imageType := rootDiskImageType(rec); imageType != "" {
- disk.ImageType = imageType
- disk.BackingFiles = imageType == vm.FormatQCOW2
- }
- return disk
-}
-
-func rootDiskImageType(rec *vm.VMRecord) string {
- if rec.Firmware != "" {
- return vm.FormatQCOW2
- }
- if filepath.Ext(rec.RootDisk) == ".qcow2" {
- return vm.FormatQCOW2
- }
- return ""
-}
diff --git a/internal/backend/cloudhypervisor/config_test.go b/internal/backend/cloudhypervisor/config_test.go
deleted file mode 100644
index 54b4e2f..0000000
--- a/internal/backend/cloudhypervisor/config_test.go
+++ /dev/null
@@ -1,491 +0,0 @@
-package cloudhypervisor
-
-import (
- "bytes"
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/kumabox/kumabox/internal/config"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestRenderConfigWritesResolvedPaths(t *testing.T) {
- dir := t.TempDir()
- rec := &vm.VMRecord{
- ID: "kb_test",
- Name: "test",
- RootDisk: "/fixtures/base.qcow2",
- Kernel: "/fixtures/vmlinuz",
- Initrd: "/fixtures/initrd.img",
- RunDir: filepath.Join(dir, "run", "vms", "kb_test"),
- LogDir: filepath.Join(dir, "logs", "vms", "kb_test"),
- Config: filepath.Join(dir, "run", "vms", "kb_test", "cloud-hypervisor.json"),
- }
- rec.VsockSocket = filepath.Join(rec.RunDir, "vsock.uds")
-
- cfg := config.Default()
- cfg.Backend.CloudHypervisor.Binary = "/usr/local/bin/cloud-hypervisor"
-
- if err := NewRenderer(cfg).RenderConfig(rec); err != nil {
- t.Fatal(err)
- }
-
- raw, err := os.ReadFile(rec.Config)
- if err != nil {
- t.Fatal(err)
- }
-
- var rendered Config
- if err := json.Unmarshal(raw, &rendered); err != nil {
- t.Fatal(err)
- }
- if rendered.Binary != "/usr/local/bin/cloud-hypervisor" {
- t.Fatalf("binary = %s", rendered.Binary)
- }
- if rendered.Kernel == nil {
- t.Fatal("kernel config is nil")
- }
- if rendered.Kernel.Path != rec.Kernel {
- t.Fatalf("kernel path = %s", rendered.Kernel.Path)
- }
- if rendered.APISocket != filepath.Join(rec.RunDir, "ch.sock") {
- t.Fatalf("api socket = %s", rendered.APISocket)
- }
- if rendered.Vsock == nil || rendered.Vsock.CID != 3 || rendered.Vsock.Socket != rec.VsockSocket {
- t.Fatalf("vsock = %+v", rendered.Vsock)
- }
- if !argsContainPair(rendered.Args, "--vsock", "cid=3,socket="+rec.VsockSocket) {
- t.Fatalf("vsock arg missing: %v", rendered.Args)
- }
-}
-
-func TestRenderConfigSupportsFirmwareBoot(t *testing.T) {
- dir := t.TempDir()
- rec := &vm.VMRecord{
- ID: "kb_uefi",
- Name: "uefi",
- RootDisk: "/fixtures/ubuntu.img",
- Firmware: "/fixtures/CLOUDHV.fd",
- RunDir: filepath.Join(dir, "run", "vms", "kb_uefi"),
- LogDir: filepath.Join(dir, "logs", "vms", "kb_uefi"),
- Config: filepath.Join(dir, "run", "vms", "kb_uefi", "cloud-hypervisor.json"),
- Metadata: &vm.Metadata{
- Type: "nocloud",
- CidataDir: filepath.Join(dir, "run", "vms", "kb_uefi", "cidata"),
- CidataDisk: filepath.Join(dir, "run", "vms", "kb_uefi", "cidata.img"),
- },
- }
-
- cfg := config.Default()
- if err := NewRenderer(cfg).RenderConfig(rec); err != nil {
- t.Fatal(err)
- }
-
- raw, err := os.ReadFile(rec.Config)
- if err != nil {
- t.Fatal(err)
- }
- var rendered Config
- if err := json.Unmarshal(raw, &rendered); err != nil {
- t.Fatal(err)
- }
- if rendered.Firmware == nil || rendered.Firmware.Path != rec.Firmware {
- t.Fatalf("firmware = %+v", rendered.Firmware)
- }
- if rendered.Kernel != nil || rendered.Initramfs != nil {
- t.Fatalf("direct boot payload must be omitted: kernel=%+v initramfs=%+v", rendered.Kernel, rendered.Initramfs)
- }
- if len(rendered.Disks) != 2 {
- t.Fatalf("disks = %+v", rendered.Disks)
- }
- if rendered.Disks[1].Path != rec.Metadata.CidataDisk || !rendered.Disks[1].Readonly || rendered.Disks[1].ImageType != "raw" {
- t.Fatalf("cidata disk = %+v", rendered.Disks[1])
- }
- for _, name := range []string{"meta-data", "user-data", "network-config"} {
- if _, err := os.Stat(filepath.Join(rec.Metadata.CidataDir, name)); err != nil {
- t.Fatalf("%s missing: %v", name, err)
- }
- }
- if _, err := os.Stat(rec.Metadata.CidataDisk); err != nil {
- t.Fatal(err)
- }
- if !argsContainPair(rendered.Args, "--firmware", rec.Firmware) {
- t.Fatalf("firmware arg missing: %v", rendered.Args)
- }
- if !argsContainPair(rendered.Args, "--disk", "path="+rec.RootDisk+",direct=on,image_type=qcow2,backing_files=on,num_queues=1,queue_size=512") {
- t.Fatalf("qcow2 backing files arg missing: %v", rendered.Args)
- }
- if !argsContainPair(rendered.Args, "--disk", "path="+rec.Metadata.CidataDisk+",readonly=on,image_type=raw,num_queues=1,queue_size=512") {
- t.Fatalf("cidata disk arg missing: %v", rendered.Args)
- }
- if countArg(rendered.Args, "--disk") != 1 {
- t.Fatalf("disk option must be grouped: %v", rendered.Args)
- }
-}
-
-func TestRenderConfigEnablesBackingFilesOnlyForWritableQcow2(t *testing.T) {
- rec := &vm.VMRecord{
- ID: "kb_overlay",
- Name: "overlay",
- Firmware: "/fixtures/CLOUDHV.fd",
- RunDir: "/run/kumabox/vms/kb_overlay",
- LogDir: "/var/log/kumabox/vms/kb_overlay",
- StorageConfigs: []vm.StorageConfig{
- {ID: "root", Role: vm.StorageRoleCOW, Path: "/data/root.overlay.qcow2", Format: "qcow2"},
- {ID: "layer", Role: vm.StorageRoleLayer, Path: "/data/layer.erofs", Readonly: true, Format: "raw"},
- },
- }
-
- rendered := NewConfig(config.Default(), rec)
- if !rendered.Disks[0].BackingFiles {
- t.Fatal("writable qcow2 disk did not enable backing files")
- }
- if rendered.Disks[1].BackingFiles {
- t.Fatal("read-only raw disk unexpectedly enabled backing files")
- }
- if !argsContainPair(rendered.Args, "--disk", "path=/data/root.overlay.qcow2,direct=on,image_type=qcow2,backing_files=on,num_queues=1,queue_size=512") {
- t.Fatalf("overlay disk arg missing: %v", rendered.Args)
- }
-}
-
-func TestRenderConfigSupportsOCIStorageDisks(t *testing.T) {
- dir := t.TempDir()
- rec := &vm.VMRecord{
- ID: "kb_oci",
- Name: "oci",
- Kernel: "/fixtures/vmlinuz",
- Initrd: "/fixtures/initrd.img",
- KernelCmdline: "console=ttyS0 kumabox.layers={{layers}} kumabox.cow={{cow}}",
- RunDir: filepath.Join(dir, "run", "vms", "kb_oci"),
- LogDir: filepath.Join(dir, "logs", "vms", "kb_oci"),
- Config: filepath.Join(dir, "run", "vms", "kb_oci", "cloud-hypervisor.json"),
- StorageConfigs: []vm.StorageConfig{
- {
- ID: "layer0",
- Type: "layer",
- Path: "/data/oci/erofs/blobs/sha256/layer0.erofs",
- Readonly: true,
- ImageType: "raw",
- Serial: "kumabox-layer0",
- },
- {
- ID: "cow",
- Type: "cow",
- Path: filepath.Join(dir, "run", "vms", "kb_oci", "cow.ext4"),
- ImageType: "raw",
- Serial: "kumabox-cow",
- },
- },
- NetworkConfigs: []kbnetwork.Config{
- {
- MAC: "5a:00:00:00:00:01",
- IfName: "eth0",
- Network: &kbnetwork.GuestInfo{
- IP: "10.88.0.2",
- Gateway: "10.88.0.1",
- Prefix: 16,
- DNS: []string{"1.1.1.1", "8.8.8.8"},
- },
- },
- },
- }
-
- cfg := config.Default()
- if err := NewRenderer(cfg).RenderConfig(rec); err != nil {
- t.Fatal(err)
- }
-
- raw, err := os.ReadFile(rec.Config)
- if err != nil {
- t.Fatal(err)
- }
- var rendered Config
- if err := json.Unmarshal(raw, &rendered); err != nil {
- t.Fatal(err)
- }
- if len(rendered.Disks) != 2 {
- t.Fatalf("disks = %+v", rendered.Disks)
- }
- if !rendered.Disks[0].Readonly || rendered.Disks[0].Serial != "kumabox-layer0" {
- t.Fatalf("layer disk = %+v", rendered.Disks[0])
- }
- if rendered.Disks[1].Readonly || rendered.Disks[1].Serial != "kumabox-cow" {
- t.Fatalf("cow disk = %+v", rendered.Disks[1])
- }
- wantCmdline := "console=hvc0 kumabox.layers=kumabox-layer0 kumabox.cow=kumabox-cow kumabox.hostname=oci net.ifnames=0 ip=10.88.0.2::10.88.0.1:255.255.0.0:oci:eth0:off:1.1.1.1:8.8.8.8"
- if rendered.Kernel == nil || rendered.Kernel.Cmdline != wantCmdline {
- t.Fatalf("kernel = %+v", rendered.Kernel)
- }
- if !argsContainPair(rendered.Args, "--disk", "path=/data/oci/erofs/blobs/sha256/layer0.erofs,readonly=on,image_type=raw,num_queues=1,queue_size=512,serial=kumabox-layer0") {
- t.Fatalf("layer disk arg missing: %v", rendered.Args)
- }
- if !argsContainPair(rendered.Args, "--disk", "path="+filepath.Join(dir, "run", "vms", "kb_oci", "cow.ext4")+",direct=on,sparse=on,image_type=raw,num_queues=1,queue_size=512,serial=kumabox-cow") {
- t.Fatalf("cow disk arg missing: %v", rendered.Args)
- }
-}
-
-func TestRenderConfigUsesConfiguredDiskIOPolicy(t *testing.T) {
- rec := &vm.VMRecord{
- ID: "kb_disk_policy",
- Name: "disk-policy",
- CPUs: 4,
- Kernel: "/fixtures/vmlinuz",
- Initrd: "/fixtures/initrd.img",
- RunDir: "/run/kumabox/vms/kb_disk_policy",
- LogDir: "/var/log/kumabox/vms/kb_disk_policy",
- StorageConfigs: []vm.StorageConfig{{
- ID: "data", Path: "/data/data.raw", Format: vm.FormatRaw,
- DirectIO: boolPtr(false),
- }},
- }
- cfg := config.Default()
- cfg.Backend.CloudHypervisor.DiskQueueSize = 128
- rendered := NewConfig(cfg, rec)
- if got := rendered.Disks[0]; got.NumQueues != 4 || got.QueueSize != 128 || got.DirectIO || !got.Sparse {
- t.Fatalf("disk policy = %+v", got)
- }
- if len(rendered.Disks[0].QueueAffinity) != 4 || rendered.Disks[0].QueueAffinity[2].QueueIndex != 2 {
- t.Fatalf("disk queue affinity = %+v", rendered.Disks[0].QueueAffinity)
- }
- if !argsContainPair(rendered.Args, "--disk", "path=/data/data.raw,sparse=on,image_type=raw,num_queues=4,queue_size=128,queue_affinity=[0@[0],1@[1],2@[2],3@[3]]") {
- t.Fatalf("configured disk affinity missing: %v", rendered.Args)
- }
-}
-
-func TestDirectBootConsoleCmdlineUsesPTYConsole(t *testing.T) {
- tests := map[string]struct {
- input string
- want string
- }{
- "serial console": {input: "console=ttyS0 loglevel=3", want: "console=hvc0 loglevel=3"},
- "other console": {input: "console=ttyAMA0 rw", want: "console=hvc0 rw"},
- "missing console": {input: "loglevel=3 rw", want: "console=hvc0 loglevel=3 rw"},
- }
- for name, test := range tests {
- t.Run(name, func(t *testing.T) {
- if got := directBootConsoleCmdline(test.input); got != test.want {
- t.Fatalf("console cmdline = %q, want %q", got, test.want)
- }
- })
- }
-}
-
-func boolPtr(value bool) *bool { return &value }
-
-func TestRenderConfigIncludesNetworkDevice(t *testing.T) {
- dir := t.TempDir()
- rec := &vm.VMRecord{
- ID: "kb_net",
- Name: "net",
- RootDisk: "/fixtures/ubuntu.img",
- Firmware: "/fixtures/CLOUDHV.fd",
- CPUs: 4,
- RunDir: filepath.Join(dir, "run", "vms", "kb_net"),
- LogDir: filepath.Join(dir, "logs", "vms", "kb_net"),
- Config: filepath.Join(dir, "run", "vms", "kb_net", "cloud-hypervisor.json"),
- Metadata: &vm.Metadata{
- Type: "nocloud",
- CidataDir: filepath.Join(dir, "run", "vms", "kb_net", "cidata"),
- CidataDisk: filepath.Join(dir, "run", "vms", "kb_net", "cidata.img"),
- },
- NetworkConfigs: []kbnetwork.Config{{
- ID: "net_test",
- TAP: "kbtaptest",
- MAC: "02:00:00:00:00:11",
- NumQueues: 2,
- QueueSize: 256,
- Backend: kbnetwork.ProviderCNI,
- IfName: "eth0",
- NetnsPath: "/var/run/netns/kb_net",
- Network: &kbnetwork.GuestInfo{
- IP: "10.88.0.2",
- Gateway: "10.88.0.1",
- Prefix: 16,
- DNS: []string{"1.1.1.1"},
- },
- }},
- }
-
- if err := NewRenderer(config.Default()).RenderConfig(rec); err != nil {
- t.Fatal(err)
- }
-
- raw, err := os.ReadFile(rec.Config)
- if err != nil {
- t.Fatal(err)
- }
- var rendered Config
- if err := json.Unmarshal(raw, &rendered); err != nil {
- t.Fatal(err)
- }
- if len(rendered.Nets) != 1 {
- t.Fatalf("nets = %+v", rendered.Nets)
- }
- if rendered.Nets[0].TAP != "kbtaptest" || rendered.Nets[0].MAC != "02:00:00:00:00:11" {
- t.Fatalf("net = %+v", rendered.Nets[0])
- }
- if rendered.NetnsPath != "/var/run/netns/kb_net" {
- t.Fatalf("netns path = %s", rendered.NetnsPath)
- }
- if rendered.CPUs.Boot != 4 {
- t.Fatalf("cpus = %+v", rendered.CPUs)
- }
- if rendered.Memory.Size != 512<<20 {
- t.Fatalf("memory = %+v", rendered.Memory)
- }
- if !argsContainPair(rendered.Args, "--cpus", "boot=4") {
- t.Fatalf("cpus arg missing: %v", rendered.Args)
- }
- if !argsContainPair(rendered.Args, "--memory", "size=536870912") {
- t.Fatalf("memory arg missing: %v", rendered.Args)
- }
- if !argsContainPair(rendered.Args, "--net", "tap=kbtaptest,mac=02:00:00:00:00:11,num_queues=2,queue_size=256,offload_tso=on,offload_ufo=on,offload_csum=on") {
- t.Fatalf("net arg missing: %v", rendered.Args)
- }
- networkConfig, err := os.ReadFile(filepath.Join(rec.Metadata.CidataDir, "network-config"))
- if err != nil {
- t.Fatal(err)
- }
- if !bytes.Contains(networkConfig, []byte(`macaddress: "02:00:00:00:00:11"`)) ||
- !bytes.Contains(networkConfig, []byte("10.88.0.2/16")) ||
- !bytes.Contains(networkConfig, []byte("gateway4: 10.88.0.1")) {
- t.Fatalf("network-config = %s", networkConfig)
- }
-}
-
-func TestConfigGroupsMultipleNetworkValuesUnderOneOption(t *testing.T) {
- rec := &vm.VMRecord{
- ID: "kb_multi_net",
- Name: "multi-net",
- RootDisk: "/fixtures/ubuntu.img",
- Firmware: "/fixtures/CLOUDHV.fd",
- RunDir: "/run/kumabox/vms/kb_multi_net",
- LogDir: "/var/log/kumabox/vms/kb_multi_net",
- NetworkConfigs: []kbnetwork.Config{
- {TAP: "kbtap0", MAC: "02:00:00:00:00:10", NumQueues: 2, QueueSize: 256},
- {TAP: "kbtap1", MAC: "02:00:00:00:00:11", NumQueues: 2, QueueSize: 256},
- },
- }
-
- rendered := NewConfig(config.Default(), rec)
- if countArg(rendered.Args, "--net") != 1 {
- t.Fatalf("network option must be grouped: %v", rendered.Args)
- }
- for _, value := range []string{
- "tap=kbtap0,mac=02:00:00:00:00:10,num_queues=2,queue_size=256,offload_tso=on,offload_ufo=on,offload_csum=on",
- "tap=kbtap1,mac=02:00:00:00:00:11,num_queues=2,queue_size=256,offload_tso=on,offload_ufo=on,offload_csum=on",
- } {
- if !argsContainPair(rendered.Args, "--net", value) {
- t.Fatalf("network value %q missing: %v", value, rendered.Args)
- }
- }
-}
-
-func TestRenderConfigRejectsInvalidNetworkQueues(t *testing.T) {
- dir := t.TempDir()
- rec := &vm.VMRecord{
- ID: "kb_bad_queue",
- Name: "bad-queue",
- RootDisk: "/fixtures/ubuntu.img",
- Firmware: "/fixtures/CLOUDHV.fd",
- RunDir: filepath.Join(dir, "run", "vms", "kb_bad_queue"),
- LogDir: filepath.Join(dir, "logs", "vms", "kb_bad_queue"),
- Config: filepath.Join(dir, "run", "vms", "kb_bad_queue", "cloud-hypervisor.json"),
- NetworkConfigs: []kbnetwork.Config{{
- ID: "net_bad",
- TAP: "kbtapbad",
- MAC: "02:00:00:00:00:12",
- NumQueues: 1,
- Backend: kbnetwork.ProviderHostTap,
- }},
- }
-
- err := NewRenderer(config.Default()).RenderConfig(rec)
- if err == nil || !bytes.Contains([]byte(err.Error()), []byte("numQueues must be at least 2")) {
- t.Fatalf("render error = %v", err)
- }
-}
-
-func TestValidateConfigRejectsIncompleteLaunchPlan(t *testing.T) {
- valid := Config{
- Binary: "cloud-hypervisor", APISocket: "/run/ch.sock", PIDFile: "/run/ch.pid",
- CPUs: CPUs{Boot: 1}, Memory: Memory{Size: 512 << 20},
- Kernel: &Kernel{Path: "/boot/vmlinuz"}, Initramfs: &Initramfs{Path: "/boot/initrd"},
- }
- if err := ValidateConfig(valid); err != nil {
- t.Fatalf("valid launch plan: %v", err)
- }
- valid.Memory.Size = 0
- if err := ValidateConfig(valid); err == nil {
- t.Fatal("expected invalid memory error")
- }
-}
-
-func TestRenderConfigSkipsCidataAfterFirstBoot(t *testing.T) {
- dir := t.TempDir()
- rec := &vm.VMRecord{
- ID: "kb_uefi",
- Name: "uefi",
- RootDisk: "/fixtures/ubuntu.img",
- Firmware: "/fixtures/CLOUDHV.fd",
- RunDir: filepath.Join(dir, "run", "vms", "kb_uefi"),
- LogDir: filepath.Join(dir, "logs", "vms", "kb_uefi"),
- Config: filepath.Join(dir, "run", "vms", "kb_uefi", "cloud-hypervisor.json"),
- FirstBooted: true,
- Metadata: &vm.Metadata{
- Type: "nocloud",
- CidataDir: filepath.Join(dir, "run", "vms", "kb_uefi", "cidata"),
- CidataDisk: filepath.Join(dir, "run", "vms", "kb_uefi", "cidata.img"),
- },
- }
-
- if err := NewRenderer(config.Default()).RenderConfig(rec); err != nil {
- t.Fatal(err)
- }
-
- raw, err := os.ReadFile(rec.Config)
- if err != nil {
- t.Fatal(err)
- }
- var rendered Config
- if err := json.Unmarshal(raw, &rendered); err != nil {
- t.Fatal(err)
- }
- if len(rendered.Disks) != 1 {
- t.Fatalf("disks = %+v", rendered.Disks)
- }
- if argsContainPair(rendered.Args, "--disk", "path="+rec.Metadata.CidataDisk+",readonly=on,image_type=raw") {
- t.Fatalf("cidata disk arg should be skipped after first boot: %v", rendered.Args)
- }
- if _, err := os.Stat(rec.Metadata.CidataDisk); !os.IsNotExist(err) {
- t.Fatalf("cidata disk should not be regenerated after first boot: %v", err)
- }
-}
-
-func argsContainPair(args []string, key, value string) bool {
- for i := 0; i < len(args); i++ {
- if args[i] != key {
- continue
- }
- for i++; i < len(args) && !strings.HasPrefix(args[i], "--"); i++ {
- if args[i] == value {
- return true
- }
- }
- }
- return false
-}
-
-func countArg(args []string, value string) int {
- count := 0
- for _, arg := range args {
- if arg == value {
- count++
- }
- }
- return count
-}
diff --git a/internal/backend/cloudhypervisor/console.go b/internal/backend/cloudhypervisor/console.go
deleted file mode 100644
index 513485a..0000000
--- a/internal/backend/cloudhypervisor/console.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "fmt"
- "io"
- "net"
- "os"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const backendConsoleTimeout = 5 * time.Second
-
-type consoleFile struct {
- *os.File
-}
-
-func (f *consoleFile) SetSize(rows, columns uint16) error {
- if rows == 0 || columns == 0 {
- return fmt.Errorf("console dimensions must be non-zero")
- }
- return setConsoleSize(f.Fd(), rows, columns)
-}
-
-// OpenConsole resolves the PTY allocated by Cloud Hypervisor for direct boot.
-// The PTY path is intentionally read from vm.info instead of guessed from the
-// host, because Cloud Hypervisor owns its allocation.
-func (b Backend) OpenConsole(ctx context.Context, rec *vm.VMRecord) (io.ReadWriteCloser, error) {
- if rec == nil {
- return nil, fmt.Errorf("VM record is nil")
- }
- if rec.Firmware != "" {
- return nil, fmt.Errorf("VM %s uses firmware boot; console socket is not configured", rec.Name)
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendConsoleTimeout)
- if err != nil {
- return nil, fmt.Errorf("query VM console: %w", err)
- }
- path := info.Config.Console.File
- if path == "" || !isPTYConsoleMode(info.Config.Console.Mode) {
- return nil, fmt.Errorf("VM %s has no PTY console (mode=%s)", rec.Name, info.Config.Console.Mode)
- }
- fileInfo, err := os.Stat(path)
- if err != nil {
- return nil, fmt.Errorf("stat console PTY %s: %w", path, err)
- }
- if fileInfo.Mode()&os.ModeSocket != 0 {
- conn, dialErr := (&net.Dialer{}).DialContext(ctx, "unix", path)
- if dialErr != nil {
- return nil, fmt.Errorf("connect console socket %s: %w", path, dialErr)
- }
- return conn, nil
- }
- file, err := os.OpenFile(path, os.O_RDWR, 0) //nolint:gosec
- if err != nil {
- return nil, fmt.Errorf("open console PTY %s: %w", path, err)
- }
- return &consoleFile{File: file}, nil
-}
-
-func isPTYConsoleMode(mode string) bool {
- return strings.EqualFold(strings.TrimSpace(mode), "pty")
-}
diff --git a/internal/backend/cloudhypervisor/console_resize_linux.go b/internal/backend/cloudhypervisor/console_resize_linux.go
deleted file mode 100644
index 09c906c..0000000
--- a/internal/backend/cloudhypervisor/console_resize_linux.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build linux
-
-package cloudhypervisor
-
-import "golang.org/x/sys/unix"
-
-func setConsoleSize(fileFD uintptr, rows, columns uint16) error {
- return unix.IoctlSetWinsize(int(fileFD), unix.TIOCSWINSZ, &unix.Winsize{Row: rows, Col: columns})
-}
diff --git a/internal/backend/cloudhypervisor/console_resize_other.go b/internal/backend/cloudhypervisor/console_resize_other.go
deleted file mode 100644
index a44313e..0000000
--- a/internal/backend/cloudhypervisor/console_resize_other.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !linux
-
-package cloudhypervisor
-
-import "fmt"
-
-func setConsoleSize(_ uintptr, _, _ uint16) error {
- return fmt.Errorf("console resize is only supported on Linux")
-}
diff --git a/internal/backend/cloudhypervisor/console_test.go b/internal/backend/cloudhypervisor/console_test.go
deleted file mode 100644
index 92f6986..0000000
--- a/internal/backend/cloudhypervisor/console_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package cloudhypervisor
-
-import "testing"
-
-func TestIsPTYConsoleMode(t *testing.T) {
- tests := map[string]bool{
- "pty": true,
- "Pty": true,
- "PTY": true,
- " pty ": true,
- "file": false,
- "": false,
- }
- for mode, want := range tests {
- t.Run(mode, func(t *testing.T) {
- if got := isPTYConsoleMode(mode); got != want {
- t.Fatalf("isPTYConsoleMode(%q) = %t, want %t", mode, got, want)
- }
- })
- }
-}
diff --git a/internal/backend/cloudhypervisor/devices.go b/internal/backend/cloudhypervisor/devices.go
deleted file mode 100644
index 360012a..0000000
--- a/internal/backend/cloudhypervisor/devices.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "errors"
- "strings"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// InspectDevices obtains one vm.info snapshot and derives all KumaBox-owned
-// hotplug devices from it.
-func (b Backend) InspectDevices(ctx context.Context, rec *vm.VMRecord) (backend.DeviceState, error) {
- if rec == nil {
- return backend.DeviceState{}, errors.New("VM record is nil")
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendAPIRequestTimeout)
- if err != nil {
- return backend.DeviceState{}, err
- }
- state := backend.DeviceState{}
- for _, disk := range info.Config.Disks {
- if strings.HasPrefix(disk.ID, diskIDPrefix) {
- name := strings.TrimPrefix(disk.ID, diskIDPrefix)
- if validDiskName(name) {
- state.Disks = append(state.Disks, backend.AttachedDisk{ID: disk.ID, Name: name, Path: disk.Path, ReadOnly: disk.ReadOnly})
- }
- }
- }
- for _, fs := range info.Config.Fs {
- if strings.HasPrefix(fs.ID, filesystemIDPrefix) {
- state.Filesystems = append(state.Filesystems, backend.AttachedFilesystem{ID: fs.ID, Tag: fs.Tag, Socket: fs.Socket})
- }
- }
- for _, device := range info.Config.Devices {
- if strings.HasPrefix(device.ID, "kumabox-pci-") {
- state.PCIDevices = append(state.PCIDevices, backend.AttachedPCIDevice{ID: device.ID, PCI: device.Path})
- }
- }
- return state, nil
-}
diff --git a/internal/backend/cloudhypervisor/disk.go b/internal/backend/cloudhypervisor/disk.go
deleted file mode 100644
index cdb5a17..0000000
--- a/internal/backend/cloudhypervisor/disk.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const (
- diskIDPrefix = "kumabox-disk-"
- cloudHypervisorRaw = "Raw"
-)
-
-func (b Backend) AttachDisk(ctx context.Context, rec *vm.VMRecord, spec backend.DiskSpec) (backend.AttachedDisk, error) {
- if rec == nil {
- return backend.AttachedDisk{}, errors.New("VM record is nil")
- }
- if !filepath.IsAbs(spec.Path) {
- return backend.AttachedDisk{}, fmt.Errorf("disk path must be absolute")
- }
- if !validDiskName(spec.Name) {
- return backend.AttachedDisk{}, fmt.Errorf("disk name %q is invalid", spec.Name)
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendAPIRequestTimeout)
- if err != nil {
- return backend.AttachedDisk{}, err
- }
- if !strings.EqualFold(info.State, backendStateRunning) {
- return backend.AttachedDisk{}, fmt.Errorf("VM must be running")
- }
- st, err := os.Stat(spec.Path)
- if err != nil {
- return backend.AttachedDisk{}, fmt.Errorf("stat disk: %w", err)
- }
- if !st.Mode().IsRegular() {
- return backend.AttachedDisk{}, fmt.Errorf("disk path is not a regular file")
- }
- id := diskIDPrefix + spec.Name
- for _, disk := range info.Config.Disks {
- if disk.ID == id || disk.Serial == spec.Name || disk.Path == spec.Path {
- return backend.AttachedDisk{}, fmt.Errorf("disk %q is already attached", spec.Name)
- }
- }
- direct := false
- if spec.DirectIO != nil {
- direct = *spec.DirectIO
- }
- body := map[string]any{"id": id, "path": spec.Path, "readonly": spec.ReadOnly, "direct": direct, "image_type": cloudHypervisorRaw, "serial": spec.Name}
- if _, err := doAPIOnce(ctx, rec.APISocket, backendAPIRequestTimeout, http.MethodPut, apiVMAddDisk, mustJSON(body), http.StatusOK, http.StatusNoContent); err != nil {
- return backend.AttachedDisk{}, err
- }
- return backend.AttachedDisk{ID: id, Name: spec.Name, Path: spec.Path, ReadOnly: spec.ReadOnly}, nil
-}
-
-func (b Backend) DetachDisk(ctx context.Context, rec *vm.VMRecord, name string) error {
- if rec == nil {
- return errors.New("VM record is nil")
- }
- if !validDiskName(name) {
- return fmt.Errorf("disk name %q is invalid", name)
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendAPIRequestTimeout)
- if err != nil {
- return err
- }
- id := diskIDPrefix + name
- for _, disk := range info.Config.Disks {
- if disk.ID == id || disk.Serial == name {
- _, err := doAPIOnce(ctx, rec.APISocket, backendAPIRequestTimeout, http.MethodPut, apiVMRemoveDevice, mustJSON(map[string]string{"id": disk.ID}), http.StatusNoContent)
- return err
- }
- }
- return fmt.Errorf("disk %q is not attached", name)
-}
-
-func (b Backend) ListDisks(ctx context.Context, rec *vm.VMRecord) ([]backend.AttachedDisk, error) {
- if rec == nil {
- return nil, errors.New("VM record is nil")
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendAPIRequestTimeout)
- if err != nil {
- return nil, err
- }
- result := make([]backend.AttachedDisk, 0)
- for _, disk := range info.Config.Disks {
- if !strings.HasPrefix(disk.ID, diskIDPrefix) {
- continue
- }
- name := strings.TrimPrefix(disk.ID, diskIDPrefix)
- if validDiskName(name) {
- result = append(result, backend.AttachedDisk{ID: disk.ID, Name: name, Path: disk.Path, ReadOnly: disk.ReadOnly})
- }
- }
- return result, nil
-}
-
-func validDiskName(name string) bool {
- if len(name) == 0 || len(name) > 20 || name[0] < 'a' || name[0] > 'z' {
- return false
- }
- for _, c := range name[1:] {
- if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '_' && c != '-' {
- return false
- }
- }
- return true
-}
-
-func mustJSON(value any) []byte { raw, _ := json.Marshal(value); return raw }
diff --git a/internal/backend/cloudhypervisor/disk_test.go b/internal/backend/cloudhypervisor/disk_test.go
deleted file mode 100644
index 245a444..0000000
--- a/internal/backend/cloudhypervisor/disk_test.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package cloudhypervisor
-
-import "testing"
-
-func TestValidDiskName(t *testing.T) {
- for _, test := range []struct {
- name string
- valid bool
- }{
- {"workspace", true}, {"data_1", true}, {"1data", false}, {"bad.name", false}, {"", false},
- } {
- if got := validDiskName(test.name); got != test.valid {
- t.Errorf("validDiskName(%q) = %v, want %v", test.name, got, test.valid)
- }
- }
-}
diff --git a/internal/backend/cloudhypervisor/filesystem.go b/internal/backend/cloudhypervisor/filesystem.go
deleted file mode 100644
index c534b7a..0000000
--- a/internal/backend/cloudhypervisor/filesystem.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const filesystemIDPrefix = "kumabox-fs-"
-
-func (b Backend) AttachFilesystem(ctx context.Context, rec *vm.VMRecord, spec backend.FilesystemSpec) (backend.AttachedFilesystem, error) {
- if rec == nil {
- return backend.AttachedFilesystem{}, fmt.Errorf("VM record is nil")
- }
- if !rec.SharedMemory {
- return backend.AttachedFilesystem{}, fmt.Errorf("virtio-fs requires shared memory at VM creation")
- }
- if spec.Socket == "" || spec.Tag == "" {
- return backend.AttachedFilesystem{}, fmt.Errorf("socket and tag are required")
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendAPIRequestTimeout)
- if err != nil {
- return backend.AttachedFilesystem{}, err
- }
- id := filesystemIDPrefix + spec.Tag
- for _, fs := range info.Config.Fs {
- if fs.ID == id || fs.Tag == spec.Tag {
- return backend.AttachedFilesystem{}, fmt.Errorf("filesystem tag %q is already attached", spec.Tag)
- }
- }
- body, err := json.Marshal(map[string]any{"id": id, "tag": spec.Tag, "socket": spec.Socket, "num_queues": spec.NumQueues, "queue_size": spec.QueueSize})
- if err != nil {
- return backend.AttachedFilesystem{}, err
- }
- if _, err := doAPIOnce(ctx, rec.APISocket, backendAPIRequestTimeout, http.MethodPut, apiVMAddFS, body, http.StatusOK, http.StatusNoContent); err != nil {
- return backend.AttachedFilesystem{}, err
- }
- return backend.AttachedFilesystem{ID: id, Tag: spec.Tag, Socket: spec.Socket}, nil
-}
-
-func (b Backend) DetachFilesystem(ctx context.Context, rec *vm.VMRecord, tag string) error {
- if rec == nil {
- return fmt.Errorf("VM record is nil")
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendAPIRequestTimeout)
- if err != nil {
- return err
- }
- for _, fs := range info.Config.Fs {
- if fs.Tag == tag || fs.ID == filesystemIDPrefix+tag {
- body, _ := json.Marshal(map[string]string{"id": fs.ID})
- _, err := doAPIOnce(ctx, rec.APISocket, backendAPIRequestTimeout, http.MethodPut, apiVMRemoveDevice, body, http.StatusNoContent)
- return err
- }
- }
- return fmt.Errorf("filesystem tag %q is not attached", tag)
-}
-
-func (b Backend) ListFilesystems(ctx context.Context, rec *vm.VMRecord) ([]backend.AttachedFilesystem, error) {
- if rec == nil {
- return nil, fmt.Errorf("VM record is nil")
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendAPIRequestTimeout)
- if err != nil {
- return nil, err
- }
- result := make([]backend.AttachedFilesystem, 0, len(info.Config.Fs))
- for _, fs := range info.Config.Fs {
- if strings.HasPrefix(fs.ID, filesystemIDPrefix) {
- result = append(result, backend.AttachedFilesystem{ID: fs.ID, Tag: fs.Tag, Socket: fs.Socket})
- }
- }
- return result, nil
-}
diff --git a/internal/backend/cloudhypervisor/network.go b/internal/backend/cloudhypervisor/network.go
deleted file mode 100644
index 2ab3846..0000000
--- a/internal/backend/cloudhypervisor/network.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
-
- "github.com/kumabox/kumabox/internal/backend"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-var _ backend.NetworkController = Backend{}
-
-func (b Backend) AttachNetwork(ctx context.Context, rec *vm.VMRecord, network kbnetwork.Config) error {
- if rec == nil {
- return fmt.Errorf("VM record is nil")
- }
- body, err := json.Marshal(map[string]any{"id": network.ID, "tap": network.TAP, "mac": network.MAC, "num_queues": network.NumQueues, "queue_size": network.QueueSize, "offload_tso": true, "offload_ufo": true, "offload_csum": true})
- if err != nil {
- return err
- }
- _, err = doAPIOnce(ctx, rec.APISocket, backendAPIRequestTimeout, http.MethodPut, apiVMAddNet, body, http.StatusOK, http.StatusNoContent)
- return err
-}
-
-func (b Backend) DetachNetwork(ctx context.Context, rec *vm.VMRecord, network kbnetwork.Config) error {
- if rec == nil {
- return fmt.Errorf("VM record is nil")
- }
- body, err := json.Marshal(map[string]string{"id": network.ID})
- if err != nil {
- return err
- }
- _, err = doAPIOnce(ctx, rec.APISocket, backendAPIRequestTimeout, http.MethodPut, apiVMRemoveDevice, body, http.StatusNoContent)
- return err
-}
diff --git a/internal/backend/cloudhypervisor/observe.go b/internal/backend/cloudhypervisor/observe.go
deleted file mode 100644
index 6fad725..0000000
--- a/internal/backend/cloudhypervisor/observe.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "syscall"
- "time"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const backendObserveTimeout = 500 * time.Millisecond
-
-func ObserveVM(rec *vm.VMRecord) vm.Observation {
- now := time.Now().UTC()
- if rec == nil {
- return observation(vm.ObservedStateUnknown, "VM record is nil", now)
- }
-
- switch rec.State {
- case vm.StateCreated:
- return observation(vm.ObservedStateCreated, "VM has not been started", now)
- case vm.StateStopped:
- return observation(vm.ObservedStateStopped, "VM is stopped", now)
- case vm.StateError:
- if rec.Error != "" {
- return observation(vm.ObservedStateFailed, rec.Error, now)
- }
- return observation(vm.ObservedStateFailed, "VM is recorded in error state", now)
- case vm.StateRunning, vm.StatePaused:
- default:
- return observation(vm.ObservedStateUnknown, "unrecognized persisted state "+string(rec.State), now)
- }
-
- pid := rec.PID
- apiSocket := rec.APISocket
- binary := ""
- if cfg, err := readRenderedConfig(rec.Config); err == nil {
- binary = cfg.Binary
- if apiSocket == "" {
- apiSocket = cfg.APISocket
- }
- } else if !errors.Is(err, os.ErrNotExist) {
- return observation(vm.ObservedStateUnknown, fmt.Sprintf("read backend config: %v", err), now)
- }
-
- if pid <= 0 {
- return observation(vm.ObservedStateUnknown, "running record has no pid", now)
- }
- if !processAlive(pid) {
- return observation(vm.ObservedStateStopped, fmt.Sprintf("process %d is not alive", pid), now)
- }
- if binary != "" && apiSocket != "" {
- matched, reason := verifyProcessIdentity(pid, binary, apiSocket)
- if !matched {
- return observation(vm.ObservedStateUnknown, reason, now)
- }
- }
- if apiSocket == "" {
- return observation(vm.ObservedStateUnknown, "running record has no API socket", now)
- }
- ctx, cancel := context.WithTimeout(context.Background(), backendObserveTimeout)
- defer cancel()
- info, err := queryVMInfo(ctx, apiSocket, backendObserveTimeout)
- if err != nil {
- return observation(vm.ObservedStateUnknown, fmt.Sprintf("API state check failed: %v", err), now)
- }
- switch strings.ToLower(info.State) {
- case "running":
- return observation(vm.ObservedStateRunning, "process identity and backend state are healthy", now)
- case "paused":
- return observation(vm.ObservedStatePaused, "process identity is healthy and backend is paused", now)
- default:
- return observation(vm.ObservedStateUnknown, "backend reported state "+info.State, now)
- }
-}
-
-func observation(state vm.ObservedState, reason string, checkedAt time.Time) vm.Observation {
- return vm.Observation{
- State: state,
- Reason: reason,
- CheckedAt: checkedAt,
- }
-}
-
-func readRenderedConfig(path string) (*Config, error) {
- if path == "" {
- return nil, os.ErrNotExist
- }
- raw, err := os.ReadFile(path) //nolint:gosec
- if err != nil {
- return nil, err
- }
- var cfg Config
- if err := json.Unmarshal(raw, &cfg); err != nil {
- return nil, fmt.Errorf("parse Cloud Hypervisor config: %w", err)
- }
- return &cfg, nil
-}
-
-func verifyProcessIdentity(pid int, binary string, apiSocket string) (bool, string) {
- data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) //nolint:gosec
- if err != nil {
- return false, fmt.Sprintf("cannot verify process identity for pid %d: %v", pid, err)
- }
-
- cmdline := string(data)
- binaryName := filepath.Base(binary)
- if !strings.Contains(cmdline, binaryName) {
- return false, fmt.Sprintf("pid %d command line does not contain %q", pid, binaryName)
- }
- if !strings.Contains(cmdline, apiSocket) {
- return false, fmt.Sprintf("pid %d command line does not contain API socket %q", pid, apiSocket)
- }
- return true, ""
-}
-
-func processAlive(pid int) bool {
- if pid <= 0 {
- return false
- }
- err := syscall.Kill(pid, 0)
- return err == nil || errors.Is(err, syscall.EPERM)
-}
diff --git a/internal/backend/cloudhypervisor/pci.go b/internal/backend/cloudhypervisor/pci.go
deleted file mode 100644
index 3e11388..0000000
--- a/internal/backend/cloudhypervisor/pci.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const pciSysfsPrefix = "/sys/bus/pci/devices/"
-
-func (b Backend) AttachPCIDevice(ctx context.Context, rec *vm.VMRecord, spec backend.PCIDeviceSpec) (backend.AttachedPCIDevice, error) {
- if rec == nil {
- return backend.AttachedPCIDevice{}, fmt.Errorf("VM record is nil")
- }
- path, err := normalizePCIPath(spec.PCI)
- if err != nil {
- return backend.AttachedPCIDevice{}, err
- }
- if _, err := os.Stat(path); err != nil {
- return backend.AttachedPCIDevice{}, fmt.Errorf("stat PCI device: %w", err)
- }
- driver, err := os.Readlink(filepath.Join(path, "driver"))
- if err != nil {
- return backend.AttachedPCIDevice{}, fmt.Errorf("read PCI driver: %w", err)
- }
- if filepath.Base(driver) != "vfio-pci" {
- return backend.AttachedPCIDevice{}, fmt.Errorf("PCI device %s is bound to %s, want vfio-pci", path, filepath.Base(driver))
- }
- id := spec.ID
- if id == "" {
- id = "kumabox-pci-" + strings.ReplaceAll(strings.TrimPrefix(path, pciSysfsPrefix), ":", "-")
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendAPIRequestTimeout)
- if err != nil {
- return backend.AttachedPCIDevice{}, err
- }
- for _, device := range info.Config.Devices {
- if device.ID == id || device.Path == path {
- return backend.AttachedPCIDevice{}, fmt.Errorf("PCI device %s is already attached", path)
- }
- }
- body, _ := json.Marshal(map[string]string{"id": id, "path": path})
- if _, err := doAPIOnce(ctx, rec.APISocket, backendAPIRequestTimeout, http.MethodPut, apiVMAddDevice, body, http.StatusOK, http.StatusNoContent); err != nil {
- return backend.AttachedPCIDevice{}, err
- }
- return backend.AttachedPCIDevice{ID: id, PCI: path}, nil
-}
-
-func (b Backend) DetachPCIDevice(ctx context.Context, rec *vm.VMRecord, id string) error {
- if rec == nil {
- return fmt.Errorf("VM record is nil")
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendAPIRequestTimeout)
- if err != nil {
- return err
- }
- for _, device := range info.Config.Devices {
- if device.ID == id {
- body, _ := json.Marshal(map[string]string{"id": id})
- _, err := doAPIOnce(ctx, rec.APISocket, backendAPIRequestTimeout, http.MethodPut, apiVMRemoveDevice, body, http.StatusNoContent)
- return err
- }
- }
- return fmt.Errorf("PCI device %q is not attached", id)
-}
-
-func (b Backend) ListPCIDevices(ctx context.Context, rec *vm.VMRecord) ([]backend.AttachedPCIDevice, error) {
- if rec == nil {
- return nil, fmt.Errorf("VM record is nil")
- }
- info, err := queryVMInfo(ctx, rec.APISocket, backendAPIRequestTimeout)
- if err != nil {
- return nil, err
- }
- result := make([]backend.AttachedPCIDevice, 0)
- for _, device := range info.Config.Devices {
- if strings.HasPrefix(device.ID, "kumabox-pci-") {
- result = append(result, backend.AttachedPCIDevice{ID: device.ID, PCI: device.Path})
- }
- }
- return result, nil
-}
-
-func normalizePCIPath(value string) (string, error) {
- value = strings.ToLower(strings.TrimSpace(value))
- if strings.HasPrefix(value, pciSysfsPrefix) {
- value = strings.TrimPrefix(filepath.Clean(value), pciSysfsPrefix)
- } else if len(value) == 8 && value[4] == ':' {
- } else if len(value) == 7 && value[2] == ':' {
- value = "0000:" + value
- } else {
- return "", fmt.Errorf("PCI address %q is invalid", value)
- }
- return pciSysfsPrefix + value, nil
-}
diff --git a/internal/backend/cloudhypervisor/restore.go b/internal/backend/cloudhypervisor/restore.go
deleted file mode 100644
index 12983e1..0000000
--- a/internal/backend/cloudhypervisor/restore.go
+++ /dev/null
@@ -1,326 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/fileutil"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// RestoreVM launches an API-only Cloud Hypervisor process, restores native
-// state, and resumes vCPU execution. The source directory is private staging
-// prepared by runtime and may therefore be patched in place.
-func (b Backend) RestoreVM(ctx context.Context, rec *vm.VMRecord, sourceDir, mode string) (_ *backend.StartResult, err error) {
- return b.restoreNativeVM(ctx, rec, sourceDir, mode, nativeRestorePlan{})
-}
-
-// CloneVM restores a snapshot paused, replaces its source NIC devices with
-// the clone's provider allocations, and only then resumes guest execution.
-func (b Backend) CloneVM(ctx context.Context, rec *vm.VMRecord, sourceDir, mode string) (*backend.StartResult, error) {
- return b.restoreNativeVM(ctx, rec, sourceDir, mode, nativeRestorePlan{
- useCloneRestoreTaps: true,
- beforeResume: func(client *http.Client, config map[string]json.RawMessage) error {
- return hotSwapCloneNetworks(ctx, client, config, rec)
- },
- })
-}
-
-type nativeRestorePlan struct {
- useCloneRestoreTaps bool
- beforeResume func(*http.Client, map[string]json.RawMessage) error
-}
-
-func (b Backend) restoreNativeVM(ctx context.Context, rec *vm.VMRecord, sourceDir, mode string, plan nativeRestorePlan) (_ *backend.StartResult, err error) {
- if rec == nil {
- return nil, errors.New("VM record is nil")
- }
- rendered, err := readRenderedConfig(rec.Config)
- if err != nil {
- return nil, fmt.Errorf("read backend launch config: %w", err)
- }
- nativeConfig, err := patchRestoreConfig(filepath.Join(sourceDir, snapshot.NativeConfigFile), rec, plan.useCloneRestoreTaps)
- if err != nil {
- return nil, fmt.Errorf("patch native restore config: %w", err)
- }
- if err := reapInterruptedRestore(*rendered); err != nil {
- return nil, err
- }
- cleanupRuntimeFiles(rec.RunDir)
- launch := *rendered
- launch.Args = []string{"--api-socket", rendered.APISocket}
- result, err := startProcess(launch)
- if err != nil {
- return nil, fmt.Errorf("launch Cloud Hypervisor restore process: %w", err)
- }
- defer func() {
- if err == nil {
- return
- }
- _ = terminateProcess(result.PID, rendered.Binary, rendered.APISocket)
- cleanupRuntimeFiles(rec.RunDir)
- }()
-
- request, requestErr := nativeRestoreRequest(sourceDir, mode)
- if requestErr != nil {
- return nil, requestErr
- }
- if err = putJSONOnce(ctx, rendered.APISocket, nativeSnapshotTimeout, apiVMRestore, request, http.StatusNoContent); err != nil {
- return nil, fmt.Errorf("vm.restore: %w", err)
- }
- client := socketHTTPClient(rendered.APISocket, nativeSnapshotTimeout)
- defer client.CloseIdleConnections()
- if plan.beforeResume != nil {
- if err = plan.beforeResume(client, nativeConfig); err != nil {
- return nil, err
- }
- }
- if err = stateTransition(ctx, &vm.VMRecord{Config: rec.Config, APISocket: rendered.APISocket}, apiVMResume, backendStateRunning); err != nil {
- return nil, fmt.Errorf("vm.resume: %w", err)
- }
- return result, nil
-}
-
-type restoreRequest struct {
- SourceURL string `json:"source_url"`
- MemoryRestoreMode string `json:"memory_restore_mode,omitempty"`
-}
-
-func nativeRestoreRequest(sourceDir, mode string) (restoreRequest, error) {
- request := restoreRequest{SourceURL: (&url.URL{Scheme: "file", Path: sourceDir}).String()}
- switch mode {
- case "copy":
- case "ondemand":
- request.MemoryRestoreMode = "OnDemand"
- case "mmap":
- request.MemoryRestoreMode = "Mmap"
- default:
- return restoreRequest{}, fmt.Errorf("RESTORE_MODE_UNSUPPORTED: %s", mode)
- }
- return request, nil
-}
-
-func reapInterruptedRestore(cfg Config) error {
- pid, err := readPIDFile(cfg.PIDFile)
- if errors.Is(err, os.ErrNotExist) {
- return nil
- }
- if err != nil {
- return fmt.Errorf("read interrupted restore pid: %w", err)
- }
- if !processAlive(pid) {
- return nil
- }
- if err := terminateProcess(pid, cfg.Binary, cfg.APISocket); err != nil {
- return fmt.Errorf("terminate interrupted restore process: %w", err)
- }
- return nil
-}
-
-// patchRestoreConfig preserves backend-owned and future fields while replacing
-// only host-local paths. Device order and identities were checked by preflight.
-func patchRestoreConfig(path string, rec *vm.VMRecord, useCloneRestoreTaps bool) (map[string]json.RawMessage, error) {
- raw, err := os.ReadFile(path) //nolint:gosec
- if err != nil {
- return nil, err
- }
- var config map[string]json.RawMessage
- if err := json.Unmarshal(raw, &config); err != nil {
- return nil, fmt.Errorf("decode config: %w", err)
- }
- var disks []map[string]json.RawMessage
- if err := json.Unmarshal(config["disks"], &disks); err != nil {
- return nil, fmt.Errorf("decode disks: %w", err)
- }
- paths, err := restoreDiskPaths(rec, len(disks))
- if err != nil {
- return nil, err
- }
- if len(disks) != len(paths) {
- return nil, fmt.Errorf("disk count mismatch: native=%d target=%d", len(disks), len(paths))
- }
- for i := range disks {
- if err := setRawField(disks[i], "path", paths[i]); err != nil {
- return nil, err
- }
- }
- patchedDisks, err := json.Marshal(disks)
- if err != nil {
- return nil, fmt.Errorf("encode disks: %w", err)
- }
- config["disks"] = patchedDisks
- if useCloneRestoreTaps {
- if err := patchCloneRestoreTaps(config, rec); err != nil {
- return nil, err
- }
- }
- if serial, found := config["serial"]; found {
- var serialConfig map[string]json.RawMessage
- if err := json.Unmarshal(serial, &serialConfig); err != nil {
- return nil, fmt.Errorf("decode serial: %w", err)
- }
- var mode string
- if rawMode, ok := serialConfig["mode"]; ok {
- if err := json.Unmarshal(rawMode, &mode); err != nil {
- return nil, fmt.Errorf("decode serial mode: %w", err)
- }
- }
- if strings.EqualFold(mode, "file") {
- if err := patchRawPath(config, "serial", "file", filepath.Join(rec.LogDir, "console.log")); err != nil {
- return nil, err
- }
- }
- }
- if rec.VsockSocket != "" {
- if err := patchRawPath(config, "vsock", "socket", rec.VsockSocket); err != nil {
- return nil, err
- }
- }
- if err := fileutil.WriteJSONAtomic(path, config, ".restore-config-*.tmp"); err != nil {
- return nil, err
- }
- return config, nil
-}
-
-// patchCloneRestoreTaps replaces the snapshot TAPs with names that are unique
-// to this restore. Cloud Hypervisor owns these transient TAPs until the guest
-// ACKs device eject; only then can hotSwapCloneNetworks attach the clone's
-// provider-owned CNI TAPs. Reusing provider TAPs here races with hot-add and
-// can leave the restored guest's virtio and vsock devices unstable.
-func patchCloneRestoreTaps(config map[string]json.RawMessage, rec *vm.VMRecord) error {
- raw, found := config["net"]
- if !found || string(raw) == "null" {
- if len(rec.NetworkConfigs) == 0 {
- return nil
- }
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: snapshot has 0 NICs, clone has %d", len(rec.NetworkConfigs))
- }
- var nets []map[string]json.RawMessage
- if err := json.Unmarshal(raw, &nets); err != nil {
- return fmt.Errorf("decode snapshot networks: %w", err)
- }
- if len(nets) != len(rec.NetworkConfigs) {
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: snapshot has %d NICs, clone has %d", len(nets), len(rec.NetworkConfigs))
- }
- for i := range nets {
- if err := setRawField(nets[i], "tap", cloneRestoreTAPName(rec.ID, i)); err != nil {
- return err
- }
- }
- patched, err := json.Marshal(nets)
- if err != nil {
- return fmt.Errorf("encode snapshot networks: %w", err)
- }
- config["net"] = patched
- return nil
-}
-
-func cloneRestoreTAPName(vmID string, index int) string {
- const prefix = "rm"
- if len(vmID) > 8 {
- vmID = vmID[:8]
- }
- return fmt.Sprintf("%s%s-%d", prefix, vmID, index)
-}
-
-func hotSwapCloneNetworks(ctx context.Context, client *http.Client, config map[string]json.RawMessage, rec *vm.VMRecord) error {
- var oldNets []struct {
- ID string `json:"id"`
- }
- if raw := config["net"]; len(raw) > 0 {
- if err := json.Unmarshal(raw, &oldNets); err != nil {
- return fmt.Errorf("decode snapshot networks: %w", err)
- }
- }
- if len(oldNets) != len(rec.NetworkConfigs) {
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: snapshot has %d NICs, clone has %d", len(oldNets), len(rec.NetworkConfigs))
- }
- for i, oldNet := range oldNets {
- if oldNet.ID == "" {
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: snapshot NIC %d has no backend device id", i)
- }
- body, err := json.Marshal(map[string]string{"id": oldNet.ID})
- if err != nil {
- return err
- }
- if _, err := doAPIOnceWithClient(ctx, client, http.MethodPut, apiVMRemoveDevice, body, http.StatusNoContent); err != nil {
- return fmt.Errorf("remove snapshot NIC %s: %w", oldNet.ID, err)
- }
- }
- for i, nc := range rec.NetworkConfigs {
- payload := map[string]any{
- "id": cloneNetworkDeviceID(nc.MAC),
- "tap": nc.TAP,
- "mac": nc.MAC,
- "num_queues": nc.NumQueues,
- "queue_size": nc.QueueSize,
- "offload_tso": true,
- "offload_ufo": true,
- "offload_csum": true,
- }
- body, err := json.Marshal(payload)
- if err != nil {
- return err
- }
- if _, err := doAPIOnceWithClient(ctx, client, http.MethodPut, apiVMAddNet, body, http.StatusOK, http.StatusNoContent); err != nil {
- return fmt.Errorf("add clone NIC %d: %w", i, err)
- }
- }
- return nil
-}
-
-func cloneNetworkDeviceID(mac string) string {
- return "kumabox-net-" + strings.ReplaceAll(strings.ToLower(mac), ":", "")
-}
-
-func restoreDiskPaths(rec *vm.VMRecord, nativeCount int) ([]string, error) {
- paths := make([]string, 0, len(rec.StorageConfigs)+1)
- for _, disk := range rec.StorageConfigs {
- paths = append(paths, disk.Path)
- }
- if nativeCount == len(paths)+1 && rec.Metadata != nil && rec.Metadata.CidataDisk != "" {
- paths = append(paths, rec.Metadata.CidataDisk)
- }
- if len(paths) != nativeCount {
- return nil, fmt.Errorf("native disk count %d cannot be mapped to target storage", nativeCount)
- }
- return paths, nil
-}
-
-func patchRawPath(config map[string]json.RawMessage, objectKey, fieldKey, value string) error {
- raw, ok := config[objectKey]
- if !ok || string(raw) == "null" {
- return nil
- }
- var object map[string]json.RawMessage
- if err := json.Unmarshal(raw, &object); err != nil {
- return fmt.Errorf("decode %s: %w", objectKey, err)
- }
- if err := setRawField(object, fieldKey, value); err != nil {
- return err
- }
- patched, err := json.Marshal(object)
- if err != nil {
- return fmt.Errorf("encode %s: %w", objectKey, err)
- }
- config[objectKey] = patched
- return nil
-}
-
-func setRawField(object map[string]json.RawMessage, key string, value any) error {
- raw, err := json.Marshal(value)
- if err != nil {
- return fmt.Errorf("encode %s: %w", key, err)
- }
- object[key] = raw
- return nil
-}
diff --git a/internal/backend/cloudhypervisor/restore_test.go b/internal/backend/cloudhypervisor/restore_test.go
deleted file mode 100644
index 2ae63d1..0000000
--- a/internal/backend/cloudhypervisor/restore_test.go
+++ /dev/null
@@ -1,163 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestPatchRestoreConfigPreservesBackendFields(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "config.json")
- raw := `{
- "platform":{"num_pci_segments":1},
- "disks":[{"path":"/old/cow.raw","readonly":false,"id":"disk0","queue_size":128}],
- "serial":{"mode":"File","file":"/old/console.log"},
- "vsock":{"cid":3,"socket":"/old/vsock.sock","id":"vsock0"}
-}`
- if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
- t.Fatal(err)
- }
- rec := &vm.VMRecord{
- LogDir: "/new/log", VsockSocket: "/new/vsock.sock",
- StorageConfigs: []vm.StorageConfig{{ID: "cow", Path: "/new/cow.raw"}},
- }
- if _, err := patchRestoreConfig(path, rec, false); err != nil {
- t.Fatal(err)
- }
- var got map[string]json.RawMessage
- patched, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- if err := json.Unmarshal(patched, &got); err != nil {
- t.Fatal(err)
- }
- if _, ok := got["platform"]; !ok {
- t.Fatal("platform field was discarded")
- }
- var disks []map[string]any
- if err := json.Unmarshal(got["disks"], &disks); err != nil {
- t.Fatal(err)
- }
- if disks[0]["path"] != "/new/cow.raw" || disks[0]["id"] != "disk0" || disks[0]["queue_size"] != float64(128) {
- t.Fatalf("patched disks = %#v", disks)
- }
- var vsock map[string]any
- if err := json.Unmarshal(got["vsock"], &vsock); err != nil {
- t.Fatal(err)
- }
- if vsock["socket"] != rec.VsockSocket || vsock["id"] != "vsock0" {
- t.Fatalf("patched vsock = %#v", vsock)
- }
-}
-
-func TestPatchRestoreConfigUsesTransientCloneTapWithoutChangingGuestIdentity(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "config.json")
- raw := `{
- "disks":[{"path":"/old/cow.raw"}],
- "net":[{"id":"snapshot-net0","tap":"kbtapsource","mac":"02:00:00:00:00:01","num_queues":2,"queue_size":256}]
-}`
- if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
- t.Fatal(err)
- }
- rec := &vm.VMRecord{
- ID: "kb_1234567890abcdef",
- StorageConfigs: []vm.StorageConfig{{ID: "cow", Path: "/new/cow.raw"}},
- NetworkConfigs: []kbnetwork.Config{{TAP: "kbtapclone", MAC: "02:00:00:00:00:02"}},
- }
- patched, err := patchRestoreConfig(path, rec, true)
- if err != nil {
- t.Fatal(err)
- }
- var nets []map[string]any
- if err := json.Unmarshal(patched["net"], &nets); err != nil {
- t.Fatal(err)
- }
- if len(nets) != 1 || nets[0]["tap"] != "rmkb_12345-0" {
- t.Fatalf("patched networks = %#v", nets)
- }
- if nets[0]["id"] != "snapshot-net0" || nets[0]["mac"] != "02:00:00:00:00:01" {
- t.Fatalf("snapshot guest identity changed before restore: %#v", nets[0])
- }
-}
-
-func TestCloneRestoreTAPNameFitsLinuxInterfaceLimit(t *testing.T) {
- name := cloneRestoreTAPName("kb_1234567890abcdef", 12)
- if name != "rmkb_12345-12" || len(name) > 15 {
- t.Fatalf("clone restore TAP = %q", name)
- }
-}
-
-func TestHotSwapCloneNetworksRemovesOldBeforeAddingNew(t *testing.T) {
- var calls []string
- client := apiTestClient(func(req *http.Request) (*http.Response, error) {
- body, _ := io.ReadAll(req.Body)
- calls = append(calls, req.URL.Path+":"+string(body))
- code := http.StatusNoContent
- if req.URL.Path == "/api/v1/vm.add-net" {
- code = http.StatusOK
- }
- return apiResponse(code, ""), nil
- })
- old, err := json.Marshal([]map[string]any{{"id": "old-net", "mac": "02:00:00:00:00:01"}})
- if err != nil {
- t.Fatal(err)
- }
- rec := &vm.VMRecord{NetworkConfigs: []kbnetwork.Config{{
- TAP: "kbtapnew", MAC: "02:00:00:00:00:02", NumQueues: 2, QueueSize: 256,
- }}}
- if err := hotSwapCloneNetworks(context.Background(), client, map[string]json.RawMessage{"net": old}, rec); err != nil {
- t.Fatal(err)
- }
- if len(calls) != 2 || calls[0] != `/api/v1/vm.remove-device:{"id":"old-net"}` {
- t.Fatalf("calls = %v", calls)
- }
- wantID := cloneNetworkDeviceID(rec.NetworkConfigs[0].MAC)
- got := calls[len(calls)-1]
- if !strings.Contains(got, "/api/v1/vm.add-net:") || !strings.Contains(got, fmt.Sprintf(`"id":"%s"`, wantID)) || !strings.Contains(got, `"tap":"kbtapnew"`) {
- t.Fatalf("add call = %s", got)
- }
-}
-
-func TestNativeRestoreRequestMapsMemoryModes(t *testing.T) {
- tests := []struct {
- mode string
- want string
- }{
- {mode: "copy", want: ""},
- {mode: "ondemand", want: "OnDemand"},
- {mode: "mmap", want: "Mmap"},
- }
- for _, tt := range tests {
- t.Run(tt.mode, func(t *testing.T) {
- request, err := nativeRestoreRequest("/tmp/snapshot with space", tt.mode)
- if err != nil {
- t.Fatal(err)
- }
- if request.MemoryRestoreMode != tt.want || request.SourceURL != "file:///tmp/snapshot%20with%20space" {
- t.Fatalf("request = %+v", request)
- }
- raw, err := json.Marshal(request)
- if err != nil {
- t.Fatal(err)
- }
- if tt.mode == "copy" && strings.Contains(string(raw), "memory_restore_mode") {
- t.Fatalf("copy request contains extension: %s", raw)
- }
- })
- }
- if _, err := nativeRestoreRequest("/tmp/snapshot", "invalid"); err == nil {
- t.Fatal("expected unsupported mode error")
- }
-}
diff --git a/internal/backend/cloudhypervisor/snapshot.go b/internal/backend/cloudhypervisor/snapshot.go
deleted file mode 100644
index 480353a..0000000
--- a/internal/backend/cloudhypervisor/snapshot.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/url"
- "path/filepath"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// SnapshotVM asks Cloud Hypervisor to write its native paused VM state into
-// destination. Writable disks are captured separately by runtime.
-func (Backend) SnapshotVM(ctx context.Context, rec *vm.VMRecord, destination string) error {
- if rec == nil {
- return fmt.Errorf("VM record is nil")
- }
- abs, err := filepath.Abs(destination)
- if err != nil {
- return fmt.Errorf("resolve native snapshot destination: %w", err)
- }
- apiSocket, _, err := backendAPIConfig(rec)
- if err != nil {
- return err
- }
- destinationURL := (&url.URL{Scheme: "file", Path: abs}).String()
- return putJSONOnce(ctx, apiSocket, nativeSnapshotTimeout, apiVMSnapshot, map[string]string{
- "destination_url": destinationURL,
- }, http.StatusNoContent)
-}
diff --git a/internal/backend/cloudhypervisor/start.go b/internal/backend/cloudhypervisor/start.go
deleted file mode 100644
index 5c845b0..0000000
--- a/internal/backend/cloudhypervisor/start.go
+++ /dev/null
@@ -1,163 +0,0 @@
-package cloudhypervisor
-
-import (
- "encoding/json"
- "fmt"
- "net"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "syscall"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/fileutil"
-)
-
-const (
- defaultAPISocketWaitTimeout = 5 * time.Second
- apiSocketPollInterval = 50 * time.Millisecond
-)
-
-type Starter struct{}
-
-func NewStarter() Starter {
- return Starter{}
-}
-
-func (Starter) StartConfig(path string) (*backend.StartResult, error) {
- raw, err := os.ReadFile(path) //nolint:gosec
- if err != nil {
- return nil, fmt.Errorf("read Cloud Hypervisor config: %w", err)
- }
-
- var cfg Config
- if err := json.Unmarshal(raw, &cfg); err != nil {
- return nil, fmt.Errorf("parse Cloud Hypervisor config: %w", err)
- }
- return startProcess(cfg)
-}
-
-func startProcess(cfg Config) (result *backend.StartResult, err error) {
- if err := validateStartConfig(cfg); err != nil {
- return nil, err
- }
-
- if err := os.MkdirAll(filepath.Dir(cfg.PIDFile), 0o755); err != nil {
- return nil, fmt.Errorf("create pid directory: %w", err)
- }
- if err := os.MkdirAll(filepath.Dir(cfg.StdoutLog), 0o755); err != nil {
- return nil, fmt.Errorf("create stdout log directory: %w", err)
- }
- if err := os.MkdirAll(filepath.Dir(cfg.StderrLog), 0o755); err != nil {
- return nil, fmt.Errorf("create stderr log directory: %w", err)
- }
-
- stdout, err := os.OpenFile(cfg.StdoutLog, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
- if err != nil {
- return nil, fmt.Errorf("open stdout log: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, stdout, "close Cloud Hypervisor stdout log")
-
- stderr, err := os.OpenFile(cfg.StderrLog, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
- if err != nil {
- return nil, fmt.Errorf("open stderr log: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, stderr, "close Cloud Hypervisor stderr log")
-
- cmd := exec.Command(cfg.Binary, cfg.Args...) //nolint:gosec
- cmd.Stdout = stdout
- cmd.Stderr = stderr
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
-
- if err := startInNetNS(cmd, cfg.NetnsPath); err != nil {
- return nil, fmt.Errorf("start Cloud Hypervisor: %w", err)
- }
-
- pid := cmd.Process.Pid
- if err := writePIDFile(cfg.PIDFile, pid); err != nil {
- _ = cmd.Process.Kill()
- _ = cmd.Wait()
- return nil, err
- }
- exited := make(chan error, 1)
- go func() {
- exited <- cmd.Wait()
- }()
-
- timeout := time.Duration(cfg.APITimeoutMs) * time.Millisecond
- if timeout <= 0 {
- timeout = defaultAPISocketWaitTimeout
- }
- if err := waitForUnixSocket(cfg.APISocket, exited, timeout); err != nil {
- _ = cmd.Process.Kill()
- _ = os.Remove(cfg.PIDFile)
- return nil, err
- }
-
- return &backend.StartResult{PID: pid, APISocket: cfg.APISocket}, nil
-}
-
-func validateStartConfig(cfg Config) error {
- if cfg.Binary == "" {
- return fmt.Errorf("cloud hypervisor binary is empty")
- }
- if cfg.APISocket == "" {
- return fmt.Errorf("cloud hypervisor API socket is empty")
- }
- if cfg.PIDFile == "" {
- return fmt.Errorf("cloud hypervisor pid file is empty")
- }
- if cfg.StdoutLog == "" {
- return fmt.Errorf("cloud hypervisor stdout log is empty")
- }
- if cfg.StderrLog == "" {
- return fmt.Errorf("cloud hypervisor stderr log is empty")
- }
- return nil
-}
-
-func writePIDFile(path string, pid int) error {
- data := []byte(fmt.Sprintf("%d\n", pid))
- if err := os.WriteFile(path, data, 0o644); err != nil {
- return fmt.Errorf("write pid file: %w", err)
- }
- return nil
-}
-
-func readPIDFile(path string) (int, error) {
- raw, err := os.ReadFile(path) //nolint:gosec
- if err != nil {
- return 0, err
- }
- pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
- if err != nil || pid <= 0 {
- return 0, fmt.Errorf("invalid pid file %s", path)
- }
- return pid, nil
-}
-
-func waitForUnixSocket(path string, exited <-chan error, timeout time.Duration) error {
- deadline := time.Now().Add(timeout)
- for {
- select {
- case err := <-exited:
- if err == nil {
- return fmt.Errorf("cloud hypervisor exited before API socket became ready")
- }
- return fmt.Errorf("cloud hypervisor exited before API socket became ready: %w", err)
- default:
- }
- conn, err := net.DialTimeout("unix", path, 100*time.Millisecond)
- if err == nil {
- _ = conn.Close()
- return nil
- }
- if time.Now().After(deadline) {
- return fmt.Errorf("timed out waiting for Cloud Hypervisor API socket %s", path)
- }
- time.Sleep(apiSocketPollInterval)
- }
-}
diff --git a/internal/backend/cloudhypervisor/start_linux.go b/internal/backend/cloudhypervisor/start_linux.go
deleted file mode 100644
index ebf6de1..0000000
--- a/internal/backend/cloudhypervisor/start_linux.go
+++ /dev/null
@@ -1,49 +0,0 @@
-//go:build linux
-
-package cloudhypervisor
-
-import (
- "fmt"
- "os/exec"
- "runtime"
-
- "github.com/kumabox/kumabox/internal/fileutil"
- "github.com/vishvananda/netns"
-)
-
-func startInNetNS(cmd *exec.Cmd, netnsPath string) (err error) {
- if netnsPath == "" {
- return cmd.Start()
- }
- runtime.LockOSThread()
- defer runtime.UnlockOSThread()
-
- origNS, err := netns.Get()
- if err != nil {
- return fmt.Errorf("get current netns: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, &origNS, "close original network namespace")
-
- targetNS, err := netns.GetFromPath(netnsPath)
- if err != nil {
- return fmt.Errorf("open netns %s: %w", netnsPath, err)
- }
- defer fileutil.CloseAndJoin(&err, &targetNS, "close target network namespace")
-
- if err := netns.Set(targetNS); err != nil {
- return fmt.Errorf("enter netns %s: %w", netnsPath, err)
- }
- startErr := cmd.Start()
- restoreErr := netns.Set(origNS)
- if startErr != nil {
- return startErr
- }
- if restoreErr != nil {
- if cmd.Process != nil {
- _ = cmd.Process.Kill()
- _ = cmd.Wait()
- }
- return fmt.Errorf("restore netns: %w", restoreErr)
- }
- return nil
-}
diff --git a/internal/backend/cloudhypervisor/start_other.go b/internal/backend/cloudhypervisor/start_other.go
deleted file mode 100644
index bb5d330..0000000
--- a/internal/backend/cloudhypervisor/start_other.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !linux
-
-package cloudhypervisor
-
-import "os/exec"
-
-func startInNetNS(cmd *exec.Cmd, _ string) error {
- return cmd.Start()
-}
diff --git a/internal/backend/cloudhypervisor/start_test.go b/internal/backend/cloudhypervisor/start_test.go
deleted file mode 100644
index 181a12e..0000000
--- a/internal/backend/cloudhypervisor/start_test.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package cloudhypervisor
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-func TestStartProcessReportsEarlyProcessExit(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- stderrPath := filepath.Join(dir, "stderr.log")
- startedAt := time.Now()
- _, err := startProcess(Config{
- Binary: "/bin/sh",
- Args: []string{"-c", "echo deliberate-start-failure >&2; exit 42"},
- APISocket: filepath.Join(dir, "ch.sock"),
- APITimeoutMs: 5000,
- PIDFile: filepath.Join(dir, "ch.pid"),
- StdoutLog: filepath.Join(dir, "stdout.log"),
- StderrLog: stderrPath,
- })
- if err == nil {
- t.Fatal("startProcess() error = nil, want early process exit")
- }
- if !strings.Contains(err.Error(), "exited before API socket became ready: exit status 42") {
- t.Fatalf("startProcess() error = %q", err)
- }
- if elapsed := time.Since(startedAt); elapsed >= 2*time.Second {
- t.Fatalf("startProcess() reported early exit after %s", elapsed)
- }
- if _, statErr := os.Stat(filepath.Join(dir, "ch.pid")); !os.IsNotExist(statErr) {
- t.Fatalf("pid file error = %v, want not exist", statErr)
- }
- raw, readErr := os.ReadFile(stderrPath)
- if readErr != nil {
- t.Fatalf("read stderr log: %v", readErr)
- }
- if !strings.Contains(string(raw), "deliberate-start-failure") {
- t.Fatalf("stderr log = %q", raw)
- }
-}
diff --git a/internal/backend/cloudhypervisor/state.go b/internal/backend/cloudhypervisor/state.go
deleted file mode 100644
index 36a1f4c..0000000
--- a/internal/backend/cloudhypervisor/state.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// PauseVM pauses vCPU execution without terminating the VMM process.
-func (Backend) PauseVM(ctx context.Context, rec *vm.VMRecord) error {
- return stateTransition(ctx, rec, apiVMPause, backendStatePaused)
-}
-
-// ResumeVM resumes a paused VM.
-func (Backend) ResumeVM(ctx context.Context, rec *vm.VMRecord) error {
- return stateTransition(ctx, rec, apiVMResume, backendStateRunning)
-}
diff --git a/internal/backend/cloudhypervisor/stop.go b/internal/backend/cloudhypervisor/stop.go
deleted file mode 100644
index d679e82..0000000
--- a/internal/backend/cloudhypervisor/stop.go
+++ /dev/null
@@ -1,153 +0,0 @@
-package cloudhypervisor
-
-import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "syscall"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const (
- terminateGrace = 2 * time.Second
- defaultStopTimeout = 10 * time.Second
- backendAPIRequestTimeout = 2 * time.Second
- processPollInterval = 100 * time.Millisecond
-)
-
-func (b Backend) StopVM(rec *vm.VMRecord, opts backend.StopOptions) (*backend.StopResult, error) {
- return b.stopper.StopVM(rec, opts)
-}
-
-type Stopper struct{}
-
-func NewStopper() Stopper {
- return Stopper{}
-}
-
-func (Stopper) StopVM(rec *vm.VMRecord, opts backend.StopOptions) (*backend.StopResult, error) {
- if rec == nil {
- return nil, fmt.Errorf("VM record is nil")
- }
- cfg, err := readRenderedConfig(rec.Config)
- if err != nil {
- return nil, fmt.Errorf("read backend config: %w", err)
- }
- apiSocket := rec.APISocket
- if apiSocket == "" {
- apiSocket = cfg.APISocket
- }
- if rec.PID <= 0 {
- if rec.Restore != nil {
- pid, pidErr := readPIDFile(cfg.PIDFile)
- if pidErr == nil && processAlive(pid) {
- if err := terminateProcess(pid, cfg.Binary, apiSocket); err != nil {
- return nil, fmt.Errorf("stop interrupted restore process: %w", err)
- }
- } else if pidErr != nil && !errors.Is(pidErr, os.ErrNotExist) {
- return nil, fmt.Errorf("read interrupted restore pid: %w", pidErr)
- }
- }
- cleanupRuntimeFiles(rec.RunDir)
- return &backend.StopResult{}, nil
- }
- if !processAlive(rec.PID) {
- cleanupRuntimeFiles(rec.RunDir)
- return &backend.StopResult{}, nil
- }
-
- matched, reason := verifyProcessIdentity(rec.PID, cfg.Binary, apiSocket)
- if !matched {
- return nil, fmt.Errorf("refusing to stop VM: %s", reason)
- }
-
- timeout := opts.Timeout
- if timeout <= 0 {
- timeout = defaultStopTimeout
- }
- if !opts.Force {
- _ = resumeIfPaused(context.Background(), apiSocket)
- _ = shutdownVM(context.Background(), apiSocket)
- if waitForExit(rec.PID, timeout) {
- cleanupRuntimeFiles(rec.RunDir)
- return &backend.StopResult{}, nil
- }
- }
-
- if err := terminateProcess(rec.PID, cfg.Binary, apiSocket); err != nil {
- return nil, err
- }
- cleanupRuntimeFiles(rec.RunDir)
- return &backend.StopResult{}, nil
-}
-
-func shutdownVM(ctx context.Context, apiSocket string) error {
- _, err := doAPIOnce(ctx, apiSocket, backendAPIRequestTimeout, http.MethodPut, apiVMShutdown, nil, http.StatusNoContent)
- return err
-}
-
-func resumeIfPaused(ctx context.Context, apiSocket string) error {
- info, err := queryVMInfo(ctx, apiSocket, backendAPIRequestTimeout)
- if err != nil || !strings.EqualFold(info.State, backendStatePaused) {
- return err
- }
- _, err = doAPIOnce(ctx, apiSocket, backendAPIRequestTimeout, http.MethodPut, apiVMResume, nil, http.StatusNoContent)
- return err
-}
-
-func waitForExit(pid int, timeout time.Duration) bool {
- deadline := time.Now().Add(timeout)
- for {
- if !processAlive(pid) {
- return true
- }
- if time.Now().After(deadline) {
- return false
- }
- time.Sleep(processPollInterval)
- }
-}
-
-func terminateProcess(pid int, binary string, apiSocket string) error {
- matched, reason := verifyProcessIdentity(pid, binary, apiSocket)
- if !matched {
- if !processAlive(pid) {
- return nil
- }
- return fmt.Errorf("refusing to terminate process: %s", reason)
- }
-
- proc, err := os.FindProcess(pid)
- if err != nil {
- return fmt.Errorf("find process %d: %w", pid, err)
- }
- if err := proc.Signal(syscall.SIGTERM); err != nil && processAlive(pid) {
- _ = proc.Kill()
- }
- if waitForExit(pid, terminateGrace) {
- return nil
- }
- if err := proc.Kill(); err != nil && processAlive(pid) {
- return fmt.Errorf("kill process %d: %w", pid, err)
- }
- if !waitForExit(pid, terminateGrace) {
- return fmt.Errorf("process %d did not exit after SIGKILL", pid)
- }
- return nil
-}
-
-func cleanupRuntimeFiles(runDir string) {
- for _, name := range []string{"ch.pid", "ch.sock"} {
- err := os.Remove(filepath.Join(runDir, name))
- if err != nil && !errors.Is(err, os.ErrNotExist) {
- continue
- }
- }
-}
diff --git a/internal/batch/batch.go b/internal/batch/batch.go
deleted file mode 100644
index 260004d..0000000
--- a/internal/batch/batch.go
+++ /dev/null
@@ -1,106 +0,0 @@
-// Package batch runs bounded, best-effort operations over named resources.
-package batch
-
-import (
- "context"
- "errors"
- "fmt"
- "runtime"
- "sync"
-)
-
-// Options controls the amount of parallel work. Zero uses the host CPU count.
-type Options struct {
- Concurrency int
-}
-
-// Failure describes one resource that could not complete an operation.
-type Failure struct {
- Ref string `json:"ref"`
- Error string `json:"error"`
-}
-
-// Result is the stable, input-ordered outcome of a best-effort batch.
-type Result[T any] struct {
- Succeeded []T `json:"succeeded"`
- Failed []Failure `json:"failed,omitempty"`
- errors []error
-}
-
-// Err joins all per-resource failures while retaining their error chains.
-func (r Result[T]) Err() error {
- return errors.Join(r.errors...)
-}
-
-type item[T any] struct {
- ref string
- value T
- err error
-}
-
-// Run executes fn once for each ref with bounded concurrency.
-func Run[T any](
- ctx context.Context,
- refs []string,
- opts Options,
- operation string,
- fn func(context.Context, int, string) (T, error),
-) Result[T] {
- if len(refs) == 0 {
- return Result[T]{Succeeded: []T{}}
- }
- concurrency := opts.Concurrency
- if concurrency <= 0 {
- concurrency = runtime.NumCPU()
- }
- concurrency = min(concurrency, len(refs))
-
- items := make([]item[T], len(refs))
- jobs := make(chan int)
- var workers sync.WaitGroup
- workers.Add(concurrency)
- for range concurrency {
- go func() {
- defer workers.Done()
- for index := range jobs {
- ref := refs[index]
- if err := ctx.Err(); err != nil {
- items[index] = item[T]{ref: ref, err: err}
- continue
- }
- value, err := fn(ctx, index, ref)
- items[index] = item[T]{ref: ref, value: value, err: err}
- }
- }()
- }
- for index := range refs {
- jobs <- index
- }
- close(jobs)
- workers.Wait()
-
- result := Result[T]{Succeeded: make([]T, 0, len(items))}
- for _, item := range items {
- if item.err == nil {
- result.Succeeded = append(result.Succeeded, item.value)
- continue
- }
- result.Failed = append(result.Failed, Failure{Ref: item.ref, Error: item.err.Error()})
- result.errors = append(result.errors, fmt.Errorf("%s %s: %w", operation, item.ref, item.err))
- }
- return result
-}
-
-// Distinct preserves the first occurrence of each resource reference.
-func Distinct(refs []string) []string {
- distinct := make([]string, 0, len(refs))
- seen := make(map[string]struct{}, len(refs))
- for _, ref := range refs {
- if _, exists := seen[ref]; exists {
- continue
- }
- seen[ref] = struct{}{}
- distinct = append(distinct, ref)
- }
- return distinct
-}
diff --git a/internal/batch/batch_test.go b/internal/batch/batch_test.go
deleted file mode 100644
index 14c8a80..0000000
--- a/internal/batch/batch_test.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package batch
-
-import (
- "context"
- "errors"
- "sync/atomic"
- "testing"
- "time"
-)
-
-func TestRunPreservesOrderAndPartialSuccess(t *testing.T) {
- t.Parallel()
- wantErr := errors.New("operation failed")
- result := Run(t.Context(), []string{"first", "failed", "last"}, Options{Concurrency: 2}, "test",
- func(_ context.Context, _ int, ref string) (string, error) {
- if ref == "failed" {
- return "", wantErr
- }
- return ref + "-done", nil
- })
- if len(result.Succeeded) != 2 || result.Succeeded[0] != "first-done" || result.Succeeded[1] != "last-done" {
- t.Fatalf("succeeded = %v", result.Succeeded)
- }
- if len(result.Failed) != 1 || result.Failed[0].Ref != "failed" {
- t.Fatalf("failed = %v", result.Failed)
- }
- if !errors.Is(result.Err(), wantErr) {
- t.Fatalf("error = %v, want wrapped %v", result.Err(), wantErr)
- }
-}
-
-func TestRunHonorsConcurrency(t *testing.T) {
- t.Parallel()
- var active atomic.Int32
- var peak atomic.Int32
- release := make(chan struct{})
- started := make(chan struct{}, 4)
- done := make(chan Result[string], 1)
- go func() {
- done <- Run(t.Context(), []string{"a", "b", "c", "d"}, Options{Concurrency: 2}, "test",
- func(_ context.Context, _ int, ref string) (string, error) {
- current := active.Add(1)
- for {
- previous := peak.Load()
- if current <= previous || peak.CompareAndSwap(previous, current) {
- break
- }
- }
- started <- struct{}{}
- <-release
- active.Add(-1)
- return ref, nil
- })
- }()
- for range 2 {
- select {
- case <-started:
- case <-time.After(time.Second):
- t.Fatal("batch did not start two workers")
- }
- }
- select {
- case <-started:
- t.Fatal("batch exceeded concurrency limit")
- case <-time.After(20 * time.Millisecond):
- }
- close(release)
- if err := (<-done).Err(); err != nil {
- t.Fatal(err)
- }
- if peak.Load() != 2 {
- t.Fatalf("peak concurrency = %d, want 2", peak.Load())
- }
-}
-
-func TestRunReportsCanceledItems(t *testing.T) {
- ctx, cancel := context.WithCancel(t.Context())
- cancel()
- result := Run(ctx, []string{"a", "b"}, Options{Concurrency: 1}, "test",
- func(context.Context, int, string) (string, error) {
- t.Fatal("operation ran after context cancellation")
- return "", nil
- })
- if len(result.Succeeded) != 0 || len(result.Failed) != 2 {
- t.Fatalf("result = %+v", result)
- }
- if !errors.Is(result.Err(), context.Canceled) {
- t.Fatalf("error = %v, want context canceled", result.Err())
- }
-}
-
-func TestDistinctPreservesFirstOccurrence(t *testing.T) {
- t.Parallel()
- got := Distinct([]string{"a", "b", "a", "c", "b"})
- want := []string{"a", "b", "c"}
- for i := range want {
- if len(got) != len(want) || got[i] != want[i] {
- t.Fatalf("distinct refs = %v, want %v", got, want)
- }
- }
-}
diff --git a/internal/cli/agent.go b/internal/cli/agent.go
deleted file mode 100644
index 1d9172c..0000000
--- a/internal/cli/agent.go
+++ /dev/null
@@ -1,237 +0,0 @@
-package cli
-
-import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/spf13/cobra"
-
- agentclient "github.com/kumabox/kumabox/internal/agent/client"
- "github.com/kumabox/kumabox/internal/vm"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func newAgentCommand(opts *rootOptions) *cobra.Command {
- cmd := &cobra.Command{
- Use: "agent",
- Short: "Interact with the guest agent",
- }
- cmd.AddCommand(newAgentPingCommand(opts))
- cmd.AddCommand(newAgentStatusCommand(opts))
- cmd.AddCommand(newAgentReseedCommand(opts))
- return cmd
-}
-
-func newAgentReseedCommand(opts *rootOptions) *cobra.Command {
- var (
- machineID bool
- timeout time.Duration
- )
- cmd := &cobra.Command{
- Use: "reseed VM",
- Short: "Inject fresh entropy into a running guest",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- if timeout <= 0 {
- timeout = agentclient.DefaultPingTimeout
- }
- ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
- defer cancel()
- rec, err := rt.ReseedGuestVM(ctx, args[0], machineID)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), struct {
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
- RegeneratedMachineID bool `json:"regeneratedMachineId"`
- ReseededAt time.Time `json:"reseededAt"`
- }{
- VMID: rec.ID, VMName: rec.Name,
- RegeneratedMachineID: machineID,
- ReseededAt: time.Now().UTC(),
- })
- },
- }
- cmd.Flags().BoolVar(&machineID, "machine-id", false, "also regenerate /etc/machine-id; use for clones, not restore")
- cmd.Flags().DurationVar(&timeout, "timeout", agentclient.DefaultPingTimeout, "agent reseed timeout")
- return cmd
-}
-
-type agentStatusView struct {
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
- VMState vm.VMState `json:"vmState"`
- ObservedState vm.ObservedState `json:"observedState,omitempty"`
- Readiness string `json:"readiness"`
- Ready bool `json:"ready"`
- VsockSocket string `json:"vsockSocket,omitempty"`
- Agent *agentclient.PingPongResponse `json:"agent,omitempty"`
- Error string `json:"error,omitempty"`
- Diagnostics map[string]string `json:"diagnostics,omitempty"`
- CheckedAt time.Time `json:"checkedAt"`
-}
-
-func newAgentStatusCommand(opts *rootOptions) *cobra.Command {
- var timeout time.Duration
-
- cmd := &cobra.Command{
- Use: "status VM",
- Short: "Inspect guest agent readiness and diagnostics",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.InspectVM(args[0])
- if err != nil {
- return err
- }
- view := inspectAgentStatus(cmd.Context(), rec, timeout)
- return writeJSON(cmd.OutOrStdout(), view)
- },
- }
- cmd.Flags().DurationVar(&timeout, "timeout", agentclient.DefaultPingTimeout, "agent readiness timeout")
- return cmd
-}
-
-func inspectAgentStatus(parent context.Context, rec *vm.VMRecord, timeout time.Duration) agentStatusView {
- view := agentStatusView{
- VMID: rec.ID,
- VMName: rec.Name,
- VMState: rec.State,
- ObservedState: rec.ObservedState,
- Readiness: "vm-not-running",
- CheckedAt: time.Now().UTC(),
- }
- if rec.State != vm.StateRunning {
- view.Error = fmt.Sprintf("VM %s is not running", rec.Name)
- view.Diagnostics = guestDiagnostics(rec)
- return view
- }
- view.VsockSocket = rec.VsockSocket
- if rec.VsockSocket == "" {
- view.Readiness = "vsock-unavailable"
- view.Error = "VM has no guest agent vsock socket"
- view.Diagnostics = guestDiagnostics(rec)
- return view
- }
- if timeout <= 0 {
- timeout = agentclient.DefaultPingTimeout
- }
- ctx, cancel := context.WithTimeout(parent, timeout)
- defer cancel()
- pong, err := agentclient.Ping(ctx, rec.VsockSocket)
- if err != nil {
- view.Readiness = "agent-not-ready"
- view.Error = err.Error()
- view.Diagnostics = guestDiagnostics(rec)
- return view
- }
- view.Readiness = "ready"
- view.Ready = true
- view.Agent = pong
- return view
-}
-
-func guestDiagnostics(rec *vm.VMRecord) map[string]string {
- diagnostics := make(map[string]string, 2)
- for name, path := range map[string]string{
- "consoleTail": filepath.Join(rec.LogDir, "console.log"),
- "vmmStderrTail": filepath.Join(rec.LogDir, "cloud-hypervisor.stderr.log"),
- } {
- if tail := readLogTail(path, 40); tail != "" {
- diagnostics[name] = tail
- }
- }
- if len(diagnostics) == 0 {
- return nil
- }
- return diagnostics
-}
-
-func readLogTail(path string, lines int) string {
- if path == "" || lines <= 0 {
- return ""
- }
- raw, err := os.ReadFile(path) //nolint:gosec
- if err != nil {
- return ""
- }
- values := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
- if len(values) > lines {
- values = values[len(values)-lines:]
- }
- return strings.TrimSpace(strings.Join(values, "\n"))
-}
-
-func newAgentPingCommand(opts *rootOptions) *cobra.Command {
- var timeout time.Duration
-
- cmd := &cobra.Command{
- Use: "ping VM",
- Short: "Check guest agent readiness",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.InspectVM(args[0])
- if err != nil {
- return err
- }
- if rec.State != vm.StateRunning {
- return fmt.Errorf("AGENT_NOT_READY: VM %s is not running", rec.Name)
- }
- if rec.VsockSocket == "" {
- return fmt.Errorf("AGENT_NOT_READY: VM %s has no vsock socket", rec.Name)
- }
- if timeout <= 0 {
- timeout = agentclient.DefaultPingTimeout
- }
- ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
- defer cancel()
- resp, err := agentclient.Ping(ctx, rec.VsockSocket)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), struct {
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
- VsockSocket string `json:"vsockSocket"`
- Agent *agentclient.PingPongResponse `json:"agent"`
- CheckedAt time.Time `json:"checkedAt"`
- }{
- VMID: rec.ID,
- VMName: rec.Name,
- VsockSocket: rec.VsockSocket,
- Agent: resp,
- CheckedAt: time.Now().UTC(),
- })
- },
- }
- cmd.Flags().DurationVar(&timeout, "timeout", agentclient.DefaultPingTimeout, "agent readiness timeout")
- return cmd
-}
diff --git a/internal/cli/agent_test.go b/internal/cli/agent_test.go
deleted file mode 100644
index d0e48ea..0000000
--- a/internal/cli/agent_test.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package cli
-
-import (
- "context"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestInspectAgentStatusReportsStoppedVM(t *testing.T) {
- t.Parallel()
-
- root := t.TempDir()
- logDir := filepath.Join(root, "logs")
- if err := os.MkdirAll(logDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(logDir, "console.log"), []byte("booting\nagent failed\n"), 0o600); err != nil {
- t.Fatal(err)
- }
-
- view := inspectAgentStatus(context.Background(), &vm.VMRecord{
- ID: "kb_test",
- Name: "stopped",
- State: vm.StateStopped,
- LogDir: logDir,
- }, 0)
- if view.Ready || view.Readiness != "vm-not-running" {
- t.Fatalf("view = %+v", view)
- }
- if !strings.Contains(view.Diagnostics["consoleTail"], "agent failed") {
- t.Fatalf("diagnostics = %+v", view.Diagnostics)
- }
-}
-
-func TestReadLogTailKeepsLatestLines(t *testing.T) {
- t.Parallel()
-
- path := filepath.Join(t.TempDir(), "console.log")
- if err := os.WriteFile(path, []byte("one\ntwo\nthree\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- if got := readLogTail(path, 2); got != "two\nthree" {
- t.Fatalf("tail = %q", got)
- }
-}
diff --git a/internal/cli/batch.go b/internal/cli/batch.go
deleted file mode 100644
index 573ff51..0000000
--- a/internal/cli/batch.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package cli
-
-import (
- "errors"
- "fmt"
-
- "github.com/spf13/cobra"
-
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-type lifecycleBatchOutput struct {
- Succeeded []string `json:"succeeded"`
- Failed []kbruntime.BatchFailure `json:"failed,omitempty"`
-}
-
-func addBatchConcurrencyFlag(cmd *cobra.Command, concurrency *int) {
- cmd.Flags().IntVar(concurrency, "concurrency", 0, "maximum concurrent VM operations; 0 uses host CPU count")
-}
-
-func addResourceBatchConcurrencyFlag(cmd *cobra.Command, concurrency *int) {
- cmd.Flags().IntVar(concurrency, "concurrency", 0, "maximum concurrent operations; 0 uses host CPU count")
-}
-
-func validateBatchConcurrency(concurrency int) error {
- if concurrency < 0 {
- return errors.New("concurrency must be greater than or equal to zero")
- }
- return nil
-}
-
-func lifecycleBatchOptions(concurrency int) (kbruntime.BatchOptions, error) {
- if err := validateBatchConcurrency(concurrency); err != nil {
- return kbruntime.BatchOptions{}, err
- }
- return kbruntime.BatchOptions{Concurrency: concurrency}, nil
-}
-
-func writeLifecycleBatchResult(
- cmd *cobra.Command,
- refs []string,
- operation string,
- result kbruntime.BatchResult,
-) error {
- if len(refs) == 1 {
- if err := result.Err(); err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), result.Succeeded[0])
- }
- output := lifecycleBatchOutput{
- Succeeded: make([]string, 0, len(result.Succeeded)),
- Failed: result.Failed,
- }
- for _, record := range result.Succeeded {
- output.Succeeded = append(output.Succeeded, record.ID)
- }
- if err := writeJSON(cmd.OutOrStdout(), output); err != nil {
- return err
- }
- if err := result.Err(); err != nil {
- return fmt.Errorf("%s: %w", operation, err)
- }
- return nil
-}
diff --git a/internal/cli/clone.go b/internal/cli/clone.go
deleted file mode 100644
index 50f453f..0000000
--- a/internal/cli/clone.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package cli
-
-import (
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/config"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func newCloneCommand(opts *rootOptions) *cobra.Command {
- var name, mode string
- var networks []string
- cmd := &cobra.Command{
- Use: "clone SNAPSHOT",
- Short: "Create a running VM with new identity from a native snapshot",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if err := config.EnsureRuntimeDirs(cfg); err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.CloneNativeSnapshot(cmd.Context(), args[0], kbruntime.NativeCloneOptions{
- Name: name, Networks: networks, Mode: kbruntime.RestoreMode(mode),
- })
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- },
- }
- cmd.Flags().StringVar(&name, "name", "", "new VM name")
- cmd.Flags().StringArrayVar(&networks, "network", nil, "new network attachment, repeatable")
- cmd.Flags().StringVar(&mode, "restore-mode", "copy", "memory restore mode: copy, ondemand, or mmap")
- _ = cmd.MarkFlagRequired("name")
- return cmd
-}
diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go
deleted file mode 100644
index bdbae0e..0000000
--- a/internal/cli/completion_test.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package cli
-
-import (
- "bytes"
- "path/filepath"
- "slices"
- "strings"
- "testing"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestCompletionGeneratesSupportedShells(t *testing.T) {
- t.Parallel()
-
- for _, shell := range []string{"bash", "zsh", "fish", "powershell"} {
- shell := shell
- t.Run(shell, func(t *testing.T) {
- t.Parallel()
- cmd := NewRootCommand()
- var output bytes.Buffer
- cmd.SetOut(&output)
- cmd.SetArgs([]string{"completion", shell})
- if err := cmd.Execute(); err != nil {
- t.Fatal(err)
- }
- if output.Len() == 0 || !strings.Contains(strings.ToLower(output.String()), "kumabox") {
- t.Fatalf("%s completion output is empty or invalid", shell)
- }
- })
- }
-}
-
-func TestResourceCompletionReadsVMNames(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := vm.New(rootDir)
- for _, name := range []string{"alpha", "beta"} {
- if _, err := store.Create(vm.CreateRequest{
- Name: name, RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- }); err != nil {
- t.Fatal(err)
- }
- }
- root := newTestRootCommand(rootDir)
- inspect, _, err := root.Find([]string{"inspect"})
- if err != nil {
- t.Fatal(err)
- }
- candidates, directive := inspect.ValidArgsFunction(inspect, nil, "a")
- if directive != cobra.ShellCompDirectiveNoFileComp || !slices.Equal(candidates, []string{"alpha"}) {
- t.Fatalf("completion candidates=%v directive=%v", candidates, directive)
- }
-}
-
-func TestCommandResourceKind(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- use string
- index int
- want string
- ok bool
- }{
- {use: "restore VM SNAPSHOT", index: 0, want: "vm", ok: true},
- {use: "restore VM SNAPSHOT", index: 1, want: "snapshot", ok: true},
- {use: "start VM [VM...]", index: 4, want: "vm", ok: true},
- {use: "exec VM -- CMD [ARG...]", index: 1, ok: false},
- }
- for _, test := range tests {
- got, ok := commandResourceKind(test.use, test.index)
- if got != test.want || ok != test.ok {
- t.Fatalf("commandResourceKind(%q, %d) = %q, %t", test.use, test.index, got, ok)
- }
- }
-}
diff --git a/internal/cli/console.go b/internal/cli/console.go
deleted file mode 100644
index fed691e..0000000
--- a/internal/cli/console.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package cli
-
-import (
- "context"
- "fmt"
- "io"
- "os"
- "os/signal"
- "syscall"
-
- "golang.org/x/term"
-
- "github.com/spf13/cobra"
-
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func newConsoleCommand(opts *rootOptions) *cobra.Command {
- return &cobra.Command{
- Use: "console VM",
- Short: "Connect to a running VM console",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- stream, err := rt.OpenConsole(cmd.Context(), args[0])
- if err != nil {
- return err
- }
- defer stream.Close() //nolint:errcheck
- if isTerminal(cmd.InOrStdin()) && isTerminal(cmd.OutOrStdout()) {
- return relayInteractiveConsole(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), stream)
- }
- return relayConsole(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), stream)
- },
- }
-}
-
-type consoleSizer interface {
- SetSize(uint16, uint16) error
-}
-
-func isTerminal(value any) bool {
- file, ok := value.(*os.File)
- return ok && term.IsTerminal(int(file.Fd()))
-}
-
-func relayInteractiveConsole(ctx context.Context, in io.Reader, out io.Writer, stream io.ReadWriteCloser) error {
- input, ok := in.(*os.File)
- if !ok {
- return fmt.Errorf("interactive console requires terminal stdin")
- }
- columns, rows, err := term.GetSize(int(input.Fd()))
- if err != nil {
- return fmt.Errorf("get terminal size: %w", err)
- }
- state, err := term.MakeRaw(int(input.Fd()))
- if err != nil {
- return fmt.Errorf("set terminal raw mode: %w", err)
- }
- defer term.Restore(int(input.Fd()), state) //nolint:errcheck
- setRemoteConsoleSize(stream, uint16(rows), uint16(columns))
-
- resizeSignal := make(chan os.Signal, 1)
- signal.Notify(resizeSignal, syscall.SIGWINCH)
- defer signal.Stop(resizeSignal)
- inputData := make(chan []byte, 1)
- inputErr := make(chan error, 1)
- readInput := func() {
- buffer := make([]byte, 32*1024)
- n, readErr := input.Read(buffer)
- if n > 0 {
- inputData <- append([]byte(nil), buffer[:n]...)
- }
- inputErr <- readErr
- }
- go readInput()
-
- relayErrs := make(chan error, 1)
- go func() { _, copyErr := io.Copy(out, stream); relayErrs <- copyErr }()
- for {
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-resizeSignal:
- width, height, sizeErr := term.GetSize(int(input.Fd()))
- if sizeErr == nil {
- setRemoteConsoleSize(stream, uint16(height), uint16(width))
- }
- case data := <-inputData:
- if index := indexConsoleEscape(data); index >= 0 {
- if index > 0 {
- if _, err := stream.Write(data[:index]); err != nil {
- return fmt.Errorf("write console input: %w", err)
- }
- }
- return nil
- }
- if _, err := stream.Write(data); err != nil {
- return fmt.Errorf("write console input: %w", err)
- }
- go readInput()
- case readErr := <-inputErr:
- if readErr == nil {
- continue
- }
- if readErr == io.EOF {
- return nil
- }
- return fmt.Errorf("read console input: %w", readErr)
- case relayErr := <-relayErrs:
- if relayErr == nil || relayErr == io.EOF {
- return nil
- }
- return fmt.Errorf("relay console: %w", relayErr)
- }
- }
-}
-
-func setRemoteConsoleSize(stream io.ReadWriteCloser, rows, columns uint16) {
- if sizer, ok := stream.(consoleSizer); ok {
- _ = sizer.SetSize(rows, columns)
- }
-}
-
-func indexConsoleEscape(data []byte) int {
- for index, value := range data {
- if value == 0x1d { // Ctrl-] is the console escape sequence.
- return index
- }
- }
- return -1
-}
-
-func relayConsole(ctx context.Context, in io.Reader, out io.Writer, stream io.ReadWriteCloser) error {
- errCh := make(chan error, 2)
- go func() { _, err := io.Copy(out, stream); errCh <- err }()
- go func() { _, err := io.Copy(stream, in); errCh <- err }()
- select {
- case <-ctx.Done():
- return ctx.Err()
- case err := <-errCh:
- if err == nil || err == io.EOF {
- return nil
- }
- return fmt.Errorf("relay console: %w", err)
- }
-}
diff --git a/internal/cli/console_test.go b/internal/cli/console_test.go
deleted file mode 100644
index 4ad74da..0000000
--- a/internal/cli/console_test.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package cli
-
-import (
- "bytes"
- "context"
- "io"
- "strings"
- "sync"
- "testing"
-)
-
-type consoleStream struct {
- mu sync.Mutex
- readData []byte
- readReady chan struct{}
- readyOnce sync.Once
- writes bytes.Buffer
-}
-
-func (s *consoleStream) Close() error { return nil }
-
-func (s *consoleStream) Read(p []byte) (int, error) {
- <-s.readReady
- s.mu.Lock()
- defer s.mu.Unlock()
- if len(s.readData) == 0 {
- return 0, io.EOF
- }
- n := copy(p, s.readData)
- s.readData = s.readData[n:]
- return n, nil
-}
-
-func (s *consoleStream) Write(p []byte) (int, error) {
- s.mu.Lock()
- n, err := s.writes.Write(p)
- s.mu.Unlock()
- s.readyOnce.Do(func() { close(s.readReady) })
- return n, err
-}
-
-type recordingWriter struct {
- mu sync.Mutex
- data bytes.Buffer
- done chan struct{}
- once sync.Once
-}
-
-func (w *recordingWriter) Write(p []byte) (int, error) {
- w.mu.Lock()
- n, err := w.data.Write(p)
- w.mu.Unlock()
- w.once.Do(func() { close(w.done) })
- return n, err
-}
-
-func (w *recordingWriter) String() string {
- w.mu.Lock()
- defer w.mu.Unlock()
- return w.data.String()
-}
-
-func TestRelayConsoleCopiesBothDirections(t *testing.T) {
- stream := &consoleStream{readData: []byte("from-guest"), readReady: make(chan struct{})}
- output := &recordingWriter{done: make(chan struct{})}
- input := strings.NewReader("to-guest")
-
- if err := relayConsole(context.Background(), input, output, stream); err != nil && err != io.EOF {
- t.Fatalf("relayConsole() error = %v", err)
- }
- <-output.done
- stream.mu.Lock()
- guestInput := stream.writes.String()
- stream.mu.Unlock()
- if !strings.Contains(guestInput, "to-guest") {
- t.Fatalf("guest input was not relayed: %q", guestInput)
- }
- if output.String() != "from-guest" {
- t.Fatalf("guest output was not relayed: %q", output.String())
- }
-}
-
-func TestIndexConsoleEscape(t *testing.T) {
- tests := []struct {
- name string
- data []byte
- want int
- }{
- {name: "missing", data: []byte("hello"), want: -1},
- {name: "first", data: []byte{0x1d}, want: 0},
- {name: "after output", data: []byte{'o', 'k', 0x1d, 'x'}, want: 2},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- if got := indexConsoleEscape(test.data); got != test.want {
- t.Fatalf("indexConsoleEscape(%q) = %d, want %d", test.data, got, test.want)
- }
- })
- }
-}
diff --git a/internal/cli/data_disk_test.go b/internal/cli/data_disk_test.go
deleted file mode 100644
index f10c387..0000000
--- a/internal/cli/data_disk_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package cli
-
-import "testing"
-
-func TestParseDataDisks(t *testing.T) {
- disks, err := parseDataDisks([]string{
- "size=20M,name=db,mount=/var/lib/db,directio=on",
- "size=16M,fstype=none,mount=",
- })
- if err != nil {
- t.Fatal(err)
- }
- if len(disks) != 2 {
- t.Fatalf("data disk count = %d", len(disks))
- }
- if disks[0].Name != "db" || disks[0].MountPoint != "/var/lib/db" || disks[0].DirectIO == nil || !*disks[0].DirectIO {
- t.Fatalf("first data disk = %+v", disks[0])
- }
- if disks[1].Name != "data1" || disks[1].MountSet == false || disks[1].Filesystem != "none" {
- t.Fatalf("second data disk = %+v", disks[1])
- }
-}
-
-func TestParseDataDisksRejectsMalformedSpec(t *testing.T) {
- for _, value := range []string{"size=8M,name=db", "size=20M,fstype=xfs", "size=20M,name=db,name=other"} {
- if _, err := parseDataDisks([]string{value}); err == nil {
- t.Fatalf("parseDataDisks(%q) error = nil", value)
- }
- }
-}
diff --git a/internal/cli/debug.go b/internal/cli/debug.go
deleted file mode 100644
index a3d26be..0000000
--- a/internal/cli/debug.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package cli
-
-import (
- "errors"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/backend/cloudhypervisor"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func newDebugCommand(opts *rootOptions) *cobra.Command {
- command := &cobra.Command{
- Use: "debug",
- Short: "Inspect plans without changing host state",
- }
- command.AddCommand(newDebugLaunchCommand(opts))
- return command
-}
-
-func newDebugLaunchCommand(opts *rootOptions) *cobra.Command {
- flags := createVMFlags{name: "launch-preview", cpus: 1, memory: "512M", networks: []string{"none"}}
- var jsonOutput bool
- command := &cobra.Command{
- Use: "launch IMAGE",
- Short: "Render a VM launch plan without creating it",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- if !jsonOutput {
- return errors.New("debug launch requires --json")
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- request, err := newCreateRequest(flags, args, cfg)
- if err != nil {
- return err
- }
- record, err := vm.PreviewRecord(request, cfg.Runtime.RootDir, "kb_preview")
- if err != nil {
- return err
- }
- launch := cloudhypervisor.NewConfig(cfg, record)
- if err := cloudhypervisor.ValidateConfig(launch); err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), struct {
- SchemaVersion string `json:"schemaVersion"`
- DryRun bool `json:"dryRun"`
- VM *vm.VMRecord `json:"vm"`
- Launch cloudhypervisor.Config `json:"launch"`
- }{
- SchemaVersion: "kumabox.debug.launch.v1",
- DryRun: true,
- VM: record,
- Launch: launch,
- })
- },
- }
- command.Flags().StringVar(&flags.name, "name", flags.name, "preview VM name")
- command.Flags().IntVar(&flags.cpus, "cpus", flags.cpus, "number of vCPUs")
- command.Flags().StringVar(&flags.memory, "memory", flags.memory, "guest memory size")
- command.Flags().StringVar(&flags.storage, "storage", "", "per-VM writable COW size")
- command.Flags().StringArrayVar(&flags.dataDisks, "data-disk", nil, "managed data disk")
- command.Flags().BoolVar(&flags.sharedMemory, "shared-memory", false, "enable shared guest memory")
- command.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return command
-}
diff --git a/internal/cli/disk.go b/internal/cli/disk.go
deleted file mode 100644
index 97b4c1b..0000000
--- a/internal/cli/disk.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package cli
-
-import (
- "fmt"
- "github.com/kumabox/kumabox/internal/backend"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
- "github.com/spf13/cobra"
-)
-
-func newDiskCommand(opts *rootOptions) *cobra.Command {
- cmd := &cobra.Command{Use: "disk", Short: "Manage runtime disks"}
- attach := &cobra.Command{Use: "attach VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- path, _ := cmd.Flags().GetString("path")
- name, _ := cmd.Flags().GetString("name")
- readonly, _ := cmd.Flags().GetBool("readonly")
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.AttachDisk(cmd.Context(), args[0], backend.DiskSpec{Path: path, Name: name, ReadOnly: readonly})
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- }}
- attach.Flags().String("path", "", "absolute raw disk path")
- attach.Flags().String("name", "", "guest disk serial and detach name")
- attach.Flags().Bool("readonly", false, "attach read-only")
- _ = attach.MarkFlagRequired("path")
- _ = attach.MarkFlagRequired("name")
- detach := &cobra.Command{Use: "detach VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- name, _ := cmd.Flags().GetString("name")
- if name == "" {
- return fmt.Errorf("--name is required")
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.DetachDisk(cmd.Context(), args[0], name)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- }}
- detach.Flags().String("name", "", "disk name")
- list := &cobra.Command{Use: "list VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- disks, err := rt.ListDisks(cmd.Context(), args[0])
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), disks)
- }}
- cmd.AddCommand(attach, detach, list)
- return cmd
-}
diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go
deleted file mode 100644
index 41ca2f4..0000000
--- a/internal/cli/doctor.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package cli
-
-import (
- "fmt"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/doctor"
-)
-
-func newDoctorCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
-
- cmd := &cobra.Command{
- Use: "doctor",
- Short: "Check host requirements",
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
-
- report := doctor.Run(cfg)
- if jsonOutput {
- if err := writeJSON(cmd.OutOrStdout(), report); err != nil {
- return err
- }
- } else {
- writeDoctorText(cmd.OutOrStdout(), report)
- }
-
- if report.Status != doctor.StatusPass {
- return fmt.Errorf("doctor checks failed")
- }
- return nil
- },
- }
-
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
diff --git a/internal/cli/exec.go b/internal/cli/exec.go
deleted file mode 100644
index afb8f97..0000000
--- a/internal/cli/exec.go
+++ /dev/null
@@ -1,205 +0,0 @@
-package cli
-
-import (
- "bytes"
- "context"
- "fmt"
- "io"
- "os"
- "os/signal"
- "syscall"
- "time"
-
- "github.com/spf13/cobra"
- "golang.org/x/term"
-
- agentclient "github.com/kumabox/kumabox/internal/agent/client"
- "github.com/kumabox/kumabox/internal/vm"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func newExecCommand(opts *rootOptions) *cobra.Command {
- var env []string
- var workdir string
- var user string
- var timeout time.Duration
- var jsonOutput bool
- var interactive bool
- var tty bool
-
- cmd := &cobra.Command{
- Use: "exec VM -- CMD [ARG...]",
- Short: "Execute a command inside a running guest",
- Args: cobra.MinimumNArgs(2),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.InspectVM(args[0])
- if err != nil {
- return err
- }
- if rec.State != vm.StateRunning {
- return fmt.Errorf("AGENT_NOT_READY: VM %s is not running", rec.Name)
- }
- if rec.VsockSocket == "" {
- return fmt.Errorf("AGENT_NOT_READY: VM %s has no vsock socket", rec.Name)
- }
- if timeout <= 0 {
- timeout = agentclient.DefaultPingTimeout
- }
- stdin := cmd.InOrStdin()
- if tty && !interactive {
- interactive = true
- }
- if !interactive {
- stdin, err = optionalStdin(stdin)
- if err != nil {
- return err
- }
- }
- ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
- defer cancel()
- if tty {
- if jsonOutput {
- return fmt.Errorf("--json cannot be combined with --tty")
- }
- return runTTYExec(ctx, cmd, rec.VsockSocket, agentclient.ExecRequest{
- Args: args[1:], Env: env, WorkDir: workdir, User: user,
- }, stdin)
- }
- var stdout, stderr bytes.Buffer
- outWriter, errWriter := cmd.OutOrStdout(), cmd.ErrOrStderr()
- if jsonOutput {
- outWriter, errWriter = &stdout, &stderr
- }
- code, err := agentclient.ExecStream(ctx, rec.VsockSocket, agentclient.ExecRequest{
- Args: args[1:],
- Env: env,
- WorkDir: workdir,
- User: user,
- }, stdin, outWriter, errWriter)
- if err != nil {
- return err
- }
- if jsonOutput {
- if err := writeJSON(cmd.OutOrStdout(), agentclient.ExecResponse{
- OK: code == 0,
- ExitCode: code,
- Stdout: stdout.Bytes(),
- Stderr: stderr.Bytes(),
- }); err != nil {
- return err
- }
- }
- if code != 0 {
- return commandExitError{code: code}
- }
- return nil
- },
- }
- cmd.Flags().StringArrayVarP(&env, "env", "e", nil, "environment variable in KEY=VALUE form")
- cmd.Flags().StringVarP(&workdir, "workdir", "w", "", "working directory inside the guest")
- cmd.Flags().StringVar(&user, "user", "", "guest user (root is currently supported)")
- cmd.Flags().DurationVar(&timeout, "timeout", agentclient.DefaultPingTimeout, "agent exec timeout")
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "print exec result as JSON")
- cmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "keep stdin open for the guest command")
- cmd.Flags().BoolVarP(&tty, "tty", "t", false, "allocate a guest terminal")
- return cmd
-}
-
-func runTTYExec(ctx context.Context, cmd *cobra.Command, socketPath string, req agentclient.ExecRequest, stdin io.Reader) error {
- in, ok := stdin.(*os.File)
- if !ok || !term.IsTerminal(int(in.Fd())) {
- return fmt.Errorf("--tty requires a terminal on stdin")
- }
- out, ok := cmd.OutOrStdout().(*os.File)
- if !ok || !term.IsTerminal(int(out.Fd())) {
- return fmt.Errorf("--tty requires a terminal on stdout")
- }
- columns, rows, err := term.GetSize(int(in.Fd()))
- if err != nil {
- return fmt.Errorf("get terminal size: %w", err)
- }
- state, err := term.MakeRaw(int(in.Fd()))
- if err != nil {
- return fmt.Errorf("set terminal raw mode: %w", err)
- }
- defer term.Restore(int(in.Fd()), state) //nolint:errcheck
-
- resizeSignal := make(chan os.Signal, 1)
- signal.Notify(resizeSignal, syscall.SIGWINCH)
- defer signal.Stop(resizeSignal)
- resize := make(chan agentclient.TTYSize, 1)
- go func() {
- for {
- select {
- case <-ctx.Done():
- return
- case <-resizeSignal:
- width, height, sizeErr := term.GetSize(int(in.Fd()))
- if sizeErr == nil {
- select {
- case resize <- agentclient.TTYSize{Rows: uint16(height), Columns: uint16(width)}:
- case <-ctx.Done():
- return
- }
- }
- }
- }
- }()
-
- signalInput := make(chan os.Signal, 4)
- signal.Notify(signalInput, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
- defer signal.Stop(signalInput)
- signals := make(chan string, 4)
- go func() {
- for {
- select {
- case <-ctx.Done():
- return
- case received := <-signalInput:
- name := map[os.Signal]string{
- syscall.SIGINT: "SIGINT", syscall.SIGTERM: "SIGTERM",
- syscall.SIGHUP: "SIGHUP", syscall.SIGQUIT: "SIGQUIT",
- }[received]
- if name != "" {
- select {
- case signals <- name:
- case <-ctx.Done():
- return
- }
- }
- }
- }
- }()
-
- code, err := agentclient.ExecTTY(ctx, socketPath, req, in, cmd.OutOrStdout(), agentclient.TTYOptions{
- Rows: uint16(rows), Columns: uint16(columns), Resize: resize, Signals: signals,
- })
- if err != nil {
- return err
- }
- if code != 0 {
- return commandExitError{code: code}
- }
- return nil
-}
-
-func optionalStdin(r io.Reader) (io.Reader, error) {
- if file, ok := r.(*os.File); ok {
- info, err := file.Stat()
- if err != nil {
- return nil, fmt.Errorf("stat stdin: %w", err)
- }
- if info.Mode()&os.ModeCharDevice != 0 {
- return nil, nil
- }
- }
- return r, nil
-}
diff --git a/internal/cli/exit.go b/internal/cli/exit.go
deleted file mode 100644
index f85919a..0000000
--- a/internal/cli/exit.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package cli
-
-type exitCoder interface {
- ExitCode() int
-}
-
-func ExitCode(err error) int {
- if err == nil {
- return 0
- }
- if exitErr, ok := err.(exitCoder); ok {
- return exitErr.ExitCode()
- }
- return 1
-}
-
-type commandExitError struct {
- code int
-}
-
-func (e commandExitError) Error() string {
- return "command exited with status " + intString(e.code)
-}
-
-func (e commandExitError) ExitCode() int {
- return e.code
-}
-
-func intString(value int) string {
- if value == 0 {
- return "0"
- }
- var buf [20]byte
- i := len(buf)
- v := value
- for v > 0 {
- i--
- buf[i] = byte('0' + v%10)
- v /= 10
- }
- return string(buf[i:])
-}
diff --git a/internal/cli/filesystem.go b/internal/cli/filesystem.go
deleted file mode 100644
index c105c85..0000000
--- a/internal/cli/filesystem.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package cli
-
-import (
- "fmt"
- "github.com/kumabox/kumabox/internal/backend"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
- "github.com/spf13/cobra"
-)
-
-func newFilesystemCommand(opts *rootOptions) *cobra.Command {
- cmd := &cobra.Command{Use: "fs", Short: "Manage runtime virtio-fs filesystems"}
- attach := &cobra.Command{Use: "attach VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- socket, _ := cmd.Flags().GetString("socket")
- tag, _ := cmd.Flags().GetString("tag")
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.AttachFilesystem(cmd.Context(), args[0], backend.FilesystemSpec{Socket: socket, Tag: tag})
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- }}
- attach.Flags().String("socket", "", "virtiofsd socket")
- attach.Flags().String("tag", "", "guest filesystem tag")
- _ = attach.MarkFlagRequired("socket")
- _ = attach.MarkFlagRequired("tag")
- detach := &cobra.Command{Use: "detach VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- tag, _ := cmd.Flags().GetString("tag")
- if tag == "" {
- return fmt.Errorf("--tag is required")
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.DetachFilesystem(cmd.Context(), args[0], tag)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- }}
- detach.Flags().String("tag", "", "guest filesystem tag")
- list := &cobra.Command{Use: "list VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- filesystems, err := rt.ListFilesystems(cmd.Context(), args[0])
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), filesystems)
- }}
- cmd.AddCommand(attach, detach, list)
- return cmd
-}
diff --git a/internal/cli/gc.go b/internal/cli/gc.go
deleted file mode 100644
index 6dfeba5..0000000
--- a/internal/cli/gc.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package cli
-
-import (
- "fmt"
- "io"
- "strings"
- "text/tabwriter"
- "time"
-
- "github.com/spf13/cobra"
-
- kbgc "github.com/kumabox/kumabox/internal/gc"
-)
-
-func newGCCommand(opts *rootOptions) *cobra.Command {
- var dryRun bool
- var repair bool
- var jsonOutput bool
- var snapshotKeep int
- var snapshotMaxAge time.Duration
- var snapshotMaxBytes string
-
- cmd := &cobra.Command{
- Use: "gc",
- Short: "Inspect garbage-collection candidates",
- RunE: func(cmd *cobra.Command, args []string) error {
- if !dryRun && !repair {
- return fmt.Errorf("choose --dry-run or --repair")
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- options := kbgc.Options{}
- policyEnabled := cmd.Flags().Changed("snapshot-keep") ||
- cmd.Flags().Changed("snapshot-max-age") ||
- cmd.Flags().Changed("snapshot-max-bytes")
- if policyEnabled {
- if snapshotKeep < 0 {
- return fmt.Errorf("--snapshot-keep must not be negative")
- }
- if snapshotMaxAge < 0 {
- return fmt.Errorf("--snapshot-max-age must not be negative")
- }
- var maxBytes int64
- if strings.TrimSpace(snapshotMaxBytes) != "" {
- maxBytes, err = parsePositiveByteSize("--snapshot-max-bytes", snapshotMaxBytes)
- if err != nil {
- return err
- }
- }
- options.SnapshotPolicy = &kbgc.SnapshotPolicy{
- KeepLast: snapshotKeep, KeepLastSet: cmd.Flags().Changed("snapshot-keep"),
- MaxAge: snapshotMaxAge, MaxBytes: maxBytes,
- }
- }
- var report *kbgc.Report
- if repair {
- report, err = kbgc.RepairWithOptions(cmd.Context(), cfg, options)
- } else {
- report, err = kbgc.DryRunContext(cmd.Context(), cfg, options)
- }
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), report)
- }
- return writeGCReport(cmd.OutOrStdout(), report)
- },
- }
-
- cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show candidates without deleting anything")
- cmd.Flags().BoolVar(&repair, "repair", false, "remove safe orphan resources and retry stale network cleanup")
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- cmd.Flags().IntVar(&snapshotKeep, "snapshot-keep", 0, "keep at least this many newest snapshots per source VM")
- cmd.Flags().DurationVar(&snapshotMaxAge, "snapshot-max-age", 0, "evict snapshots not accessed within this duration")
- cmd.Flags().StringVar(&snapshotMaxBytes, "snapshot-max-bytes", "", "evict least-recently-used snapshots above this total size")
- return cmd
-}
-
-func writeGCReport(w io.Writer, report *kbgc.Report) error {
- tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
- if _, err := fmt.Fprintln(tw, "COMPONENT\tTYPE\tPATH\tREASON"); err != nil {
- return err
- }
- for _, candidate := range report.Candidates {
- if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", candidate.Component, candidate.Type, candidate.Path, candidate.Reason); err != nil {
- return err
- }
- }
- if report.SnapshotPolicy != nil {
- for _, candidate := range report.SnapshotPolicy.Candidates {
- if _, err := fmt.Fprintf(tw, "snapshot-policy\t%s\t%s\t%s\n", candidate.Reason, candidate.Name, "eligible snapshot policy candidate"); err != nil {
- return err
- }
- }
- for _, candidate := range report.SnapshotPolicy.Blocked {
- if _, err := fmt.Fprintf(tw, "snapshot-policy\tblocked\t%s\t%s\n", candidate.Name, candidate.Reason); err != nil {
- return err
- }
- }
- }
- return tw.Flush()
-}
diff --git a/internal/cli/hibernate.go b/internal/cli/hibernate.go
deleted file mode 100644
index 3025043..0000000
--- a/internal/cli/hibernate.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package cli
-
-import (
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/config"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func newHibernateCommand(opts *rootOptions) *cobra.Command {
- var name string
- cmd := &cobra.Command{
- Use: "hibernate VM", Short: "Durably snapshot a running VM and release its VMM", Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if err := config.EnsureRuntimeDirs(cfg); err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- result, err := rt.HibernateVM(cmd.Context(), args[0], kbruntime.HibernateOptions{Name: name})
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), result)
- },
- }
- cmd.Flags().StringVar(&name, "name", "", "hibernate snapshot name")
- _ = cmd.MarkFlagRequired("name")
- return cmd
-}
diff --git a/internal/cli/image.go b/internal/cli/image.go
deleted file mode 100644
index 5ef0efa..0000000
--- a/internal/cli/image.go
+++ /dev/null
@@ -1,630 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package cli
-
-import (
- "context"
- "errors"
- "fmt"
- "net/url"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/batch"
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/image/oci"
- "github.com/kumabox/kumabox/internal/lock"
- "github.com/kumabox/kumabox/internal/state"
-)
-
-func newImageCommand(opts *rootOptions) *cobra.Command {
- cmd := &cobra.Command{
- Use: "image",
- Short: "Manage KumaBox images",
- }
- cmd.AddCommand(newImageAddCommand(opts))
- cmd.AddCommand(newImageImportCommand(opts))
- cmd.AddCommand(newImagePullCommand(opts))
- cmd.AddCommand(newImagePullOCICommand(opts))
- cmd.AddCommand(newImageBuildCommand(opts))
- cmd.AddCommand(newImageLSCommand(opts))
- cmd.AddCommand(newImageInspectCommand(opts))
- cmd.AddCommand(newImageRMCommand(opts))
- return cmd
-}
-
-type imageSourceKind string
-
-const (
- imageSourceLocal imageSourceKind = "local"
- imageSourceHTTP imageSourceKind = "http"
- imageSourceOCI imageSourceKind = "oci"
-)
-
-func newImageAddCommand(opts *rootOptions) *cobra.Command {
- var name, firmware, qemuImg, expectedSHA256 string
- var platform, source, mkfsEROFS, agentProfile string
- var concurrency int
- var progress bool
-
- cmd := &cobra.Command{
- Use: "add SOURCE",
- Short: "Add a local, HTTP, or OCI image",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- if concurrency < 0 {
- return errors.New("--concurrency must not be negative")
- }
- kind, err := classifyImageSource(args[0])
- if err != nil {
- return err
- }
- if kind != imageSourceOCI && firmware == "" {
- return errors.New("--firmware is required for local and HTTP cloud images")
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- qemuImg = configuredQEMUImg(qemuImg, cfg)
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- if stores.Metadata != nil {
- defer func() { _ = stores.Metadata.Close() }()
- }
- mutation, err := stores.Guard.BeginMutation(cmd.Context())
- if err != nil {
- return err
- }
- defer mutation.Release() //nolint:errcheck
-
- var record *image.ImageRecord
- switch kind {
- case imageSourceLocal:
- record, err = stores.Images.ImportLocal(image.ImportRequest{
- Name: name, File: args[0], Firmware: firmware, QemuImgPath: qemuImg,
- })
- case imageSourceHTTP:
- record, err = stores.Images.Pull(image.PullRequest{
- Name: name, URL: args[0], Firmware: firmware,
- QemuImgPath: qemuImg, SHA256: expectedSHA256,
- })
- case imageSourceOCI:
- record, err = oci.NewImagePipeline(cfg.Runtime.RootDir, stores.OCI, stores.Images).Build(cmd.Context(), oci.BuildRequest{
- Name: name, Ref: args[0], Platform: platform, Source: source,
- MkfsEROFS: mkfsEROFS, Concurrency: concurrency, AgentProfile: agentProfile,
- Progress: cliOCIProgress(cmd, progress),
- })
- default:
- return fmt.Errorf("unsupported image source kind %q", kind)
- }
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), record)
- },
- }
- cmd.Flags().StringVar(&name, "name", "", "image name")
- cmd.Flags().StringVar(&firmware, "firmware", "", "UEFI firmware path for cloud images")
- cmd.Flags().StringVar(&qemuImg, "qemu-img", "", "qemu-img binary path override")
- cmd.Flags().StringVar(&expectedSHA256, "sha256", "", "expected HTTP image sha256 digest")
- cmd.Flags().StringVar(&platform, "platform", oci.DefaultPlatform(), "OCI platform os/arch[/variant]")
- cmd.Flags().StringVar(&source, "source", "auto", "OCI source: auto, registry, or daemon")
- cmd.Flags().StringVar(&mkfsEROFS, "mkfs-erofs", "mkfs.erofs", "mkfs.erofs binary path")
- cmd.Flags().IntVar(&concurrency, "concurrency", 0, "maximum concurrent OCI layer conversions")
- cmd.Flags().StringVar(&agentProfile, "agent-profile", image.AgentProfileAuto, "guest agent profile")
- cmd.Flags().BoolVar(&progress, "progress", false, "print OCI import progress to stderr")
- _ = cmd.MarkFlagRequired("name")
- return cmd
-}
-
-func classifyImageSource(source string) (imageSourceKind, error) {
- parsed, err := url.Parse(source)
- if err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") {
- if parsed.Host == "" {
- return "", fmt.Errorf("invalid HTTP image source: %s", source)
- }
- return imageSourceHTTP, nil
- }
- info, statErr := os.Stat(source)
- if statErr == nil {
- if !info.Mode().IsRegular() {
- return "", fmt.Errorf("local image source is not a regular file: %s", source)
- }
- return imageSourceLocal, nil
- }
- if !errors.Is(statErr, os.ErrNotExist) {
- return "", fmt.Errorf("inspect image source %s: %w", source, statErr)
- }
- if filepath.IsAbs(source) || strings.HasPrefix(source, "./") || strings.HasPrefix(source, "../") {
- return "", fmt.Errorf("local image source does not exist: %s", source)
- }
- return imageSourceOCI, nil
-}
-
-func newImagePullOCICommand(opts *rootOptions) *cobra.Command {
- var platform string
- var jsonOutput bool
- var source string
- var progress bool
-
- cmd := &cobra.Command{
- Use: "pull-oci REF",
- Short: "Pull OCI blobs into the content store",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- mutation, err := stores.Guard.BeginMutation(cmd.Context())
- if err != nil {
- return err
- }
- defer mutation.Release() //nolint:errcheck
- result, err := oci.NewImagePipeline(cfg.Runtime.RootDir, stores.OCI, stores.Images).Pull(cmd.Context(), oci.PullRequest{
- Ref: args[0],
- Platform: platform,
- Source: source,
- Progress: cliOCIProgress(cmd, progress),
- })
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), result)
- },
- }
- cmd.Flags().StringVar(&platform, "platform", oci.DefaultPlatform(), "OCI platform os/arch[/variant]")
- cmd.Flags().StringVar(&source, "source", "auto", "OCI source: auto, registry, or daemon")
- cmd.Flags().BoolVar(&progress, "progress", false, "print OCI import progress to stderr")
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
-
-func newImageBuildCommand(opts *rootOptions) *cobra.Command {
- var name string
- var platform string
- var dryRun bool
- var jsonOutput bool
- var source string
- var mkfsEROFS string
- var concurrency int
- var agentProfile string
- var progress bool
-
- cmd := &cobra.Command{
- Use: "build REF",
- Short: "Build a KumaBox image from an OCI reference",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if name == "" {
- return errors.New("--name is required")
- }
-
- if dryRun {
- if !jsonOutput {
- return fmt.Errorf("P3_RESOLVE_REQUIRES_JSON: P3-01 dry-run output requires --json")
- }
- result, err := oci.Resolve(cmd.Context(), args[0], platform)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), struct {
- SchemaVersion string `json:"schemaVersion"`
- Name string `json:"name"`
- DryRun bool `json:"dryRun"`
- Result *oci.ResolveResult `json:"result"`
- }{
- SchemaVersion: "kumabox.oci.resolve.v1",
- Name: name,
- DryRun: true,
- Result: result,
- })
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- mutation, err := stores.Guard.BeginMutation(cmd.Context())
- if err != nil {
- return err
- }
- defer mutation.Release() //nolint:errcheck
- rec, err := oci.NewImagePipeline(cfg.Runtime.RootDir, stores.OCI, stores.Images).Build(cmd.Context(), oci.BuildRequest{
- Name: name,
- Ref: args[0],
- Platform: platform,
- Source: source,
- MkfsEROFS: mkfsEROFS,
- Concurrency: concurrency,
- AgentProfile: agentProfile,
- Progress: cliOCIProgress(cmd, progress),
- })
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- },
- }
- cmd.Flags().StringVar(&name, "name", "", "image name")
- cmd.Flags().StringVar(&platform, "platform", oci.DefaultPlatform(), "OCI platform os/arch[/variant]")
- cmd.Flags().StringVar(&source, "source", "auto", "OCI source: auto, registry, or daemon")
- cmd.Flags().StringVar(&mkfsEROFS, "mkfs-erofs", "mkfs.erofs", "mkfs.erofs binary path")
- cmd.Flags().IntVar(&concurrency, "concurrency", 0, "maximum concurrent OCI layer conversions; 0 uses host CPU count")
- cmd.Flags().StringVar(&agentProfile, "agent-profile", image.AgentProfileAuto, "guest agent profile: auto, required, embedded, or unsupported")
- cmd.Flags().BoolVar(&progress, "progress", false, "print OCI import progress to stderr")
- cmd.Flags().BoolVar(&dryRun, "dry-run", false, "resolve OCI metadata without publishing an image")
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- _ = cmd.MarkFlagRequired("name")
- return cmd
-}
-
-func cliOCIProgress(cmd *cobra.Command, enabled bool) func(oci.ProgressEvent) {
- if !enabled {
- return nil
- }
- return func(event oci.ProgressEvent) {
- if event.Total > 0 {
- _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "oci: phase=%s item=%d/%d digest=%s cached=%t\n", event.Phase, event.Index+1, event.Total, event.Digest, event.Cached)
- return
- }
- _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "oci: phase=%s digest=%s\n", event.Phase, event.Digest)
- }
-}
-
-func newImageImportCommand(opts *rootOptions) *cobra.Command {
- var name string
- var firmware string
- var qemuImg string
-
- cmd := &cobra.Command{
- Use: "import FILE",
- Short: "Import a local cloud image",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- qemuImg = configuredQEMUImg(qemuImg, cfg)
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- mutation, err := stores.Guard.BeginMutation(cmd.Context())
- if err != nil {
- return err
- }
- defer mutation.Release() //nolint:errcheck
- rec, err := stores.Images.ImportLocal(image.ImportRequest{
- Name: name,
- File: args[0],
- Firmware: firmware,
- QemuImgPath: qemuImg,
- })
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- },
- }
- cmd.Flags().StringVar(&name, "name", "", "image name")
- cmd.Flags().StringVar(&firmware, "firmware", "", "UEFI firmware path")
- cmd.Flags().StringVar(&qemuImg, "qemu-img", "", "qemu-img binary path override")
- _ = cmd.MarkFlagRequired("name")
- _ = cmd.MarkFlagRequired("firmware")
- return cmd
-}
-
-func newImagePullCommand(opts *rootOptions) *cobra.Command {
- var names []string
- var firmware string
- var qemuImg string
- var sha256Digest string
- var concurrency int
-
- cmd := &cobra.Command{
- Use: "pull URL...",
- Short: "Pull a cloud image URL",
- Args: cobra.MinimumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- if err := validateBatchConcurrency(concurrency); err != nil {
- return err
- }
- if len(names) != len(args) {
- return fmt.Errorf("provide one --name for each URL: got %d name(s) for %d URL(s)", len(names), len(args))
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- qemuImg = configuredQEMUImg(qemuImg, cfg)
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- mutation, err := stores.Guard.BeginMutation(cmd.Context())
- if err != nil {
- return err
- }
- defer mutation.Release() //nolint:errcheck
- result := batch.Run(cmd.Context(), args, batch.Options{Concurrency: concurrency}, "pull image", func(_ context.Context, index int, ref string) (*image.ImageRecord, error) {
- return stores.Images.Pull(image.PullRequest{
- Name: names[index], URL: ref, Firmware: firmware,
- QemuImgPath: qemuImg, SHA256: sha256Digest,
- })
- })
- return writeResourceBatchResult(func(value any) error {
- return writeJSON(cmd.OutOrStdout(), value)
- }, args, "pull image", result)
- },
- }
- cmd.Flags().StringArrayVar(&names, "name", nil, "image name, repeat once per URL")
- cmd.Flags().StringVar(&firmware, "firmware", "", "UEFI firmware path")
- cmd.Flags().StringVar(&qemuImg, "qemu-img", "", "qemu-img binary path override")
- cmd.Flags().StringVar(&sha256Digest, "sha256", "", "expected image sha256 digest")
- addResourceBatchConcurrencyFlag(cmd, &concurrency)
- _ = cmd.MarkFlagRequired("name")
- _ = cmd.MarkFlagRequired("firmware")
- return cmd
-}
-
-func configuredQEMUImg(override string, cfg config.Config) string {
- if override != "" {
- return override
- }
- return cfg.Storage.QEMUImgBinary
-}
-
-func newImageLSCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
-
- cmd := &cobra.Command{
- Use: "ls",
- Aliases: []string{"list"},
- Short: "List images",
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- records, err := stores.Images.List()
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), records)
- }
- return writeImageTable(cmd.OutOrStdout(), records)
- },
- }
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
-
-func newImageInspectCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
-
- cmd := &cobra.Command{
- Use: "inspect IMAGE",
- Short: "Inspect an image",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- rec, err := stores.Images.Inspect(args[0])
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), rec)
- }
- return writeImageTable(cmd.OutOrStdout(), []*image.ImageRecord{rec})
- },
- }
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
-
-func newImageRMCommand(opts *rootOptions) *cobra.Command {
- var force bool
- var concurrency int
-
- cmd := &cobra.Command{
- Use: "rm IMAGE...",
- Aliases: []string{"remove"},
- Short: "Remove an unused image",
- Args: cobra.MinimumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- if err := validateBatchConcurrency(concurrency); err != nil {
- return err
- }
- args = batch.Distinct(args)
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- mutation, err := stores.Guard.BeginMutation(cmd.Context())
- if err != nil {
- return err
- }
- defer mutation.Release() //nolint:errcheck
- result := batch.Run(cmd.Context(), args, batch.Options{Concurrency: concurrency}, "remove image", func(ctx context.Context, _ int, ref string) (*image.ImageRecord, error) {
- return removeImage(ctx, stores, ref, force)
- })
- return writeResourceBatchResult(func(value any) error {
- return writeJSON(cmd.OutOrStdout(), value)
- }, args, "remove image", result)
- },
- }
- cmd.Flags().BoolVar(&force, "force", false, "allow removal of damaged unreferenced image directories")
- addResourceBatchConcurrencyFlag(cmd, &concurrency)
- return cmd
-}
-
-func removeImage(ctx context.Context, stores state.Set, ref string, force bool) (record *image.ImageRecord, err error) {
- imageRecord, err := stores.Images.Inspect(ref)
- if err != nil {
- return nil, err
- }
- imageLock, err := stores.Guard.LockEntity(ctx, lock.EntityImage, imageRecord.ID)
- if err != nil {
- return nil, err
- }
- defer func() {
- if releaseErr := imageLock.Release(); releaseErr != nil {
- err = errors.Join(err, fmt.Errorf("release image lock: %w", releaseErr))
- }
- }()
- references, err := imageReferencesFromVMs(stores)
- if err != nil {
- return nil, fmt.Errorf("recheck image references: %w", err)
- }
- if explicit, explicitErr := explicitImageReferences(ctx, stores, ref); explicitErr != nil {
- return nil, explicitErr
- } else if len(explicit) > 0 {
- references = mergeImageReferences(references, explicit)
- }
- return stores.Images.Remove(image.RemoveRequest{Ref: ref, Force: force, References: references})
-}
-
-func mergeImageReferences(groups ...[]image.Reference) []image.Reference {
- seen := make(map[string]struct{})
- var merged []image.Reference
- for _, group := range groups {
- for _, reference := range group {
- key := reference.Kind + "\x00" + reference.VMID + "\x00" + reference.ImageID
- if _, exists := seen[key]; exists {
- continue
- }
- seen[key] = struct{}{}
- merged = append(merged, reference)
- }
- }
- return merged
-}
-
-func explicitImageReferences(ctx context.Context, stores state.Set, ref string) ([]image.Reference, error) {
- if stores.References == nil {
- return nil, nil
- }
- imageRecord, err := stores.Images.Inspect(ref)
- if err != nil {
- return nil, err
- }
- records, err := stores.References.ListTarget(ctx, "image", imageRecord.ID)
- if err != nil {
- return nil, err
- }
- liveVMs, liveSnapshots, err := liveImageReferenceSources(stores)
- if err != nil {
- return nil, err
- }
- refs := make([]image.Reference, 0, len(records))
- for _, record := range records {
- live := true
- switch record.SourceKind {
- case "vm":
- _, live = liveVMs[record.SourceID]
- case "snapshot":
- _, live = liveSnapshots[record.SourceID]
- }
- if !live {
- if err := stores.References.Delete(ctx, record.ID); err != nil {
- return nil, fmt.Errorf("delete dangling image reference %s: %w", record.ID, err)
- }
- continue
- }
- refs = append(refs, image.Reference{Kind: record.SourceKind, VMID: record.SourceID, VMName: record.SourceID, ImageID: imageRecord.ID})
- }
- return refs, nil
-}
-
-func liveImageReferenceSources(stores state.Set) (map[string]struct{}, map[string]struct{}, error) {
- vms, err := stores.VM.List()
- if err != nil {
- return nil, nil, fmt.Errorf("list VMs for image references: %w", err)
- }
- liveVMs := make(map[string]struct{}, len(vms))
- for _, rec := range vms {
- if rec != nil {
- liveVMs[rec.ID] = struct{}{}
- }
- }
- snapshots, err := stores.Snapshots.List()
- if err != nil {
- return nil, nil, fmt.Errorf("list snapshots for image references: %w", err)
- }
- liveSnapshots := make(map[string]struct{}, len(snapshots))
- for _, rec := range snapshots {
- if rec != nil {
- liveSnapshots[rec.ID] = struct{}{}
- }
- }
- return liveVMs, liveSnapshots, nil
-}
-
-func imageReferencesFromVMs(stores state.Set) ([]image.Reference, error) {
- records, err := stores.VM.List()
- if err != nil {
- return nil, err
- }
- refs := make([]image.Reference, 0)
- for _, rec := range records {
- if rec == nil || rec.Image == nil {
- continue
- }
- refs = append(refs, image.Reference{
- Kind: "vm",
- VMID: rec.ID,
- VMName: rec.Name,
- VMState: string(rec.State),
- ImageID: rec.Image.ID,
- })
- }
- snapshots, err := stores.Snapshots.List()
- if err != nil {
- return nil, fmt.Errorf("read snapshot references: %w", err)
- }
- for _, rec := range snapshots {
- manifest, err := stores.Snapshots.PeekManifest(context.Background(), rec.ID)
- if err != nil {
- return nil, fmt.Errorf("read snapshot %s image reference: %w", rec.ID, err)
- }
- if manifest.Base == nil || manifest.Base.ImageID == "" {
- continue
- }
- refs = append(refs, image.Reference{
- Kind: "snapshot", VMID: rec.ID, VMName: rec.Name, VMState: string(rec.State), ImageID: manifest.Base.ImageID,
- })
- }
- return refs, nil
-}
diff --git a/internal/cli/metadata.go b/internal/cli/metadata.go
deleted file mode 100644
index 8c7228d..0000000
--- a/internal/cli/metadata.go
+++ /dev/null
@@ -1,159 +0,0 @@
-package cli
-
-import (
- "fmt"
- "os"
- "path/filepath"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/lock"
- metasqlite "github.com/kumabox/kumabox/internal/meta/sqlite"
- "github.com/kumabox/kumabox/internal/state"
-)
-
-func newMetadataCommand(opts *rootOptions) *cobra.Command {
- cmd := &cobra.Command{Use: "metadata", Short: "Inspect metadata storage"}
- cmd.AddCommand(newMetadataInitCommand(opts))
- cmd.AddCommand(newMetadataStatusCommand(opts))
- cmd.AddCommand(newMetadataVerifyCommand(opts))
- cmd.AddCommand(newMetadataConvertCommand(opts))
- cmd.AddCommand(newMetadataBackupCommand(opts))
- return cmd
-}
-
-func newMetadataBackupCommand(opts *rootOptions) *cobra.Command {
- return &cobra.Command{
- Use: "backup OUTPUT", Short: "Create a verified SQLite metadata backup", Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if cfg.Metadata.Backend != "sqlite" {
- return fmt.Errorf("metadata backup requires the sqlite backend, got %q", cfg.Metadata.Backend)
- }
- destination, err := filepath.Abs(args[0])
- if err != nil {
- return fmt.Errorf("resolve metadata backup destination: %w", err)
- }
- if err := metasqlite.Backup(cmd.Context(), state.SQLiteMetadataPath(cfg), destination); err != nil {
- return err
- }
- info, err := os.Stat(destination)
- if err != nil {
- return fmt.Errorf("stat metadata backup: %w", err)
- }
- return writeJSON(cmd.OutOrStdout(), map[string]any{
- "backend": "sqlite", "output": destination, "sizeBytes": info.Size(), "verified": true,
- })
- },
- }
-}
-
-func newMetadataInitCommand(opts *rootOptions) *cobra.Command {
- return &cobra.Command{
- Use: "init", Short: "Initialize the configured SQLite metadata database", Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, _ []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if err := state.InitSQLiteMetadata(cmd.Context(), cfg); err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), map[string]any{
- "backend": "sqlite", "path": state.SQLiteMetadataPath(cfg), "initialized": true,
- })
- },
- }
-}
-
-func newMetadataConvertCommand(opts *rootOptions) *cobra.Command {
- return &cobra.Command{
- Use: "convert", Short: "Switch metadata to the configured backend", Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, _ []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- maintenance, err := lock.NewGuard(cfg.Runtime.RootDir).BeginMaintenance(cmd.Context())
- if err != nil {
- return err
- }
- defer maintenance.Release() //nolint:errcheck
- result, err := state.ConvertMetadata(cmd.Context(), cfg)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), result)
- },
- }
-}
-
-func newMetadataStatusCommand(opts *rootOptions) *cobra.Command {
- return &cobra.Command{
- Use: "status", Short: "Show metadata backend and namespace state", Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, _ []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- result := map[string]any{"backend": cfg.Metadata.Backend}
- if cfg.Metadata.Backend == "sqlite" {
- path := state.SQLiteMetadataPath(cfg)
- result["path"] = path
- engine, ok := stores.Metadata.(*metasqlite.Store)
- if !ok {
- return fmt.Errorf("configured SQLite metadata engine has unexpected type %T", stores.Metadata)
- }
- status, err := engine.Status(cmd.Context())
- if err != nil {
- return err
- }
- result["namespaces"] = status
- }
- return writeJSON(cmd.OutOrStdout(), result)
- },
- }
-}
-
-func newMetadataVerifyCommand(opts *rootOptions) *cobra.Command {
- return &cobra.Command{
- Use: "verify", Short: "Verify metadata backend identity and namespace state", Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, _ []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- if cfg.Metadata.Backend != "sqlite" {
- return writeJSON(cmd.OutOrStdout(), map[string]any{"backend": cfg.Metadata.Backend, "verified": true})
- }
- engine, ok := stores.Metadata.(*metasqlite.Store)
- if !ok {
- return fmt.Errorf("configured SQLite metadata engine has unexpected type %T", stores.Metadata)
- }
- status, err := engine.Status(cmd.Context())
- if err != nil {
- return err
- }
- for _, namespace := range status {
- if namespace.State != "initialized" && namespace.State != "converted" {
- return fmt.Errorf("metadata namespace %q has invalid state %q", namespace.Namespace, namespace.State)
- }
- }
- if err := engine.Verify(cmd.Context()); err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), map[string]any{"backend": "sqlite", "verified": true, "namespaces": status})
- },
- }
-}
diff --git a/internal/cli/metadata_test.go b/internal/cli/metadata_test.go
deleted file mode 100644
index 6df771c..0000000
--- a/internal/cli/metadata_test.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package cli
-
-import (
- "bytes"
- "encoding/json"
- "path/filepath"
- "testing"
-
- "github.com/kumabox/kumabox/internal/config"
- metasqlite "github.com/kumabox/kumabox/internal/meta/sqlite"
- "github.com/kumabox/kumabox/internal/reference"
- "github.com/kumabox/kumabox/internal/state"
-)
-
-func TestMetadataInitCommandCreatesVerifiedSQLiteStore(t *testing.T) {
- rootDir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = rootDir
- cfg.Runtime.RunDir = filepath.Join(rootDir, "run")
- cfg.Runtime.LogDir = filepath.Join(rootDir, "log")
- cfg.Metadata.Backend = "sqlite"
-
- cmd := NewRootCommandWithConfig(cfg)
- var output bytes.Buffer
- cmd.SetOut(&output)
- cmd.SetArgs([]string{"metadata", "init"})
- if err := cmd.Execute(); err != nil {
- t.Fatal(err)
- }
- var result struct {
- Initialized bool `json:"initialized"`
- }
- if err := json.Unmarshal(output.Bytes(), &result); err != nil {
- t.Fatal(err)
- }
- if !result.Initialized {
- t.Fatal("metadata init did not report success")
- }
-
- stores, err := state.Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- engine, ok := stores.Metadata.(*metasqlite.Store)
- if !ok {
- t.Fatalf("metadata engine type = %T", stores.Metadata)
- }
- t.Cleanup(func() {
- if err := engine.Close(); err != nil {
- t.Errorf("close metadata engine: %v", err)
- }
- })
- if err := engine.Verify(t.Context()); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestMetadataBackupCommandCreatesUsableDatabase(t *testing.T) {
- rootDir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = rootDir
- cfg.Runtime.RunDir = filepath.Join(rootDir, "run")
- cfg.Runtime.LogDir = filepath.Join(rootDir, "log")
- cfg.Metadata.Backend = "sqlite"
- if err := state.InitSQLiteMetadata(t.Context(), cfg); err != nil {
- t.Fatal(err)
- }
- stores, err := state.Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- if err := stores.References.Upsert(t.Context(), reference.Record{
- ID: "backup-ref", SourceKind: "vm", SourceID: "vm-1", TargetKind: "image", TargetID: "image-1",
- }); err != nil {
- t.Fatal(err)
- }
- if err := stores.Metadata.Close(); err != nil {
- t.Fatal(err)
- }
-
- destination := filepath.Join(t.TempDir(), "kumabox-backup.db")
- cmd := NewRootCommandWithConfig(cfg)
- var output bytes.Buffer
- cmd.SetOut(&output)
- cmd.SetArgs([]string{"metadata", "backup", destination})
- if err := cmd.Execute(); err != nil {
- t.Fatal(err)
- }
- var result struct {
- Verified bool `json:"verified"`
- }
- if err := json.Unmarshal(output.Bytes(), &result); err != nil {
- t.Fatal(err)
- }
- if !result.Verified {
- t.Fatal("metadata backup did not report verification")
- }
-
- backupConfig := cfg
- backupConfig.Metadata.Path = destination
- backupStores, err := state.Open(backupConfig)
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := backupStores.Metadata.Close(); err != nil {
- t.Errorf("close backup metadata: %v", err)
- }
- }()
- references, err := backupStores.References.ListTarget(t.Context(), "image", "image-1")
- if err != nil {
- t.Fatal(err)
- }
- if len(references) != 1 || references[0].ID != "backup-ref" {
- t.Fatalf("backup references = %+v", references)
- }
-}
diff --git a/internal/cli/network.go b/internal/cli/network.go
deleted file mode 100644
index da3d842..0000000
--- a/internal/cli/network.go
+++ /dev/null
@@ -1,180 +0,0 @@
-package cli
-
-import (
- "context"
- "errors"
- "time"
-
- "github.com/spf13/cobra"
-
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/vm"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func newNetworkCommand(opts *rootOptions) *cobra.Command {
- cmd := &cobra.Command{
- Use: "network",
- Short: "Inspect VM network resources",
- }
- cmd.AddCommand(newNetworkLSCommand(opts))
- cmd.AddCommand(newNetworkInspectCommand(opts))
- cmd.AddCommand(newNetworkSetupCommand(opts))
- cmd.AddCommand(newNetworkTeardownCommand(opts))
- cmd.AddCommand(newNetworkResizeCommand(opts))
- return cmd
-}
-
-func newNetworkResizeCommand(opts *rootOptions) *cobra.Command {
- var count int
- cmd := &cobra.Command{Use: "resize VM", Short: "Resize NICs on a running VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.ResizeNetwork(cmd.Context(), args[0], count)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- }}
- cmd.Flags().IntVar(&count, "nics", 1, "target NIC count")
- return cmd
-}
-
-func newNetworkLSCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
-
- cmd := &cobra.Command{
- Use: "ls",
- Short: "List network provider records",
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- records, err := stores.Networks.List()
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), records)
- }
- return writeNetworkTable(cmd.OutOrStdout(), records)
- },
- }
-
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
-
-func newNetworkSetupCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
-
- cmd := &cobra.Command{
- Use: "setup",
- Short: "Ensure the default host-tap network",
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
- defer cancel()
- report, err := kbnetwork.EnsureHostTap(ctx, cfg.Runtime.RootDir, cfg.Network)
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), report)
- }
- return writeJSON(cmd.OutOrStdout(), report)
- },
- }
-
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
-
-func newNetworkTeardownCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
-
- cmd := &cobra.Command{
- Use: "teardown",
- Short: "Remove the default host-tap network if owned by this root dir",
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
- defer cancel()
- report, err := kbnetwork.TeardownHostTap(ctx, cfg.Runtime.RootDir, cfg.Network)
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), report)
- }
- return writeJSON(cmd.OutOrStdout(), report)
- },
- }
-
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
-
-func newNetworkInspectCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
-
- cmd := &cobra.Command{
- Use: "inspect VM",
- Short: "Inspect one VM's network provider records",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- var vmID, vmName, networkName string
- var networks []string
- var networkConfigs []kbnetwork.Config
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- rec, err := stores.VM.Inspect(args[0])
- if err != nil && !errors.Is(err, vm.ErrNotFound) {
- return err
- }
- if rec != nil {
- vmID = rec.ID
- vmName = rec.Name
- networkName = rec.Network
- networks = append([]string(nil), rec.Networks...)
- networkConfigs = rec.NetworkConfigs
- } else {
- vmID = args[0]
- }
- result, err := stores.Networks.InspectVM(vmID, vmName, networkName, networks, networkConfigs)
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), result)
- }
- return writeNetworkTable(cmd.OutOrStdout(), result.Interfaces)
- },
- }
-
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
diff --git a/internal/cli/output.go b/internal/cli/output.go
deleted file mode 100644
index 3e309ce..0000000
--- a/internal/cli/output.go
+++ /dev/null
@@ -1,158 +0,0 @@
-package cli
-
-import (
- "encoding/json"
- "fmt"
- "io"
- "strings"
- "text/tabwriter"
-
- "github.com/kumabox/kumabox/internal/doctor"
- "github.com/kumabox/kumabox/internal/image"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/vm"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func writeJSON(w io.Writer, value any) error {
- encoder := json.NewEncoder(w)
- encoder.SetIndent("", " ")
- return encoder.Encode(value)
-}
-
-func writeDoctorText(w io.Writer, report doctor.Report) {
- _, _ = fmt.Fprintf(w, "doctor: %s\n", report.Status)
- for _, check := range report.Checks {
- if check.Code != "" {
- _, _ = fmt.Fprintf(w, "%s: %s (%s): %s\n", check.Status, check.Name, check.Code, check.Message)
- continue
- }
- _, _ = fmt.Fprintf(w, "%s: %s: %s\n", check.Status, check.Name, check.Message)
- }
-}
-
-func writeVMTable(w io.Writer, records []*vm.VMRecord) error {
- tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
- if _, err := fmt.Fprintln(tw, "ID\tNAME\tSTATE\tOBSERVED\tBACKEND"); err != nil {
- return err
- }
- for _, rec := range records {
- observed := string(rec.ObservedState)
- if observed == "" {
- observed = "-"
- }
- if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", rec.ID, rec.Name, rec.State, observed, rec.Backend); err != nil {
- return err
- }
- }
- return tw.Flush()
-}
-
-func writeVMEventTable(w io.Writer, events []kbruntime.VMStatusEvent, header bool) error {
- tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
- if header {
- if _, err := fmt.Fprintln(tw, "EVENT\tID\tNAME\tSTATE\tOBSERVED\tBACKEND"); err != nil {
- return err
- }
- }
- for _, event := range events {
- record := event.VM
- if record == nil {
- continue
- }
- observed := string(record.ObservedState)
- if observed == "" {
- observed = "-"
- }
- if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
- event.Event, record.ID, record.Name, record.State, observed, record.Backend); err != nil {
- return err
- }
- }
- return tw.Flush()
-}
-
-func writeJSONLine(w io.Writer, value any) error {
- return json.NewEncoder(w).Encode(value)
-}
-
-func writeImageTable(w io.Writer, records []*image.ImageRecord) error {
- tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
- if _, err := fmt.Fprintln(tw, "ID\tNAME\tSOURCE\tFORMAT\tPROFILE"); err != nil {
- return err
- }
- for _, rec := range records {
- if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
- rec.ID,
- rec.Name,
- rec.Source.Type,
- rec.RootDisk.Format,
- rec.OS.Profile,
- ); err != nil {
- return err
- }
- }
- return tw.Flush()
-}
-
-func writeNetworkTable(w io.Writer, records []kbnetwork.Record) error {
- tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
- if _, err := fmt.Fprintln(tw, "ID\tVM\tPROVIDER\tIFACE\tTAP\tMAC\tIPS\tCLEANUP"); err != nil {
- return err
- }
- for _, rec := range records {
- cleanup := "ok"
- if rec.Cleanup.Pending {
- cleanup = "pending"
- }
- if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
- rec.ID,
- rec.VMID,
- rec.Provider,
- rec.IfName,
- rec.TAP,
- rec.MAC,
- strings.Join(rec.IPs, ","),
- cleanup,
- ); err != nil {
- return err
- }
- }
- return tw.Flush()
-}
-
-func writeVMLogs(w io.Writer, logs *kbruntime.VMLogs) error {
- if len(logs.Files) == 1 {
- _, err := io.WriteString(w, logs.Files[0].Content)
- return err
- }
- for i, file := range logs.Files {
- if i > 0 {
- if _, err := fmt.Fprintln(w); err != nil {
- return err
- }
- }
- if _, err := fmt.Fprintf(w, "==> %s <==\n", file.Name); err != nil {
- return err
- }
- if _, err := io.WriteString(w, file.Content); err != nil {
- return err
- }
- if file.Content != "" && file.Content[len(file.Content)-1] != '\n' {
- if _, err := fmt.Fprintln(w); err != nil {
- return err
- }
- }
- }
- return nil
-}
-
-func writeVMLogChunk(w io.Writer, chunk kbruntime.VMLogChunk, header bool) error {
- if header {
- if _, err := fmt.Fprintf(w, "==> %s <==\n", chunk.Name); err != nil {
- return err
- }
- }
- _, err := io.WriteString(w, chunk.Content)
- return err
-}
diff --git a/internal/cli/parity_test.go b/internal/cli/parity_test.go
deleted file mode 100644
index 8918e7b..0000000
--- a/internal/cli/parity_test.go
+++ /dev/null
@@ -1,194 +0,0 @@
-package cli
-
-import (
- "bytes"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestClassifyImageSource(t *testing.T) {
- local := filepath.Join(t.TempDir(), "image.qcow2")
- if err := os.WriteFile(local, []byte("image"), 0o600); err != nil {
- t.Fatal(err)
- }
- tests := []struct {
- name string
- source string
- want imageSourceKind
- }{
- {name: "local", source: local, want: imageSourceLocal},
- {name: "HTTP", source: "https://example.com/image.qcow2", want: imageSourceHTTP},
- {name: "OCI", source: "ubuntu:24.04", want: imageSourceOCI},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- got, err := classifyImageSource(test.source)
- if err != nil {
- t.Fatal(err)
- }
- if got != test.want {
- t.Fatalf("kind = %q, want %q", got, test.want)
- }
- })
- }
- if _, err := classifyImageSource(filepath.Join(t.TempDir(), "missing.qcow2")); err == nil {
- t.Fatal("expected missing explicit path error")
- }
-}
-
-func TestImageAddImportsLocalCloudImage(t *testing.T) {
- directory := t.TempDir()
- source := filepath.Join(directory, "source.img")
- firmware := filepath.Join(directory, "firmware.fd")
- for path, content := range map[string]string{source: "image", firmware: "firmware"} {
- if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
- t.Fatal(err)
- }
- }
- command := newTestRootCommand(filepath.Join(directory, "data"))
- command.SetArgs([]string{
- "image", "add", source, "--name", "local-image", "--firmware", firmware,
- "--qemu-img", fakeQemuImgForCLI(t, directory, "qcow2", 4096, 5),
- })
- var output bytes.Buffer
- command.SetOut(&output)
- if err := command.Execute(); err != nil {
- t.Fatal(err)
- }
- var record image.ImageRecord
- if err := json.Unmarshal(output.Bytes(), &record); err != nil {
- t.Fatal(err)
- }
- if record.Name != "local-image" || record.Source.Type != "local-file" {
- t.Fatalf("image record = %+v", record)
- }
-}
-
-func TestDebugLaunchIsSideEffectFree(t *testing.T) {
- directory := t.TempDir()
- root := filepath.Join(directory, "data")
- run := filepath.Join(directory, "run")
- logDirectory := filepath.Join(directory, "log")
- image, err := image.New(root).Create(image.CreateRequest{
- Name: "debug-image", Source: image.Source{Type: "test", URI: "source.qcow2"},
- RootDisk: image.RootDisk{
- Path: "/images/source.qcow2", Format: vm.FormatQCOW2,
- VirtualSizeBytes: 1 << 20, SHA256: strings.Repeat("a", 64),
- },
- Boot: image.Boot{Mode: "uefi", Firmware: "/firmware.fd"},
- })
- if err != nil {
- t.Fatal(err)
- }
- command := newTestRootCommand(root, run, logDirectory)
- command.SetArgs([]string{"debug", "launch", image.Name, "--json", "--memory", "256M"})
- var output bytes.Buffer
- command.SetOut(&output)
- if err := command.Execute(); err != nil {
- t.Fatal(err)
- }
- var result struct {
- SchemaVersion string `json:"schemaVersion"`
- DryRun bool `json:"dryRun"`
- VM struct {
- ID string `json:"id"`
- MemoryBytes int64 `json:"memoryBytes"`
- Networks []string `json:"networks"`
- } `json:"vm"`
- Launch struct {
- Args []string `json:"args"`
- } `json:"launch"`
- }
- if err := json.Unmarshal(output.Bytes(), &result); err != nil {
- t.Fatal(err)
- }
- if result.SchemaVersion != "kumabox.debug.launch.v1" || !result.DryRun || result.VM.ID != "kb_preview" {
- t.Fatalf("debug result = %+v", result)
- }
- if result.VM.MemoryBytes != 256<<20 || len(result.VM.Networks) != 1 || result.VM.Networks[0] != "none" {
- t.Fatalf("preview VM = %+v", result.VM)
- }
- records, err := vm.New(root).List()
- if err != nil || len(records) != 0 {
- t.Fatalf("persisted VMs = %+v, err = %v", records, err)
- }
- if _, err := os.Stat(run); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("run directory stat error = %v", err)
- }
-}
-
-func TestSnapshotDirectoryCLIExportImport(t *testing.T) {
- directory := t.TempDir()
- root := filepath.Join(directory, "data")
- store := snapshot.NewStore(root)
- build, err := store.Reserve(t.Context(), "source")
- if err != nil {
- t.Fatal(err)
- }
- disk := []byte("snapshot-directory")
- diskPath := filepath.Join(build.Record().StagingDir, "disks", "root.qcow2")
- if err := os.MkdirAll(filepath.Dir(diskPath), 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(diskPath, disk, 0o600); err != nil {
- t.Fatal(err)
- }
- digest := sha256.Sum256(disk)
- manifest := snapshot.Manifest{
- SchemaVersion: "kumabox.snapshot.v1", ID: build.Record().ID, Name: "source",
- Type: "disk", Consistency: "stopped-disk",
- Disks: []snapshot.DiskManifest{{
- ID: "root", Role: "cow", Path: "disks/root.qcow2", Format: "qcow2",
- VirtualSizeBytes: int64(len(disk)), AllocatedSizeBytes: int64(len(disk)),
- SHA256: hex.EncodeToString(digest[:]), CopyStrategy: "stream",
- }},
- }
- raw, err := json.Marshal(manifest)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(build.Record().StagingDir, snapshot.ManifestFile), raw, 0o600); err != nil {
- t.Fatal(err)
- }
- if _, err := build.Finalize(int64(len(disk))); err != nil {
- t.Fatal(err)
- }
-
- exported := filepath.Join(directory, "exported")
- exportCommand := newTestRootCommand(root)
- exportCommand.SetArgs([]string{"snapshot", "export", "source", "--to-dir", exported})
- if err := exportCommand.Execute(); err != nil {
- t.Fatal(err)
- }
- importCommand := newTestRootCommand(root)
- importCommand.SetArgs([]string{
- "--qemu-img-bin", fakeImportQEMUImgForCLI(t, directory),
- "snapshot", "import", "--from-dir", exported, "--name", "imported",
- })
- if err := importCommand.Execute(); err != nil {
- t.Fatal(err)
- }
- if _, err := store.Inspect("imported"); err != nil {
- t.Fatal(err)
- }
-}
-
-func fakeImportQEMUImgForCLI(t *testing.T, directory string) string {
- t.Helper()
- path := filepath.Join(directory, "qemu-img-import")
- script := "#!/bin/sh\nset -eu\nprintf '%s\\n' '{\"format\":\"qcow2\",\"virtual-size\":18}'\n"
- if err := os.WriteFile(path, []byte(script), 0o700); err != nil {
- t.Fatal(err)
- }
- return path
-}
diff --git a/internal/cli/pci.go b/internal/cli/pci.go
deleted file mode 100644
index f644a12..0000000
--- a/internal/cli/pci.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package cli
-
-import (
- "fmt"
- "github.com/kumabox/kumabox/internal/backend"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
- "github.com/spf13/cobra"
-)
-
-func newPCIDeviceCommand(opts *rootOptions) *cobra.Command {
- cmd := &cobra.Command{Use: "device", Short: "Manage VFIO PCI devices"}
- attach := &cobra.Command{Use: "attach VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- pci, _ := cmd.Flags().GetString("pci")
- id, _ := cmd.Flags().GetString("id")
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.AttachPCIDevice(cmd.Context(), args[0], backend.PCIDeviceSpec{PCI: pci, ID: id})
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- }}
- attach.Flags().String("pci", "", "PCI BDF or sysfs path")
- attach.Flags().String("id", "", "device id")
- _ = attach.MarkFlagRequired("pci")
- detach := &cobra.Command{Use: "detach VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- id, _ := cmd.Flags().GetString("id")
- if id == "" {
- return fmt.Errorf("--id is required")
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.DetachPCIDevice(cmd.Context(), args[0], id)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- }}
- detach.Flags().String("id", "", "device id")
- list := &cobra.Command{Use: "list VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- devices, err := rt.ListPCIDevices(cmd.Context(), args[0])
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), devices)
- }}
- state := &cobra.Command{Use: "state VM", Short: "Refresh and show live hotplug device state", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.RefreshDeviceState(cmd.Context(), args[0])
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- }}
- cmd.AddCommand(attach, detach, list, state)
- return cmd
-}
diff --git a/internal/cli/requests.go b/internal/cli/requests.go
deleted file mode 100644
index 75e7945..0000000
--- a/internal/cli/requests.go
+++ /dev/null
@@ -1,247 +0,0 @@
-package cli
-
-import (
- "fmt"
- "strings"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-type createVMFlags struct {
- name string
- rootDisk string
- kernel string
- initrd string
- firmware string
- cpus int
- memory string
- storage string
- dataDisks []string
- sharedMemory bool
- networks []string
-}
-
-func addCreateVMFlags(cmd *cobra.Command, flags *createVMFlags) {
- cmd.Flags().StringVar(&flags.name, "name", "", "VM name")
- cmd.Flags().StringVar(&flags.rootDisk, "root-disk", "", "root disk path")
- cmd.Flags().StringVar(&flags.kernel, "kernel", "", "kernel image path")
- cmd.Flags().StringVar(&flags.initrd, "initrd", "", "initrd image path")
- cmd.Flags().StringVar(&flags.firmware, "firmware", "", "UEFI firmware path")
- cmd.Flags().IntVar(&flags.cpus, "cpus", 1, "number of vCPUs")
- cmd.Flags().StringVar(&flags.memory, "memory", "512M", "guest memory size, for example 512M or 2G")
- cmd.Flags().StringVar(&flags.storage, "storage", "", "per-VM writable COW size for OCI images, for example 4G")
- cmd.Flags().StringArrayVar(&flags.dataDisks, "data-disk", nil, "managed data disk: size=20G,name=workspace,fstype=ext4,mount=/workspace")
- cmd.Flags().BoolVar(&flags.sharedMemory, "shared-memory", false, "enable shared guest memory for virtio-fs")
- cmd.Flags().StringArrayVar(&flags.networks, "network", nil, "network attachment, repeatable: none, default, host-tap, cni, or cni:NAME")
- _ = cmd.MarkFlagRequired("name")
-}
-
-func newCreateRequest(flags createVMFlags, args []string, cfg config.Config) (vm.CreateRequest, error) {
- if flags.cpus < 0 {
- return vm.CreateRequest{}, fmt.Errorf("--cpus must be greater than zero")
- }
- if flags.cpus == 0 {
- flags.cpus = 1
- }
- memoryBytes, err := parseMemorySize(defaultString(flags.memory, "512M"))
- if err != nil {
- return vm.CreateRequest{}, err
- }
- if len(args) == 0 {
- if flags.rootDisk == "" {
- return vm.CreateRequest{}, fmt.Errorf("either IMAGE or --root-disk is required")
- }
- dataDisks, err := parseDataDisks(flags.dataDisks)
- if err != nil {
- return vm.CreateRequest{}, err
- }
- return vm.CreateRequest{Name: flags.name, RootDisk: flags.rootDisk, Kernel: flags.kernel, Initrd: flags.initrd, Firmware: flags.firmware, CPUs: flags.cpus, MemoryBytes: memoryBytes, Networks: normalizedNetworkFlags(flags.networks), DataDisks: dataDisks, SharedMemory: flags.sharedMemory, RunDir: cfg.Runtime.RunDir, LogDir: cfg.Runtime.LogDir}, nil
- }
- if flags.rootDisk != "" || flags.kernel != "" || flags.initrd != "" || flags.firmware != "" {
- return vm.CreateRequest{}, fmt.Errorf("IMAGE cannot be combined with --root-disk, --kernel, --initrd, or --firmware")
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return vm.CreateRequest{}, err
- }
- if stores.Metadata != nil {
- defer func() { _ = stores.Metadata.Close() }()
- }
- image, err := stores.Images.Inspect(args[0])
- if err != nil {
- return vm.CreateRequest{}, fmt.Errorf("resolve image %q: %w", args[0], err)
- }
- if image.OCI == nil && image.RootDisk.Path == "" {
- return vm.CreateRequest{}, fmt.Errorf("image %q has no root disk", args[0])
- }
- if image.OCI != nil {
- return newOCIImageCreateRequest(flags, image, cfg)
- }
- if image.RootDisk.Format != vm.FormatQCOW2 {
- return vm.CreateRequest{}, fmt.Errorf("image %q root disk format %q cannot use a qcow2 overlay", image.Name, image.RootDisk.Format)
- }
- if image.RootDisk.SHA256 == "" {
- return vm.CreateRequest{}, fmt.Errorf("image %q root disk has no pinned sha256 digest", image.Name)
- }
- digest := image.RootDisk.SHA256
- if !strings.HasPrefix(digest, "sha256:") {
- digest = "sha256:" + digest
- }
- dataDisks, err := parseDataDisks(flags.dataDisks)
- if err != nil {
- return vm.CreateRequest{}, err
- }
- req := vm.CreateRequest{Name: flags.name, RootDisk: image.RootDisk.Path, Kernel: image.Boot.Kernel, Initrd: image.Boot.Initrd, Firmware: image.Boot.Firmware, CPUs: flags.cpus, MemoryBytes: memoryBytes, Networks: normalizedNetworkFlags(flags.networks), DataDisks: dataDisks, SharedMemory: flags.sharedMemory, Image: &vm.ImageRef{ID: image.ID, Name: image.Name, RootDisk: image.RootDisk.Path, BootMode: image.Boot.Mode, Digest: digest}, StorageConfigs: []vm.StorageConfig{{ID: "root", Role: vm.StorageRoleCOW, Format: vm.FormatQCOW2, VirtualSizeBytes: image.RootDisk.VirtualSizeBytes, Base: &vm.StorageBase{Family: "cloudimg", ImageID: image.ID, Digest: digest, Format: image.RootDisk.Format, Path: image.RootDisk.Path}}}, RunDir: cfg.Runtime.RunDir, LogDir: cfg.Runtime.LogDir}
- if req.Firmware == "" && (req.Kernel == "" || req.Initrd == "") {
- return vm.CreateRequest{}, fmt.Errorf("image %q has no usable boot configuration", args[0])
- }
- return req, nil
-}
-
-func newOCIImageCreateRequest(flags createVMFlags, image *image.ImageRecord, cfg config.Config) (vm.CreateRequest, error) {
- if image.Boot.Mode != "direct" || image.Boot.Kernel == "" || image.Boot.Initrd == "" {
- return vm.CreateRequest{}, fmt.Errorf("image %q has no OCI direct boot profile", image.Name)
- }
- cowSize, err := parseByteSize(defaultString(flags.storage, defaultOCIStorageSize))
- if err != nil {
- return vm.CreateRequest{}, err
- }
- memoryBytes, err := parseMemorySize(defaultString(flags.memory, defaultOCIMemorySize))
- if err != nil {
- return vm.CreateRequest{}, err
- }
- manifestDigest := image.OCI.DigestRef
- if _, digest, found := strings.Cut(manifestDigest, "@"); found {
- manifestDigest = digest
- }
- if manifestDigest == "" {
- return vm.CreateRequest{}, fmt.Errorf("image %q has no OCI manifest digest", image.Name)
- }
- storageConfigs := make([]vm.StorageConfig, 0, len(image.OCI.Layers)+1)
- layerDigests := make([]string, 0, len(image.OCI.Layers))
- for i, layer := range image.OCI.Layers {
- if layer.EROFS == nil || layer.EROFS.Path == "" {
- return vm.CreateRequest{}, fmt.Errorf("image %q layer %d has no EROFS blob", image.Name, i)
- }
- serial := layer.Serial
- if serial == "" {
- serial = vm.LayerSerial(i)
- }
- storageConfigs = append(storageConfigs, vm.StorageConfig{ID: vm.LayerID(i), Role: vm.StorageRoleLayer, Path: layer.EROFS.Path, Readonly: true, Format: vm.FormatRaw, Serial: serial, Filesystem: vm.FilesystemEROFS, SourceLayer: layer.Digest, VirtualSizeBytes: layer.EROFS.SizeBytes})
- layerDigests = append(layerDigests, layer.Digest)
- }
- storageConfigs = append(storageConfigs, vm.StorageConfig{ID: vm.StorageIDCOW, Role: vm.StorageRoleCOW, Format: vm.FormatRaw, Serial: vm.StorageSerialCOW, Filesystem: vm.FilesystemEXT4, VirtualSizeBytes: cowSize, Base: &vm.StorageBase{Family: vm.BaseFamilyOCI, ImageID: image.ID, Digest: manifestDigest, LayerDigests: append([]string(nil), layerDigests...)}})
- dataDisks, err := parseDataDisks(flags.dataDisks)
- if err != nil {
- return vm.CreateRequest{}, err
- }
- return vm.CreateRequest{Name: flags.name, Kernel: image.Boot.Kernel, Initrd: image.Boot.Initrd, KernelCmdline: image.Boot.Cmdline, CPUs: flags.cpus, MemoryBytes: memoryBytes, Networks: normalizedOCIImageNetworkFlags(flags.networks, cfg), DataDisks: dataDisks, SharedMemory: flags.sharedMemory, StorageConfigs: storageConfigs, Image: &vm.ImageRef{ID: image.ID, Name: image.Name, RootDisk: image.RootDisk.Path, BootMode: image.Boot.Mode, Digest: manifestDigest, LayerDigests: append([]string(nil), layerDigests...)}, RunDir: cfg.Runtime.RunDir, LogDir: cfg.Runtime.LogDir}, nil
-}
-
-func parseDataDisks(values []string) ([]vm.DataDiskRequest, error) {
- if len(values) == 0 {
- return nil, nil
- }
- result := make([]vm.DataDiskRequest, 0, len(values))
- usedNames := make(map[string]struct{}, len(values))
- for _, value := range values {
- for _, part := range strings.Split(value, ",") {
- key, val, ok := strings.Cut(strings.TrimSpace(part), "=")
- if ok && strings.TrimSpace(key) == "name" && strings.TrimSpace(val) != "" {
- usedNames[strings.TrimSpace(val)] = struct{}{}
- }
- }
- }
- for _, value := range values {
- var disk vm.DataDiskRequest
- seenKeys := make(map[string]struct{})
- for _, part := range strings.Split(value, ",") {
- key, val, ok := strings.Cut(strings.TrimSpace(part), "=")
- if !ok || (strings.TrimSpace(val) == "" && strings.TrimSpace(key) != "mount") {
- return nil, fmt.Errorf("--data-disk expects key=value fields: %q", value)
- }
- key = strings.TrimSpace(key)
- if _, exists := seenKeys[key]; exists {
- return nil, fmt.Errorf("--data-disk field %q repeated", key)
- }
- seenKeys[key] = struct{}{}
- switch key {
- case "name":
- disk.Name = strings.TrimSpace(val)
- case "size":
- size, err := parsePositiveByteSize("--data-disk size", val)
- if err != nil {
- return nil, err
- }
- if size < 16<<20 {
- return nil, fmt.Errorf("--data-disk size %s is below the 16M minimum", val)
- }
- disk.SizeBytes = size
- case "fstype":
- disk.Filesystem = strings.TrimSpace(val)
- if disk.Filesystem != vm.FilesystemEXT4 && disk.Filesystem != vm.FilesystemNone {
- return nil, fmt.Errorf("--data-disk: unsupported fstype %q", disk.Filesystem)
- }
- case "mount":
- disk.MountPoint = strings.TrimSpace(val)
- disk.MountSet = true
- case "directio":
- parsed, err := parseOptionalBool(val)
- if err != nil {
- return nil, fmt.Errorf("--data-disk directio: %w", err)
- }
- disk.DirectIO = parsed
- default:
- return nil, fmt.Errorf("--data-disk has unknown field %q", key)
- }
- }
- explicitName := disk.Name != ""
- if disk.Name == "" {
- for index := 1; ; index++ {
- candidate := fmt.Sprintf("data%d", index)
- if _, exists := usedNames[candidate]; !exists {
- disk.Name = candidate
- usedNames[candidate] = struct{}{}
- break
- }
- }
- }
- if explicitName && countDataDiskName(values, disk.Name) > 1 {
- return nil, fmt.Errorf("--data-disk name %q duplicated", disk.Name)
- }
- result = append(result, disk)
- }
- return result, nil
-}
-
-func countDataDiskName(values []string, name string) int {
- count := 0
- for _, value := range values {
- for _, part := range strings.Split(value, ",") {
- key, val, ok := strings.Cut(strings.TrimSpace(part), "=")
- if ok && strings.TrimSpace(key) == "name" && strings.TrimSpace(val) == name {
- count++
- }
- }
- }
- return count
-}
-
-func parseOptionalBool(value string) (*bool, error) {
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "on", "true", "yes":
- parsed := true
- return &parsed, nil
- case "off", "false", "no":
- parsed := false
- return &parsed, nil
- case "auto":
- return nil, nil
- default:
- return nil, fmt.Errorf("expected on, off, or auto")
- }
-}
diff --git a/internal/cli/resource_batch.go b/internal/cli/resource_batch.go
deleted file mode 100644
index fc27d6e..0000000
--- a/internal/cli/resource_batch.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package cli
-
-import (
- "fmt"
-
- "github.com/kumabox/kumabox/internal/batch"
-)
-
-type resourceBatchFailure = batch.Failure
-
-func writeResourceBatchResult[T any](cmdOutput func(any) error, refs []string, operation string, result batch.Result[T]) error {
- if len(refs) == 1 {
- if err := result.Err(); err != nil {
- return err
- }
- return cmdOutput(result.Succeeded[0])
- }
- if err := cmdOutput(struct {
- Succeeded []T `json:"succeeded"`
- Failed []batch.Failure `json:"failed,omitempty"`
- }{Succeeded: result.Succeeded, Failed: result.Failed}); err != nil {
- return err
- }
- if err := result.Err(); err != nil {
- return fmt.Errorf("%s: %w", operation, err)
- }
- return nil
-}
diff --git a/internal/cli/restore.go b/internal/cli/restore.go
deleted file mode 100644
index a78a827..0000000
--- a/internal/cli/restore.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package cli
-
-import (
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/config"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func newRestoreCommand(opts *rootOptions) *cobra.Command {
- var mode string
- cmd := &cobra.Command{
- Use: "restore VM SNAPSHOT",
- Short: "Restore a native running snapshot into its original VM",
- Args: cobra.ExactArgs(2),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if err := config.EnsureRuntimeDirs(cfg); err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.RestoreNativeVM(cmd.Context(), args[0], args[1], kbruntime.NativeRestoreOptions{Mode: kbruntime.RestoreMode(mode)})
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- },
- }
- cmd.Flags().StringVar(&mode, "restore-mode", "copy", "memory restore mode: copy, ondemand, or mmap")
- return cmd
-}
diff --git a/internal/cli/root.go b/internal/cli/root.go
deleted file mode 100644
index f1a9cd4..0000000
--- a/internal/cli/root.go
+++ /dev/null
@@ -1,213 +0,0 @@
-package cli
-
-import (
- "fmt"
- "strings"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/config"
-)
-
-type rootOptions struct {
- configPath string
- configOverride *config.Config
- cloudHypervisorBin string
- qemuImgBin string
- metadataBackend string
- metadataPath string
-}
-
-func NewRootCommand() *cobra.Command {
- opts := &rootOptions{}
- return newRootCommand(opts)
-}
-
-// NewRootCommandWithConfig creates a command with an injected configuration.
-// It is intended for embedding and tests that need isolated storage roots.
-func NewRootCommandWithConfig(cfg config.Config) *cobra.Command {
- return newRootCommand(&rootOptions{configOverride: &cfg})
-}
-
-func newRootCommand(opts *rootOptions) *cobra.Command {
-
- cmd := &cobra.Command{
- Use: "kumabox",
- Short: "KumaBox microVM sandbox runtime",
- SilenceUsage: true,
- SilenceErrors: true,
- }
-
- cmd.PersistentFlags().StringVar(&opts.configPath, "config", "", "config file path")
- cmd.PersistentFlags().StringVar(&opts.cloudHypervisorBin, "cloud-hypervisor-bin", "", "cloud-hypervisor binary path")
- cmd.PersistentFlags().StringVar(&opts.qemuImgBin, "qemu-img-bin", "", "qemu-img binary path")
- cmd.PersistentFlags().StringVar(&opts.metadataBackend, "metadata-backend", "", "metadata backend: json or sqlite")
- cmd.PersistentFlags().StringVar(&opts.metadataPath, "metadata-path", "", "SQLite metadata database path")
-
- cmd.AddCommand(newVersionCommand())
- cmd.AddCommand(newDoctorCommand(opts))
- cmd.AddCommand(newCreateCommand(opts))
- cmd.AddCommand(newRunCommand(opts))
- cmd.AddCommand(newStartCommand(opts))
- cmd.AddCommand(newStopCommand(opts))
- cmd.AddCommand(newPauseCommand(opts))
- cmd.AddCommand(newResumeCommand(opts))
- cmd.AddCommand(newRestoreCommand(opts))
- cmd.AddCommand(newCloneCommand(opts))
- cmd.AddCommand(newHibernateCommand(opts))
- cmd.AddCommand(newInspectCommand(opts))
- cmd.AddCommand(newLogsCommand(opts))
- cmd.AddCommand(newConsoleCommand(opts))
- cmd.AddCommand(newDeleteCommand(opts))
- cmd.AddCommand(newGCCommand(opts))
- cmd.AddCommand(newImageCommand(opts))
- cmd.AddCommand(newSnapshotCommand(opts))
- cmd.AddCommand(newNetworkCommand(opts))
- cmd.AddCommand(newDiskCommand(opts))
- cmd.AddCommand(newFilesystemCommand(opts))
- cmd.AddCommand(newPCIDeviceCommand(opts))
- cmd.AddCommand(newAgentCommand(opts))
- cmd.AddCommand(newExecCommand(opts))
- cmd.AddCommand(newPSCommand(opts))
- cmd.AddCommand(newUsageCommand(opts))
- cmd.AddCommand(newMetadataCommand(opts))
- cmd.AddCommand(newDebugCommand(opts))
- cmd.AddCommand(newCompletionCommand())
- configureResourceCompletions(cmd, opts)
- return cmd
-}
-
-func newCompletionCommand() *cobra.Command {
- return &cobra.Command{
- Use: "completion [bash|zsh|fish|powershell]",
- Short: "Generate a shell completion script",
- Args: cobra.ExactArgs(1),
- ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
- RunE: func(cmd *cobra.Command, args []string) error {
- switch args[0] {
- case "bash":
- return cmd.Root().GenBashCompletion(cmd.OutOrStdout())
- case "zsh":
- return cmd.Root().GenZshCompletion(cmd.OutOrStdout())
- case "fish":
- return cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true)
- case "powershell":
- return cmd.Root().GenPowerShellCompletionWithDesc(cmd.OutOrStdout())
- default:
- return fmt.Errorf("unsupported shell %q", args[0])
- }
- },
- }
-}
-
-func configureResourceCompletions(root *cobra.Command, opts *rootOptions) {
- for _, command := range root.Commands() {
- configureResourceCompletions(command, opts)
- if _, ok := commandResourceKind(command.Use, 0); command.ValidArgsFunction == nil && ok {
- command.ValidArgsFunction = completeResources(opts)
- }
- }
-}
-
-func commandResourceKind(use string, argIndex int) (string, bool) {
- fields := strings.Fields(use)
- if len(fields) < 2 {
- return "", false
- }
- arguments := fields[1:]
- if argIndex >= len(arguments) {
- last := strings.Trim(arguments[len(arguments)-1], "[]")
- if !strings.HasSuffix(last, "...") {
- return "", false
- }
- argIndex = len(arguments) - 1
- }
- field := strings.Trim(arguments[argIndex], "[]")
- field = strings.TrimSuffix(field, "...")
- switch field {
- case "VM", "IMAGE", "SNAPSHOT":
- return strings.ToLower(field), true
- default:
- return "", false
- }
-}
-
-func completeResources(opts *rootOptions) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
- return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- kind, ok := commandResourceKind(cmd.Use, len(args))
- if !ok {
- return nil, cobra.ShellCompDirectiveDefault
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
- if stores.Metadata != nil {
- defer func() { _ = stores.Metadata.Close() }()
- }
- var candidates []string
- switch kind {
- case "vm":
- records, listErr := stores.VM.List()
- if listErr != nil {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
- for _, record := range records {
- candidates = append(candidates, record.Name)
- }
- case "image":
- records, listErr := stores.Images.List()
- if listErr != nil {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
- for _, record := range records {
- candidates = append(candidates, record.Name)
- }
- case "snapshot":
- records, listErr := stores.Snapshots.List()
- if listErr != nil {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
- for _, record := range records {
- candidates = append(candidates, record.Name)
- }
- }
- filtered := candidates[:0]
- for _, candidate := range candidates {
- if strings.HasPrefix(candidate, toComplete) {
- filtered = append(filtered, candidate)
- }
- }
- return filtered, cobra.ShellCompDirectiveNoFileComp
- }
-}
-
-func loadConfig(opts *rootOptions) (config.Config, error) {
- if opts.configOverride != nil {
- cfg := *opts.configOverride
- if opts.cloudHypervisorBin != "" {
- cfg.Backend.CloudHypervisor.Binary = opts.cloudHypervisorBin
- }
- if opts.qemuImgBin != "" {
- cfg.Storage.QEMUImgBinary = opts.qemuImgBin
- }
- if opts.metadataBackend != "" {
- cfg.Metadata.Backend = opts.metadataBackend
- }
- if opts.metadataPath != "" {
- cfg.Metadata.Path = opts.metadataPath
- }
- return cfg, nil
- }
- overrides := config.Overrides{
- CloudHypervisorBin: opts.cloudHypervisorBin,
- QEMUImgBinary: opts.qemuImgBin,
- MetadataBackend: opts.metadataBackend,
- MetadataPath: opts.metadataPath,
- }
- return config.Load(opts.configPath, overrides)
-}
diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go
deleted file mode 100644
index cbf7f84..0000000
--- a/internal/cli/root_test.go
+++ /dev/null
@@ -1,1463 +0,0 @@
-package cli
-
-import (
- "bytes"
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "testing"
- "time"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/lock"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/reference"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/state"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func newTestRootCommand(rootDir string, paths ...string) *cobra.Command {
- cfg := config.Default()
- cfg.Runtime.RootDir = rootDir
- cfg.Runtime.RunDir = filepath.Join(rootDir, "run")
- cfg.Runtime.LogDir = filepath.Join(rootDir, "log")
- if len(paths) > 0 {
- cfg.Runtime.RunDir = paths[0]
- }
- if len(paths) > 1 {
- cfg.Runtime.LogDir = paths[1]
- }
- return NewRootCommandWithConfig(cfg)
-}
-
-func TestVersionJSONCommand(t *testing.T) {
- cmd := NewRootCommand()
- var out bytes.Buffer
- cmd.SetOut(&out)
- cmd.SetArgs([]string{"version", "--json"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var payload map[string]string
- if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
- t.Fatal(err)
- }
- if payload["version"] == "" {
- t.Fatal("version must not be empty")
- }
-}
-
-func TestRootCommandRejectsRuntimePathFlags(t *testing.T) {
- cmd := NewRootCommand()
- cmd.SetArgs([]string{"--root-dir", "/tmp/ignored", "version"})
- err := cmd.Execute()
- if err == nil || !strings.Contains(err.Error(), "unknown flag: --root-dir") {
- t.Fatalf("expected root-dir to be rejected, got %v", err)
- }
-}
-
-func TestConfiguredQEMUImgPrecedence(t *testing.T) {
- t.Parallel()
-
- cfg := config.Default()
- cfg.Storage.QEMUImgBinary = "/configured/qemu-img"
- if got := configuredQEMUImg("", cfg); got != cfg.Storage.QEMUImgBinary {
- t.Fatalf("configuredQEMUImg() = %q, want configured binary", got)
- }
- if got := configuredQEMUImg("/command/qemu-img", cfg); got != "/command/qemu-img" {
- t.Fatalf("configuredQEMUImg() = %q, want command override", got)
- }
-}
-
-func TestDoctorInitializesConfiguredDirectories(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
-
- cmd := newTestRootCommand(rootDir, runDir, logDir)
- var out bytes.Buffer
- cmd.SetOut(&out)
- cmd.SetArgs([]string{"doctor", "--json"})
-
- _ = cmd.Execute()
-
- for _, path := range []string{rootDir, runDir, logDir} {
- info, err := os.Stat(path)
- if err != nil {
- t.Fatal(err)
- }
- if !info.IsDir() {
- t.Fatalf("%s is not a directory", path)
- }
- }
-
- var payload struct {
- Checks []struct {
- Name string `json:"name"`
- Status string `json:"status"`
- } `json:"checks"`
- }
- if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
- t.Fatal(err)
- }
-
- for _, check := range payload.Checks {
- if check.Name == "paths" && check.Status == "pass" {
- return
- }
- }
- t.Fatal("doctor output did not include passing paths check")
-}
-
-func TestNetworkLSJSONReturnsEmptyListWithoutIndex(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- cmd := newTestRootCommand(rootDir)
- var out bytes.Buffer
- cmd.SetOut(&out)
- cmd.SetArgs([]string{"network", "ls", "--json"})
-
- if err := cmd.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var records []map[string]any
- if err := json.Unmarshal(out.Bytes(), &records); err != nil {
- t.Fatal(err)
- }
- if len(records) != 0 {
- t.Fatalf("records = %d, want 0", len(records))
- }
-}
-
-func TestNetworkInspectResolvesVMName(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
- store := vm.New(rootDir)
- rec, err := store.Create(vm.CreateRequest{
- Name: "p2-inspect",
- RootDisk: "fixtures/base.qcow2",
- Kernel: "fixtures/vmlinuz",
- Initrd: "fixtures/initrd.img",
- RunDir: runDir,
- LogDir: logDir,
- Network: "default",
- })
- if err != nil {
- t.Fatal(err)
- }
- cfg := kbnetwork.Config{
- ID: kbnetwork.NetworkID(rec.ID, 0),
- NetworkName: "default",
- TAP: "kbtaptest",
- MAC: "5a:00:00:00:00:01",
- Backend: kbnetwork.ProviderHostTap,
- BridgeDev: "kumabox0",
- Network: &kbnetwork.GuestInfo{
- IP: "10.88.0.2",
- Gateway: "10.88.0.1",
- Prefix: 16,
- DNS: []string{"1.1.1.1"},
- },
- }
- if _, err := store.SetNetworkConfigs(rec.ID, []kbnetwork.Config{cfg}); err != nil {
- t.Fatal(err)
- }
- if err := kbnetwork.NewStore(rootDir).UpsertRecord(kbnetwork.Record{
- ID: cfg.ID,
- VMID: rec.ID,
- Network: "default",
- Provider: kbnetwork.ProviderHostTap,
- IfName: "eth0",
- TAP: cfg.TAP,
- MAC: cfg.MAC,
- BridgeDev: cfg.BridgeDev,
- IPs: []string{"10.88.0.2/16"},
- Gateway: "10.88.0.1",
- DNS: []string{"1.1.1.1"},
- }); err != nil {
- t.Fatal(err)
- }
-
- cmd := newTestRootCommand(rootDir, runDir, logDir)
- var out bytes.Buffer
- cmd.SetOut(&out)
- cmd.SetArgs([]string{"network", "inspect", "p2-inspect", "--json"})
- if err := cmd.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var result struct {
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
- Interfaces []kbnetwork.Record `json:"interfaces"`
- VMConfigs []kbnetwork.Config `json:"vmConfigs"`
- Drift []string `json:"drift"`
- }
- if err := json.Unmarshal(out.Bytes(), &result); err != nil {
- t.Fatal(err)
- }
- if result.VMID != rec.ID || result.VMName != "p2-inspect" {
- t.Fatalf("unexpected inspect identity: %+v", result)
- }
- if len(result.Interfaces) != 1 || len(result.VMConfigs) != 1 || len(result.Drift) != 0 {
- t.Fatalf("unexpected inspect result: %+v", result)
- }
-}
-
-func TestCreateInspectAndPSCommands(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
-
- create := newTestRootCommand(rootDir, runDir, logDir)
- create.SetArgs([]string{
- "--cloud-hypervisor-bin", "/custom/bin/cloud-hypervisor",
- "create",
- "--name", "p0-store",
- "--root-disk", "fixtures/base.qcow2",
- "--kernel", "fixtures/vmlinuz",
- "--initrd", "fixtures/initrd.img",
- })
- var createOut bytes.Buffer
- create.SetOut(&createOut)
- if err := create.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var created struct {
- ID string `json:"id"`
- Name string `json:"name"`
- State string `json:"state"`
- Config string `json:"config"`
- }
- if err := json.Unmarshal(createOut.Bytes(), &created); err != nil {
- t.Fatal(err)
- }
- if created.ID == "" || created.Name != "p0-store" || created.State != "created" {
- t.Fatalf("unexpected create output: %+v", created)
- }
- if created.Config == "" {
- t.Fatal("expected rendered backend config path")
- }
- if _, err := os.Stat(created.Config); err != nil {
- t.Fatal(err)
- }
- rawConfig, err := os.ReadFile(created.Config)
- if err != nil {
- t.Fatal(err)
- }
- var renderedConfig struct {
- Binary string `json:"binary"`
- }
- if err := json.Unmarshal(rawConfig, &renderedConfig); err != nil {
- t.Fatal(err)
- }
- if renderedConfig.Binary != "/custom/bin/cloud-hypervisor" {
- t.Fatalf("rendered binary = %s", renderedConfig.Binary)
- }
-
- inspect := newTestRootCommand(rootDir, runDir, logDir)
- inspect.SetArgs([]string{"inspect", "p0-store", "--json"})
- var inspectOut bytes.Buffer
- inspect.SetOut(&inspectOut)
- if err := inspect.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var inspected struct {
- ID string `json:"id"`
- }
- if err := json.Unmarshal(inspectOut.Bytes(), &inspected); err != nil {
- t.Fatal(err)
- }
- if inspected.ID != created.ID {
- t.Fatalf("inspect id = %s, want %s", inspected.ID, created.ID)
- }
-
- ps := newTestRootCommand(rootDir, runDir, logDir)
- ps.SetArgs([]string{"ps", "--json"})
- var psOut bytes.Buffer
- ps.SetOut(&psOut)
- if err := ps.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var records []struct {
- ID string `json:"id"`
- }
- if err := json.Unmarshal(psOut.Bytes(), &records); err != nil {
- t.Fatal(err)
- }
- if len(records) != 1 || records[0].ID != created.ID {
- t.Fatalf("ps records = %+v", records)
- }
-}
-
-func TestNewCreateRequestPreservesRepeatedNetworks(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- req, err := newCreateRequest(createVMFlags{
- name: "multi-net",
- rootDisk: "fixtures/base.qcow2",
- firmware: "fixtures/CLOUDHV.fd",
- cpus: 3,
- networks: []string{"cni:front", "cni:back"},
- }, nil, cfg)
- if err != nil {
- t.Fatal(err)
- }
- if len(req.Networks) != 2 || req.Networks[0] != "cni:front" || req.Networks[1] != "cni:back" {
- t.Fatalf("networks = %#v", req.Networks)
- }
- if req.CPUs != 3 {
- t.Fatalf("cpus = %d", req.CPUs)
- }
-}
-
-func TestCreateRejectsMixedNetworkProviderFamilies(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
-
- cmd := newTestRootCommand(rootDir, runDir, logDir)
- cmd.SetArgs([]string{
- "create",
- "--name", "mixed-net",
- "--root-disk", "fixtures/base.qcow2",
- "--kernel", "fixtures/vmlinuz",
- "--initrd", "fixtures/initrd.img",
- "--network", "default",
- "--network", "cni:isolated",
- })
- var out bytes.Buffer
- cmd.SetOut(&out)
- err := cmd.Execute()
- if err == nil || !strings.Contains(err.Error(), "same provider family") {
- t.Fatalf("create error = %v", err)
- }
-}
-
-func TestCreateRejectsDuplicateName(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
- args := []string{
- "create",
- "--name", "duplicate",
- "--root-disk", "fixtures/base.qcow2",
- "--kernel", "fixtures/vmlinuz",
- "--initrd", "fixtures/initrd.img",
- }
-
- first := newTestRootCommand(rootDir, runDir, logDir)
- first.SetArgs(args)
- if err := first.Execute(); err != nil {
- t.Fatal(err)
- }
-
- second := newTestRootCommand(rootDir, runDir, logDir)
- second.SetArgs(args)
- if err := second.Execute(); err == nil {
- t.Fatal("expected duplicate name error")
- }
-}
-
-func TestCreateFirmwareBootCommand(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
-
- create := newTestRootCommand(rootDir, runDir, logDir)
- create.SetArgs([]string{
- "create",
- "--name", "uefi",
- "--root-disk", "fixtures/ubuntu.img",
- "--firmware", "fixtures/CLOUDHV.fd",
- })
- var out bytes.Buffer
- create.SetOut(&out)
- if err := create.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var created struct {
- Config string `json:"config"`
- Firmware string `json:"firmware"`
- Metadata struct {
- Type string `json:"type"`
- CidataDir string `json:"cidataDir"`
- CidataDisk string `json:"cidataDisk"`
- } `json:"metadata"`
- }
- if err := json.Unmarshal(out.Bytes(), &created); err != nil {
- t.Fatal(err)
- }
- if created.Firmware == "" {
- t.Fatal("expected firmware in create output")
- }
- if created.Metadata.Type != "nocloud" || created.Metadata.CidataDisk == "" {
- t.Fatalf("metadata = %+v", created.Metadata)
- }
-
- rawConfig, err := os.ReadFile(created.Config)
- if err != nil {
- t.Fatal(err)
- }
- var rendered struct {
- Firmware *struct {
- Path string `json:"path"`
- } `json:"firmware"`
- Kernel any `json:"kernel"`
- Disks []struct {
- Path string `json:"path"`
- Readonly bool `json:"readonly"`
- ImageType string `json:"imageType"`
- } `json:"disks"`
- }
- if err := json.Unmarshal(rawConfig, &rendered); err != nil {
- t.Fatal(err)
- }
- if rendered.Firmware == nil || rendered.Firmware.Path == "" {
- t.Fatalf("rendered firmware = %+v", rendered.Firmware)
- }
- if rendered.Kernel != nil {
- t.Fatalf("expected no direct kernel payload: %+v", rendered.Kernel)
- }
- if len(rendered.Disks) != 2 {
- t.Fatalf("disks = %+v", rendered.Disks)
- }
- if rendered.Disks[1].Path != created.Metadata.CidataDisk || !rendered.Disks[1].Readonly || rendered.Disks[1].ImageType != "raw" {
- t.Fatalf("metadata disk = %+v", rendered.Disks[1])
- }
- for _, name := range []string{"meta-data", "user-data", "network-config"} {
- if _, err := os.Stat(filepath.Join(created.Metadata.CidataDir, name)); err != nil {
- t.Fatalf("%s missing: %v", name, err)
- }
- }
-}
-
-func TestCreateImageRefCommand(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
- rootDisk := filepath.Join(rootDir, "cloudimg", "img_test", "base.qcow2")
- firmware := filepath.Join(dir, "fixtures", "CLOUDHV.fd")
- if err := os.MkdirAll(filepath.Dir(rootDisk), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(rootDisk, []byte("managed image"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Dir(firmware), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(firmware, []byte("firmware"), 0o644); err != nil {
- t.Fatal(err)
- }
- imageRecord, err := image.New(rootDir).Create(image.CreateRequest{
- Name: "ubuntu",
- Source: image.Source{Type: "test", URI: rootDisk},
- RootDisk: image.RootDisk{
- Path: rootDisk,
- Format: "qcow2",
- VirtualSizeBytes: 1024 * 1024,
- SHA256: hex.EncodeToString(sha256.New().Sum(nil)),
- },
- Boot: image.Boot{Mode: "uefi", Firmware: firmware},
- OS: image.OS{Family: "ubuntu", Profile: "ubuntu-cloudimg"},
- })
- if err != nil {
- t.Fatal(err)
- }
-
- create := newTestRootCommand(rootDir, runDir, logDir)
- create.SetArgs([]string{
- "--qemu-img-bin", fakeQEMUImgForOverlay(t, dir, rootDisk),
- "create", "ubuntu",
- "--name", "from-image",
- })
- var out bytes.Buffer
- create.SetOut(&out)
- if err := create.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var created struct {
- RootDisk string `json:"rootDisk"`
- Firmware string `json:"firmware"`
- Config string `json:"config"`
- Image struct {
- ID string `json:"id"`
- Name string `json:"name"`
- RootDisk string `json:"rootDisk"`
- BootMode string `json:"bootMode"`
- } `json:"image"`
- }
- if err := json.Unmarshal(out.Bytes(), &created); err != nil {
- t.Fatal(err)
- }
- if created.RootDisk == rootDisk || !strings.HasSuffix(created.RootDisk, "root.overlay.qcow2") || created.Firmware != firmware {
- t.Fatalf("boot fields = root %s firmware %s", created.RootDisk, created.Firmware)
- }
- if created.Image.ID != imageRecord.ID || created.Image.Name != "ubuntu" || created.Image.RootDisk != rootDisk {
- t.Fatalf("image ref = %+v", created.Image)
- }
- if created.Image.BootMode != "uefi" {
- t.Fatalf("image boot mode = %s", created.Image.BootMode)
- }
-
- rawConfig, err := os.ReadFile(created.Config)
- if err != nil {
- t.Fatal(err)
- }
- var rendered struct {
- Disks []struct {
- Path string `json:"path"`
- } `json:"disks"`
- }
- if err := json.Unmarshal(rawConfig, &rendered); err != nil {
- t.Fatal(err)
- }
- if len(rendered.Disks) == 0 || rendered.Disks[0].Path != created.RootDisk {
- t.Fatalf("rendered disks = %+v", rendered.Disks)
- }
-}
-
-func TestNewCreateRequestSupportsOCIImageStorage(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- req, err := newOCIImageCreateRequest(createVMFlags{
- name: "oci-vm",
- storage: "8M",
- cpus: 2,
- }, &image.ImageRecord{
- ID: "img_oci",
- Name: "oci-image",
- Boot: image.Boot{
- Mode: "direct",
- Kernel: filepath.Join(dir, "vmlinuz"),
- Initrd: filepath.Join(dir, "initrd.img"),
- Cmdline: "kumabox.layers={{layers}} kumabox.cow={{cow}}",
- },
- OCI: &image.OCI{
- DigestRef: "index.docker.io/kumabox/ubuntu@sha256:" + strings.Repeat("b", 64),
- Layers: []image.OCILayer{
- {
- Index: 0,
- Digest: "sha256:" + strings.Repeat("a", 64),
- EROFS: &image.EROFSLayer{
- Path: filepath.Join(dir, "layer0.erofs"),
- SizeBytes: 4096,
- },
- },
- },
- },
- }, cfg)
- if err != nil {
- t.Fatal(err)
- }
- if req.RootDisk != "" || req.Kernel == "" || req.Initrd == "" || req.KernelCmdline == "" {
- t.Fatalf("unexpected boot request: %+v", req)
- }
- if len(req.StorageConfigs) != 2 {
- t.Fatalf("storage configs = %+v", req.StorageConfigs)
- }
- if req.StorageConfigs[0].Role != vm.StorageRoleLayer || !req.StorageConfigs[0].Readonly || req.StorageConfigs[0].Serial != "kumabox-layer0" {
- t.Fatalf("layer storage = %+v", req.StorageConfigs[0])
- }
- if req.StorageConfigs[1].Role != vm.StorageRoleCOW || req.StorageConfigs[1].VirtualSizeBytes != 8*1024*1024 || req.StorageConfigs[1].Serial != "kumabox-cow" || req.StorageConfigs[1].Base == nil {
- t.Fatalf("cow storage = %+v", req.StorageConfigs[1])
- }
- if req.StorageConfigs[1].Base.Digest != "sha256:"+strings.Repeat("b", 64) {
- t.Fatalf("base digest = %q", req.StorageConfigs[1].Base.Digest)
- }
- if len(req.Networks) != 1 || req.Networks[0] != "cni:kumabox" {
- t.Fatalf("OCI default network = %#v", req.Networks)
- }
-}
-
-func TestLogsCommandTailsVMLogs(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
-
- create := newTestRootCommand(rootDir, runDir, logDir)
- create.SetArgs([]string{
- "create",
- "--name", "loggy",
- "--root-disk", "fixtures/base.qcow2",
- "--kernel", "fixtures/vmlinuz",
- "--initrd", "fixtures/initrd.img",
- })
- var createOut bytes.Buffer
- create.SetOut(&createOut)
- if err := create.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var created struct {
- LogDir string `json:"logDir"`
- }
- if err := json.Unmarshal(createOut.Bytes(), &created); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(created.LogDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(created.LogDir, "cloud-hypervisor.stdout.log"), []byte("line-1\nline-2\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(created.LogDir, "cloud-hypervisor.stderr.log"), []byte("err-1\nerr-2\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(created.LogDir, "console.log"), []byte("console-1\nconsole-2\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- logs := newTestRootCommand(rootDir, runDir, logDir)
- logs.SetArgs([]string{"logs", "loggy", "--tail", "1"})
- var logsOut bytes.Buffer
- logs.SetOut(&logsOut)
- if err := logs.Execute(); err != nil {
- t.Fatal(err)
- }
-
- got := logsOut.String()
- if strings.Contains(got, "==>") {
- t.Fatalf("single-source logs should not include section headers: %s", got)
- }
- if strings.Contains(got, "console-1") || !strings.Contains(got, "console-2") {
- t.Fatalf("logs output did not tail console: %s", got)
- }
-
- vmmLogs := newTestRootCommand(rootDir, runDir, logDir)
- vmmLogs.SetArgs([]string{"logs", "loggy", "--source", "vmm", "--tail", "1"})
- var vmmLogsOut bytes.Buffer
- vmmLogs.SetOut(&vmmLogsOut)
- if err := vmmLogs.Execute(); err != nil {
- t.Fatal(err)
- }
-
- vmmGot := vmmLogsOut.String()
- if !strings.Contains(vmmGot, "==> cloud-hypervisor.stdout.log <==") {
- t.Fatalf("logs output missing stdout header: %s", vmmGot)
- }
- if strings.Contains(vmmGot, "line-1") || !strings.Contains(vmmGot, "line-2") {
- t.Fatalf("logs output did not tail stdout: %s", vmmGot)
- }
- if strings.Contains(vmmGot, "err-1") || !strings.Contains(vmmGot, "err-2") {
- t.Fatalf("logs output did not tail stderr: %s", vmmGot)
- }
-}
-
-func TestDeleteCommandRemovesVMRecord(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
- rootDisk := filepath.Join(dir, "fixtures", "base.qcow2")
- if err := os.MkdirAll(filepath.Dir(rootDisk), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(rootDisk, []byte("root disk"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- create := newTestRootCommand(rootDir, runDir, logDir)
- create.SetArgs([]string{
- "create",
- "--name", "delete-cli",
- "--root-disk", rootDisk,
- "--kernel", "fixtures/vmlinuz",
- "--initrd", "fixtures/initrd.img",
- })
- if err := create.Execute(); err != nil {
- t.Fatal(err)
- }
-
- del := newTestRootCommand(rootDir, runDir, logDir)
- del.SetArgs([]string{"delete", "delete-cli"})
- var delOut bytes.Buffer
- del.SetOut(&delOut)
- if err := del.Execute(); err != nil {
- t.Fatal(err)
- }
- var deleted struct {
- Name string `json:"name"`
- RootDisk string `json:"rootDisk"`
- }
- if err := json.Unmarshal(delOut.Bytes(), &deleted); err != nil {
- t.Fatal(err)
- }
- if deleted.Name != "delete-cli" || deleted.RootDisk != rootDisk {
- t.Fatalf("deleted payload = %+v", deleted)
- }
- if _, err := os.Stat(rootDisk); err != nil {
- t.Fatalf("root disk should remain: %v", err)
- }
-
- ps := newTestRootCommand(rootDir, runDir, logDir)
- ps.SetArgs([]string{"ps", "--json"})
- var psOut bytes.Buffer
- ps.SetOut(&psOut)
- if err := ps.Execute(); err != nil {
- t.Fatal(err)
- }
- var records []struct {
- Name string `json:"name"`
- }
- if err := json.Unmarshal(psOut.Bytes(), &records); err != nil {
- t.Fatal(err)
- }
- if len(records) != 0 {
- t.Fatalf("expected no records after delete, got %+v", records)
- }
-}
-
-func TestDeleteCommandBestEffortBatchResult(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
- store := vm.New(rootDir)
- wantIDs := make([]string, 0, 2)
-
- for _, name := range []string{"batch-a", "batch-b"} {
- record, err := store.Create(vm.CreateRequest{
- Name: name,
- RootDisk: filepath.Join(dir, name+".qcow2"),
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(runDir, name),
- LogDir: filepath.Join(logDir, name),
- })
- if err != nil {
- t.Fatal(err)
- }
- wantIDs = append(wantIDs, record.ID)
- }
-
- cmd := newTestRootCommand(rootDir, runDir, logDir)
- cmd.SetArgs([]string{"delete", "batch-a", "missing", "batch-b", "--concurrency", "2"})
- var out bytes.Buffer
- cmd.SetOut(&out)
- err := cmd.Execute()
- if err == nil || !strings.Contains(err.Error(), "delete: VM missing") {
- t.Fatalf("error = %v", err)
- }
-
- var result struct {
- Succeeded []string `json:"succeeded"`
- Failed []struct {
- Ref string `json:"ref"`
- Error string `json:"error"`
- } `json:"failed"`
- }
- if err := json.Unmarshal(out.Bytes(), &result); err != nil {
- t.Fatal(err)
- }
- if len(result.Succeeded) != 2 || result.Succeeded[0] != wantIDs[0] || result.Succeeded[1] != wantIDs[1] {
- t.Fatalf("succeeded = %+v", result.Succeeded)
- }
- if len(result.Failed) != 1 || result.Failed[0].Ref != "missing" || result.Failed[0].Error == "" {
- t.Fatalf("failed = %+v", result.Failed)
- }
- if records, err := store.List(); err != nil || len(records) != 0 {
- t.Fatalf("remaining records = %+v, error = %v", records, err)
- }
-}
-
-func TestLifecycleCommandRejectsNegativeConcurrency(t *testing.T) {
- cmd := newTestRootCommand(t.TempDir())
- cmd.SetArgs([]string{"start", "vm-a", "--concurrency", "-1"})
- err := cmd.Execute()
- if err == nil || err.Error() != "concurrency must be greater than or equal to zero" {
- t.Fatalf("error = %v", err)
- }
-}
-
-func TestLifecycleCommandsAcceptBatchAndExposeConcurrency(t *testing.T) {
- opts := &rootOptions{}
- commands := []*cobra.Command{
- newStartCommand(opts),
- newStopCommand(opts),
- newPauseCommand(opts),
- newResumeCommand(opts),
- newDeleteCommand(opts),
- }
- for _, cmd := range commands {
- t.Run(cmd.Name(), func(t *testing.T) {
- if err := cmd.Args(cmd, []string{"vm-a", "vm-b"}); err != nil {
- t.Fatalf("batch args rejected: %v", err)
- }
- if cmd.Flags().Lookup("concurrency") == nil {
- t.Fatal("concurrency flag is missing")
- }
- })
- }
-}
-
-func TestGCDryRunCommandReportsCandidates(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
- orphan := filepath.Join(runDir, "vms", "orphan")
- if err := os.MkdirAll(orphan, 0o755); err != nil {
- t.Fatal(err)
- }
-
- cmd := newTestRootCommand(rootDir, runDir, logDir)
- cmd.SetArgs([]string{
- "gc",
- "--dry-run",
- "--json",
- })
- var out bytes.Buffer
- cmd.SetOut(&out)
- if err := cmd.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var payload struct {
- DryRun bool `json:"dryRun"`
- Candidates []struct {
- Path string `json:"path"`
- Type string `json:"type"`
- } `json:"candidates"`
- }
- if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
- t.Fatal(err)
- }
- if !payload.DryRun {
- t.Fatal("expected dryRun true")
- }
- if len(payload.Candidates) != 1 {
- t.Fatalf("candidates = %+v", payload.Candidates)
- }
- if payload.Candidates[0].Path != orphan || payload.Candidates[0].Type != "orphan_run_dir" {
- t.Fatalf("candidate = %+v", payload.Candidates[0])
- }
-}
-
-func TestGCSnapshotPolicyFlags(t *testing.T) {
- dir := t.TempDir()
- cmd := newTestRootCommand(filepath.Join(dir, "data"), filepath.Join(dir, "run"), filepath.Join(dir, "log"))
- cmd.SetArgs([]string{
- "gc", "--dry-run", "--json",
- "--snapshot-keep", "0",
- "--snapshot-max-age", "168h",
- "--snapshot-max-bytes", "20G",
- })
- var out bytes.Buffer
- cmd.SetOut(&out)
- if err := cmd.Execute(); err != nil {
- t.Fatal(err)
- }
- var payload struct {
- SnapshotPolicy struct {
- Policy struct {
- KeepLast int `json:"keepLast"`
- MaxAge time.Duration `json:"maxAge"`
- MaxBytes int64 `json:"maxBytes"`
- } `json:"policy"`
- TargetSatisfied bool `json:"targetSatisfied"`
- } `json:"snapshotPolicy"`
- }
- if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
- t.Fatal(err)
- }
- if payload.SnapshotPolicy.Policy.KeepLast != 0 ||
- payload.SnapshotPolicy.Policy.MaxAge != 168*time.Hour ||
- payload.SnapshotPolicy.Policy.MaxBytes != 20<<30 ||
- !payload.SnapshotPolicy.TargetSatisfied {
- t.Fatalf("snapshot policy = %+v", payload.SnapshotPolicy)
- }
-}
-
-func TestGCSnapshotPolicyRejectsInvalidFlags(t *testing.T) {
- tests := []struct {
- name string
- args []string
- want string
- }{
- {name: "negative keep", args: []string{"gc", "--dry-run", "--snapshot-keep", "-1"}, want: "--snapshot-keep must not be negative"},
- {name: "invalid bytes", args: []string{"gc", "--dry-run", "--snapshot-max-bytes", "none"}, want: "--snapshot-max-bytes must be a positive size"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cmd := newTestRootCommand(t.TempDir())
- cmd.SetArgs(tt.args)
- err := cmd.Execute()
- if err == nil || !strings.Contains(err.Error(), tt.want) {
- t.Fatalf("error = %v, want containing %q", err, tt.want)
- }
- })
- }
-}
-
-func TestImageListAndInspectCommands(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
-
- listEmpty := newTestRootCommand(rootDir)
- listEmpty.SetArgs([]string{"image", "ls", "--json"})
- var emptyOut bytes.Buffer
- listEmpty.SetOut(&emptyOut)
- if err := listEmpty.Execute(); err != nil {
- t.Fatal(err)
- }
- var empty []any
- if err := json.Unmarshal(emptyOut.Bytes(), &empty); err != nil {
- t.Fatal(err)
- }
- if len(empty) != 0 {
- t.Fatalf("expected empty image list, got %+v", empty)
- }
-
- created, err := image.New(rootDir).Create(image.CreateRequest{
- Name: "ubuntu",
- Source: image.Source{Type: "test", URI: "fixtures/ubuntu.img"},
- RootDisk: image.RootDisk{
- Path: "base.qcow2",
- Format: "qcow2",
- },
- Boot: image.Boot{Mode: "uefi", Firmware: "CLOUDHV.fd"},
- OS: image.OS{Family: "ubuntu", Profile: "ubuntu-cloudimg"},
- })
- if err != nil {
- t.Fatal(err)
- }
-
- inspect := newTestRootCommand(rootDir)
- inspect.SetArgs([]string{"image", "inspect", "ubuntu", "--json"})
- var inspectOut bytes.Buffer
- inspect.SetOut(&inspectOut)
- if err := inspect.Execute(); err != nil {
- t.Fatal(err)
- }
- var inspected struct {
- ID string `json:"id"`
- Name string `json:"name"`
- RootDisk struct {
- Format string `json:"format"`
- } `json:"rootDisk"`
- }
- if err := json.Unmarshal(inspectOut.Bytes(), &inspected); err != nil {
- t.Fatal(err)
- }
- if inspected.ID != created.ID || inspected.Name != "ubuntu" || inspected.RootDisk.Format != "qcow2" {
- t.Fatalf("inspect image = %+v", inspected)
- }
-}
-
-func TestImageRemoveRejectsReferencedImage(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- runDir := filepath.Join(dir, "run")
- logDir := filepath.Join(dir, "log")
- basePath := filepath.Join(rootDir, "cloudimg", "img_test", "base.qcow2")
- if err := os.MkdirAll(filepath.Dir(basePath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(basePath, []byte("base"), 0o600); err != nil {
- t.Fatal(err)
- }
- imageRecord, err := image.New(rootDir).Create(image.CreateRequest{
- Name: "ubuntu",
- Source: image.Source{Type: "test", URI: "fixtures/ubuntu.img"},
- RootDisk: image.RootDisk{
- Path: basePath,
- Format: "qcow2",
- VirtualSizeBytes: 1024 * 1024,
- SHA256: strings.Repeat("a", 64),
- },
- Boot: image.Boot{Mode: "uefi", Firmware: "CLOUDHV.fd"},
- OS: image.OS{Family: "ubuntu", Profile: "ubuntu-cloudimg"},
- })
- if err != nil {
- t.Fatal(err)
- }
-
- create := newTestRootCommand(rootDir, runDir, logDir)
- create.SetArgs([]string{
- "--qemu-img-bin", fakeQEMUImgForOverlay(t, dir, imageRecord.RootDisk.Path),
- "create", "ubuntu",
- "--name", "ref",
- })
- if err := create.Execute(); err != nil {
- t.Fatal(err)
- }
-
- rm := newTestRootCommand(rootDir, runDir, logDir)
- rm.SetArgs([]string{"image", "rm", "ubuntu"})
- if err := rm.Execute(); !errors.Is(err, image.ErrImageInUse) {
- t.Fatalf("expected ErrImageInUse, got %v", err)
- }
- if _, err := image.New(rootDir).Inspect(imageRecord.ID); err != nil {
- t.Fatalf("referenced image should remain: %v", err)
- }
-
- del := newTestRootCommand(rootDir, runDir, logDir)
- del.SetArgs([]string{"delete", "ref"})
- if err := del.Execute(); err != nil {
- t.Fatal(err)
- }
-
- rm = newTestRootCommand(rootDir, runDir, logDir)
- rm.SetArgs([]string{"image", "rm", "ubuntu"})
- var rmOut bytes.Buffer
- rm.SetOut(&rmOut)
- if err := rm.Execute(); err != nil {
- t.Fatal(err)
- }
- var removed struct {
- ID string `json:"id"`
- }
- if err := json.Unmarshal(rmOut.Bytes(), &removed); err != nil {
- t.Fatal(err)
- }
- if removed.ID != imageRecord.ID {
- t.Fatalf("removed id = %s, want %s", removed.ID, imageRecord.ID)
- }
-}
-
-func TestImageRemoveBestEffortBatch(t *testing.T) {
- rootDir := t.TempDir()
- store := image.New(rootDir)
- created := make([]*image.ImageRecord, 0, 2)
- for _, name := range []string{"batch-image-a", "batch-image-b"} {
- record, err := store.Create(image.CreateRequest{
- Name: name, Source: image.Source{Type: "test", URI: name},
- RootDisk: image.RootDisk{Path: name + ".qcow2", Format: "qcow2"},
- Boot: image.Boot{Mode: "uefi", Firmware: "CLOUDHV.fd"},
- })
- if err != nil {
- t.Fatal(err)
- }
- created = append(created, record)
- }
- cmd := newTestRootCommand(rootDir)
- cmd.SetArgs([]string{"image", "rm", "batch-image-a", "missing", "batch-image-b", "batch-image-a", "--concurrency", "2"})
- var out bytes.Buffer
- cmd.SetOut(&out)
- err := cmd.Execute()
- if err == nil || !strings.Contains(err.Error(), "remove image") {
- t.Fatalf("error = %v", err)
- }
- var result struct {
- Succeeded []*image.ImageRecord `json:"succeeded"`
- Failed []resourceBatchFailure `json:"failed"`
- }
- if err := json.Unmarshal(out.Bytes(), &result); err != nil {
- t.Fatal(err)
- }
- if len(result.Succeeded) != 2 || result.Succeeded[0].ID != created[0].ID || result.Succeeded[1].ID != created[1].ID {
- t.Fatalf("succeeded = %+v", result.Succeeded)
- }
- if len(result.Failed) != 1 || result.Failed[0].Ref != "missing" {
- t.Fatalf("failed = %+v", result.Failed)
- }
-}
-
-func TestImagePullBatchRequiresOneNamePerURL(t *testing.T) {
- cmd := newTestRootCommand(t.TempDir())
- cmd.SetArgs([]string{
- "image", "pull", "https://example.invalid/a.img", "https://example.invalid/b.img",
- "--name", "only-one", "--firmware", "firmware.fd",
- })
- err := cmd.Execute()
- if err == nil || !strings.Contains(err.Error(), "provide one --name for each URL") {
- t.Fatalf("error = %v", err)
- }
-}
-
-func TestImageAndSnapshotBatchCommandsExposeConcurrency(t *testing.T) {
- opts := &rootOptions{}
- commands := []*cobra.Command{newImagePullCommand(opts), newImageRMCommand(opts), newSnapshotRMCommand(opts)}
- for _, cmd := range commands {
- t.Run(cmd.CommandPath(), func(t *testing.T) {
- if err := cmd.Args(cmd, []string{"first", "second"}); err != nil {
- t.Fatalf("batch args rejected: %v", err)
- }
- if cmd.Flags().Lookup("concurrency") == nil {
- t.Fatal("concurrency flag is missing")
- }
- })
- }
-}
-
-func TestSnapshotRemoveBestEffortBatch(t *testing.T) {
- rootDir := t.TempDir()
- store := snapshot.NewStore(rootDir)
- created := make([]*snapshot.Record, 0, 2)
- for _, name := range []string{"batch-snapshot-a", "batch-snapshot-b"} {
- build, err := store.Reserve(t.Context(), name)
- if err != nil {
- t.Fatal(err)
- }
- pending := build.Record()
- manifest := snapshot.Manifest{
- SchemaVersion: "kumabox.snapshot.v2", ID: pending.ID, Name: pending.Name,
- Type: "stopped", Consistency: "crash", Source: snapshot.Source{VMID: "vm-source"},
- }
- raw, err := json.Marshal(manifest)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(pending.StagingDir, snapshot.ManifestFile), raw, 0o600); err != nil {
- t.Fatal(err)
- }
- record, err := build.Finalize(int64(len(raw)))
- if err != nil {
- t.Fatal(err)
- }
- created = append(created, record)
- }
- cmd := newTestRootCommand(rootDir)
- cmd.SetArgs([]string{"snapshot", "rm", "batch-snapshot-a", "missing", "batch-snapshot-b", "--concurrency", "2"})
- var out bytes.Buffer
- cmd.SetOut(&out)
- err := cmd.Execute()
- if err == nil || !strings.Contains(err.Error(), "remove snapshot") {
- t.Fatalf("error = %v", err)
- }
- var result struct {
- Succeeded []*snapshot.Record `json:"succeeded"`
- Failed []resourceBatchFailure `json:"failed"`
- }
- if err := json.Unmarshal(out.Bytes(), &result); err != nil {
- t.Fatal(err)
- }
- if len(result.Succeeded) != 2 || result.Succeeded[0].ID != created[0].ID || result.Succeeded[1].ID != created[1].ID {
- t.Fatalf("succeeded = %+v", result.Succeeded)
- }
- if len(result.Failed) != 1 || result.Failed[0].Ref != "missing" {
- t.Fatalf("failed = %+v", result.Failed)
- }
-}
-
-func TestImageRemoveRechecksReferencesAfterEntityLock(t *testing.T) {
- for _, backend := range []string{"json", "sqlite"} {
- t.Run(backend, func(t *testing.T) {
- testImageRemoveRechecksReferencesAfterEntityLock(t, backend)
- })
- }
-}
-
-func testImageRemoveRechecksReferencesAfterEntityLock(t *testing.T, backend string) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- cfg := config.Default()
- cfg.Runtime.RootDir = rootDir
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
- cfg.Metadata.Backend = backend
- if backend == "sqlite" {
- cfg.Metadata.Path = filepath.Join(rootDir, "metadata", "kumabox.db")
- if err := state.InitSQLiteMetadata(t.Context(), cfg); err != nil {
- t.Fatal(err)
- }
- }
- stores, err := state.Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- if stores.Metadata != nil {
- t.Cleanup(func() { _ = stores.Metadata.Close() })
- }
- imageRecord, err := stores.Images.Create(image.CreateRequest{
- Name: "ubuntu",
- Source: image.Source{Type: "test", URI: "fixtures/ubuntu.img"},
- RootDisk: image.RootDisk{
- Path: filepath.Join(rootDir, "cloudimg", "base.qcow2"), Format: "qcow2",
- },
- Boot: image.Boot{Mode: "uefi", Firmware: "CLOUDHV.fd"},
- })
- if err != nil {
- t.Fatal(err)
- }
-
- guard := lock.NewGuard(rootDir)
- mutation, err := guard.BeginMutation(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = mutation.Release() })
- imageLock, err := guard.LockEntity(t.Context(), lock.EntityImage, imageRecord.ID)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = imageLock.Release() })
-
- rm := NewRootCommandWithConfig(cfg)
- rm.SetArgs([]string{"image", "rm", imageRecord.ID})
- result := make(chan error, 1)
- go func() { result <- rm.Execute() }()
-
- vm, err := stores.VM.Create(vm.CreateRequest{
- Name: "late-reference", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd",
- Image: &vm.ImageRef{ID: imageRecord.ID, Name: imageRecord.Name},
- RunDir: cfg.Runtime.RunDir, LogDir: cfg.Runtime.LogDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := imageLock.Release(); err != nil {
- t.Fatal(err)
- }
- if err := mutation.Release(); err != nil {
- t.Fatal(err)
- }
-
- select {
- case err := <-result:
- if !errors.Is(err, image.ErrImageInUse) {
- t.Fatalf("image remove error = %v, want ErrImageInUse", err)
- }
- case <-time.After(5 * time.Second):
- t.Fatal("image remove did not resume after entity lock released")
- }
- if _, err := stores.Images.Inspect(imageRecord.ID); err != nil {
- t.Fatalf("newly referenced image was removed: %v", err)
- }
- if _, err := stores.VM.Inspect(vm.ID); err != nil {
- t.Fatalf("late VM reference was not persisted: %v", err)
- }
-}
-
-func TestImageRemovePrunesDanglingExplicitReferences(t *testing.T) {
- rootDir := t.TempDir()
- basePath := filepath.Join(rootDir, "cloudimg", "img_test", "base.qcow2")
- if err := os.MkdirAll(filepath.Dir(basePath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(basePath, []byte("base"), 0o600); err != nil {
- t.Fatal(err)
- }
- image, err := image.New(rootDir).Create(image.CreateRequest{
- Name: "ubuntu",
- Source: image.Source{Type: "test", URI: "fixtures/ubuntu.img"},
- RootDisk: image.RootDisk{
- Path: basePath, Format: "qcow2", VirtualSizeBytes: 1024 * 1024, SHA256: strings.Repeat("a", 64),
- },
- Boot: image.Boot{Mode: "uefi", Firmware: "CLOUDHV.fd"},
- OS: image.OS{Family: "ubuntu", Profile: "ubuntu-cloudimg"},
- })
- if err != nil {
- t.Fatal(err)
- }
- references := reference.New(rootDir)
- if err := references.Upsert(context.Background(), reference.Record{
- ID: "snapshot-image:deleted", SourceKind: "snapshot", SourceID: "deleted",
- TargetKind: "image", TargetID: image.ID, Mode: "base",
- }); err != nil {
- t.Fatal(err)
- }
-
- rm := newTestRootCommand(rootDir)
- rm.SetArgs([]string{"image", "rm", image.ID})
- if err := rm.Execute(); err != nil {
- t.Fatal(err)
- }
- remaining, err := references.ListTarget(context.Background(), "image", image.ID)
- if err != nil || len(remaining) != 0 {
- t.Fatalf("dangling references = %+v, err=%v", remaining, err)
- }
-}
-
-func fakeQEMUImgForOverlay(t *testing.T, dir, backing string) string {
- t.Helper()
- path := filepath.Join(dir, "qemu-img-overlay")
- script := "#!/bin/sh\nset -eu\ncase \"$1\" in\n" +
- "create) for last do :; done; : > \"$last\" ;;\n" +
- "info) printf '%s\\n' '{\"format\":\"qcow2\",\"backing-filename\":\"" + backing + "\",\"virtual-size\":1048576}' ;;\n" +
- "*) exit 2 ;;\nesac\n"
- if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
- t.Fatal(err)
- }
- return path
-}
-
-func TestImageImportCommand(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- source := filepath.Join(dir, "fixtures", "jammy-server-cloudimg-amd64.img")
- if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(source, []byte("cloud image"), 0o644); err != nil {
- t.Fatal(err)
- }
- firmware := filepath.Join(dir, "fixtures", "CLOUDHV.fd")
- if err := os.WriteFile(firmware, []byte("firmware"), 0o644); err != nil {
- t.Fatal(err)
- }
- qemuImg := fakeQemuImgForCLI(t, dir, "qcow2", 4096, 11)
-
- importCmd := newTestRootCommand(rootDir)
- importCmd.SetArgs([]string{
- "image", "import", source,
- "--name", "ubuntu",
- "--firmware", firmware,
- "--qemu-img", qemuImg,
- })
- var importOut bytes.Buffer
- importCmd.SetOut(&importOut)
- if err := importCmd.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var imported struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Source struct {
- Type string `json:"type"`
- URI string `json:"uri"`
- } `json:"source"`
- RootDisk struct {
- Path string `json:"path"`
- Format string `json:"format"`
- } `json:"rootDisk"`
- Boot struct {
- Mode string `json:"mode"`
- Firmware string `json:"firmware"`
- } `json:"boot"`
- }
- if err := json.Unmarshal(importOut.Bytes(), &imported); err != nil {
- t.Fatal(err)
- }
- if imported.ID == "" || imported.Name != "ubuntu" || imported.Source.Type != "local-file" {
- t.Fatalf("imported = %+v", imported)
- }
- if imported.RootDisk.Format != "qcow2" || imported.RootDisk.Path == source {
- t.Fatalf("root disk = %+v", imported.RootDisk)
- }
- if imported.Boot.Mode != "uefi" || imported.Boot.Firmware != firmware {
- t.Fatalf("boot = %+v", imported.Boot)
- }
-
- inspect := newTestRootCommand(rootDir)
- inspect.SetArgs([]string{"image", "inspect", "ubuntu", "--json"})
- var inspectOut bytes.Buffer
- inspect.SetOut(&inspectOut)
- if err := inspect.Execute(); err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(inspectOut.String(), imported.ID) {
- t.Fatalf("inspect output missing imported id: %s", inspectOut.String())
- }
-}
-
-func TestImagePullCommand(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- content := []byte("pulled cloud image")
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write(content)
- }))
- defer server.Close()
-
- firmware := filepath.Join(dir, "fixtures", "CLOUDHV.fd")
- if err := os.MkdirAll(filepath.Dir(firmware), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(firmware, []byte("firmware"), 0o644); err != nil {
- t.Fatal(err)
- }
- sum := sha256.Sum256(content)
- qemuImg := fakeQemuImgForCLI(t, dir, "qcow2", 4096, int64(len(content)))
-
- pullCmd := newTestRootCommand(rootDir)
- pullCmd.SetArgs([]string{
- "image", "pull", server.URL + "/jammy-server-cloudimg-amd64.img",
- "--name", "ubuntu-pull",
- "--firmware", firmware,
- "--qemu-img", qemuImg,
- "--sha256", hex.EncodeToString(sum[:]),
- })
- var pullOut bytes.Buffer
- pullCmd.SetOut(&pullOut)
- if err := pullCmd.Execute(); err != nil {
- t.Fatal(err)
- }
-
- var pulled struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Source struct {
- Type string `json:"type"`
- URI string `json:"uri"`
- } `json:"source"`
- RootDisk struct {
- Path string `json:"path"`
- Format string `json:"format"`
- SHA256 string `json:"sha256"`
- } `json:"rootDisk"`
- }
- if err := json.Unmarshal(pullOut.Bytes(), &pulled); err != nil {
- t.Fatal(err)
- }
- if pulled.ID == "" || pulled.Name != "ubuntu-pull" || pulled.Source.Type != "url" {
- t.Fatalf("pulled = %+v", pulled)
- }
- if pulled.RootDisk.Format != "qcow2" || pulled.RootDisk.SHA256 != hex.EncodeToString(sum[:]) {
- t.Fatalf("root disk = %+v", pulled.RootDisk)
- }
- if _, err := os.Stat(pulled.RootDisk.Path); err != nil {
- t.Fatalf("pulled root disk missing: %v", err)
- }
-
- inspect := newTestRootCommand(rootDir)
- inspect.SetArgs([]string{"image", "inspect", "ubuntu-pull", "--json"})
- var inspectOut bytes.Buffer
- inspect.SetOut(&inspectOut)
- if err := inspect.Execute(); err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(inspectOut.String(), pulled.ID) {
- t.Fatalf("inspect output missing pulled id: %s", inspectOut.String())
- }
-}
-
-func fakeQemuImgForCLI(t *testing.T, dir, format string, virtualSize, actualSize int64) string {
- t.Helper()
- path := filepath.Join(dir, "qemu-img")
- script := "#!/bin/sh\n" +
- "printf '{\"format\":\"" + format + "\",\"virtual-size\":" + strconv.FormatInt(virtualSize, 10) + ",\"actual-size\":" + strconv.FormatInt(actualSize, 10) + "}'\n"
- if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
- t.Fatal(err)
- }
- return path
-}
diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go
deleted file mode 100644
index 4e840d5..0000000
--- a/internal/cli/snapshot.go
+++ /dev/null
@@ -1,363 +0,0 @@
-package cli
-
-import (
- "context"
- "fmt"
- "io"
- "path/filepath"
- "text/tabwriter"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/batch"
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/state"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func newSnapshotCommand(opts *rootOptions) *cobra.Command {
- cmd := &cobra.Command{Use: "snapshot", Short: "Manage VM snapshots"}
- cmd.AddCommand(newSnapshotCreateCommand(opts))
- cmd.AddCommand(newSnapshotExportCommand(opts))
- cmd.AddCommand(newSnapshotImportCommand(opts))
- cmd.AddCommand(newSnapshotRestoreCommand(opts))
- cmd.AddCommand(newSnapshotLSCommand(opts))
- cmd.AddCommand(newSnapshotInspectCommand(opts))
- cmd.AddCommand(newSnapshotVerifyCommand(opts))
- cmd.AddCommand(newSnapshotRMCommand(opts))
- return cmd
-}
-
-func newSnapshotVerifyCommand(opts *rootOptions) *cobra.Command {
- var vmRef string
- cmd := &cobra.Command{
- Use: "verify SNAPSHOT", Short: "Verify native snapshot integrity and compatibility", Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- manifest, err := rt.VerifyNativeSnapshot(cmd.Context(), args[0], vmRef)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), manifest)
- },
- }
- cmd.Flags().StringVar(&vmRef, "vm", "", "target VM used for compatibility checks")
- _ = cmd.MarkFlagRequired("vm")
- return cmd
-}
-
-func newSnapshotRestoreCommand(opts *rootOptions) *cobra.Command {
- var name string
- var cpus int
- var networks []string
- cmd := &cobra.Command{
- Use: "restore SNAPSHOT",
- Short: "Create a new VM from a stopped snapshot",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- if cpus <= 0 {
- return fmt.Errorf("--cpus must be greater than zero")
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if err := config.EnsureRuntimeDirs(cfg); err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.RestoreSnapshot(cmd.Context(), args[0], kbruntime.RestoreOptions{
- Name: name,
- CPUs: cpus,
- Networks: normalizedNetworkFlags(networks),
- })
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- },
- }
- cmd.Flags().StringVar(&name, "name", "", "new VM name")
- cmd.Flags().IntVar(&cpus, "cpus", 1, "number of vCPUs")
- cmd.Flags().StringArrayVar(&networks, "network", nil, "network attachment, repeatable")
- _ = cmd.MarkFlagRequired("name")
- return cmd
-}
-
-func newSnapshotImportCommand(opts *rootOptions) *cobra.Command {
- var name, fromDirectory string
- cmd := &cobra.Command{
- Use: "import [PACKAGE]", Short: "Import a snapshot package or directory", Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- if len(args) == 0 && fromDirectory == "" {
- return fmt.Errorf("provide PACKAGE or --from-dir")
- }
- if len(args) != 0 && fromDirectory != "" {
- return fmt.Errorf("PACKAGE and --from-dir are mutually exclusive")
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- input := fromDirectory
- if len(args) != 0 {
- input = args[0]
- }
- input, err = filepath.Abs(input)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- if stores.Metadata != nil {
- defer func() { _ = stores.Metadata.Close() }()
- }
- mutation, err := stores.Guard.BeginMutation(cmd.Context())
- if err != nil {
- return err
- }
- defer mutation.Release() //nolint:errcheck
- var rec *snapshot.Record
- if fromDirectory != "" {
- rec, err = stores.Snapshots.ImportDirectory(cmd.Context(), input, name, cfg.Storage.QEMUImgBinary)
- } else {
- rec, err = stores.Snapshots.Import(cmd.Context(), snapshot.ImportOptions{Input: input, Name: name, QEMUImgBinary: cfg.Storage.QEMUImgBinary})
- }
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- },
- }
- cmd.Flags().StringVar(&name, "name", "", "imported snapshot name")
- cmd.Flags().StringVar(&fromDirectory, "from-dir", "", "unpacked snapshot directory")
- _ = cmd.MarkFlagRequired("name")
- return cmd
-}
-
-func newSnapshotExportCommand(opts *rootOptions) *cobra.Command {
- var output, toDirectory, compression string
- cmd := &cobra.Command{
- Use: "export SNAPSHOT", Short: "Export a portable snapshot package", Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- if output == "" && toDirectory == "" {
- return fmt.Errorf("provide --output or --to-dir")
- }
- if output != "" && toDirectory != "" {
- return fmt.Errorf("--output and --to-dir are mutually exclusive")
- }
- if toDirectory != "" && cmd.Flags().Changed("compression") {
- return fmt.Errorf("--compression cannot be used with --to-dir")
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- destination := output
- if toDirectory != "" {
- destination = toDirectory
- }
- absolute, err := filepath.Abs(destination)
- if err != nil {
- return fmt.Errorf("resolve export output: %w", err)
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- if stores.Metadata != nil {
- defer func() { _ = stores.Metadata.Close() }()
- }
- if toDirectory != "" {
- if err := stores.Snapshots.ExportDirectory(cmd.Context(), args[0], absolute); err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), map[string]string{"snapshot": args[0], "directory": absolute})
- }
- if err := stores.Snapshots.Export(cmd.Context(), args[0], snapshot.ExportOptions{Output: absolute, Compression: compression}); err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), map[string]string{"snapshot": args[0], "output": absolute, "compression": compression})
- },
- }
- cmd.Flags().StringVar(&output, "output", "", "output .kbsnap path")
- cmd.Flags().StringVar(&toDirectory, "to-dir", "", "output unpacked snapshot directory")
- cmd.Flags().StringVar(&compression, "compression", "none", "compression: none, gzip, or zstd")
- return cmd
-}
-
-func newSnapshotCreateCommand(opts *rootOptions) *cobra.Command {
- var name string
- var snapshotType string
- cmd := &cobra.Command{
- Use: "create VM", Short: "Capture a stopped disk or running native snapshot", Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- var rec *snapshot.Record
- switch snapshotType {
- case "disk":
- rec, err = rt.CreateStoppedSnapshot(cmd.Context(), args[0], name)
- case "running":
- rec, err = rt.CreateRunningSnapshot(cmd.Context(), args[0], name)
- default:
- return fmt.Errorf("--type must be disk or running")
- }
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- },
- }
- cmd.Flags().StringVar(&name, "name", "", "snapshot name")
- cmd.Flags().StringVar(&snapshotType, "type", "disk", "snapshot type: disk or running")
- _ = cmd.MarkFlagRequired("name")
- return cmd
-}
-
-func newSnapshotLSCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
- cmd := &cobra.Command{
- Use: "ls", Aliases: []string{"list"}, Short: "List ready snapshots",
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, _ []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- records, err := stores.Snapshots.List()
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), records)
- }
- return writeSnapshotTable(cmd.OutOrStdout(), records)
- },
- }
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
-
-func newSnapshotInspectCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
- cmd := &cobra.Command{
- Use: "inspect SNAPSHOT", Short: "Inspect a ready snapshot", Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- rec, err := stores.Snapshots.Inspect(args[0])
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), rec)
- }
- return writeSnapshotTable(cmd.OutOrStdout(), []*snapshot.Record{rec})
- },
- }
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
-
-func newSnapshotRMCommand(opts *rootOptions) *cobra.Command {
- var concurrency int
- cmd := &cobra.Command{
- Use: "rm SNAPSHOT...", Aliases: []string{"remove"}, Short: "Remove unused snapshots", Args: cobra.MinimumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- if err := validateBatchConcurrency(concurrency); err != nil {
- return err
- }
- args = batch.Distinct(args)
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- mutation, err := stores.Guard.BeginMutation(cmd.Context())
- if err != nil {
- return err
- }
- defer mutation.Release() //nolint:errcheck
- result := batch.Run(cmd.Context(), args, batch.Options{Concurrency: concurrency}, "remove snapshot", func(ctx context.Context, _ int, ref string) (*snapshot.Record, error) {
- return removeSnapshot(ctx, stores, ref)
- })
- return writeResourceBatchResult(func(value any) error {
- return writeJSON(cmd.OutOrStdout(), value)
- }, args, "remove snapshot", result)
- },
- }
- addResourceBatchConcurrencyFlag(cmd, &concurrency)
- return cmd
-}
-
-func removeSnapshot(ctx context.Context, stores state.Set, ref string) (*snapshot.Record, error) {
- if stores.References != nil {
- record, err := stores.Snapshots.Inspect(ref)
- if err != nil {
- return nil, err
- }
- refs, err := stores.References.ListTarget(ctx, "snapshot", record.ID)
- if err != nil {
- return nil, err
- }
- if len(refs) > 0 {
- return nil, fmt.Errorf("SNAPSHOT_IN_USE: snapshot %s has %d explicit reference(s)", record.Name, len(refs))
- }
- }
- record, err := stores.Snapshots.Remove(ref)
- if err != nil {
- return nil, err
- }
- if stores.References != nil {
- if err := stores.References.DeleteSource(ctx, "snapshot", record.ID); err != nil {
- return nil, fmt.Errorf("remove snapshot references: %w", err)
- }
- }
- return record, nil
-}
-
-func writeSnapshotTable(w io.Writer, records []*snapshot.Record) error {
- tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
- if _, err := fmt.Fprintln(tw, "ID\tNAME\tSTATE\tSIZE\tCREATED"); err != nil {
- return err
- }
- for _, rec := range records {
- if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\n", rec.ID, rec.Name, rec.State, rec.SizeBytes, rec.CreatedAt.Format("2006-01-02T15:04:05Z")); err != nil {
- return err
- }
- }
- return tw.Flush()
-}
diff --git a/internal/cli/state.go b/internal/cli/state.go
deleted file mode 100644
index f4fc3a1..0000000
--- a/internal/cli/state.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package cli
-
-import (
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/config"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func newPauseCommand(opts *rootOptions) *cobra.Command {
- var concurrency int
- cmd := &cobra.Command{
- Use: "pause VM [VM...]",
- Short: "Pause one or more running VMs",
- Args: cobra.MinimumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- batchOpts, err := lifecycleBatchOptions(concurrency)
- if err != nil {
- return err
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if err := config.EnsureRuntimeDirs(cfg); err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- result := rt.PauseVMs(cmd.Context(), args, batchOpts)
- return writeLifecycleBatchResult(cmd, args, "pause", result)
- },
- }
- addBatchConcurrencyFlag(cmd, &concurrency)
- return cmd
-}
-
-func newResumeCommand(opts *rootOptions) *cobra.Command {
- var concurrency int
- cmd := &cobra.Command{
- Use: "resume VM [VM...]",
- Short: "Resume one or more paused VMs",
- Args: cobra.MinimumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- batchOpts, err := lifecycleBatchOptions(concurrency)
- if err != nil {
- return err
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if err := config.EnsureRuntimeDirs(cfg); err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- result := rt.ResumeVMs(cmd.Context(), args, batchOpts)
- return writeLifecycleBatchResult(cmd, args, "resume", result)
- },
- }
- addBatchConcurrencyFlag(cmd, &concurrency)
- return cmd
-}
diff --git a/internal/cli/stores.go b/internal/cli/stores.go
deleted file mode 100644
index fa2d619..0000000
--- a/internal/cli/stores.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package cli
-
-import (
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/state"
-)
-
-func configuredStores(cfg config.Config) (state.Set, error) {
- return state.Open(cfg)
-}
diff --git a/internal/cli/usage.go b/internal/cli/usage.go
deleted file mode 100644
index 87e35c3..0000000
--- a/internal/cli/usage.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package cli
-
-import (
- "fmt"
- "time"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/backend/cloudhypervisor"
- "github.com/kumabox/kumabox/internal/metering"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func newUsageCommand(opts *rootOptions) *cobra.Command {
- var sinceValue string
- var untilValue string
- cmd := &cobra.Command{
- Use: "usage [VM]", Short: "Show VM compute usage intervals", Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- stores, err := configuredStores(cfg)
- if err != nil {
- return err
- }
- if stores.Metadata != nil {
- defer func() { _ = stores.Metadata.Close() }()
- }
- rt, err := kbruntime.NewWithBackendAndState(stores, cloudhypervisor.NewBackend(cfg))
- if err != nil {
- return err
- }
- if err := rt.ReconcileMetering(cmd.Context()); err != nil {
- return fmt.Errorf("reconcile metering: %w", err)
- }
- query := metering.Query{}
- if len(args) == 1 {
- query.VMRef = args[0]
- }
- if query.Since, err = parseUsageTime("since", sinceValue); err != nil {
- return err
- }
- if query.Until, err = parseUsageTime("until", untilValue); err != nil {
- return err
- }
- if query.Since != nil && query.Until != nil && !query.Since.Before(*query.Until) {
- return fmt.Errorf("--since must be before --until")
- }
- intervals, err := stores.Metering.Usage(cmd.Context(), query)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), intervals)
- },
- }
- cmd.Flags().StringVar(&sinceValue, "since", "", "include usage ending after this RFC3339 time")
- cmd.Flags().StringVar(&untilValue, "until", "", "include usage starting before this RFC3339 time")
- return cmd
-}
-
-func parseUsageTime(name, value string) (*time.Time, error) {
- if value == "" {
- return nil, nil
- }
- parsed, err := time.Parse(time.RFC3339Nano, value)
- if err != nil {
- return nil, fmt.Errorf("parse --%s as RFC3339: %w", name, err)
- }
- parsed = parsed.UTC()
- return &parsed, nil
-}
diff --git a/internal/cli/usage_test.go b/internal/cli/usage_test.go
deleted file mode 100644
index b283916..0000000
--- a/internal/cli/usage_test.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package cli
-
-import (
- "testing"
- "time"
-)
-
-func TestParseUsageTime(t *testing.T) {
- parsed, err := parseUsageTime("since", "2026-08-12T10:00:00+08:00")
- if err != nil {
- t.Fatal(err)
- }
- want := time.Date(2026, 8, 12, 2, 0, 0, 0, time.UTC)
- if parsed == nil || !parsed.Equal(want) {
- t.Fatalf("parsed = %v, want %s", parsed, want)
- }
- if _, err := parseUsageTime("until", "not-a-time"); err == nil {
- t.Fatal("invalid usage time was accepted")
- }
-}
diff --git a/internal/cli/values.go b/internal/cli/values.go
deleted file mode 100644
index cd77821..0000000
--- a/internal/cli/values.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package cli
-
-import (
- "fmt"
- "math"
- "strconv"
- "strings"
-
- "github.com/kumabox/kumabox/internal/config"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
-)
-
-func parseByteSize(value string) (int64, error) {
- return parsePositiveByteSize("--storage", value)
-}
-
-func parseMemorySize(value string) (int64, error) {
- return parsePositiveByteSize("--memory", value)
-}
-
-func parsePositiveByteSize(flag, value string) (int64, error) {
- trimmed := strings.TrimSpace(value)
- if trimmed == "" {
- return 0, fmt.Errorf("%s must not be empty", flag)
- }
- multiplier := int64(1)
- suffix := strings.ToUpper(trimmed[len(trimmed)-1:])
- switch suffix {
- case "K":
- multiplier = 1024
- trimmed = trimmed[:len(trimmed)-1]
- case "M":
- multiplier = 1024 * 1024
- trimmed = trimmed[:len(trimmed)-1]
- case "G":
- multiplier = 1024 * 1024 * 1024
- trimmed = trimmed[:len(trimmed)-1]
- }
- n, err := strconv.ParseInt(trimmed, 10, 64)
- if err != nil || n <= 0 {
- return 0, fmt.Errorf("%s must be a positive size like 512M or 4G", flag)
- }
- if n > math.MaxInt64/multiplier {
- return 0, fmt.Errorf("%s exceeds the supported size", flag)
- }
- return n * multiplier, nil
-}
-
-func defaultString(value, fallback string) string {
- if value == "" {
- return fallback
- }
- return value
-}
-
-func normalizedNetworkFlags(values []string) []string {
- if len(values) == 0 {
- return []string{"none"}
- }
- return append([]string(nil), values...)
-}
-
-func normalizedOCIImageNetworkFlags(values []string, cfg config.Config) []string {
- if len(values) > 0 {
- return append([]string(nil), values...)
- }
- if cfg.Network.Mode == kbnetwork.ProviderCNI {
- return []string{"cni:" + cfg.Network.Default}
- }
- return []string{"none"}
-}
diff --git a/internal/cli/version.go b/internal/cli/version.go
deleted file mode 100644
index 1d76b86..0000000
--- a/internal/cli/version.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package cli
-
-import (
- "fmt"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/version"
-)
-
-func newVersionCommand() *cobra.Command {
- var jsonOutput bool
-
- cmd := &cobra.Command{
- Use: "version",
- Short: "Show version information",
- RunE: func(cmd *cobra.Command, args []string) error {
- info := version.Info()
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), info)
- }
- _, err := fmt.Fprintf(cmd.OutOrStdout(), "kumabox %s (%s, built %s)\n", info.Version, info.Commit, info.BuildTime)
- return err
- },
- }
-
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
diff --git a/internal/cli/vm.go b/internal/cli/vm.go
deleted file mode 100644
index 4ed7c03..0000000
--- a/internal/cli/vm.go
+++ /dev/null
@@ -1,372 +0,0 @@
-package cli
-
-import (
- "context"
- "fmt"
- "time"
-
- "github.com/spf13/cobra"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/vm"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-const (
- defaultOCIStorageSize = "4G"
- defaultOCIMemorySize = "512M"
-)
-
-func errInvalidLogSource(source string) error {
- return fmt.Errorf("invalid log source %q: expected console, stdout, stderr, vmm, or all", source)
-}
-
-func newCreateCommand(opts *rootOptions) *cobra.Command {
- flags := createVMFlags{}
-
- cmd := &cobra.Command{
- Use: "create [IMAGE]",
- Short: "Create a VM record",
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if err := config.EnsureRuntimeDirs(cfg); err != nil {
- return err
- }
-
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- req, err := newCreateRequest(flags, args, cfg)
- if err != nil {
- return err
- }
- rec, err := rt.CreateVM(req)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- },
- }
-
- addCreateVMFlags(cmd, &flags)
- return cmd
-}
-
-func newRunCommand(opts *rootOptions) *cobra.Command {
- flags := createVMFlags{}
- var timeout time.Duration
-
- cmd := &cobra.Command{
- Use: "run [IMAGE]",
- Short: "Create and start a VM",
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if err := config.EnsureRuntimeDirs(cfg); err != nil {
- return err
- }
-
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- req, err := newCreateRequest(flags, args, cfg)
- if err != nil {
- return err
- }
- runContext := cmd.Context()
- if timeout > 0 {
- var cancel context.CancelFunc
- runContext, cancel = context.WithTimeout(runContext, timeout)
- defer cancel()
- }
- rec, err := rt.RunVMContext(runContext, req)
- if err != nil {
- return err
- }
- return writeJSON(cmd.OutOrStdout(), rec)
- },
- }
-
- addCreateVMFlags(cmd, &flags)
- cmd.Flags().DurationVar(&timeout, "timeout", 0, "VM startup and guest readiness timeout")
- return cmd
-}
-
-func newStartCommand(opts *rootOptions) *cobra.Command {
- var concurrency int
-
- cmd := &cobra.Command{
- Use: "start VM [VM...]",
- Short: "Start one or more VMs",
- Args: cobra.MinimumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- batchOpts, err := lifecycleBatchOptions(concurrency)
- if err != nil {
- return err
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- result := rt.StartVMsContext(cmd.Context(), args, batchOpts)
- return writeLifecycleBatchResult(cmd, args, "start", result)
- },
- }
- addBatchConcurrencyFlag(cmd, &concurrency)
- return cmd
-}
-
-func newStopCommand(opts *rootOptions) *cobra.Command {
- var timeout time.Duration
- var force bool
- var concurrency int
-
- cmd := &cobra.Command{
- Use: "stop VM [VM...]",
- Short: "Stop one or more VMs",
- Args: cobra.MinimumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- batchOpts, err := lifecycleBatchOptions(concurrency)
- if err != nil {
- return err
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if timeout <= 0 && cfg.Backend.CloudHypervisor.StopTimeoutMS > 0 {
- timeout = time.Duration(cfg.Backend.CloudHypervisor.StopTimeoutMS) * time.Millisecond
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- result := rt.StopVMsContext(cmd.Context(), args, backend.StopOptions{
- Timeout: timeout,
- Force: force,
- }, batchOpts)
- return writeLifecycleBatchResult(cmd, args, "stop", result)
- },
- }
-
- cmd.Flags().DurationVar(&timeout, "timeout", 0, "graceful shutdown timeout")
- cmd.Flags().BoolVar(&force, "force", false, "skip API shutdown and terminate the VMM")
- addBatchConcurrencyFlag(cmd, &concurrency)
- return cmd
-}
-
-func newInspectCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
-
- cmd := &cobra.Command{
- Use: "inspect VM",
- Short: "Inspect a VM record",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- rec, err := rt.InspectVM(args[0])
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), rec)
- }
- return writeVMTable(cmd.OutOrStdout(), []*vm.VMRecord{rec})
- },
- }
-
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- return cmd
-}
-
-func newLogsCommand(opts *rootOptions) *cobra.Command {
- var tail int
- var source string
- var jsonOutput bool
- var follow bool
- var interval time.Duration
-
- cmd := &cobra.Command{
- Use: "logs VM",
- Short: "Show VM logs",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- if !kbruntime.ValidLogSource(source) {
- return errInvalidLogSource(source)
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- selectedSource := source
- if follow && !cmd.Flags().Changed("source") {
- selectedSource = kbruntime.LogSourceVMM
- }
- logOpts := kbruntime.LogOptions{
- Tail: tail,
- Source: selectedSource,
- }
- if follow {
- multiple := len(kbruntime.LogFileNames(selectedSource)) > 1
- return rt.FollowLogsVM(cmd.Context(), args[0], logOpts, interval, func(chunk kbruntime.VMLogChunk) error {
- if jsonOutput {
- return writeJSONLine(cmd.OutOrStdout(), chunk)
- }
- return writeVMLogChunk(cmd.OutOrStdout(), chunk, multiple)
- })
- }
- logs, err := rt.LogsVM(args[0], logOpts)
- if err != nil {
- return err
- }
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), logs)
- }
- return writeVMLogs(cmd.OutOrStdout(), logs)
- },
- }
-
- cmd.Flags().IntVar(&tail, "tail", 100, "number of recent lines to show, 0 for all")
- cmd.Flags().StringVar(&source, "source", kbruntime.LogSourceConsole, "log source: console, stdout, stderr, vmm, all")
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- cmd.Flags().BoolVarP(&follow, "follow", "f", false, "stream appended log content until interrupted")
- cmd.Flags().DurationVar(&interval, "interval", 200*time.Millisecond, "poll interval used while following")
- return cmd
-}
-
-func newDeleteCommand(opts *rootOptions) *cobra.Command {
- var force bool
- var concurrency int
-
- cmd := &cobra.Command{
- Use: "delete VM [VM...]",
- Short: "Delete one or more VMs",
- Args: cobra.MinimumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- batchOpts, err := lifecycleBatchOptions(concurrency)
- if err != nil {
- return err
- }
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- result := rt.DeleteVMsContext(cmd.Context(), args, force, batchOpts)
- return writeLifecycleBatchResult(cmd, args, "delete", result)
- },
- }
-
- cmd.Flags().BoolVar(&force, "force", false, "stop running VM before deleting it")
- addBatchConcurrencyFlag(cmd, &concurrency)
- return cmd
-}
-
-func newPSCommand(opts *rootOptions) *cobra.Command {
- var jsonOutput bool
- var watch bool
- var events bool
- var interval time.Duration
- var eventHeader bool
-
- cmd := &cobra.Command{
- Use: "ps [VM...]",
- Short: "List or watch VM records",
- Args: cobra.ArbitraryArgs,
- RunE: func(cmd *cobra.Command, args []string) error {
- cfg, err := loadConfig(opts)
- if err != nil {
- return err
- }
- rt, err := kbruntime.New(cfg)
- if err != nil {
- return err
- }
- if watch || events {
- return rt.WatchVMs(cmd.Context(), args, interval, func(update kbruntime.VMStatusUpdate) error {
- if events {
- if jsonOutput {
- for _, event := range update.Events {
- if err := writeJSONLine(cmd.OutOrStdout(), event); err != nil {
- return err
- }
- }
- return nil
- }
- err := writeVMEventTable(cmd.OutOrStdout(), update.Events, !eventHeader)
- eventHeader = true
- return err
- }
- if jsonOutput {
- return writeJSONLine(cmd.OutOrStdout(), update.Records)
- }
- return writeVMTable(cmd.OutOrStdout(), update.Records)
- })
- }
- records, err := rt.ListVMs()
- if err != nil {
- return err
- }
- records = filterVMRecords(records, args)
- if jsonOutput {
- return writeJSON(cmd.OutOrStdout(), records)
- }
- return writeVMTable(cmd.OutOrStdout(), records)
- },
- }
-
- cmd.Flags().BoolVar(&jsonOutput, "json", false, "output JSON")
- cmd.Flags().BoolVarP(&watch, "watch", "w", false, "watch and redraw when VM status changes")
- cmd.Flags().BoolVar(&events, "events", false, "stream ADDED, MODIFIED, and DELETED events")
- cmd.Flags().DurationVar(&interval, "interval", time.Second, "poll interval used while watching")
- return cmd
-}
-
-func filterVMRecords(records []*vm.VMRecord, refs []string) []*vm.VMRecord {
- if len(refs) == 0 {
- return records
- }
- selected := make([]*vm.VMRecord, 0, len(refs))
- seen := make(map[string]struct{}, len(refs))
- for _, ref := range refs {
- for _, record := range records {
- if record.ID != ref && record.Name != ref {
- continue
- }
- if _, ok := seen[record.ID]; !ok {
- selected = append(selected, record)
- seen[record.ID] = struct{}{}
- }
- break
- }
- }
- return selected
-}
diff --git a/internal/cli/vm_watch_test.go b/internal/cli/vm_watch_test.go
deleted file mode 100644
index b64cc42..0000000
--- a/internal/cli/vm_watch_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package cli
-
-import (
- "bytes"
- "encoding/json"
- "strings"
- "testing"
-
- "github.com/kumabox/kumabox/internal/vm"
- kbruntime "github.com/kumabox/kumabox/internal/vm/runtime"
-)
-
-func TestWriteVMEventTable(t *testing.T) {
- t.Parallel()
-
- var output bytes.Buffer
- events := []kbruntime.VMStatusEvent{{
- Event: kbruntime.VMEventAdded,
- VM: &vm.VMRecord{
- ID: "vm-1", Name: "example", State: vm.StateRunning,
- ObservedState: vm.ObservedStateRunning, Backend: "cloud-hypervisor",
- },
- }}
- if err := writeVMEventTable(&output, events, true); err != nil {
- t.Fatal(err)
- }
- for _, expected := range []string{"EVENT", "ADDED", "vm-1", "example", "RUNNING"} {
- if !strings.Contains(output.String(), expected) {
- t.Fatalf("event output %q does not contain %q", output.String(), expected)
- }
- }
-}
-
-func TestWriteVMEventJSONLine(t *testing.T) {
- t.Parallel()
-
- var output bytes.Buffer
- event := kbruntime.VMStatusEvent{
- Event: kbruntime.VMEventDeleted,
- VM: &vm.VMRecord{ID: "vm-1", Name: "example"},
- }
- if err := writeJSONLine(&output, event); err != nil {
- t.Fatal(err)
- }
- if strings.Count(output.String(), "\n") != 1 {
- t.Fatalf("JSON event is not one NDJSON line: %q", output.String())
- }
- var decoded kbruntime.VMStatusEvent
- if err := json.Unmarshal(output.Bytes(), &decoded); err != nil {
- t.Fatal(err)
- }
- if decoded.Event != kbruntime.VMEventDeleted || decoded.VM.ID != "vm-1" {
- t.Fatalf("decoded event = %+v", decoded)
- }
-}
-
-func TestFilterVMRecords(t *testing.T) {
- t.Parallel()
-
- records := []*vm.VMRecord{{ID: "vm-1", Name: "first"}, {ID: "vm-2", Name: "second"}}
- selected := filterVMRecords(records, []string{"second", "vm-1", "second", "missing"})
- if len(selected) != 2 || selected[0].ID != "vm-2" || selected[1].ID != "vm-1" {
- t.Fatalf("selected records = %+v", selected)
- }
-}
-
-func TestLogsCommandExposesFollowFlags(t *testing.T) {
- t.Parallel()
-
- cmd := newLogsCommand(&rootOptions{})
- for _, name := range []string{"follow", "interval", "source", "tail"} {
- if cmd.Flags().Lookup(name) == nil {
- t.Fatalf("logs flag %q is missing", name)
- }
- }
-}
diff --git a/internal/config/config.go b/internal/config/config.go
deleted file mode 100644
index b6d538c..0000000
--- a/internal/config/config.go
+++ /dev/null
@@ -1,269 +0,0 @@
-// Package config owns KumaBox's process-level configuration model.
-//
-// Configuration is intentionally layered: compiled defaults are loaded first,
-// an optional TOML file may replace them, and CLI overrides win last. Runtime
-// code should receive a fully validated Config instead of reading flags or
-// environment variables directly.
-package config
-
-import (
- "errors"
- "fmt"
- "os"
-
- "github.com/pelletier/go-toml/v2"
-)
-
-const (
- defaultRootDir = "/var/lib/kumabox"
- defaultRunDir = "/var/lib/kumabox/run"
- defaultLogDir = "/var/log/kumabox"
- defaultCloudHypervisorBinary = "cloud-hypervisor"
- defaultQEMUImgBinary = "qemu-img"
- defaultAPISocketTimeoutMS = 5000
- defaultStopTimeoutMS = 10000
- defaultDiskQueueSize = 512
- defaultNetworkMode = "cni"
- defaultNetworkName = "kumabox"
- defaultBridge = "kumabox0"
- defaultCIDR = "10.88.0.0/16"
- defaultGateway = "10.88.0.1"
- defaultTapPrefix = "kbtap"
- defaultNATBackend = "auto"
- defaultCNIConfigDir = "/etc/cni/net.d"
- defaultCNIBinDir = "/opt/cni/bin"
-)
-
-var defaultDNS = []string{"1.1.1.1", "8.8.8.8"}
-
-// Config is the complete configuration snapshot used by a KumaBox command.
-//
-// The value is treated as immutable after Load returns. Packages that need
-// paths or provider settings receive this struct explicitly so tests can use
-// isolated root/run/log directories without mutating global process state.
-type Config struct {
- Runtime RuntimeConfig `toml:"runtime" json:"runtime"`
- Backend BackendConfig `toml:"backend" json:"backend"`
- Network NetworkConfig `toml:"network" json:"network"`
- Storage StorageConfig `toml:"storage" json:"storage"`
- Metadata MetadataConfig `toml:"metadata" json:"metadata"`
-}
-
-// MetadataConfig selects the durable metadata engine. JSON remains the
-// default for compatibility; SQLite is an explicit opt-in backend.
-type MetadataConfig struct {
- Backend string `toml:"backend" json:"backend"`
- Path string `toml:"path" json:"path"`
-}
-
-// StorageConfig controls host tools used to prepare durable VM disks.
-type StorageConfig struct {
- QEMUImgBinary string `toml:"qemu_img_binary" json:"qemuImgBinary"`
-}
-
-// RuntimeConfig contains the three host path roots used by KumaBox.
-//
-// RootDir is durable state such as VM/image indexes and network leases. RunDir
-// holds runtime state such as sockets and rendered VMM config. It lives under
-// RootDir by default so native snapshot restore can hard-link memory payloads
-// instead of crossing from durable storage into a tmpfs. LogDir is
-// command-readable VM output and event logs.
-type RuntimeConfig struct {
- RootDir string `toml:"root_dir" json:"rootDir"`
- RunDir string `toml:"run_dir" json:"runDir"`
- LogDir string `toml:"log_dir" json:"logDir"`
-}
-
-// BackendConfig contains backend-specific runtime configuration.
-type BackendConfig struct {
- CloudHypervisor CloudHypervisorConfig `toml:"cloud_hypervisor" json:"cloudHypervisor"`
-}
-
-// CloudHypervisorConfig controls the Cloud Hypervisor binary and timeouts.
-type CloudHypervisorConfig struct {
- Binary string `toml:"binary" json:"binary"`
- APISocketTimeoutMS int `toml:"api_socket_timeout_ms" json:"apiSocketTimeoutMs"`
- StopTimeoutMS int `toml:"stop_timeout_ms" json:"stopTimeoutMs"`
- DiskQueueSize int `toml:"disk_queue_size" json:"diskQueueSize"`
- NoDirectIO bool `toml:"no_direct_io" json:"noDirectIO"`
-}
-
-// NetworkConfig contains host networking defaults used by network providers.
-//
-// The host-tap provider owns a single bridge/NAT domain per RootDir. CIDR and
-// Gateway define the guest address pool; TapPrefix is constrained by Linux's
-// interface-name limit after KumaBox appends a stable hash suffix.
-type NetworkConfig struct {
- Mode string `toml:"mode" json:"mode"`
- Default string `toml:"default" json:"default"`
- Bridge string `toml:"bridge" json:"bridge"`
- CIDR string `toml:"cidr" json:"cidr"`
- Gateway string `toml:"gateway" json:"gateway"`
- DNS []string `toml:"dns" json:"dns"`
- TapPrefix string `toml:"tap_prefix" json:"tapPrefix"`
- NATBackend string `toml:"nat_backend" json:"natBackend"`
- CNIConfigDir string `toml:"cni_config_dir" json:"cniConfigDir"`
- CNIBinDir string `toml:"cni_bin_dir" json:"cniBinDir"`
-}
-
-// Overrides contains command-line values that replace file or default config.
-type Overrides struct {
- RootDir string
- RunDir string
- LogDir string
- CloudHypervisorBin string
- QEMUImgBinary string
- MetadataBackend string
- MetadataPath string
-}
-
-// Load reads config from path, applies overrides, and validates the result.
-//
-// A missing path means "use defaults plus overrides". When path is non-empty it
-// must exist and contain TOML compatible with Config.
-func Load(path string, overrides Overrides) (Config, error) {
- cfg := Default()
- if path != "" {
- raw, err := os.ReadFile(path) //nolint:gosec
- if err != nil {
- return Config{}, fmt.Errorf("read config %s: %w", path, err)
- }
- if err := toml.Unmarshal(raw, &cfg); err != nil {
- return Config{}, fmt.Errorf("parse config %s: %w", path, err)
- }
- }
-
- applyOverrides(&cfg, overrides)
- if err := validate(cfg); err != nil {
- return Config{}, err
- }
- return cfg, nil
-}
-
-// Default returns the built-in KumaBox configuration.
-//
-// The default network uses the host's CNI configuration. The built-in
-// host-tap values remain available for the explicit compatibility provider.
-func Default() Config {
- return Config{
- Runtime: RuntimeConfig{
- RootDir: defaultRootDir,
- RunDir: defaultRunDir,
- LogDir: defaultLogDir,
- },
- Backend: BackendConfig{
- CloudHypervisor: CloudHypervisorConfig{
- Binary: defaultCloudHypervisorBinary,
- APISocketTimeoutMS: defaultAPISocketTimeoutMS,
- StopTimeoutMS: defaultStopTimeoutMS,
- DiskQueueSize: defaultDiskQueueSize,
- },
- },
- Network: NetworkConfig{
- Mode: defaultNetworkMode,
- Default: defaultNetworkName,
- Bridge: defaultBridge,
- CIDR: defaultCIDR,
- Gateway: defaultGateway,
- DNS: append([]string(nil), defaultDNS...),
- TapPrefix: defaultTapPrefix,
- NATBackend: defaultNATBackend,
- CNIConfigDir: defaultCNIConfigDir,
- CNIBinDir: defaultCNIBinDir,
- },
- Storage: StorageConfig{QEMUImgBinary: defaultQEMUImgBinary},
- Metadata: MetadataConfig{Backend: "json"},
- }
-}
-
-// EnsureRuntimeDirs creates the configured runtime directories.
-//
-// Callers should do this before rendering VM config, writing indexes, or
-// creating host networking state. The function creates only the configured
-// roots; per-VM subdirectories remain owned by runtime/backend code.
-func EnsureRuntimeDirs(cfg Config) error {
- for _, dir := range []string{cfg.Runtime.RootDir, cfg.Runtime.RunDir, cfg.Runtime.LogDir} {
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return fmt.Errorf("create directory %s: %w", dir, err)
- }
- }
- return nil
-}
-
-func applyOverrides(cfg *Config, overrides Overrides) {
- if overrides.RootDir != "" {
- cfg.Runtime.RootDir = overrides.RootDir
- }
- if overrides.RunDir != "" {
- cfg.Runtime.RunDir = overrides.RunDir
- }
- if overrides.LogDir != "" {
- cfg.Runtime.LogDir = overrides.LogDir
- }
- if overrides.CloudHypervisorBin != "" {
- cfg.Backend.CloudHypervisor.Binary = overrides.CloudHypervisorBin
- }
- if overrides.QEMUImgBinary != "" {
- cfg.Storage.QEMUImgBinary = overrides.QEMUImgBinary
- }
- if overrides.MetadataBackend != "" {
- cfg.Metadata.Backend = overrides.MetadataBackend
- }
- if overrides.MetadataPath != "" {
- cfg.Metadata.Path = overrides.MetadataPath
- }
-}
-
-func validate(cfg Config) error {
- if cfg.Runtime.RootDir == "" {
- return errors.New("runtime.root_dir must not be empty")
- }
- if cfg.Runtime.RunDir == "" {
- return errors.New("runtime.run_dir must not be empty")
- }
- if cfg.Runtime.LogDir == "" {
- return errors.New("runtime.log_dir must not be empty")
- }
- if cfg.Backend.CloudHypervisor.Binary == "" {
- return errors.New("backend.cloud_hypervisor.binary must not be empty")
- }
- if cfg.Backend.CloudHypervisor.DiskQueueSize < 0 {
- return errors.New("backend.cloud_hypervisor.disk_queue_size must be non-negative")
- }
- if cfg.Storage.QEMUImgBinary == "" {
- return errors.New("storage.qemu_img_binary must not be empty")
- }
- if cfg.Metadata.Backend != "json" && cfg.Metadata.Backend != "sqlite" {
- return fmt.Errorf("metadata.backend must be json or sqlite")
- }
- if cfg.Network.Mode == "" {
- return errors.New("network.mode must not be empty")
- }
- if cfg.Network.Default == "" {
- return errors.New("network.default must not be empty")
- }
- if cfg.Network.Mode != "host-tap" && cfg.Network.Mode != "none" && cfg.Network.Mode != "cni" {
- return fmt.Errorf("network.mode must be one of host-tap, cni, or none")
- }
- if cfg.Network.Mode == "host-tap" {
- if cfg.Network.Bridge == "" {
- return errors.New("network.bridge must not be empty when network.mode is host-tap")
- }
- if cfg.Network.CIDR == "" {
- return errors.New("network.cidr must not be empty when network.mode is host-tap")
- }
- if cfg.Network.Gateway == "" {
- return errors.New("network.gateway must not be empty when network.mode is host-tap")
- }
- if cfg.Network.TapPrefix == "" {
- return errors.New("network.tap_prefix must not be empty when network.mode is host-tap")
- }
- }
- if cfg.Network.NATBackend == "" {
- return errors.New("network.nat_backend must not be empty")
- }
- if cfg.Network.NATBackend != "auto" && cfg.Network.NATBackend != "iptables" && cfg.Network.NATBackend != "nft" && cfg.Network.NATBackend != "none" {
- return fmt.Errorf("network.nat_backend must be one of auto, iptables, nft, or none")
- }
- return nil
-}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
deleted file mode 100644
index 3be6ff9..0000000
--- a/internal/config/config_test.go
+++ /dev/null
@@ -1,114 +0,0 @@
-package config
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestLoadAppliesFileAndFlagOverrides(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "config.toml")
- raw := []byte(`
-[runtime]
-root_dir = "/from-file/root"
-run_dir = "/from-file/run"
-log_dir = "/from-file/log"
-
-[backend.cloud_hypervisor]
-binary = "/usr/local/bin/cloud-hypervisor"
-api_socket_timeout_ms = 1234
-stop_timeout_ms = 5678
-disk_queue_size = 256
-no_direct_io = true
-
-[network]
-mode = "host-tap"
-default = "default"
-bridge = "kb-test0"
-cidr = "10.99.0.0/16"
-gateway = "10.99.0.1"
-dns = ["9.9.9.9"]
-tap_prefix = "kbtest"
-nat_backend = "nft"
-cni_config_dir = "/tmp/cni/net.d"
-cni_bin_dir = "/tmp/cni/bin"
-`)
- if err := os.WriteFile(path, raw, 0o644); err != nil {
- t.Fatal(err)
- }
-
- cfg, err := Load(path, Overrides{
- RootDir: "/from-flag/root",
- CloudHypervisorBin: "/from-flag/cloud-hypervisor",
- })
- if err != nil {
- t.Fatal(err)
- }
-
- if cfg.Runtime.RootDir != "/from-flag/root" {
- t.Fatalf("root dir = %q", cfg.Runtime.RootDir)
- }
- if cfg.Runtime.RunDir != "/from-file/run" {
- t.Fatalf("run dir = %q", cfg.Runtime.RunDir)
- }
- if cfg.Backend.CloudHypervisor.Binary != "/from-flag/cloud-hypervisor" {
- t.Fatalf("cloud-hypervisor binary = %q", cfg.Backend.CloudHypervisor.Binary)
- }
- if cfg.Backend.CloudHypervisor.DiskQueueSize != 256 || !cfg.Backend.CloudHypervisor.NoDirectIO {
- t.Fatalf("disk policy = %+v", cfg.Backend.CloudHypervisor)
- }
- if cfg.Network.Bridge != "kb-test0" {
- t.Fatalf("network bridge = %q", cfg.Network.Bridge)
- }
- if cfg.Network.NATBackend != "nft" {
- t.Fatalf("network nat backend = %q", cfg.Network.NATBackend)
- }
- if len(cfg.Network.DNS) != 1 || cfg.Network.DNS[0] != "9.9.9.9" {
- t.Fatalf("network dns = %#v", cfg.Network.DNS)
- }
-}
-
-func TestDefaultNetworkConfig(t *testing.T) {
- cfg := Default()
- if cfg.Runtime.RunDir != filepath.Join(cfg.Runtime.RootDir, "run") {
- t.Fatalf("default run dir = %q, want under root dir %q", cfg.Runtime.RunDir, cfg.Runtime.RootDir)
- }
- if cfg.Network.Mode != "cni" {
- t.Fatalf("network mode = %q", cfg.Network.Mode)
- }
- if cfg.Network.Default != "kumabox" {
- t.Fatalf("default network = %q", cfg.Network.Default)
- }
- if cfg.Network.Bridge != "kumabox0" {
- t.Fatalf("network bridge = %q", cfg.Network.Bridge)
- }
- if cfg.Network.CIDR == "" || cfg.Network.Gateway == "" || cfg.Network.TapPrefix == "" {
- t.Fatalf("incomplete default network config: %+v", cfg.Network)
- }
- if cfg.Backend.CloudHypervisor.DiskQueueSize != 512 || cfg.Backend.CloudHypervisor.NoDirectIO {
- t.Fatalf("disk defaults = %+v", cfg.Backend.CloudHypervisor)
- }
-}
-
-func TestEnsureRuntimeDirs(t *testing.T) {
- dir := t.TempDir()
- cfg := Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- if err := EnsureRuntimeDirs(cfg); err != nil {
- t.Fatal(err)
- }
-
- for _, path := range []string{cfg.Runtime.RootDir, cfg.Runtime.RunDir, cfg.Runtime.LogDir} {
- info, err := os.Stat(path)
- if err != nil {
- t.Fatal(err)
- }
- if !info.IsDir() {
- t.Fatalf("%s is not a directory", path)
- }
- }
-}
diff --git a/internal/disk/copy.go b/internal/disk/copy.go
deleted file mode 100644
index 5245370..0000000
--- a/internal/disk/copy.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package disk
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "io"
- "os"
- "syscall"
-
- "github.com/kumabox/kumabox/internal/fileutil"
-)
-
-// MaxConcurrentFileCopies bounds simultaneous large file copies so snapshot
-// and restore operations use parallel IO without saturating the host disk.
-const MaxConcurrentFileCopies = 2
-
-// CopyResult describes the durable copy created for one snapshot disk.
-type CopyResult struct {
- Strategy string
- LogicalSizeBytes int64
- AllocatedSizeBytes int64
- SHA256 string
-}
-
-// CopyFile preserves sparse allocation where supported, fsyncs the result, and
-// computes its checksum before returning.
-func CopyFile(ctx context.Context, source, destination string) (CopyResult, error) {
- staged, err := StageFile(ctx, source, destination)
- if err != nil {
- return CopyResult{}, err
- }
- return FinalizeStagedFile(ctx, destination, staged)
-}
-
-// StageFile creates a copy without reading it back or forcing it to stable
-// disk. Callers with a latency-sensitive pause window must finalize it
-// after the source workload has resumed.
-func StageFile(ctx context.Context, source, destination string) (CopyResult, error) {
- strategy, err := copyPlatform(ctx, source, destination)
- if err != nil {
- return CopyResult{}, err
- }
- info, err := os.Stat(destination)
- if err != nil {
- return CopyResult{}, fmt.Errorf("stat staged disk: %w", err)
- }
- return copyResult(strategy, info, ""), nil
-}
-
-// FinalizeStagedFile makes a staged copy durable and computes its checksum.
-func FinalizeStagedFile(ctx context.Context, path string, staged CopyResult) (CopyResult, error) {
- file, err := os.OpenFile(path, os.O_RDWR, 0) //nolint:gosec
- if err != nil {
- return CopyResult{}, fmt.Errorf("open staged disk: %w", err)
- }
- if err := file.Sync(); err != nil {
- _ = file.Close()
- return CopyResult{}, fmt.Errorf("sync staged disk: %w", err)
- }
- hash := sha256.New()
- if _, err := io.Copy(hash, &contextReader{ctx: ctx, reader: file}); err != nil {
- _ = file.Close()
- return CopyResult{}, fmt.Errorf("checksum staged disk: %w", err)
- }
- info, err := file.Stat()
- closeErr := file.Close()
- if err != nil {
- return CopyResult{}, fmt.Errorf("stat staged disk: %w", err)
- }
- if closeErr != nil {
- return CopyResult{}, fmt.Errorf("close staged disk: %w", closeErr)
- }
- return copyResult(staged.Strategy, info, hex.EncodeToString(hash.Sum(nil))), nil
-}
-
-func copyResult(strategy string, info os.FileInfo, checksum string) CopyResult {
- allocated := info.Size()
- if stat, ok := info.Sys().(*syscall.Stat_t); ok {
- allocated = stat.Blocks * 512
- }
- return CopyResult{
- Strategy: strategy, LogicalSizeBytes: info.Size(), AllocatedSizeBytes: allocated,
- SHA256: checksum,
- }
-}
-
-func bufferedCopy(ctx context.Context, source, destination string) (strategy string, err error) {
- src, err := os.Open(source) //nolint:gosec
- if err != nil {
- return "", fmt.Errorf("open source disk: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, src, "close source disk")
- dst, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec
- if err != nil {
- return "", fmt.Errorf("create destination disk: %w", err)
- }
- ok := false
- defer func() {
- fileutil.CloseAndJoin(&err, dst, "close destination disk")
- if !ok {
- _ = os.Remove(destination)
- }
- }()
- if _, err := io.Copy(dst, &contextReader{ctx: ctx, reader: src}); err != nil {
- return "", fmt.Errorf("copy disk: %w", err)
- }
- ok = true
- return "stream", nil
-}
-
-type contextReader struct {
- ctx context.Context
- reader io.Reader
-}
-
-func (r *contextReader) Read(p []byte) (int, error) {
- if err := r.ctx.Err(); err != nil {
- return 0, err
- }
- return r.reader.Read(p)
-}
diff --git a/internal/disk/copy_linux.go b/internal/disk/copy_linux.go
deleted file mode 100644
index 5bc545c..0000000
--- a/internal/disk/copy_linux.go
+++ /dev/null
@@ -1,94 +0,0 @@
-//go:build linux
-
-package disk
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "os"
-
- "github.com/kumabox/kumabox/internal/fileutil"
- "golang.org/x/sys/unix"
-)
-
-func copyPlatform(ctx context.Context, source, destination string) (strategy string, err error) {
- src, err := os.Open(source) //nolint:gosec
- if err != nil {
- return "", fmt.Errorf("open source disk: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, src, "close source disk")
- dst, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) //nolint:gosec
- if err != nil {
- return "", fmt.Errorf("create destination disk: %w", err)
- }
- ok := false
- dstClosed := false
- defer func() {
- if !dstClosed {
- fileutil.CloseAndJoin(&err, dst, "close destination disk")
- }
- if !ok {
- _ = os.Remove(destination)
- }
- }()
-
- if err := unix.IoctlFileClone(int(dst.Fd()), int(src.Fd())); err == nil {
- ok = true
- return "reflink", nil
- }
- if err := copySparseExtents(ctx, src, dst); err == nil {
- ok = true
- return "sparse", nil
- } else if !errors.Is(err, unix.EINVAL) && !errors.Is(err, unix.ENOTSUP) && !errors.Is(err, unix.ENOSYS) {
- return "", err
- }
- if err := dst.Close(); err != nil {
- return "", fmt.Errorf("close sparse fallback: %w", err)
- }
- dstClosed = true
- if err := os.Remove(destination); err != nil {
- return "", fmt.Errorf("reset sparse fallback: %w", err)
- }
- strategy, err = bufferedCopy(ctx, source, destination)
- if err != nil {
- return "", err
- }
- ok = true
- return strategy, nil
-}
-
-func copySparseExtents(ctx context.Context, src, dst *os.File) error {
- info, err := src.Stat()
- if err != nil {
- return fmt.Errorf("stat source disk: %w", err)
- }
- if err := dst.Truncate(info.Size()); err != nil {
- return fmt.Errorf("size sparse disk: %w", err)
- }
- for offset := int64(0); offset < info.Size(); {
- if err := ctx.Err(); err != nil {
- return err
- }
- data, err := unix.Seek(int(src.Fd()), offset, unix.SEEK_DATA)
- if errors.Is(err, unix.ENXIO) {
- return nil
- }
- if err != nil {
- return err
- }
- hole, err := unix.Seek(int(src.Fd()), data, unix.SEEK_HOLE)
- if err != nil {
- return err
- }
- if _, err := dst.Seek(data, io.SeekStart); err != nil {
- return fmt.Errorf("seek destination extent: %w", err)
- }
- if _, err := io.CopyN(dst, io.NewSectionReader(src, data, hole-data), hole-data); err != nil {
- return fmt.Errorf("copy sparse extent: %w", err)
- }
- offset = hole
- }
- return nil
-}
diff --git a/internal/disk/copy_other.go b/internal/disk/copy_other.go
deleted file mode 100644
index cf6ee09..0000000
--- a/internal/disk/copy_other.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !linux
-
-package disk
-
-import "context"
-
-func copyPlatform(ctx context.Context, source, destination string) (string, error) {
- return bufferedCopy(ctx, source, destination)
-}
diff --git a/internal/disk/copy_test.go b/internal/disk/copy_test.go
deleted file mode 100644
index 41eb545..0000000
--- a/internal/disk/copy_test.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package disk
-
-import (
- "context"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestStageAndFinalizeFile(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- source := filepath.Join(dir, "source.raw")
- destination := filepath.Join(dir, "destination.raw")
- if err := os.WriteFile(source, []byte("snapshot payload"), 0o600); err != nil {
- t.Fatal(err)
- }
-
- staged, err := StageFile(context.Background(), source, destination)
- if err != nil {
- t.Fatal(err)
- }
- if staged.Strategy == "" || staged.SHA256 != "" {
- t.Fatalf("staged result = %+v", staged)
- }
- finalized, err := FinalizeStagedFile(context.Background(), destination, staged)
- if err != nil {
- t.Fatal(err)
- }
- if finalized.SHA256 == "" || finalized.LogicalSizeBytes != int64(len("snapshot payload")) {
- t.Fatalf("finalized result = %+v", finalized)
- }
-}
-
-func TestProbeReflinkUsesRequestedDirectory(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- probe, err := ProbeReflink(dir)
- if err != nil {
- t.Fatal(err)
- }
- if probe.Directory != dir {
- t.Fatalf("probe directory = %q, want %q", probe.Directory, dir)
- }
-}
-
-func TestProbeReflinkRejectsFile(t *testing.T) {
- t.Parallel()
-
- file := filepath.Join(t.TempDir(), "not-a-directory")
- if err := os.WriteFile(file, nil, 0o600); err != nil {
- t.Fatal(err)
- }
- if _, err := ProbeReflink(file); err == nil {
- t.Fatal("expected file path to be rejected")
- }
-}
diff --git a/internal/disk/qemuimg.go b/internal/disk/qemuimg.go
deleted file mode 100644
index 42add4e..0000000
--- a/internal/disk/qemuimg.go
+++ /dev/null
@@ -1,167 +0,0 @@
-// Package disk prepares and validates durable VM-owned block devices.
-package disk
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-)
-
-const defaultQEMUImgTimeout = 30 * time.Second
-
-// OverlaySpec describes one qcow2 writable layer and its immutable backing file.
-type OverlaySpec struct {
- Path string
- BasePath string
- BaseFormat string
-}
-
-// ImageInfo is the qemu-img metadata needed to validate an existing overlay.
-type ImageInfo struct {
- Format string `json:"format"`
- BackingFilename string `json:"backing-filename"`
- VirtualSize int64 `json:"virtual-size"`
-}
-
-// QEMUImg is a bounded adapter around qemu-img. It never invokes a shell.
-type QEMUImg struct {
- binary string
- timeout time.Duration
-}
-
-// NewQEMUImg creates an adapter for binary.
-func NewQEMUImg(binary string) *QEMUImg {
- return &QEMUImg{binary: binary, timeout: defaultQEMUImgTimeout}
-}
-
-// EnsureOverlay atomically creates an overlay or validates the existing file.
-func (q *QEMUImg) EnsureOverlay(ctx context.Context, spec OverlaySpec) error {
- if err := validateOverlaySpec(spec); err != nil {
- return err
- }
- if _, err := os.Stat(spec.BasePath); err != nil {
- return fmt.Errorf("stat overlay base: %w", err)
- }
- if _, err := os.Stat(spec.Path); err == nil {
- return q.validateOverlay(ctx, spec)
- } else if !errors.Is(err, os.ErrNotExist) {
- return fmt.Errorf("stat overlay: %w", err)
- }
-
- if err := os.MkdirAll(filepath.Dir(spec.Path), 0o700); err != nil {
- return fmt.Errorf("create overlay owner directory: %w", err)
- }
- tmp, err := os.CreateTemp(filepath.Dir(spec.Path), ".root-overlay-*.qcow2")
- if err != nil {
- return fmt.Errorf("create overlay staging file: %w", err)
- }
- tmpPath := tmp.Name()
- if err := tmp.Close(); err != nil {
- _ = os.Remove(tmpPath)
- return fmt.Errorf("close overlay staging file: %w", err)
- }
- if err := os.Remove(tmpPath); err != nil {
- return fmt.Errorf("prepare overlay staging path: %w", err)
- }
- defer os.Remove(tmpPath) //nolint:errcheck
-
- if _, err := q.run(ctx, "create", "-f", "qcow2", "-F", spec.BaseFormat, "-b", spec.BasePath, tmpPath); err != nil {
- return fmt.Errorf("create qcow2 overlay: %w", err)
- }
- if err := q.validateOverlay(ctx, OverlaySpec{Path: tmpPath, BasePath: spec.BasePath, BaseFormat: spec.BaseFormat}); err != nil {
- return err
- }
- if err := os.Chmod(tmpPath, 0o600); err != nil {
- return fmt.Errorf("set overlay permissions: %w", err)
- }
- if err := os.Rename(tmpPath, spec.Path); err != nil {
- return fmt.Errorf("publish qcow2 overlay: %w", err)
- }
- return nil
-}
-
-// Info returns qemu-img metadata for path.
-func (q *QEMUImg) Info(ctx context.Context, path string) (ImageInfo, error) {
- out, err := q.run(ctx, "info", "--output=json", path)
- if err != nil {
- return ImageInfo{}, fmt.Errorf("inspect image: %w", err)
- }
- var info ImageInfo
- if err := json.Unmarshal(out, &info); err != nil {
- return ImageInfo{}, fmt.Errorf("decode qemu-img info: %w", err)
- }
- return info, nil
-}
-
-// RebaseOverlay rewrites an imported qcow2 overlay to an equivalent local base.
-// Callers must verify the base digest before using the metadata-only operation.
-func (q *QEMUImg) RebaseOverlay(ctx context.Context, overlay, base, baseFormat string) error {
- if err := validateOverlaySpec(OverlaySpec{Path: overlay, BasePath: base, BaseFormat: baseFormat}); err != nil {
- return err
- }
- if _, err := q.run(ctx, "rebase", "-u", "-f", "qcow2", "-F", baseFormat, "-b", base, overlay); err != nil {
- return fmt.Errorf("rebase qcow2 overlay: %w", err)
- }
- return q.validateOverlay(ctx, OverlaySpec{Path: overlay, BasePath: base, BaseFormat: baseFormat})
-}
-
-func (q *QEMUImg) validateOverlay(ctx context.Context, spec OverlaySpec) error {
- info, err := q.Info(ctx, spec.Path)
- if err != nil {
- return err
- }
- if info.Format != "qcow2" {
- return fmt.Errorf("overlay format is %q, want qcow2", info.Format)
- }
- actualBase, err := filepath.Abs(info.BackingFilename)
- if err != nil {
- return fmt.Errorf("resolve overlay backing path: %w", err)
- }
- wantBase, err := filepath.Abs(spec.BasePath)
- if err != nil {
- return fmt.Errorf("resolve expected backing path: %w", err)
- }
- if filepath.Clean(actualBase) != filepath.Clean(wantBase) {
- return fmt.Errorf("overlay backing file is %q, want %q", info.BackingFilename, spec.BasePath)
- }
- if info.VirtualSize <= 0 {
- return errors.New("overlay virtual size must be positive")
- }
- return nil
-}
-
-func (q *QEMUImg) run(parent context.Context, args ...string) ([]byte, error) {
- if q == nil || strings.TrimSpace(q.binary) == "" {
- return nil, errors.New("qemu-img binary must not be empty")
- }
- ctx, cancel := context.WithTimeout(parent, q.timeout)
- defer cancel()
- cmd := exec.CommandContext(ctx, q.binary, args...) //nolint:gosec
- out, err := cmd.CombinedOutput()
- if errors.Is(ctx.Err(), context.DeadlineExceeded) {
- return nil, fmt.Errorf("qemu-img timed out after %s", q.timeout)
- }
- if err != nil {
- return nil, fmt.Errorf("qemu-img %s: %w: %s", args[0], err, strings.TrimSpace(string(out)))
- }
- return out, nil
-}
-
-func validateOverlaySpec(spec OverlaySpec) error {
- if !filepath.IsAbs(spec.Path) || !filepath.IsAbs(spec.BasePath) {
- return errors.New("overlay and base paths must be absolute")
- }
- if filepath.Clean(spec.Path) == filepath.Clean(spec.BasePath) {
- return errors.New("overlay path must differ from base path")
- }
- if spec.BaseFormat != "qcow2" {
- return fmt.Errorf("unsupported overlay base format %q", spec.BaseFormat)
- }
- return nil
-}
diff --git a/internal/disk/qemuimg_test.go b/internal/disk/qemuimg_test.go
deleted file mode 100644
index 44a9225..0000000
--- a/internal/disk/qemuimg_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package disk
-
-import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestQEMUImgEnsureOverlayCreatesAndValidatesBacking(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- base := filepath.Join(dir, "base.qcow2")
- overlay := filepath.Join(dir, "vm", "root.overlay.qcow2")
- if err := os.WriteFile(base, []byte("base"), 0o600); err != nil {
- t.Fatal(err)
- }
- qemuImg := NewQEMUImg(fakeQEMUImg(t, dir, base))
- spec := OverlaySpec{Path: overlay, BasePath: base, BaseFormat: "qcow2"}
- if err := qemuImg.EnsureOverlay(context.Background(), spec); err != nil {
- t.Fatal(err)
- }
- if err := qemuImg.EnsureOverlay(context.Background(), spec); err != nil {
- t.Fatalf("validate existing overlay: %v", err)
- }
- info, err := os.Stat(overlay)
- if err != nil {
- t.Fatal(err)
- }
- if info.Mode().Perm() != 0o600 {
- t.Fatalf("overlay mode = %o, want 600", info.Mode().Perm())
- }
-}
-
-func TestQEMUImgEnsureOverlayRejectsUnexpectedBacking(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- base := filepath.Join(dir, "base.qcow2")
- overlay := filepath.Join(dir, "root.overlay.qcow2")
- if err := os.WriteFile(base, []byte("base"), 0o600); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(overlay, []byte("overlay"), 0o600); err != nil {
- t.Fatal(err)
- }
- qemuImg := NewQEMUImg(fakeQEMUImg(t, dir, filepath.Join(dir, "other.qcow2")))
- if err := qemuImg.EnsureOverlay(context.Background(), OverlaySpec{
- Path: overlay, BasePath: base, BaseFormat: "qcow2",
- }); err == nil {
- t.Fatal("expected backing mismatch")
- }
-}
-
-func TestQEMUImgRebaseOverlayValidatesLocalBacking(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- base := filepath.Join(dir, "base.qcow2")
- overlay := filepath.Join(dir, "root.overlay.qcow2")
- for _, path := range []string{base, overlay} {
- if err := os.WriteFile(path, []byte("image"), 0o600); err != nil {
- t.Fatal(err)
- }
- }
- qemuImg := NewQEMUImg(fakeQEMUImg(t, dir, base))
- if err := qemuImg.RebaseOverlay(context.Background(), overlay, base, "qcow2"); err != nil {
- t.Fatal(err)
- }
-}
-
-func fakeQEMUImg(t *testing.T, dir, backing string) string {
- t.Helper()
- path := filepath.Join(dir, "qemu-img")
- script := fmt.Sprintf(`#!/bin/sh
-set -eu
-case "$1" in
- create)
- for last do :; done
- : > "$last"
- ;;
- info)
- printf '%%s\n' '{"format":"qcow2","backing-filename":%q,"virtual-size":1048576}'
- ;;
- rebase)
- ;;
- *) exit 2 ;;
-esac
-`, backing)
- if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
- t.Fatal(err)
- }
- return path
-}
diff --git a/internal/disk/reflink.go b/internal/disk/reflink.go
deleted file mode 100644
index b4e51c9..0000000
--- a/internal/disk/reflink.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package disk
-
-import (
- "fmt"
- "os"
-)
-
-// ReflinkProbe describes whether the directory's filesystem supports local
-// copy-on-write file cloning. The directory must be the actual destination
-// directory used by the caller; probing another mount is not meaningful.
-type ReflinkProbe struct {
- Directory string `json:"directory"`
- Supported bool `json:"supported"`
-}
-
-// ProbeReflink tests FICLONE in directory without touching application data.
-// Unsupported filesystems return Supported=false and a nil error so callers
-// can choose the Cocoon-compatible sparse/stream fallback.
-func ProbeReflink(directory string) (ReflinkProbe, error) {
- info, err := os.Stat(directory)
- if err != nil {
- return ReflinkProbe{}, fmt.Errorf("stat reflink directory: %w", err)
- }
- if !info.IsDir() {
- return ReflinkProbe{}, fmt.Errorf("reflink path is not a directory: %s", directory)
- }
- supported, err := probeReflink(directory)
- if err != nil {
- return ReflinkProbe{}, err
- }
- return ReflinkProbe{Directory: directory, Supported: supported}, nil
-}
diff --git a/internal/disk/reflink_linux.go b/internal/disk/reflink_linux.go
deleted file mode 100644
index 0b595af..0000000
--- a/internal/disk/reflink_linux.go
+++ /dev/null
@@ -1,41 +0,0 @@
-//go:build linux
-
-package disk
-
-import (
- "fmt"
- "os"
- "path/filepath"
-
- "github.com/kumabox/kumabox/internal/fileutil"
- "golang.org/x/sys/unix"
-)
-
-func probeReflink(directory string) (result bool, err error) {
- tmp, err := os.MkdirTemp(directory, ".kumabox-reflink-probe-")
- if err != nil {
- return false, fmt.Errorf("create reflink probe directory: %w", err)
- }
- defer os.RemoveAll(tmp) //nolint:errcheck
-
- sourcePath := filepath.Join(tmp, "source")
- destinationPath := filepath.Join(tmp, "destination")
- if err := os.WriteFile(sourcePath, []byte("kumabox-reflink-probe"), 0o600); err != nil {
- return false, fmt.Errorf("write reflink probe source: %w", err)
- }
- source, err := os.Open(sourcePath) //nolint:gosec
- if err != nil {
- return false, fmt.Errorf("open reflink probe source: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, source, "close reflink probe source")
- destination, err := os.OpenFile(destinationPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) //nolint:gosec
- if err != nil {
- return false, fmt.Errorf("create reflink probe destination: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, destination, "close reflink probe destination")
-
- if err := unix.IoctlFileClone(int(destination.Fd()), int(source.Fd())); err != nil {
- return false, nil
- }
- return true, nil
-}
diff --git a/internal/disk/reflink_other.go b/internal/disk/reflink_other.go
deleted file mode 100644
index f1ac1b0..0000000
--- a/internal/disk/reflink_other.go
+++ /dev/null
@@ -1,7 +0,0 @@
-//go:build !linux
-
-package disk
-
-func probeReflink(string) (bool, error) {
- return false, nil
-}
diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go
deleted file mode 100644
index ce0f865..0000000
--- a/internal/doctor/doctor.go
+++ /dev/null
@@ -1,258 +0,0 @@
-// Package doctor runs host capability checks for KumaBox.
-//
-// Checks are deliberately descriptive rather than merely boolean because the
-// Linux/KVM/network setup has several common failure modes that need actionable
-// operator feedback.
-package doctor
-
-import (
- "os"
- "os/exec"
- "runtime"
-
- "github.com/kumabox/kumabox/internal/config"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
-)
-
-const (
- // StatusPass means the check succeeded.
- StatusPass = "pass"
-
- // StatusWarn means KumaBox can often proceed, but the operator may need
- // elevated permissions or a different environment.
- StatusWarn = "warn"
-
- // StatusFail means the checked capability is unavailable.
- StatusFail = "fail"
-)
-
-// Report groups all doctor checks with an aggregate status.
-type Report struct {
- Status string `json:"status"`
- Checks []Check `json:"checks"`
-}
-
-// Check describes one host capability result.
-type Check struct {
- Name string `json:"name"`
- Status string `json:"status"`
- Code string `json:"code,omitempty"`
- Message string `json:"message"`
- SuggestedAction string `json:"suggestedAction,omitempty"`
-}
-
-// Run executes host, backend, and network capability checks.
-//
-// The function does not mutate host state. Setup commands such as network setup
-// are responsible for making changes after the operator has reviewed failures.
-func Run(cfg config.Config) Report {
- checks := []Check{
- checkPaths(cfg),
- checkKVM(),
- checkCloudHypervisor(cfg),
- checkNetworkProvider(cfg),
- checkNetworkTun(cfg),
- checkNetworkIPCommand(cfg),
- checkNetworkNAT(cfg),
- checkNetworkPermission(cfg),
- }
- return Report{
- Status: overallStatus(checks),
- Checks: checks,
- }
-}
-
-func checkNetworkProvider(cfg config.Config) Check {
- provider, err := kbnetwork.ResolveProvider(cfg.Network)
- if err != nil {
- return Check{
- Name: "networkProvider",
- Status: StatusFail,
- Code: "NETWORK_PROVIDER_NOT_CONFIGURED",
- Message: err.Error(),
- SuggestedAction: "set network.mode to host-tap, cni, or none",
- }
- }
- return Check{Name: "networkProvider", Status: StatusPass, Message: "network provider mode is " + provider}
-}
-
-func checkNetworkTun(cfg config.Config) Check {
- if cfg.Network.Mode == kbnetwork.ProviderNone {
- return Check{Name: "networkTun", Status: StatusPass, Message: "network disabled"}
- }
- if runtime.GOOS != "linux" {
- return Check{
- Name: "networkTun",
- Status: StatusFail,
- Code: "NETWORK_TUN_UNAVAILABLE",
- Message: "tuntap networking is only available on Linux",
- SuggestedAction: "run network-enabled KumaBox commands inside the Linux VM",
- }
- }
- if _, err := os.Stat("/dev/net/tun"); err != nil {
- return Check{
- Name: "networkTun",
- Status: StatusFail,
- Code: "TUNTAP_MISSING",
- Message: err.Error(),
- SuggestedAction: "load the tun module and ensure /dev/net/tun exists",
- }
- }
- return Check{Name: "networkTun", Status: StatusPass, Message: "/dev/net/tun exists"}
-}
-
-func checkNetworkIPCommand(cfg config.Config) Check {
- if cfg.Network.Mode == kbnetwork.ProviderNone {
- return Check{Name: "networkIPCommand", Status: StatusPass, Message: "network disabled"}
- }
- path, err := exec.LookPath("ip")
- if err != nil {
- return Check{
- Name: "networkIPCommand",
- Status: StatusFail,
- Code: "IPROUTE2_MISSING",
- Message: "ip command not found",
- SuggestedAction: "install iproute2",
- }
- }
- return Check{Name: "networkIPCommand", Status: StatusPass, Message: "found " + path}
-}
-
-func checkNetworkNAT(cfg config.Config) Check {
- if cfg.Network.Mode == kbnetwork.ProviderNone || cfg.Network.NATBackend == kbnetwork.NATBackendNone {
- return Check{Name: "networkNAT", Status: StatusPass, Message: "NAT disabled"}
- }
- iptablesPath, iptablesErr := exec.LookPath("iptables")
- nftPath, nftErr := exec.LookPath("nft")
- switch cfg.Network.NATBackend {
- case kbnetwork.NATBackendIPTables:
- if iptablesErr != nil {
- return Check{
- Name: "networkNAT",
- Status: StatusFail,
- Code: "IPTABLES_MISSING",
- Message: "iptables command not found",
- SuggestedAction: "install iptables or set network.nat_backend to nft",
- }
- }
- return Check{Name: "networkNAT", Status: StatusPass, Message: "found " + iptablesPath}
- case kbnetwork.NATBackendNFT:
- if nftErr != nil {
- return Check{
- Name: "networkNAT",
- Status: StatusFail,
- Code: "NFT_MISSING",
- Message: "nft command not found",
- SuggestedAction: "install nftables or set network.nat_backend to iptables",
- }
- }
- return Check{Name: "networkNAT", Status: StatusPass, Message: "found " + nftPath}
- default:
- if iptablesErr == nil {
- return Check{Name: "networkNAT", Status: StatusPass, Message: "found " + iptablesPath}
- }
- if nftErr == nil {
- return Check{Name: "networkNAT", Status: StatusPass, Message: "found " + nftPath}
- }
- return Check{
- Name: "networkNAT",
- Status: StatusFail,
- Code: "NAT_BACKEND_MISSING",
- Message: "neither iptables nor nft is available",
- SuggestedAction: "install iptables or nftables",
- }
- }
-}
-
-func checkNetworkPermission(cfg config.Config) Check {
- if cfg.Network.Mode == kbnetwork.ProviderNone {
- return Check{Name: "networkPermission", Status: StatusPass, Message: "network disabled"}
- }
- if runtime.GOOS != "linux" {
- return Check{
- Name: "networkPermission",
- Status: StatusFail,
- Code: "NETWORK_PERMISSION_DENIED",
- Message: "network setup requires Linux root privileges",
- SuggestedAction: "run network-enabled KumaBox commands as root or through sudo inside the Linux VM",
- }
- }
- if os.Geteuid() != 0 {
- return Check{
- Name: "networkPermission",
- Status: StatusFail,
- Code: "NETWORK_PERMISSION_DENIED",
- Message: "current user is not root",
- SuggestedAction: "run network-enabled KumaBox commands as root or through sudo",
- }
- }
- return Check{Name: "networkPermission", Status: StatusPass, Message: "current user can configure host networking"}
-}
-
-func checkPaths(cfg config.Config) Check {
- if err := config.EnsureRuntimeDirs(cfg); err != nil {
- return Check{
- Name: "paths",
- Status: StatusFail,
- Code: "PATH_INIT_FAILED",
- Message: err.Error(),
- SuggestedAction: "ensure /var/lib/kumabox, /var/lib/kumabox/run, and /var/log/kumabox are writable",
- }
- }
- return Check{Name: "paths", Status: StatusPass, Message: "runtime directories are ready"}
-}
-
-func checkKVM() Check {
- if runtime.GOOS != "linux" {
- return Check{
- Name: "kvm",
- Status: StatusFail,
- Code: "KVM_UNAVAILABLE",
- Message: "KVM is only available on Linux",
- SuggestedAction: "run KumaBox inside the Linux VM with nested virtualization enabled",
- }
- }
-
- file, err := os.OpenFile("/dev/kvm", os.O_RDWR, 0)
- if err != nil {
- return Check{
- Name: "kvm",
- Status: StatusFail,
- Code: "KVM_UNAVAILABLE",
- Message: err.Error(),
- SuggestedAction: "enable nested virtualization and ensure the current user can access /dev/kvm",
- }
- }
- _ = file.Close()
- return Check{Name: "kvm", Status: StatusPass, Message: "/dev/kvm is accessible"}
-}
-
-func checkCloudHypervisor(cfg config.Config) Check {
- binary := cfg.Backend.CloudHypervisor.Binary
- path, err := exec.LookPath(binary)
- if err != nil {
- return Check{
- Name: "cloudHypervisor",
- Status: StatusFail,
- Code: "CH_MISSING",
- Message: "cloud-hypervisor binary not found: " + binary,
- SuggestedAction: "install Cloud Hypervisor or set backend.cloud_hypervisor.binary",
- }
- }
-
- return Check{Name: "cloudHypervisor", Status: StatusPass, Message: "found " + path}
-}
-
-func overallStatus(checks []Check) string {
- for _, check := range checks {
- if check.Status == StatusFail {
- return StatusFail
- }
- }
- for _, check := range checks {
- if check.Status == StatusWarn {
- return StatusWarn
- }
- }
- return StatusPass
-}
diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go
deleted file mode 100644
index ccb228f..0000000
--- a/internal/doctor/doctor_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package doctor
-
-import (
- "path/filepath"
- "testing"
-
- "github.com/kumabox/kumabox/internal/config"
-)
-
-func TestRunInitializesRuntimeDirectories(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- report := Run(cfg)
- if len(report.Checks) == 0 {
- t.Fatal("expected checks")
- }
-
- var foundPaths bool
- for _, check := range report.Checks {
- if check.Name == "paths" {
- foundPaths = true
- if check.Status != StatusPass {
- t.Fatalf("paths status = %s: %s", check.Status, check.Message)
- }
- }
- }
- if !foundPaths {
- t.Fatal("missing paths check")
- }
-}
diff --git a/internal/fault/fault.go b/internal/fault/fault.go
deleted file mode 100644
index 46e823a..0000000
--- a/internal/fault/fault.go
+++ /dev/null
@@ -1,62 +0,0 @@
-// Package fault provides deterministic, context-scoped failure injection for
-// tests of durable operation boundaries. Production callers carry no injector,
-// so Check is a no-op.
-package fault
-
-import (
- "context"
- "errors"
-)
-
-// Point identifies one stable persistence or external-side-effect boundary.
-type Point string
-
-// Injector decides whether execution should fail at a named point.
-type Injector interface {
- Check(Point) error
-}
-
-// InjectorFunc adapts a function to Injector.
-type InjectorFunc func(Point) error
-
-func (fn InjectorFunc) Check(point Point) error { return fn(point) }
-
-// ErrInterrupted marks a simulated process exit. Callers must return it
-// without publishing terminal operation state so a new process can reconcile
-// the durable running intent.
-var ErrInterrupted = errors.New("injected process interruption")
-
-// Interrupt returns an error that models process termination at point.
-func Interrupt(point Point) error { return interruption{point: point} }
-
-type interruption struct{ point Point }
-
-func (err interruption) Error() string { return string(err.point) + ": " + ErrInterrupted.Error() }
-func (err interruption) Unwrap() error { return ErrInterrupted }
-
-type contextKey struct{}
-
-// WithInjector returns a child context carrying an injector. The injector is
-// deliberately scoped to the call tree instead of package globals so parallel
-// tests and concurrent production operations cannot affect each other.
-func WithInjector(ctx context.Context, injector Injector) context.Context {
- if ctx == nil {
- ctx = context.Background()
- }
- if injector == nil {
- return ctx
- }
- return context.WithValue(ctx, contextKey{}, injector)
-}
-
-// Check invokes the context injector when one is present.
-func Check(ctx context.Context, point Point) error {
- if ctx == nil {
- return nil
- }
- injector, _ := ctx.Value(contextKey{}).(Injector)
- if injector == nil {
- return nil
- }
- return injector.Check(point)
-}
diff --git a/internal/fault/fault_test.go b/internal/fault/fault_test.go
deleted file mode 100644
index 79c1ff7..0000000
--- a/internal/fault/fault_test.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package fault
-
-import (
- "errors"
- "testing"
-)
-
-func TestCheckUsesOnlyContextInjector(t *testing.T) {
- want := errors.New("injected")
- ctx := WithInjector(t.Context(), InjectorFunc(func(point Point) error {
- if point == SnapshotBeforePublish {
- return want
- }
- return nil
- }))
- if err := Check(ctx, SnapshotBeforePublish); !errors.Is(err, want) {
- t.Fatalf("Check() error = %v, want %v", err, want)
- }
- if err := Check(t.Context(), SnapshotBeforePublish); err != nil {
- t.Fatalf("plain context Check() error = %v", err)
- }
-}
diff --git a/internal/fault/points.go b/internal/fault/points.go
deleted file mode 100644
index f8fd8c7..0000000
--- a/internal/fault/points.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package fault
-
-const (
- MetadataJSONBeforeRename Point = "metadata.json.before-rename"
- MetadataJSONAfterRename Point = "metadata.json.after-rename"
- MetadataConvertNamespace Point = "metadata.convert.after-namespace"
- MetadataConvertAfterCopy Point = "metadata.convert.after-copy"
- MetadataConvertRetired Point = "metadata.convert.after-source-retire"
- MetadataBackupBeforeSwap Point = "metadata.backup.before-swap"
- SnapshotBeforePublish Point = "snapshot.before-publish"
- SnapshotAfterRename Point = "snapshot.after-rename"
- NetworkAfterAdd Point = "network.after-add"
- NetworkAfterDelete Point = "network.after-del"
- CloneAfterStage Point = "clone.after-stage"
- CloneAfterDiskCommit Point = "clone.after-disk-commit"
- DeleteBeforeRecordDelete Point = "delete.before-record-delete"
- GCBeforeDelete Point = "gc.before-delete"
-)
diff --git a/internal/fileutil/cleanup.go b/internal/fileutil/cleanup.go
deleted file mode 100644
index 802b7e7..0000000
--- a/internal/fileutil/cleanup.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package fileutil
-
-import (
- "errors"
- "fmt"
- "io"
-)
-
-// CloseAndJoin closes a resource during deferred cleanup without discarding
-// the error. A cleanup failure is joined with the operation error so the
-// original failure remains discoverable with errors.Is and errors.As.
-func CloseAndJoin(errp *error, resource io.Closer, description string) {
- if errp == nil || resource == nil {
- return
- }
- if err := resource.Close(); err != nil {
- *errp = errors.Join(*errp, fmt.Errorf("%s: %w", description, err))
- }
-}
diff --git a/internal/fileutil/json.go b/internal/fileutil/json.go
deleted file mode 100644
index b9af058..0000000
--- a/internal/fileutil/json.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package fileutil
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
-)
-
-func WriteJSONAtomic(path string, value any, tempPattern string) error {
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return fmt.Errorf("create parent directory: %w", err)
- }
-
- raw, err := json.MarshalIndent(value, "", " ")
- if err != nil {
- return fmt.Errorf("marshal JSON: %w", err)
- }
- raw = append(raw, '\n')
-
- tmp, err := os.CreateTemp(filepath.Dir(path), tempPattern)
- if err != nil {
- return fmt.Errorf("create temp file: %w", err)
- }
- tmpPath := tmp.Name()
- defer os.Remove(tmpPath) //nolint:errcheck
-
- if _, err := tmp.Write(raw); err != nil {
- _ = tmp.Close()
- return fmt.Errorf("write temp file: %w", err)
- }
- if err := tmp.Sync(); err != nil {
- _ = tmp.Close()
- return fmt.Errorf("sync temp file: %w", err)
- }
- if err := tmp.Close(); err != nil {
- return fmt.Errorf("close temp file: %w", err)
- }
- if err := os.Rename(tmpPath, path); err != nil {
- return fmt.Errorf("rename temp file: %w", err)
- }
- return nil
-}
diff --git a/internal/gc/gc.go b/internal/gc/gc.go
deleted file mode 100644
index 22d6ed3..0000000
--- a/internal/gc/gc.go
+++ /dev/null
@@ -1,834 +0,0 @@
-// Package gc identifies and repairs KumaBox-managed resources that are no
-// longer owned by a live VM.
-package gc
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/lock"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/state"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// Candidate describes one file or directory that GC would remove in a future
-// non-dry-run mode.
-type Candidate struct {
- Component string `json:"component"`
- Path string `json:"path"`
- Type string `json:"type"`
- Reason string `json:"reason"`
-}
-
-// Report is the result of a GC scan.
-type Report struct {
- DryRun bool `json:"dryRun"`
- CheckedAt time.Time `json:"checkedAt"`
- Candidates []Candidate `json:"candidates"`
- Repaired []Candidate `json:"repaired,omitempty"`
- Skipped []Candidate `json:"skipped,omitempty"`
- SnapshotPolicy *SnapshotPolicyReport `json:"snapshotPolicy,omitempty"`
-}
-
-// Options enables optional policy-based collection in addition to orphan
-// reconciliation.
-type Options struct {
- SnapshotPolicy *SnapshotPolicy
-}
-
-// DryRun scans VM, runtime, log, and image state for orphaned managed files.
-//
-// It never removes data. The report is intended for operator review and for
-// validating GC policy before destructive cleanup is implemented.
-func DryRun(cfg config.Config) (*Report, error) {
- return DryRunContext(context.Background(), cfg, Options{})
-}
-
-// DryRunContext scans with optional policy rules without deleting state.
-func DryRunContext(ctx context.Context, cfg config.Config, options Options) (report *Report, err error) {
- stores, err := state.Open(cfg)
- if err != nil {
- return nil, fmt.Errorf("open resource stores: %w", err)
- }
- if stores.Metadata != nil {
- defer func() {
- if closeErr := stores.Metadata.Close(); closeErr != nil {
- err = errors.Join(err, fmt.Errorf("close metadata store: %w", closeErr))
- }
- }()
- }
- return scan(ctx, cfg, stores, options)
-}
-
-func scan(ctx context.Context, cfg config.Config, stores state.Set, options Options) (*Report, error) {
- records, err := stores.VM.List()
- if err != nil {
- return nil, fmt.Errorf("read VM store: %w", err)
- }
- images, err := stores.Images.List()
- if err != nil {
- return nil, fmt.Errorf("read image store: %w", err)
- }
- networkStore := stores.Networks
- networkRecords, err := networkStore.List()
- if err != nil {
- return nil, fmt.Errorf("read network store: %w", err)
- }
- leases, err := networkStore.ListLeases()
- if err != nil {
- return nil, fmt.Errorf("read network leases: %w", err)
- }
- snapshotStore := stores.Snapshots
- snapshots, err := snapshotStore.Scan()
- if err != nil {
- return nil, fmt.Errorf("read snapshot store: %w", err)
- }
-
- report := &Report{
- DryRun: true,
- CheckedAt: time.Now().UTC(),
- Candidates: []Candidate{},
- }
- liveRunDirs := map[string]struct{}{}
- liveLogDirs := map[string]struct{}{}
- liveImageIDs := map[string]struct{}{}
- liveStorageDirs := map[string]struct{}{}
- liveOCIPaths := map[string]struct{}{}
- liveOCIDigests := map[string]struct{}{}
-
- for _, rec := range records {
- liveRunDirs[rec.RunDir] = struct{}{}
- liveLogDirs[rec.LogDir] = struct{}{}
- liveStorageDirs[filepath.Join(cfg.Runtime.RootDir, "storage", "vms", rec.ID)] = struct{}{}
- if rec.Image != nil && rec.Image.ID != "" {
- liveImageIDs[rec.Image.ID] = struct{}{}
- }
- addLivePath(liveOCIPaths, rec.Kernel)
- addLivePath(liveOCIPaths, rec.Initrd)
- for _, storage := range rec.StorageConfigs {
- addLivePath(liveOCIPaths, storage.Path)
- }
- report.Candidates = append(report.Candidates, staleRuntimeFiles(rec)...)
- report.Candidates = append(report.Candidates, staleRestoreStaging(rec, report.CheckedAt)...)
- }
- snapshotCandidates, err := snapshotGCCandidates(
- ctx,
- snapshotStore,
- cfg.Runtime.RootDir,
- snapshots,
- report.CheckedAt,
- liveImageIDs,
- liveOCIPaths,
- liveOCIDigests,
- )
- if err != nil {
- return nil, err
- }
- for _, image := range images {
- addLiveImageOCI(liveOCIPaths, liveOCIDigests, image)
- }
-
- report.Candidates = append(report.Candidates, orphanDirs(filepath.Join(cfg.Runtime.RunDir, "vms"), liveRunDirs, "runtime", "orphan_run_dir")...)
- report.Candidates = append(report.Candidates, orphanDirs(filepath.Join(cfg.Runtime.LogDir, "vms"), liveLogDirs, "runtime", "orphan_log_dir")...)
- report.Candidates = append(report.Candidates, orphanDirs(filepath.Join(cfg.Runtime.RootDir, "storage", "vms"), liveStorageDirs, "storage", "orphan_vm_storage")...)
- report.Candidates = append(report.Candidates, snapshotCandidates...)
- report.Candidates = append(report.Candidates, imageCandidates(cfg.Runtime.RootDir, images, liveImageIDs)...)
- report.Candidates = append(report.Candidates, ociCandidates(cfg.Runtime.RootDir, liveOCIPaths, liveOCIDigests)...)
- report.Candidates = append(report.Candidates, networkCandidates(records, networkRecords, leases)...)
- if options.SnapshotPolicy != nil {
- policyReport, err := planSnapshotPolicy(ctx, stores, *options.SnapshotPolicy, report.CheckedAt)
- if err != nil {
- return nil, err
- }
- report.SnapshotPolicy = policyReport
- }
-
- sort.Slice(report.Candidates, func(i, j int) bool {
- if report.Candidates[i].Path == report.Candidates[j].Path {
- return report.Candidates[i].Type < report.Candidates[j].Type
- }
- return report.Candidates[i].Path < report.Candidates[j].Path
- })
- return report, nil
-}
-
-// Repair rescans before acting, then removes only candidates inside managed
-// roots. Network records are cleaned only when their VM is gone; drift on a
-// live VM is reported and left for explicit reconciliation.
-func Repair(cfg config.Config) (*Report, error) {
- return RepairContext(context.Background(), cfg)
-}
-
-// RepairContext excludes concurrent resource publication for the complete
-// scan-and-delete cycle. Candidates are discovered only after the exclusive
-// lock is held, so a report produced before lock acquisition is never used.
-func RepairContext(ctx context.Context, cfg config.Config) (*Report, error) {
- return RepairWithOptions(ctx, cfg, Options{})
-}
-
-// RepairWithOptions performs orphan repair and optional snapshot policy
-// eviction under one maintenance lock and one consistent resource setup.
-func RepairWithOptions(ctx context.Context, cfg config.Config, options Options) (report *Report, err error) {
- maintenance, err := lock.NewGuard(cfg.Runtime.RootDir).BeginMaintenance(ctx)
- if err != nil {
- return nil, err
- }
- defer func() {
- if releaseErr := maintenance.Release(); releaseErr != nil {
- err = errors.Join(err, fmt.Errorf("release GC maintenance lock: %w", releaseErr))
- }
- }()
-
- stores, err := state.Open(cfg)
- if err != nil {
- return nil, fmt.Errorf("open resource stores for repair: %w", err)
- }
- if stores.Metadata != nil {
- defer func() {
- if closeErr := stores.Metadata.Close(); closeErr != nil {
- err = errors.Join(err, fmt.Errorf("close metadata store: %w", closeErr))
- }
- }()
- }
- report, err = scan(ctx, cfg, stores, options)
- if err != nil {
- return nil, err
- }
- networkRecords, err := stores.Networks.List()
- if err != nil {
- return nil, fmt.Errorf("read network records for repair: %w", err)
- }
- report.DryRun = false
- for _, candidate := range report.Candidates {
- if candidate.Component == "network" {
- if err := repairNetworkCandidate(ctx, cfg, stores.Networks, networkRecords, candidate); err != nil {
- return nil, err
- }
- if candidate.Type == "network_drift" {
- report.Skipped = append(report.Skipped, candidate)
- } else {
- report.Repaired = append(report.Repaired, candidate)
- }
- continue
- }
- if !managedCandidatePath(cfg, candidate.Path) {
- report.Skipped = append(report.Skipped, candidate)
- continue
- }
- if err := os.RemoveAll(candidate.Path); err != nil {
- return nil, fmt.Errorf("repair %s: %w", candidate.Path, err)
- }
- report.Repaired = append(report.Repaired, candidate)
- }
- if report.SnapshotPolicy != nil {
- if err := applySnapshotPolicy(ctx, stores, report.SnapshotPolicy); err != nil {
- return nil, err
- }
- }
- return report, nil
-}
-
-func managedCandidatePath(cfg config.Config, path string) bool {
- if path == "" || !filepath.IsAbs(path) {
- return false
- }
- for _, root := range []string{cfg.Runtime.RootDir, cfg.Runtime.RunDir, cfg.Runtime.LogDir} {
- rel, err := filepath.Rel(root, path)
- if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
- return true
- }
- }
- return false
-}
-
-func repairNetworkCandidate(ctx context.Context, cfg config.Config, store state.NetworkState, records []kbnetwork.Record, candidate Candidate) error {
- if candidate.Type == "network_drift" {
- return nil
- }
- providerStore, ok := store.(*kbnetwork.Store)
- if !ok {
- return fmt.Errorf("network repair requires a concrete network store")
- }
- allocator := kbnetwork.NewAllocatorWithStore(providerStore, cfg.Network)
- if candidate.Type == "orphan_lease" {
- return allocator.ReleaseIP(candidate.Path)
- }
- for _, rec := range records {
- if rec.ID != candidate.Path && rec.TAP != candidate.Path {
- continue
- }
- switch rec.Provider {
- case kbnetwork.ProviderHostTap:
- if err := kbnetwork.DeleteHostTap(rec.TAP); err != nil {
- return fmt.Errorf("delete stale tap %s: %w", rec.TAP, err)
- }
- case kbnetwork.ProviderCNI:
- if err := kbnetwork.DeleteCNI(ctx, cfg.Runtime.RootDir, cfg.Network, kbnetwork.CNIDeleteRequest{VMID: rec.VMID, Network: rec.Network, IfName: rec.IfName, TAP: rec.TAP, NetNSPath: rec.NetnsPath}); err != nil {
- return fmt.Errorf("delete stale CNI network %s: %w", rec.ID, err)
- }
- }
- if err := allocator.ReleaseIP(firstString(rec.IPs)); err != nil {
- return err
- }
- return store.DeleteRecord(rec.ID)
- }
- return nil
-}
-
-func snapshotGCCandidates(
- ctx context.Context,
- store state.SnapshotState,
- rootDir string,
- records []*snapshot.Record,
- now time.Time,
- liveImageIDs map[string]struct{},
- liveOCIPaths map[string]struct{},
- liveOCIDigests map[string]struct{},
-) ([]Candidate, error) {
- const pendingGrace = time.Hour
- snapshotDir := filepath.Join(rootDir, "snapshot")
- liveStaging := make(map[string]struct{}, len(records))
- indexedIDs := make(map[string]struct{}, len(records))
- var candidates []Candidate
- for _, rec := range records {
- indexedIDs[rec.ID] = struct{}{}
- if rec.StagingDir != "" {
- liveStaging[rec.StagingDir] = struct{}{}
- }
- switch rec.State {
- case snapshot.StateReady:
- if _, err := os.Stat(rec.DataDir); errors.Is(err, os.ErrNotExist) {
- candidates = append(candidates, Candidate{Component: "snapshot", Path: rec.DataDir, Type: "missing_snapshot_payload", Reason: "ready snapshot index record has no payload directory"})
- continue
- } else if err != nil {
- return nil, fmt.Errorf("stat ready snapshot %s: %w", rec.ID, err)
- }
- manifest, err := store.PeekManifest(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("read ready snapshot %s: %w", rec.ID, err)
- }
- if err := addSnapshotLiveSet(rootDir, manifest, liveImageIDs, liveOCIPaths, liveOCIDigests); err != nil {
- return nil, fmt.Errorf("read ready snapshot %s references: %w", rec.ID, err)
- }
- case snapshot.StatePending:
- if now.Sub(rec.UpdatedAt) < pendingGrace {
- continue
- }
- leased, err := store.IsLeased(rec.ID)
- if err != nil {
- return nil, fmt.Errorf("inspect snapshot lease %s: %w", rec.ID, err)
- }
- if !leased && rec.StagingDir != "" {
- candidates = append(candidates, Candidate{Component: "snapshot", Path: rec.StagingDir, Type: "stale_pending_snapshot", Reason: "pending snapshot exceeded the one hour grace period"})
- }
- case snapshot.StateDeleting:
- if now.Sub(rec.UpdatedAt) >= pendingGrace {
- candidates = append(candidates, Candidate{Component: "snapshot", Path: rec.DataDir, Type: "stale_deleting_snapshot", Reason: "snapshot delete transaction exceeded the one hour grace period"})
- }
- }
- }
- entries, err := readDirIfExists(filepath.Join(snapshotDir, "staging"))
- if err != nil {
- return nil, fmt.Errorf("read snapshot staging directory: %w", err)
- }
- for _, entry := range entries {
- path := filepath.Join(snapshotDir, "staging", entry.Name())
- if entry.IsDir() && pathOlderThan(path, now.Add(-pendingGrace)) {
- if _, ok := liveStaging[path]; !ok {
- candidates = append(candidates, Candidate{Component: "snapshot", Path: path, Type: "orphan_snapshot_staging", Reason: "staging directory has no snapshot index record"})
- }
- }
- }
- entries, err = readDirIfExists(snapshotDir)
- if err != nil {
- return nil, fmt.Errorf("read snapshot payload directory: %w", err)
- }
- for _, entry := range entries {
- if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "snap_") {
- continue
- }
- path := filepath.Join(snapshotDir, entry.Name())
- if _, ok := indexedIDs[entry.Name()]; !ok {
- candidates = append(candidates, Candidate{Component: "snapshot", Path: path, Type: "orphan_snapshot_payload", Reason: "payload directory has no snapshot index record"})
- }
- }
- return candidates, nil
-}
-
-func addSnapshotLiveSet(rootDir string, manifest *snapshot.Manifest, imageIDs map[string]struct{}, paths map[string]struct{}, digests map[string]struct{}) error {
- if manifest == nil {
- return nil
- }
- if manifest.Source.ImageID != "" {
- imageIDs[manifest.Source.ImageID] = struct{}{}
- }
- if err := addSnapshotContentDigest(manifest.Source.ImageDigest, digests); err != nil {
- return fmt.Errorf("source image digest: %w", err)
- }
- if manifest.Base != nil {
- if manifest.Base.ImageID != "" {
- imageIDs[manifest.Base.ImageID] = struct{}{}
- }
- if err := addSnapshotContentDigest(manifest.Base.Digest, digests); err != nil {
- return fmt.Errorf("base digest: %w", err)
- }
- for _, digest := range manifest.Base.LayerDigests {
- if err := addSnapshotDigestAssets(rootDir, digest, paths, digests); err != nil {
- return fmt.Errorf("base layer digest: %w", err)
- }
- }
- }
- if manifest.Boot != nil {
- if err := addSnapshotBootAsset(rootDir, manifest.Boot.KernelDigest, paths); err != nil {
- return fmt.Errorf("kernel digest: %w", err)
- }
- if err := addSnapshotBootAsset(rootDir, manifest.Boot.InitrdDigest, paths); err != nil {
- return fmt.Errorf("initrd digest: %w", err)
- }
- }
- return nil
-}
-
-func addSnapshotDigestAssets(rootDir, digest string, paths map[string]struct{}, digests map[string]struct{}) error {
- algorithm, value, err := parseSnapshotDigest(digest)
- if err != nil || digest == "" {
- return err
- }
- digests[digest] = struct{}{}
- paths[filepath.Join(rootDir, "oci", "erofs", "blobs", algorithm, value+".erofs")] = struct{}{}
- return nil
-}
-
-func addSnapshotContentDigest(digest string, digests map[string]struct{}) error {
- _, _, err := parseSnapshotDigest(digest)
- if err != nil || digest == "" {
- return err
- }
- digests[digest] = struct{}{}
- return nil
-}
-
-func addSnapshotBootAsset(rootDir, digest string, paths map[string]struct{}) error {
- algorithm, value, err := parseSnapshotDigest(digest)
- if err != nil || digest == "" {
- return err
- }
- paths[filepath.Join(rootDir, "oci", "boot", "blobs", algorithm, value)] = struct{}{}
- return nil
-}
-
-func parseSnapshotDigest(digest string) (string, string, error) {
- if digest == "" {
- return "", "", nil
- }
- algorithm, value, ok := strings.Cut(digest, ":")
- if !ok || algorithm != "sha256" || len(value) != 64 {
- return "", "", fmt.Errorf("invalid digest %q", digest)
- }
- for _, char := range value {
- if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
- return "", "", fmt.Errorf("invalid digest %q", digest)
- }
- }
- return algorithm, value, nil
-}
-
-func staleRestoreStaging(rec *vm.VMRecord, now time.Time) []Candidate {
- if rec == nil {
- return nil
- }
- path := filepath.Join(rec.RunDir, ".restore-staging")
- if !pathOlderThan(path, now.Add(-time.Hour)) {
- return nil
- }
- return []Candidate{{
- Component: "snapshot", Path: path, Type: "stale_restore_staging",
- Reason: "restore staging directory exceeded the one hour grace period",
- }}
-}
-
-func pathOlderThan(path string, cutoff time.Time) bool {
- info, err := os.Stat(path)
- return err == nil && info.ModTime().Before(cutoff)
-}
-
-func readDirIfExists(path string) ([]os.DirEntry, error) {
- entries, err := os.ReadDir(path)
- if errors.Is(err, os.ErrNotExist) {
- return nil, nil
- }
- return entries, err
-}
-
-func addLivePath(live map[string]struct{}, path string) {
- if path == "" {
- return
- }
- live[path] = struct{}{}
-}
-
-func addLiveImageOCI(paths map[string]struct{}, digests map[string]struct{}, image *image.ImageRecord) {
- if image == nil {
- return
- }
- addLivePath(paths, image.Boot.Kernel)
- addLivePath(paths, image.Boot.Initrd)
- if image.OCI == nil {
- return
- }
- if _, digest, ok := strings.Cut(image.OCI.DigestRef, "@"); ok {
- digests[digest] = struct{}{}
- }
- if image.OCI.Config.Digest != "" {
- digests[image.OCI.Config.Digest] = struct{}{}
- }
- for _, layer := range image.OCI.Layers {
- if layer.Digest != "" {
- digests[layer.Digest] = struct{}{}
- }
- if layer.EROFS != nil {
- addLivePath(paths, layer.EROFS.Path)
- }
- addLivePath(paths, layer.Kernel)
- addLivePath(paths, layer.Initrd)
- }
-}
-
-func networkCandidates(
- vms []*vm.VMRecord,
- records []kbnetwork.Record,
- leases map[string]kbnetwork.Lease,
-) []Candidate {
- liveVMs := map[string]*vm.VMRecord{}
- vmConfigsByID := map[string]kbnetwork.Config{}
- liveIPs := map[string]struct{}{}
- for _, rec := range vms {
- if rec == nil {
- continue
- }
- liveVMs[rec.ID] = rec
- for _, cfg := range rec.NetworkConfigs {
- if cfg.ID != "" {
- vmConfigsByID[cfg.ID] = cfg
- }
- if cfg.Network != nil && cfg.Network.IP != "" {
- liveIPs[cfg.Network.IP] = struct{}{}
- }
- }
- }
-
- providerByID := map[string]kbnetwork.Record{}
- providerIPs := map[string]struct{}{}
- var candidates []Candidate
- for _, rec := range records {
- providerByID[rec.ID] = rec
- for _, ipCIDR := range rec.IPs {
- if ip := ipFromCIDR(ipCIDR); ip != "" {
- providerIPs[ip] = struct{}{}
- }
- }
- if rec.Cleanup.Pending {
- candidates = append(candidates, Candidate{
- Component: "network",
- Path: rec.ID,
- Type: "pending_cleanup",
- Reason: rec.Cleanup.Reason,
- })
- }
- vmRec, vmExists := liveVMs[rec.VMID]
- vmCfg, cfgExists := vmConfigsByID[rec.ID]
- switch {
- case !vmExists:
- candidates = append(candidates, Candidate{
- Component: "network",
- Path: rec.TAP,
- Type: "stale_tap",
- Reason: fmt.Sprintf("provider record %s references missing VM %s", rec.ID, rec.VMID),
- })
- case !cfgExists:
- candidates = append(candidates, Candidate{
- Component: "network",
- Path: rec.ID,
- Type: "network_drift",
- Reason: fmt.Sprintf("provider record %s is missing from VM %s network configs", rec.ID, vmRec.ID),
- })
- case networkConfigDrift(vmCfg, rec):
- candidates = append(candidates, Candidate{
- Component: "network",
- Path: rec.ID,
- Type: "network_drift",
- Reason: fmt.Sprintf("provider record %s differs from VM %s network config", rec.ID, vmRec.ID),
- })
- }
- }
-
- for cfgID := range vmConfigsByID {
- if _, ok := providerByID[cfgID]; ok {
- continue
- }
- candidates = append(candidates, Candidate{
- Component: "network",
- Path: cfgID,
- Type: "network_drift",
- Reason: "VM network config is missing provider record",
- })
- }
-
- for ip, lease := range leases {
- _, usedByVM := liveIPs[ip]
- _, usedByProvider := providerIPs[ip]
- if usedByVM || usedByProvider {
- continue
- }
- candidates = append(candidates, Candidate{
- Component: "network",
- Path: ip,
- Type: "orphan_lease",
- Reason: fmt.Sprintf("lease for tap %s is not referenced by VM or provider state", lease.TAP),
- })
- }
- return candidates
-}
-
-func networkConfigDrift(cfg kbnetwork.Config, rec kbnetwork.Record) bool {
- if cfg.TAP != rec.TAP || cfg.MAC != rec.MAC || cfg.Backend != rec.Provider || cfg.BridgeDev != rec.BridgeDev {
- return true
- }
- if cfg.Network == nil {
- return len(rec.IPs) > 0 || rec.Gateway != "" || len(rec.DNS) > 0
- }
- if cfg.Network.IP != ipFromCIDR(firstString(rec.IPs)) {
- return true
- }
- return cfg.Network.Gateway != rec.Gateway
-}
-
-func ipFromCIDR(value string) string {
- for i, r := range value {
- if r == '/' {
- return value[:i]
- }
- }
- return value
-}
-
-func firstString(values []string) string {
- if len(values) == 0 {
- return ""
- }
- return values[0]
-}
-
-func staleRuntimeFiles(rec *vm.VMRecord) []Candidate {
- if rec == nil || rec.State == vm.StateRunning {
- return nil
- }
- var candidates []Candidate
- for _, name := range []string{"ch.pid", "ch.sock", "vsock.uds"} {
- path := filepath.Join(rec.RunDir, name)
- if _, err := os.Stat(path); err == nil {
- typ := "stale_runtime_file"
- if name == "vsock.uds" {
- typ = "stale_agent_socket"
- }
- candidates = append(candidates, Candidate{
- Component: "runtime",
- Path: path,
- Type: typ,
- Reason: fmt.Sprintf("VM %s is %s but runtime file remains", rec.ID, rec.State),
- })
- }
- }
- return candidates
-}
-
-func orphanDirs(parent string, live map[string]struct{}, component string, typ string) []Candidate {
- entries, err := os.ReadDir(parent)
- if err != nil {
- return nil
- }
- var candidates []Candidate
- for _, entry := range entries {
- if !entry.IsDir() {
- continue
- }
- path := filepath.Join(parent, entry.Name())
- if _, ok := live[path]; ok {
- continue
- }
- candidates = append(candidates, Candidate{
- Component: component,
- Path: path,
- Type: typ,
- Reason: "directory is not referenced by VM store",
- })
- }
- return candidates
-}
-
-func imageCandidates(rootDir string, images []*image.ImageRecord, liveImageIDs map[string]struct{}) []Candidate {
- cloudimgDir := filepath.Join(rootDir, "cloudimg")
- indexedIDs := make(map[string]struct{}, len(images))
- for _, image := range images {
- if image == nil {
- continue
- }
- indexedIDs[image.ID] = struct{}{}
- }
-
- var candidates []Candidate
- candidates = append(candidates, imageStagingCandidates(filepath.Join(cloudimgDir, "staging"))...)
-
- entries, err := os.ReadDir(cloudimgDir)
- if err != nil {
- return candidates
- }
- for _, entry := range entries {
- if !entry.IsDir() {
- continue
- }
- name := entry.Name()
- if name == "staging" {
- continue
- }
- if _, ok := indexedIDs[name]; ok {
- continue
- }
- if _, ok := liveImageIDs[name]; ok {
- continue
- }
- candidates = append(candidates, Candidate{
- Component: "image",
- Path: filepath.Join(cloudimgDir, name),
- Type: "orphan_image_dir",
- Reason: "image directory is not referenced by image index or VM store",
- })
- }
- return candidates
-}
-
-func imageStagingCandidates(stagingDir string) []Candidate {
- entries, err := os.ReadDir(stagingDir)
- if err != nil {
- return nil
- }
- var candidates []Candidate
- for _, entry := range entries {
- if !entry.IsDir() {
- continue
- }
- candidates = append(candidates, Candidate{
- Component: "image",
- Path: filepath.Join(stagingDir, entry.Name()),
- Type: "image_staging_dir",
- Reason: "image staging directory is not referenced by image index",
- })
- }
- return candidates
-}
-
-func ociCandidates(rootDir string, livePaths map[string]struct{}, liveDigests map[string]struct{}) []Candidate {
- var candidates []Candidate
- candidates = append(candidates, ociStagingCandidates(filepath.Join(rootDir, "oci", "content", "staging"), "oci_content_staging")...)
- candidates = append(candidates, ociStagingCandidates(filepath.Join(rootDir, "oci", "staging"), "oci_build_staging")...)
- candidates = append(candidates, orphanOCIContentBlobs(rootDir, liveDigests)...)
- candidates = append(candidates, orphanOCIPathFiles(filepath.Join(rootDir, "oci", "erofs", "blobs"), livePaths, "oci", "orphan_erofs_blob")...)
- candidates = append(candidates, orphanOCIPathFiles(filepath.Join(rootDir, "oci", "boot", "blobs"), livePaths, "oci", "orphan_boot_asset")...)
- return candidates
-}
-
-func ociStagingCandidates(stagingDir string, typ string) []Candidate {
- entries, err := os.ReadDir(stagingDir)
- if err != nil {
- return nil
- }
- candidates := make([]Candidate, 0, len(entries))
- for _, entry := range entries {
- candidates = append(candidates, Candidate{
- Component: "oci",
- Path: filepath.Join(stagingDir, entry.Name()),
- Type: typ,
- Reason: "OCI staging path is not referenced by committed image state",
- })
- }
- return candidates
-}
-
-func orphanOCIContentBlobs(rootDir string, liveDigests map[string]struct{}) []Candidate {
- blobsDir := filepath.Join(rootDir, "oci", "content", "blobs")
- files := listRegularFiles(blobsDir)
- var candidates []Candidate
- for _, file := range files {
- digest := digestFromBlobPath(blobsDir, file)
- if digest == "" {
- continue
- }
- if _, ok := liveDigests[digest]; ok {
- continue
- }
- candidates = append(candidates, Candidate{
- Component: "oci",
- Path: file,
- Type: "orphan_content_blob",
- Reason: fmt.Sprintf("OCI content blob %s is not referenced by any image", digest),
- })
- }
- return candidates
-}
-
-func orphanOCIPathFiles(root string, livePaths map[string]struct{}, component string, typ string) []Candidate {
- files := listRegularFiles(root)
- var candidates []Candidate
- for _, file := range files {
- if _, ok := livePaths[file]; ok {
- continue
- }
- candidates = append(candidates, Candidate{
- Component: component,
- Path: file,
- Type: typ,
- Reason: "OCI artifact is not referenced by any image or VM",
- })
- }
- return candidates
-}
-
-func listRegularFiles(root string) []string {
- var files []string
- _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
- if err != nil || entry == nil || entry.IsDir() {
- return nil
- }
- info, statErr := entry.Info()
- if statErr != nil || !info.Mode().IsRegular() {
- return nil
- }
- files = append(files, path)
- return nil
- })
- sort.Strings(files)
- return files
-}
-
-func digestFromBlobPath(root string, file string) string {
- rel, err := filepath.Rel(root, file)
- if err != nil {
- return ""
- }
- algo, value := filepath.Split(filepath.ToSlash(rel))
- algo = strings.TrimSuffix(algo, "/")
- if algo == "" || value == "" {
- return ""
- }
- return algo + ":" + value
-}
diff --git a/internal/gc/gc_test.go b/internal/gc/gc_test.go
deleted file mode 100644
index 6e7d988..0000000
--- a/internal/gc/gc_test.go
+++ /dev/null
@@ -1,763 +0,0 @@
-package gc
-
-import (
- "context"
- "encoding/json"
- "errors"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/lock"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestDryRunReportsSnapshotAndStorageOrphansButProtectsLeasedPending(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- orphanStorage := filepath.Join(cfg.Runtime.RootDir, "storage", "vms", "kb_orphan")
- orphanStaging := filepath.Join(cfg.Runtime.RootDir, "snapshot", "staging", "capture-orphan")
- for _, path := range []string{orphanStorage, orphanStaging} {
- if err := os.MkdirAll(path, 0o700); err != nil {
- t.Fatal(err)
- }
- }
- old := time.Now().Add(-2 * time.Hour)
- if err := os.Chtimes(orphanStaging, old, old); err != nil {
- t.Fatal(err)
- }
- build, err := snapshot.NewStore(cfg.Runtime.RootDir).Reserve(context.Background(), "active-build")
- if err != nil {
- t.Fatal(err)
- }
- defer build.Abort() //nolint:errcheck
- indexPath := filepath.Join(cfg.Runtime.RootDir, "snapshot", "index.json")
- raw, err := os.ReadFile(indexPath)
- if err != nil {
- t.Fatal(err)
- }
- var index map[string]any
- if err := json.Unmarshal(raw, &index); err != nil {
- t.Fatal(err)
- }
- snapshots := index["snapshots"].(map[string]any)
- record := snapshots[build.Record().ID].(map[string]any)
- record["updatedAt"] = time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339Nano)
- raw, err = json.Marshal(index)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(indexPath, raw, 0o600); err != nil {
- t.Fatal(err)
- }
-
- report, err := DryRun(cfg)
- if err != nil {
- t.Fatal(err)
- }
- assertCandidate(t, report, orphanStorage, "orphan_vm_storage")
- assertCandidate(t, report, orphanStaging, "orphan_snapshot_staging")
- assertNoCandidate(t, report, build.Record().StagingDir)
-}
-
-func TestDryRunProtectsNativeSnapshotAssetsAndExplainsStaleStaging(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- const imageID = "img_snapshot_only"
- manifestDigest := "sha256:" + strings.Repeat("d", 64)
- layerDigest := "sha256:" + strings.Repeat("a", 64)
- kernelDigest := "sha256:" + strings.Repeat("b", 64)
- initrdDigest := "sha256:" + strings.Repeat("c", 64)
- manifest := snapshot.Manifest{
- SchemaVersion: "kumabox.snapshot.v2", Type: "native", Consistency: "crash",
- Source: snapshot.Source{VMID: "kb_deleted", ImageID: imageID},
- Base: &snapshot.Base{Family: "oci", ImageID: imageID, Digest: manifestDigest, LayerDigests: []string{layerDigest}},
- Boot: &snapshot.BootManifest{KernelDigest: kernelDigest, InitrdDigest: initrdDigest},
- }
- ready := createGCReadySnapshot(t, cfg.Runtime.RootDir, "native-live", manifest)
-
- imageDir := filepath.Join(cfg.Runtime.RootDir, "cloudimg", imageID)
- layerPath := filepath.Join(cfg.Runtime.RootDir, "oci", "erofs", "blobs", "sha256", strings.TrimPrefix(layerDigest, "sha256:")+".erofs")
- kernelPath := filepath.Join(cfg.Runtime.RootDir, "oci", "boot", "blobs", "sha256", strings.TrimPrefix(kernelDigest, "sha256:"))
- initrdPath := filepath.Join(cfg.Runtime.RootDir, "oci", "boot", "blobs", "sha256", strings.TrimPrefix(initrdDigest, "sha256:"))
- contentPath := filepath.Join(cfg.Runtime.RootDir, "oci", "content", "blobs", "sha256", strings.TrimPrefix(layerDigest, "sha256:"))
- manifestContentPath := filepath.Join(cfg.Runtime.RootDir, "oci", "content", "blobs", "sha256", strings.TrimPrefix(manifestDigest, "sha256:"))
- for _, path := range []string{filepath.Join(imageDir, "base.qcow2"), layerPath, kernelPath, initrdPath, contentPath, manifestContentPath} {
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("asset"), 0o600); err != nil {
- t.Fatal(err)
- }
- }
-
- vmStore := vm.New(cfg.Runtime.RootDir)
- vm, err := vmStore.Create(vm.CreateRequest{
- Name: "restore-staging", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd", RunDir: cfg.Runtime.RunDir, LogDir: cfg.Runtime.LogDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- staleRestore := filepath.Join(vm.RunDir, ".restore-staging")
- staleOrphan := filepath.Join(cfg.Runtime.RootDir, "snapshot", "staging", "orphan-old")
- freshOrphan := filepath.Join(cfg.Runtime.RootDir, "snapshot", "staging", "orphan-fresh")
- for _, path := range []string{staleRestore, staleOrphan, freshOrphan} {
- if err := os.MkdirAll(path, 0o700); err != nil {
- t.Fatal(err)
- }
- }
- old := time.Now().Add(-2 * time.Hour)
- for _, path := range []string{staleRestore, staleOrphan} {
- if err := os.Chtimes(path, old, old); err != nil {
- t.Fatal(err)
- }
- }
-
- report, err := DryRun(cfg)
- if err != nil {
- t.Fatal(err)
- }
- assertCandidate(t, report, staleRestore, "stale_restore_staging")
- assertCandidate(t, report, staleOrphan, "orphan_snapshot_staging")
- assertNoCandidate(t, report, freshOrphan)
- for _, protected := range []string{ready.DataDir, imageDir, layerPath, kernelPath, initrdPath, contentPath, manifestContentPath} {
- assertNoCandidate(t, report, protected)
- }
-}
-
-func TestDryRunFailsClosedForCorruptReadyNativeManifest(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
- ready := createGCReadySnapshot(t, cfg.Runtime.RootDir, "corrupt-native", snapshot.Manifest{
- SchemaVersion: "kumabox.snapshot.v2", Type: "native", Consistency: "crash",
- })
- if err := os.WriteFile(filepath.Join(ready.DataDir, "snapshot.json"), []byte("{"), 0o600); err != nil {
- t.Fatal(err)
- }
- if _, err := DryRun(cfg); err == nil || !strings.Contains(err.Error(), "read ready snapshot") {
- t.Fatalf("dry-run error = %v", err)
- }
-}
-
-func TestDryRunFailsClosedForInvalidNativeSnapshotReference(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
- createGCReadySnapshot(t, cfg.Runtime.RootDir, "invalid-reference", snapshot.Manifest{
- SchemaVersion: "kumabox.snapshot.v2", Type: "native", Consistency: "crash",
- Base: &snapshot.Base{Family: "oci", LayerDigests: []string{"sha256:not-a-digest"}},
- })
- if _, err := DryRun(cfg); err == nil || !strings.Contains(err.Error(), "base layer digest") {
- t.Fatalf("dry-run error = %v", err)
- }
-}
-
-func createGCReadySnapshot(t *testing.T, rootDir, name string, manifest snapshot.Manifest) *snapshot.Record {
- t.Helper()
- store := snapshot.NewStore(rootDir)
- build, err := store.Reserve(context.Background(), name)
- if err != nil {
- t.Fatal(err)
- }
- rec := build.Record()
- manifest.ID = rec.ID
- manifest.Name = rec.Name
- raw, err := json.Marshal(manifest)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(rec.StagingDir, "snapshot.json"), raw, 0o600); err != nil {
- t.Fatal(err)
- }
- ready, err := build.Finalize(int64(len(raw)))
- if err != nil {
- t.Fatal(err)
- }
- return ready
-}
-
-func TestDryRunReportsOnlyManagedCandidates(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- rootDisk := filepath.Join(dir, "fixtures", "base.qcow2")
- if err := os.MkdirAll(filepath.Dir(rootDisk), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(rootDisk, []byte("root disk"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- store := vm.New(cfg.Runtime.RootDir)
- rec, err := store.Create(vm.CreateRequest{
- Name: "gc",
- RootDisk: rootDisk,
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: cfg.Runtime.RunDir,
- LogDir: cfg.Runtime.LogDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(rec.RunDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(rec.RunDir, "ch.pid"), []byte("123\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(rec.RunDir, "vsock.uds"), nil, 0o600); err != nil {
- t.Fatal(err)
- }
- orphanRun := filepath.Join(cfg.Runtime.RunDir, "vms", "orphan")
- orphanLog := filepath.Join(cfg.Runtime.LogDir, "vms", "orphan")
- if err := os.MkdirAll(orphanRun, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(orphanLog, 0o755); err != nil {
- t.Fatal(err)
- }
-
- report, err := DryRun(cfg)
- if err != nil {
- t.Fatal(err)
- }
- assertCandidate(t, report, filepath.Join(rec.RunDir, "ch.pid"), "stale_runtime_file")
- assertCandidate(t, report, filepath.Join(rec.RunDir, "vsock.uds"), "stale_agent_socket")
- assertCandidate(t, report, orphanRun, "orphan_run_dir")
- assertCandidate(t, report, orphanLog, "orphan_log_dir")
- for _, candidate := range report.Candidates {
- if candidate.Path == rootDisk {
- t.Fatalf("root disk must not be a GC candidate: %+v", candidate)
- }
- }
-}
-
-func TestRepairRemovesOrphanManagedStorage(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
- orphan := filepath.Join(cfg.Runtime.RootDir, "storage", "vms", "kb_orphan")
- if err := os.MkdirAll(orphan, 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(orphan, "cow.ext4"), []byte("orphan"), 0o600); err != nil {
- t.Fatal(err)
- }
- report, err := Repair(cfg)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(orphan); !os.IsNotExist(err) {
- t.Fatalf("orphan storage still exists, stat error = %v", err)
- }
- assertCandidate(t, report, orphan, "orphan_vm_storage")
- if report.DryRun {
- t.Fatal("repair report is marked dry-run")
- }
-}
-
-func TestRepairWaitsForMutationAndScansAfterLockAcquisition(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
- orphan := filepath.Join(cfg.Runtime.RootDir, "storage", "vms", "kb_claimed")
- if err := os.MkdirAll(orphan, 0o700); err != nil {
- t.Fatal(err)
- }
-
- mutation, err := lock.NewGuard(cfg.Runtime.RootDir).BeginMutation(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = mutation.Release() })
-
- type repairResult struct {
- report *Report
- err error
- }
- result := make(chan repairResult, 1)
- go func() {
- report, repairErr := RepairContext(t.Context(), cfg)
- result <- repairResult{report: report, err: repairErr}
- }()
-
- select {
- case got := <-result:
- t.Fatalf("RepairContext completed during mutation: report=%+v err=%v", got.report, got.err)
- case <-time.After(75 * time.Millisecond):
- }
-
- // The in-flight mutation resolves what looked orphaned before GC entered
- // its critical section. GC must scan the post-mutation state.
- if err := os.RemoveAll(orphan); err != nil {
- t.Fatal(err)
- }
- if err := mutation.Release(); err != nil {
- t.Fatal(err)
- }
-
- select {
- case got := <-result:
- if got.err != nil {
- t.Fatal(got.err)
- }
- assertNoCandidate(t, got.report, orphan)
- case <-time.After(5 * time.Second):
- t.Fatal("RepairContext did not resume after mutation released")
- }
-}
-
-func TestRepairCancellationLeavesCandidatesUntouched(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
- orphan := filepath.Join(cfg.Runtime.RootDir, "storage", "vms", "kb_orphan")
- if err := os.MkdirAll(orphan, 0o700); err != nil {
- t.Fatal(err)
- }
-
- mutation, err := lock.NewGuard(cfg.Runtime.RootDir).BeginMutation(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- defer mutation.Release() //nolint:errcheck
-
- ctx, cancel := context.WithTimeout(t.Context(), 75*time.Millisecond)
- defer cancel()
- if _, err := RepairContext(ctx, cfg); !errors.Is(err, context.DeadlineExceeded) {
- t.Fatalf("RepairContext() error = %v, want context deadline", err)
- }
- if _, err := os.Stat(orphan); err != nil {
- t.Fatalf("candidate changed while repair waited for lock: %v", err)
- }
-}
-
-func TestDryRunReportsImageCandidates(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- imageStore := image.New(cfg.Runtime.RootDir)
- indexed, err := imageStore.Create(image.CreateRequest{
- Name: "indexed",
- Source: image.Source{Type: "test", URI: "fixtures/indexed.img"},
- RootDisk: image.RootDisk{
- Path: filepath.Join(cfg.Runtime.RootDir, "cloudimg", "img_indexed", "base.qcow2"),
- Format: "qcow2",
- },
- Boot: image.Boot{Mode: "uefi", Firmware: "CLOUDHV.fd"},
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(cfg.Runtime.RootDir, "cloudimg", indexed.ID), 0o755); err != nil {
- t.Fatal(err)
- }
- staging := filepath.Join(cfg.Runtime.RootDir, "cloudimg", "staging", "import-deadbeef")
- if err := os.MkdirAll(staging, 0o755); err != nil {
- t.Fatal(err)
- }
- orphan := filepath.Join(cfg.Runtime.RootDir, "cloudimg", "img_orphan")
- if err := os.MkdirAll(orphan, 0o755); err != nil {
- t.Fatal(err)
- }
- vmReferencedMissingFromIndex := filepath.Join(cfg.Runtime.RootDir, "cloudimg", "img_live_missing")
- if err := os.MkdirAll(vmReferencedMissingFromIndex, 0o755); err != nil {
- t.Fatal(err)
- }
-
- vmStore := vm.New(cfg.Runtime.RootDir)
- _, err = vmStore.Create(vm.CreateRequest{
- Name: "live-image",
- RootDisk: filepath.Join(vmReferencedMissingFromIndex, "base.qcow2"),
- Firmware: "CLOUDHV.fd",
- Image: &vm.ImageRef{
- ID: "img_live_missing",
- Name: "missing",
- RootDisk: filepath.Join(vmReferencedMissingFromIndex, "base.qcow2"),
- BootMode: "uefi",
- },
- RunDir: cfg.Runtime.RunDir,
- LogDir: cfg.Runtime.LogDir,
- })
- if err != nil {
- t.Fatal(err)
- }
-
- report, err := DryRun(cfg)
- if err != nil {
- t.Fatal(err)
- }
- assertCandidate(t, report, staging, "image_staging_dir")
- assertCandidate(t, report, orphan, "orphan_image_dir")
- assertNoCandidate(t, report, filepath.Join(cfg.Runtime.RootDir, "cloudimg", indexed.ID))
- assertNoCandidate(t, report, vmReferencedMissingFromIndex)
-}
-
-func TestDryRunReportsOCICandidates(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- liveKernel := filepath.Join(cfg.Runtime.RootDir, "oci", "boot", "blobs", "sha256", strings.Repeat("1", 64))
- liveInitrd := filepath.Join(cfg.Runtime.RootDir, "oci", "boot", "blobs", "sha256", strings.Repeat("2", 64))
- orphanBoot := filepath.Join(cfg.Runtime.RootDir, "oci", "boot", "blobs", "sha256", strings.Repeat("3", 64))
- liveEROFS := filepath.Join(cfg.Runtime.RootDir, "oci", "erofs", "blobs", "sha256", strings.Repeat("4", 64)+".erofs")
- orphanEROFS := filepath.Join(cfg.Runtime.RootDir, "oci", "erofs", "blobs", "sha256", strings.Repeat("5", 64)+".erofs")
- liveContent := filepath.Join(cfg.Runtime.RootDir, "oci", "content", "blobs", "sha256", strings.Repeat("6", 64))
- orphanContent := filepath.Join(cfg.Runtime.RootDir, "oci", "content", "blobs", "sha256", strings.Repeat("7", 64))
- contentStage := filepath.Join(cfg.Runtime.RootDir, "oci", "content", "staging", "blob-deadbeef")
- buildStage := filepath.Join(cfg.Runtime.RootDir, "oci", "staging", "erofs-deadbeef")
- for _, path := range []string{
- liveKernel, liveInitrd, orphanBoot, liveEROFS, orphanEROFS, liveContent, orphanContent, contentStage, filepath.Join(buildStage, "layer.tar"),
- } {
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
-
- _, err := image.New(cfg.Runtime.RootDir).Create(image.CreateRequest{
- Name: "oci-live",
- Source: image.Source{Type: "oci", URI: "example.com/live@sha256:test"},
- Boot: image.Boot{
- Mode: "direct",
- Kernel: liveKernel,
- Initrd: liveInitrd,
- },
- OCI: &image.OCI{
- Ref: "example.com/live:latest",
- Config: image.OCIDescriptor{
- Digest: "sha256:" + strings.Repeat("6", 64),
- },
- Layers: []image.OCILayer{
- {
- Digest: "sha256:" + strings.Repeat("6", 64),
- EROFS: &image.EROFSLayer{
- Path: liveEROFS,
- Filesystem: "erofs",
- Digest: "sha256:" + strings.Repeat("8", 64),
- },
- },
- },
- },
- })
- if err != nil {
- t.Fatal(err)
- }
-
- report, err := DryRun(cfg)
- if err != nil {
- t.Fatal(err)
- }
- assertCandidate(t, report, orphanBoot, "orphan_boot_asset")
- assertCandidate(t, report, orphanEROFS, "orphan_erofs_blob")
- assertCandidate(t, report, orphanContent, "orphan_content_blob")
- assertCandidate(t, report, contentStage, "oci_content_staging")
- assertCandidate(t, report, buildStage, "oci_build_staging")
- assertNoCandidate(t, report, liveKernel)
- assertNoCandidate(t, report, liveInitrd)
- assertNoCandidate(t, report, liveEROFS)
- assertNoCandidate(t, report, liveContent)
-}
-
-func TestDryRunFailsWhenImageIndexIsCorrupt(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- indexPath := filepath.Join(cfg.Runtime.RootDir, "cloudimg", "index.json")
- if err := os.MkdirAll(filepath.Dir(indexPath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(indexPath, []byte("{"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- if _, err := DryRun(cfg); err == nil {
- t.Fatal("expected corrupt image index error")
- }
-}
-
-func TestDryRunReportsNetworkPendingAndOrphans(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- vmStore := vm.New(cfg.Runtime.RootDir)
- rec, err := vmStore.Create(vm.CreateRequest{
- Name: "network-live",
- RootDisk: "base.qcow2",
- Firmware: "CLOUDHV.fd",
- RunDir: cfg.Runtime.RunDir,
- LogDir: cfg.Runtime.LogDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- liveAlloc, err := kbnetwork.NewAllocator(cfg.Runtime.RootDir, cfg.Network).Allocate(kbnetwork.AllocateRequest{
- VMID: rec.ID,
- Network: "default",
- Index: 0,
- CPU: 1,
- })
- if err != nil {
- t.Fatal(err)
- }
- networkStore := kbnetwork.NewStore(cfg.Runtime.RootDir)
- if err := networkStore.UpsertRecord(liveAlloc.Record); err != nil {
- t.Fatal(err)
- }
- if _, err := vmStore.SetNetworkConfigs(rec.ID, []kbnetwork.Config{liveAlloc.Config}); err != nil {
- t.Fatal(err)
- }
-
- pending := kbnetwork.Record{
- ID: "net_pending",
- VMID: "kb_missing",
- Network: "default",
- Provider: kbnetwork.ProviderHostTap,
- IfName: "eth0",
- TAP: "kbtappending",
- MAC: "5a:00:00:00:00:10",
- BridgeDev: cfg.Network.Bridge,
- IPs: []string{"10.88.0.42/16"},
- Gateway: cfg.Network.Gateway,
- Cleanup: kbnetwork.Cleanup{
- Pending: true,
- Reason: "tap delete failed",
- LastAttemptAt: time.Now().UTC().Format(time.RFC3339Nano),
- },
- CreatedAt: time.Now().UTC(),
- UpdatedAt: time.Now().UTC(),
- }
- if err := networkStore.UpsertRecord(pending); err != nil {
- t.Fatal(err)
- }
- orphanLease, err := kbnetwork.NewAllocator(cfg.Runtime.RootDir, cfg.Network).Allocate(kbnetwork.AllocateRequest{
- VMID: "kb_orphan",
- Network: "default",
- Index: 0,
- CPU: 1,
- })
- if err != nil {
- t.Fatal(err)
- }
-
- report, err := DryRun(cfg)
- if err != nil {
- t.Fatal(err)
- }
- assertNoCandidate(t, report, liveAlloc.Record.TAP)
- assertCandidate(t, report, pending.ID, "pending_cleanup")
- assertCandidate(t, report, pending.TAP, "stale_tap")
- assertCandidate(t, report, orphanLease.Config.Network.IP, "orphan_lease")
-}
-
-func TestDryRunReportsNetworkDriftWithoutDeleteGuess(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- vmStore := vm.New(cfg.Runtime.RootDir)
- rec, err := vmStore.Create(vm.CreateRequest{
- Name: "network-drift",
- RootDisk: "base.qcow2",
- Firmware: "CLOUDHV.fd",
- RunDir: cfg.Runtime.RunDir,
- LogDir: cfg.Runtime.LogDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- alloc, err := kbnetwork.NewAllocator(cfg.Runtime.RootDir, cfg.Network).Allocate(kbnetwork.AllocateRequest{
- VMID: rec.ID,
- Network: "default",
- Index: 0,
- CPU: 1,
- })
- if err != nil {
- t.Fatal(err)
- }
- drifted := alloc.Record
- drifted.MAC = "5a:00:00:00:00:99"
- if err := kbnetwork.NewStore(cfg.Runtime.RootDir).UpsertRecord(drifted); err != nil {
- t.Fatal(err)
- }
- if _, err := vmStore.SetNetworkConfigs(rec.ID, []kbnetwork.Config{alloc.Config}); err != nil {
- t.Fatal(err)
- }
-
- report, err := DryRun(cfg)
- if err != nil {
- t.Fatal(err)
- }
- assertCandidate(t, report, alloc.Record.ID, "network_drift")
- assertNoCandidate(t, report, alloc.Record.TAP)
-}
-
-func TestDryRunFailsWhenNetworkStateIsCorrupt(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- indexPath := filepath.Join(cfg.Runtime.RootDir, "network", "index.json")
- if err := os.MkdirAll(filepath.Dir(indexPath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(indexPath, []byte("{"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- if _, err := DryRun(cfg); err == nil {
- t.Fatal("expected corrupt network index error")
- }
-}
-
-func TestDryRunFailsWhenNetworkLeasesAreCorrupt(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(dir, "data")
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
-
- leasePath := filepath.Join(cfg.Runtime.RootDir, "network", "leases.json")
- if err := os.MkdirAll(filepath.Dir(leasePath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(leasePath, []byte("{"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- if _, err := DryRun(cfg); err == nil {
- t.Fatal("expected corrupt network lease error")
- }
-}
-
-func TestNetworkCandidatesDetectDriftAndOrphanLease(t *testing.T) {
- t.Parallel()
- vmRecord := &vm.VMRecord{
- ID: "vm-live",
- NetworkConfigs: []kbnetwork.Config{{
- ID: "net-live", TAP: "tap-live", MAC: "02:00:00:00:00:01",
- Backend: kbnetwork.ProviderHostTap, BridgeDev: "kumabox0",
- Network: &kbnetwork.GuestInfo{IP: "10.88.0.2", Gateway: "10.88.0.1"},
- }},
- }
- records := []kbnetwork.Record{
- {ID: "net-live", VMID: "vm-live", TAP: "tap-live", MAC: "02:00:00:00:00:99", Provider: kbnetwork.ProviderHostTap, BridgeDev: "kumabox0", IPs: []string{"10.88.0.2/16"}, Gateway: "10.88.0.1"},
- {ID: "net-missing-vm", VMID: "vm-gone", TAP: "tap-gone", Provider: kbnetwork.ProviderHostTap},
- }
- leases := map[string]kbnetwork.Lease{
- "10.88.0.99": {VMID: "vm-gone", TAP: "tap-gone"},
- "10.88.0.2": {VMID: "vm-live", TAP: "tap-live"},
- }
- candidates := networkCandidates([]*vm.VMRecord{vmRecord}, records, leases)
- assertCandidateList(t, candidates, "net-live", "network_drift")
- assertCandidateList(t, candidates, "tap-gone", "stale_tap")
- assertCandidateList(t, candidates, "10.88.0.99", "orphan_lease")
- assertNoCandidateList(t, candidates, "10.88.0.2")
-}
-
-func TestImageCandidatesProtectIndexedAndLiveImages(t *testing.T) {
- t.Parallel()
- root := t.TempDir()
- cloudimg := filepath.Join(root, "cloudimg")
- for _, name := range []string{"staging/import-1", "img-indexed", "img-live", "img-orphan"} {
- if err := os.MkdirAll(filepath.Join(cloudimg, name), 0o755); err != nil {
- t.Fatal(err)
- }
- }
- images := []*image.ImageRecord{{ID: "img-indexed"}}
- candidates := imageCandidates(root, images, map[string]struct{}{"img-live": {}})
- assertCandidateList(t, candidates, filepath.Join(cloudimg, "staging/import-1"), "image_staging_dir")
- assertCandidateList(t, candidates, filepath.Join(cloudimg, "img-orphan"), "orphan_image_dir")
- assertNoCandidateList(t, candidates, filepath.Join(cloudimg, "img-indexed"))
- assertNoCandidateList(t, candidates, filepath.Join(cloudimg, "img-live"))
-}
-
-func assertCandidateList(t *testing.T, candidates []Candidate, path, typ string) {
- t.Helper()
- for _, candidate := range candidates {
- if candidate.Path == path && candidate.Type == typ {
- return
- }
- }
- t.Fatalf("missing candidate %s %s in %+v", typ, path, candidates)
-}
-
-func assertNoCandidateList(t *testing.T, candidates []Candidate, path string) {
- t.Helper()
- for _, candidate := range candidates {
- if candidate.Path == path {
- t.Fatalf("unexpected candidate for %s: %+v", path, candidate)
- }
- }
-}
-
-func assertCandidate(t *testing.T, report *Report, path string, typ string) {
- t.Helper()
- for _, candidate := range report.Candidates {
- if candidate.Path == path && candidate.Type == typ && candidate.Component != "" && candidate.Reason != "" {
- return
- }
- }
- t.Fatalf("missing candidate %s %s in %+v", typ, path, report.Candidates)
-}
-
-func assertNoCandidate(t *testing.T, report *Report, path string) {
- t.Helper()
- for _, candidate := range report.Candidates {
- if candidate.Path == path {
- t.Fatalf("unexpected candidate for %s: %+v", path, candidate)
- }
- }
-}
diff --git a/internal/gc/snapshot_policy.go b/internal/gc/snapshot_policy.go
deleted file mode 100644
index 49b1761..0000000
--- a/internal/gc/snapshot_policy.go
+++ /dev/null
@@ -1,280 +0,0 @@
-package gc
-
-import (
- "context"
- "errors"
- "fmt"
- "sort"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/reference"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/state"
-)
-
-// SnapshotPolicy selects ready snapshots for deterministic LRU eviction.
-type SnapshotPolicy struct {
- KeepLast int `json:"keepLast,omitempty"`
- KeepLastSet bool `json:"-"`
- MaxAge time.Duration `json:"maxAge,omitempty"`
- MaxBytes int64 `json:"maxBytes,omitempty"`
-}
-
-// SnapshotPolicyCandidate explains one policy decision without relying on
-// payload path naming conventions.
-type SnapshotPolicyCandidate struct {
- ID string `json:"id"`
- Name string `json:"name"`
- SourceVMID string `json:"sourceVmId,omitempty"`
- Reason string `json:"reason"`
- SizeBytes int64 `json:"sizeBytes"`
- LastAccessedAt time.Time `json:"lastAccessedAt"`
- References []reference.Record `json:"references,omitempty"`
-}
-
-// SnapshotPolicyReport is both the dry-run plan and the execution result.
-type SnapshotPolicyReport struct {
- Policy SnapshotPolicy `json:"policy"`
- TotalBytes int64 `json:"totalBytes"`
- EstimatedFreeBytes int64 `json:"estimatedFreeBytes"`
- EstimatedBytes int64 `json:"estimatedBytesAfter"`
- TargetSatisfied bool `json:"targetSatisfied"`
- Candidates []SnapshotPolicyCandidate `json:"candidates"`
- Blocked []SnapshotPolicyCandidate `json:"blocked,omitempty"`
- Deleted []SnapshotPolicyCandidate `json:"deleted,omitempty"`
- Skipped []SnapshotPolicyCandidate `json:"skipped,omitempty"`
-}
-
-type snapshotPolicyItem struct {
- record *snapshot.Record
- sourceVMID string
- references []reference.Record
- leased bool
-}
-
-func (p SnapshotPolicy) validate() error {
- if p.KeepLast < 0 {
- return errors.New("snapshot keep count must not be negative")
- }
- if p.MaxAge < 0 {
- return errors.New("snapshot max age must not be negative")
- }
- if p.MaxBytes < 0 {
- return errors.New("snapshot max bytes must not be negative")
- }
- return nil
-}
-
-func planSnapshotPolicy(ctx context.Context, stores state.Set, policy SnapshotPolicy, now time.Time) (*SnapshotPolicyReport, error) {
- if err := policy.validate(); err != nil {
- return nil, err
- }
- records, err := stores.Snapshots.List()
- if err != nil {
- return nil, fmt.Errorf("list snapshots for policy GC: %w", err)
- }
- items := make([]snapshotPolicyItem, 0, len(records))
- for _, record := range records {
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- manifest, err := stores.Snapshots.PeekManifest(ctx, record.ID)
- if err != nil {
- return nil, fmt.Errorf("read snapshot %s owner for policy GC: %w", record.ID, err)
- }
- refs, err := stores.References.ListTarget(ctx, "snapshot", record.ID)
- if err != nil {
- return nil, fmt.Errorf("read snapshot %s references for policy GC: %w", record.ID, err)
- }
- leased, err := stores.Snapshots.IsLeased(record.ID)
- if err != nil {
- return nil, fmt.Errorf("read snapshot %s lease for policy GC: %w", record.ID, err)
- }
- items = append(items, snapshotPolicyItem{
- record: record, sourceVMID: manifest.Source.VMID,
- references: refs, leased: leased,
- })
- }
- return buildSnapshotPolicyPlan(items, policy, now), nil
-}
-
-func buildSnapshotPolicyPlan(items []snapshotPolicyItem, policy SnapshotPolicy, now time.Time) *SnapshotPolicyReport {
- report := &SnapshotPolicyReport{Policy: policy, TargetSatisfied: true}
- keepEnabled := policy.KeepLastSet || policy.KeepLast > 0
- groups := make(map[string][]snapshotPolicyItem)
- for _, item := range items {
- report.TotalBytes += snapshotAllocatedBytes(item.record)
- groups[item.sourceVMID] = append(groups[item.sourceVMID], item)
- }
-
- protected := make(map[string]struct{})
- for _, group := range groups {
- sort.Slice(group, func(i, j int) bool {
- if group[i].record.CreatedAt.Equal(group[j].record.CreatedAt) {
- return group[i].record.ID > group[j].record.ID
- }
- return group[i].record.CreatedAt.After(group[j].record.CreatedAt)
- })
- if keepEnabled {
- limit := min(policy.KeepLast, len(group))
- for _, item := range group[:limit] {
- protected[item.record.ID] = struct{}{}
- }
- }
- }
-
- sort.Slice(items, func(i, j int) bool {
- left, right := snapshotAccessTime(items[i].record), snapshotAccessTime(items[j].record)
- if left.Equal(right) {
- return items[i].record.ID < items[j].record.ID
- }
- return left.Before(right)
- })
-
- selected := make(map[string]*SnapshotPolicyCandidate)
- blocked := make(map[string]struct{})
- for _, item := range items {
- _, keep := protected[item.record.ID]
- var reasons []string
- if !keep && keepEnabled {
- reasons = append(reasons, "keep-last")
- }
- if !keep && policy.MaxAge > 0 && snapshotAccessTime(item.record).Before(now.Add(-policy.MaxAge)) {
- reasons = append(reasons, "max-age")
- }
- if len(reasons) == 0 {
- continue
- }
- candidate := newSnapshotPolicyCandidate(item, strings.Join(reasons, "+"))
- if blockedSnapshotPolicyItem(item) {
- report.Blocked = append(report.Blocked, candidate)
- blocked[item.record.ID] = struct{}{}
- continue
- }
- candidateCopy := candidate
- selected[item.record.ID] = &candidateCopy
- }
-
- projected := report.TotalBytes
- for _, candidate := range selected {
- projected -= candidate.SizeBytes
- }
- if policy.MaxBytes > 0 && projected > policy.MaxBytes {
- for _, item := range items {
- if projected <= policy.MaxBytes {
- break
- }
- if _, keep := protected[item.record.ID]; keep {
- continue
- }
- if candidate := selected[item.record.ID]; candidate != nil {
- candidate.Reason += "+max-bytes"
- continue
- }
- if _, isBlocked := blocked[item.record.ID]; isBlocked {
- continue
- }
- candidate := newSnapshotPolicyCandidate(item, "max-bytes")
- if blockedSnapshotPolicyItem(item) {
- report.Blocked = append(report.Blocked, candidate)
- continue
- }
- candidateCopy := candidate
- selected[item.record.ID] = &candidateCopy
- projected -= snapshotAllocatedBytes(item.record)
- }
- }
-
- for _, item := range items {
- if candidate := selected[item.record.ID]; candidate != nil {
- report.Candidates = append(report.Candidates, *candidate)
- }
- }
- report.EstimatedBytes = projected
- report.EstimatedFreeBytes = report.TotalBytes - projected
- if policy.MaxBytes > 0 && projected > policy.MaxBytes {
- report.TargetSatisfied = false
- }
- return report
-}
-
-func blockedSnapshotPolicyItem(item snapshotPolicyItem) bool {
- return item.sourceVMID == "" || item.leased || len(item.references) > 0
-}
-
-func newSnapshotPolicyCandidate(item snapshotPolicyItem, reason string) SnapshotPolicyCandidate {
- if item.sourceVMID == "" {
- reason += "+owner-unknown"
- }
- if item.leased {
- reason += "+leased"
- }
- if len(item.references) > 0 {
- reason += "+referenced"
- }
- return SnapshotPolicyCandidate{
- ID: item.record.ID, Name: item.record.Name, SourceVMID: item.sourceVMID,
- Reason: reason, SizeBytes: snapshotAllocatedBytes(item.record),
- LastAccessedAt: snapshotAccessTime(item.record), References: item.references,
- }
-}
-
-func snapshotAllocatedBytes(record *snapshot.Record) int64 {
- if record.AllocatedBytes > 0 {
- return record.AllocatedBytes
- }
- return record.SizeBytes
-}
-
-func snapshotAccessTime(record *snapshot.Record) time.Time {
- if !record.LastAccessedAt.IsZero() {
- return record.LastAccessedAt
- }
- return record.CreatedAt
-}
-
-func applySnapshotPolicy(ctx context.Context, stores state.Set, report *SnapshotPolicyReport) error {
- for _, candidate := range report.Candidates {
- if err := ctx.Err(); err != nil {
- return err
- }
- current, err := stores.Snapshots.Inspect(candidate.ID)
- if err != nil {
- report.Skipped = append(report.Skipped, candidate)
- continue
- }
- if !snapshotAccessTime(current).Equal(candidate.LastAccessedAt) {
- candidate.Reason += "+accessed-after-plan"
- report.Skipped = append(report.Skipped, candidate)
- continue
- }
- refs, err := stores.References.ListTarget(ctx, "snapshot", candidate.ID)
- if err != nil {
- return fmt.Errorf("recheck snapshot %s references: %w", candidate.ID, err)
- }
- if len(refs) > 0 {
- candidate.Reason += "+referenced-after-plan"
- candidate.References = refs
- report.Skipped = append(report.Skipped, candidate)
- continue
- }
- if err := fault.Check(ctx, fault.GCBeforeDelete); err != nil {
- return err
- }
- if _, err := stores.Snapshots.Remove(candidate.ID); err != nil {
- if errors.Is(err, snapshot.ErrInUse) || errors.Is(err, snapshot.ErrNotFound) {
- candidate.Reason += "+changed-after-plan"
- report.Skipped = append(report.Skipped, candidate)
- continue
- }
- return fmt.Errorf("evict snapshot %s: %w", candidate.ID, err)
- }
- if err := stores.References.DeleteSource(ctx, "snapshot", candidate.ID); err != nil {
- return fmt.Errorf("remove snapshot %s references: %w", candidate.ID, err)
- }
- report.Deleted = append(report.Deleted, candidate)
- }
- return nil
-}
diff --git a/internal/gc/snapshot_policy_test.go b/internal/gc/snapshot_policy_test.go
deleted file mode 100644
index 4382697..0000000
--- a/internal/gc/snapshot_policy_test.go
+++ /dev/null
@@ -1,249 +0,0 @@
-package gc
-
-import (
- "encoding/json"
- "errors"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/reference"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/state"
-)
-
-func TestBuildSnapshotPolicyPlan(t *testing.T) {
- t.Parallel()
- now := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
- item := func(id, source string, age time.Duration, size int64) snapshotPolicyItem {
- accessed := now.Add(-age)
- return snapshotPolicyItem{record: &snapshot.Record{
- ID: id, Name: id, State: snapshot.StateReady, SizeBytes: size,
- CreatedAt: accessed, LastAccessedAt: accessed,
- }, sourceVMID: source}
- }
- tests := []struct {
- name string
- items []snapshotPolicyItem
- policy SnapshotPolicy
- wantCandidates []string
- wantBlocked []string
- wantBytes int64
- wantSatisfied bool
- }{
- {
- name: "keep latest per source",
- items: []snapshotPolicyItem{
- item("a-old", "vm-a", 3*time.Hour, 10), item("a-new", "vm-a", time.Hour, 10),
- item("b-old", "vm-b", 4*time.Hour, 10), item("b-new", "vm-b", 2*time.Hour, 10),
- },
- policy: SnapshotPolicy{KeepLast: 1}, wantCandidates: []string{"b-old", "a-old"},
- wantBytes: 20, wantSatisfied: true,
- },
- {
- name: "explicit keep zero selects every snapshot",
- items: []snapshotPolicyItem{
- item("old", "vm-a", 2*time.Hour, 10), item("new", "vm-a", time.Hour, 10),
- },
- policy: SnapshotPolicy{KeepLastSet: true}, wantCandidates: []string{"old", "new"},
- wantBytes: 0, wantSatisfied: true,
- },
- {
- name: "age and size use stable LRU order",
- items: []snapshotPolicyItem{
- item("old", "vm-a", 10*time.Hour, 30), item("middle", "vm-a", 5*time.Hour, 30), item("new", "vm-a", time.Hour, 30),
- },
- policy: SnapshotPolicy{MaxAge: 8 * time.Hour, MaxBytes: 40},
- wantCandidates: []string{"old", "middle"}, wantBytes: 30, wantSatisfied: true,
- },
- {
- name: "references leases and unknown owners fail closed",
- items: []snapshotPolicyItem{
- func() snapshotPolicyItem {
- v := item("referenced", "vm-a", 10*time.Hour, 20)
- v.references = []reference.Record{{ID: "ref"}}
- return v
- }(),
- func() snapshotPolicyItem { v := item("leased", "vm-a", 9*time.Hour, 20); v.leased = true; return v }(),
- item("unknown", "", 8*time.Hour, 20),
- },
- policy: SnapshotPolicy{MaxAge: time.Hour, MaxBytes: 1},
- wantBlocked: []string{"referenced", "leased", "unknown"}, wantBytes: 60, wantSatisfied: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- report := buildSnapshotPolicyPlan(tt.items, tt.policy, now)
- if got := candidateIDs(report.Candidates); !equalStrings(got, tt.wantCandidates) {
- t.Fatalf("candidate IDs = %v, want %v", got, tt.wantCandidates)
- }
- if got := candidateIDs(report.Blocked); !equalStrings(got, tt.wantBlocked) {
- t.Fatalf("blocked IDs = %v, want %v", got, tt.wantBlocked)
- }
- if report.EstimatedBytes != tt.wantBytes || report.TargetSatisfied != tt.wantSatisfied {
- t.Fatalf("estimated bytes=%d satisfied=%t, want %d/%t", report.EstimatedBytes, report.TargetSatisfied, tt.wantBytes, tt.wantSatisfied)
- }
- })
- }
-}
-
-func TestSnapshotPolicyGCMatchesJSONAndSQLite(t *testing.T) {
- for _, backend := range []string{"json", "sqlite"} {
- t.Run(backend, func(t *testing.T) {
- cfg := snapshotPolicyConfig(t, backend)
- stores, err := state.Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- if stores.Metadata != nil {
- engine := stores.Metadata
- t.Cleanup(func() { _ = engine.Close() })
- }
- oldest := createPolicySnapshot(t, stores, "oldest", "vm-source", 10)
- time.Sleep(time.Millisecond)
- middle := createPolicySnapshot(t, stores, "middle", "vm-source", 20)
- time.Sleep(time.Millisecond)
- newest := createPolicySnapshot(t, stores, "newest", "vm-source", 30)
- if err := stores.References.Upsert(t.Context(), reference.Record{
- ID: "operation-oldest", SourceKind: "operation", SourceID: "op-1",
- TargetKind: "snapshot", TargetID: oldest.ID, Mode: "active",
- }); err != nil {
- t.Fatal(err)
- }
- if stores.Metadata != nil {
- if err := stores.Metadata.Close(); err != nil {
- t.Fatal(err)
- }
- stores.Metadata = nil
- }
-
- dryRun, err := DryRunContext(t.Context(), cfg, Options{SnapshotPolicy: &SnapshotPolicy{KeepLast: 1}})
- if err != nil {
- t.Fatal(err)
- }
- if got := candidateIDs(dryRun.SnapshotPolicy.Candidates); !equalStrings(got, []string{middle.ID}) {
- t.Fatalf("dry-run candidates = %v", got)
- }
- if got := candidateIDs(dryRun.SnapshotPolicy.Blocked); !equalStrings(got, []string{oldest.ID}) {
- t.Fatalf("dry-run blocked = %v", got)
- }
-
- repaired, err := RepairWithOptions(t.Context(), cfg, Options{SnapshotPolicy: &SnapshotPolicy{KeepLast: 1}})
- if err != nil {
- t.Fatal(err)
- }
- if got := candidateIDs(repaired.SnapshotPolicy.Deleted); !equalStrings(got, []string{middle.ID}) {
- t.Fatalf("deleted = %v", got)
- }
- verifyStores, err := state.Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- if verifyStores.Metadata != nil {
- defer verifyStores.Metadata.Close() //nolint:errcheck
- }
- for _, id := range []string{oldest.ID, newest.ID} {
- if _, err := verifyStores.Snapshots.Inspect(id); err != nil {
- t.Fatalf("protected snapshot %s: %v", id, err)
- }
- }
- if _, err := verifyStores.Snapshots.Inspect(middle.ID); err == nil {
- t.Fatalf("snapshot %s was not deleted", middle.ID)
- }
- })
- }
-}
-
-func TestSnapshotPolicyFailureBeforeDeleteKeepsSnapshot(t *testing.T) {
- cfg := snapshotPolicyConfig(t, "json")
- stores, err := state.Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- ready := createPolicySnapshot(t, stores, "retained", "vm-source", 10)
- injected := errors.New("injected before GC delete")
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == fault.GCBeforeDelete {
- return injected
- }
- return nil
- }))
- if _, err := RepairWithOptions(ctx, cfg, Options{SnapshotPolicy: &SnapshotPolicy{KeepLastSet: true}}); !errors.Is(err, injected) {
- t.Fatalf("RepairWithOptions() error = %v, want %v", err, injected)
- }
- if _, err := stores.Snapshots.Inspect(ready.ID); err != nil {
- t.Fatalf("snapshot changed before delete: %v", err)
- }
- report, err := RepairWithOptions(t.Context(), cfg, Options{SnapshotPolicy: &SnapshotPolicy{KeepLastSet: true}})
- if err != nil {
- t.Fatal(err)
- }
- if got := candidateIDs(report.SnapshotPolicy.Deleted); !equalStrings(got, []string{ready.ID}) {
- t.Fatalf("retry deleted = %v, want %s", got, ready.ID)
- }
-}
-
-func snapshotPolicyConfig(t *testing.T, backend string) config.Config {
- t.Helper()
- root := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = filepath.Join(root, "data")
- cfg.Runtime.RunDir = filepath.Join(root, "run")
- cfg.Runtime.LogDir = filepath.Join(root, "log")
- cfg.Metadata.Backend = backend
- if backend == "sqlite" {
- cfg.Metadata.Path = filepath.Join(cfg.Runtime.RootDir, "metadata", "kumabox.db")
- if err := state.InitSQLiteMetadata(t.Context(), cfg); err != nil {
- t.Fatal(err)
- }
- }
- return cfg
-}
-
-func createPolicySnapshot(t *testing.T, stores state.Set, name, source string, size int64) *snapshot.Record {
- t.Helper()
- build, err := stores.Snapshots.Reserve(t.Context(), name)
- if err != nil {
- t.Fatal(err)
- }
- record := build.Record()
- manifest := snapshot.Manifest{
- SchemaVersion: "kumabox.snapshot.v2", ID: record.ID, Name: record.Name,
- Type: "stopped", Consistency: "crash", Source: snapshot.Source{VMID: source},
- }
- raw, err := json.Marshal(manifest)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(record.StagingDir, snapshot.ManifestFile), raw, 0o600); err != nil {
- t.Fatal(err)
- }
- ready, err := build.Finalize(size)
- if err != nil {
- t.Fatal(err)
- }
- return ready
-}
-
-func candidateIDs(candidates []SnapshotPolicyCandidate) []string {
- ids := make([]string, 0, len(candidates))
- for _, candidate := range candidates {
- ids = append(ids, candidate.ID)
- }
- return ids
-}
-
-func equalStrings(left, right []string) bool {
- if len(left) != len(right) {
- return false
- }
- for i := range left {
- if left[i] != right[i] {
- return false
- }
- }
- return true
-}
diff --git a/internal/image/file_import.go b/internal/image/file_import.go
deleted file mode 100644
index 1364a3b..0000000
--- a/internal/image/file_import.go
+++ /dev/null
@@ -1,256 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package image
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "hash"
- "io"
- "net/http"
- "net/url"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-)
-
-const qemuImgInfoTimeout = 30 * time.Second
-
-type fileImportRequest struct {
- Source string
- Destination string
- QemuImgPath string
- ExpectedSHA256 string
-}
-
-type importedArtifact struct {
- Path string
- SourceHint string
- SHA256 string
- SizeBytes int64
- Format string
- VirtualSizeBytes int64
- ActualSizeBytes int64
-}
-
-func importLocal(req fileImportRequest) (*importedArtifact, error) {
- if req.Source == "" {
- return nil, errors.New("source image path must not be empty")
- }
- if req.Destination == "" {
- return nil, errors.New("destination image path must not be empty")
- }
-
- sourcePath, err := filepath.Abs(req.Source)
- if err != nil {
- return nil, fmt.Errorf("resolve source image path: %w", err)
- }
- info, err := inspect(req.QemuImgPath, sourcePath)
- if err != nil {
- return nil, err
- }
- sum, size, err := copyAndHashFile(sourcePath, req.Destination)
- if err != nil {
- return nil, err
- }
- return artifact(req.Destination, sourcePath, sum, size, info), nil
-}
-
-func importRemote(req fileImportRequest) (*importedArtifact, error) {
- if req.Source == "" {
- return nil, errors.New("image URL must not be empty")
- }
- if req.Destination == "" {
- return nil, errors.New("destination image path must not be empty")
- }
-
- sum, size, sourceHint, err := download(req.Source, req.Destination)
- if err != nil {
- return nil, err
- }
- if err := verifySHA256(req.ExpectedSHA256, sum); err != nil {
- return nil, err
- }
- info, err := inspect(req.QemuImgPath, req.Destination)
- if err != nil {
- return nil, err
- }
- return artifact(req.Destination, sourceHint, sum, size, info), nil
-}
-
-type imageInfo struct {
- Format string `json:"format"`
- VirtualSizeBytes int64 `json:"virtual-size"`
- ActualSizeBytes int64 `json:"actual-size"`
-}
-
-func artifact(path, sourceHint, sum string, size int64, info *imageInfo) *importedArtifact {
- actualSize := info.ActualSizeBytes
- if actualSize <= 0 {
- actualSize = size
- }
- return &importedArtifact{
- Path: path,
- SourceHint: sourceHint,
- SHA256: sum,
- SizeBytes: size,
- Format: info.Format,
- VirtualSizeBytes: info.VirtualSizeBytes,
- ActualSizeBytes: actualSize,
- }
-}
-
-func inspect(qemuImgPath, sourcePath string) (*imageInfo, error) {
- ctx, cancel := context.WithTimeout(context.Background(), qemuImgInfoTimeout)
- defer cancel()
-
- out, err := exec.CommandContext(ctx, qemuImgPath, "info", "--output=json", sourcePath).Output() //nolint:gosec
- if ctx.Err() == context.DeadlineExceeded {
- return nil, fmt.Errorf("qemu-img info %s timed out after %s", sourcePath, qemuImgInfoTimeout)
- }
- if err != nil {
- return nil, fmt.Errorf("qemu-img info %s: %w", sourcePath, err)
- }
- var info imageInfo
- if err := json.Unmarshal(out, &info); err != nil {
- return nil, fmt.Errorf("parse qemu-img info: %w", err)
- }
- if info.Format == "" {
- return nil, errors.New("qemu-img info did not report image format")
- }
- if info.VirtualSizeBytes < 0 || info.ActualSizeBytes < 0 {
- return nil, errors.New("qemu-img info reported negative image size")
- }
- return &info, nil
-}
-
-func copyAndHashFile(src, dst string) (sum string, size int64, err error) {
- in, err := os.Open(src) //nolint:gosec
- if err != nil {
- return "", 0, fmt.Errorf("open source image: %w", err)
- }
- defer func() {
- if closeErr := in.Close(); err == nil && closeErr != nil {
- err = fmt.Errorf("close source image: %w", closeErr)
- }
- }()
- return writeStreamWithSHA256(in, dst)
-}
-
-func download(rawURL, dst string) (string, int64, string, error) {
- parsed, err := url.Parse(rawURL)
- if err != nil {
- return "", 0, "", fmt.Errorf("parse image URL: %w", err)
- }
- switch parsed.Scheme {
- case "file":
- sourcePath, err := fileURLPath(parsed)
- if err != nil {
- return "", 0, "", err
- }
- sum, size, err := copyAndHashFile(sourcePath, dst)
- return sum, size, sourcePath, err
- case "http", "https":
- sum, size, err := downloadHTTP(rawURL, dst)
- return sum, size, parsed.Path, err
- default:
- return "", 0, "", fmt.Errorf("unsupported image URL scheme: %s", parsed.Scheme)
- }
-}
-
-func fileURLPath(parsed *url.URL) (string, error) {
- if parsed.Host != "" && parsed.Host != "localhost" {
- return "", fmt.Errorf("unsupported file URL host: %s", parsed.Host)
- }
- if parsed.Path == "" {
- return "", errors.New("file URL path must not be empty")
- }
- path, err := url.PathUnescape(parsed.Path)
- if err != nil {
- return "", fmt.Errorf("decode file URL path: %w", err)
- }
- return path, nil
-}
-
-func downloadHTTP(rawURL, dst string) (sum string, size int64, err error) {
- req, err := http.NewRequest(http.MethodGet, rawURL, nil)
- if err != nil {
- return "", 0, fmt.Errorf("create image download request: %w", err)
- }
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return "", 0, fmt.Errorf("download image: %w", err)
- }
- defer func() {
- if closeErr := resp.Body.Close(); err == nil && closeErr != nil {
- err = fmt.Errorf("close image response: %w", closeErr)
- }
- }()
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
- return "", 0, fmt.Errorf("download image: unexpected HTTP status %s", resp.Status)
- }
- return writeStreamWithSHA256(resp.Body, dst)
-}
-
-func writeStreamWithSHA256(src io.Reader, dst string) (string, int64, error) {
- out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec
- if err != nil {
- return "", 0, fmt.Errorf("create staged image: %w", err)
- }
- hasher := sha256.New()
- size, copyErr := copyAndHash(out, src, hasher)
- closeErr := out.Close()
- if copyErr != nil {
- return "", 0, copyErr
- }
- if closeErr != nil {
- return "", 0, fmt.Errorf("close staged image: %w", closeErr)
- }
- return hex.EncodeToString(hasher.Sum(nil)), size, nil
-}
-
-func verifySHA256(expected, actual string) error {
- if expected == "" {
- return nil
- }
- normalized := strings.ToLower(strings.TrimSpace(expected))
- if normalized != actual {
- return fmt.Errorf("%w: got %s, want %s", ErrChecksumMismatch, actual, normalized)
- }
- return nil
-}
-
-func copyAndHash(dst io.Writer, src io.Reader, hasher hash.Hash) (int64, error) {
- size, err := io.Copy(io.MultiWriter(dst, hasher), src)
- if err != nil {
- return 0, fmt.Errorf("copy image to staging: %w", err)
- }
- return size, nil
-}
-
-// DiskExtension returns the managed filename extension for an image format.
-func diskExtension(format string) string {
- switch strings.ToLower(format) {
- case "raw":
- return "raw"
- case "qcow2":
- return "qcow2"
- default:
- return "img"
- }
-}
-
-// OSFamily infers a guest family from a source filename.
-func osFamily(path string) string {
- lower := strings.ToLower(filepath.Base(path))
- if strings.Contains(lower, "ubuntu") || strings.Contains(lower, "jammy") || strings.Contains(lower, "noble") {
- return "ubuntu"
- }
- return ""
-}
diff --git a/internal/image/file_import_test.go b/internal/image/file_import_test.go
deleted file mode 100644
index 9086f4a..0000000
--- a/internal/image/file_import_test.go
+++ /dev/null
@@ -1,101 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package image
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "os"
- "path/filepath"
- "strconv"
- "testing"
-)
-
-func TestLocalCopiesAndInspectsImage(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- source := filepath.Join(dir, "ubuntu-jammy.img")
- content := []byte("cloud image")
- if err := os.WriteFile(source, content, 0o644); err != nil {
- t.Fatal(err)
- }
- qemuImg := fakeInspectQEMUImg(t, dir, "qcow2", 4096, int64(len(content)))
- destination := filepath.Join(dir, "staging", "base.img")
- if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
- t.Fatal(err)
- }
-
- artifact, err := importLocal(fileImportRequest{Source: source, Destination: destination, QemuImgPath: qemuImg})
- if err != nil {
- t.Fatal(err)
- }
- expected := sha256.Sum256(content)
- if artifact.Path != destination || artifact.Format != "qcow2" || artifact.VirtualSizeBytes != 4096 {
- t.Fatalf("artifact = %+v", artifact)
- }
- if artifact.SHA256 != hex.EncodeToString(expected[:]) || artifact.ActualSizeBytes != int64(len(content)) {
- t.Fatalf("artifact digest and size = %+v", artifact)
- }
- if osFamily(source) != "ubuntu" || diskExtension(artifact.Format) != "qcow2" {
- t.Fatalf("source helpers returned unexpected values")
- }
-}
-
-func TestRemoteCopiesFileURLAndChecksDigest(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- source := filepath.Join(dir, "noble.img")
- content := []byte("file URL image")
- if err := os.WriteFile(source, content, 0o644); err != nil {
- t.Fatal(err)
- }
- expected := sha256.Sum256(content)
- qemuImg := fakeInspectQEMUImg(t, dir, "raw", 8192, int64(len(content)))
- destination := filepath.Join(dir, "base.img")
-
- artifact, err := importRemote(fileImportRequest{
- Source: "file://" + source,
- Destination: destination,
- QemuImgPath: qemuImg,
- ExpectedSHA256: hex.EncodeToString(expected[:]),
- })
- if err != nil {
- t.Fatal(err)
- }
- if artifact.SourceHint != source || artifact.Format != "raw" {
- t.Fatalf("artifact = %+v", artifact)
- }
-}
-
-func TestRemoteRejectsDigestMismatch(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- source := filepath.Join(dir, "image.img")
- if err := os.WriteFile(source, []byte("image"), 0o644); err != nil {
- t.Fatal(err)
- }
- _, err := importRemote(fileImportRequest{
- Source: "file://" + source,
- Destination: filepath.Join(dir, "base.img"),
- QemuImgPath: fakeInspectQEMUImg(t, dir, "raw", 1024, 5),
- ExpectedSHA256: "0000000000000000000000000000000000000000000000000000000000000000",
- })
- if !errors.Is(err, ErrChecksumMismatch) {
- t.Fatalf("expected checksum mismatch, got %v", err)
- }
-}
-
-func fakeInspectQEMUImg(t *testing.T, dir, format string, virtualSize, actualSize int64) string {
- t.Helper()
- path := filepath.Join(dir, "qemu-img")
- script := "#!/bin/sh\n" +
- "printf '{\"format\":\"" + format + "\",\"virtual-size\":" + strconv.FormatInt(virtualSize, 10) + ",\"actual-size\":" + strconv.FormatInt(actualSize, 10) + "}'\n"
- if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
- t.Fatal(err)
- }
- return path
-}
diff --git a/internal/image/index.go b/internal/image/index.go
deleted file mode 100644
index 87063f3..0000000
--- a/internal/image/index.go
+++ /dev/null
@@ -1,68 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package image
-
-import (
- "errors"
- "fmt"
- "strings"
-)
-
-var (
- // ErrNotFound is returned when an image reference does not resolve.
- ErrNotFound = errors.New("image not found")
- // ErrNameConflict is returned when an image name already exists.
- ErrNameConflict = errors.New("image name already exists")
- // ErrAmbiguous is returned when an image reference matches multiple images.
- ErrAmbiguous = errors.New("image ref is ambiguous")
- // ErrChecksumMismatch is returned when a pulled image does not match the expected digest.
- ErrChecksumMismatch = errors.New("image checksum mismatch")
- // ErrImageInUse is returned when an image is still referenced by one or more VMs.
- ErrImageInUse = errors.New("image in use")
-)
-
-type imageIndex struct {
- SchemaVersion string `json:"schemaVersion"`
- Images map[string]*ImageRecord `json:"images"`
- Names map[string]string `json:"names"`
-}
-
-func (idx *imageIndex) init() {
- if idx.SchemaVersion == "" {
- idx.SchemaVersion = "kumabox.image.index.v1"
- }
- if idx.Images == nil {
- idx.Images = make(map[string]*ImageRecord)
- }
- if idx.Names == nil {
- idx.Names = make(map[string]string)
- }
-}
-
-func (idx *imageIndex) resolve(ref string) (string, error) {
- idx.init()
- if _, ok := idx.Images[ref]; ok {
- return ref, nil
- }
- if id, ok := idx.Names[ref]; ok {
- return id, nil
- }
- if len(ref) < 3 {
- return "", ErrNotFound
- }
-
- var matched string
- for id := range idx.Images {
- if !strings.HasPrefix(id, ref) {
- continue
- }
- if matched != "" {
- return "", fmt.Errorf("%w: %s", ErrAmbiguous, ref)
- }
- matched = id
- }
- if matched == "" {
- return "", ErrNotFound
- }
- return matched, nil
-}
diff --git a/internal/image/index_codec.go b/internal/image/index_codec.go
deleted file mode 100644
index 8f7ffcc..0000000
--- a/internal/image/index_codec.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package image
-
-import (
- stdjson "encoding/json"
- "fmt"
-
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-const imageIndexTable = "image-index"
-const imageIndexRecord = "root"
-
-type indexCodec struct{}
-
-func (indexCodec) Decode(raw []byte) (*metajson.Model, error) {
- model := metajson.NewModel()
- if len(raw) == 0 {
- return model, nil
- }
- var index imageIndex
- if err := stdjson.Unmarshal(raw, &index); err != nil {
- return nil, fmt.Errorf("parse image index: %w", err)
- }
- index.init()
- encoded, err := stdjson.Marshal(index)
- if err != nil {
- return nil, fmt.Errorf("encode image index record: %w", err)
- }
- model.Tables[imageIndexTable] = map[string]stdjson.RawMessage{imageIndexRecord: encoded}
- return model, nil
-}
-
-func (indexCodec) Encode(model *metajson.Model) ([]byte, error) {
- if model == nil {
- return nil, fmt.Errorf("image index metadata model must not be nil")
- }
- raw := model.Tables[imageIndexTable][imageIndexRecord]
- if len(raw) == 0 {
- index := imageIndex{}
- index.init()
- raw, _ = stdjson.Marshal(index)
- }
- var index imageIndex
- if err := stdjson.Unmarshal(raw, &index); err != nil {
- return nil, fmt.Errorf("parse image index record: %w", err)
- }
- index.init()
- return stdjson.MarshalIndent(index, "", " ")
-}
diff --git a/internal/image/oci/builder.go b/internal/image/oci/builder.go
deleted file mode 100644
index 660c3d1..0000000
--- a/internal/image/oci/builder.go
+++ /dev/null
@@ -1,758 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-// Builder converts OCI layers into bootable KumaBox image data.
-package oci
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "context"
- "crypto/rand"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "os"
- "os/exec"
- "path"
- "path/filepath"
- "regexp"
- "runtime"
- "strconv"
- "strings"
- "time"
-
- "github.com/klauspost/compress/zstd"
- "golang.org/x/sync/errgroup"
-
- "github.com/kumabox/kumabox/internal/agent/protocol"
- "github.com/kumabox/kumabox/internal/fileutil"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const ociCmdlineTemplate = "console=ttyS0 loglevel=3 clocksource=kvm-clock reboot=k panic=1 boot=kumabox-overlay kumabox.layers={{layers}} kumabox.cow={{cow}} kumabox.timeout=10 rw"
-
-// BuildRequest describes an OCI image build.
-type BuildRequest struct {
- Name string
- Ref string
- Platform string
- Source string
- MkfsEROFS string
- Concurrency int
- AgentProfile string
- Progress func(ProgressEvent)
-}
-
-// Builder converts OCI layers into shared EROFS blobs and publishes an image record.
-type Builder struct {
- rootDir string
- erofsDir string
- stageDir string
- content interface {
- Pull(context.Context, PullRequest) (*PullResult, error)
- }
- images interface {
- Create(image.CreateRequest) (*image.ImageRecord, error)
- }
-}
-
-// NewBuilder returns a Builder rooted under rootDir.
-func NewBuilder(rootDir string) *Builder {
- return NewBuilderWithStores(rootDir, NewStore(rootDir), image.New(rootDir))
-}
-
-// NewBuilderWithStores creates a builder using caller-owned metadata stores.
-func NewBuilderWithStores(rootDir string, content interface {
- Pull(context.Context, PullRequest) (*PullResult, error)
-}, images interface {
- Create(image.CreateRequest) (*image.ImageRecord, error)
-}) *Builder {
- base := filepath.Join(rootDir, "oci", "erofs")
- return &Builder{
- rootDir: rootDir,
- erofsDir: filepath.Join(base, "blobs"),
- stageDir: filepath.Join(base, "staging"),
- content: content,
- images: images,
- }
-}
-
-// Build pulls an OCI image, converts its layers to EROFS, and records image metadata.
-func (b *Builder) Build(ctx context.Context, req BuildRequest) (*image.ImageRecord, error) {
- if req.Name == "" {
- return nil, errors.New("image name must not be empty")
- }
- if req.Ref == "" {
- return nil, errors.New("OCI ref must not be empty")
- }
- if req.MkfsEROFS == "" {
- req.MkfsEROFS = "mkfs.erofs"
- }
-
- pull, err := b.content.Pull(ctx, PullRequest{
- Ref: req.Ref,
- Platform: req.Platform,
- Source: req.Source,
- Progress: req.Progress,
- })
- if err != nil {
- return nil, err
- }
-
- if err := checkEROFSVersion(ctx, req.MkfsEROFS); err != nil {
- return nil, err
- }
- results := make([]layerBuildResult, len(pull.Layers))
- workers := req.Concurrency
- if workers <= 0 {
- workers = runtime.NumCPU()
- }
- if workers > len(pull.Layers) {
- workers = len(pull.Layers)
- }
- if workers == 0 {
- return nil, fmt.Errorf("BOOT_PROFILE_UNSUPPORTED: OCI image has no layers")
- }
- group, groupCtx := errgroup.WithContext(ctx)
- sem := make(chan struct{}, workers)
- for i, layer := range pull.Layers {
- i, layer := i, layer
- group.Go(func() error {
- select {
- case sem <- struct{}{}:
- case <-groupCtx.Done():
- return groupCtx.Err()
- }
- defer func() { <-sem }()
- erofs, kernel, initrd, err := b.ensureEROFSWithAssets(groupCtx, req.MkfsEROFS, layer)
- if err != nil {
- return fmt.Errorf("build layer %d %s: %w", i, layer.Digest, err)
- }
- results[i] = layerBuildResult{
- layer: image.OCILayer{
- Index: i, Digest: layer.Digest, Serial: vm.LayerSerial(i),
- MediaType: layer.MediaType, SizeBytes: layer.SizeBytes, EROFS: erofs,
- },
- kernel: kernel, initrd: initrd,
- }
- if kernel != nil {
- results[i].layer.Kernel = kernel.Path
- }
- if initrd != nil {
- results[i].layer.Initrd = initrd.Path
- }
- if req.Progress != nil {
- req.Progress(ProgressEvent{Phase: "erofs", Index: i, Total: len(pull.Layers), Digest: layer.Digest})
- }
- return nil
- })
- }
- if err := group.Wait(); err != nil {
- return nil, err
- }
- layers := make([]image.OCILayer, 0, len(results))
- var kernel, initrd *bootAsset
- for _, result := range results {
- layers = append(layers, result.layer)
- if result.kernel != nil {
- kernel = result.kernel
- }
- if result.initrd != nil {
- initrd = result.initrd
- }
- }
- if kernel == nil || initrd == nil {
- return nil, fmt.Errorf("BOOT_PROFILE_UNSUPPORTED: OCI image must contain /boot/vmlinuz-* and /boot/initrd.img-*")
- }
- boot := image.Boot{Mode: "direct", Kernel: kernel.Path, Initrd: initrd.Path, Cmdline: ociCmdlineTemplate}
- imageConfig, err := decodeOCIImageConfig(pull.Config.Path)
- if err != nil {
- return nil, err
- }
- agent, err := b.inspectAgentProfile(pull.Layers, req.AgentProfile)
- if err != nil {
- return nil, err
- }
-
- return b.images.Create(image.CreateRequest{
- Name: req.Name,
- Source: image.Source{
- Type: "oci",
- URI: pull.DigestRef,
- },
- OS: image.OS{
- Family: "linux",
- Profile: "oci-erofs",
- },
- Agent: agent,
- Boot: boot,
- OCI: &image.OCI{
- Ref: pull.Ref,
- Source: pull.Source,
- DigestRef: pull.DigestRef,
- Platform: image.OCIPlatform{
- OS: pull.Platform.OS,
- Architecture: pull.Platform.Architecture,
- Variant: pull.Platform.Variant,
- },
- Config: image.OCIDescriptor{
- Digest: pull.Config.Digest,
- MediaType: pull.Config.MediaType,
- SizeBytes: pull.Config.SizeBytes,
- },
- ImageConfig: imageConfig,
- AgentInjection: agent.Injection,
- Layers: layers,
- BuiltAt: time.Now().UTC(),
- },
- })
-}
-
-type layerBuildResult struct {
- layer image.OCILayer
- kernel *bootAsset
- initrd *bootAsset
-}
-
-func decodeOCIImageConfig(configPath string) (image.OCIImageConfig, error) {
- raw, err := os.ReadFile(configPath) //nolint:gosec
- if err != nil {
- return image.OCIImageConfig{}, fmt.Errorf("read OCI config: %w", err)
- }
- var doc struct {
- Config map[string]json.RawMessage `json:"config"`
- }
- if err := json.Unmarshal(raw, &doc); err != nil {
- return image.OCIImageConfig{}, fmt.Errorf("decode OCI config: %w", err)
- }
- if len(doc.Config) == 0 {
- return image.OCIImageConfig{}, nil
- }
-
- cfg := image.OCIImageConfig{}
- if value, ok, err := decodeConfigStringSlice(doc.Config, "Env"); err != nil {
- return image.OCIImageConfig{}, err
- } else if ok {
- cfg.Env = value
- }
- if value, ok, err := decodeConfigStringSlice(doc.Config, "Cmd"); err != nil {
- return image.OCIImageConfig{}, err
- } else if ok {
- cfg.Cmd = value
- }
- if value, ok, err := decodeConfigStringSlice(doc.Config, "Entrypoint"); err != nil {
- return image.OCIImageConfig{}, err
- } else if ok {
- cfg.Entrypoint = value
- }
- if value, ok, err := decodeConfigString(doc.Config, "WorkingDir"); err != nil {
- return image.OCIImageConfig{}, err
- } else if ok {
- cfg.Workdir = value
- }
- if value, ok, err := decodeConfigString(doc.Config, "User"); err != nil {
- return image.OCIImageConfig{}, err
- } else if ok {
- cfg.User = value
- }
- if value, ok, err := decodeConfigLabels(doc.Config, "Labels"); err != nil {
- return image.OCIImageConfig{}, err
- } else if ok {
- cfg.Labels = value
- }
- return cfg, nil
-}
-
-func (b *Builder) inspectAgentProfile(layers []BlobRecord, mode string) (*image.AgentProfile, error) {
- if mode == "" {
- mode = image.AgentProfileAuto
- }
- if mode != image.AgentProfileAuto && mode != image.AgentProfileRequired && mode != image.AgentInjectionEmbedded && mode != image.AgentInjectionUnsupported {
- return nil, fmt.Errorf("AGENT_PROFILE_INVALID: %q", mode)
- }
- if mode == image.AgentInjectionUnsupported {
- return &image.AgentProfile{
- Name: image.AgentName,
- Injection: image.AgentInjectionUnsupported,
- }, nil
- }
-
- var binaryFound, serviceFound bool
- for _, layer := range layers {
- in, err := os.Open(layer.Path) //nolint:gosec
- if err != nil {
- return nil, fmt.Errorf("open agent profile layer: %w", err)
- }
- reader, closeReader, err := layerTarReader(layer.MediaType, in)
- if err != nil {
- _ = in.Close()
- return nil, err
- }
- tr := tar.NewReader(reader)
- for {
- hdr, nextErr := tr.Next()
- if errors.Is(nextErr, io.EOF) {
- break
- }
- if nextErr != nil {
- closeReader()
- _ = in.Close()
- return nil, fmt.Errorf("scan agent profile layer: %w", nextErr)
- }
- if hdr.Typeflag != tar.TypeReg {
- continue
- }
- switch normalizeLayerPath(hdr.Name) {
- case strings.TrimPrefix(image.AgentBinaryPath, "/"):
- binaryFound = true
- case strings.TrimPrefix(image.AgentServicePath, "/"):
- serviceFound = true
- }
- }
- closeReader()
- if err := in.Close(); err != nil {
- return nil, fmt.Errorf("close agent profile layer: %w", err)
- }
- }
-
- if !binaryFound || !serviceFound {
- if mode == image.AgentProfileRequired || mode == image.AgentInjectionEmbedded {
- return nil, fmt.Errorf("AGENT_INJECTION_FAILED: image must contain %s and %s", image.AgentBinaryPath, image.AgentServicePath)
- }
- return &image.AgentProfile{
- Name: image.AgentName,
- Injection: image.AgentInjectionUnsupported,
- }, nil
- }
- return &image.AgentProfile{
- Name: image.AgentName,
- Injection: image.AgentInjectionEmbedded,
- BinaryPath: image.AgentBinaryPath,
- ServicePath: image.AgentServicePath,
- Capabilities: []string{
- string(protocol.CapabilityPingPong),
- string(protocol.CapabilityExec),
- string(protocol.CapabilityExecStream),
- string(protocol.CapabilityExecTTY),
- string(protocol.CapabilityIdentity),
- string(protocol.CapabilityReseed),
- },
- }, nil
-}
-
-func normalizeLayerPath(name string) string {
- return strings.TrimPrefix(path.Clean(strings.TrimPrefix(name, "/")), "./")
-}
-
-func decodeConfigStringSlice(config map[string]json.RawMessage, key string) (*[]string, bool, error) {
- raw, ok := config[key]
- if !ok {
- return nil, false, nil
- }
- var values []string
- if err := json.Unmarshal(raw, &values); err != nil {
- return nil, false, fmt.Errorf("decode OCI config %s: %w", key, err)
- }
- return &values, true, nil
-}
-
-func decodeConfigString(config map[string]json.RawMessage, key string) (*string, bool, error) {
- raw, ok := config[key]
- if !ok {
- return nil, false, nil
- }
- if strings.TrimSpace(string(raw)) == "null" {
- return nil, true, nil
- }
- var value string
- if err := json.Unmarshal(raw, &value); err != nil {
- return nil, false, fmt.Errorf("decode OCI config %s: %w", key, err)
- }
- return &value, true, nil
-}
-
-func decodeConfigLabels(config map[string]json.RawMessage, key string) (*map[string]string, bool, error) {
- raw, ok := config[key]
- if !ok {
- return nil, false, nil
- }
- var labels map[string]string
- if err := json.Unmarshal(raw, &labels); err != nil {
- return nil, false, fmt.Errorf("decode OCI config %s: %w", key, err)
- }
- return &labels, true, nil
-}
-
-func (b *Builder) resolveBootProfile(layers []BlobRecord) (image.Boot, error) {
- var kernel *bootAsset
- var initrd *bootAsset
-
- for _, layer := range layers {
- layerKernel, layerInitrd, err := b.scanBootAssets(layer)
- if err != nil {
- return image.Boot{}, err
- }
- if layerKernel != nil {
- kernel = layerKernel
- }
- if layerInitrd != nil {
- initrd = layerInitrd
- }
- }
- if kernel == nil || initrd == nil {
- return image.Boot{}, fmt.Errorf("BOOT_PROFILE_UNSUPPORTED: OCI image must contain /boot/vmlinuz-* and /boot/initrd.img-*")
- }
- return image.Boot{
- Mode: "direct",
- Kernel: kernel.Path,
- Initrd: initrd.Path,
- Cmdline: ociCmdlineTemplate,
- }, nil
-}
-
-func (b *Builder) scanBootAssets(layer BlobRecord) (kernel, initrd *bootAsset, err error) {
- in, err := os.Open(layer.Path) //nolint:gosec
- if err != nil {
- return nil, nil, fmt.Errorf("open layer blob: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, in, "close OCI layer blob")
-
- reader, closeReader, err := layerTarReader(layer.MediaType, in)
- if err != nil {
- return nil, nil, err
- }
- defer closeReader()
-
- tr := tar.NewReader(reader)
- for {
- hdr, err := tr.Next()
- if errors.Is(err, io.EOF) {
- break
- }
- if err != nil {
- return nil, nil, fmt.Errorf("read layer tar: %w", err)
- }
- if hdr.Typeflag != tar.TypeReg {
- continue
- }
- kind, ok := bootAssetKind(hdr.Name)
- if !ok {
- continue
- }
- asset, err := b.commitBootAsset(kind, hdr.Name, layer.Digest, tr)
- if err != nil {
- return nil, nil, err
- }
- switch kind {
- case "kernel":
- if kernel == nil || asset.SourcePath > kernel.SourcePath {
- kernel = asset
- }
- case "initrd":
- if initrd == nil || asset.SourcePath > initrd.SourcePath {
- initrd = asset
- }
- }
- }
- return kernel, initrd, nil
-}
-
-type bootAsset struct {
- Path string
- Digest string
- SizeBytes int64
- SourceLayer string
- SourcePath string
-}
-
-func bootAssetKind(name string) (string, bool) {
- cleaned := strings.TrimPrefix(path.Clean(strings.TrimPrefix(name, "/")), "./")
- dir := path.Dir(cleaned)
- base := path.Base(cleaned)
- if dir != "boot" && dir != "." {
- return "", false
- }
- if base == "vmlinuz" || strings.HasPrefix(base, "vmlinuz-") {
- return "kernel", true
- }
- if base == "initrd.img" || strings.HasPrefix(base, "initrd.img-") || strings.HasPrefix(base, "initramfs-") {
- return "initrd", true
- }
- return "", false
-}
-
-func (b *Builder) commitBootAsset(kind, sourcePath, sourceLayer string, src io.Reader) (*bootAsset, error) {
- opID, err := operationID()
- if err != nil {
- return nil, err
- }
- stage := filepath.Join(b.stageDir, "boot-"+opID)
- if err := os.MkdirAll(stage, 0o755); err != nil {
- return nil, fmt.Errorf("create boot asset staging dir: %w", err)
- }
- defer os.RemoveAll(stage) //nolint:errcheck
-
- tmpPath := filepath.Join(stage, kind)
- tmp, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec
- if err != nil {
- return nil, fmt.Errorf("create boot asset staging file: %w", err)
- }
- hasher := sha256.New()
- _, copyErr := io.Copy(tmp, io.TeeReader(src, hasher))
- if copyErr == nil {
- copyErr = tmp.Sync()
- }
- closeErr := tmp.Close()
- if copyErr != nil {
- return nil, fmt.Errorf("write boot asset staging file: %w", copyErr)
- }
- if closeErr != nil {
- return nil, fmt.Errorf("close boot asset staging file: %w", closeErr)
- }
-
- digest := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
- _, value, err := splitDigest(digest)
- if err != nil {
- return nil, err
- }
- target := filepath.Join(b.rootDir, "oci", "boot", "blobs", "sha256", value)
- if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
- return nil, fmt.Errorf("create boot asset dir: %w", err)
- }
- if _, err := os.Stat(target); errors.Is(err, os.ErrNotExist) {
- if err := os.Rename(tmpPath, target); err != nil {
- if !os.IsExist(err) {
- return nil, fmt.Errorf("commit boot asset: %w", err)
- }
- }
- } else if err != nil {
- return nil, fmt.Errorf("stat boot asset: %w", err)
- }
- info, err := os.Stat(target)
- if err != nil {
- return nil, fmt.Errorf("stat committed boot asset: %w", err)
- }
- return &bootAsset{
- Path: target,
- Digest: digest,
- SizeBytes: info.Size(),
- SourceLayer: sourceLayer,
- SourcePath: strings.TrimPrefix(path.Clean(strings.TrimPrefix(sourcePath, "/")), "./"),
- }, nil
-}
-
-func (b *Builder) ensureEROFS(ctx context.Context, mkfs string, layer BlobRecord) (*image.EROFSLayer, error) {
- erofs, _, _, err := b.ensureEROFSWithAssets(ctx, mkfs, layer)
- return erofs, err
-}
-
-func (b *Builder) ensureEROFSWithAssets(ctx context.Context, mkfs string, layer BlobRecord) (*image.EROFSLayer, *bootAsset, *bootAsset, error) {
- var lastErr error
- for attempt := 0; attempt < 3; attempt++ {
- erofs, kernel, initrd, err := b.ensureEROFSOnce(ctx, mkfs, layer)
- if err == nil {
- return erofs, kernel, initrd, nil
- }
- lastErr = err
- if ctx.Err() != nil || attempt == 2 {
- break
- }
- select {
- case <-ctx.Done():
- return nil, nil, nil, ctx.Err()
- case <-time.After(2 * time.Second):
- }
- }
- return nil, nil, nil, fmt.Errorf("EROFS_CONVERSION_FAILED after retries: %w", lastErr)
-}
-
-func (b *Builder) ensureEROFSOnce(ctx context.Context, mkfs string, layer BlobRecord) (*image.EROFSLayer, *bootAsset, *bootAsset, error) {
- algo, value, err := splitDigest(layer.Digest)
- if err != nil {
- return nil, nil, nil, err
- }
- target := filepath.Join(b.erofsDir, algo, value+".erofs")
- if info, err := os.Stat(target); err == nil && info.Mode().IsRegular() {
- sum, err := fileSHA256(target)
- if err != nil {
- return nil, nil, nil, err
- }
- kernel, initrd, scanErr := b.scanBootAssets(layer)
- if scanErr != nil {
- return nil, nil, nil, scanErr
- }
- return &image.EROFSLayer{
- Path: target,
- Filesystem: "erofs",
- Digest: "sha256:" + sum,
- SizeBytes: info.Size(),
- SourceLayer: layer.Digest,
- }, kernel, initrd, nil
- }
-
- opID, err := operationID()
- if err != nil {
- return nil, nil, nil, err
- }
- stage := filepath.Join(b.stageDir, "erofs-"+opID)
- if err := os.MkdirAll(stage, 0o755); err != nil {
- return nil, nil, nil, fmt.Errorf("create EROFS staging dir: %w", err)
- }
- defer os.RemoveAll(stage) //nolint:errcheck
-
- stagedEROFS := filepath.Join(stage, "layer.erofs")
- cmd := exec.CommandContext(ctx, mkfs, "--tar=f", "-zlz4hc", "-C16384", "-T0", "-U", erofsUUID(value), stagedEROFS) //nolint:gosec
- var output bytes.Buffer
- cmd.Stderr = &output
- stdin, err := cmd.StdinPipe()
- if err != nil {
- return nil, nil, nil, fmt.Errorf("create mkfs.erofs stdin: %w", err)
- }
- if err := cmd.Start(); err != nil {
- return nil, nil, nil, fmt.Errorf("start mkfs.erofs: %w", err)
- }
- in, err := os.Open(layer.Path) //nolint:gosec
- if err != nil {
- _ = stdin.Close()
- _ = cmd.Wait()
- return nil, nil, nil, fmt.Errorf("open layer blob: %w", err)
- }
- reader, closeReader, err := layerTarReader(layer.MediaType, in)
- if err != nil {
- _ = in.Close()
- _ = stdin.Close()
- _ = cmd.Wait()
- return nil, nil, nil, err
- }
- kernel, initrd, scanErr := b.scanBootAndStream(reader, stdin, layer.Digest)
- closeReader()
- _ = in.Close()
- closeErr := stdin.Close()
- waitErr := cmd.Wait()
- if scanErr != nil {
- return nil, nil, nil, scanErr
- }
- if closeErr != nil {
- return nil, nil, nil, fmt.Errorf("close mkfs.erofs input: %w", closeErr)
- }
- if waitErr != nil {
- return nil, nil, nil, fmt.Errorf("mkfs.erofs failed: %w: %s", waitErr, strings.TrimSpace(output.String()))
- }
-
- if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
- return nil, nil, nil, fmt.Errorf("create EROFS blob dir: %w", err)
- }
- if err := os.Rename(stagedEROFS, target); err != nil && !os.IsExist(err) {
- return nil, nil, nil, fmt.Errorf("commit EROFS blob: %w", err)
- }
- info, err := os.Stat(target)
- if err != nil {
- return nil, nil, nil, fmt.Errorf("stat EROFS blob: %w", err)
- }
- sum, err := fileSHA256(target)
- if err != nil {
- return nil, nil, nil, err
- }
- return &image.EROFSLayer{
- Path: target,
- Filesystem: "erofs",
- Digest: "sha256:" + sum,
- SizeBytes: info.Size(),
- SourceLayer: layer.Digest,
- }, kernel, initrd, nil
-}
-
-var erofsVersionPattern = regexp.MustCompile(`(\d+)\.(\d+)`)
-
-func checkEROFSVersion(ctx context.Context, mkfs string) error {
- out, err := exec.CommandContext(ctx, mkfs, "--version").CombinedOutput() //nolint:gosec
- if err != nil {
- return fmt.Errorf("EROFS_VERSION_UNAVAILABLE: %s: %w", strings.TrimSpace(string(out)), err)
- }
- match := erofsVersionPattern.FindStringSubmatch(string(out))
- if len(match) != 3 {
- return fmt.Errorf("EROFS_VERSION_INVALID: cannot parse mkfs.erofs version from %q", strings.TrimSpace(string(out)))
- }
- major, _ := strconv.Atoi(match[1])
- minor, _ := strconv.Atoi(match[2])
- if major < 1 || (major == 1 && minor < 8) {
- return fmt.Errorf("EROFS_VERSION_UNSUPPORTED: mkfs.erofs %s.%s requires at least 1.8", match[1], match[2])
- }
- return nil
-}
-
-func erofsUUID(value string) string {
- return fmt.Sprintf("%s-%s-5%s-8%s-%s", value[0:8], value[8:12], value[13:16], value[17:20], value[20:32])
-}
-
-// scanBootAndStream lets mkfs.erofs consume the same uncompressed tar stream
-// that is inspected for boot assets. This avoids materializing a second tar.
-func (b *Builder) scanBootAndStream(src io.Reader, dst io.Writer, sourceLayer string) (*bootAsset, *bootAsset, error) {
- tee := io.TeeReader(src, dst)
- tr := tar.NewReader(tee)
- var kernel, initrd *bootAsset
- for {
- hdr, err := tr.Next()
- if errors.Is(err, io.EOF) {
- break
- }
- if err != nil {
- return nil, nil, fmt.Errorf("read layer tar: %w", err)
- }
- if hdr.Typeflag != tar.TypeReg {
- continue
- }
- kind, ok := bootAssetKind(hdr.Name)
- if !ok {
- continue
- }
- asset, err := b.commitBootAsset(kind, hdr.Name, sourceLayer, tr)
- if err != nil {
- return nil, nil, err
- }
- if kind == "kernel" && (kernel == nil || asset.SourcePath > kernel.SourcePath) {
- kernel = asset
- }
- if kind == "initrd" && (initrd == nil || asset.SourcePath > initrd.SourcePath) {
- initrd = asset
- }
- }
- if _, err := io.Copy(io.Discard, tee); err != nil {
- return nil, nil, fmt.Errorf("drain layer stream: %w", err)
- }
- return kernel, initrd, nil
-}
-
-func layerTarReader(mediaType string, src io.Reader) (io.Reader, func(), error) {
- switch {
- case strings.HasSuffix(mediaType, ".tar+gzip"), strings.HasSuffix(mediaType, ".tar.gzip"):
- reader, err := gzip.NewReader(src)
- if err != nil {
- return nil, func() {}, fmt.Errorf("open gzip layer: %w", err)
- }
- return reader, func() { _ = reader.Close() }, nil
- case strings.HasSuffix(mediaType, ".tar+zstd"), strings.HasSuffix(mediaType, ".tar.zstd"):
- reader, err := zstd.NewReader(src)
- if err != nil {
- return nil, func() {}, fmt.Errorf("open zstd layer: %w", err)
- }
- return reader, reader.Close, nil
- case strings.HasSuffix(mediaType, ".tar"):
- return src, func() {}, nil
- default:
- return nil, func() {}, fmt.Errorf("OCI_LAYER_MEDIA_TYPE_UNSUPPORTED: %s", mediaType)
- }
-}
-
-func operationID() (string, error) {
- var raw [8]byte
- if _, err := rand.Read(raw[:]); err != nil {
- return "", fmt.Errorf("generate operation ID: %w", err)
- }
- return hex.EncodeToString(raw[:]), nil
-}
diff --git a/internal/image/oci/builder_test.go b/internal/image/oci/builder_test.go
deleted file mode 100644
index 3479e6e..0000000
--- a/internal/image/oci/builder_test.go
+++ /dev/null
@@ -1,295 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package oci
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestEnsureEROFSBuildsAndReusesLayer(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- layerPath := filepath.Join(dir, "layer.tar.gz")
- layerBytes := gzipTar(t, map[string]string{"hello.txt": "hello"})
- if err := os.WriteFile(layerPath, layerBytes, 0o600); err != nil {
- t.Fatal(err)
- }
- sum := sha256.Sum256(layerBytes)
- layerDigest := "sha256:" + hex.EncodeToString(sum[:])
-
- mkfs := filepath.Join(dir, "mkfs.erofs")
- if err := os.WriteFile(mkfs, []byte("#!/bin/sh\ncat > \"$7\"\n"), 0o755); err != nil {
- t.Fatal(err)
- }
-
- builder := NewBuilder(dir)
- rec, err := builder.ensureEROFS(context.Background(), mkfs, BlobRecord{
- Digest: layerDigest,
- Path: layerPath,
- MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
- SizeBytes: int64(len(layerBytes)),
- })
- if err != nil {
- t.Fatal(err)
- }
- if rec.Filesystem != "erofs" || rec.SourceLayer != layerDigest {
- t.Fatalf("unexpected EROFS record: %+v", rec)
- }
- if _, err := os.Stat(rec.Path); err != nil {
- t.Fatal(err)
- }
- if filepath.Base(rec.Path) != strings.TrimPrefix(layerDigest, "sha256:")+".erofs" {
- t.Fatalf("unexpected EROFS path: %s", rec.Path)
- }
-
- cached, err := builder.ensureEROFS(context.Background(), mkfs, BlobRecord{
- Digest: layerDigest,
- Path: layerPath,
- MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
- SizeBytes: int64(len(layerBytes)),
- })
- if err != nil {
- t.Fatal(err)
- }
- if cached.Path != rec.Path || cached.Digest != rec.Digest {
- t.Fatalf("cached record = %+v, want %+v", cached, rec)
- }
-}
-
-func TestResolveBootProfileExtractsKernelAndInitrd(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- layerPath := filepath.Join(dir, "layer.tar")
- layerBytes := plainTar(t, map[string]string{
- "boot/vmlinuz-6.8.0": "kernel",
- "boot/initrd.img-6.8.0": "initrd",
- })
- if err := os.WriteFile(layerPath, layerBytes, 0o600); err != nil {
- t.Fatal(err)
- }
- sum := sha256.Sum256(layerBytes)
-
- boot, err := NewBuilder(dir).resolveBootProfile([]BlobRecord{{
- Digest: "sha256:" + hex.EncodeToString(sum[:]),
- Path: layerPath,
- MediaType: "application/vnd.oci.image.layer.v1.tar",
- SizeBytes: int64(len(layerBytes)),
- }})
- if err != nil {
- t.Fatal(err)
- }
- if boot.Mode != "direct" || boot.Kernel == "" || boot.Initrd == "" || boot.Cmdline == "" {
- t.Fatalf("boot profile = %+v", boot)
- }
- for _, path := range []string{boot.Kernel, boot.Initrd} {
- if _, err := os.Stat(path); err != nil {
- t.Fatal(err)
- }
- }
- if !strings.Contains(boot.Cmdline, "kumabox.layers={{layers}}") || !strings.Contains(boot.Cmdline, "kumabox.cow={{cow}}") {
- t.Fatalf("cmdline template missing overlay placeholders: %s", boot.Cmdline)
- }
- if !strings.Contains(boot.Cmdline, "boot=kumabox-overlay") || strings.Contains(boot.Cmdline, "root=/dev/ram0") {
- t.Fatalf("cmdline template does not select KumaBox overlay boot: %s", boot.Cmdline)
- }
- if !strings.Contains(boot.Cmdline, "loglevel=3") || !strings.Contains(boot.Cmdline, "clocksource=kvm-clock") {
- t.Fatalf("cmdline template is missing fast-boot parameters: %s", boot.Cmdline)
- }
-}
-
-func TestResolveBootProfileRejectsMissingAssets(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- layerPath := filepath.Join(dir, "layer.tar")
- layerBytes := plainTar(t, map[string]string{"etc/os-release": "ID=test"})
- if err := os.WriteFile(layerPath, layerBytes, 0o600); err != nil {
- t.Fatal(err)
- }
- sum := sha256.Sum256(layerBytes)
-
- _, err := NewBuilder(dir).resolveBootProfile([]BlobRecord{{
- Digest: "sha256:" + hex.EncodeToString(sum[:]),
- Path: layerPath,
- MediaType: "application/vnd.oci.image.layer.v1.tar",
- SizeBytes: int64(len(layerBytes)),
- }})
- if err == nil || !strings.Contains(err.Error(), "BOOT_PROFILE_UNSUPPORTED") {
- t.Fatalf("expected BOOT_PROFILE_UNSUPPORTED, got %v", err)
- }
-}
-
-func TestDecodeOCIImageConfigPreservesExecutionMetadata(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- configPath := filepath.Join(dir, "config.json")
- if err := os.WriteFile(configPath, []byte(`{
- "config": {
- "Env": ["A=1", "B=2"],
- "Cmd": ["/sbin/init"],
- "Entrypoint": [],
- "WorkingDir": "/work",
- "User": "1000:1000",
- "Labels": {"org.opencontainers.image.title": "kumabox"}
- }
- }`), 0o600); err != nil {
- t.Fatal(err)
- }
-
- cfg, err := decodeOCIImageConfig(configPath)
- if err != nil {
- t.Fatal(err)
- }
- if cfg.Env == nil || strings.Join(*cfg.Env, ",") != "A=1,B=2" {
- t.Fatalf("env = %#v", cfg.Env)
- }
- if cfg.Cmd == nil || strings.Join(*cfg.Cmd, ",") != "/sbin/init" {
- t.Fatalf("cmd = %#v", cfg.Cmd)
- }
- if cfg.Entrypoint == nil || len(*cfg.Entrypoint) != 0 {
- t.Fatalf("entrypoint = %#v", cfg.Entrypoint)
- }
- if cfg.Workdir == nil || *cfg.Workdir != "/work" {
- t.Fatalf("workdir = %#v", cfg.Workdir)
- }
- if cfg.User == nil || *cfg.User != "1000:1000" {
- t.Fatalf("user = %#v", cfg.User)
- }
- if cfg.Labels == nil || (*cfg.Labels)["org.opencontainers.image.title"] != "kumabox" {
- t.Fatalf("labels = %#v", cfg.Labels)
- }
-
- raw, err := json.Marshal(cfg)
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(raw), `"entrypoint":[]`) {
- t.Fatalf("explicit empty entrypoint was not preserved: %s", raw)
- }
-}
-
-func TestDecodeOCIImageConfigOmitsMissingFields(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- configPath := filepath.Join(dir, "config.json")
- if err := os.WriteFile(configPath, []byte(`{"config":{"Cmd":[]}}`), 0o600); err != nil {
- t.Fatal(err)
- }
-
- cfg, err := decodeOCIImageConfig(configPath)
- if err != nil {
- t.Fatal(err)
- }
- if cfg.Cmd == nil || len(*cfg.Cmd) != 0 {
- t.Fatalf("cmd = %#v", cfg.Cmd)
- }
- if cfg.Env != nil || cfg.Entrypoint != nil || cfg.Workdir != nil || cfg.User != nil || cfg.Labels != nil {
- t.Fatalf("missing fields should remain nil: %+v", cfg)
- }
-}
-
-func TestInspectAgentProfileDetectsEmbeddedAgent(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- layerPath := filepath.Join(dir, "layer.tar")
- layerBytes := plainTar(t, map[string]string{
- "usr/local/bin/kumabox-agent": "agent",
- "etc/systemd/system/kumabox-agent.service": "unit",
- })
- if err := os.WriteFile(layerPath, layerBytes, 0o600); err != nil {
- t.Fatal(err)
- }
-
- profile, err := NewBuilder(dir).inspectAgentProfile([]BlobRecord{{
- Path: layerPath,
- MediaType: "application/vnd.oci.image.layer.v1.tar",
- }}, "required")
- if err != nil {
- t.Fatal(err)
- }
- if profile.Injection != "embedded" || profile.BinaryPath == "" || profile.ServicePath == "" {
- t.Fatalf("profile = %+v", profile)
- }
- if len(profile.Capabilities) != 6 {
- t.Fatalf("capabilities = %v", profile.Capabilities)
- }
-}
-
-func TestInspectAgentProfileRejectsRequiredAgentWhenMissing(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- layerPath := filepath.Join(dir, "layer.tar")
- if err := os.WriteFile(layerPath, plainTar(t, map[string]string{"etc/os-release": "ID=ubuntu"}), 0o600); err != nil {
- t.Fatal(err)
- }
-
- _, err := NewBuilder(dir).inspectAgentProfile([]BlobRecord{{
- Path: layerPath,
- MediaType: "application/vnd.oci.image.layer.v1.tar",
- }}, "required")
- if err == nil || !strings.Contains(err.Error(), "AGENT_INJECTION_FAILED") {
- t.Fatalf("error = %v, want AGENT_INJECTION_FAILED", err)
- }
-}
-
-func gzipTar(t *testing.T, files map[string]string) []byte {
- t.Helper()
-
- var compressed bytes.Buffer
- gz := gzip.NewWriter(&compressed)
- tw := tar.NewWriter(gz)
- writeTarFiles(t, tw, files)
- if err := tw.Close(); err != nil {
- t.Fatal(err)
- }
- if err := gz.Close(); err != nil {
- t.Fatal(err)
- }
- return compressed.Bytes()
-}
-
-func plainTar(t *testing.T, files map[string]string) []byte {
- t.Helper()
-
- var raw bytes.Buffer
- tw := tar.NewWriter(&raw)
- writeTarFiles(t, tw, files)
- if err := tw.Close(); err != nil {
- t.Fatal(err)
- }
- return raw.Bytes()
-}
-
-func writeTarFiles(t *testing.T, tw *tar.Writer, files map[string]string) {
- t.Helper()
-
- for name, content := range files {
- body := []byte(content)
- if err := tw.WriteHeader(&tar.Header{
- Name: name,
- Mode: 0o644,
- Size: int64(len(body)),
- }); err != nil {
- t.Fatal(err)
- }
- if _, err := tw.Write(body); err != nil {
- t.Fatal(err)
- }
- }
-}
diff --git a/internal/image/oci/index_codec.go b/internal/image/oci/index_codec.go
deleted file mode 100644
index 2b97d78..0000000
--- a/internal/image/oci/index_codec.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package oci
-
-import (
- stdjson "encoding/json"
- "fmt"
-
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-const contentIndexTable = "oci-content"
-const contentIndexRecord = "root"
-
-type indexCodec struct{}
-
-func (indexCodec) Decode(raw []byte) (*metajson.Model, error) {
- model := metajson.NewModel()
- if len(raw) == 0 {
- return model, nil
- }
- var index indexFile
- if err := stdjson.Unmarshal(raw, &index); err != nil {
- return nil, fmt.Errorf("parse OCI content index: %w", err)
- }
- index.init()
- encoded, err := stdjson.Marshal(index)
- if err != nil {
- return nil, fmt.Errorf("encode OCI content index record: %w", err)
- }
- model.Tables[contentIndexTable] = map[string]stdjson.RawMessage{contentIndexRecord: encoded}
- return model, nil
-}
-
-func (indexCodec) Encode(model *metajson.Model) ([]byte, error) {
- if model == nil {
- return nil, fmt.Errorf("OCI content metadata model must not be nil")
- }
- raw := model.Tables[contentIndexTable][contentIndexRecord]
- if len(raw) == 0 {
- index := indexFile{}
- index.init()
- raw, _ = stdjson.Marshal(index)
- }
- var index indexFile
- if err := stdjson.Unmarshal(raw, &index); err != nil {
- return nil, fmt.Errorf("parse OCI content index record: %w", err)
- }
- index.init()
- return stdjson.MarshalIndent(index, "", " ")
-}
diff --git a/internal/image/oci/pipeline.go b/internal/image/oci/pipeline.go
deleted file mode 100644
index e0d9af8..0000000
--- a/internal/image/oci/pipeline.go
+++ /dev/null
@@ -1,56 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-// Package oci coordinates OCI reference resolution, content retrieval, and
-// publication as a bootable KumaBox image.
-package oci
-
-import (
- "context"
-
- "github.com/kumabox/kumabox/internal/image"
-)
-
-// Content retrieves and indexes OCI content.
-type Content interface {
- Pull(context.Context, PullRequest) (*PullResult, error)
-}
-
-// ImageCatalog publishes durable managed-image records.
-type ImageCatalog interface {
- Create(image.CreateRequest) (*image.ImageRecord, error)
-}
-
-type imageBuilder interface {
- Build(context.Context, BuildRequest) (*image.ImageRecord, error)
-}
-
-// ImagePipeline presents one entry point for all OCI-backed image workflows.
-type ImagePipeline struct {
- content Content
- builder imageBuilder
-}
-
-// NewImagePipeline creates an OCI image pipeline using caller-owned metadata
-// capabilities. The pipeline does not own or close those capabilities.
-func NewImagePipeline(rootDir string, content Content, images ImageCatalog) *ImagePipeline {
- return &ImagePipeline{
- content: content,
- builder: NewBuilderWithStores(rootDir, content, images),
- }
-}
-
-// Resolve returns the digest-pinned metadata for an OCI reference without
-// opening the local content or image metadata stores.
-func Resolve(ctx context.Context, ref, platform string) (*ResolveResult, error) {
- return (Resolver{}).Resolve(ctx, ref, platform)
-}
-
-// Pull retrieves and indexes the content needed by an OCI image.
-func (p *ImagePipeline) Pull(ctx context.Context, req PullRequest) (*PullResult, error) {
- return p.content.Pull(ctx, req)
-}
-
-// Build converts OCI content into a bootable managed image.
-func (p *ImagePipeline) Build(ctx context.Context, req BuildRequest) (*image.ImageRecord, error) {
- return p.builder.Build(ctx, req)
-}
diff --git a/internal/image/oci/pipeline_test.go b/internal/image/oci/pipeline_test.go
deleted file mode 100644
index 7e4b7db..0000000
--- a/internal/image/oci/pipeline_test.go
+++ /dev/null
@@ -1,55 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package oci
-
-import (
- "context"
- "testing"
-
- "github.com/kumabox/kumabox/internal/image"
-)
-
-type fakeContentStore struct {
- result *PullResult
- req PullRequest
-}
-
-func (f *fakeContentStore) Pull(_ context.Context, req PullRequest) (*PullResult, error) {
- f.req = req
- return f.result, nil
-}
-
-type fakeImageBuilder struct {
- result *image.ImageRecord
- req BuildRequest
-}
-
-func (f *fakeImageBuilder) Build(_ context.Context, req BuildRequest) (*image.ImageRecord, error) {
- f.req = req
- return f.result, nil
-}
-
-func TestImagePipelineDelegatesWorkflowSteps(t *testing.T) {
- ref := "registry.example/test:latest"
- pullResult := &PullResult{Ref: ref}
- imageResult := &image.ImageRecord{Name: "test"}
- content := &fakeContentStore{result: pullResult}
- builder := &fakeImageBuilder{result: imageResult}
- pipeline := &ImagePipeline{content: content, builder: builder}
-
- pulled, err := pipeline.Pull(t.Context(), PullRequest{Ref: ref})
- if err != nil {
- t.Fatalf("pull: %v", err)
- }
- if pulled != pullResult || content.req.Ref != ref {
- t.Fatalf("pull was not delegated: result=%#v request=%#v", pulled, content.req)
- }
-
- built, err := pipeline.Build(t.Context(), BuildRequest{Name: imageResult.Name, Ref: ref})
- if err != nil {
- t.Fatalf("build: %v", err)
- }
- if built != imageResult || builder.req.Name != imageResult.Name {
- t.Fatalf("build was not delegated: result=%#v request=%#v", built, builder.req)
- }
-}
diff --git a/internal/image/oci/resolver.go b/internal/image/oci/resolver.go
deleted file mode 100644
index 6cd8152..0000000
--- a/internal/image/oci/resolver.go
+++ /dev/null
@@ -1,139 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package oci
-
-import (
- "context"
- "fmt"
- "runtime"
- "strings"
- "time"
-
- "github.com/google/go-containerregistry/pkg/authn"
- "github.com/google/go-containerregistry/pkg/name"
- v1 "github.com/google/go-containerregistry/pkg/v1"
- "github.com/google/go-containerregistry/pkg/v1/remote"
-)
-
-// Platform identifies the OCI platform selected from a manifest list.
-type Platform struct {
- OS string `json:"os"`
- Architecture string `json:"architecture"`
- Variant string `json:"variant,omitempty"`
-}
-
-// V1 converts Platform to go-containerregistry's platform type.
-func (p Platform) V1() v1.Platform {
- return v1.Platform{
- OS: p.OS,
- Architecture: p.Architecture,
- Variant: p.Variant,
- }
-}
-
-// Descriptor describes one OCI descriptor needed by later content-store steps.
-type Descriptor struct {
- Digest string `json:"digest"`
- MediaType string `json:"mediaType"`
- SizeBytes int64 `json:"sizeBytes"`
-}
-
-// ResolveResult is the digest-pinned view of an OCI image reference.
-type ResolveResult struct {
- Ref string `json:"ref"`
- Repository string `json:"repository"`
- ResolvedDigest string `json:"resolvedDigest"`
- DigestRef string `json:"digestRef"`
- Platform Platform `json:"platform"`
- Config Descriptor `json:"config"`
- Layers []Descriptor `json:"layers"`
- ResolvedAt time.Time `json:"resolvedAt"`
-}
-
-// Resolver resolves OCI refs using a registry, auth keychain, and selected platform.
-type Resolver struct{}
-
-// Resolve resolves ref to a single image manifest and returns its pinned digest.
-func (Resolver) Resolve(ctx context.Context, ref string, platform string) (*ResolveResult, error) {
- parsed, err := name.ParseReference(ref)
- if err != nil {
- return nil, fmt.Errorf("OCI_REF_INVALID: %w", err)
- }
- selected, err := ParsePlatform(platform)
- if err != nil {
- return nil, err
- }
-
- img, err := remote.Image(parsed,
- remote.WithAuthFromKeychain(authn.DefaultKeychain),
- remote.WithContext(ctx),
- remote.WithPlatform(selected.V1()),
- )
- if err != nil {
- return nil, fmt.Errorf("OCI_RESOLVE_FAILED: %w", err)
- }
-
- digest, err := img.Digest()
- if err != nil {
- return nil, fmt.Errorf("OCI_DIGEST_FAILED: %w", err)
- }
- manifest, err := img.Manifest()
- if err != nil {
- return nil, fmt.Errorf("OCI_MANIFEST_FAILED: %w", err)
- }
-
- layers := make([]Descriptor, 0, len(manifest.Layers))
- for _, layer := range manifest.Layers {
- layers = append(layers, Descriptor{
- Digest: layer.Digest.String(),
- MediaType: string(layer.MediaType),
- SizeBytes: layer.Size,
- })
- }
-
- return &ResolveResult{
- Ref: parsed.String(),
- Repository: parsed.Context().String(),
- ResolvedDigest: digest.String(),
- DigestRef: parsed.Context().String() + "@" + digest.String(),
- Platform: selected,
- Config: Descriptor{
- Digest: manifest.Config.Digest.String(),
- MediaType: string(manifest.Config.MediaType),
- SizeBytes: manifest.Config.Size,
- },
- Layers: layers,
- ResolvedAt: time.Now().UTC(),
- }, nil
-}
-
-// DefaultPlatform returns the host Linux OCI platform used when no flag is set.
-func DefaultPlatform() string {
- return "linux/" + runtime.GOARCH
-}
-
-// ParsePlatform parses os/arch[/variant] strings.
-func ParsePlatform(value string) (Platform, error) {
- if value == "" {
- value = DefaultPlatform()
- }
- parts := strings.Split(value, "/")
- if len(parts) < 2 || len(parts) > 3 || parts[0] == "" || parts[1] == "" {
- return Platform{}, fmt.Errorf("PLATFORM_INVALID: platform must be os/arch or os/arch/variant, got %q", value)
- }
- if parts[0] != "linux" {
- return Platform{}, fmt.Errorf("PLATFORM_UNSUPPORTED: only linux OCI images are supported, got %q", parts[0])
- }
- return Platform{
- OS: parts[0],
- Architecture: parts[1],
- Variant: variant(parts),
- }, nil
-}
-
-func variant(parts []string) string {
- if len(parts) == 3 {
- return parts[2]
- }
- return ""
-}
diff --git a/internal/image/oci/resolver_test.go b/internal/image/oci/resolver_test.go
deleted file mode 100644
index 03f4f62..0000000
--- a/internal/image/oci/resolver_test.go
+++ /dev/null
@@ -1,57 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package oci
-
-import "testing"
-
-func TestParsePlatform(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- value string
- want Platform
- wantErr bool
- }{
- {
- name: "linux amd64",
- value: "linux/amd64",
- want: Platform{OS: "linux", Architecture: "amd64"},
- },
- {
- name: "linux arm variant",
- value: "linux/arm/v7",
- want: Platform{OS: "linux", Architecture: "arm", Variant: "v7"},
- },
- {
- name: "missing arch",
- value: "linux",
- wantErr: true,
- },
- {
- name: "unsupported os",
- value: "windows/amd64",
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got, err := ParsePlatform(tt.value)
- if tt.wantErr {
- if err == nil {
- t.Fatal("expected error")
- }
- return
- }
- if err != nil {
- t.Fatalf("ParsePlatform returned error: %v", err)
- }
- if got != tt.want {
- t.Fatalf("ParsePlatform = %+v, want %+v", got, tt.want)
- }
- })
- }
-}
diff --git a/internal/image/oci/source.go b/internal/image/oci/source.go
deleted file mode 100644
index 983e785..0000000
--- a/internal/image/oci/source.go
+++ /dev/null
@@ -1,162 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-// Source acquisition supports local Docker images and remote registries.
-package oci
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "os/exec"
- "strings"
- "time"
-
- "github.com/google/go-containerregistry/pkg/authn"
- "github.com/google/go-containerregistry/pkg/name"
- v1 "github.com/google/go-containerregistry/pkg/v1"
- "github.com/google/go-containerregistry/pkg/v1/remote"
- "github.com/google/go-containerregistry/pkg/v1/tarball"
-)
-
-// SourceRequest describes an OCI image source.
-type SourceRequest struct {
- Ref string
- Platform string
- Source string
-}
-
-// SourceResult contains an acquired OCI image and its resolved metadata.
-type SourceResult struct {
- Image v1.Image
- Resolved *ResolveResult
- Source string
-}
-
-// openSource acquires an OCI image from the requested source. Source "auto" tries
-// the local Docker daemon before falling back to a registry.
-func openSource(ctx context.Context, req SourceRequest) (*SourceResult, error) {
- source := req.Source
- if source == "" {
- source = "auto"
- }
- switch source {
- case "auto":
- result, err := openDaemon(ctx, req)
- if err == nil {
- return result, nil
- }
- return openRegistry(ctx, req)
- case "daemon":
- return openDaemon(ctx, req)
- case "registry":
- return openRegistry(ctx, req)
- default:
- return nil, fmt.Errorf("OCI_SOURCE_INVALID: source must be one of auto, registry, or daemon")
- }
-}
-
-func openRegistry(ctx context.Context, req SourceRequest) (*SourceResult, error) {
- resolved, err := (Resolver{}).Resolve(ctx, req.Ref, req.Platform)
- if err != nil {
- return nil, err
- }
- parsed, err := name.ParseReference(req.Ref)
- if err != nil {
- return nil, fmt.Errorf("OCI_REF_INVALID: %w", err)
- }
- img, err := remote.Image(parsed,
- remote.WithAuthFromKeychain(authn.DefaultKeychain),
- remote.WithContext(ctx),
- remote.WithPlatform(resolved.Platform.V1()),
- )
- if err != nil {
- return nil, fmt.Errorf("OCI_PULL_FAILED: %w", err)
- }
- return &SourceResult{Image: img, Resolved: resolved, Source: "registry"}, nil
-}
-
-func openDaemon(ctx context.Context, req SourceRequest) (*SourceResult, error) {
- platform, err := ParsePlatform(req.Platform)
- if err != nil {
- return nil, err
- }
- parsed, err := name.ParseReference(req.Ref)
- if err != nil {
- return nil, fmt.Errorf("OCI_REF_INVALID: %w", err)
- }
- var out bytes.Buffer
- var stderr bytes.Buffer
- cmd := exec.CommandContext(ctx, "docker", "image", "save", req.Ref)
- cmd.Stdout = &out
- cmd.Stderr = &stderr
- if err := cmd.Run(); err != nil {
- return nil, fmt.Errorf("OCI_DAEMON_IMAGE_FAILED: docker image save %s: %w: %s", req.Ref, err, strings.TrimSpace(stderr.String()))
- }
- img, err := tarball.Image(func() (io.ReadCloser, error) {
- return io.NopCloser(bytes.NewReader(out.Bytes())), nil
- }, nil)
- if err != nil {
- return nil, fmt.Errorf("OCI_DAEMON_IMAGE_FAILED: parse docker image tar: %w", err)
- }
- if err := validatePlatform(img, platform); err != nil {
- return nil, err
- }
- digest, err := img.Digest()
- if err != nil {
- return nil, fmt.Errorf("OCI_DIGEST_FAILED: %w", err)
- }
- manifest, err := img.Manifest()
- if err != nil {
- return nil, fmt.Errorf("OCI_MANIFEST_FAILED: %w", err)
- }
- layers := make([]Descriptor, 0, len(manifest.Layers))
- for _, layer := range manifest.Layers {
- layers = append(layers, Descriptor{
- Digest: layer.Digest.String(),
- MediaType: string(layer.MediaType),
- SizeBytes: layer.Size,
- })
- }
- resolved := &ResolveResult{
- Ref: req.Ref,
- Repository: parsed.Context().String(),
- ResolvedDigest: digest.String(),
- DigestRef: parsed.Context().String() + "@" + digest.String(),
- Platform: platform,
- Config: Descriptor{
- Digest: manifest.Config.Digest.String(),
- MediaType: string(manifest.Config.MediaType),
- SizeBytes: manifest.Config.Size,
- },
- Layers: layers,
- ResolvedAt: time.Now().UTC(),
- }
- return &SourceResult{Image: img, Resolved: resolved, Source: "daemon"}, nil
-}
-
-func validatePlatform(img v1.Image, want Platform) error {
- raw, err := img.RawConfigFile()
- if err != nil {
- return fmt.Errorf("OCI_CONFIG_FAILED: %w", err)
- }
- var config struct {
- OS string `json:"os"`
- Architecture string `json:"architecture"`
- Variant string `json:"variant,omitempty"`
- }
- if err := json.Unmarshal(raw, &config); err != nil {
- return fmt.Errorf("OCI_CONFIG_FAILED: decode daemon image config: %w", err)
- }
- if config.OS != "" && config.OS != want.OS {
- return fmt.Errorf("OCI_PLATFORM_MISMATCH: daemon image os=%s, requested=%s", config.OS, want.OS)
- }
- if config.Architecture != "" && config.Architecture != want.Architecture {
- return fmt.Errorf("OCI_PLATFORM_MISMATCH: daemon image architecture=%s, requested=%s", config.Architecture, want.Architecture)
- }
- if want.Variant != "" && config.Variant != "" && config.Variant != want.Variant {
- return fmt.Errorf("OCI_PLATFORM_MISMATCH: daemon image variant=%s, requested=%s", config.Variant, want.Variant)
- }
- return nil
-}
diff --git a/internal/image/oci/source_test.go b/internal/image/oci/source_test.go
deleted file mode 100644
index 317e201..0000000
--- a/internal/image/oci/source_test.go
+++ /dev/null
@@ -1,18 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package oci
-
-import (
- "context"
- "strings"
- "testing"
-)
-
-func TestOpenRejectsUnknownSource(t *testing.T) {
- t.Parallel()
-
- _, err := openSource(context.Background(), SourceRequest{Ref: "example.com/image:latest", Source: "unknown"})
- if err == nil || !strings.Contains(err.Error(), "OCI_SOURCE_INVALID") {
- t.Fatalf("expected source validation error, got %v", err)
- }
-}
diff --git a/internal/image/oci/store.go b/internal/image/oci/store.go
deleted file mode 100644
index 7b89d29..0000000
--- a/internal/image/oci/store.go
+++ /dev/null
@@ -1,381 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package oci
-
-import (
- "bytes"
- "context"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
- "sync"
- "time"
-
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-// Store caches OCI manifest/config/layer blobs by digest.
-type Store struct {
- rootDir string
- engine meta.MetaEngine
- blobsDir string
- stageDir string
- blobLocks sync.Map
-}
-
-// PullRequest describes a P3-02 content-store pull.
-type PullRequest struct {
- Ref string
- Platform string
- Source string
- Progress func(ProgressEvent)
-}
-
-// ProgressEvent reports one durable phase of an OCI import.
-type ProgressEvent struct {
- Phase string
- Index int
- Total int
- Digest string
- Cached bool
-}
-
-// BlobRecord is one content-addressed blob on disk.
-type BlobRecord struct {
- Digest string `json:"digest"`
- Path string `json:"path"`
- MediaType string `json:"mediaType"`
- SizeBytes int64 `json:"sizeBytes"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-// RefRecord records the latest digest resolved for a tag/ref.
-type RefRecord struct {
- Ref string `json:"ref"`
- DigestRef string `json:"digestRef"`
- ResolvedDigest string `json:"resolvedDigest"`
- Platform Platform `json:"platform"`
- Config string `json:"config"`
- Layers []string `json:"layers"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-// PullResult summarizes a content-store pull.
-type PullResult struct {
- SchemaVersion string `json:"schemaVersion"`
- Ref string `json:"ref"`
- Source string `json:"source"`
- DigestRef string `json:"digestRef"`
- Platform Platform `json:"platform"`
- Manifest BlobRecord `json:"manifest"`
- Config BlobRecord `json:"config"`
- Layers []BlobRecord `json:"layers"`
- Cached int `json:"cached"`
- Downloaded int `json:"downloaded"`
-}
-
-type indexFile struct {
- SchemaVersion string `json:"schemaVersion"`
- Blobs map[string]*BlobRecord `json:"blobs"`
- Refs map[string]*RefRecord `json:"refs"`
-}
-
-var contentIndexCollection = meta.NewCollection[indexFile]("oci-content", contentIndexTable)
-
-// NewStore returns an OCI content store under rootDir.
-func NewStore(rootDir string) *Store {
- return NewStoreWithEngine(rootDir, mustOpenContentEngine(JSONNamespace(rootDir)))
-}
-
-// JSONNamespace describes the OCI content index used by the JSON metadata backend.
-func JSONNamespace(rootDir string) metajson.Namespace {
- base := filepath.Join(rootDir, "oci", "content")
- return metajson.Namespace{Name: "oci-content", FilePath: filepath.Join(base, "index.json"), LockPath: filepath.Join(base, "index.lock"), Codec: indexCodec{}}
-}
-
-// NewStoreWithEngine creates an OCI content store with an injected metadata engine.
-func NewStoreWithEngine(rootDir string, engine meta.MetaEngine) *Store {
- base := filepath.Join(rootDir, "oci", "content")
- return &Store{rootDir: base, engine: engine, blobsDir: filepath.Join(base, "blobs"), stageDir: filepath.Join(base, "staging")}
-}
-
-// MetadataEngine exposes the persistence boundary to migration tools.
-func (s *Store) MetadataEngine() meta.MetaEngine { return s.engine }
-
-func mustOpenContentEngine(namespace metajson.Namespace) meta.MetaEngine {
- engine, err := metajson.Open(namespace)
- if err != nil {
- panic(fmt.Sprintf("open OCI content metadata engine: %v", err))
- }
- return engine
-}
-
-// Pull resolves an OCI ref and downloads manifest/config/layers into the blob store.
-func (s *Store) Pull(ctx context.Context, req PullRequest) (*PullResult, error) {
- if req.Ref == "" {
- return nil, fmt.Errorf("OCI_REF_REQUIRED: ref must not be empty")
- }
-
- sourceResult, err := openSource(ctx, SourceRequest{
- Ref: req.Ref,
- Platform: req.Platform,
- Source: req.Source,
- })
- if err != nil {
- return nil, err
- }
- img := sourceResult.Image
- resolved := sourceResult.Resolved
- source := sourceResult.Source
-
- manifestBytes, err := img.RawManifest()
- if err != nil {
- return nil, fmt.Errorf("OCI_MANIFEST_FAILED: %w", err)
- }
- configBytes, err := img.RawConfigFile()
- if err != nil {
- return nil, fmt.Errorf("OCI_CONFIG_FAILED: %w", err)
- }
- layers, err := img.Layers()
- if err != nil {
- return nil, fmt.Errorf("OCI_LAYERS_FAILED: %w", err)
- }
-
- result := &PullResult{
- SchemaVersion: "kumabox.oci.content.pull.v1",
- Ref: resolved.Ref,
- Source: source,
- DigestRef: resolved.DigestRef,
- Platform: resolved.Platform,
- }
- emitProgress(req.Progress, ProgressEvent{Phase: "manifest", Digest: resolved.ResolvedDigest})
-
- manifest, manifestCached, err := s.ensureBlob(resolved.ResolvedDigest, "application/vnd.oci.image.manifest.v1+json", bytes.NewReader(manifestBytes))
- if err != nil {
- return nil, fmt.Errorf("store manifest: %w", err)
- }
- result.Manifest = manifest
- configRecord, configCached, err := s.ensureBlob(resolved.Config.Digest, resolved.Config.MediaType, bytes.NewReader(configBytes))
- if err != nil {
- return nil, fmt.Errorf("store config: %w", err)
- }
- result.Config = configRecord
- emitProgress(req.Progress, ProgressEvent{Phase: "config", Digest: result.Config.Digest})
- result.recordBlob(manifestCached)
- result.recordBlob(configCached)
-
- for i, layer := range layers {
- digest, err := layer.Digest()
- if err != nil {
- return nil, fmt.Errorf("layer %d digest: %w", i, err)
- }
- mediaType, err := layer.MediaType()
- if err != nil {
- return nil, fmt.Errorf("layer %d media type: %w", i, err)
- }
- rc, err := layer.Compressed()
- if err != nil {
- return nil, fmt.Errorf("layer %d compressed stream: %w", i, err)
- }
- rec, cached, storeErr := s.ensureBlob(digest.String(), string(mediaType), rc)
- closeErr := rc.Close()
- if storeErr != nil {
- return nil, fmt.Errorf("store layer %d: %w", i, storeErr)
- }
- if closeErr != nil {
- return nil, fmt.Errorf("close layer %d: %w", i, closeErr)
- }
- result.Layers = append(result.Layers, rec)
- result.recordBlob(cached)
- emitProgress(req.Progress, ProgressEvent{Phase: "layer", Index: i, Total: len(layers), Digest: rec.Digest, Cached: cached})
- }
-
- err = s.engine.Update(ctx, meta.Scope{Write: "oci-content"}, meta.CommitDurable, func(writer meta.Writer) error {
- idx, err := contentIndexCollection.Get(ctx, writer, contentIndexRecord)
- if errors.Is(err, meta.ErrNotFound) {
- idx = &indexFile{}
- } else if err != nil {
- return fmt.Errorf("read OCI content index: %w", err)
- }
- idx.init()
- for _, record := range append([]BlobRecord{result.Manifest, result.Config}, result.Layers...) {
- if existing := idx.Blobs[record.Digest]; existing != nil {
- record.CreatedAt = existing.CreatedAt
- }
- record.UpdatedAt = time.Now().UTC()
- recordCopy := record
- idx.Blobs[record.Digest] = &recordCopy
- }
-
- layerDigests := make([]string, 0, len(result.Layers))
- for _, layer := range result.Layers {
- layerDigests = append(layerDigests, layer.Digest)
- }
- now := time.Now().UTC()
- idx.Refs[resolved.Ref] = &RefRecord{
- Ref: resolved.Ref,
- DigestRef: resolved.DigestRef,
- ResolvedDigest: resolved.ResolvedDigest,
- Platform: resolved.Platform,
- Config: result.Config.Digest,
- Layers: layerDigests,
- UpdatedAt: now,
- }
- return contentIndexCollection.Upsert(ctx, writer, contentIndexRecord, idx)
- })
- if err != nil {
- return nil, err
- }
- emitProgress(req.Progress, ProgressEvent{Phase: "complete", Total: len(result.Layers)})
- return result, nil
-}
-
-func (r *PullResult) recordBlob(cached bool) {
- if cached {
- r.Cached++
- return
- }
- r.Downloaded++
-}
-
-func emitProgress(progress func(ProgressEvent), event ProgressEvent) {
- if progress != nil {
- progress(event)
- }
-}
-
-func (s *Store) ensureBlob(digest, mediaType string, src io.Reader) (BlobRecord, bool, error) {
- algo, hexDigest, err := splitDigest(digest)
- if err != nil {
- return BlobRecord{}, false, err
- }
- lockValue, _ := s.blobLocks.LoadOrStore(digest, &sync.Mutex{})
- lock := lockValue.(*sync.Mutex)
- lock.Lock()
- defer lock.Unlock()
- path := filepath.Join(s.blobsDir, algo, hexDigest)
- now := time.Now().UTC()
-
- if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() {
- got, hashErr := fileSHA256(path)
- if hashErr != nil {
- return BlobRecord{}, false, fmt.Errorf("verify existing blob: %w", hashErr)
- }
- if got != hexDigest {
- if err := os.Remove(path); err != nil {
- return BlobRecord{}, false, fmt.Errorf("remove corrupt blob: %w", err)
- }
- } else {
- rec := &BlobRecord{
- Digest: digest,
- Path: path,
- MediaType: mediaType,
- SizeBytes: info.Size(),
- CreatedAt: now,
- UpdatedAt: now,
- }
- return *rec, true, nil
- }
- }
-
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return BlobRecord{}, false, fmt.Errorf("create blob dir: %w", err)
- }
- if err := os.MkdirAll(s.stageDir, 0o755); err != nil {
- return BlobRecord{}, false, fmt.Errorf("create staging dir: %w", err)
- }
- tmp, err := os.CreateTemp(s.stageDir, "blob-*")
- if err != nil {
- return BlobRecord{}, false, fmt.Errorf("create staging blob: %w", err)
- }
- tmpPath := tmp.Name()
- defer os.Remove(tmpPath) //nolint:errcheck
-
- hasher := sha256.New()
- n, err := io.Copy(tmp, io.TeeReader(src, hasher))
- if err != nil {
- _ = tmp.Close()
- return BlobRecord{}, false, fmt.Errorf("write staging blob: %w", err)
- }
- if err := tmp.Sync(); err != nil {
- _ = tmp.Close()
- return BlobRecord{}, false, fmt.Errorf("sync staging blob: %w", err)
- }
- if err := tmp.Close(); err != nil {
- return BlobRecord{}, false, fmt.Errorf("close staging blob: %w", err)
- }
- if got := hex.EncodeToString(hasher.Sum(nil)); got != hexDigest {
- return BlobRecord{}, false, fmt.Errorf("OCI_DIGEST_MISMATCH: %s got sha256:%s", digest, got)
- }
- if err := os.Rename(tmpPath, path); err != nil {
- if !os.IsExist(err) {
- return BlobRecord{}, false, fmt.Errorf("commit blob: %w", err)
- }
- if got, hashErr := fileSHA256(path); hashErr != nil || got != hexDigest {
- return BlobRecord{}, false, fmt.Errorf("commit blob: existing target failed digest verification")
- }
- }
-
- rec := &BlobRecord{
- Digest: digest,
- Path: path,
- MediaType: mediaType,
- SizeBytes: n,
- CreatedAt: now,
- UpdatedAt: now,
- }
- return *rec, false, nil
-}
-
-func fileSHA256(path string) (sum string, err error) {
- file, err := os.Open(path) //nolint:gosec
- if err != nil {
- return "", err
- }
- defer func() {
- if closeErr := file.Close(); err == nil && closeErr != nil {
- err = fmt.Errorf("close OCI blob: %w", closeErr)
- }
- }()
- hasher := sha256.New()
- if _, err := io.Copy(hasher, file); err != nil {
- return "", err
- }
- return hex.EncodeToString(hasher.Sum(nil)), nil
-}
-
-func (idx *indexFile) init() {
- if idx.SchemaVersion == "" {
- idx.SchemaVersion = "kumabox.oci.content.index.v1"
- }
- if idx.Blobs == nil {
- idx.Blobs = make(map[string]*BlobRecord)
- }
- if idx.Refs == nil {
- idx.Refs = make(map[string]*RefRecord)
- }
-}
-
-func splitDigest(digest string) (string, string, error) {
- algo, value, ok := strings.Cut(digest, ":")
- if !ok || algo == "" || value == "" {
- return "", "", fmt.Errorf("OCI_DIGEST_INVALID: %s", digest)
- }
- if algo != "sha256" {
- return "", "", fmt.Errorf("OCI_DIGEST_UNSUPPORTED: %s", digest)
- }
- if len(value) != sha256.Size*2 {
- return "", "", fmt.Errorf("OCI_DIGEST_INVALID: %s", digest)
- }
- if _, err := hex.DecodeString(value); err != nil {
- return "", "", fmt.Errorf("OCI_DIGEST_INVALID: %w", err)
- }
- return algo, value, nil
-}
diff --git a/internal/image/oci/store_test.go b/internal/image/oci/store_test.go
deleted file mode 100644
index e70bdfb..0000000
--- a/internal/image/oci/store_test.go
+++ /dev/null
@@ -1,132 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package oci
-
-import (
- "bytes"
- "crypto/sha256"
- "encoding/hex"
- "os"
- "path/filepath"
- "strings"
- "sync"
- "testing"
-
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-func TestSplitDigest(t *testing.T) {
- t.Parallel()
-
- valid := "sha256:" + strings.Repeat("a", 64)
- algo, value, err := splitDigest(valid)
- if err != nil {
- t.Fatalf("splitDigest returned error: %v", err)
- }
- if algo != "sha256" || value != strings.Repeat("a", 64) {
- t.Fatalf("splitDigest = %q %q", algo, value)
- }
-
- for _, digest := range []string{
- "",
- "sha256:",
- "sha512:" + strings.Repeat("a", 128),
- "sha256:not-hex",
- "sha256:" + strings.Repeat("a", 63),
- } {
- if _, _, err := splitDigest(digest); err == nil {
- t.Fatalf("splitDigest(%q) expected error", digest)
- }
- }
-}
-
-func TestEnsureBlobReportsCacheAndAdoptsContent(t *testing.T) {
- t.Parallel()
-
- engine, err := meta.NewMemoryEngine("oci-content")
- if err != nil {
- t.Fatal(err)
- }
- store := NewStoreWithEngine(t.TempDir(), engine)
- content := []byte("content-addressed layer")
- digest := sha256Digest(content)
-
- first, cached, err := store.ensureBlob(digest, "application/octet-stream", bytes.NewReader(content))
- if err != nil {
- t.Fatal(err)
- }
- if cached {
- t.Fatal("new blob reported as cached")
- }
- second, cached, err := store.ensureBlob(digest, "application/octet-stream", bytes.NewReader([]byte("unused")))
- if err != nil {
- t.Fatal(err)
- }
- if !cached || first.Path != second.Path || first.SizeBytes != second.SizeBytes {
- t.Fatalf("cached blob = %+v cached=%t, first=%+v", second, cached, first)
- }
-}
-
-func TestEnsureBlobSerializesConcurrentDigestWriters(t *testing.T) {
- t.Parallel()
-
- engine, err := meta.NewMemoryEngine("oci-content")
- if err != nil {
- t.Fatal(err)
- }
- store := NewStoreWithEngine(t.TempDir(), engine)
- content := bytes.Repeat([]byte("layer"), 4096)
- digest := sha256Digest(content)
-
- const writers = 8
- var wg sync.WaitGroup
- errs := make(chan error, writers)
- for range writers {
- wg.Add(1)
- go func() {
- defer wg.Done()
- _, _, err := store.ensureBlob(digest, "application/octet-stream", bytes.NewReader(content))
- errs <- err
- }()
- }
- wg.Wait()
- close(errs)
- for err := range errs {
- if err != nil {
- t.Fatal(err)
- }
- }
-
- _, value, err := splitDigest(digest)
- if err != nil {
- t.Fatal(err)
- }
- path := filepath.Join(store.blobsDir, "sha256", value)
- stored, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- if !bytes.Equal(stored, content) {
- t.Fatalf("stored blob length = %d, want %d", len(stored), len(content))
- }
- if got, err := fileSHA256(path); err != nil || "sha256:"+got != digest {
- t.Fatalf("stored digest = sha256:%s, error %v", got, err)
- }
-}
-
-func TestPullResultRecordBlob(t *testing.T) {
- t.Parallel()
-
- var result PullResult
- result.recordBlob(false)
- result.recordBlob(true)
- result.recordBlob(false)
- if result.Downloaded != 2 || result.Cached != 1 {
- t.Fatalf("pull counters = downloaded %d cached %d", result.Downloaded, result.Cached)
- }
-}
-
-func sha256Digest(content []byte) string {
- digest := sha256.Sum256(content)
- return "sha256:" + hex.EncodeToString(digest[:])
-}
diff --git a/internal/image/record.go b/internal/image/record.go
deleted file mode 100644
index 1e8efcc..0000000
--- a/internal/image/record.go
+++ /dev/null
@@ -1,198 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-// Package image manages imported, pulled, and OCI-backed images.
-//
-// Image records are metadata only: they point at managed root disks and boot
-// requirements. VM records copy the resolved image reference at create/run time
-// so later image renames do not change existing VM intent.
-package image
-
-import "time"
-
-// Source describes where a managed image was imported from.
-type Source struct {
- Type string `json:"type"`
- URI string `json:"uri,omitempty"`
-}
-
-// RootDisk describes the managed root disk stored with an image.
-type RootDisk struct {
- Path string `json:"path,omitempty"`
- Format string `json:"format,omitempty"`
- VirtualSizeBytes int64 `json:"virtualSizeBytes,omitempty"`
- ActualSizeBytes int64 `json:"actualSizeBytes,omitempty"`
- SHA256 string `json:"sha256,omitempty"`
-}
-
-// Boot describes how VMs should boot from an image.
-type Boot struct {
- Mode string `json:"mode,omitempty"`
- Firmware string `json:"firmware,omitempty"`
- Kernel string `json:"kernel,omitempty"`
- Initrd string `json:"initrd,omitempty"`
- Cmdline string `json:"cmdline,omitempty"`
-}
-
-// OS describes the guest operating system profile for an image.
-type OS struct {
- Family string `json:"family,omitempty"`
- Version string `json:"version,omitempty"`
- Profile string `json:"profile,omitempty"`
-}
-
-// OCIPlatform identifies the image platform selected during OCI resolution.
-type OCIPlatform struct {
- OS string `json:"os"`
- Architecture string `json:"architecture"`
- Variant string `json:"variant,omitempty"`
-}
-
-// OCIDescriptor records one digest-addressed OCI object.
-type OCIDescriptor struct {
- Digest string `json:"digest"`
- MediaType string `json:"mediaType,omitempty"`
- SizeBytes int64 `json:"sizeBytes,omitempty"`
-}
-
-// EROFSLayer records the converted read-only filesystem for one OCI layer.
-type EROFSLayer struct {
- Path string `json:"path"`
- Filesystem string `json:"filesystem"`
- Digest string `json:"digest"`
- SizeBytes int64 `json:"sizeBytes"`
- SourceLayer string `json:"sourceLayer"`
-}
-
-// OCILayer records one OCI layer and its converted shared filesystem.
-type OCILayer struct {
- Index int `json:"index"`
- Digest string `json:"digest"`
- Serial string `json:"serial,omitempty"`
- Kernel string `json:"kernel,omitempty"`
- Initrd string `json:"initrd,omitempty"`
- MediaType string `json:"mediaType"`
- SizeBytes int64 `json:"sizeBytes"`
- EROFS *EROFSLayer `json:"erofs,omitempty"`
-}
-
-// OCIImageConfig preserves the container config fields needed by future agent
-// execution without starting the OCI entrypoint as the VM init process.
-type OCIImageConfig struct {
- Env *[]string `json:"env,omitempty"`
- Cmd *[]string `json:"cmd,omitempty"`
- Entrypoint *[]string `json:"entrypoint,omitempty"`
- Workdir *string `json:"workdir,omitempty"`
- User *string `json:"user,omitempty"`
- Labels *map[string]string `json:"labels,omitempty"`
-}
-
-// OCI records the OCI source and layer order for an image build.
-type OCI struct {
- Ref string `json:"ref"`
- Source string `json:"source"`
- DigestRef string `json:"digestRef"`
- Platform OCIPlatform `json:"platform"`
- Config OCIDescriptor `json:"config"`
- ImageConfig OCIImageConfig `json:"imageConfig,omitempty"`
- AgentInjection string `json:"agentInjection,omitempty"`
- Layers []OCILayer `json:"layers"`
- BuiltAt time.Time `json:"builtAt"`
-}
-
-const (
- AgentName = "kumabox-agent"
- AgentBinaryPath = "/usr/local/bin/kumabox-agent"
- AgentServicePath = "/etc/systemd/system/kumabox-agent.service"
- AgentProfileAuto = "auto"
- AgentProfileRequired = "required"
- AgentInjectionEmbedded = "embedded"
- AgentInjectionUnsupported = "unsupported"
-)
-
-// AgentProfile records how the guest agent is provided by an image.
-type AgentProfile struct {
- Name string `json:"name"`
- Version string `json:"version,omitempty"`
- Injection string `json:"injection"`
- BinaryPath string `json:"binaryPath,omitempty"`
- ServicePath string `json:"servicePath,omitempty"`
- Capabilities []string `json:"capabilities,omitempty"`
-}
-
-// ImageRecord is the persisted metadata for one managed image.
-type ImageRecord struct {
- SchemaVersion string `json:"schemaVersion"`
- ID string `json:"id"`
- Name string `json:"name"`
- Source Source `json:"source"`
- RootDisk RootDisk `json:"rootDisk"`
- Boot Boot `json:"boot"`
- OS OS `json:"os"`
- OCI *OCI `json:"oci,omitempty"`
- Agent *AgentProfile `json:"agent,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-func cloneRecord(rec *ImageRecord) *ImageRecord {
- if rec == nil {
- return nil
- }
- copied := *rec
- copied.Agent = cloneAgentProfile(rec.Agent)
- if rec.OCI != nil {
- oci := *rec.OCI
- oci.ImageConfig = cloneOCIImageConfig(rec.OCI.ImageConfig)
- oci.Layers = append([]OCILayer(nil), rec.OCI.Layers...)
- for i := range oci.Layers {
- if oci.Layers[i].EROFS == nil {
- continue
- }
- erofs := *oci.Layers[i].EROFS
- oci.Layers[i].EROFS = &erofs
- }
- copied.OCI = &oci
- }
- return &copied
-}
-
-func cloneAgentProfile(profile *AgentProfile) *AgentProfile {
- if profile == nil {
- return nil
- }
- copied := *profile
- copied.Capabilities = append([]string(nil), profile.Capabilities...)
- return &copied
-}
-
-func cloneOCIImageConfig(cfg OCIImageConfig) OCIImageConfig {
- copied := cfg
- if cfg.Env != nil {
- env := append([]string(nil), (*cfg.Env)...)
- copied.Env = &env
- }
- if cfg.Cmd != nil {
- cmd := append([]string(nil), (*cfg.Cmd)...)
- copied.Cmd = &cmd
- }
- if cfg.Entrypoint != nil {
- entrypoint := append([]string(nil), (*cfg.Entrypoint)...)
- copied.Entrypoint = &entrypoint
- }
- if cfg.Workdir != nil {
- workdir := *cfg.Workdir
- copied.Workdir = &workdir
- }
- if cfg.User != nil {
- user := *cfg.User
- copied.User = &user
- }
- if cfg.Labels != nil {
- labels := make(map[string]string, len(*cfg.Labels))
- for key, value := range *cfg.Labels {
- labels[key] = value
- }
- copied.Labels = &labels
- }
- return copied
-}
diff --git a/internal/image/store.go b/internal/image/store.go
deleted file mode 100644
index c9c76c5..0000000
--- a/internal/image/store.go
+++ /dev/null
@@ -1,614 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package image
-
-import (
- "context"
- "crypto/rand"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/fileutil"
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-// Store persists image metadata in the KumaBox image index.
-type Store struct {
- cloudimgDir string
- engine meta.MetaEngine
-}
-
-var imageIndexCollection = meta.NewCollection[imageIndex]("images", imageIndexTable)
-
-// New returns a Store rooted under rootDir.
-func New(rootDir string) *Store {
- engine := mustOpenImageEngine(JSONNamespace(rootDir))
- return NewWithEngine(rootDir, engine)
-}
-
-// JSONNamespace describes the image index used by the JSON metadata backend.
-func JSONNamespace(rootDir string) metajson.Namespace {
- cloudimgDir := filepath.Join(rootDir, "cloudimg")
- return metajson.Namespace{
- Name: "images",
- FilePath: filepath.Join(cloudimgDir, "index.json"),
- LockPath: filepath.Join(cloudimgDir, "index.lock"),
- Codec: indexCodec{},
- }
-}
-
-// NewWithEngine creates an image store with an injected metadata engine.
-func NewWithEngine(rootDir string, engine meta.MetaEngine) *Store {
- return &Store{cloudimgDir: filepath.Join(rootDir, "cloudimg"), engine: engine}
-}
-
-// MetadataEngine exposes the persistence boundary to migration tools.
-func (s *Store) MetadataEngine() meta.MetaEngine { return s.engine }
-
-func mustOpenImageEngine(namespace metajson.Namespace) meta.MetaEngine {
- engine, err := metajson.Open(namespace)
- if err != nil {
- panic(fmt.Sprintf("open image metadata engine: %v", err))
- }
- return engine
-}
-
-// CreateRequest contains metadata for creating an image record directly.
-type CreateRequest struct {
- Name string
- Source Source
- RootDisk RootDisk
- Boot Boot
- OS OS
- Agent *AgentProfile
- OCI *OCI
-}
-
-// ImportRequest describes a local cloud image import operation.
-type ImportRequest struct {
- Name string
- File string
- Firmware string
- QemuImgPath string
-}
-
-// PullRequest describes a URL cloud image pull operation.
-type PullRequest struct {
- Name string
- URL string
- Firmware string
- QemuImgPath string
- SHA256 string
-}
-
-// RemoveRequest describes a protected image deletion.
-type RemoveRequest struct {
- Ref string
- Force bool
- References []Reference
-}
-
-// Reference describes a VM that currently references an image.
-type Reference struct {
- Kind string `json:"kind,omitempty"`
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
- VMState string `json:"vmState,omitempty"`
- ImageID string `json:"imageId"`
-}
-
-// ImageInUseError reports VM references that blocked image deletion.
-type ImageInUseError struct {
- ImageID string `json:"imageId"`
- ImageName string `json:"imageName"`
- References []Reference `json:"references"`
-}
-
-func (e *ImageInUseError) Error() string {
- return fmt.Sprintf("IMAGE_IN_USE: image %s is referenced by %d resource(s)", e.ImageName, len(e.References))
-}
-
-func (e *ImageInUseError) Unwrap() error {
- return ErrImageInUse
-}
-
-// Create inserts an image record into the image index.
-func (s *Store) Create(req CreateRequest) (*ImageRecord, error) {
- if err := validateCreateRequest(req); err != nil {
- return nil, err
- }
-
- var created *ImageRecord
- err := s.update(func(idx *imageIndex) error {
- if _, ok := idx.Names[req.Name]; ok {
- return fmt.Errorf("%w: %s", ErrNameConflict, req.Name)
- }
-
- id, err := newID()
- if err != nil {
- return err
- }
- for {
- if _, exists := idx.Images[id]; !exists {
- break
- }
- id, err = newID()
- if err != nil {
- return err
- }
- }
-
- now := time.Now().UTC()
- rec := &ImageRecord{
- SchemaVersion: "kumabox.image.v1",
- ID: id,
- Name: req.Name,
- Source: req.Source,
- RootDisk: req.RootDisk,
- Boot: req.Boot,
- OS: req.OS,
- Agent: cloneAgentProfile(req.Agent),
- OCI: cloneOCI(req.OCI),
- CreatedAt: now,
- UpdatedAt: now,
- }
- imageDir := filepath.Join(s.cloudimgDir, id)
- if err := os.MkdirAll(imageDir, 0o755); err != nil {
- return fmt.Errorf("create image dir: %w", err)
- }
- if err := fileutil.WriteJSONAtomic(filepath.Join(imageDir, "image.json"), rec, ".image-*.tmp"); err != nil {
- _ = os.RemoveAll(imageDir)
- return fmt.Errorf("write image manifest: %w", err)
- }
- if err := fileutil.WriteJSONAtomic(filepath.Join(imageDir, "source.json"), rec.Source, ".source-*.tmp"); err != nil {
- _ = os.RemoveAll(imageDir)
- return fmt.Errorf("write image source manifest: %w", err)
- }
-
- idx.Images[id] = rec
- idx.Names[req.Name] = id
- created = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return created, nil
-}
-
-// ImportLocal imports a local cloud image into the managed image store.
-func (s *Store) ImportLocal(req ImportRequest) (*ImageRecord, error) {
- if err := validateImportRequest(req); err != nil {
- return nil, err
- }
- sourcePath, err := filepath.Abs(req.File)
- if err != nil {
- return nil, fmt.Errorf("resolve source image path: %w", err)
- }
- firmwarePath, err := filepath.Abs(req.Firmware)
- if err != nil {
- return nil, fmt.Errorf("resolve firmware path: %w", err)
- }
- if err := validateReadableFile(firmwarePath, "firmware"); err != nil {
- return nil, err
- }
-
- stagingDir, cleanup, err := s.createStagingDir("import")
- if err != nil {
- return nil, err
- }
- defer cleanup()
-
- artifact, err := importLocal(fileImportRequest{
- Source: sourcePath,
- Destination: filepath.Join(stagingDir, "base.img"),
- QemuImgPath: req.QemuImgPath,
- })
- if err != nil {
- return nil, err
- }
- diskName := "base." + diskExtension(artifact.Format)
- if artifact.Path != filepath.Join(stagingDir, diskName) {
- if err := os.Rename(artifact.Path, filepath.Join(stagingDir, diskName)); err != nil {
- return nil, fmt.Errorf("prepare imported image: %w", err)
- }
- artifact.Path = filepath.Join(stagingDir, diskName)
- }
-
- return s.commitImportedImage(CreateRequest{
- Name: req.Name,
- Source: Source{
- Type: "local-file",
- URI: sourcePath,
- },
- RootDisk: RootDisk{
- Path: diskName,
- Format: artifact.Format,
- VirtualSizeBytes: artifact.VirtualSizeBytes,
- ActualSizeBytes: artifact.ActualSizeBytes,
- SHA256: artifact.SHA256,
- },
- Boot: Boot{
- Mode: "uefi",
- Firmware: firmwarePath,
- },
- OS: OS{
- Family: osFamily(sourcePath),
- Profile: "ubuntu-cloudimg",
- },
- }, artifact.Path)
-}
-
-// Pull downloads a cloud image URL into staging and commits it to the image store.
-func (s *Store) Pull(req PullRequest) (*ImageRecord, error) {
- if err := validatePullRequest(req); err != nil {
- return nil, err
- }
- firmwarePath, err := filepath.Abs(req.Firmware)
- if err != nil {
- return nil, fmt.Errorf("resolve firmware path: %w", err)
- }
- if err := validateReadableFile(firmwarePath, "firmware"); err != nil {
- return nil, err
- }
-
- stagingDir, cleanup, err := s.createStagingDir("pull")
- if err != nil {
- return nil, err
- }
- defer cleanup()
-
- downloadedDisk := filepath.Join(stagingDir, "download.img")
- artifact, err := importRemote(fileImportRequest{
- Source: req.URL,
- Destination: downloadedDisk,
- QemuImgPath: req.QemuImgPath,
- ExpectedSHA256: req.SHA256,
- })
- if err != nil {
- if errors.Is(err, ErrChecksumMismatch) {
- return nil, fmt.Errorf("%w: %v", ErrChecksumMismatch, err)
- }
- return nil, err
- }
-
- diskName := "base." + diskExtension(artifact.Format)
- stagedDisk := filepath.Join(stagingDir, diskName)
- if err := os.Rename(downloadedDisk, stagedDisk); err != nil {
- return nil, fmt.Errorf("prepare pulled image: %w", err)
- }
-
- return s.commitImportedImage(CreateRequest{
- Name: req.Name,
- Source: Source{
- Type: "url",
- URI: req.URL,
- },
- RootDisk: RootDisk{
- Path: diskName,
- Format: artifact.Format,
- VirtualSizeBytes: artifact.VirtualSizeBytes,
- ActualSizeBytes: artifact.ActualSizeBytes,
- SHA256: artifact.SHA256,
- },
- Boot: Boot{
- Mode: "uefi",
- Firmware: firmwarePath,
- },
- OS: OS{
- Family: osFamily(artifact.SourceHint),
- Profile: "ubuntu-cloudimg",
- },
- }, stagedDisk)
-}
-
-// Inspect returns an image record by exact ID, name, or unique ID prefix.
-func (s *Store) Inspect(ref string) (*ImageRecord, error) {
- var rec *ImageRecord
- err := s.withIndex(func(idx *imageIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec = cloneRecord(idx.Images[id])
- return nil
- })
- if err != nil {
- return nil, err
- }
- return rec, nil
-}
-
-// List returns all image records sorted by creation time.
-func (s *Store) List() ([]*ImageRecord, error) {
- var records []*ImageRecord
- err := s.withIndex(func(idx *imageIndex) error {
- records = make([]*ImageRecord, 0, len(idx.Images))
- for _, rec := range idx.Images {
- records = append(records, cloneRecord(rec))
- }
- sort.Slice(records, func(i, j int) bool {
- return records[i].CreatedAt.Before(records[j].CreatedAt)
- })
- return nil
- })
- if err != nil {
- return nil, err
- }
- return records, nil
-}
-
-// Remove deletes an image manifest and managed disk when no VM references it.
-func (s *Store) Remove(req RemoveRequest) (*ImageRecord, error) {
- if req.Ref == "" {
- return nil, errors.New("image ref must not be empty")
- }
-
- var removed *ImageRecord
- err := s.update(func(idx *imageIndex) error {
- id, err := idx.resolve(req.Ref)
- if err != nil {
- return err
- }
- rec := idx.Images[id]
- refs := referencesForImage(req.References, id)
- if len(refs) > 0 {
- return &ImageInUseError{
- ImageID: rec.ID,
- ImageName: rec.Name,
- References: refs,
- }
- }
-
- imageDir := filepath.Join(s.cloudimgDir, id)
- if err := os.RemoveAll(imageDir); err != nil {
- return fmt.Errorf("remove image dir: %w", err)
- }
- delete(idx.Names, rec.Name)
- delete(idx.Images, id)
- removed = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return removed, nil
-}
-
-func (s *Store) commitImportedImage(req CreateRequest, stagedDisk string) (*ImageRecord, error) {
- if err := validateCreateRequest(req); err != nil {
- return nil, err
- }
-
- var created *ImageRecord
- err := s.update(func(idx *imageIndex) error {
- if _, ok := idx.Names[req.Name]; ok {
- return fmt.Errorf("%w: %s", ErrNameConflict, req.Name)
- }
-
- id, err := newID()
- if err != nil {
- return err
- }
- for {
- if _, exists := idx.Images[id]; !exists {
- break
- }
- id, err = newID()
- if err != nil {
- return err
- }
- }
-
- imageDir := filepath.Join(s.cloudimgDir, id)
- if err := os.MkdirAll(imageDir, 0o755); err != nil {
- return fmt.Errorf("create image dir: %w", err)
- }
- committedDisk := filepath.Join(imageDir, filepath.Base(req.RootDisk.Path))
- if err := os.Rename(stagedDisk, committedDisk); err != nil {
- return fmt.Errorf("commit root disk: %w", err)
- }
-
- now := time.Now().UTC()
- rec := &ImageRecord{
- SchemaVersion: "kumabox.image.v1",
- ID: id,
- Name: req.Name,
- Source: req.Source,
- RootDisk: RootDisk{
- Path: committedDisk,
- Format: req.RootDisk.Format,
- VirtualSizeBytes: req.RootDisk.VirtualSizeBytes,
- ActualSizeBytes: req.RootDisk.ActualSizeBytes,
- SHA256: req.RootDisk.SHA256,
- },
- Boot: req.Boot,
- OS: req.OS,
- Agent: cloneAgentProfile(req.Agent),
- OCI: cloneOCI(req.OCI),
- CreatedAt: now,
- UpdatedAt: now,
- }
- if err := fileutil.WriteJSONAtomic(filepath.Join(imageDir, "image.json"), rec, ".image-*.tmp"); err != nil {
- _ = os.RemoveAll(imageDir)
- return fmt.Errorf("write image manifest: %w", err)
- }
- if err := fileutil.WriteJSONAtomic(filepath.Join(imageDir, "source.json"), rec.Source, ".source-*.tmp"); err != nil {
- _ = os.RemoveAll(imageDir)
- return fmt.Errorf("write image source manifest: %w", err)
- }
-
- idx.Images[id] = rec
- idx.Names[req.Name] = id
- created = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return created, nil
-}
-
-func cloneOCI(oci *OCI) *OCI {
- if oci == nil {
- return nil
- }
- copied := *oci
- copied.ImageConfig = cloneOCIImageConfig(oci.ImageConfig)
- copied.Layers = append([]OCILayer(nil), oci.Layers...)
- for i := range copied.Layers {
- if copied.Layers[i].EROFS == nil {
- continue
- }
- erofs := *copied.Layers[i].EROFS
- copied.Layers[i].EROFS = &erofs
- }
- return &copied
-}
-
-func referencesForImage(refs []Reference, imageID string) []Reference {
- matched := make([]Reference, 0)
- for _, ref := range refs {
- if ref.ImageID == imageID {
- matched = append(matched, ref)
- }
- }
- return matched
-}
-
-func (s *Store) createStagingDir(prefix string) (string, func(), error) {
- stageID, err := newOperationID(prefix)
- if err != nil {
- return "", nil, err
- }
- stagingDir := filepath.Join(s.cloudimgDir, "staging", stageID)
- if err := os.MkdirAll(stagingDir, 0o755); err != nil {
- return "", nil, fmt.Errorf("create %s staging dir: %w", prefix, err)
- }
- return stagingDir, func() {
- _ = os.RemoveAll(stagingDir)
- }, nil
-}
-
-func (s *Store) withIndex(fn func(*imageIndex) error) error {
- ctx := context.Background()
- return s.engine.View(ctx, []meta.Namespace{"images"}, func(reader meta.Reader) error {
- idx, err := s.readIndex(ctx, reader)
- if err != nil {
- return err
- }
- return fn(idx)
- })
-}
-
-func (s *Store) update(fn func(*imageIndex) error) error {
- ctx := context.Background()
- return s.engine.Update(ctx, meta.Scope{Write: "images"}, meta.CommitDurable, func(writer meta.Writer) error {
- idx, err := s.readIndex(ctx, writer)
- if err != nil {
- return err
- }
- if err := fn(idx); err != nil {
- return err
- }
- return imageIndexCollection.Upsert(ctx, writer, imageIndexRecord, idx)
- })
-}
-
-func (s *Store) readIndex(ctx context.Context, reader meta.Reader) (*imageIndex, error) {
- idx, err := imageIndexCollection.Get(ctx, reader, imageIndexRecord)
- if errors.Is(err, meta.ErrNotFound) {
- idx = &imageIndex{}
- } else if err != nil {
- return nil, fmt.Errorf("read image index: %w", err)
- }
- idx.init()
- return idx, nil
-}
-
-func validateCreateRequest(req CreateRequest) error {
- if req.Name == "" {
- return errors.New("image name must not be empty")
- }
- return nil
-}
-
-func validateImportRequest(req ImportRequest) error {
- if req.Name == "" {
- return errors.New("image name must not be empty")
- }
- if req.File == "" {
- return errors.New("image file must not be empty")
- }
- if req.Firmware == "" {
- return errors.New("firmware must not be empty")
- }
- if req.QemuImgPath == "" {
- return errors.New("qemu-img path must not be empty")
- }
- return nil
-}
-
-func validatePullRequest(req PullRequest) error {
- if req.Name == "" {
- return errors.New("image name must not be empty")
- }
- if req.URL == "" {
- return errors.New("image URL must not be empty")
- }
- if req.Firmware == "" {
- return errors.New("firmware must not be empty")
- }
- if req.QemuImgPath == "" {
- return errors.New("qemu-img path must not be empty")
- }
- if req.SHA256 != "" {
- expected := strings.ToLower(strings.TrimSpace(req.SHA256))
- if len(expected) != sha256.Size*2 {
- return errors.New("sha256 must be a 64 character hex digest")
- }
- if _, err := hex.DecodeString(expected); err != nil {
- return fmt.Errorf("sha256 must be hex: %w", err)
- }
- }
- return nil
-}
-
-func validateReadableFile(path, label string) error {
- info, err := os.Stat(path)
- if err != nil {
- return fmt.Errorf("stat %s: %w", label, err)
- }
- if info.IsDir() {
- return fmt.Errorf("%s must be a file: %s", label, path)
- }
- file, err := os.Open(path) //nolint:gosec
- if err != nil {
- return fmt.Errorf("open %s: %w", label, err)
- }
- return file.Close()
-}
-
-func newID() (string, error) {
- var raw [8]byte
- if _, err := rand.Read(raw[:]); err != nil {
- return "", fmt.Errorf("generate image ID: %w", err)
- }
- return "img_" + hex.EncodeToString(raw[:]), nil
-}
-
-func newOperationID(prefix string) (string, error) {
- var raw [8]byte
- if _, err := rand.Read(raw[:]); err != nil {
- return "", fmt.Errorf("generate operation ID: %w", err)
- }
- return prefix + "-" + hex.EncodeToString(raw[:]), nil
-}
diff --git a/internal/image/store_test.go b/internal/image/store_test.go
deleted file mode 100644
index 8036cad..0000000
--- a/internal/image/store_test.go
+++ /dev/null
@@ -1,421 +0,0 @@
-// SPDX-License-Identifier: MIT
-
-package image
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "testing"
-)
-
-func TestStoreCreateListInspectAndResolve(t *testing.T) {
- store := New(filepath.Join(t.TempDir(), "data"))
-
- rec, err := store.Create(CreateRequest{
- Name: "ubuntu",
- Source: Source{Type: "test", URI: "fixtures/ubuntu.img"},
- RootDisk: RootDisk{
- Path: "base.qcow2",
- Format: "qcow2",
- },
- Boot: Boot{Mode: "uefi", Firmware: "CLOUDHV.fd"},
- OS: OS{Family: "ubuntu", Profile: "ubuntu-cloudimg"},
- })
- if err != nil {
- t.Fatal(err)
- }
- if !strings.HasPrefix(rec.ID, "img_") {
- t.Fatalf("image id = %s", rec.ID)
- }
-
- byName, err := store.Inspect("ubuntu")
- if err != nil {
- t.Fatal(err)
- }
- if byName.ID != rec.ID || byName.RootDisk.Format != "qcow2" {
- t.Fatalf("inspect by name = %+v", byName)
- }
-
- byPrefix, err := store.Inspect(rec.ID[:8])
- if err != nil {
- t.Fatal(err)
- }
- if byPrefix.ID != rec.ID {
- t.Fatalf("inspect by prefix = %+v", byPrefix)
- }
-
- records, err := store.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 1 || records[0].Name != "ubuntu" {
- t.Fatalf("records = %+v", records)
- }
-}
-
-func TestStoreRecoversPreviousIndexGeneration(t *testing.T) {
- dir := t.TempDir()
- store := New(dir)
- request := func(name string) CreateRequest {
- return CreateRequest{
- Name: name,
- Source: Source{Type: "test", URI: "fixture:" + name},
- RootDisk: RootDisk{Path: filepath.Join(dir, name+".img"), Format: "raw"},
- }
- }
- first, err := store.Create(request("first"))
- if err != nil {
- t.Fatal(err)
- }
- if _, err := store.Create(request("second")); err != nil {
- t.Fatal(err)
- }
-
- indexPath := filepath.Join(dir, "cloudimg", "index.json")
- if err := os.WriteFile(indexPath, []byte("{"), 0o600); err != nil {
- t.Fatal(err)
- }
- recovered, err := store.Inspect(first.ID)
- if err != nil {
- t.Fatalf("inspect recovered image: %v", err)
- }
- if recovered.Name != "first" {
- t.Fatalf("recovered image name = %q", recovered.Name)
- }
-}
-
-func TestStoreRejectsDuplicateImageName(t *testing.T) {
- store := New(filepath.Join(t.TempDir(), "data"))
-
- if _, err := store.Create(CreateRequest{Name: "ubuntu"}); err != nil {
- t.Fatal(err)
- }
- if _, err := store.Create(CreateRequest{Name: "ubuntu"}); !errors.Is(err, ErrNameConflict) {
- t.Fatalf("expected ErrNameConflict, got %v", err)
- }
-}
-
-func TestRemoveDeletesUnreferencedImage(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
- rec, err := store.Create(CreateRequest{
- Name: "ubuntu",
- Source: Source{Type: "test", URI: "fixtures/ubuntu.img"},
- RootDisk: RootDisk{
- Path: "base.qcow2",
- Format: "qcow2",
- },
- Boot: Boot{Mode: "uefi", Firmware: "CLOUDHV.fd"},
- })
- if err != nil {
- t.Fatal(err)
- }
- imageDir := filepath.Join(dir, "data", "cloudimg", rec.ID)
- if err := os.MkdirAll(imageDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(imageDir, "base.qcow2"), []byte("disk"), 0o600); err != nil {
- t.Fatal(err)
- }
-
- removed, err := store.Remove(RemoveRequest{Ref: "ubuntu"})
- if err != nil {
- t.Fatal(err)
- }
- if removed.ID != rec.ID {
- t.Fatalf("removed id = %s, want %s", removed.ID, rec.ID)
- }
- if _, err := store.Inspect("ubuntu"); !errors.Is(err, ErrNotFound) {
- t.Fatalf("inspect removed image error = %v", err)
- }
- if _, err := os.Stat(imageDir); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("image dir should be removed, stat error = %v", err)
- }
-}
-
-func TestRemoveRejectsReferencedImage(t *testing.T) {
- store := New(filepath.Join(t.TempDir(), "data"))
- rec, err := store.Create(CreateRequest{
- Name: "ubuntu",
- Source: Source{Type: "test", URI: "fixtures/ubuntu.img"},
- RootDisk: RootDisk{
- Path: "base.qcow2",
- Format: "qcow2",
- },
- Boot: Boot{Mode: "uefi", Firmware: "CLOUDHV.fd"},
- })
- if err != nil {
- t.Fatal(err)
- }
-
- _, err = store.Remove(RemoveRequest{
- Ref: rec.ID,
- References: []Reference{{
- VMID: "kb_123",
- VMName: "ref",
- VMState: "created",
- ImageID: rec.ID,
- }},
- })
- if !errors.Is(err, ErrImageInUse) {
- t.Fatalf("expected ErrImageInUse, got %v", err)
- }
- var inUse *ImageInUseError
- if !errors.As(err, &inUse) {
- t.Fatalf("expected ImageInUseError, got %T", err)
- }
- if len(inUse.References) != 1 || inUse.References[0].VMName != "ref" {
- t.Fatalf("references = %+v", inUse.References)
- }
- if _, err := store.Inspect(rec.ID); err != nil {
- t.Fatalf("referenced image should remain: %v", err)
- }
-}
-
-func TestResolveAmbiguousImagePrefix(t *testing.T) {
- idx := &imageIndex{
- Images: map[string]*ImageRecord{
- "img_abcdef1111111111": {ID: "img_abcdef1111111111"},
- "img_abcdef2222222222": {ID: "img_abcdef2222222222"},
- },
- }
-
- if _, err := idx.resolve("img_abcdef"); !errors.Is(err, ErrAmbiguous) {
- t.Fatalf("expected ambiguous ref, got %v", err)
- }
-}
-
-func TestImportLocalCommitsImageAndManifests(t *testing.T) {
- dir := t.TempDir()
- source := filepath.Join(dir, "fixtures", "jammy-server-cloudimg-amd64.img")
- if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil {
- t.Fatal(err)
- }
- sourceContent := []byte("cloud image")
- if err := os.WriteFile(source, sourceContent, 0o644); err != nil {
- t.Fatal(err)
- }
- firmware := filepath.Join(dir, "fixtures", "CLOUDHV.fd")
- if err := os.WriteFile(firmware, []byte("firmware"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- store := New(filepath.Join(dir, "data"))
- rec, err := store.ImportLocal(ImportRequest{
- Name: "ubuntu",
- File: source,
- Firmware: firmware,
- QemuImgPath: fakeQemuImg(t, dir, "qcow2", 4096, int64(len(sourceContent))),
- })
- if err != nil {
- t.Fatal(err)
- }
-
- if rec.Name != "ubuntu" || rec.Source.Type != "local-file" || rec.Source.URI != source {
- t.Fatalf("record source = %+v", rec)
- }
- if rec.RootDisk.Format != "qcow2" || rec.RootDisk.VirtualSizeBytes != 4096 {
- t.Fatalf("root disk = %+v", rec.RootDisk)
- }
- expectedSum := sha256.Sum256(sourceContent)
- if rec.RootDisk.SHA256 != hex.EncodeToString(expectedSum[:]) {
- t.Fatalf("sha256 = %s", rec.RootDisk.SHA256)
- }
- if _, err := os.Stat(rec.RootDisk.Path); err != nil {
- t.Fatalf("committed root disk missing: %v", err)
- }
- if !strings.Contains(rec.RootDisk.Path, string(filepath.Separator)+"cloudimg"+string(filepath.Separator)) {
- t.Fatalf("root disk path = %s, want cloudimg store", rec.RootDisk.Path)
- }
- if _, err := os.Stat(filepath.Join(filepath.Dir(rec.RootDisk.Path), "image.json")); err != nil {
- t.Fatalf("image manifest missing: %v", err)
- }
- if _, err := os.Stat(filepath.Join(filepath.Dir(rec.RootDisk.Path), "source.json")); err != nil {
- t.Fatalf("source manifest missing: %v", err)
- }
- if _, err := os.Stat(source); err != nil {
- t.Fatalf("source image should remain: %v", err)
- }
-
- inspected, err := store.Inspect("ubuntu")
- if err != nil {
- t.Fatal(err)
- }
- if inspected.ID != rec.ID {
- t.Fatalf("inspect id = %s, want %s", inspected.ID, rec.ID)
- }
-}
-
-func TestImportLocalDoesNotIndexFailedInspect(t *testing.T) {
- dir := t.TempDir()
- source := filepath.Join(dir, "ubuntu.img")
- if err := os.WriteFile(source, []byte("cloud image"), 0o644); err != nil {
- t.Fatal(err)
- }
- firmware := filepath.Join(dir, "CLOUDHV.fd")
- if err := os.WriteFile(firmware, []byte("firmware"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- store := New(filepath.Join(dir, "data"))
- _, err := store.ImportLocal(ImportRequest{
- Name: "bad",
- File: source,
- Firmware: firmware,
- QemuImgPath: fakeFailingQemuImg(t, dir),
- })
- if err == nil {
- t.Fatal("expected import failure")
- }
- records, listErr := store.List()
- if listErr != nil {
- t.Fatal(listErr)
- }
- if len(records) != 0 {
- t.Fatalf("failed import should not update index: %+v", records)
- }
-}
-
-func TestPullDownloadsHTTPURLAndCommitsImage(t *testing.T) {
- dir := t.TempDir()
- content := []byte("cloud image from http")
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/jammy-server-cloudimg-amd64.img" {
- http.NotFound(w, r)
- return
- }
- _, _ = w.Write(content)
- }))
- defer server.Close()
-
- firmware := filepath.Join(dir, "fixtures", "CLOUDHV.fd")
- if err := os.MkdirAll(filepath.Dir(firmware), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(firmware, []byte("firmware"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- expectedSum := sha256.Sum256(content)
- store := New(filepath.Join(dir, "data"))
- rec, err := store.Pull(PullRequest{
- Name: "ubuntu-http",
- URL: server.URL + "/jammy-server-cloudimg-amd64.img",
- Firmware: firmware,
- QemuImgPath: fakeQemuImg(t, dir, "qcow2", 8192, int64(len(content))),
- SHA256: hex.EncodeToString(expectedSum[:]),
- })
- if err != nil {
- t.Fatal(err)
- }
-
- if rec.Name != "ubuntu-http" || rec.Source.Type != "url" {
- t.Fatalf("record source = %+v", rec)
- }
- if rec.RootDisk.Format != "qcow2" || rec.RootDisk.VirtualSizeBytes != 8192 {
- t.Fatalf("root disk = %+v", rec.RootDisk)
- }
- if rec.RootDisk.SHA256 != hex.EncodeToString(expectedSum[:]) {
- t.Fatalf("sha256 = %s", rec.RootDisk.SHA256)
- }
- committed, err := os.ReadFile(rec.RootDisk.Path)
- if err != nil {
- t.Fatal(err)
- }
- if string(committed) != string(content) {
- t.Fatalf("committed disk content = %q", committed)
- }
-}
-
-func TestPullCopiesFileURLAndCommitsImage(t *testing.T) {
- dir := t.TempDir()
- source := filepath.Join(dir, "fixtures", "noble-server-cloudimg-amd64.img")
- if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(source, []byte("file url image"), 0o644); err != nil {
- t.Fatal(err)
- }
- firmware := filepath.Join(dir, "fixtures", "CLOUDHV.fd")
- if err := os.WriteFile(firmware, []byte("firmware"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- store := New(filepath.Join(dir, "data"))
- rec, err := store.Pull(PullRequest{
- Name: "ubuntu-file",
- URL: "file://" + source,
- Firmware: firmware,
- QemuImgPath: fakeQemuImg(t, dir, "raw", 4096, 14),
- })
- if err != nil {
- t.Fatal(err)
- }
-
- if rec.RootDisk.Format != "raw" || filepath.Base(rec.RootDisk.Path) != "base.raw" {
- t.Fatalf("root disk = %+v", rec.RootDisk)
- }
- if rec.OS.Family != "ubuntu" {
- t.Fatalf("os = %+v", rec.OS)
- }
-}
-
-func TestPullRejectsChecksumMismatchWithoutIndexUpdate(t *testing.T) {
- dir := t.TempDir()
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte("unexpected content"))
- }))
- defer server.Close()
-
- firmware := filepath.Join(dir, "CLOUDHV.fd")
- if err := os.WriteFile(firmware, []byte("firmware"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- store := New(filepath.Join(dir, "data"))
- _, err := store.Pull(PullRequest{
- Name: "bad-checksum",
- URL: server.URL + "/image.img",
- Firmware: firmware,
- QemuImgPath: fakeQemuImg(t, dir, "qcow2", 4096, 18),
- SHA256: strings.Repeat("0", sha256.Size*2),
- })
- if !errors.Is(err, ErrChecksumMismatch) {
- t.Fatalf("expected ErrChecksumMismatch, got %v", err)
- }
-
- records, listErr := store.List()
- if listErr != nil {
- t.Fatal(listErr)
- }
- if len(records) != 0 {
- t.Fatalf("failed pull should not update index: %+v", records)
- }
-}
-
-func fakeQemuImg(t *testing.T, dir, format string, virtualSize, actualSize int64) string {
- t.Helper()
- path := filepath.Join(dir, "qemu-img")
- script := "#!/bin/sh\n" +
- "printf '{\"format\":\"" + format + "\",\"virtual-size\":" + strconv.FormatInt(virtualSize, 10) + ",\"actual-size\":" + strconv.FormatInt(actualSize, 10) + "}'\n"
- if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
- t.Fatal(err)
- }
- return path
-}
-
-func fakeFailingQemuImg(t *testing.T, dir string) string {
- t.Helper()
- path := filepath.Join(dir, "qemu-img-fail")
- if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 2\n"), 0o755); err != nil {
- t.Fatal(err)
- }
- return path
-}
diff --git a/internal/lock/guard.go b/internal/lock/guard.go
deleted file mode 100644
index b63ca94..0000000
--- a/internal/lock/guard.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package lock
-
-import (
- "context"
- "fmt"
- "path/filepath"
-)
-
-const maintenanceKey = "maintenance"
-
-// EntityKind identifies a lock domain for one durable resource type.
-type EntityKind string
-
-const (
- // EntityImage coordinates image references with image deletion.
- EntityImage EntityKind = "image"
-)
-
-// Guard owns the stable cross-process locks for one KumaBox root.
-type Guard struct {
- locks *Locker
-}
-
-// NewGuard creates a guard rooted in KumaBox's durable lock directory.
-func NewGuard(rootDir string) *Guard {
- return &Guard{locks: NewLocker(filepath.Join(rootDir, "locks", "resources"))}
-}
-
-// BeginMutation permits concurrent ordinary mutations while excluding GC.
-func (g *Guard) BeginMutation(ctx context.Context) (*Lock, error) {
- lock, err := g.locks.AcquireShared(ctx, maintenanceKey)
- if err != nil {
- return nil, fmt.Errorf("lock resource mutation: %w", err)
- }
- return lock, nil
-}
-
-// BeginMaintenance excludes all guarded mutations for a complete GC cycle.
-func (g *Guard) BeginMaintenance(ctx context.Context) (*Lock, error) {
- lock, err := g.locks.Acquire(ctx, maintenanceKey)
- if err != nil {
- return nil, fmt.Errorf("lock resource maintenance: %w", err)
- }
- return lock, nil
-}
-
-// LockEntity serializes publication, reference changes, and deletion for one
-// durable entity. Callers must acquire the maintenance lock first.
-func (g *Guard) LockEntity(ctx context.Context, kind EntityKind, id string) (*Lock, error) {
- if kind == "" || id == "" {
- return nil, fmt.Errorf("resource lock kind and id must not be empty")
- }
- lock, err := g.locks.Acquire(ctx, string(kind)+"-"+id)
- if err != nil {
- return nil, fmt.Errorf("lock %s %s: %w", kind, id, err)
- }
- return lock, nil
-}
diff --git a/internal/lock/guard_test.go b/internal/lock/guard_test.go
deleted file mode 100644
index 068d3ed..0000000
--- a/internal/lock/guard_test.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package lock
-
-import (
- "context"
- "errors"
- "testing"
- "time"
-)
-
-func TestMaintenanceWaitsForEveryMutation(t *testing.T) {
- guard := NewGuard(t.TempDir())
- first, err := guard.BeginMutation(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- second, err := guard.BeginMutation(t.Context())
- if err != nil {
- t.Fatal(err)
- }
-
- ctx, cancel := context.WithTimeout(t.Context(), 75*time.Millisecond)
- defer cancel()
- _, err = guard.BeginMaintenance(ctx)
- if !errors.Is(err, context.DeadlineExceeded) {
- t.Fatalf("BeginMaintenance() error = %v, want context deadline", err)
- }
- if err := first.Release(); err != nil {
- t.Fatal(err)
- }
- if err := second.Release(); err != nil {
- t.Fatal(err)
- }
-
- maintenance, err := guard.BeginMaintenance(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if err := maintenance.Release(); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestEntityLocksAreScopedByKindAndID(t *testing.T) {
- guard := NewGuard(t.TempDir())
- image, err := guard.LockEntity(t.Context(), EntityImage, "img_one")
- if err != nil {
- t.Fatal(err)
- }
- defer image.Release() //nolint:errcheck
-
- other, err := guard.LockEntity(t.Context(), EntityImage, "img_two")
- if err != nil {
- t.Fatal(err)
- }
- if err := other.Release(); err != nil {
- t.Fatal(err)
- }
-
- ctx, cancel := context.WithTimeout(t.Context(), 75*time.Millisecond)
- defer cancel()
- _, err = guard.LockEntity(ctx, EntityImage, "img_one")
- if !errors.Is(err, context.DeadlineExceeded) {
- t.Fatalf("LockEntity() error = %v, want context deadline", err)
- }
-}
diff --git a/internal/lock/locker.go b/internal/lock/locker.go
deleted file mode 100644
index d181795..0000000
--- a/internal/lock/locker.go
+++ /dev/null
@@ -1,102 +0,0 @@
-// Package lock provides cross-process coordination for daemonless operations.
-package lock
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "sync"
- "syscall"
- "time"
-)
-
-const retryInterval = 25 * time.Millisecond
-
-// Locker owns a directory of stable lock files.
-type Locker struct {
- dir string
-}
-
-// Lock is an acquired advisory file lock.
-type Lock struct {
- file *os.File
- once sync.Once
-}
-
-// NewLocker returns a Locker rooted at dir.
-func NewLocker(dir string) *Locker {
- return &Locker{dir: dir}
-}
-
-// Acquire waits until key is exclusively locked or ctx is cancelled.
-func (l *Locker) Acquire(ctx context.Context, key string) (*Lock, error) {
- return l.acquire(ctx, key, syscall.LOCK_EX)
-}
-
-// AcquireShared waits until key is shared-locked or ctx is cancelled.
-// Shared holders may run concurrently, but exclude an Acquire holder.
-func (l *Locker) AcquireShared(ctx context.Context, key string) (*Lock, error) {
- return l.acquire(ctx, key, syscall.LOCK_SH)
-}
-
-func (l *Locker) acquire(ctx context.Context, key string, mode int) (*Lock, error) {
- if err := validateKey(key); err != nil {
- return nil, err
- }
- if err := ctx.Err(); err != nil {
- return nil, fmt.Errorf("acquire lock %s: %w", key, err)
- }
- if err := os.MkdirAll(l.dir, 0o700); err != nil {
- return nil, fmt.Errorf("create lock directory: %w", err)
- }
- file, err := os.OpenFile(filepath.Join(l.dir, key+".lock"), os.O_CREATE|os.O_RDWR, 0o600)
- if err != nil {
- return nil, fmt.Errorf("open lock %s: %w", key, err)
- }
-
- ticker := time.NewTicker(retryInterval)
- defer ticker.Stop()
- for {
- err = syscall.Flock(int(file.Fd()), mode|syscall.LOCK_NB)
- if err == nil {
- return &Lock{file: file}, nil
- }
- if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) {
- _ = file.Close()
- return nil, fmt.Errorf("lock %s: %w", key, err)
- }
- select {
- case <-ctx.Done():
- _ = file.Close()
- return nil, fmt.Errorf("acquire lock %s: %w", key, ctx.Err())
- case <-ticker.C:
- }
- }
-}
-
-// Release unlocks and closes the lock. It is safe to call more than once.
-func (l *Lock) Release() error {
- if l == nil || l.file == nil {
- return nil
- }
- var releaseErr error
- l.once.Do(func() {
- unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
- closeErr := l.file.Close()
- releaseErr = errors.Join(unlockErr, closeErr)
- })
- if releaseErr != nil {
- return fmt.Errorf("release lock: %w", releaseErr)
- }
- return nil
-}
-
-func validateKey(key string) error {
- if key == "" || key == "." || key == ".." || strings.ContainsAny(key, `/\\`) {
- return fmt.Errorf("invalid lock key %q", key)
- }
- return nil
-}
diff --git a/internal/lock/locker_test.go b/internal/lock/locker_test.go
deleted file mode 100644
index ac3bb63..0000000
--- a/internal/lock/locker_test.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package lock
-
-import (
- "context"
- "errors"
- "testing"
- "time"
-)
-
-func TestAcquireSerializesSameKey(t *testing.T) {
- locker := NewLocker(t.TempDir())
- first, err := locker.Acquire(context.Background(), "kb_same")
- if err != nil {
- t.Fatal(err)
- }
- defer first.Release() //nolint:errcheck
-
- ctx, cancel := context.WithTimeout(context.Background(), 75*time.Millisecond)
- defer cancel()
- _, err = locker.Acquire(ctx, "kb_same")
- if !errors.Is(err, context.DeadlineExceeded) {
- t.Fatalf("Acquire() error = %v, want context deadline", err)
- }
-}
-
-func TestAcquireDoesNotSerializeDifferentKeys(t *testing.T) {
- locker := NewLocker(t.TempDir())
- first, err := locker.Acquire(context.Background(), "kb_first")
- if err != nil {
- t.Fatal(err)
- }
- defer first.Release() //nolint:errcheck
-
- second, err := locker.Acquire(context.Background(), "kb_second")
- if err != nil {
- t.Fatal(err)
- }
- if err := second.Release(); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestSharedLocksRunConcurrentlyAndExcludeWriter(t *testing.T) {
- locker := NewLocker(t.TempDir())
- first, err := locker.AcquireShared(context.Background(), "resources")
- if err != nil {
- t.Fatal(err)
- }
- defer first.Release() //nolint:errcheck
-
- second, err := locker.AcquireShared(context.Background(), "resources")
- if err != nil {
- t.Fatal(err)
- }
- if err := second.Release(); err != nil {
- t.Fatal(err)
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 75*time.Millisecond)
- defer cancel()
- _, err = locker.Acquire(ctx, "resources")
- if !errors.Is(err, context.DeadlineExceeded) {
- t.Fatalf("exclusive Acquire() error = %v, want context deadline", err)
- }
-}
-
-func TestExclusiveLockExcludesSharedReader(t *testing.T) {
- locker := NewLocker(t.TempDir())
- writer, err := locker.Acquire(context.Background(), "resources")
- if err != nil {
- t.Fatal(err)
- }
- defer writer.Release() //nolint:errcheck
-
- ctx, cancel := context.WithTimeout(context.Background(), 75*time.Millisecond)
- defer cancel()
- _, err = locker.AcquireShared(ctx, "resources")
- if !errors.Is(err, context.DeadlineExceeded) {
- t.Fatalf("AcquireShared() error = %v, want context deadline", err)
- }
-}
-
-func TestReleaseAllowsReacquire(t *testing.T) {
- locker := NewLocker(t.TempDir())
- lock, err := locker.Acquire(context.Background(), "kb_release")
- if err != nil {
- t.Fatal(err)
- }
- if err := lock.Release(); err != nil {
- t.Fatal(err)
- }
- if err := lock.Release(); err != nil {
- t.Fatal(err)
- }
-
- next, err := locker.Acquire(context.Background(), "kb_release")
- if err != nil {
- t.Fatal(err)
- }
- defer next.Release() //nolint:errcheck
-}
diff --git a/internal/meta/backend_benchmark_test.go b/internal/meta/backend_benchmark_test.go
deleted file mode 100644
index 26ae69a..0000000
--- a/internal/meta/backend_benchmark_test.go
+++ /dev/null
@@ -1,167 +0,0 @@
-package meta_test
-
-import (
- "context"
- "errors"
- "path/filepath"
- "testing"
-
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
- metasqlite "github.com/kumabox/kumabox/internal/meta/sqlite"
-)
-
-type benchmarkRecord struct {
- Value int `json:"value"`
-}
-
-func BenchmarkMetadataUpdateJSON(b *testing.B) {
- benchmarkMetadataUpdate(b, func(dir string) (meta.MetaEngine, error) {
- return metajson.Open(metajson.Namespace{
- Name: "bench", FilePath: filepath.Join(dir, "records.json"), LockPath: filepath.Join(dir, "records.lock"),
- Codec: metajson.TableCodec{Specs: []metajson.TableSpec{{Key: "records", Table: "records"}}},
- })
- })
-}
-
-func BenchmarkMetadataUpdateSQLite(b *testing.B) {
- benchmarkMetadataUpdate(b, func(dir string) (meta.MetaEngine, error) {
- return openSQLiteEngine(context.Background(), filepath.Join(dir, "metadata.db"), metasqlite.Namespace{Name: "bench", Tables: []meta.Table{"records"}})
- })
-}
-
-func openSQLiteEngine(ctx context.Context, path string, definition metasqlite.Namespace) (meta.MetaEngine, error) {
- if err := metasqlite.Init(ctx, path, definition); err != nil {
- return nil, err
- }
- return metasqlite.Open(path, definition)
-}
-
-func benchmarkMetadataUpdate(b *testing.B, open func(string) (meta.MetaEngine, error)) {
- b.Helper()
- engine, err := open(b.TempDir())
- if err != nil {
- b.Fatal(err)
- }
- b.Cleanup(func() {
- if err := engine.Close(); err != nil {
- b.Errorf("close metadata engine: %v", err)
- }
- })
- collection := meta.NewCollection[benchmarkRecord]("bench", "records")
- ctx := context.Background()
- b.ReportAllocs()
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- record := benchmarkRecord{Value: i}
- if err := engine.Update(ctx, meta.Scope{Write: "bench"}, meta.CommitRelaxed, func(writer meta.Writer) error {
- return collection.Upsert(ctx, writer, meta.RecordID("record"), &record)
- }); err != nil {
- b.Fatal(err)
- }
- }
-}
-
-func TestMetadataBackendsRollbackTheWholeUpdate(t *testing.T) {
- for _, tc := range []struct {
- name string
- open func(string) (meta.MetaEngine, error)
- }{
- {name: "json", open: func(dir string) (meta.MetaEngine, error) {
- return metajson.Open(metajson.Namespace{
- Name: "fault", FilePath: filepath.Join(dir, "records.json"), LockPath: filepath.Join(dir, "records.lock"),
- Codec: metajson.TableCodec{Specs: []metajson.TableSpec{{Key: "records", Table: "records"}}},
- })
- }},
- {name: "sqlite", open: func(dir string) (meta.MetaEngine, error) {
- return openSQLiteEngine(context.Background(), filepath.Join(dir, "metadata.db"), metasqlite.Namespace{Name: "fault", Tables: []meta.Table{"records"}})
- }},
- } {
- t.Run(tc.name, func(t *testing.T) {
- engine, err := tc.open(t.TempDir())
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- if err := engine.Close(); err != nil {
- t.Errorf("close metadata engine: %v", err)
- }
- })
- collection := meta.NewCollection[benchmarkRecord]("fault", "records")
- ctx := context.Background()
- wantErr := errors.New("injected failure")
- if err := engine.Update(ctx, meta.Scope{Write: "fault"}, meta.CommitDurable, func(writer meta.Writer) error {
- record := benchmarkRecord{Value: 1}
- if err := collection.Upsert(ctx, writer, meta.RecordID("record"), &record); err != nil {
- return err
- }
- return wantErr
- }); !errors.Is(err, wantErr) {
- t.Fatalf("update error = %v", err)
- }
- if err := engine.View(ctx, []meta.Namespace{"fault"}, func(reader meta.Reader) error {
- _, err := collection.Get(ctx, reader, meta.RecordID("record"))
- if !errors.Is(err, meta.ErrNotFound) {
- return errors.New("failed update was persisted")
- }
- return nil
- }); err != nil {
- t.Fatal(err)
- }
- })
- }
-}
-
-func TestMetadataBackendsDoNotPartiallyOverwriteExistingRecords(t *testing.T) {
- for _, tc := range []struct {
- name string
- open func(string) (meta.MetaEngine, error)
- }{
- {name: "json", open: func(dir string) (meta.MetaEngine, error) {
- return metajson.Open(metajson.Namespace{Name: "fault", FilePath: filepath.Join(dir, "records.json"), LockPath: filepath.Join(dir, "records.lock"), Codec: metajson.TableCodec{Specs: []metajson.TableSpec{{Key: "records", Table: "records"}}}})
- }},
- {name: "sqlite", open: func(dir string) (meta.MetaEngine, error) {
- return openSQLiteEngine(context.Background(), filepath.Join(dir, "metadata.db"), metasqlite.Namespace{Name: "fault", Tables: []meta.Table{"records"}})
- }},
- } {
- t.Run(tc.name, func(t *testing.T) {
- engine, err := tc.open(t.TempDir())
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- if err := engine.Close(); err != nil {
- t.Errorf("close metadata engine: %v", err)
- }
- })
- collection := meta.NewCollection[benchmarkRecord]("fault", "records")
- ctx := context.Background()
- if err := engine.Update(ctx, meta.Scope{Write: "fault"}, meta.CommitDurable, func(writer meta.Writer) error {
- return collection.Upsert(ctx, writer, meta.RecordID("record"), &benchmarkRecord{Value: 7})
- }); err != nil {
- t.Fatal(err)
- }
- wantErr := errors.New("injected overwrite failure")
- if err := engine.Update(ctx, meta.Scope{Write: "fault"}, meta.CommitDurable, func(writer meta.Writer) error {
- if err := collection.Upsert(ctx, writer, meta.RecordID("record"), &benchmarkRecord{Value: 99}); err != nil {
- return err
- }
- return wantErr
- }); !errors.Is(err, wantErr) {
- t.Fatalf("update error = %v", err)
- }
- if err := engine.View(ctx, []meta.Namespace{"fault"}, func(reader meta.Reader) error {
- record, err := collection.Get(ctx, reader, meta.RecordID("record"))
- if err != nil {
- return err
- }
- if record.Value != 7 {
- return errors.New("failed overwrite changed existing record")
- }
- return nil
- }); err != nil {
- t.Fatal(err)
- }
- })
- }
-}
diff --git a/internal/meta/collection.go b/internal/meta/collection.go
deleted file mode 100644
index 2bd989b..0000000
--- a/internal/meta/collection.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package meta
-
-import (
- "context"
- "encoding/json"
- "fmt"
-)
-
-// Collection is the typed record boundary for one metadata table. The engine
-// stores encoded bytes, but callers read and write detached Go values.
-type Collection[R any] struct {
- namespace Namespace
- table Table
-}
-
-// NewCollection binds a collection to one metadata namespace and table.
-func NewCollection[R any](namespace Namespace, table Table) *Collection[R] {
- return &Collection[R]{namespace: namespace, table: table}
-}
-
-// Get returns a detached record or ErrNotFound.
-func (c *Collection[R]) Get(ctx context.Context, reader Reader, id RecordID) (*R, error) {
- raw, ok, err := reader.GetRaw(ctx, c.namespace, c.table, id)
- if err != nil {
- return nil, err
- }
- if !ok {
- return nil, fmt.Errorf("%s/%s %q: %w", c.namespace, c.table, id, ErrNotFound)
- }
- return c.decode(id, raw)
-}
-
-// Insert adds a record and fails if the id already exists.
-func (c *Collection[R]) Insert(ctx context.Context, writer Writer, id RecordID, record *R) error {
- if _, ok, err := writer.GetRaw(ctx, c.namespace, c.table, id); err != nil {
- return err
- } else if ok {
- return fmt.Errorf("%s/%s %q exists: %w", c.namespace, c.table, id, ErrConflict)
- }
- return c.put(ctx, writer, id, record)
-}
-
-// Replace overwrites an existing record and fails if it is absent.
-func (c *Collection[R]) Replace(ctx context.Context, writer Writer, id RecordID, record *R) error {
- if _, ok, err := writer.GetRaw(ctx, c.namespace, c.table, id); err != nil {
- return err
- } else if !ok {
- return fmt.Errorf("%s/%s %q: %w", c.namespace, c.table, id, ErrNotFound)
- }
- return c.put(ctx, writer, id, record)
-}
-
-// Upsert inserts or replaces a record.
-func (c *Collection[R]) Upsert(ctx context.Context, writer Writer, id RecordID, record *R) error {
- return c.put(ctx, writer, id, record)
-}
-
-// Delete removes a record. Deleting an absent record is idempotent.
-func (c *Collection[R]) Delete(ctx context.Context, writer Writer, id RecordID) error {
- return writer.DeleteRaw(ctx, c.namespace, c.table, id)
-}
-
-// Scan yields detached records in the engine's stable order.
-func (c *Collection[R]) Scan(ctx context.Context, reader Reader, fn func(RecordID, *R) error) error {
- if fn == nil {
- return fmt.Errorf("metadata collection scan callback must not be nil: %w", ErrScope)
- }
- return reader.ScanRaw(ctx, c.namespace, c.table, func(id RecordID, raw json.RawMessage) error {
- record, err := c.decode(id, raw)
- if err != nil {
- return err
- }
- return fn(id, record)
- })
-}
-
-// List returns all records detached from the engine state.
-func (c *Collection[R]) List(ctx context.Context, reader Reader) (map[RecordID]*R, error) {
- result := make(map[RecordID]*R)
- if err := c.Scan(ctx, reader, func(id RecordID, record *R) error {
- result[id] = record
- return nil
- }); err != nil {
- return nil, err
- }
- return result, nil
-}
-
-func (c *Collection[R]) put(ctx context.Context, writer Writer, id RecordID, record *R) error {
- if record == nil {
- return fmt.Errorf("%s/%s %q: nil record: %w", c.namespace, c.table, id, ErrIO)
- }
- raw, err := json.Marshal(record)
- if err != nil {
- return fmt.Errorf("encode %s/%s %q: %w", c.namespace, c.table, id, err)
- }
- return writer.PutRaw(ctx, c.namespace, c.table, id, raw)
-}
-
-func (c *Collection[R]) decode(id RecordID, raw json.RawMessage) (*R, error) {
- record := new(R)
- if err := json.Unmarshal(raw, record); err != nil {
- return nil, fmt.Errorf("decode %s/%s %q: %w", c.namespace, c.table, id, err)
- }
- return record, nil
-}
diff --git a/internal/meta/engine.go b/internal/meta/engine.go
deleted file mode 100644
index ab043d9..0000000
--- a/internal/meta/engine.go
+++ /dev/null
@@ -1,71 +0,0 @@
-// Package meta defines the persistence boundary shared by metadata engines.
-// It deliberately knows nothing about JSON files, SQLite tables, or host
-// resources.
-package meta
-
-import (
- "context"
- "encoding/json"
- "errors"
-)
-
-var (
- ErrNotFound = errors.New("metadata record not found")
- ErrConflict = errors.New("metadata record conflict")
- ErrBusy = errors.New("metadata store busy")
- ErrCorrupt = errors.New("metadata store corrupt")
- ErrNoSpace = errors.New("metadata store has no space")
- ErrIO = errors.New("metadata store I/O error")
- ErrScope = errors.New("metadata scope violation")
- ErrClosed = errors.New("metadata store is closed")
- ErrDurabilityContract = errors.New("durability contract violation")
-)
-
-// CommitMode controls the durability required from a successful update.
-type CommitMode uint8
-
-const (
- CommitDurable CommitMode = iota
- CommitRelaxed
-)
-
-// Scope declares the metadata namespaces an update may access. Write is the
-// only namespace the transaction may modify; Read declares the other
-// namespaces it may inspect. Engines acquire declared namespaces in a stable
-// order so multi-namespace operations cannot deadlock.
-type Scope struct {
- Write Namespace
- Read []Namespace
-}
-
-// Namespace identifies one independently locked metadata document.
-type Namespace string
-
-// Table identifies a logical collection inside a namespace.
-type Table string
-
-// RecordID identifies one record inside a table.
-type RecordID string
-
-// MetaEngine is the engine-neutral metadata transaction boundary.
-type MetaEngine interface {
- View(ctx context.Context, namespaces []Namespace, fn func(Reader) error) error
- Update(context.Context, Scope, CommitMode, func(Writer) error) error
- Events(context.Context) (<-chan struct{}, func(), error)
- Close() error
-}
-
-// Reader is the low-level storage SPI used by Collection. Resource code should
-// normally use a typed Collection instead of handling encoded values directly.
-type Reader interface {
- GetRaw(ctx context.Context, namespace Namespace, table Table, id RecordID) (json.RawMessage, bool, error)
- ScanRaw(ctx context.Context, namespace Namespace, table Table, fn func(RecordID, json.RawMessage) error) error
-}
-
-// Writer is the low-level write SPI. All mutations are discarded when the
-// callback returns an error. Collection is the typed boundary above it.
-type Writer interface {
- Reader
- PutRaw(ctx context.Context, namespace Namespace, table Table, id RecordID, raw json.RawMessage) error
- DeleteRaw(ctx context.Context, namespace Namespace, table Table, id RecordID) error
-}
diff --git a/internal/meta/json/codec.go b/internal/meta/json/codec.go
deleted file mode 100644
index 83e0798..0000000
--- a/internal/meta/json/codec.go
+++ /dev/null
@@ -1,122 +0,0 @@
-// Package json implements the metadata engine backed by one JSON document per
-// metadata namespace.
-package json
-
-import (
- stdjson "encoding/json"
- "fmt"
- "sort"
-)
-
-// Codec translates one namespace's existing JSON shape to and from tables.
-// Domain packages own codecs so legacy index formats remain compatible while
-// the engine owns locking and transaction semantics.
-type Codec interface {
- Decode([]byte) (*Model, error)
- Encode(*Model) ([]byte, error)
-}
-
-// Model is the engine-neutral in-memory representation of one namespace.
-type Model struct {
- Tables map[string]map[string]stdjson.RawMessage
-}
-
-func NewModel() *Model {
- return &Model{Tables: map[string]map[string]stdjson.RawMessage{}}
-}
-
-func (m *Model) table(name string) map[string]stdjson.RawMessage {
- if m.Tables == nil {
- m.Tables = map[string]map[string]stdjson.RawMessage{}
- }
- if m.Tables[name] == nil {
- m.Tables[name] = map[string]stdjson.RawMessage{}
- }
- return m.Tables[name]
-}
-
-// TableCodec handles a document whose top-level fields are table objects. It
-// is useful for indexes shaped like {"records":{"id":{...}}} and keeps the
-// adapter independent from any particular resource type.
-type TableCodec struct {
- Specs []TableSpec
-}
-
-// TableSpec maps one top-level JSON field to a metadata table.
-type TableSpec struct {
- Key string
- Table string
-}
-
-func (c TableCodec) Decode(raw []byte) (*Model, error) {
- model := NewModel()
- if len(raw) == 0 {
- return model, nil
- }
- var document map[string]stdjson.RawMessage
- if err := stdjson.Unmarshal(raw, &document); err != nil {
- return nil, fmt.Errorf("decode metadata JSON: %w", err)
- }
- for _, spec := range c.Specs {
- if spec.Key == "" || spec.Table == "" {
- return nil, fmt.Errorf("metadata table spec is incomplete")
- }
- value, ok := document[spec.Key]
- if !ok {
- continue
- }
- var records map[string]stdjson.RawMessage
- if err := stdjson.Unmarshal(value, &records); err != nil {
- return nil, fmt.Errorf("decode metadata table %q: %w", spec.Key, err)
- }
- for id, record := range records {
- if id == "" || !stdjson.Valid(record) {
- return nil, fmt.Errorf("metadata table %q contains invalid record", spec.Key)
- }
- model.table(spec.Table)[id] = cloneRaw(record)
- }
- }
- return model, nil
-}
-
-func (c TableCodec) Encode(model *Model) ([]byte, error) {
- if model == nil {
- return nil, fmt.Errorf("metadata model must not be nil")
- }
- document := make(map[string]map[string]stdjson.RawMessage, len(c.Specs))
- for _, spec := range c.Specs {
- if spec.Key == "" || spec.Table == "" {
- return nil, fmt.Errorf("metadata table spec is incomplete")
- }
- records := model.Tables[spec.Table]
- if records == nil {
- records = map[string]stdjson.RawMessage{}
- }
- copied := make(map[string]stdjson.RawMessage, len(records))
- for id, record := range records {
- if id == "" || !stdjson.Valid(record) {
- return nil, fmt.Errorf("metadata table %q contains invalid record", spec.Table)
- }
- copied[id] = cloneRaw(record)
- }
- document[spec.Key] = copied
- }
- return stdjson.MarshalIndent(document, "", " ")
-}
-
-func cloneRaw(raw stdjson.RawMessage) stdjson.RawMessage {
- if raw == nil {
- return nil
- }
- return append(stdjson.RawMessage(nil), raw...)
-}
-
-// TableNames returns stable table names for diagnostics and tests.
-func (m *Model) TableNames() []string {
- names := make([]string, 0, len(m.Tables))
- for name := range m.Tables {
- names = append(names, name)
- }
- sort.Strings(names)
- return names
-}
diff --git a/internal/meta/json/store.go b/internal/meta/json/store.go
deleted file mode 100644
index 9811dbe..0000000
--- a/internal/meta/json/store.go
+++ /dev/null
@@ -1,553 +0,0 @@
-package json
-
-import (
- "context"
- "crypto/sha256"
- stdjson "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "sync"
- "time"
-
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/lock"
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-const previousSuffix = ".prev"
-
-// Namespace describes one logical metadata namespace and its legacy JSON
-// representation.
-type Namespace struct {
- Name string
- FilePath string
- LockPath string
- Codec Codec
-}
-
-// Store is the JSON MetaEngine. It owns no domain records; codecs and callers
-// define their table meaning.
-type Store struct {
- namespaces map[string]Namespace
- mu sync.Mutex
- subs map[*subscription]struct{}
- closed bool
-}
-
-type subscription struct {
- changes chan struct{}
- cancel context.CancelFunc
- done chan struct{}
- stop sync.Once
-}
-
-func (s *subscription) close() {
- s.stop.Do(func() {
- s.cancel()
- <-s.done
- close(s.changes)
- })
-}
-
-var _ meta.MetaEngine = (*Store)(nil)
-
-// Open validates namespace definitions without creating files.
-func Open(definitions ...Namespace) (*Store, error) {
- if len(definitions) == 0 {
- return nil, fmt.Errorf("JSON metadata engine requires a namespace")
- }
- namespaces := make(map[string]Namespace, len(definitions))
- for _, definition := range definitions {
- if definition.Name == "" || definition.FilePath == "" || definition.LockPath == "" || definition.Codec == nil {
- return nil, fmt.Errorf("metadata namespace %q has incomplete definition: %w", definition.Name, meta.ErrScope)
- }
- if _, exists := namespaces[definition.Name]; exists {
- return nil, fmt.Errorf("metadata namespace %q declared twice: %w", definition.Name, meta.ErrScope)
- }
- namespaces[definition.Name] = definition
- }
- return &Store{namespaces: namespaces, subs: make(map[*subscription]struct{})}, nil
-}
-
-func (s *Store) View(ctx context.Context, requested []meta.Namespace, fn func(meta.Reader) error) error {
- if fn == nil {
- return fmt.Errorf("metadata view callback must not be nil: %w", meta.ErrScope)
- }
- definitions, err := s.resolve(requested, "")
- if err != nil {
- return err
- }
- locks, err := s.acquire(ctx, definitions)
- if err != nil {
- return err
- }
- defer releaseLocks(locks)
-
- models, err := s.load(ctx, definitions)
- if err != nil {
- return err
- }
- return fn(&reader{models: models, allowed: names(definitions)})
-}
-
-func (s *Store) Update(ctx context.Context, scope meta.Scope, mode meta.CommitMode, fn func(meta.Writer) error) error {
- if fn == nil {
- return fmt.Errorf("metadata update callback must not be nil: %w", meta.ErrScope)
- }
- definitions, err := s.resolve(append([]meta.Namespace{scope.Write}, scope.Read...), scope.Write)
- if err != nil {
- return err
- }
- locks, err := s.acquire(ctx, definitions)
- if err != nil {
- return err
- }
- defer releaseLocks(locks)
-
- models, err := s.load(ctx, definitions)
- if err != nil {
- return err
- }
- writer := &writer{
- reader: reader{models: models, allowed: names(definitions)},
- writeNamespace: scope.Write,
- dirty: false,
- }
- if err := fn(writer); err != nil {
- return err
- }
- if err := ctx.Err(); err != nil {
- return err
- }
- if writer.dirty {
- if err := s.commit(ctx, definitions, models, scope.Write, mode); err != nil {
- return err
- }
- s.notify()
- }
- return nil
-}
-
-func (s *Store) Events(ctx context.Context) (<-chan struct{}, func(), error) {
- if err := ctx.Err(); err != nil {
- return nil, nil, err
- }
- fingerprint, err := s.fingerprint()
- if err != nil {
- return nil, nil, err
- }
- watchCtx, cancel := context.WithCancel(ctx)
- sub := &subscription{changes: make(chan struct{}, 1), cancel: cancel, done: make(chan struct{})}
- s.mu.Lock()
- if s.closed {
- s.mu.Unlock()
- cancel()
- return nil, nil, meta.ErrClosed
- }
- s.subs[sub] = struct{}{}
- s.mu.Unlock()
- go s.watchFiles(watchCtx, sub, fingerprint)
-
- var once sync.Once
- release := func() {
- once.Do(func() {
- s.mu.Lock()
- delete(s.subs, sub)
- s.mu.Unlock()
- sub.close()
- })
- }
- return sub.changes, release, nil
-}
-
-func (s *Store) Close() error {
- s.mu.Lock()
- if s.closed {
- s.mu.Unlock()
- return nil
- }
- s.closed = true
- subs := make([]*subscription, 0, len(s.subs))
- for sub := range s.subs {
- subs = append(subs, sub)
- delete(s.subs, sub)
- }
- s.mu.Unlock()
- for _, sub := range subs {
- sub.close()
- }
- return nil
-}
-
-func (s *Store) notify() {
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.closed {
- return
- }
- for sub := range s.subs {
- select {
- case sub.changes <- struct{}{}:
- default:
- }
- }
-}
-
-func (s *Store) watchFiles(ctx context.Context, sub *subscription, previous [32]byte) {
- defer close(sub.done)
- ticker := time.NewTicker(200 * time.Millisecond)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- current, err := s.fingerprint()
- if err != nil || current == previous {
- continue
- }
- previous = current
- s.mu.Lock()
- if _, ok := s.subs[sub]; ok && !s.closed {
- select {
- case sub.changes <- struct{}{}:
- default:
- }
- }
- s.mu.Unlock()
- }
- }
-}
-
-func (s *Store) fingerprint() ([32]byte, error) {
- hash := sha256.New()
- names := make([]string, 0, len(s.namespaces))
- for name := range s.namespaces {
- names = append(names, name)
- }
- sort.Strings(names)
- for _, name := range names {
- path := s.namespaces[name].FilePath
- raw, err := os.ReadFile(path)
- if errors.Is(err, os.ErrNotExist) {
- _, _ = fmt.Fprintf(hash, "%s:missing\n", path)
- continue
- }
- if err != nil {
- return [32]byte{}, fmt.Errorf("fingerprint metadata %s: %w", path, err)
- }
- _, _ = fmt.Fprintf(hash, "%s:%d:", path, len(raw))
- _, _ = hash.Write(raw)
- }
- var fingerprint [32]byte
- copy(fingerprint[:], hash.Sum(nil))
- return fingerprint, nil
-}
-
-func (s *Store) resolve(requested []meta.Namespace, write meta.Namespace) ([]Namespace, error) {
- seen := make(map[string]struct{}, len(requested))
- for _, name := range requested {
- if name == "" {
- return nil, fmt.Errorf("metadata namespace must not be empty: %w", meta.ErrScope)
- }
- if _, ok := s.namespaces[string(name)]; !ok {
- return nil, fmt.Errorf("metadata namespace %q is not declared: %w", name, meta.ErrScope)
- }
- seen[string(name)] = struct{}{}
- }
- if write != "" {
- if _, ok := seen[string(write)]; !ok {
- return nil, fmt.Errorf("write namespace %q is outside scope: %w", write, meta.ErrScope)
- }
- }
- definitions := make([]Namespace, 0, len(seen))
- for name := range seen {
- definitions = append(definitions, s.namespaces[string(name)])
- }
- sort.Slice(definitions, func(i, j int) bool { return definitions[i].Name < definitions[j].Name })
- return definitions, nil
-}
-
-func (s *Store) acquire(ctx context.Context, definitions []Namespace) ([]*lock.Lock, error) {
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- s.mu.Lock()
- closed := s.closed
- s.mu.Unlock()
- if closed {
- return nil, meta.ErrClosed
- }
- locks := make([]*lock.Lock, 0, len(definitions))
- for _, definition := range definitions {
- fileLock, err := lock.NewLocker(filepath.Dir(definition.LockPath)).Acquire(ctx, lockKey(definition.LockPath))
- if err != nil {
- releaseLocks(locks)
- return nil, fmt.Errorf("lock metadata namespace %s: %w", definition.Name, err)
- }
- locks = append(locks, fileLock)
- }
- return locks, nil
-}
-
-func (s *Store) load(ctx context.Context, definitions []Namespace) (map[string]*loaded, error) {
- models := make(map[string]*loaded, len(definitions))
- for _, definition := range definitions {
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- loaded, err := loadNamespace(definition)
- if err != nil {
- return nil, fmt.Errorf("load metadata namespace %s: %w", definition.Name, err)
- }
- models[definition.Name] = loaded
- }
- return models, nil
-}
-
-func (s *Store) commit(ctx context.Context, definitions []Namespace, models map[string]*loaded, write meta.Namespace, _ meta.CommitMode) error {
- if err := ctx.Err(); err != nil {
- return err
- }
- for _, definition := range definitions {
- if definition.Name != string(write) {
- continue
- }
- current := models[string(write)]
- raw, err := definition.Codec.Encode(current.model)
- if err != nil {
- return fmt.Errorf("encode metadata namespace %s: %w", write, err)
- }
- if err := writeAtomic(ctx, definition.FilePath, raw, current.raw); err != nil {
- return fmt.Errorf("commit metadata namespace %s: %w", write, err)
- }
- return nil
- }
- return fmt.Errorf("write namespace %q was not resolved: %w", write, meta.ErrScope)
-}
-
-type loaded struct {
- model *Model
- raw []byte
- recovered bool
-}
-
-func loadNamespace(definition Namespace) (*loaded, error) {
- raw, err := os.ReadFile(definition.FilePath)
- if errors.Is(err, os.ErrNotExist) {
- model, decodeErr := definition.Codec.Decode(nil)
- if decodeErr != nil {
- return nil, fmt.Errorf("initialize empty metadata namespace: %w", decodeErr)
- }
- return &loaded{model: model}, nil
- }
- if err != nil {
- return nil, fmt.Errorf("read metadata file: %w", err)
- }
- model, decodeErr := definition.Codec.Decode(raw)
- if decodeErr == nil {
- return &loaded{model: model, raw: append([]byte(nil), raw...)}, nil
- }
- previous, previousErr := os.ReadFile(definition.FilePath + previousSuffix)
- if previousErr == nil {
- previousModel, previousDecodeErr := definition.Codec.Decode(previous)
- if previousDecodeErr == nil {
- return &loaded{model: previousModel, raw: append([]byte(nil), previous...), recovered: true}, nil
- }
- }
- return nil, fmt.Errorf("decode metadata file: %w: %v", meta.ErrCorrupt, decodeErr)
-}
-
-type reader struct {
- models map[string]*loaded
- allowed map[string]struct{}
-}
-
-func (r reader) GetRaw(ctx context.Context, namespace meta.Namespace, table meta.Table, id meta.RecordID) (stdjson.RawMessage, bool, error) {
- if err := contextErr(ctx); err != nil {
- return nil, false, err
- }
- if err := r.checkRead(namespace); err != nil {
- return nil, false, err
- }
- model := r.models[string(namespace)].model
- records := model.Tables[string(table)]
- if records == nil {
- return nil, false, nil
- }
- raw, ok := records[string(id)]
- return cloneRaw(raw), ok, nil
-}
-
-func (r reader) ScanRaw(ctx context.Context, namespace meta.Namespace, table meta.Table, fn func(meta.RecordID, stdjson.RawMessage) error) error {
- if fn == nil {
- return fmt.Errorf("metadata scan callback must not be nil: %w", meta.ErrScope)
- }
- if err := r.checkRead(namespace); err != nil {
- return err
- }
- records := r.models[string(namespace)].model.Tables[string(table)]
- ids := make([]string, 0, len(records))
- for id := range records {
- ids = append(ids, id)
- }
- sort.Strings(ids)
- for _, id := range ids {
- if err := contextErr(ctx); err != nil {
- return err
- }
- if err := fn(meta.RecordID(id), cloneRaw(records[id])); err != nil {
- return err
- }
- }
- return nil
-}
-
-func (r reader) checkRead(namespace meta.Namespace) error {
- if _, ok := r.allowed[string(namespace)]; !ok {
- return fmt.Errorf("cannot read metadata namespace %q outside transaction scope: %w", namespace, meta.ErrScope)
- }
- return nil
-}
-
-type writer struct {
- reader
- writeNamespace meta.Namespace
- dirty bool
-}
-
-func (w *writer) PutRaw(ctx context.Context, namespace meta.Namespace, table meta.Table, id meta.RecordID, raw stdjson.RawMessage) error {
- if err := w.checkWrite(ctx, namespace, table, id); err != nil {
- return err
- }
- if raw == nil || !stdjson.Valid(raw) {
- return fmt.Errorf("metadata record %s/%s is invalid JSON: %w", table, id, meta.ErrIO)
- }
- model := w.models[string(namespace)].model
- if model.Tables == nil {
- model.Tables = map[string]map[string]stdjson.RawMessage{}
- }
- if model.Tables[string(table)] == nil {
- model.Tables[string(table)] = map[string]stdjson.RawMessage{}
- }
- model.Tables[string(table)][string(id)] = cloneRaw(raw)
- w.dirty = true
- return nil
-}
-
-func (w *writer) DeleteRaw(ctx context.Context, namespace meta.Namespace, table meta.Table, id meta.RecordID) error {
- if err := w.checkWrite(ctx, namespace, table, id); err != nil {
- return err
- }
- delete(w.models[string(namespace)].model.Tables[string(table)], string(id))
- w.dirty = true
- return nil
-}
-
-func (w *writer) checkWrite(ctx context.Context, namespace meta.Namespace, table meta.Table, id meta.RecordID) error {
- if err := contextErr(ctx); err != nil {
- return err
- }
- if namespace != w.writeNamespace {
- return fmt.Errorf("cannot write metadata namespace %q from %q transaction: %w", namespace, w.writeNamespace, meta.ErrScope)
- }
- if table == "" || id == "" {
- return fmt.Errorf("metadata table and id must not be empty: %w", meta.ErrScope)
- }
- return nil
-}
-
-func writeAtomic(ctx context.Context, path string, raw, previous []byte) error {
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
- return fmt.Errorf("create metadata directory: %w", err)
- }
- if len(previous) > 0 {
- if err := writeFileSync(ctx, path+previousSuffix, previous, ".prev-*.tmp", false); err != nil {
- return fmt.Errorf("preserve previous metadata generation: %w", err)
- }
- }
- if err := writeFileSync(ctx, path, raw, ".meta-*.tmp", true); err != nil {
- return err
- }
- return syncDirectory(filepath.Dir(path))
-}
-
-func writeFileSync(ctx context.Context, path string, raw []byte, pattern string, inject bool) error {
- tmp, err := os.CreateTemp(filepath.Dir(path), pattern)
- if err != nil {
- return fmt.Errorf("create metadata temporary file: %w", err)
- }
- tmpPath := tmp.Name()
- defer os.Remove(tmpPath) //nolint:errcheck
- if _, err := tmp.Write(raw); err != nil {
- _ = tmp.Close()
- return fmt.Errorf("write metadata temporary file: %w", err)
- }
- if err := tmp.Sync(); err != nil {
- _ = tmp.Close()
- return fmt.Errorf("sync metadata temporary file: %w", err)
- }
- if err := tmp.Close(); err != nil {
- return fmt.Errorf("close metadata temporary file: %w", err)
- }
- if inject {
- if err := fault.Check(ctx, fault.MetadataJSONBeforeRename); err != nil {
- return err
- }
- }
- if err := os.Rename(tmpPath, path); err != nil {
- return fmt.Errorf("publish metadata file: %w", err)
- }
- if inject {
- if err := fault.Check(ctx, fault.MetadataJSONAfterRename); err != nil {
- return err
- }
- }
- return nil
-}
-
-func syncDirectory(path string) (err error) {
- dir, err := os.Open(path)
- if err != nil {
- return fmt.Errorf("open metadata directory: %w", err)
- }
- defer func() {
- if closeErr := dir.Close(); err == nil && closeErr != nil {
- err = fmt.Errorf("close metadata directory: %w", closeErr)
- }
- }()
- if err := dir.Sync(); err != nil && !errors.Is(err, os.ErrInvalid) {
- return fmt.Errorf("sync metadata directory: %w", err)
- }
- return nil
-}
-
-func releaseLocks(locks []*lock.Lock) {
- for i := len(locks) - 1; i >= 0; i-- {
- _ = locks[i].Release()
- }
-}
-
-func lockKey(path string) string {
- key := filepath.Base(path)
- return strings.TrimSuffix(key, filepath.Ext(key))
-}
-
-func names(definitions []Namespace) map[string]struct{} {
- allowed := make(map[string]struct{}, len(definitions))
- for _, definition := range definitions {
- allowed[definition.Name] = struct{}{}
- }
- return allowed
-}
-
-func contextErr(ctx context.Context) error {
- if ctx == nil {
- return nil
- }
- return ctx.Err()
-}
diff --git a/internal/meta/json/store_test.go b/internal/meta/json/store_test.go
deleted file mode 100644
index f335007..0000000
--- a/internal/meta/json/store_test.go
+++ /dev/null
@@ -1,303 +0,0 @@
-package json
-
-import (
- "bytes"
- "context"
- stdjson "encoding/json"
- "errors"
- "os"
- "path/filepath"
- "sync"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-func TestStoreCommitsAndRollsBack(t *testing.T) {
- store, dir := newTestStore(t)
- ctx := context.Background()
-
- if err := store.Update(ctx, meta.Scope{Write: "vm"}, meta.CommitDurable, func(w meta.Writer) error {
- return w.PutRaw(ctx, "vm", "records", "vm-1", stdjson.RawMessage(`{"name":"one"}`))
- }); err != nil {
- t.Fatalf("initial update: %v", err)
- }
-
- wantErr := errors.New("abort")
- if err := store.Update(ctx, meta.Scope{Write: "vm"}, meta.CommitDurable, func(w meta.Writer) error {
- if err := w.PutRaw(ctx, "vm", "records", "vm-2", stdjson.RawMessage(`{"name":"two"}`)); err != nil {
- return err
- }
- return wantErr
- }); !errors.Is(err, wantErr) {
- t.Fatalf("rollback error = %v, want %v", err, wantErr)
- }
-
- if err := store.View(ctx, []meta.Namespace{"vm"}, func(r meta.Reader) error {
- if _, ok, err := r.GetRaw(ctx, "vm", "records", "vm-2"); err != nil {
- return err
- } else if ok {
- t.Fatal("rolled-back record is visible")
- }
- return nil
- }); err != nil {
- t.Fatalf("view after rollback: %v", err)
- }
- if _, err := os.Stat(filepath.Join(dir, "vm.json")); err != nil {
- t.Fatalf("committed metadata file missing: %v", err)
- }
-}
-
-func TestStorePreservesPreviousGenerationAndRecovers(t *testing.T) {
- store, dir := newTestStore(t)
- ctx := context.Background()
- put := func(name string) error {
- return store.Update(ctx, meta.Scope{Write: "vm"}, meta.CommitDurable, func(w meta.Writer) error {
- return w.PutRaw(ctx, "vm", "records", "vm-1", stdjson.RawMessage(`{"name":"`+name+`"}`))
- })
- }
- if err := put("one"); err != nil {
- t.Fatalf("first update: %v", err)
- }
- if err := put("two"); err != nil {
- t.Fatalf("second update: %v", err)
- }
-
- path := filepath.Join(dir, "vm.json")
- if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
- t.Fatalf("corrupt main generation: %v", err)
- }
- if err := store.View(ctx, []meta.Namespace{"vm"}, func(r meta.Reader) error {
- raw, ok, err := r.GetRaw(ctx, "vm", "records", "vm-1")
- if err != nil {
- return err
- }
- if !ok || !sameJSON(raw, []byte(`{"name":"one"}`)) {
- t.Fatalf("recovered record = %s, present=%v", raw, ok)
- }
- return nil
- }); err != nil {
- t.Fatalf("view from previous generation: %v", err)
- }
- if err := put("three"); err != nil {
- t.Fatalf("repair update: %v", err)
- }
- if err := store.View(ctx, []meta.Namespace{"vm"}, func(r meta.Reader) error {
- raw, _, err := r.GetRaw(ctx, "vm", "records", "vm-1")
- if err != nil {
- return err
- }
- if !sameJSON(raw, []byte(`{"name":"three"}`)) {
- t.Fatalf("repaired record = %s", raw)
- }
- return nil
- }); err != nil {
- t.Fatalf("view after repair: %v", err)
- }
-}
-
-func TestStoreAtomicCommitFailureLeavesCompleteGeneration(t *testing.T) {
- tests := []struct {
- name string
- point fault.Point
- want string
- }{
- {name: "before rename", point: fault.MetadataJSONBeforeRename, want: "before"},
- {name: "after rename", point: fault.MetadataJSONAfterRename, want: "after"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- store, _ := newTestStore(t)
- put := func(ctx context.Context, name string) error {
- return store.Update(ctx, meta.Scope{Write: "vm"}, meta.CommitDurable, func(w meta.Writer) error {
- return w.PutRaw(ctx, "vm", "records", "vm-1", stdjson.RawMessage(`{"name":"`+name+`"}`))
- })
- }
- if err := put(t.Context(), "before"); err != nil {
- t.Fatal(err)
- }
- injected := errors.New("injected atomic commit interruption")
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == tt.point {
- return injected
- }
- return nil
- }))
- if err := put(ctx, "after"); !errors.Is(err, injected) {
- t.Fatalf("Update() error = %v, want %v", err, injected)
- }
- if err := store.View(t.Context(), []meta.Namespace{"vm"}, func(r meta.Reader) error {
- raw, _, err := r.GetRaw(t.Context(), "vm", "records", "vm-1")
- if err == nil && !sameJSON(raw, []byte(`{"name":"`+tt.want+`"}`)) {
- t.Fatalf("record after interruption = %s, want %s", raw, tt.want)
- }
- return err
- }); err != nil {
- t.Fatal(err)
- }
- })
- }
-}
-
-func TestStoreEnforcesScopeAndDetachedValues(t *testing.T) {
- store, _ := newTestStore(t)
- ctx := context.Background()
- if err := store.Update(ctx, meta.Scope{Write: "vm", Read: []meta.Namespace{"network"}}, meta.CommitDurable, func(w meta.Writer) error {
- return w.PutRaw(ctx, "network", "leases", "ip-1", stdjson.RawMessage(`{}`))
- }); !errors.Is(err, meta.ErrScope) {
- t.Fatalf("write scope error = %v, want ErrScope", err)
- }
- if err := store.View(ctx, []meta.Namespace{"vm"}, func(r meta.Reader) error {
- _, _, err := r.GetRaw(ctx, "network", "leases", "ip-1")
- return err
- }); !errors.Is(err, meta.ErrScope) {
- t.Fatalf("read scope error = %v, want ErrScope", err)
- }
-
- if err := store.Update(ctx, meta.Scope{Write: "vm"}, meta.CommitDurable, func(w meta.Writer) error {
- return w.PutRaw(ctx, "vm", "records", "vm-1", stdjson.RawMessage(`{"n":1}`))
- }); err != nil {
- t.Fatalf("seed update: %v", err)
- }
- if err := store.View(ctx, []meta.Namespace{"vm"}, func(r meta.Reader) error {
- raw, _, err := r.GetRaw(ctx, "vm", "records", "vm-1")
- if err != nil {
- return err
- }
- raw[0] = 'X'
- return nil
- }); err != nil {
- t.Fatalf("detached read: %v", err)
- }
- if err := store.View(ctx, []meta.Namespace{"vm"}, func(r meta.Reader) error {
- raw, _, err := r.GetRaw(ctx, "vm", "records", "vm-1")
- if err != nil {
- return err
- }
- if !sameJSON(raw, []byte(`{"n":1}`)) {
- t.Fatalf("stored record mutated: %s", raw)
- }
- return nil
- }); err != nil {
- t.Fatalf("verify detached read: %v", err)
- }
-}
-
-func TestStoreEventsCoalesce(t *testing.T) {
- store, _ := newTestStore(t)
- ctx := context.Background()
- ch, release, err := store.Events(ctx)
- if err != nil {
- t.Fatalf("subscribe: %v", err)
- }
- defer release()
- for i := 0; i < 3; i++ {
- if err := store.Update(ctx, meta.Scope{Write: "vm"}, meta.CommitRelaxed, func(w meta.Writer) error {
- return w.PutRaw(ctx, "vm", "records", meta.RecordID(string(rune('a'+i))), stdjson.RawMessage(`{}`))
- }); err != nil {
- t.Fatalf("update %d: %v", i, err)
- }
- }
- select {
- case <-ch:
- default:
- t.Fatal("expected metadata event")
- }
- select {
- case <-ch:
- t.Fatal("event channel should coalesce notifications")
- default:
- }
-}
-
-func TestStoreEventsObserveAnotherStoreProcess(t *testing.T) {
- _, dir := newTestStore(t)
- open := func() *Store {
- store, err := Open(Namespace{
- Name: "vm", FilePath: filepath.Join(dir, "vm.json"), LockPath: filepath.Join(dir, "vm.lock"),
- Codec: TableCodec{Specs: []TableSpec{{Key: "records", Table: "records"}}},
- })
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = store.Close() })
- return store
- }
- reader, writer := open(), open()
- changes, release, err := reader.Events(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- defer release()
- if err := writer.Update(t.Context(), meta.Scope{Write: "vm"}, meta.CommitDurable, func(w meta.Writer) error {
- return w.PutRaw(t.Context(), "vm", "records", "external", []byte(`{}`))
- }); err != nil {
- t.Fatal(err)
- }
- select {
- case <-changes:
- case <-time.After(2 * time.Second):
- t.Fatal("subscriber did not observe external JSON store commit")
- }
-}
-
-func TestStoreEventsReleaseMayRaceClose(t *testing.T) {
- store, _ := newTestStore(t)
- _, release, err := store.Events(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- var wait sync.WaitGroup
- wait.Add(2)
- go func() { defer wait.Done(); release() }()
- go func() { defer wait.Done(); _ = store.Close() }()
- wait.Wait()
-}
-
-func TestStoreRejectsCorruptMetadataWithoutPreviousGeneration(t *testing.T) {
- store, dir := newTestStore(t)
- path := filepath.Join(dir, "vm.json")
- if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
- t.Fatal(err)
- }
- if err := store.View(context.Background(), []meta.Namespace{"vm"}, func(meta.Reader) error { return nil }); !errors.Is(err, meta.ErrCorrupt) {
- t.Fatalf("corrupt error = %v, want ErrCorrupt", err)
- }
-}
-
-func newTestStore(t *testing.T) (*Store, string) {
- t.Helper()
- dir := t.TempDir()
- store, err := Open(
- Namespace{
- Name: "vm",
- FilePath: filepath.Join(dir, "vm.json"),
- LockPath: filepath.Join(dir, "vm.lock"),
- Codec: TableCodec{Specs: []TableSpec{{Key: "records", Table: "records"}}},
- },
- Namespace{
- Name: "network",
- FilePath: filepath.Join(dir, "network.json"),
- LockPath: filepath.Join(dir, "network.lock"),
- Codec: TableCodec{Specs: []TableSpec{{Key: "leases", Table: "leases"}}},
- },
- )
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = store.Close() })
- return store, dir
-}
-
-func sameJSON(left, right []byte) bool {
- var normalizedLeft, normalizedRight bytes.Buffer
- if err := stdjson.Compact(&normalizedLeft, left); err != nil {
- return false
- }
- if err := stdjson.Compact(&normalizedRight, right); err != nil {
- return false
- }
- return bytes.Equal(normalizedLeft.Bytes(), normalizedRight.Bytes())
-}
diff --git a/internal/meta/memory.go b/internal/meta/memory.go
deleted file mode 100644
index c00b8b6..0000000
--- a/internal/meta/memory.go
+++ /dev/null
@@ -1,301 +0,0 @@
-package meta
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "sort"
- "sync"
-)
-
-// MemoryEngine is a deterministic engine for contract tests and small
-// in-process uses. It is not a persistence backend.
-type MemoryEngine struct {
- mu sync.RWMutex
- data map[string]map[string]map[string]json.RawMessage
- subscribers map[chan struct{}]struct{}
- closed bool
-}
-
-// NewMemoryEngine creates an engine with an explicit metadata namespace set.
-func NewMemoryEngine(namespaces ...string) (*MemoryEngine, error) {
- if len(namespaces) == 0 {
- return nil, fmt.Errorf("memory engine requires at least one namespace: %w", ErrScope)
- }
- seen := make(map[string]struct{}, len(namespaces))
- data := make(map[string]map[string]map[string]json.RawMessage, len(namespaces))
- for _, namespace := range namespaces {
- if namespace == "" {
- return nil, fmt.Errorf("metadata namespace must not be empty: %w", ErrScope)
- }
- if _, ok := seen[namespace]; ok {
- return nil, fmt.Errorf("metadata namespace %q declared twice: %w", namespace, ErrScope)
- }
- seen[namespace] = struct{}{}
- data[namespace] = make(map[string]map[string]json.RawMessage)
- }
- return &MemoryEngine{
- data: data,
- subscribers: make(map[chan struct{}]struct{}),
- }, nil
-}
-
-func (e *MemoryEngine) View(ctx context.Context, namespaces []Namespace, fn func(Reader) error) error {
- if fn == nil {
- return fmt.Errorf("metadata view callback must not be nil: %w", ErrScope)
- }
- ordered, err := e.resolveScope(namespaces, "")
- if err != nil {
- return err
- }
- if err := contextErr(ctx); err != nil {
- return err
- }
-
- e.mu.RLock()
- defer e.mu.RUnlock()
- if e.closed {
- return ErrClosed
- }
- view := cloneNamespaces(e.data, ordered)
- return fn(memoryReader{data: view, allowed: namespaceSet(ordered)})
-}
-
-func (e *MemoryEngine) Update(ctx context.Context, scope Scope, _ CommitMode, fn func(Writer) error) error {
- if fn == nil {
- return fmt.Errorf("metadata update callback must not be nil: %w", ErrScope)
- }
- ordered, err := e.resolveScope(append([]Namespace{scope.Write}, scope.Read...), scope.Write)
- if err != nil {
- return err
- }
- if err := contextErr(ctx); err != nil {
- return err
- }
-
- e.mu.Lock()
- defer e.mu.Unlock()
- if e.closed {
- return ErrClosed
- }
- working := cloneNamespaces(e.data, ordered)
- writer := &memoryWriter{
- memoryReader: memoryReader{data: working, allowed: namespaceSet(ordered)},
- writeNamespace: scope.Write,
- }
- if err := fn(writer); err != nil {
- return err
- }
- if err := contextErr(ctx); err != nil {
- return err
- }
- for namespace, tables := range working {
- e.data[namespace] = tables
- }
- e.notifyLocked()
- return nil
-}
-
-func (e *MemoryEngine) Events(ctx context.Context) (<-chan struct{}, func(), error) {
- if err := contextErr(ctx); err != nil {
- return nil, nil, err
- }
- e.mu.Lock()
- defer e.mu.Unlock()
- if e.closed {
- return nil, nil, ErrClosed
- }
- ch := make(chan struct{}, 1)
- e.subscribers[ch] = struct{}{}
- var once sync.Once
- release := func() {
- once.Do(func() {
- e.mu.Lock()
- if _, ok := e.subscribers[ch]; ok {
- delete(e.subscribers, ch)
- close(ch)
- }
- e.mu.Unlock()
- })
- }
- return ch, release, nil
-}
-
-func (e *MemoryEngine) Close() error {
- e.mu.Lock()
- defer e.mu.Unlock()
- if e.closed {
- return nil
- }
- e.closed = true
- for ch := range e.subscribers {
- close(ch)
- delete(e.subscribers, ch)
- }
- return nil
-}
-
-func (e *MemoryEngine) resolveScope(namespaces []Namespace, write Namespace) ([]string, error) {
- seen := make(map[string]struct{}, len(namespaces))
- for _, namespace := range namespaces {
- if namespace == "" {
- return nil, fmt.Errorf("metadata namespace must not be empty: %w", ErrScope)
- }
- if _, ok := e.data[string(namespace)]; !ok {
- return nil, fmt.Errorf("metadata namespace %q is not declared: %w", namespace, ErrScope)
- }
- if _, ok := seen[string(namespace)]; ok {
- continue
- }
- seen[string(namespace)] = struct{}{}
- }
- if write != "" {
- if _, ok := seen[string(write)]; !ok {
- return nil, fmt.Errorf("write namespace %q is outside scope: %w", write, ErrScope)
- }
- }
- ordered := make([]string, 0, len(seen))
- for namespace := range seen {
- ordered = append(ordered, namespace)
- }
- sort.Strings(ordered)
- return ordered, nil
-}
-
-func (e *MemoryEngine) notifyLocked() {
- for ch := range e.subscribers {
- select {
- case ch <- struct{}{}:
- default:
- }
- }
-}
-
-type memoryReader struct {
- data map[string]map[string]map[string]json.RawMessage
- allowed map[string]struct{}
-}
-
-func (r memoryReader) GetRaw(ctx context.Context, namespace Namespace, table Table, id RecordID) (json.RawMessage, bool, error) {
- if err := contextErr(ctx); err != nil {
- return nil, false, err
- }
- if err := r.checkRead(namespace); err != nil {
- return nil, false, err
- }
- tableData, ok := r.data[string(namespace)][string(table)]
- if !ok {
- return nil, false, nil
- }
- raw, ok := tableData[string(id)]
- return cloneRaw(raw), ok, nil
-}
-
-func (r memoryReader) ScanRaw(ctx context.Context, namespace Namespace, table Table, fn func(RecordID, json.RawMessage) error) error {
- if fn == nil {
- return fmt.Errorf("metadata scan callback must not be nil: %w", ErrScope)
- }
- if err := r.checkRead(namespace); err != nil {
- return err
- }
- tableData := r.data[string(namespace)][string(table)]
- ids := make([]string, 0, len(tableData))
- for id := range tableData {
- ids = append(ids, id)
- }
- sort.Strings(ids)
- for _, id := range ids {
- if err := contextErr(ctx); err != nil {
- return err
- }
- if err := fn(RecordID(id), cloneRaw(tableData[id])); err != nil {
- return err
- }
- }
- return nil
-}
-
-func (r memoryReader) checkRead(namespace Namespace) error {
- if _, ok := r.allowed[string(namespace)]; !ok {
- return fmt.Errorf("cannot read metadata namespace %q outside transaction scope: %w", namespace, ErrScope)
- }
- return nil
-}
-
-type memoryWriter struct {
- memoryReader
- writeNamespace Namespace
-}
-
-func (w *memoryWriter) PutRaw(ctx context.Context, namespace Namespace, table Table, id RecordID, raw json.RawMessage) error {
- if err := w.checkWrite(ctx, namespace, table, id); err != nil {
- return err
- }
- if raw == nil {
- return fmt.Errorf("metadata value must not be nil: %w", ErrIO)
- }
- if w.data[string(namespace)][string(table)] == nil {
- w.data[string(namespace)][string(table)] = make(map[string]json.RawMessage)
- }
- w.data[string(namespace)][string(table)][string(id)] = cloneRaw(raw)
- return nil
-}
-
-func (w *memoryWriter) DeleteRaw(ctx context.Context, namespace Namespace, table Table, id RecordID) error {
- if err := w.checkWrite(ctx, namespace, table, id); err != nil {
- return err
- }
- delete(w.data[string(namespace)][string(table)], string(id))
- return nil
-}
-
-func (w *memoryWriter) checkWrite(ctx context.Context, namespace Namespace, table Table, id RecordID) error {
- if err := contextErr(ctx); err != nil {
- return err
- }
- if namespace != w.writeNamespace {
- return fmt.Errorf("cannot write metadata namespace %q from %q transaction: %w", namespace, w.writeNamespace, ErrScope)
- }
- if table == "" || id == "" {
- return fmt.Errorf("metadata table and id must not be empty: %w", ErrScope)
- }
- return nil
-}
-
-func cloneNamespaces(data map[string]map[string]map[string]json.RawMessage, namespaces []string) map[string]map[string]map[string]json.RawMessage {
- clone := make(map[string]map[string]map[string]json.RawMessage, len(namespaces))
- for _, namespace := range namespaces {
- tables := make(map[string]map[string]json.RawMessage)
- for table, records := range data[namespace] {
- copied := make(map[string]json.RawMessage, len(records))
- for id, raw := range records {
- copied[id] = cloneRaw(raw)
- }
- tables[table] = copied
- }
- clone[namespace] = tables
- }
- return clone
-}
-
-func namespaceSet(namespaces []string) map[string]struct{} {
- allowed := make(map[string]struct{}, len(namespaces))
- for _, namespace := range namespaces {
- allowed[namespace] = struct{}{}
- }
- return allowed
-}
-
-func cloneRaw(raw json.RawMessage) json.RawMessage {
- if raw == nil {
- return nil
- }
- return append(json.RawMessage(nil), raw...)
-}
-
-func contextErr(ctx context.Context) error {
- if ctx == nil {
- return nil
- }
- return ctx.Err()
-}
diff --git a/internal/meta/memory_test.go b/internal/meta/memory_test.go
deleted file mode 100644
index 8fc1ea2..0000000
--- a/internal/meta/memory_test.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package meta
-
-import (
- "context"
- "encoding/json"
- "errors"
- "testing"
-)
-
-func TestMemoryEngineCommitsAndRollsBack(t *testing.T) {
- engine := newTestEngine(t)
- ctx := context.Background()
-
- if err := engine.Update(ctx, Scope{Write: "vm"}, CommitDurable, func(w Writer) error {
- return w.PutRaw(ctx, "vm", "records", "vm-1", json.RawMessage(`{"name":"one"}`))
- }); err != nil {
- t.Fatalf("initial update: %v", err)
- }
-
- wantErr := errors.New("abort")
- err := engine.Update(ctx, Scope{Write: "vm"}, CommitDurable, func(w Writer) error {
- if err := w.PutRaw(ctx, "vm", "records", "vm-2", json.RawMessage(`{"name":"two"}`)); err != nil {
- return err
- }
- return wantErr
- })
- if !errors.Is(err, wantErr) {
- t.Fatalf("rollback error = %v, want %v", err, wantErr)
- }
-
- if err := engine.View(ctx, []Namespace{"vm"}, func(r Reader) error {
- _, ok, err := r.GetRaw(ctx, "vm", "records", "vm-2")
- if err != nil {
- return err
- }
- if ok {
- t.Fatal("rolled-back record is visible")
- }
- return nil
- }); err != nil {
- t.Fatalf("view after rollback: %v", err)
- }
-}
-
-func TestMemoryEngineEnforcesWriteScope(t *testing.T) {
- engine := newTestEngine(t)
- ctx := context.Background()
- err := engine.Update(ctx, Scope{Write: "vm", Read: []Namespace{"network"}}, CommitDurable, func(w Writer) error {
- return w.PutRaw(ctx, "network", "leases", "10.0.0.2", json.RawMessage(`{}`))
- })
- if !errors.Is(err, ErrScope) {
- t.Fatalf("scope error = %v, want ErrScope", err)
- }
-}
-
-func TestMemoryEngineEnforcesReadScope(t *testing.T) {
- engine := newTestEngine(t)
- ctx := context.Background()
- err := engine.View(ctx, []Namespace{"vm"}, func(r Reader) error {
- _, _, err := r.GetRaw(ctx, "network", "leases", "10.0.0.2")
- return err
- })
- if !errors.Is(err, ErrScope) {
- t.Fatalf("scope error = %v, want ErrScope", err)
- }
-}
-
-func TestMemoryEngineDetachedValuesAndStableScan(t *testing.T) {
- engine := newTestEngine(t)
- ctx := context.Background()
- if err := engine.Update(ctx, Scope{Write: "vm"}, CommitDurable, func(w Writer) error {
- if err := w.PutRaw(ctx, "vm", "records", "b", json.RawMessage(`{"n":2}`)); err != nil {
- return err
- }
- return w.PutRaw(ctx, "vm", "records", "a", json.RawMessage(`{"n":1}`))
- }); err != nil {
- t.Fatalf("seed update: %v", err)
- }
-
- if err := engine.View(ctx, []Namespace{"vm"}, func(r Reader) error {
- raw, ok, err := r.GetRaw(ctx, "vm", "records", "a")
- if err != nil || !ok {
- return errors.New("record a missing")
- }
- raw[0] = 'X'
- ids := make([]string, 0, 2)
- if err := r.ScanRaw(ctx, "vm", "records", func(id RecordID, _ json.RawMessage) error {
- ids = append(ids, string(id))
- return nil
- }); err != nil {
- return err
- }
- if len(ids) != 2 || ids[0] != "a" || ids[1] != "b" {
- return errors.New("scan order is not stable")
- }
- return nil
- }); err != nil {
- t.Fatalf("detached view: %v", err)
- }
-
- if err := engine.View(ctx, []Namespace{"vm"}, func(r Reader) error {
- raw, _, err := r.GetRaw(ctx, "vm", "records", "a")
- if err != nil {
- return err
- }
- if string(raw) != `{"n":1}` {
- t.Fatalf("stored value mutated through reader: %s", raw)
- }
- return nil
- }); err != nil {
- t.Fatalf("verify detached value: %v", err)
- }
-}
-
-func TestCollectionPersistsTypedDetachedRecords(t *testing.T) {
- type record struct {
- Name string `json:"name"`
- }
-
- engine := newTestEngine(t)
- collection := NewCollection[record]("vm", "records")
- ctx := context.Background()
- if err := engine.Update(ctx, Scope{Write: "vm"}, CommitDurable, func(writer Writer) error {
- return collection.Upsert(ctx, writer, "vm-1", &record{Name: "one"})
- }); err != nil {
- t.Fatalf("typed update: %v", err)
- }
-
- if err := engine.View(ctx, []Namespace{"vm"}, func(reader Reader) error {
- got, err := collection.Get(ctx, reader, "vm-1")
- if err != nil {
- return err
- }
- got.Name = "mutated outside transaction"
- return nil
- }); err != nil {
- t.Fatalf("typed view: %v", err)
- }
-
- if err := engine.View(ctx, []Namespace{"vm"}, func(reader Reader) error {
- got, err := collection.Get(ctx, reader, "vm-1")
- if err != nil {
- return err
- }
- if got.Name != "one" {
- t.Fatalf("typed record was not detached: %q", got.Name)
- }
- return nil
- }); err != nil {
- t.Fatalf("verify typed record: %v", err)
- }
-}
-
-func TestMemoryEngineEventsAreCoalesced(t *testing.T) {
- engine := newTestEngine(t)
- ctx := context.Background()
- ch, release, err := engine.Events(ctx)
- if err != nil {
- t.Fatalf("subscribe: %v", err)
- }
- defer release()
-
- for i := 0; i < 3; i++ {
- if err := engine.Update(ctx, Scope{Write: "vm"}, CommitRelaxed, func(w Writer) error {
- return w.PutRaw(ctx, "vm", "records", RecordID(string(rune('a'+i))), json.RawMessage(`{}`))
- }); err != nil {
- t.Fatalf("update %d: %v", i, err)
- }
- }
- select {
- case <-ch:
- default:
- t.Fatal("expected metadata event")
- }
- select {
- case <-ch:
- t.Fatal("event channel should coalesce pending notifications")
- default:
- }
-}
-
-func TestMemoryEngineContextCancellation(t *testing.T) {
- engine := newTestEngine(t)
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- if err := engine.View(ctx, []Namespace{"vm"}, func(Reader) error { return nil }); !errors.Is(err, context.Canceled) {
- t.Fatalf("view error = %v, want context.Canceled", err)
- }
-}
-
-func newTestEngine(t *testing.T) *MemoryEngine {
- t.Helper()
- engine, err := NewMemoryEngine("vm", "network")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = engine.Close() })
- return engine
-}
diff --git a/internal/meta/sqlite/convert.go b/internal/meta/sqlite/convert.go
deleted file mode 100644
index debf3af..0000000
--- a/internal/meta/sqlite/convert.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package sqlite
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-// Convert copies declared metadata from another engine into SQLite and marks
-// each namespace converted only after all records have been committed.
-// Keeping the source untouched makes retry and rollback operationally safe.
-func Convert(ctx context.Context, source meta.MetaEngine, destination *Store, sourceName string, tables []meta.TableSet) (meta.TransferReport, error) {
- if sourceName == "" {
- return meta.TransferReport{}, fmt.Errorf("metadata conversion source must not be empty: %w", meta.ErrScope)
- }
- if destination == nil {
- return meta.TransferReport{}, fmt.Errorf("metadata conversion destination must not be nil: %w", meta.ErrScope)
- }
- report, err := meta.TransferWithReport(ctx, source, destination, tables)
- if err != nil {
- return meta.TransferReport{}, err
- }
- if err := destination.markConverted(ctx, sourceName, report); err != nil {
- return meta.TransferReport{}, err
- }
- return report, nil
-}
-
-func (s *Store) markConverted(ctx context.Context, sourceName string, report meta.TransferReport) error {
- tx, err := s.durable.BeginTx(ctx, nil)
- if err != nil {
- return mapError(err)
- }
- for namespace, count := range report.Records {
- result, execErr := tx.ExecContext(ctx, "UPDATE "+metadataStateTable+" SET state='converted', records=?, source=?, digest=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE namespace=? AND schema_version=?", count, sourceName, report.Digest, namespace, databaseSchemaVersion)
- if execErr != nil {
- _ = tx.Rollback()
- return mapError(execErr)
- }
- updated, execErr := result.RowsAffected()
- if execErr != nil || updated != 1 {
- _ = tx.Rollback()
- if execErr != nil {
- return mapError(execErr)
- }
- return fmt.Errorf("metadata conversion namespace %q is not declared: %w", namespace, meta.ErrScope)
- }
- }
- if err := tx.Commit(); err != nil {
- return mapError(err)
- }
- s.notify()
- return nil
-}
-
-// MarkConverted records the verified source identity for one namespace.
-func (s *Store) MarkConverted(ctx context.Context, namespace meta.Namespace, sourceName, digest string, records int) error {
- report := meta.TransferReport{
- Records: map[meta.Namespace]int{namespace: records},
- Digest: digest,
- }
- return s.markConverted(ctx, sourceName, report)
-}
-
-// Checkpoint folds committed WAL pages into the main database before the file
-// is retired or moved.
-func Checkpoint(ctx context.Context, path string) (err error) {
- db, err := openDatabase(path, "FULL", true)
- if err != nil {
- return err
- }
- defer func() { err = errors.Join(err, db.Close()) }()
- _, err = db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)")
- return mapError(err)
-}
diff --git a/internal/meta/sqlite/init.go b/internal/meta/sqlite/init.go
deleted file mode 100644
index 938e85c..0000000
--- a/internal/meta/sqlite/init.go
+++ /dev/null
@@ -1,226 +0,0 @@
-package sqlite
-
-import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "os"
- "path/filepath"
-
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-// Init creates a new SQLite metadata database or adds newly declared
-// namespaces to an existing compatible KumaBox database. Existing namespaces
-// are validated and never rebuilt, so missing tables remain a corruption error
-// rather than being mistaken for an upgrade.
-func Init(ctx context.Context, path string, definitions ...Namespace) (err error) {
- if err := RefuseConversion(path); err != nil {
- return err
- }
- return initStore(ctx, path, definitions...)
-}
-
-// InitForRecovery creates a conversion target while its manifest is present.
-func InitForRecovery(ctx context.Context, path string, definitions ...Namespace) error {
- return initStore(ctx, path, definitions...)
-}
-
-func initStore(ctx context.Context, path string, definitions ...Namespace) (err error) {
- if path == "" || len(definitions) == 0 {
- return fmt.Errorf("sqlite metadata path and namespace definitions are required: %w", meta.ErrScope)
- }
- namespaces, err := validateDefinitions(definitions)
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
- return fmt.Errorf("create sqlite metadata directory: %w", err)
- }
- if _, statErr := os.Stat(path); statErr == nil {
- empty, inspectErr := isEmptyDatabase(path)
- if inspectErr != nil {
- return inspectErr
- }
- if !empty {
- return upgradeStore(ctx, path, namespaces)
- }
- if err := os.Remove(path); err != nil {
- return fmt.Errorf("remove incomplete sqlite metadata database: %w", err)
- }
- } else if !errors.Is(statErr, os.ErrNotExist) {
- return fmt.Errorf("stat sqlite metadata database: %w", statErr)
- }
- db, err := openDatabase(path, "FULL", true)
- if err != nil {
- return err
- }
- closed := false
- defer func() {
- if !closed {
- err = errors.Join(err, db.Close())
- }
- }()
- tx, err := db.BeginTx(ctx, nil)
- if err != nil {
- return mapError(err)
- }
- defer tx.Rollback() //nolint:errcheck
- if err := createSchema(ctx, tx, namespaces); err != nil {
- return err
- }
- if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id = %d", databaseApplicationID)); err != nil {
- return mapError(err)
- }
- if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", databaseSchemaVersion)); err != nil {
- return mapError(err)
- }
- if err := tx.Commit(); err != nil {
- return mapError(err)
- }
- if _, err := db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
- return mapError(err)
- }
- if err := db.Close(); err != nil {
- return fmt.Errorf("close initialized sqlite metadata database: %w", err)
- }
- closed = true
- return syncDatabase(path)
-}
-
-func upgradeStore(ctx context.Context, path string, namespaces map[meta.Namespace]map[meta.Table]struct{}) (err error) {
- db, err := openDatabase(path, "FULL", true)
- if err != nil {
- return err
- }
- defer func() { err = errors.Join(err, db.Close()) }()
-
- tx, err := db.BeginTx(ctx, nil)
- if err != nil {
- return mapError(err)
- }
- defer tx.Rollback() //nolint:errcheck
- if err := verifyDatabaseIdentity(ctx, tx); err != nil {
- return err
- }
-
- added := false
- for namespace, tables := range namespaces {
- exists, err := namespaceStateExists(ctx, tx, namespace)
- if err != nil {
- return err
- }
- if exists {
- if err := verifyNamespaceTables(ctx, tx, namespace, tables); err != nil {
- return err
- }
- continue
- }
- if err := createNamespace(ctx, tx, namespace, tables); err != nil {
- return err
- }
- added = true
- }
- if !added {
- return fmt.Errorf("sqlite metadata database %s already contains every declared namespace: %w", path, meta.ErrConflict)
- }
- if err := tx.Commit(); err != nil {
- return mapError(err)
- }
- if _, err := db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
- return mapError(err)
- }
- return syncDatabase(path)
-}
-
-func namespaceStateExists(ctx context.Context, tx *sql.Tx, namespace meta.Namespace) (bool, error) {
- var schemaVersion int
- err := tx.QueryRowContext(ctx, "SELECT schema_version FROM "+metadataStateTable+" WHERE namespace = ?", namespace).Scan(&schemaVersion)
- if errors.Is(err, sql.ErrNoRows) {
- return false, nil
- }
- if err != nil {
- return false, mapError(err)
- }
- if schemaVersion != databaseSchemaVersion {
- return false, fmt.Errorf("metadata namespace %q has unsupported schema version %d: %w", namespace, schemaVersion, meta.ErrCorrupt)
- }
- return true, nil
-}
-
-func verifyNamespaceTables(ctx context.Context, tx *sql.Tx, namespace meta.Namespace, tables map[meta.Table]struct{}) error {
- for table := range tables {
- var count int
- if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?", rawTableName(namespace, table)).Scan(&count); err != nil {
- return mapError(err)
- }
- if count != 1 {
- return fmt.Errorf("metadata namespace %q is missing table %q: %w", namespace, table, meta.ErrCorrupt)
- }
- }
- return nil
-}
-
-func createNamespace(ctx context.Context, tx *sql.Tx, namespace meta.Namespace, tables map[meta.Table]struct{}) error {
- for table := range tables {
- query := "CREATE TABLE " + tableName(namespace, table) + " (id TEXT PRIMARY KEY NOT NULL, data BLOB NOT NULL)"
- if _, err := tx.ExecContext(ctx, query); err != nil {
- return mapError(err)
- }
- }
- _, err := tx.ExecContext(ctx, "INSERT INTO "+metadataStateTable+" (namespace, state, schema_version, updated_at) VALUES (?, 'initialized', ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", namespace, databaseSchemaVersion)
- return mapError(err)
-}
-
-func isEmptyDatabase(path string) (empty bool, err error) {
- info, err := os.Stat(path)
- if err != nil {
- return false, err
- }
- if info.Size() == 0 {
- return true, nil
- }
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
- if err != nil {
- return false, fmt.Errorf("inspect existing sqlite metadata database: %w", err)
- }
- defer func() { err = errors.Join(err, db.Close()) }()
- var tables int
- if err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); err != nil {
- return false, fmt.Errorf("inspect existing sqlite metadata schema: %w", mapError(err))
- }
- return tables == 0, nil
-}
-
-func createSchema(ctx context.Context, tx *sql.Tx, namespaces map[meta.Namespace]map[meta.Table]struct{}) error {
- if _, err := tx.ExecContext(ctx, "CREATE TABLE "+metadataStateTable+" (namespace TEXT PRIMARY KEY NOT NULL, state TEXT NOT NULL, schema_version INTEGER NOT NULL, source TEXT NOT NULL DEFAULT '', digest TEXT NOT NULL DEFAULT '', records INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL)"); err != nil {
- return mapError(err)
- }
- for namespace, tables := range namespaces {
- if err := createNamespace(ctx, tx, namespace, tables); err != nil {
- return err
- }
- }
- return nil
-}
-
-func syncDatabase(path string) (err error) {
- file, err := os.Open(path)
- if err != nil {
- return fmt.Errorf("open sqlite metadata database for sync: %w", err)
- }
- defer func() { err = errors.Join(err, file.Close()) }()
- if err := file.Sync(); err != nil {
- return fmt.Errorf("sync sqlite metadata database: %w", err)
- }
- dir, err := os.Open(filepath.Dir(path))
- if err != nil {
- return fmt.Errorf("open sqlite metadata directory for sync: %w", err)
- }
- defer func() { err = errors.Join(err, dir.Close()) }()
- if err := dir.Sync(); err != nil && !errors.Is(err, os.ErrInvalid) {
- return fmt.Errorf("sync sqlite metadata directory: %w", err)
- }
- return nil
-}
diff --git a/internal/meta/sqlite/maintenance.go b/internal/meta/sqlite/maintenance.go
deleted file mode 100644
index e4fe4c8..0000000
--- a/internal/meta/sqlite/maintenance.go
+++ /dev/null
@@ -1,173 +0,0 @@
-package sqlite
-
-import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "os"
- "path/filepath"
-
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/lock"
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-// Backup atomically replaces destination with a verified, single-file SQLite
-// snapshot. A failed run removes only its temporary file and leaves any
-// previously published backup intact.
-func Backup(ctx context.Context, sourcePath, destinationPath string) (err error) {
- if sourcePath == "" || destinationPath == "" {
- return fmt.Errorf("sqlite backup source and destination are required: %w", meta.ErrScope)
- }
- sourcePath, err = filepath.Abs(sourcePath)
- if err != nil {
- return fmt.Errorf("resolve sqlite backup source: %w", err)
- }
- destinationPath, err = filepath.Abs(destinationPath)
- if err != nil {
- return fmt.Errorf("resolve sqlite backup destination: %w", err)
- }
- if sourcePath == destinationPath {
- return fmt.Errorf("sqlite backup destination must differ from source: %w", meta.ErrScope)
- }
- if _, err := os.Stat(sourcePath); err != nil {
- return fmt.Errorf("stat sqlite backup source: %w", err)
- }
- if err := RefuseConversion(sourcePath); err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(destinationPath), 0o750); err != nil {
- return fmt.Errorf("create sqlite backup directory: %w", err)
- }
- lockKey := filepath.Base(destinationPath) + ".backup"
- fileLock, err := lock.NewLocker(filepath.Dir(destinationPath)).Acquire(ctx, lockKey)
- if err != nil {
- return fmt.Errorf("lock sqlite backup destination: %w", err)
- }
- defer func() { err = errors.Join(err, fileLock.Release()) }()
-
- temporary, err := os.CreateTemp(filepath.Dir(destinationPath), ".kumabox-backup-*.db")
- if err != nil {
- return fmt.Errorf("reserve sqlite backup temporary path: %w", err)
- }
- temporaryPath := temporary.Name()
- if err := temporary.Close(); err != nil {
- return fmt.Errorf("close sqlite backup temporary file: %w", err)
- }
- if err := os.Remove(temporaryPath); err != nil {
- return fmt.Errorf("prepare sqlite backup temporary path: %w", err)
- }
- defer func() {
- if err != nil {
- _ = os.Remove(temporaryPath)
- }
- }()
-
- if err := vacuumInto(ctx, sourcePath, temporaryPath); err != nil {
- return err
- }
- if err := verifyDatabaseFile(ctx, temporaryPath); err != nil {
- return fmt.Errorf("verify sqlite backup: %w", err)
- }
- if err := os.Chmod(temporaryPath, 0o600); err != nil {
- return fmt.Errorf("set sqlite backup permissions: %w", err)
- }
- if err := syncFile(temporaryPath); err != nil {
- return err
- }
- if err := fault.Check(ctx, fault.MetadataBackupBeforeSwap); err != nil {
- return err
- }
- if err := os.Rename(temporaryPath, destinationPath); err != nil {
- return fmt.Errorf("publish sqlite backup: %w", err)
- }
- return syncParent(filepath.Dir(destinationPath))
-}
-
-func vacuumInto(ctx context.Context, sourcePath, destinationPath string) (err error) {
- db, err := openDatabase(sourcePath, "FULL", true)
- if err != nil {
- return err
- }
- defer func() { err = errors.Join(err, db.Close()) }()
- if err := verifyDatabaseIdentity(ctx, db); err != nil {
- return err
- }
- if _, err := db.ExecContext(ctx, "VACUUM INTO ?", destinationPath); err != nil {
- return fmt.Errorf("create sqlite backup: %w", mapError(err))
- }
- return nil
-}
-
-func verifyDatabaseFile(ctx context.Context, path string) (err error) {
- db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&_pragma=query_only(ON)&_pragma=trusted_schema(OFF)")
- if err != nil {
- return fmt.Errorf("open sqlite database for verification: %w", err)
- }
- defer func() { err = errors.Join(err, db.Close()) }()
- if err := verifyDatabaseIdentity(ctx, db); err != nil {
- return err
- }
- var result string
- if err := db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&result); err != nil {
- return mapError(err)
- }
- if result != "ok" {
- return fmt.Errorf("sqlite integrity check returned %q: %w", result, meta.ErrCorrupt)
- }
- return nil
-}
-
-type queryRower interface {
- QueryRowContext(context.Context, string, ...any) *sql.Row
-}
-
-func verifyDatabaseIdentity(ctx context.Context, db queryRower) error {
- var applicationID, schemaVersion int
- if err := db.QueryRowContext(ctx, "PRAGMA application_id").Scan(&applicationID); err != nil {
- return mapError(err)
- }
- if applicationID != databaseApplicationID {
- return fmt.Errorf("sqlite application id %d is not KumaBox: %w", applicationID, meta.ErrCorrupt)
- }
- if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&schemaVersion); err != nil {
- return mapError(err)
- }
- if schemaVersion != databaseSchemaVersion {
- return fmt.Errorf("unsupported sqlite schema version %d: %w", schemaVersion, meta.ErrCorrupt)
- }
- var namespaces, invalidStates int
- query := "SELECT count(*), coalesce(sum(CASE WHEN state IN ('initialized', 'converted') THEN 0 ELSE 1 END), 0) FROM " + metadataStateTable
- if err := db.QueryRowContext(ctx, query).Scan(&namespaces, &invalidStates); err != nil {
- return fmt.Errorf("read sqlite metadata namespace state: %w", mapError(err))
- }
- if namespaces == 0 || invalidStates != 0 {
- return fmt.Errorf("sqlite metadata namespace state is incomplete: %w", meta.ErrCorrupt)
- }
- return nil
-}
-
-func syncFile(path string) (err error) {
- file, err := os.Open(path)
- if err != nil {
- return fmt.Errorf("open sqlite backup for sync: %w", err)
- }
- defer func() { err = errors.Join(err, file.Close()) }()
- if err := file.Sync(); err != nil {
- return fmt.Errorf("sync sqlite backup: %w", err)
- }
- return nil
-}
-
-func syncParent(path string) (err error) {
- directory, err := os.Open(path)
- if err != nil {
- return fmt.Errorf("open sqlite backup directory: %w", err)
- }
- defer func() { err = errors.Join(err, directory.Close()) }()
- if err := directory.Sync(); err != nil && !errors.Is(err, os.ErrInvalid) {
- return fmt.Errorf("sync sqlite backup directory: %w", err)
- }
- return nil
-}
diff --git a/internal/meta/sqlite/maintenance_test.go b/internal/meta/sqlite/maintenance_test.go
deleted file mode 100644
index cc89b1b..0000000
--- a/internal/meta/sqlite/maintenance_test.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package sqlite
-
-import (
- "errors"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-func TestBackupPublishesVerifiedCurrentState(t *testing.T) {
- definition := Namespace{Name: "vms", Tables: []meta.Table{"records"}}
- sourcePath := filepath.Join(t.TempDir(), "metadata.db")
- destinationPath := filepath.Join(t.TempDir(), "backup.db")
- if err := Init(t.Context(), sourcePath, definition); err != nil {
- t.Fatal(err)
- }
- store, err := Open(sourcePath, definition)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- if err := store.Close(); err != nil {
- t.Errorf("close source store: %v", err)
- }
- })
- writeBackupRecord(t, store, "before")
-
- if err := Backup(t.Context(), sourcePath, destinationPath); err != nil {
- t.Fatal(err)
- }
- info, err := os.Stat(destinationPath)
- if err != nil {
- t.Fatal(err)
- }
- if info.Mode().Perm() != 0o600 {
- t.Fatalf("backup permissions = %o", info.Mode().Perm())
- }
- if got := readBackupRecord(t, destinationPath, definition); got != "before" {
- t.Fatalf("backup record = %q", got)
- }
- writeBackupRecord(t, store, "after")
- if err := Backup(t.Context(), sourcePath, destinationPath); err != nil {
- t.Fatal(err)
- }
- if got := readBackupRecord(t, destinationPath, definition); got != "after" {
- t.Fatalf("replaced backup record = %q", got)
- }
-}
-
-func TestBackupFailurePreservesPublishedBackup(t *testing.T) {
- definition := Namespace{Name: "vms", Tables: []meta.Table{"records"}}
- sourcePath := filepath.Join(t.TempDir(), "metadata.db")
- destinationPath := filepath.Join(t.TempDir(), "backup.db")
- if err := Init(t.Context(), sourcePath, definition); err != nil {
- t.Fatal(err)
- }
- store, err := Open(sourcePath, definition)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- if err := store.Close(); err != nil {
- t.Errorf("close source store: %v", err)
- }
- })
- writeBackupRecord(t, store, "published")
- if err := Backup(t.Context(), sourcePath, destinationPath); err != nil {
- t.Fatal(err)
- }
- writeBackupRecord(t, store, "unpublished")
-
- injected := errors.New("injected backup failure")
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == fault.MetadataBackupBeforeSwap {
- return injected
- }
- return nil
- }))
- err = Backup(ctx, sourcePath, destinationPath)
- if !errors.Is(err, injected) {
- t.Fatalf("backup error = %v", err)
- }
- if got := readBackupRecord(t, destinationPath, definition); got != "published" {
- t.Fatalf("backup changed after failed publish: %q", got)
- }
-}
-
-func TestBackupRejectsSourceAsDestination(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- definition := Namespace{Name: "vms", Tables: []meta.Table{"records"}}
- if err := Init(t.Context(), path, definition); err != nil {
- t.Fatal(err)
- }
- if err := Backup(t.Context(), path, path); !errors.Is(err, meta.ErrScope) {
- t.Fatalf("same-path backup error = %v", err)
- }
-}
-
-func writeBackupRecord(t *testing.T, store *Store, name string) {
- t.Helper()
- collection := meta.NewCollection[struct {
- Name string `json:"name"`
- }]("vms", "records")
- record := struct {
- Name string `json:"name"`
- }{Name: name}
- if err := store.Update(t.Context(), meta.Scope{Write: "vms"}, meta.CommitDurable, func(writer meta.Writer) error {
- return collection.Upsert(t.Context(), writer, "vm-1", &record)
- }); err != nil {
- t.Fatal(err)
- }
-}
-
-func readBackupRecord(t *testing.T, path string, definition Namespace) string {
- t.Helper()
- store, err := Open(path, definition)
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := store.Close(); err != nil {
- t.Errorf("close backup store: %v", err)
- }
- }()
- collection := meta.NewCollection[struct {
- Name string `json:"name"`
- }]("vms", "records")
- var name string
- if err := store.View(t.Context(), []meta.Namespace{"vms"}, func(reader meta.Reader) error {
- record, err := collection.Get(t.Context(), reader, "vm-1")
- if err == nil {
- name = record.Name
- }
- return err
- }); err != nil {
- t.Fatal(err)
- }
- return name
-}
diff --git a/internal/meta/sqlite/store.go b/internal/meta/sqlite/store.go
deleted file mode 100644
index 40e3473..0000000
--- a/internal/meta/sqlite/store.go
+++ /dev/null
@@ -1,584 +0,0 @@
-// Package sqlite implements the metadata transaction boundary with SQLite.
-// Tables contain only an id and an encoded record; typed object handling stays
-// in meta.Collection, just as it does for the JSON engine.
-package sqlite
-
-import (
- "context"
- "database/sql"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "sync"
- "time"
-
- "github.com/kumabox/kumabox/internal/meta"
- _ "modernc.org/sqlite"
-)
-
-const (
- databaseApplicationID = 0x4b4d4231 // "KMB1"
- databaseSchemaVersion = 1
- metadataStateTable = "_kumabox_meta_state"
- // ConversionManifestName marks an unfinished offline backend switch.
- ConversionManifestName = "meta-convert.manifest"
-)
-
-// Namespace declares the tables an SQLite metadata file may contain.
-type Namespace struct {
- Name meta.Namespace
- Tables []meta.Table
-}
-
-// NamespaceStatus describes the durable initialization state of one metadata
-// namespace. It is intentionally separate from resource records so startup
-// can validate the database before opening resource collections.
-type NamespaceStatus struct {
- Namespace meta.Namespace
- State string
- SchemaVersion int
- Records int
- Source string
- Digest string
- UpdatedAt string
-}
-
-// Store is an SQLite-backed MetaEngine.
-type Store struct {
- path string
- durable *sql.DB
- relaxed *sql.DB
- readers *sql.DB
- namespaces map[meta.Namespace]map[meta.Table]struct{}
- mu sync.Mutex
- subscribers map[*subscription]struct{}
- closed bool
-}
-
-type subscription struct {
- changes chan struct{}
- cancel context.CancelFunc
- done chan struct{}
- stop sync.Once
-}
-
-func (s *subscription) close() {
- s.stop.Do(func() {
- s.cancel()
- <-s.done
- close(s.changes)
- })
-}
-
-var _ meta.MetaEngine = (*Store)(nil)
-
-// Open opens an initialized metadata database. Database creation and schema
-// changes belong to Init so a normal command can never mistake a partial or
-// unrelated SQLite file for an empty KumaBox store.
-func Open(path string, definitions ...Namespace) (*Store, error) {
- if err := RefuseConversion(path); err != nil {
- return nil, err
- }
- return open(path, definitions...)
-}
-
-// OpenForRecovery bypasses the conversion guard for the conversion command.
-func OpenForRecovery(path string, definitions ...Namespace) (*Store, error) {
- return open(path, definitions...)
-}
-
-func open(path string, definitions ...Namespace) (*Store, error) {
- if path == "" || len(definitions) == 0 {
- return nil, fmt.Errorf("SQLite metadata path and namespace definitions are required: %w", meta.ErrScope)
- }
- namespaces, err := validateDefinitions(definitions)
- if err != nil {
- return nil, err
- }
- if _, err := os.Stat(path); err != nil {
- if errors.Is(err, os.ErrNotExist) {
- return nil, fmt.Errorf("sqlite metadata database %s is not initialized: %w", path, os.ErrNotExist)
- }
- return nil, fmt.Errorf("stat sqlite metadata database: %w", err)
- }
- store := &Store{path: path, namespaces: namespaces, subscribers: make(map[*subscription]struct{})}
- if store.durable, err = openDatabase(path, "FULL", true); err != nil {
- return nil, err
- }
- if store.relaxed, err = openDatabase(path, "NORMAL", true); err != nil {
- return nil, errors.Join(err, store.Close())
- }
- if store.readers, err = openDatabase(path, "FULL", false); err != nil {
- return nil, errors.Join(err, store.Close())
- }
- store.readers.SetMaxOpenConns(max(2, runtime.NumCPU()))
- store.readers.SetMaxIdleConns(max(2, runtime.NumCPU()))
- if err := store.initializeIdentity(); err != nil {
- return nil, errors.Join(err, store.Close())
- }
- return store, nil
-}
-
-// RefuseConversion prevents ordinary commands from using either side of an
-// unfinished metadata switch.
-func RefuseConversion(path string) error {
- manifest := filepath.Join(filepath.Dir(path), ConversionManifestName)
- if _, err := os.Stat(manifest); err == nil {
- return fmt.Errorf("metadata conversion manifest %s exists; rerun metadata convert", manifest)
- } else if !errors.Is(err, os.ErrNotExist) {
- return fmt.Errorf("stat metadata conversion manifest: %w", err)
- }
- return nil
-}
-
-func (s *Store) View(ctx context.Context, namespaces []meta.Namespace, fn func(meta.Reader) error) error {
- if fn == nil {
- return fmt.Errorf("metadata view callback must not be nil: %w", meta.ErrScope)
- }
- if err := s.checkOpenAndScope(namespaces, ""); err != nil {
- return err
- }
- tx, err := s.readers.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
- if err != nil {
- return mapError(err)
- }
- defer tx.Rollback() //nolint:errcheck
- return fn(&txReader{tx: tx, allowed: namespaceSet(namespaces), tables: s.tablesFor(namespaces)})
-}
-
-func (s *Store) Update(ctx context.Context, scope meta.Scope, mode meta.CommitMode, fn func(meta.Writer) error) error {
- if fn == nil {
- return fmt.Errorf("metadata update callback must not be nil: %w", meta.ErrScope)
- }
- if mode != meta.CommitDurable && mode != meta.CommitRelaxed {
- return fmt.Errorf("unsupported metadata commit mode %d: %w", mode, meta.ErrDurabilityContract)
- }
- namespaces := append([]meta.Namespace{scope.Write}, scope.Read...)
- if err := s.checkOpenAndScope(namespaces, scope.Write); err != nil {
- return err
- }
- db := s.durable
- if mode == meta.CommitRelaxed {
- db = s.relaxed
- }
- tx, err := db.BeginTx(ctx, nil)
- if err != nil {
- return mapError(err)
- }
- writer := &txWriter{txReader: txReader{tx: tx, allowed: namespaceSet(namespaces), tables: s.tablesFor(namespaces)}, writeNamespace: scope.Write, mode: mode}
- if err := fn(writer); err != nil {
- _ = tx.Rollback()
- return err
- }
- if err := ctx.Err(); err != nil {
- _ = tx.Rollback()
- return err
- }
- if err := tx.Commit(); err != nil {
- return mapError(err)
- }
- s.notify()
- return nil
-}
-
-func (s *Store) Events(ctx context.Context) (<-chan struct{}, func(), error) {
- if err := ctx.Err(); err != nil {
- return nil, nil, err
- }
- watchCtx, cancel := context.WithCancel(ctx)
- conn, err := s.readers.Conn(watchCtx)
- if err != nil {
- cancel()
- return nil, nil, mapError(err)
- }
- version, err := sqliteDataVersion(watchCtx, conn)
- if err != nil {
- _ = conn.Close()
- cancel()
- return nil, nil, err
- }
- sub := &subscription{changes: make(chan struct{}, 1), cancel: cancel, done: make(chan struct{})}
- s.mu.Lock()
- if s.closed {
- s.mu.Unlock()
- _ = conn.Close()
- cancel()
- return nil, nil, meta.ErrClosed
- }
- s.subscribers[sub] = struct{}{}
- s.mu.Unlock()
- go s.watchDataVersion(watchCtx, conn, sub, version)
-
- var once sync.Once
- release := func() {
- once.Do(func() {
- s.mu.Lock()
- delete(s.subscribers, sub)
- s.mu.Unlock()
- sub.close()
- })
- }
- return sub.changes, release, nil
-}
-
-func (s *Store) Close() error {
- s.mu.Lock()
- if s.closed {
- s.mu.Unlock()
- return nil
- }
- s.closed = true
- subs := make([]*subscription, 0, len(s.subscribers))
- for sub := range s.subscribers {
- subs = append(subs, sub)
- delete(s.subscribers, sub)
- }
- s.mu.Unlock()
- for _, sub := range subs {
- sub.close()
- }
- var closeErrors []error
- for _, db := range []*sql.DB{s.durable, s.relaxed, s.readers} {
- if db != nil {
- closeErrors = append(closeErrors, db.Close())
- }
- }
- s.durable, s.relaxed, s.readers = nil, nil, nil
- return errors.Join(closeErrors...)
-}
-
-// Status returns the initialization state recorded for each declared
-// namespace. The state is used by migration and recovery tooling rather than
-// by normal resource reads and writes.
-func (s *Store) Status(ctx context.Context) (result []NamespaceStatus, err error) {
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- s.mu.Lock()
- closed := s.closed
- s.mu.Unlock()
- if closed {
- return nil, meta.ErrClosed
- }
- rows, err := s.readers.QueryContext(ctx, "SELECT namespace, state, schema_version, records, source, digest, updated_at FROM "+metadataStateTable+" ORDER BY namespace")
- if err != nil {
- return nil, mapError(err)
- }
- defer func() {
- if closeErr := rows.Close(); err == nil && closeErr != nil {
- err = mapError(closeErr)
- }
- }()
- for rows.Next() {
- var status NamespaceStatus
- if err := rows.Scan(&status.Namespace, &status.State, &status.SchemaVersion, &status.Records, &status.Source, &status.Digest, &status.UpdatedAt); err != nil {
- return nil, mapError(err)
- }
- if _, declared := s.namespaces[status.Namespace]; declared {
- result = append(result, status)
- }
- }
- if err := rows.Err(); err != nil {
- return nil, mapError(err)
- }
- if len(result) != len(s.namespaces) {
- return nil, fmt.Errorf("SQLite metadata namespace state is incomplete: %w", meta.ErrCorrupt)
- }
- return result, nil
-}
-
-// Verify checks SQLite's page and index invariants in addition to KumaBox's
-// identity and namespace declarations.
-func (s *Store) Verify(ctx context.Context) error {
- if _, err := s.Status(ctx); err != nil {
- return err
- }
- var result string
- if err := s.readers.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&result); err != nil {
- return mapError(err)
- }
- if result != "ok" {
- return fmt.Errorf("sqlite metadata integrity check returned %q: %w", result, meta.ErrCorrupt)
- }
- return nil
-}
-
-func (s *Store) initializeIdentity() error {
- var applicationID int
- if err := s.readers.QueryRow("PRAGMA application_id").Scan(&applicationID); err != nil {
- return mapError(err)
- }
- if applicationID != databaseApplicationID {
- return fmt.Errorf("sqlite metadata application id %d is not KumaBox: %w", applicationID, meta.ErrCorrupt)
- }
-
- var schemaVersion int
- if err := s.readers.QueryRow("PRAGMA user_version").Scan(&schemaVersion); err != nil {
- return mapError(err)
- }
- if schemaVersion != databaseSchemaVersion {
- return fmt.Errorf("unsupported sqlite metadata schema version %d: %w", schemaVersion, meta.ErrCorrupt)
- }
- _, err := s.Status(context.Background())
- return err
-}
-
-func (s *Store) checkOpenAndScope(namespaces []meta.Namespace, write meta.Namespace) error {
- s.mu.Lock()
- closed := s.closed
- s.mu.Unlock()
- if closed {
- return meta.ErrClosed
- }
- seen := make(map[meta.Namespace]struct{}, len(namespaces))
- for _, namespace := range namespaces {
- if namespace == "" {
- return fmt.Errorf("metadata namespace must not be empty: %w", meta.ErrScope)
- }
- if _, ok := s.namespaces[namespace]; !ok {
- return fmt.Errorf("metadata namespace %q is not declared: %w", namespace, meta.ErrScope)
- }
- seen[namespace] = struct{}{}
- }
- if write != "" {
- if _, ok := seen[write]; !ok {
- return fmt.Errorf("write namespace %q is outside scope: %w", write, meta.ErrScope)
- }
- }
- return nil
-}
-
-func (s *Store) notify() {
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.closed {
- return
- }
- for sub := range s.subscribers {
- select {
- case sub.changes <- struct{}{}:
- default:
- }
- }
-}
-
-func (s *Store) watchDataVersion(ctx context.Context, conn *sql.Conn, sub *subscription, previous int64) {
- defer close(sub.done)
- defer func() { _ = conn.Close() }()
- ticker := time.NewTicker(200 * time.Millisecond)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- current, err := sqliteDataVersion(ctx, conn)
- if err != nil || current == previous {
- continue
- }
- previous = current
- s.mu.Lock()
- if _, ok := s.subscribers[sub]; ok && !s.closed {
- select {
- case sub.changes <- struct{}{}:
- default:
- }
- }
- s.mu.Unlock()
- }
- }
-}
-
-func sqliteDataVersion(ctx context.Context, conn *sql.Conn) (int64, error) {
- var version int64
- if err := conn.QueryRowContext(ctx, "PRAGMA data_version").Scan(&version); err != nil {
- return 0, mapError(err)
- }
- return version, nil
-}
-
-type txReader struct {
- tx *sql.Tx
- allowed map[meta.Namespace]struct{}
- tables map[meta.Namespace]map[meta.Table]struct{}
-}
-
-func (r *txReader) GetRaw(ctx context.Context, namespace meta.Namespace, table meta.Table, id meta.RecordID) (json.RawMessage, bool, error) {
- if err := r.checkRead(namespace, table); err != nil {
- return nil, false, err
- }
- var raw []byte
- err := r.tx.QueryRowContext(ctx, "SELECT data FROM "+tableName(namespace, table)+" WHERE id = ?", string(id)).Scan(&raw)
- if errors.Is(err, sql.ErrNoRows) {
- return nil, false, nil
- }
- if err != nil {
- return nil, false, mapError(err)
- }
- return append(json.RawMessage(nil), raw...), true, nil
-}
-
-func (r *txReader) ScanRaw(ctx context.Context, namespace meta.Namespace, table meta.Table, fn func(meta.RecordID, json.RawMessage) error) (err error) {
- if fn == nil {
- return fmt.Errorf("metadata scan callback must not be nil: %w", meta.ErrScope)
- }
- if err := r.checkRead(namespace, table); err != nil {
- return err
- }
- rows, err := r.tx.QueryContext(ctx, "SELECT id, data FROM "+tableName(namespace, table)+" ORDER BY id")
- if err != nil {
- return mapError(err)
- }
- defer func() {
- if closeErr := rows.Close(); err == nil && closeErr != nil {
- err = mapError(closeErr)
- }
- }()
- for rows.Next() {
- var id string
- var raw []byte
- if err := rows.Scan(&id, &raw); err != nil {
- return mapError(err)
- }
- if err := fn(meta.RecordID(id), append(json.RawMessage(nil), raw...)); err != nil {
- return err
- }
- }
- return mapError(rows.Err())
-}
-
-func (r *txReader) checkRead(namespace meta.Namespace, table meta.Table) error {
- if _, ok := r.allowed[namespace]; !ok {
- return fmt.Errorf("metadata namespace %q is outside transaction scope: %w", namespace, meta.ErrScope)
- }
- if _, ok := r.tables[namespace][table]; !ok {
- return fmt.Errorf("metadata table %q/%q is not declared: %w", namespace, table, meta.ErrScope)
- }
- if table == "" {
- return fmt.Errorf("metadata table must not be empty: %w", meta.ErrScope)
- }
- return nil
-}
-
-type txWriter struct {
- txReader
- writeNamespace meta.Namespace
- mode meta.CommitMode
-}
-
-func (w *txWriter) PutRaw(ctx context.Context, namespace meta.Namespace, table meta.Table, id meta.RecordID, raw json.RawMessage) error {
- if err := w.checkWrite(ctx, namespace, table, id); err != nil {
- return err
- }
- _, err := w.tx.ExecContext(ctx, "INSERT INTO "+tableName(namespace, table)+" (id, data) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data=excluded.data", string(id), []byte(raw))
- return mapError(err)
-}
-
-func (w *txWriter) DeleteRaw(ctx context.Context, namespace meta.Namespace, table meta.Table, id meta.RecordID) error {
- if err := w.checkWrite(ctx, namespace, table, id); err != nil {
- return err
- }
- _, err := w.tx.ExecContext(ctx, "DELETE FROM "+tableName(namespace, table)+" WHERE id = ?", string(id))
- return mapError(err)
-}
-
-func (w *txWriter) checkWrite(ctx context.Context, namespace meta.Namespace, table meta.Table, id meta.RecordID) error {
- if err := ctx.Err(); err != nil {
- return err
- }
- if namespace != w.writeNamespace {
- return fmt.Errorf("cannot write metadata namespace %q from %q transaction: %w", namespace, w.writeNamespace, meta.ErrScope)
- }
- if table == "" || id == "" {
- return fmt.Errorf("metadata table and id must not be empty: %w", meta.ErrScope)
- }
- if w.mode != meta.CommitDurable && w.mode != meta.CommitRelaxed {
- return fmt.Errorf("unsupported metadata commit mode: %d", w.mode)
- }
- return nil
-}
-
-func validateDefinitions(definitions []Namespace) (map[meta.Namespace]map[meta.Table]struct{}, error) {
- result := make(map[meta.Namespace]map[meta.Table]struct{}, len(definitions))
- for _, definition := range definitions {
- if definition.Name == "" || len(definition.Tables) == 0 {
- return nil, fmt.Errorf("metadata namespace %q has incomplete definition: %w", definition.Name, meta.ErrScope)
- }
- if _, exists := result[definition.Name]; exists {
- return nil, fmt.Errorf("metadata namespace %q declared twice: %w", definition.Name, meta.ErrScope)
- }
- result[definition.Name] = make(map[meta.Table]struct{}, len(definition.Tables))
- for _, table := range definition.Tables {
- if table == "" {
- return nil, fmt.Errorf("metadata table must not be empty: %w", meta.ErrScope)
- }
- result[definition.Name][table] = struct{}{}
- }
- }
- return result, nil
-}
-
-func namespaceSet(namespaces []meta.Namespace) map[meta.Namespace]struct{} {
- result := make(map[meta.Namespace]struct{}, len(namespaces))
- for _, namespace := range namespaces {
- result[namespace] = struct{}{}
- }
- return result
-}
-
-func (s *Store) tablesFor(namespaces []meta.Namespace) map[meta.Namespace]map[meta.Table]struct{} {
- result := make(map[meta.Namespace]map[meta.Table]struct{}, len(namespaces))
- for _, namespace := range namespaces {
- result[namespace] = s.namespaces[namespace]
- }
- return result
-}
-
-func tableName(namespace meta.Namespace, table meta.Table) string {
- name := rawTableName(namespace, table)
- return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
-}
-
-func rawTableName(namespace meta.Namespace, table meta.Table) string {
- return string(namespace) + "__" + string(table)
-}
-
-func mapError(err error) error {
- if err == nil {
- return nil
- }
- message := strings.ToLower(err.Error())
- switch {
- case strings.Contains(message, "busy"), strings.Contains(message, "locked"):
- return fmt.Errorf("%v: %w", err, meta.ErrBusy)
- case strings.Contains(message, "full"), strings.Contains(message, "no space"):
- return fmt.Errorf("%v: %w", err, meta.ErrNoSpace)
- default:
- return err
- }
-}
-
-func openDatabase(path, synchronous string, writer bool) (*sql.DB, error) {
- dsn := "file:" + filepath.ToSlash(path) +
- "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)" +
- "&_pragma=trusted_schema(OFF)&_pragma=synchronous(" + synchronous + ")"
- if writer {
- dsn += "&_txlock=immediate"
- }
- db, err := sql.Open("sqlite", dsn)
- if err != nil {
- return nil, fmt.Errorf("open sqlite metadata database: %w", mapError(err))
- }
- if writer {
- db.SetMaxOpenConns(1)
- db.SetMaxIdleConns(1)
- }
- if err := db.Ping(); err != nil {
- return nil, errors.Join(fmt.Errorf("ping sqlite metadata database: %w", mapError(err)), db.Close())
- }
- return db, nil
-}
diff --git a/internal/meta/sqlite/store_test.go b/internal/meta/sqlite/store_test.go
deleted file mode 100644
index f776e46..0000000
--- a/internal/meta/sqlite/store_test.go
+++ /dev/null
@@ -1,402 +0,0 @@
-package sqlite
-
-import (
- "context"
- "database/sql"
- "errors"
- "os"
- "path/filepath"
- "strconv"
- "sync"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-func TestStorePersistsTypedCollectionAndRollsBack(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- if err := Init(t.Context(), path, Namespace{Name: "vms", Tables: []meta.Table{"records"}}); err != nil {
- t.Fatal(err)
- }
- store, err := Open(path, Namespace{Name: "vms", Tables: []meta.Table{"records"}})
- if err != nil {
- t.Fatal(err)
- }
- ctx := context.Background()
- type record struct {
- Name string `json:"name"`
- }
- collection := meta.NewCollection[record]("vms", "records")
-
- if err := store.Update(ctx, meta.Scope{Write: "vms"}, meta.CommitDurable, func(writer meta.Writer) error {
- return collection.Upsert(ctx, writer, "vm-1", &record{Name: "one"})
- }); err != nil {
- t.Fatal(err)
- }
- wantErr := errors.New("abort")
- if err := store.Update(ctx, meta.Scope{Write: "vms"}, meta.CommitDurable, func(writer meta.Writer) error {
- if err := collection.Upsert(ctx, writer, "vm-2", &record{Name: "two"}); err != nil {
- return err
- }
- return wantErr
- }); !errors.Is(err, wantErr) {
- t.Fatalf("rollback error = %v", err)
- }
-
- if err := store.View(ctx, []meta.Namespace{"vms"}, func(reader meta.Reader) error {
- got, err := collection.Get(ctx, reader, "vm-1")
- if err != nil {
- return err
- }
- if got.Name != "one" {
- t.Fatalf("record name = %q", got.Name)
- }
- if _, err := collection.Get(ctx, reader, "vm-2"); !errors.Is(err, meta.ErrNotFound) {
- t.Fatalf("rolled-back record error = %v", err)
- }
- return nil
- }); err != nil {
- t.Fatal(err)
- }
- if err := store.Close(); err != nil {
- t.Fatal(err)
- }
-
- store, err = Open(path, Namespace{Name: "vms", Tables: []meta.Table{"records"}})
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := store.Close(); err != nil {
- t.Errorf("close store: %v", err)
- }
- }()
- if err := store.View(ctx, []meta.Namespace{"vms"}, func(reader meta.Reader) error {
- got, err := collection.Get(ctx, reader, "vm-1")
- if err != nil {
- return err
- }
- if got.Name != "one" {
- t.Fatalf("reopened record name = %q", got.Name)
- }
- return nil
- }); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestStoreEventsObserveAnotherConnection(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- definition := Namespace{Name: "vms", Tables: []meta.Table{"records"}}
- if err := Init(t.Context(), path, definition); err != nil {
- t.Fatal(err)
- }
- reader, err := Open(path, definition)
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = reader.Close() }()
- writer, err := Open(path, definition)
- if err != nil {
- t.Fatal(err)
- }
- defer func() { _ = writer.Close() }()
-
- changes, release, err := reader.Events(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- defer release()
- if err := writer.Update(t.Context(), meta.Scope{Write: "vms"}, meta.CommitDurable, func(w meta.Writer) error {
- return w.PutRaw(t.Context(), "vms", "records", "external", []byte(`{}`))
- }); err != nil {
- t.Fatal(err)
- }
- select {
- case <-changes:
- case <-time.After(2 * time.Second):
- t.Fatal("subscriber did not observe external SQLite connection commit")
- }
-}
-
-func TestStoreEventsReleaseMayRaceClose(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- definition := Namespace{Name: "vms", Tables: []meta.Table{"records"}}
- if err := Init(t.Context(), path, definition); err != nil {
- t.Fatal(err)
- }
- store, err := Open(path, definition)
- if err != nil {
- t.Fatal(err)
- }
- _, release, err := store.Events(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- var wait sync.WaitGroup
- wait.Add(2)
- go func() { defer wait.Done(); release() }()
- go func() { defer wait.Done(); _ = store.Close() }()
- wait.Wait()
-}
-
-func TestStoreEnforcesDeclaredScopeAndCoalescesEvents(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- definitions := []Namespace{
- Namespace{Name: "vms", Tables: []meta.Table{"records"}},
- Namespace{Name: "network", Tables: []meta.Table{"leases"}},
- }
- if err := Init(t.Context(), path, definitions...); err != nil {
- t.Fatal(err)
- }
- store, err := Open(path, definitions...)
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := store.Close(); err != nil {
- t.Errorf("close store: %v", err)
- }
- }()
- ctx := context.Background()
- if err := store.Update(ctx, meta.Scope{Write: "vms"}, meta.CommitDurable, func(writer meta.Writer) error {
- return writer.PutRaw(ctx, "network", "leases", "ip-1", []byte(`{}`))
- }); !errors.Is(err, meta.ErrScope) {
- t.Fatalf("write scope error = %v", err)
- }
- if err := store.View(ctx, []meta.Namespace{"vms"}, func(reader meta.Reader) error {
- _, _, err := reader.GetRaw(ctx, "network", "leases", "ip-1")
- return err
- }); !errors.Is(err, meta.ErrScope) {
- t.Fatalf("read scope error = %v", err)
- }
-
- changes, release, err := store.Events(ctx)
- if err != nil {
- t.Fatal(err)
- }
- defer release()
- for _, id := range []string{"a", "b"} {
- if err := store.Update(ctx, meta.Scope{Write: "vms"}, meta.CommitRelaxed, func(writer meta.Writer) error {
- return writer.PutRaw(ctx, "vms", "records", meta.RecordID(id), []byte(`{}`))
- }); err != nil {
- t.Fatal(err)
- }
- }
- select {
- case <-changes:
- default:
- t.Fatal("expected metadata change event")
- }
- select {
- case <-changes:
- t.Fatal("metadata events should coalesce")
- default:
- }
-}
-
-func TestStoreRecordsIdentityAndNamespaceStatus(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- if err := Init(t.Context(), path, Namespace{Name: "vms", Tables: []meta.Table{"records"}}); err != nil {
- t.Fatal(err)
- }
- store, err := Open(path, Namespace{Name: "vms", Tables: []meta.Table{"records"}})
- if err != nil {
- t.Fatal(err)
- }
- status, err := store.Status(context.Background())
- if err != nil {
- t.Fatal(err)
- }
- if len(status) != 1 || status[0].Namespace != "vms" || status[0].State != "initialized" || status[0].SchemaVersion != databaseSchemaVersion {
- t.Fatalf("namespace status = %+v", status)
- }
- if err := store.Close(); err != nil {
- t.Fatal(err)
- }
-
- db, err := sql.Open("sqlite", "file:"+path)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := db.Exec("PRAGMA application_id = 1234"); err != nil {
- t.Fatal(err)
- }
- if err := db.Close(); err != nil {
- t.Fatal(err)
- }
- if _, err := Open(path, Namespace{Name: "vms", Tables: []meta.Table{"records"}}); !errors.Is(err, meta.ErrCorrupt) {
- t.Fatalf("wrong application id error = %v", err)
- }
-}
-
-func TestStoreRejectsUnsupportedSchemaVersion(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- if err := Init(t.Context(), path, Namespace{Name: "vms", Tables: []meta.Table{"records"}}); err != nil {
- t.Fatal(err)
- }
- store, err := Open(path, Namespace{Name: "vms", Tables: []meta.Table{"records"}})
- if err != nil {
- t.Fatal(err)
- }
- if err := store.Close(); err != nil {
- t.Fatal(err)
- }
- db, err := sql.Open("sqlite", "file:"+path)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := db.Exec("PRAGMA user_version = 99"); err != nil {
- t.Fatal(err)
- }
- if err := db.Close(); err != nil {
- t.Fatal(err)
- }
- if _, err := Open(path, Namespace{Name: "vms", Tables: []meta.Table{"records"}}); !errors.Is(err, meta.ErrCorrupt) {
- t.Fatalf("wrong schema version error = %v", err)
- }
-}
-
-func TestOpenRequiresInitialization(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- definition := Namespace{Name: "vms", Tables: []meta.Table{"records"}}
- if _, err := Open(path, definition); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("open uninitialized database error = %v", err)
- }
- if err := Init(t.Context(), path, definition); err != nil {
- t.Fatal(err)
- }
- if err := Init(t.Context(), path, definition); !errors.Is(err, meta.ErrConflict) {
- t.Fatalf("reinitialize database error = %v", err)
- }
-}
-
-func TestInitAddsNewNamespaceWithoutLosingExistingRecords(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- vms := Namespace{Name: "vms", Tables: []meta.Table{"records"}}
- usage := Namespace{Name: "metering", Tables: []meta.Table{"usage-events"}}
- if err := Init(t.Context(), path, vms); err != nil {
- t.Fatal(err)
- }
- store, err := Open(path, vms)
- if err != nil {
- t.Fatal(err)
- }
- if err := store.Update(t.Context(), meta.Scope{Write: "vms"}, meta.CommitDurable, func(writer meta.Writer) error {
- return writer.PutRaw(t.Context(), "vms", "records", "vm-1", []byte(`{"name":"preserved"}`))
- }); err != nil {
- t.Fatal(err)
- }
- if err := store.Close(); err != nil {
- t.Fatal(err)
- }
-
- if err := Init(t.Context(), path, vms, usage); err != nil {
- t.Fatal(err)
- }
- store, err = Open(path, vms, usage)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- if err := store.Close(); err != nil {
- t.Errorf("close upgraded store: %v", err)
- }
- })
- if err := store.View(t.Context(), []meta.Namespace{"vms"}, func(reader meta.Reader) error {
- raw, found, err := reader.GetRaw(t.Context(), "vms", "records", "vm-1")
- if err != nil {
- return err
- }
- if !found || string(raw) != `{"name":"preserved"}` {
- t.Fatalf("preserved record = %s, found = %v", raw, found)
- }
- return nil
- }); err != nil {
- t.Fatal(err)
- }
- status, err := store.Status(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(status) != 2 {
- t.Fatalf("namespace status = %+v", status)
- }
-}
-
-func TestInitRefusesToUpgradeIncompleteExistingNamespace(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- vms := Namespace{Name: "vms", Tables: []meta.Table{"records"}}
- if err := Init(t.Context(), path, vms); err != nil {
- t.Fatal(err)
- }
- db, err := sql.Open("sqlite", "file:"+path)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := db.Exec(`DROP TABLE "vms__records"`); err != nil {
- t.Fatal(err)
- }
- if err := db.Close(); err != nil {
- t.Fatal(err)
- }
- usage := Namespace{Name: "metering", Tables: []meta.Table{"usage-events"}}
- if err := Init(t.Context(), path, vms, usage); !errors.Is(err, meta.ErrCorrupt) {
- t.Fatalf("upgrade incomplete namespace error = %v", err)
- }
- if _, err := Open(path, vms, usage); !errors.Is(err, meta.ErrCorrupt) {
- t.Fatalf("partially upgraded store error = %v", err)
- }
-}
-
-func TestStoreSupportsConcurrentReadersAndSerializedWriters(t *testing.T) {
- path := filepath.Join(t.TempDir(), "metadata.db")
- definition := Namespace{Name: "vms", Tables: []meta.Table{"records"}}
- if err := Init(t.Context(), path, definition); err != nil {
- t.Fatal(err)
- }
- store, err := Open(path, definition)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- if err := store.Close(); err != nil {
- t.Errorf("close store: %v", err)
- }
- })
- if store.durable == store.relaxed || store.durable == store.readers {
- t.Fatal("durable, relaxed, and reader handles must be independent")
- }
-
- const workers = 8
- var wait sync.WaitGroup
- errorsCh := make(chan error, workers)
- for worker := range workers {
- wait.Add(1)
- go func() {
- defer wait.Done()
- id := meta.RecordID(strconv.Itoa(worker))
- if err := store.Update(t.Context(), meta.Scope{Write: "vms"}, meta.CommitRelaxed, func(writer meta.Writer) error {
- return writer.PutRaw(t.Context(), "vms", "records", id, []byte(`{"ok":true}`))
- }); err != nil {
- errorsCh <- err
- return
- }
- if err := store.View(t.Context(), []meta.Namespace{"vms"}, func(reader meta.Reader) error {
- _, found, err := reader.GetRaw(t.Context(), "vms", "records", id)
- if err == nil && !found {
- return errors.New("written record was not found")
- }
- return err
- }); err != nil {
- errorsCh <- err
- }
- }()
- }
- wait.Wait()
- close(errorsCh)
- for err := range errorsCh {
- t.Error(err)
- }
-}
diff --git a/internal/meta/transfer.go b/internal/meta/transfer.go
deleted file mode 100644
index 590b5b3..0000000
--- a/internal/meta/transfer.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package meta
-
-import (
- "context"
- "crypto/sha256"
- "encoding/json"
- "fmt"
- "hash"
-)
-
-// TableSet declares the records copied for one metadata namespace.
-//
-// The declaration is explicit so a migration cannot accidentally copy
-// implementation tables that belong to another resource.
-type TableSet struct {
- Namespace Namespace
- Tables []Table
-}
-
-// TransferReport is the durable evidence produced by a metadata conversion.
-// Counts are keyed by namespace and include all declared tables in that
-// namespace.
-type TransferReport struct {
- Records map[Namespace]int
- Digest string
-}
-
-// Transfer copies records from one metadata engine to another.
-//
-// Existing records in the destination are replaced. Records that exist only
-// in the destination are retained; deletion is deliberately a separate
-// operation so an interrupted migration never erases unrelated state.
-// Encoded records stay inside this package boundary. Callers migrate typed
-// data by declaring the same tables they use with meta.Collection.
-func Transfer(ctx context.Context, source, destination MetaEngine, tables []TableSet) error {
- _, err := TransferWithReport(ctx, source, destination, tables)
- return err
-}
-
-// TransferWithReport copies records and returns a deterministic content
-// digest. It is used by backend conversion so a restart can distinguish a
-// completed import from a partially copied database.
-func TransferWithReport(ctx context.Context, source, destination MetaEngine, tables []TableSet) (TransferReport, error) {
- if source == nil || destination == nil {
- return TransferReport{}, fmt.Errorf("metadata transfer engines must not be nil: %w", ErrScope)
- }
- if len(tables) == 0 {
- return TransferReport{}, fmt.Errorf("metadata transfer requires at least one table set: %w", ErrScope)
- }
-
- report := TransferReport{Records: make(map[Namespace]int, len(tables))}
- digest := sha256.New()
- for _, tableSet := range tables {
- count, err := transferNamespace(ctx, source, destination, tableSet, digest)
- if err != nil {
- return TransferReport{}, err
- }
- report.Records[tableSet.Namespace] = count
- }
- report.Digest = fmt.Sprintf("sha256:%x", digest.Sum(nil))
- return report, nil
-}
-
-type transferRecord struct {
- table Table
- id RecordID
- raw json.RawMessage
-}
-
-func transferNamespace(ctx context.Context, source, destination MetaEngine, tableSet TableSet, digest hash.Hash) (int, error) {
- if tableSet.Namespace == "" || len(tableSet.Tables) == 0 {
- return 0, fmt.Errorf("metadata transfer table set is incomplete: %w", ErrScope)
- }
- seen := make(map[Table]struct{}, len(tableSet.Tables))
- for _, table := range tableSet.Tables {
- if table == "" {
- return 0, fmt.Errorf("metadata transfer table must not be empty: %w", ErrScope)
- }
- if _, exists := seen[table]; exists {
- return 0, fmt.Errorf("metadata transfer table %q is duplicated: %w", table, ErrScope)
- }
- seen[table] = struct{}{}
- }
-
- records := make([]transferRecord, 0)
- if err := source.View(ctx, []Namespace{tableSet.Namespace}, func(reader Reader) error {
- for _, table := range tableSet.Tables {
- if err := reader.ScanRaw(ctx, tableSet.Namespace, table, func(id RecordID, raw json.RawMessage) error {
- if id == "" || !json.Valid(raw) {
- return fmt.Errorf("metadata transfer found invalid record %s/%s/%s: %w", tableSet.Namespace, table, id, ErrCorrupt)
- }
- writeDigest(digest, tableSet.Namespace, table, id, raw)
- records = append(records, transferRecord{
- table: table,
- id: id,
- raw: append(json.RawMessage(nil), raw...),
- })
- return nil
- }); err != nil {
- return err
- }
- }
- return nil
- }); err != nil {
- return 0, fmt.Errorf("read metadata namespace %s: %w", tableSet.Namespace, err)
- }
-
- if err := destination.Update(ctx, Scope{Write: tableSet.Namespace}, CommitDurable, func(writer Writer) error {
- for _, record := range records {
- if err := writer.PutRaw(ctx, tableSet.Namespace, record.table, record.id, record.raw); err != nil {
- return err
- }
- }
- return nil
- }); err != nil {
- return 0, fmt.Errorf("write metadata namespace %s: %w", tableSet.Namespace, err)
- }
- return len(records), nil
-}
-
-func writeDigest(digest hash.Hash, namespace Namespace, table Table, id RecordID, raw json.RawMessage) {
- // Length prefixes keep adjacent fields unambiguous (for example, "ab"+"c"
- // cannot collide with "a"+"bc").
- for _, value := range []string{string(namespace), string(table), string(id)} {
- _, _ = fmt.Fprintf(digest, "%d:", len(value))
- _, _ = digest.Write([]byte(value))
- }
- _, _ = fmt.Fprintf(digest, "%d:", len(raw))
- _, _ = digest.Write(raw)
-}
diff --git a/internal/meta/transfer_test.go b/internal/meta/transfer_test.go
deleted file mode 100644
index 437e5ec..0000000
--- a/internal/meta/transfer_test.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package meta_test
-
-import (
- "context"
- "path/filepath"
- "testing"
-
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
- metasqlite "github.com/kumabox/kumabox/internal/meta/sqlite"
-)
-
-func TestTransferCopiesJSONMetadataIntoSQLite(t *testing.T) {
- ctx := context.Background()
- dir := t.TempDir()
- jsonEngine, err := metajson.Open(metajson.Namespace{
- Name: "vms",
- FilePath: filepath.Join(dir, "vms.json"),
- LockPath: filepath.Join(dir, "vms.lock"),
- Codec: metajson.TableCodec{Specs: []metajson.TableSpec{{Key: "records", Table: "records"}}},
- })
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := jsonEngine.Close(); err != nil {
- t.Errorf("close JSON engine: %v", err)
- }
- }()
-
- type record struct {
- Name string `json:"name"`
- }
- collection := meta.NewCollection[record]("vms", "records")
- if err := jsonEngine.Update(ctx, meta.Scope{Write: "vms"}, meta.CommitDurable, func(writer meta.Writer) error {
- return collection.Upsert(ctx, writer, "vm-1", &record{Name: "source"})
- }); err != nil {
- t.Fatal(err)
- }
-
- databasePath := filepath.Join(dir, "metadata.db")
- databaseDefinition := metasqlite.Namespace{
- Name: "vms", Tables: []meta.Table{"records"},
- }
- if err := metasqlite.Init(ctx, databasePath, databaseDefinition); err != nil {
- t.Fatal(err)
- }
- sqliteEngine, err := metasqlite.Open(databasePath, databaseDefinition)
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := sqliteEngine.Close(); err != nil {
- t.Errorf("close SQLite engine: %v", err)
- }
- }()
-
- report, err := meta.TransferWithReport(ctx, jsonEngine, sqliteEngine, []meta.TableSet{{
- Namespace: "vms",
- Tables: []meta.Table{"records"},
- }})
- if err != nil {
- t.Fatal(err)
- }
- if report.Records["vms"] != 1 || report.Digest == "" {
- t.Fatalf("transfer report = %+v", report)
- }
- if err := sqliteEngine.View(ctx, []meta.Namespace{"vms"}, func(reader meta.Reader) error {
- got, err := collection.Get(ctx, reader, "vm-1")
- if err != nil {
- return err
- }
- if got.Name != "source" {
- t.Fatalf("transferred record = %#v", got)
- }
- return nil
- }); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestSQLiteConversionMarksNamespacesAfterTransfer(t *testing.T) {
- ctx := context.Background()
- dir := t.TempDir()
- source, err := metajson.Open(metajson.Namespace{
- Name: "vms", FilePath: filepath.Join(dir, "vms.json"), LockPath: filepath.Join(dir, "vms.lock"),
- Codec: metajson.TableCodec{Specs: []metajson.TableSpec{{Key: "records", Table: "records"}}},
- })
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := source.Close(); err != nil {
- t.Errorf("close source engine: %v", err)
- }
- }()
- collection := meta.NewCollection[map[string]string]("vms", "records")
- record := map[string]string{"name": "source"}
- if err := source.Update(ctx, meta.Scope{Write: "vms"}, meta.CommitDurable, func(writer meta.Writer) error {
- return collection.Upsert(ctx, writer, "vm-1", &record)
- }); err != nil {
- t.Fatal(err)
- }
- databasePath := filepath.Join(dir, "metadata.db")
- databaseDefinition := metasqlite.Namespace{Name: "vms", Tables: []meta.Table{"records"}}
- if err := metasqlite.Init(ctx, databasePath, databaseDefinition); err != nil {
- t.Fatal(err)
- }
- destination, err := metasqlite.Open(databasePath, databaseDefinition)
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := destination.Close(); err != nil {
- t.Errorf("close destination engine: %v", err)
- }
- }()
- if _, err := metasqlite.Convert(ctx, source, destination, "json", []meta.TableSet{{Namespace: "vms", Tables: []meta.Table{"records"}}}); err != nil {
- t.Fatal(err)
- }
- status, err := destination.Status(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if len(status) != 1 || status[0].State != "converted" || status[0].Records != 1 || status[0].Source != "json" {
- t.Fatalf("conversion status = %+v", status)
- }
-}
diff --git a/internal/metering/store.go b/internal/metering/store.go
deleted file mode 100644
index dac08af..0000000
--- a/internal/metering/store.go
+++ /dev/null
@@ -1,201 +0,0 @@
-// Package metering records durable VM compute lifecycle events and derives
-// usage intervals from them.
-package metering
-
-import (
- "context"
- "errors"
- "fmt"
- "path/filepath"
- "sort"
- "time"
-
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-const (
- Namespace meta.Namespace = "metering"
- Table meta.Table = "events"
-
- KindComputeStart Kind = "vm.compute.start"
- KindComputeStop Kind = "vm.compute.stop"
-
- ReasonBoot Reason = "boot"
- ReasonRestart Reason = "restart"
- ReasonResume Reason = "resume"
- ReasonClone Reason = "clone"
- ReasonRestore Reason = "restore"
- ReasonHibernate Reason = "hibernate"
- ReasonPause Reason = "pause"
- ReasonStopUser Reason = "stop-user"
- ReasonStopCrash Reason = "stop-crash"
- ReasonDelete Reason = "vm-delete"
-)
-
-type Kind string
-type Reason string
-
-type Shape struct {
- VCPUs int `json:"vcpus"`
- MemoryBytes int64 `json:"memoryBytes"`
-}
-
-// Event is one append-only compute lifecycle endpoint.
-type Event struct {
- ID string `json:"id"`
- Kind Kind `json:"kind"`
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
- Reason Reason `json:"reason"`
- Shape Shape `json:"shape"`
- EmittedAt time.Time `json:"emittedAt"`
-}
-
-// UsageInterval is one paired compute start/stop interval. EndedAt is nil
-// while the interval remains open.
-type UsageInterval struct {
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
- StartedAt time.Time `json:"startedAt"`
- EndedAt *time.Time `json:"endedAt,omitempty"`
- VCPUs int `json:"vcpus"`
- MemoryBytes int64 `json:"memoryBytes"`
- StartReason Reason `json:"startReason"`
- EndReason Reason `json:"endReason,omitempty"`
-}
-
-type Query struct {
- VMRef string
- Since *time.Time
- Until *time.Time
-}
-
-type Store struct {
- engine meta.MetaEngine
- collection *meta.Collection[Event]
-}
-
-func New(rootDir string) *Store {
- engine, err := metajson.Open(JSONNamespace(rootDir))
- if err != nil {
- panic(fmt.Sprintf("open metering metadata: %v", err))
- }
- return NewWithEngine(engine)
-}
-
-func JSONNamespace(rootDir string) metajson.Namespace {
- return metajson.Namespace{
- Name: string(Namespace), FilePath: filepath.Join(rootDir, "metering", "events.json"),
- LockPath: filepath.Join(rootDir, "metering", "events.lock"),
- Codec: metajson.TableCodec{Specs: []metajson.TableSpec{{Key: string(Table), Table: string(Table)}}},
- }
-}
-
-func NewWithEngine(engine meta.MetaEngine) *Store {
- return &Store{engine: engine, collection: meta.NewCollection[Event](Namespace, Table)}
-}
-
-func (s *Store) MetadataEngine() meta.MetaEngine { return s.engine }
-
-// Append inserts one event. Repeating the same ID with the same value is
-// idempotent; conflicting reuse fails closed.
-func (s *Store) Append(ctx context.Context, event Event) error {
- if event.ID == "" || event.VMID == "" || event.VMName == "" || event.EmittedAt.IsZero() {
- return fmt.Errorf("metering event identity and timestamp are required: %w", meta.ErrScope)
- }
- if event.Kind != KindComputeStart && event.Kind != KindComputeStop {
- return fmt.Errorf("unknown metering event kind %q: %w", event.Kind, meta.ErrScope)
- }
- event.EmittedAt = event.EmittedAt.UTC()
- return s.engine.Update(ctx, meta.Scope{Write: Namespace}, meta.CommitDurable, func(writer meta.Writer) error {
- existing, err := s.collection.Get(ctx, writer, meta.RecordID(event.ID))
- if err == nil {
- if equalEvent(*existing, event) {
- return nil
- }
- return fmt.Errorf("metering event id %q has conflicting content: %w", event.ID, meta.ErrConflict)
- }
- if !errors.Is(err, meta.ErrNotFound) {
- return err
- }
- return s.collection.Insert(ctx, writer, meta.RecordID(event.ID), &event)
- })
-}
-
-func (s *Store) Events(ctx context.Context, vmRef string) ([]Event, error) {
- events := make([]Event, 0)
- err := s.engine.View(ctx, []meta.Namespace{Namespace}, func(reader meta.Reader) error {
- return s.collection.Scan(ctx, reader, func(_ meta.RecordID, event *Event) error {
- if vmRef == "" || event.VMID == vmRef || event.VMName == vmRef {
- events = append(events, *event)
- }
- return nil
- })
- })
- sort.Slice(events, func(i, j int) bool {
- if events[i].EmittedAt.Equal(events[j].EmittedAt) {
- return events[i].ID < events[j].ID
- }
- return events[i].EmittedAt.Before(events[j].EmittedAt)
- })
- return events, err
-}
-
-func (s *Store) Usage(ctx context.Context, query Query) ([]UsageInterval, error) {
- events, err := s.Events(ctx, query.VMRef)
- if err != nil {
- return nil, err
- }
- open := make(map[string]*UsageInterval)
- intervals := make([]UsageInterval, 0)
- for _, event := range events {
- switch event.Kind {
- case KindComputeStart:
- if current := open[event.VMID]; current != nil {
- endedAt := event.EmittedAt
- current.EndedAt = &endedAt
- current.EndReason = ReasonStopCrash
- intervals = append(intervals, *current)
- }
- open[event.VMID] = &UsageInterval{VMID: event.VMID, VMName: event.VMName, StartedAt: event.EmittedAt, VCPUs: event.Shape.VCPUs, MemoryBytes: event.Shape.MemoryBytes, StartReason: event.Reason}
- case KindComputeStop:
- current := open[event.VMID]
- if current == nil || event.EmittedAt.Before(current.StartedAt) {
- continue
- }
- endedAt := event.EmittedAt
- current.EndedAt = &endedAt
- current.EndReason = event.Reason
- intervals = append(intervals, *current)
- delete(open, event.VMID)
- }
- }
- for _, current := range open {
- intervals = append(intervals, *current)
- }
- sort.Slice(intervals, func(i, j int) bool { return intervals[i].StartedAt.Before(intervals[j].StartedAt) })
- return filterIntervals(intervals, query.Since, query.Until), nil
-}
-
-func EventID(vmID string, kind Kind, at time.Time) string {
- return fmt.Sprintf("%s:%s:%d", vmID, kind, at.UTC().UnixNano())
-}
-
-func equalEvent(a, b Event) bool {
- return a.ID == b.ID && a.Kind == b.Kind && a.VMID == b.VMID && a.VMName == b.VMName && a.Reason == b.Reason && a.Shape == b.Shape && a.EmittedAt.Equal(b.EmittedAt)
-}
-
-func filterIntervals(intervals []UsageInterval, since, until *time.Time) []UsageInterval {
- result := make([]UsageInterval, 0, len(intervals))
- for _, interval := range intervals {
- if until != nil && !interval.StartedAt.Before(*until) {
- continue
- }
- if since != nil && interval.EndedAt != nil && !interval.EndedAt.After(*since) {
- continue
- }
- result = append(result, interval)
- }
- return result
-}
diff --git a/internal/metering/store_test.go b/internal/metering/store_test.go
deleted file mode 100644
index f45429e..0000000
--- a/internal/metering/store_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package metering
-
-import (
- "errors"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-func TestAppendIsIdempotentAndRejectsConflict(t *testing.T) {
- store := New(t.TempDir())
- at := time.Date(2026, 8, 12, 1, 2, 3, 0, time.UTC)
- event := Event{ID: EventID("vm-1", KindComputeStart, at), Kind: KindComputeStart, VMID: "vm-1", VMName: "demo", Reason: ReasonBoot, Shape: Shape{VCPUs: 2, MemoryBytes: 1024}, EmittedAt: at}
- if err := store.Append(t.Context(), event); err != nil {
- t.Fatal(err)
- }
- if err := store.Append(t.Context(), event); err != nil {
- t.Fatal(err)
- }
- event.Reason = ReasonRestart
- if err := store.Append(t.Context(), event); !errors.Is(err, meta.ErrConflict) {
- t.Fatalf("error = %v, want conflict", err)
- }
-}
-
-func TestUsagePairsEventsAndPreservesOpenInterval(t *testing.T) {
- store := New(t.TempDir())
- start := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
- stop := start.Add(time.Minute)
- events := []Event{
- {ID: EventID("vm-1", KindComputeStop, stop), Kind: KindComputeStop, VMID: "vm-1", VMName: "demo", Reason: ReasonPause, Shape: Shape{VCPUs: 2, MemoryBytes: 1024}, EmittedAt: stop},
- {ID: EventID("vm-1", KindComputeStart, start), Kind: KindComputeStart, VMID: "vm-1", VMName: "demo", Reason: ReasonBoot, Shape: Shape{VCPUs: 2, MemoryBytes: 1024}, EmittedAt: start},
- {ID: EventID("vm-1", KindComputeStart, stop.Add(time.Minute)), Kind: KindComputeStart, VMID: "vm-1", VMName: "demo", Reason: ReasonResume, Shape: Shape{VCPUs: 2, MemoryBytes: 1024}, EmittedAt: stop.Add(time.Minute)},
- }
- for _, event := range events {
- if err := store.Append(t.Context(), event); err != nil {
- t.Fatal(err)
- }
- }
- usage, err := store.Usage(t.Context(), Query{VMRef: "demo"})
- if err != nil {
- t.Fatal(err)
- }
- if len(usage) != 2 || usage[0].EndedAt == nil || usage[0].EndReason != ReasonPause || usage[1].EndedAt != nil {
- t.Fatalf("usage = %+v", usage)
- }
-}
diff --git a/internal/network/allocator.go b/internal/network/allocator.go
deleted file mode 100644
index 2ba66c3..0000000
--- a/internal/network/allocator.go
+++ /dev/null
@@ -1,315 +0,0 @@
-package network
-
-import (
- "crypto/rand"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "net"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/config"
-)
-
-const defaultQueueSize = 512
-
-var ErrLeaseConflict = errors.New("network lease conflict")
-
-// Allocator assigns deterministic tap names and exclusive MAC/IP leases.
-//
-// Allocation is file-backed and protected by the network lease lock so separate
-// CLI processes cannot hand out the same guest IP concurrently.
-type Allocator struct {
- store *Store
- cfg config.NetworkConfig
-}
-
-// AllocateRequest describes one VM interface allocation.
-//
-// Existing is used during recovery/reconciliation to re-adopt a previously
-// stored VM network config instead of assigning a new identity.
-type AllocateRequest struct {
- VMID string
- Network string
- Index int
- CPU int
- Existing *Config
-}
-
-// Allocation contains both sides of a network assignment.
-//
-// Record is persisted in the provider index; Config is copied into the VM
-// record and rendered into Cloud Hypervisor arguments.
-type Allocation struct {
- Record Record `json:"record"`
- Config Config `json:"config"`
-}
-
-// NewAllocator returns an allocator backed by rootDir's network store.
-func NewAllocator(rootDir string, cfg config.NetworkConfig) *Allocator {
- return NewAllocatorWithStore(NewStore(rootDir), cfg)
-}
-
-// NewAllocatorWithStore returns an allocator using the caller's metadata
-// backend. Runtime uses this form so SQLite and JSON never split leases across
-// different stores.
-func NewAllocatorWithStore(store *Store, cfg config.NetworkConfig) *Allocator {
- return &Allocator{store: store, cfg: cfg}
-}
-
-// Allocate reserves a tap/MAC/IP tuple for one VM interface.
-//
-// The IP lease is written before the caller creates the tap or provider record.
-// Callers must ReleaseIP if later setup steps fail.
-func (a *Allocator) Allocate(req AllocateRequest) (*Allocation, error) {
- if err := validateAllocateRequest(req); err != nil {
- return nil, err
- }
- var allocation *Allocation
- err := a.store.withLeases(true, func(leases *leaseIndex) error {
- now := time.Now().UTC()
- tap := TapName(a.cfg.TapPrefix, req.VMID, req.Index)
- var mac, ip string
- var prefix int
- var err error
- if req.Existing != nil {
- tap, mac, ip, prefix, err = a.recoverExisting(leases, req)
- } else {
- mac, err = a.allocateMAC(leases, req.VMID)
- if err == nil {
- ip, prefix, err = a.allocateIP(leases)
- }
- }
- if err != nil {
- return err
- }
- leases.CIDR = a.cfg.CIDR
- leases.Leases[ip] = &Lease{VMID: req.VMID, MAC: mac, TAP: tap, CreatedAt: now}
- allocation = a.buildAllocation(req, tap, mac, ip, prefix, now)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return allocation, nil
-}
-
-func (a *Allocator) buildAllocation(req AllocateRequest, tap, mac, ip string, prefix int, now time.Time) *Allocation {
- networkName := req.Network
- if networkName == "" {
- networkName = a.cfg.Default
- }
-
- ips := []string{fmt.Sprintf("%s/%d", ip, prefix)}
- record := Record{
- ID: NetworkID(req.VMID, req.Index),
- VMID: req.VMID,
- Network: networkName,
- Provider: ProviderHostTap,
- IfName: GuestInterfaceName(req.Index),
- TAP: tap,
- MAC: mac,
- NumQueues: netNumQueues(req.CPU),
- QueueSize: defaultQueueSize,
- BridgeDev: a.cfg.Bridge,
- IPs: ips,
- Gateway: a.cfg.Gateway,
- DNS: append([]string(nil), a.cfg.DNS...),
- Cleanup: Cleanup{},
- CreatedAt: now,
- UpdatedAt: now,
- }
- cfg := Config{
- ID: record.ID,
- NetworkName: record.Network,
- TAP: record.TAP,
- MAC: record.MAC,
- NumQueues: record.NumQueues,
- QueueSize: record.QueueSize,
- Backend: record.Provider,
- BridgeDev: record.BridgeDev,
- IfName: record.IfName,
- Network: &GuestInfo{
- IP: ip,
- Gateway: record.Gateway,
- Prefix: prefix,
- DNS: append([]string(nil), record.DNS...),
- },
- }
- return &Allocation{Record: record, Config: cfg}
-}
-
-// ReleaseIP removes a guest IP lease.
-//
-// The operation is idempotent so delete and rollback paths can safely retry it.
-func (a *Allocator) ReleaseIP(ip string) error {
- if ip == "" {
- return nil
- }
- return a.store.withLeases(true, func(leases *leaseIndex) error {
- delete(leases.Leases, ip)
- return nil
- })
-}
-
-func (a *Allocator) allocateIP(leases *leaseIndex) (string, int, error) {
- networkIP, ipNet, err := net.ParseCIDR(a.cfg.CIDR)
- if err != nil {
- return "", 0, fmt.Errorf("parse network CIDR: %w", err)
- }
- base := networkIP.To4()
- if base == nil {
- return "", 0, fmt.Errorf("network CIDR must be IPv4")
- }
- ones, bits := ipNet.Mask.Size()
- if bits != 32 {
- return "", 0, fmt.Errorf("network CIDR must be IPv4")
- }
- gateway := net.ParseIP(a.cfg.Gateway).To4()
- for ip := nextIPv4(base); ipNet.Contains(ip); ip = nextIPv4(ip) {
- if isLastIPv4(ip, ipNet) || ip.Equal(gateway) {
- continue
- }
- ipString := ip.String()
- lease, used := leases.Leases[ipString]
- if !used || lease == nil {
- return ipString, ones, nil
- }
- }
- return "", 0, fmt.Errorf("no free IP in %s", a.cfg.CIDR)
-}
-
-func (a *Allocator) allocateMAC(leases *leaseIndex, vmID string) (string, error) {
- for attempts := 0; attempts < 32; attempts++ {
- mac, err := GenerateMAC()
- if err != nil {
- return "", err
- }
- if !macInUseByOtherVM(leases, mac, vmID) {
- return mac, nil
- }
- }
- return "", fmt.Errorf("unable to generate unused MAC")
-}
-
-func (a *Allocator) recoverExisting(leases *leaseIndex, req AllocateRequest) (string, string, string, int, error) {
- existing := req.Existing
- tap := existing.TAP
- if tap == "" {
- tap = TapName(a.cfg.TapPrefix, req.VMID, req.Index)
- }
- if len(tap) > maxInterfaceNameLength {
- return "", "", "", 0, fmt.Errorf("tap name %q exceeds Linux IFNAMSIZ limit", tap)
- }
- if existing.MAC == "" {
- return "", "", "", 0, fmt.Errorf("existing network config is missing MAC")
- }
- if _, err := net.ParseMAC(existing.MAC); err != nil {
- return "", "", "", 0, fmt.Errorf("parse existing MAC: %w", err)
- }
- if macInUseByOtherVM(leases, existing.MAC, req.VMID) {
- return "", "", "", 0, fmt.Errorf("%w: MAC %s is already leased", ErrLeaseConflict, existing.MAC)
- }
- if existing.Network == nil || existing.Network.IP == "" {
- return "", "", "", 0, fmt.Errorf("existing network config is missing IP")
- }
- ip := existing.Network.IP
- parsedIP := net.ParseIP(ip)
- if parsedIP == nil || parsedIP.To4() == nil {
- return "", "", "", 0, fmt.Errorf("existing network IP must be IPv4")
- }
- _, ipNet, err := net.ParseCIDR(a.cfg.CIDR)
- if err != nil {
- return "", "", "", 0, fmt.Errorf("parse network CIDR: %w", err)
- }
- if !ipNet.Contains(parsedIP) {
- return "", "", "", 0, fmt.Errorf("existing network IP %s is outside %s", ip, a.cfg.CIDR)
- }
- if lease, ok := leases.Leases[ip]; ok && lease != nil && lease.VMID != req.VMID {
- return "", "", "", 0, fmt.Errorf("%w: IP %s is owned by VM %s", ErrLeaseConflict, ip, lease.VMID)
- }
- prefix := existing.Network.Prefix
- if prefix == 0 {
- prefix, _ = ipNet.Mask.Size()
- }
- return tap, strings.ToLower(existing.MAC), ip, prefix, nil
-}
-
-func macInUseByOtherVM(leases *leaseIndex, mac, vmID string) bool {
- for _, lease := range leases.Leases {
- if lease != nil && strings.EqualFold(lease.MAC, mac) && lease.VMID != vmID {
- return true
- }
- }
- return false
-}
-
-// TapName returns KumaBox's stable Linux TAP name for a VM interface.
-//
-// Linux interface names are limited to 15 bytes, so the VM identity is hashed
-// into a short suffix instead of embedding the full VM ID.
-func TapName(prefix, vmID string, index int) string {
- if prefix == "" {
- prefix = "kbtap"
- }
- hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", vmID, index)))
- suffix := hex.EncodeToString(hash[:])[:8]
- name := fmt.Sprintf("%s%s", prefix, suffix)
- if len(name) > maxInterfaceNameLength {
- name = name[:maxInterfaceNameLength]
- }
- return name
-}
-
-// NetworkID returns the stable provider record ID for a VM interface.
-func NetworkID(vmID string, index int) string {
- hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", vmID, index)))
- return "net_" + hex.EncodeToString(hash[:])[:16]
-}
-
-// GenerateMAC returns a random locally administered unicast MAC address.
-func GenerateMAC() (string, error) {
- buf := make([]byte, 6)
- if _, err := rand.Read(buf); err != nil {
- return "", fmt.Errorf("generate MAC: %w", err)
- }
- buf[0] = (buf[0] | 0x02) & 0xfe
- return net.HardwareAddr(buf).String(), nil
-}
-
-func validateAllocateRequest(req AllocateRequest) error {
- if req.VMID == "" {
- return fmt.Errorf("vm id must not be empty")
- }
- if req.Index < 0 {
- return fmt.Errorf("network index must be non-negative")
- }
- return nil
-}
-
-func netNumQueues(cpu int) int {
- // Cloud Hypervisor validates virtio-net with a minimum of two queues. For a
- // single vCPU this still maps to one TAP queue pair on the host side.
- if cpu <= 1 {
- return 2
- }
- return cpu * 2
-}
-
-func nextIPv4(ip net.IP) net.IP {
- next := append(net.IP(nil), ip.To4()...)
- for i := len(next) - 1; i >= 0; i-- {
- next[i]++
- if next[i] != 0 {
- break
- }
- }
- return next
-}
-
-func isLastIPv4(ip net.IP, ipNet *net.IPNet) bool {
- next := nextIPv4(ip)
- return !ipNet.Contains(next)
-}
diff --git a/internal/network/allocator_test.go b/internal/network/allocator_test.go
deleted file mode 100644
index b721a53..0000000
--- a/internal/network/allocator_test.go
+++ /dev/null
@@ -1,260 +0,0 @@
-package network
-
-import (
- "errors"
- "net"
- "testing"
-
- "github.com/kumabox/kumabox/internal/config"
-)
-
-func TestAllocatorAllocatesTapMACAndIPLease(t *testing.T) {
- dir := t.TempDir()
- cfg := testNetworkConfig()
-
- allocation, err := NewAllocator(dir, cfg).Allocate(AllocateRequest{
- VMID: "kb_allocator",
- Index: 0,
- CPU: 2,
- })
- if err != nil {
- t.Fatal(err)
- }
-
- if len(allocation.Record.TAP) > maxInterfaceNameLength {
- t.Fatalf("tap length = %d", len(allocation.Record.TAP))
- }
- if allocation.Record.TAP != TapName(cfg.TapPrefix, "kb_allocator", 0) {
- t.Fatalf("tap = %q", allocation.Record.TAP)
- }
- mac, err := net.ParseMAC(allocation.Record.MAC)
- if err != nil {
- t.Fatal(err)
- }
- if mac[0]&0x02 == 0 || mac[0]&0x01 != 0 {
- t.Fatalf("MAC is not locally administered unicast: %s", allocation.Record.MAC)
- }
- if allocation.Config.Network == nil || allocation.Config.Network.IP != "10.88.0.2" {
- t.Fatalf("network config = %+v", allocation.Config.Network)
- }
- if allocation.Config.NumQueues != 4 {
- t.Fatalf("num queues = %d", allocation.Config.NumQueues)
- }
-
- leases, err := NewStore(dir).ListLeases()
- if err != nil {
- t.Fatal(err)
- }
- lease, ok := leases["10.88.0.2"]
- if !ok {
- t.Fatalf("missing lease: %+v", leases)
- }
- if lease.VMID != "kb_allocator" || lease.MAC != allocation.Record.MAC || lease.TAP != allocation.Record.TAP {
- t.Fatalf("lease = %+v", lease)
- }
-}
-
-func TestAllocatorSkipsUsedLeaseAndGateway(t *testing.T) {
- dir := t.TempDir()
- cfg := testNetworkConfig()
- allocator := NewAllocator(dir, cfg)
- first, err := allocator.Allocate(AllocateRequest{VMID: "kb_first", Index: 0})
- if err != nil {
- t.Fatal(err)
- }
- second, err := allocator.Allocate(AllocateRequest{VMID: "kb_second", Index: 0})
- if err != nil {
- t.Fatal(err)
- }
-
- if first.Config.Network.IP != "10.88.0.2" {
- t.Fatalf("first IP = %s", first.Config.Network.IP)
- }
- if second.Config.Network.IP != "10.88.0.3" {
- t.Fatalf("second IP = %s", second.Config.Network.IP)
- }
-}
-
-func TestAllocatorAllocatesDistinctIPsForSameVMInterfaces(t *testing.T) {
- dir := t.TempDir()
- cfg := testNetworkConfig()
- allocator := NewAllocator(dir, cfg)
- first, err := allocator.Allocate(AllocateRequest{VMID: "kb_multi", Index: 0})
- if err != nil {
- t.Fatal(err)
- }
- second, err := allocator.Allocate(AllocateRequest{VMID: "kb_multi", Index: 1})
- if err != nil {
- t.Fatal(err)
- }
-
- if first.Config.Network.IP != "10.88.0.2" {
- t.Fatalf("first IP = %s", first.Config.Network.IP)
- }
- if second.Config.Network.IP != "10.88.0.3" {
- t.Fatalf("second IP = %s", second.Config.Network.IP)
- }
- if first.Record.ID == second.Record.ID || first.Record.TAP == second.Record.TAP {
- t.Fatalf("interfaces should have distinct identities: first=%+v second=%+v", first.Record, second.Record)
- }
-}
-
-func TestAllocatorUsesCloudHypervisorMinimumNetworkQueues(t *testing.T) {
- dir := t.TempDir()
- cfg := testNetworkConfig()
- allocation, err := NewAllocator(dir, cfg).Allocate(AllocateRequest{
- VMID: "kb_one_cpu",
- Index: 0,
- CPU: 1,
- })
- if err != nil {
- t.Fatal(err)
- }
- if allocation.Config.NumQueues != 2 {
- t.Fatalf("num queues = %d, want 2", allocation.Config.NumQueues)
- }
-}
-
-func TestAllocatorRecoverExistingNetworkConfig(t *testing.T) {
- dir := t.TempDir()
- cfg := testNetworkConfig()
- existing := &Config{
- TAP: "kbexist0",
- MAC: "02:00:00:00:00:aa",
- NumQueues: 1,
- QueueSize: defaultQueueSize,
- Backend: ProviderHostTap,
- Network: &GuestInfo{
- IP: "10.88.0.9",
- Gateway: "10.88.0.1",
- Prefix: 16,
- },
- }
-
- allocation, err := NewAllocator(dir, cfg).Allocate(AllocateRequest{
- VMID: "kb_recover",
- Index: 0,
- Existing: existing,
- })
- if err != nil {
- t.Fatal(err)
- }
- if allocation.Record.TAP != existing.TAP {
- t.Fatalf("tap = %s", allocation.Record.TAP)
- }
- if allocation.Record.MAC != existing.MAC {
- t.Fatalf("mac = %s", allocation.Record.MAC)
- }
- if allocation.Config.Network.IP != existing.Network.IP {
- t.Fatalf("ip = %s", allocation.Config.Network.IP)
- }
-
- leases, err := NewStore(dir).ListLeases()
- if err != nil {
- t.Fatal(err)
- }
- if leases["10.88.0.9"].VMID != "kb_recover" {
- t.Fatalf("leases = %+v", leases)
- }
-}
-
-func TestAllocatorRecoversExistingLeaseWhenNetworkIsFull(t *testing.T) {
- dir := t.TempDir()
- cfg := testNetworkConfig()
- cfg.CIDR = "10.88.0.0/30"
- allocator := NewAllocator(dir, cfg)
- original, err := allocator.Allocate(AllocateRequest{VMID: "kb_recover", Index: 0})
- if err != nil {
- t.Fatal(err)
- }
-
- recovered, err := allocator.Allocate(AllocateRequest{
- VMID: "kb_recover", Index: 0, Existing: &original.Config,
- })
- if err != nil {
- t.Fatal(err)
- }
- if recovered.Config.Network.IP != original.Config.Network.IP || recovered.Config.MAC != original.Config.MAC {
- t.Fatalf("recovered identity = %+v, want %+v", recovered.Config, original.Config)
- }
-}
-
-func TestAllocatorRecoverExistingIPConflict(t *testing.T) {
- dir := t.TempDir()
- cfg := testNetworkConfig()
- allocator := NewAllocator(dir, cfg)
- if _, err := allocator.Allocate(AllocateRequest{VMID: "kb_owner", Index: 0}); err != nil {
- t.Fatal(err)
- }
-
- _, err := allocator.Allocate(AllocateRequest{
- VMID: "kb_conflict",
- Index: 0,
- Existing: &Config{
- TAP: "kbconflict0",
- MAC: "02:00:00:00:00:bb",
- Network: &GuestInfo{
- IP: "10.88.0.2",
- Prefix: 16,
- },
- },
- })
- if !errors.Is(err, ErrLeaseConflict) {
- t.Fatalf("err = %v, want ErrLeaseConflict", err)
- }
-}
-
-func TestAllocatorRecoverExistingMACConflict(t *testing.T) {
- dir := t.TempDir()
- cfg := testNetworkConfig()
- allocator := NewAllocator(dir, cfg)
- owner, err := allocator.Allocate(AllocateRequest{VMID: "kb_owner", Index: 0})
- if err != nil {
- t.Fatal(err)
- }
-
- _, err = allocator.Allocate(AllocateRequest{
- VMID: "kb_conflict",
- Index: 0,
- Existing: &Config{
- TAP: "kbconflict1",
- MAC: owner.Config.MAC,
- Network: &GuestInfo{
- IP: "10.88.0.9",
- Prefix: 16,
- },
- },
- })
- if !errors.Is(err, ErrLeaseConflict) {
- t.Fatalf("err = %v, want ErrLeaseConflict", err)
- }
-}
-
-func TestReleaseIPRemovesLease(t *testing.T) {
- dir := t.TempDir()
- cfg := testNetworkConfig()
- allocator := NewAllocator(dir, cfg)
- allocation, err := allocator.Allocate(AllocateRequest{VMID: "kb_release", Index: 0})
- if err != nil {
- t.Fatal(err)
- }
- if err := allocator.ReleaseIP(allocation.Config.Network.IP); err != nil {
- t.Fatal(err)
- }
- leases, err := NewStore(dir).ListLeases()
- if err != nil {
- t.Fatal(err)
- }
- if len(leases) != 0 {
- t.Fatalf("leases = %+v", leases)
- }
-}
-
-func testNetworkConfig() config.NetworkConfig {
- cfg := config.Default().Network
- cfg.CIDR = "10.88.0.0/16"
- cfg.Gateway = "10.88.0.1"
- cfg.TapPrefix = "kbtap"
- cfg.DNS = []string{"1.1.1.1"}
- return cfg
-}
diff --git a/internal/network/attach_linux.go b/internal/network/attach_linux.go
deleted file mode 100644
index 336ae14..0000000
--- a/internal/network/attach_linux.go
+++ /dev/null
@@ -1,115 +0,0 @@
-//go:build linux
-
-package network
-
-import (
- "fmt"
-
- "github.com/vishvananda/netlink"
-)
-
-const (
- tapTxQueueLength = 10000
- tapGROMaxSize = 65536
-)
-
-// AttachHostTap creates a TAP device and enslaves it to the configured bridge.
-//
-// The TAP is created with IFF_NO_PI and vnet_hdr support because Cloud
-// Hypervisor's virtio-net path expects packet frames without Linux's extra
-// packet-info header and benefits from virtio network header offload metadata.
-func AttachHostTap(rec Record) error {
- if rec.TAP == "" {
- return fmt.Errorf("tap name must not be empty")
- }
- if rec.BridgeDev == "" {
- return fmt.Errorf("bridge device must not be empty")
- }
- bridge, err := netlink.LinkByName(rec.BridgeDev)
- if err != nil {
- return fmt.Errorf("find bridge %s: %w", rec.BridgeDev, err)
- }
- tap, created, err := ensureTap(rec)
- if err != nil {
- return err
- }
- if err := netlink.LinkSetMaster(tap, bridge); err != nil {
- if created {
- _ = netlink.LinkDel(tap)
- }
- return fmt.Errorf("attach tap %s to %s: %w", rec.TAP, rec.BridgeDev, err)
- }
- if err := netlink.LinkSetUp(tap); err != nil {
- if created {
- _ = netlink.LinkDel(tap)
- }
- return fmt.Errorf("set tap %s up: %w", rec.TAP, err)
- }
- return nil
-}
-
-// DeleteHostTap removes a per-VM TAP device.
-//
-// The operation is idempotent. VM delete and failure rollback both call it, and
-// a missing device means the desired cleanup state has already been reached.
-func DeleteHostTap(tapName string) error {
- if tapName == "" {
- return nil
- }
- link, err := netlink.LinkByName(tapName)
- if err != nil {
- if isLinkNotFound(err) {
- return nil
- }
- return err
- }
- return netlink.LinkDel(link)
-}
-
-func ensureTap(rec Record) (netlink.Link, bool, error) {
- if link, err := netlink.LinkByName(rec.TAP); err == nil {
- tuneTap(link)
- return link, false, nil
- } else if !isLinkNotFound(err) {
- return nil, false, err
- }
- attrs := netlink.LinkAttrs{Name: rec.TAP}
- tap := &netlink.Tuntap{
- LinkAttrs: attrs,
- Mode: netlink.TUNTAP_MODE_TAP,
- Flags: netlink.TUNTAP_NO_PI | netlink.TUNTAP_VNET_HDR,
- }
- if queuePairs := tapQueuePairs(rec.NumQueues); queuePairs > 1 {
- tap.Queues = queuePairs
- tap.Flags |= netlink.TUNTAP_MULTI_QUEUE_DEFAULTS
- }
- if err := netlink.LinkAdd(tap); err != nil {
- return nil, false, fmt.Errorf("create tap %s: %w", rec.TAP, err)
- }
- for _, fd := range tap.Fds {
- _ = fd.Close()
- }
- link, err := netlink.LinkByName(rec.TAP)
- if err != nil {
- _ = netlink.LinkDel(tap)
- return nil, false, fmt.Errorf("find created tap %s: %w", rec.TAP, err)
- }
- tuneTap(link)
- return link, true, nil
-}
-
-func tuneTap(link netlink.Link) {
- if link == nil {
- return
- }
- // Host tuning is best-effort because Cloud Hypervisor owns the TAP FDs.
- _ = netlink.LinkSetTxQLen(link, tapTxQueueLength)
- _ = netlink.LinkSetGROMaxSize(link, tapGROMaxSize)
-}
-
-func tapQueuePairs(numQueues int) int {
- if numQueues <= 2 {
- return 1
- }
- return numQueues / 2
-}
diff --git a/internal/network/attach_other.go b/internal/network/attach_other.go
deleted file mode 100644
index 39954aa..0000000
--- a/internal/network/attach_other.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build !linux
-
-package network
-
-import (
- "fmt"
- "runtime"
-)
-
-func AttachHostTap(_ Record) error {
- return fmt.Errorf("host-tap attach requires Linux (running on %s)", runtime.GOOS)
-}
-
-func DeleteHostTap(_ string) error {
- return fmt.Errorf("host-tap delete requires Linux (running on %s)", runtime.GOOS)
-}
diff --git a/internal/network/capability.go b/internal/network/capability.go
deleted file mode 100644
index f097f1c..0000000
--- a/internal/network/capability.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package network
-
-import (
- "os"
- "os/exec"
- "runtime"
-)
-
-type CapabilityReport struct {
- OS string
- TunDevice bool
- IPCommand string
- Iptables string
- Nft string
- RootUser bool
- NATBackend string
- Unavailable []string
-}
-
-func CheckCapabilities(natBackend string) CapabilityReport {
- report := CapabilityReport{
- OS: runtime.GOOS,
- NATBackend: natBackend,
- RootUser: os.Geteuid() == 0,
- }
- if runtime.GOOS == "linux" {
- if _, err := os.Stat("/dev/net/tun"); err == nil {
- report.TunDevice = true
- }
- }
- if path, err := exec.LookPath("ip"); err == nil {
- report.IPCommand = path
- }
- if path, err := exec.LookPath("iptables"); err == nil {
- report.Iptables = path
- }
- if path, err := exec.LookPath("nft"); err == nil {
- report.Nft = path
- }
-
- if report.OS != "linux" {
- report.Unavailable = append(report.Unavailable, "linux")
- }
- if !report.TunDevice {
- report.Unavailable = append(report.Unavailable, "tun")
- }
- if report.IPCommand == "" {
- report.Unavailable = append(report.Unavailable, "ip")
- }
- if natBackend != NATBackendNone && report.Iptables == "" && report.Nft == "" {
- report.Unavailable = append(report.Unavailable, "nat")
- }
- if !report.RootUser {
- report.Unavailable = append(report.Unavailable, "root")
- }
- return report
-}
diff --git a/internal/network/cni.go b/internal/network/cni.go
deleted file mode 100644
index c463b3b..0000000
--- a/internal/network/cni.go
+++ /dev/null
@@ -1,371 +0,0 @@
-package network
-
-import (
- "context"
- "fmt"
- "net"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/containernetworking/cni/libcni"
- "github.com/containernetworking/cni/pkg/types"
- types100 "github.com/containernetworking/cni/pkg/types/100"
-
- "github.com/kumabox/kumabox/internal/config"
-)
-
-var (
- prepareCNINetns = prepareCNINetnsLinux
- setupCNIDatapath = setupCNIDatapathLinux
- deleteCNIDatapath = deleteCNIDatapathLinux
- deleteCNINetns = deleteCNINetnsLinux
-)
-
-type CNIAddRequest struct {
- VMID string
- Network string
- Index int
- NetNSPath string
- CPU int
- Existing *Config
-}
-
-type CNIDeleteRequest struct {
- VMID string
- Network string
- IfName string
- TAP string
- NetNSPath string
- PreserveNetNS bool
-}
-
-type cniNetworkConfig struct {
- list *libcni.NetworkConfigList
- net *libcni.NetworkConfig
- name string
-}
-
-type CNIProvider struct {
- rootDir string
- cfg config.NetworkConfig
-}
-
-func NewCNIProvider(rootDir string, cfg config.NetworkConfig) *CNIProvider {
- return &CNIProvider{rootDir: rootDir, cfg: cfg}
-}
-
-func AddCNI(ctx context.Context, rootDir string, cfg config.NetworkConfig, req CNIAddRequest) (*Allocation, error) {
- return NewCNIProvider(rootDir, cfg).Add(ctx, req)
-}
-
-func DeleteCNI(ctx context.Context, rootDir string, cfg config.NetworkConfig, req CNIDeleteRequest) error {
- return NewCNIProvider(rootDir, cfg).Delete(ctx, req)
-}
-
-func DeleteCNINetNS(vmID, netnsPath string) error {
- return deleteCNINetns(vmID, netnsPath)
-}
-
-func (p *CNIProvider) Add(ctx context.Context, req CNIAddRequest) (_ *Allocation, retErr error) {
- if req.VMID == "" {
- return nil, fmt.Errorf("vm id must not be empty")
- }
- networkName := CNIName(req.Network, p.cfg.Default)
- cniConfig, err := loadCNIConfig(p.cfg.CNIConfigDir, networkName)
- if err != nil {
- return nil, err
- }
- requestedNetNS := req.NetNSPath
- if requestedNetNS == "" && req.Existing != nil {
- requestedNetNS = req.Existing.NetnsPath
- }
- netnsPath, createdNetns, err := prepareCNINetns(req.VMID, requestedNetNS)
- if err != nil {
- return nil, err
- }
- defer func() {
- if retErr != nil && createdNetns {
- _ = deleteCNINetns(req.VMID, netnsPath)
- }
- }()
-
- ifName := guestIfName(req.Index)
- tapName := TapName(p.cfg.TapPrefix, req.VMID, req.Index)
- mac, err := GenerateMAC()
- if err != nil {
- return nil, err
- }
- if req.Existing != nil {
- if req.Existing.TAP != "" {
- tapName = req.Existing.TAP
- }
- if req.Existing.MAC != "" {
- mac = strings.ToLower(req.Existing.MAC)
- }
- }
-
- runtimeConf := &libcni.RuntimeConf{
- ContainerID: req.VMID,
- NetNS: netnsPath,
- IfName: ifName,
- Args: cniRuntimeArgs(req.VMID, networkName, req.Existing),
- }
- cni := libcni.NewCNIConfigWithCacheDir(
- []string{p.cfg.CNIBinDir},
- filepath.Join(p.rootDir, "network", "cni-cache"),
- nil,
- )
- result, err := addCNIConfig(ctx, cni, cniConfig, runtimeConf)
- if err != nil {
- return nil, fmt.Errorf("cni add %s for VM %s: %w", networkName, req.VMID, err)
- }
- current, err := types100.GetResult(result)
- if err != nil {
- return nil, fmt.Errorf("parse cni result: %w", err)
- }
- defer func() {
- if retErr != nil {
- _ = delCNIConfig(ctx, cni, cniConfig, runtimeConf)
- }
- }()
-
- guest := guestInfoFromCNIResult(current)
- if err := validateRecoveredCNIIdentity(req.Existing, guest); err != nil {
- return nil, err
- }
- if resultMAC := macFromCNIResult(current, ifName); resultMAC != "" &&
- (req.Existing == nil || req.Existing.MAC == "") {
- mac = resultMAC
- }
- mac, err = setupCNIDatapath(netnsPath, ifName, tapName, netNumQueues(req.CPU), mac)
- if err != nil {
- return nil, fmt.Errorf("setup cni datapath for VM %s: %w", req.VMID, err)
- }
- now := time.Now().UTC()
- record := Record{
- ID: NetworkID(req.VMID, req.Index),
- VMID: req.VMID,
- Network: req.Network,
- Provider: ProviderCNI,
- IfName: ifName,
- TAP: tapName,
- MAC: mac,
- NumQueues: netNumQueues(req.CPU),
- QueueSize: defaultQueueSize,
- NetnsPath: netnsPath,
- Gateway: guestGateway(guest),
- DNS: guestDNS(guest),
- Cleanup: Cleanup{},
- CreatedAt: now,
- UpdatedAt: now,
- }
- if guest != nil && guest.IP != "" {
- record.IPs = []string{fmt.Sprintf("%s/%d", guest.IP, guest.Prefix)}
- }
- vmConfig := Config{
- ID: record.ID,
- NetworkName: record.Network,
- TAP: record.TAP,
- MAC: record.MAC,
- NumQueues: record.NumQueues,
- QueueSize: record.QueueSize,
- Backend: record.Provider,
- IfName: record.IfName,
- NetnsPath: record.NetnsPath,
- Network: guest,
- }
- return &Allocation{Record: record, Config: vmConfig}, nil
-}
-
-func (p *CNIProvider) Delete(ctx context.Context, req CNIDeleteRequest) error {
- if req.VMID == "" {
- return fmt.Errorf("vm id must not be empty")
- }
- networkName := CNIName(req.Network, p.cfg.Default)
- cniConfig, err := loadCNIConfig(p.cfg.CNIConfigDir, networkName)
- if err != nil {
- return err
- }
- if req.IfName == "" {
- return fmt.Errorf("cni interface name must not be empty")
- }
- netnsPath := req.NetNSPath
- if netnsPath == "" {
- netnsPath = NetNSPath(req.VMID)
- }
- runtimeConf := &libcni.RuntimeConf{
- ContainerID: req.VMID,
- NetNS: netnsPath,
- IfName: req.IfName,
- Args: cniRuntimeArgs(req.VMID, networkName, nil),
- }
- cni := libcni.NewCNIConfigWithCacheDir(
- []string{p.cfg.CNIBinDir},
- filepath.Join(p.rootDir, "network", "cni-cache"),
- nil,
- )
- if err := delCNIConfig(ctx, cni, cniConfig, runtimeConf); err != nil {
- return fmt.Errorf("cni del %s for VM %s: %w", networkName, req.VMID, err)
- }
- tapName := req.TAP
- if tapName == "" && strings.HasPrefix(req.IfName, p.cfg.TapPrefix) {
- tapName = req.IfName
- }
- if err := deleteCNIDatapath(netnsPath, tapName); err != nil {
- return fmt.Errorf("delete cni datapath for VM %s: %w", req.VMID, err)
- }
- if !req.PreserveNetNS {
- if err := deleteCNINetns(req.VMID, netnsPath); err != nil {
- return fmt.Errorf("delete cni netns for VM %s: %w", req.VMID, err)
- }
- }
- return nil
-}
-
-func cniRuntimeArgs(vmID, networkName string, existing *Config) [][2]string {
- args := [][2]string{
- {"IgnoreUnknown", "1"},
- {"KUMABOX_VM_ID", vmID},
- {"KUMABOX_NETWORK", networkName},
- }
- if existing != nil && existing.Network != nil && existing.Network.IP != "" {
- args = append(args, [2]string{"IP", existing.Network.IP})
- }
- return args
-}
-
-func validateRecoveredCNIIdentity(existing *Config, guest *GuestInfo) error {
- if existing == nil || existing.Network == nil || existing.Network.IP == "" {
- return nil
- }
- if guest == nil || guest.IP != existing.Network.IP {
- actual := ""
- if guest != nil {
- actual = guest.IP
- }
- return fmt.Errorf("%w: CNI recovery returned IP %q, want %q", ErrNetworkConflict, actual, existing.Network.IP)
- }
- if existing.Network.Prefix != 0 && guest.Prefix != existing.Network.Prefix {
- return fmt.Errorf("%w: CNI recovery returned prefix %d, want %d", ErrNetworkConflict,
- guest.Prefix, existing.Network.Prefix)
- }
- return nil
-}
-
-func CNIName(network, fallback string) string {
- if strings.HasPrefix(network, "cni:") {
- name := strings.TrimPrefix(network, "cni:")
- if name != "" {
- return name
- }
- }
- if network == ProviderCNI && fallback != "" {
- return fallback
- }
- if network != "" && network != ProviderCNI {
- return network
- }
- if fallback != "" {
- return fallback
- }
- return "default"
-}
-
-func IsCNISelection(network string) bool {
- return network == ProviderCNI || strings.HasPrefix(network, "cni:")
-}
-
-func guestIfName(index int) string {
- return GuestInterfaceName(index)
-}
-
-func loadCNIConfig(configDir, name string) (*cniNetworkConfig, error) {
- if configDir == "" {
- return nil, fmt.Errorf("cni config dir must not be empty")
- }
- if name == "" {
- return nil, fmt.Errorf("cni network name must not be empty")
- }
- if list, err := libcni.LoadConfList(configDir, name); err == nil {
- return &cniNetworkConfig{list: list, name: list.Name}, nil
- }
- netConf, err := libcni.LoadConf(configDir, name)
- if err != nil {
- return nil, fmt.Errorf("load cni config %q from %s: %w", name, configDir, err)
- }
- return &cniNetworkConfig{net: netConf, name: netConf.Network.Name}, nil
-}
-
-func addCNIConfig(
- ctx context.Context,
- cni *libcni.CNIConfig,
- config *cniNetworkConfig,
- runtimeConf *libcni.RuntimeConf,
-) (types.Result, error) {
- if config.list != nil {
- return cni.AddNetworkList(ctx, config.list, runtimeConf)
- }
- return cni.AddNetwork(ctx, config.net, runtimeConf)
-}
-
-func delCNIConfig(
- ctx context.Context,
- cni *libcni.CNIConfig,
- config *cniNetworkConfig,
- runtimeConf *libcni.RuntimeConf,
-) error {
- if config.list != nil {
- return cni.DelNetworkList(ctx, config.list, runtimeConf)
- }
- return cni.DelNetwork(ctx, config.net, runtimeConf)
-}
-
-func guestInfoFromCNIResult(result *types100.Result) *GuestInfo {
- if result == nil || len(result.IPs) == 0 || result.IPs[0] == nil {
- return nil
- }
- ipConfig := result.IPs[0]
- ip := ipConfig.Address.IP.To4()
- if ip == nil {
- return nil
- }
- prefix, _ := ipConfig.Address.Mask.Size()
- guest := &GuestInfo{
- IP: ip.String(),
- Prefix: prefix,
- DNS: append([]string(nil), result.DNS.Nameservers...),
- }
- if ipConfig.Gateway != nil {
- guest.Gateway = ipConfig.Gateway.String()
- }
- return guest
-}
-
-func macFromCNIResult(result *types100.Result, ifName string) string {
- if result == nil {
- return ""
- }
- for _, intf := range result.Interfaces {
- if intf != nil && intf.Name == ifName && intf.Mac != "" {
- if _, err := net.ParseMAC(intf.Mac); err == nil {
- return strings.ToLower(intf.Mac)
- }
- }
- }
- return ""
-}
-
-func guestGateway(guest *GuestInfo) string {
- if guest == nil {
- return ""
- }
- return guest.Gateway
-}
-
-func guestDNS(guest *GuestInfo) []string {
- if guest == nil {
- return nil
- }
- return append([]string(nil), guest.DNS...)
-}
diff --git a/internal/network/cni_linux.go b/internal/network/cni_linux.go
deleted file mode 100644
index 14df190..0000000
--- a/internal/network/cni_linux.go
+++ /dev/null
@@ -1,243 +0,0 @@
-//go:build linux
-
-package network
-
-import (
- "errors"
- "fmt"
- "io/fs"
- "net"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "syscall"
- "time"
-
- "github.com/kumabox/kumabox/internal/fileutil"
- "github.com/vishvananda/netlink"
- "github.com/vishvananda/netns"
-)
-
-const cniPollInterval = 100 * time.Millisecond
-
-const netnsDir = "/var/run/netns"
-
-func NetNSPath(vmID string) string {
- return filepath.Join(netnsDir, vmID)
-}
-
-func prepareCNINetnsLinux(vmID, requestedPath string) (string, bool, error) {
- nsPath := NetNSPath(vmID)
- if requestedPath != "" {
- nsPath = requestedPath
- }
- if _, err := os.Stat(nsPath); err == nil {
- return nsPath, false, nil
- } else if !errors.Is(err, fs.ErrNotExist) {
- return "", false, fmt.Errorf("stat netns %s: %w", nsPath, err)
- }
- if nsPath != NetNSPath(vmID) {
- return "", false, fmt.Errorf("missing CNI netns path %s is not managed by VM %s", nsPath, vmID)
- }
- if err := os.MkdirAll(netnsDir, 0o755); err != nil {
- return "", false, fmt.Errorf("create netns dir: %w", err)
- }
- if err := createNamedNetns(vmID); err != nil {
- return "", false, err
- }
- return nsPath, true, nil
-}
-
-func setupCNIDatapathLinux(nsPath, ifName, tapName string, queues int, overrideMAC string) (string, error) {
- var mac string
- err := withNetNSPath(nsPath, func() error {
- var err error
- mac, err = setupCNIDatapathInNS(ifName, tapName, queues, overrideMAC)
- return err
- })
- return mac, err
-}
-
-func deleteCNIDatapathLinux(nsPath, tapName string) error {
- if nsPath == "" || tapName == "" {
- return nil
- }
- if _, err := os.Stat(nsPath); errors.Is(err, fs.ErrNotExist) {
- return nil
- }
- return withNetNSPath(nsPath, func() error {
- return DeleteHostTap(tapName)
- })
-}
-
-func deleteCNINetnsLinux(vmID, nsPath string) error {
- if vmID == "" || nsPath == "" || nsPath != NetNSPath(vmID) {
- return nil
- }
- deadline := time.Now().Add(time.Second)
- for {
- err := netns.DeleteNamed(vmID)
- if err == nil || errors.Is(err, fs.ErrNotExist) {
- return nil
- }
- if time.Now().After(deadline) {
- return err
- }
- time.Sleep(cniPollInterval)
- }
-}
-
-func createNamedNetns(name string) (err error) {
- runtime.LockOSThread()
- defer runtime.UnlockOSThread()
-
- origNS, err := netns.Get()
- if err != nil {
- return fmt.Errorf("get current netns: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, &origNS, "close original network namespace")
-
- ns, err := netns.NewNamed(name)
- if err != nil {
- return fmt.Errorf("create netns %s: %w", name, err)
- }
- fileutil.CloseAndJoin(&err, &ns, "close created network namespace")
- if err := netns.Set(origNS); err != nil {
- return fmt.Errorf("restore netns: %w", err)
- }
- return nil
-}
-
-func withNetNSPath(path string, fn func() error) (err error) {
- runtime.LockOSThread()
- defer runtime.UnlockOSThread()
-
- origNS, err := netns.Get()
- if err != nil {
- return fmt.Errorf("get current netns: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, &origNS, "close original network namespace")
-
- targetNS, err := netns.GetFromPath(path)
- if err != nil {
- return fmt.Errorf("open netns %s: %w", path, err)
- }
- defer fileutil.CloseAndJoin(&err, &targetNS, "close target network namespace")
-
- if err := netns.Set(targetNS); err != nil {
- return fmt.Errorf("enter netns %s: %w", path, err)
- }
- defer func() {
- _ = netns.Set(origNS)
- }()
- return fn()
-}
-
-func setupCNIDatapathInNS(ifName, tapName string, queues int, overrideMAC string) (string, error) {
- link, err := netlink.LinkByName(ifName)
- if err != nil {
- return "", fmt.Errorf("find cni link %s: %w", ifName, err)
- }
- if overrideMAC != "" {
- hwAddr, parseErr := net.ParseMAC(overrideMAC)
- if parseErr != nil {
- return "", fmt.Errorf("parse MAC %s: %w", overrideMAC, parseErr)
- }
- if err := netlink.LinkSetHardwareAddr(link, hwAddr); err != nil {
- return "", fmt.Errorf("set MAC on %s: %w", ifName, err)
- }
- }
- mac := strings.ToLower(link.Attrs().HardwareAddr.String())
- if overrideMAC != "" {
- mac = strings.ToLower(overrideMAC)
- }
- if err := flushLinkAddresses(link); err != nil {
- return "", err
- }
-
- tap, created, err := ensureTap(Record{TAP: tapName, NumQueues: queues})
- if err != nil {
- return "", err
- }
- if created {
- defer func() {
- if err != nil {
- _ = netlink.LinkDel(tap)
- }
- }()
- }
- if mtu := link.Attrs().MTU; mtu > 0 {
- if err := netlink.LinkSetMTU(tap, mtu); err != nil {
- return "", fmt.Errorf("set tap %s mtu %d: %w", tapName, mtu, err)
- }
- }
- for _, l := range []netlink.Link{link, tap} {
- if err := netlink.LinkSetUp(l); err != nil {
- return "", fmt.Errorf("set %s up: %w", l.Attrs().Name, err)
- }
- }
- for _, l := range []netlink.Link{link, tap} {
- if err := ensureIngressQdisc(l); err != nil {
- return "", err
- }
- }
- if err := addTCRedirect(link, tap); err != nil {
- return "", fmt.Errorf("redirect %s -> %s: %w", ifName, tapName, err)
- }
- if err := addTCRedirect(tap, link); err != nil {
- return "", fmt.Errorf("redirect %s -> %s: %w", tapName, ifName, err)
- }
- return mac, nil
-}
-
-func flushLinkAddresses(link netlink.Link) error {
- addrs, err := netlink.AddrList(link, netlink.FAMILY_ALL)
- if err != nil {
- return fmt.Errorf("list addrs on %s: %w", link.Attrs().Name, err)
- }
- for _, addr := range addrs {
- if err := netlink.AddrDel(link, &addr); err != nil {
- return fmt.Errorf("flush addr %s on %s: %w", addr.IPNet, link.Attrs().Name, err)
- }
- }
- return nil
-}
-
-func ensureIngressQdisc(link netlink.Link) error {
- qdisc := &netlink.Ingress{
- QdiscAttrs: netlink.QdiscAttrs{
- LinkIndex: link.Attrs().Index,
- Parent: netlink.HANDLE_INGRESS,
- },
- }
- if err := netlink.QdiscAdd(qdisc); err != nil && !os.IsExist(err) {
- return fmt.Errorf("add ingress qdisc on %s: %w", link.Attrs().Name, err)
- }
- return nil
-}
-
-func addTCRedirect(from, to netlink.Link) error {
- filter := &netlink.U32{
- FilterAttrs: netlink.FilterAttrs{
- LinkIndex: from.Attrs().Index,
- Parent: netlink.HANDLE_INGRESS,
- Priority: 1,
- Protocol: syscall.ETH_P_ALL,
- },
- Sel: &netlink.TcU32Sel{
- Flags: netlink.TC_U32_TERMINAL,
- Keys: []netlink.TcU32Key{
- {Mask: 0x0, Val: 0x0, Off: 0, OffMask: 0x0},
- },
- },
- Actions: []netlink.Action{
- &netlink.MirredAction{
- ActionAttrs: netlink.ActionAttrs{Action: netlink.TC_ACT_STOLEN},
- MirredAction: netlink.TCA_EGRESS_REDIR,
- Ifindex: to.Attrs().Index,
- },
- },
- }
- return netlink.FilterAdd(filter)
-}
diff --git a/internal/network/cni_linux_test.go b/internal/network/cni_linux_test.go
deleted file mode 100644
index 48c6667..0000000
--- a/internal/network/cni_linux_test.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build linux
-
-package network
-
-import (
- "strings"
- "testing"
-)
-
-func TestPrepareCNINetnsRejectsMissingUnmanagedPath(t *testing.T) {
- path := t.TempDir() + "/missing"
- _, _, err := prepareCNINetnsLinux("kb_test", path)
- if err == nil || !strings.Contains(err.Error(), "is not managed") {
- t.Fatalf("prepare error = %v, want unmanaged path rejection", err)
- }
-}
diff --git a/internal/network/cni_other.go b/internal/network/cni_other.go
deleted file mode 100644
index 11a4bd5..0000000
--- a/internal/network/cni_other.go
+++ /dev/null
@@ -1,28 +0,0 @@
-//go:build !linux
-
-package network
-
-import (
- "fmt"
- "runtime"
-)
-
-func NetNSPath(vmID string) string {
- return vmID
-}
-
-func prepareCNINetnsLinux(_, _ string) (string, bool, error) {
- return "", false, fmt.Errorf("cni networking requires Linux (running on %s)", runtime.GOOS)
-}
-
-func setupCNIDatapathLinux(_, _, _ string, _ int, _ string) (string, error) {
- return "", fmt.Errorf("cni networking requires Linux (running on %s)", runtime.GOOS)
-}
-
-func deleteCNIDatapathLinux(_, _ string) error {
- return fmt.Errorf("cni networking requires Linux (running on %s)", runtime.GOOS)
-}
-
-func deleteCNINetnsLinux(_, _ string) error {
- return fmt.Errorf("cni networking requires Linux (running on %s)", runtime.GOOS)
-}
diff --git a/internal/network/cni_test.go b/internal/network/cni_test.go
deleted file mode 100644
index 471c746..0000000
--- a/internal/network/cni_test.go
+++ /dev/null
@@ -1,223 +0,0 @@
-package network
-
-import (
- "context"
- "errors"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/kumabox/kumabox/internal/config"
-)
-
-func TestAddCNICallsPluginAndParsesResult(t *testing.T) {
- dir := t.TempDir()
- cfg, logPath := writeTestCNIConfig(t, dir, false)
- withCNIDatapath(t)
-
- allocation, err := AddCNI(context.Background(), dir, cfg, CNIAddRequest{
- VMID: "kb_cni",
- Network: "cni:default",
- Index: 0,
- CPU: 1,
- })
- if err != nil {
- t.Fatal(err)
- }
- if allocation.Record.Provider != ProviderCNI {
- t.Fatalf("provider = %s", allocation.Record.Provider)
- }
- if allocation.Record.Network != "cni:default" {
- t.Fatalf("network = %s", allocation.Record.Network)
- }
- if allocation.Record.TAP == "" || allocation.Record.TAP != allocation.Config.TAP {
- t.Fatalf("tap mismatch: record=%s config=%s", allocation.Record.TAP, allocation.Config.TAP)
- }
- if allocation.Record.IfName != "eth0" || allocation.Config.IfName != "eth0" {
- t.Fatalf("ifname mismatch: record=%s config=%s", allocation.Record.IfName, allocation.Config.IfName)
- }
- if allocation.Record.NetnsPath != NetNSPath("kb_cni") || allocation.Config.NetnsPath != NetNSPath("kb_cni") {
- t.Fatalf("netns mismatch: record=%s config=%s", allocation.Record.NetnsPath, allocation.Config.NetnsPath)
- }
- if allocation.Config.Backend != ProviderCNI {
- t.Fatalf("config backend = %s", allocation.Config.Backend)
- }
- if allocation.Config.Network == nil || allocation.Config.Network.IP != "10.244.0.2" {
- t.Fatalf("guest network = %+v", allocation.Config.Network)
- }
- if allocation.Config.Network.Gateway != "10.244.0.1" || allocation.Config.Network.Prefix != 24 {
- t.Fatalf("guest network = %+v", allocation.Config.Network)
- }
- raw, err := os.ReadFile(logPath)
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(raw), "ADD kb_cni eth0 "+NetNSPath("kb_cni")) {
- t.Fatalf("plugin log = %s", raw)
- }
-}
-
-func TestAddCNIReusesExistingIdentity(t *testing.T) {
- dir := t.TempDir()
- cfg, _ := writeTestCNIConfig(t, dir, false)
- withCNIDatapath(t)
- existing := Config{
- TAP: "persisted-tap", MAC: "5a:00:00:00:00:77", NetnsPath: "/persisted/netns",
- Network: &GuestInfo{IP: "10.244.0.2", Gateway: "10.244.0.1", Prefix: 24},
- }
-
- allocation, err := AddCNI(context.Background(), dir, cfg, CNIAddRequest{
- VMID: "kb_recover", Network: "cni:default", Index: 0, CPU: 1, Existing: &existing,
- })
- if err != nil {
- t.Fatal(err)
- }
- if allocation.Config.TAP != existing.TAP || allocation.Config.MAC != existing.MAC ||
- allocation.Config.NetnsPath != existing.NetnsPath || allocation.Config.Network.IP != existing.Network.IP {
- t.Fatalf("recovered config = %+v, want identity from %+v", allocation.Config, existing)
- }
- args := cniRuntimeArgs("kb_recover", "default", &existing)
- if got := args[len(args)-1]; got != [2]string{"IP", existing.Network.IP} {
- t.Fatalf("recovery CNI args = %+v", args)
- }
-}
-
-func TestValidateRecoveredCNIIdentityRejectsChangedIP(t *testing.T) {
- existing := &Config{Network: &GuestInfo{IP: "10.244.0.2", Prefix: 24}}
- err := validateRecoveredCNIIdentity(existing, &GuestInfo{IP: "10.244.0.3", Prefix: 24})
- if !errors.Is(err, ErrNetworkConflict) {
- t.Fatalf("validation error = %v, want network conflict", err)
- }
-}
-
-func TestDeleteCNICallsPlugin(t *testing.T) {
- dir := t.TempDir()
- cfg, logPath := writeTestCNIConfig(t, dir, false)
- withCNIDatapath(t)
-
- if err := DeleteCNI(context.Background(), dir, cfg, CNIDeleteRequest{
- VMID: "kb_cni",
- Network: "cni:default",
- IfName: "eth0",
- TAP: "kbtapcni0",
- NetNSPath: NetNSPath("kb_cni"),
- }); err != nil {
- t.Fatal(err)
- }
- raw, err := os.ReadFile(logPath)
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(raw), "DEL kb_cni eth0 "+NetNSPath("kb_cni")) {
- t.Fatalf("plugin log = %s", raw)
- }
-}
-
-func TestDeleteCNIReportsPluginFailure(t *testing.T) {
- dir := t.TempDir()
- cfg, _ := writeTestCNIConfig(t, dir, true)
- withCNIDatapath(t)
-
- err := DeleteCNI(context.Background(), dir, cfg, CNIDeleteRequest{
- VMID: "kb_cni",
- Network: "cni:default",
- IfName: "eth0",
- TAP: "kbtapcni0",
- NetNSPath: NetNSPath("kb_cni"),
- })
- if err == nil || !strings.Contains(err.Error(), "forced del failure") {
- t.Fatalf("delete error = %v", err)
- }
-}
-
-func withCNIDatapath(t *testing.T) {
- t.Helper()
- oldPrepare := prepareCNINetns
- oldSetup := setupCNIDatapath
- oldDeleteDatapath := deleteCNIDatapath
- oldDeleteNetns := deleteCNINetns
- prepareCNINetns = func(vmID, requestedPath string) (string, bool, error) {
- if requestedPath != "" {
- return requestedPath, false, nil
- }
- return NetNSPath(vmID), true, nil
- }
- setupCNIDatapath = func(_ string, _ string, _ string, _ int, mac string) (string, error) {
- return mac, nil
- }
- deleteCNIDatapath = func(_, _ string) error {
- return nil
- }
- deleteCNINetns = func(_, _ string) error {
- return nil
- }
- t.Cleanup(func() {
- prepareCNINetns = oldPrepare
- setupCNIDatapath = oldSetup
- deleteCNIDatapath = oldDeleteDatapath
- deleteCNINetns = oldDeleteNetns
- })
-}
-
-func TestAddCNIFailsWhenConfigMissing(t *testing.T) {
- dir := t.TempDir()
- cfg := config.Default().Network
- cfg.CNIConfigDir = filepath.Join(dir, "missing")
- cfg.CNIBinDir = filepath.Join(dir, "bin")
-
- _, err := AddCNI(context.Background(), dir, cfg, CNIAddRequest{
- VMID: "kb_cni",
- Network: "cni:missing",
- })
- if err == nil || !strings.Contains(err.Error(), "load cni config") {
- t.Fatalf("add error = %v", err)
- }
-}
-
-func writeTestCNIConfig(t *testing.T, dir string, failDel bool) (config.NetworkConfig, string) {
- t.Helper()
- cfg := config.Default().Network
- cfg.CNIConfigDir = filepath.Join(dir, "net.d")
- cfg.CNIBinDir = filepath.Join(dir, "bin")
- if err := os.MkdirAll(cfg.CNIConfigDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(cfg.CNIBinDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(cfg.CNIConfigDir, "default.conf"), []byte(`{
- "cniVersion": "1.0.0",
- "name": "default",
- "type": "kumabox-test"
-}`), 0o644); err != nil {
- t.Fatal(err)
- }
- logPath := filepath.Join(dir, "cni.log")
- delFailure := ""
- if failDel {
- delFailure = "echo forced del failure >&2\nexit 7"
- }
- plugin := `#!/bin/sh
-set -eu
-cat >/dev/null
-case ";${CNI_ARGS:-};" in
- *";IgnoreUnknown=1;"*) ;;
- *) echo "ARGS: unknown KumaBox args without IgnoreUnknown=1" >&2; exit 2 ;;
-esac
-printf '%s %s %s %s\n' "$CNI_COMMAND" "$CNI_CONTAINERID" "$CNI_IFNAME" "$CNI_NETNS" >> "` + logPath + `"
-if [ "$CNI_COMMAND" = "ADD" ]; then
- printf '{"cniVersion":"1.0.0","interfaces":[{"name":"%s","mac":"5a:00:00:00:00:44","sandbox":"%s"}],"ips":[{"address":"10.244.0.2/24","gateway":"10.244.0.1","interface":0}],"dns":{"nameservers":["1.1.1.1"]}}\n' "$CNI_IFNAME" "$CNI_NETNS"
- exit 0
-fi
-if [ "$CNI_COMMAND" = "DEL" ]; then
-` + delFailure + `
- exit 0
-fi
-exit 0
-`
- if err := os.WriteFile(filepath.Join(cfg.CNIBinDir, "kumabox-test"), []byte(plugin), 0o755); err != nil {
- t.Fatal(err)
- }
- return cfg, logPath
-}
diff --git a/internal/network/hosttap_codec.go b/internal/network/hosttap_codec.go
deleted file mode 100644
index a5f819f..0000000
--- a/internal/network/hosttap_codec.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package network
-
-import (
- stdjson "encoding/json"
- "fmt"
-
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-const hostTapTable = "host-tap"
-const hostTapRecord = "root"
-
-type hostTapCodec struct{}
-
-func (hostTapCodec) Decode(raw []byte) (*metajson.Model, error) {
- model := metajson.NewModel()
- if len(raw) == 0 {
- return model, nil
- }
- var state HostTapState
- if err := stdjson.Unmarshal(raw, &state); err != nil {
- return nil, fmt.Errorf("parse host-tap state: %w", err)
- }
- if state.SchemaVersion != "" && state.SchemaVersion != hostTapSchemaVersion {
- return nil, fmt.Errorf("unsupported host-tap schema %q", state.SchemaVersion)
- }
- encoded, err := stdjson.Marshal(state)
- if err != nil {
- return nil, fmt.Errorf("encode host-tap state record: %w", err)
- }
- model.Tables[hostTapTable] = map[string]stdjson.RawMessage{hostTapRecord: encoded}
- return model, nil
-}
-
-func (hostTapCodec) Encode(model *metajson.Model) ([]byte, error) {
- if model == nil {
- return nil, fmt.Errorf("host-tap metadata model must not be nil")
- }
- raw := model.Tables[hostTapTable][hostTapRecord]
- if len(raw) == 0 {
- return nil, fmt.Errorf("host-tap state is absent")
- }
- var state HostTapState
- if err := stdjson.Unmarshal(raw, &state); err != nil {
- return nil, fmt.Errorf("parse host-tap state record: %w", err)
- }
- if state.SchemaVersion == "" {
- state.SchemaVersion = hostTapSchemaVersion
- }
- return stdjson.MarshalIndent(state, "", " ")
-}
diff --git a/internal/network/hosttap_linux.go b/internal/network/hosttap_linux.go
deleted file mode 100644
index e7833c6..0000000
--- a/internal/network/hosttap_linux.go
+++ /dev/null
@@ -1,423 +0,0 @@
-//go:build linux
-
-package network
-
-import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "net"
- "os/exec"
- "path/filepath"
- "strings"
- "syscall"
- "time"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/vishvananda/netlink"
-)
-
-const (
- nftTable = "kumabox"
- nftChain = "postrouting"
-)
-
-type commandRunner interface {
- Run(ctx context.Context, name string, args ...string) ([]byte, error)
-}
-
-type execRunner struct{}
-
-func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
- cmd := exec.CommandContext(ctx, name, args...)
- out, err := cmd.CombinedOutput()
- if err != nil {
- return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
- }
- return out, nil
-}
-
-// EnsureHostTap creates or reconciles the global host-tap bridge.
-//
-// The bridge, gateway address, IP forwarding, and NAT rule are shared by VMs
-// under one KumaBox root. Ownership is recorded on disk so another root cannot
-// accidentally tear down or reconfigure the same host device.
-func EnsureHostTap(ctx context.Context, rootDir string, cfg config.NetworkConfig) (*HostTapReport, error) {
- return EnsureHostTapWithStore(ctx, rootDir, cfg, NewStore(rootDir))
-}
-
-// EnsureHostTapWithStore reconciles host-tap state in the caller's metadata
-// backend instead of opening an independent JSON store.
-func EnsureHostTapWithStore(
- ctx context.Context,
- rootDir string,
- cfg config.NetworkConfig,
- store *Store,
-) (*HostTapReport, error) {
- return ensureHostTap(ctx, rootDir, cfg, store, execRunner{})
-}
-
-// TeardownHostTap removes the global host-tap bridge and NAT rule.
-//
-// Teardown refuses to run while HostTapState.RefCount is non-zero. VM delete is
-// responsible for deleting per-VM taps and decrementing that reference count.
-func TeardownHostTap(ctx context.Context, rootDir string, cfg config.NetworkConfig) (*HostTapReport, error) {
- return teardownHostTap(ctx, rootDir, cfg, execRunner{})
-}
-
-func ensureHostTap(
- ctx context.Context,
- rootDir string,
- cfg config.NetworkConfig,
- store *Store,
- runner commandRunner,
-) (*HostTapReport, error) {
- if err := validateHostTapConfig(cfg); err != nil {
- return nil, err
- }
- rootDir, err := filepath.Abs(rootDir)
- if err != nil {
- return nil, fmt.Errorf("resolve root dir: %w", err)
- }
-
- var report *HostTapReport
- err = store.withHostTap(true, func(current **HostTapState) error {
- state := *current
- if err := validateHostTapOwnership(rootDir, cfg, state); err != nil {
- return err
- }
- report = &HostTapReport{Bridge: cfg.Bridge, CIDR: cfg.CIDR, Gateway: cfg.Gateway, NATBackend: cfg.NATBackend}
- created, err := ensureBridge(cfg)
- if err != nil {
- return err
- }
- if created {
- report.Created = true
- report.Changed = append(report.Changed, "bridge")
- }
- if changed, err := ensureGateway(cfg); err != nil {
- return err
- } else if changed {
- report.Changed = append(report.Changed, "gateway")
- }
- if err := setBridgeUp(cfg.Bridge); err != nil {
- return err
- }
- if err := ensureIPForward(ctx, runner); err != nil {
- return err
- }
- if backend, changed, err := ensureNAT(ctx, runner, cfg); err != nil {
- return err
- } else {
- report.NATBackend = backend
- if changed {
- report.Changed = append(report.Changed, "nat")
- }
- }
-
- now := time.Now().UTC()
- if state == nil {
- state = &HostTapState{CreatedAt: now}
- }
- state.SchemaVersion = hostTapSchemaVersion
- state.Bridge = cfg.Bridge
- state.CIDR = cfg.CIDR
- state.Gateway = cfg.Gateway
- state.NATBackend = report.NATBackend
- state.Owner = Owner{Kind: "kumabox", RootDir: rootDir}
- state.UpdatedAt = now
- if state.CreatedAt.IsZero() {
- state.CreatedAt = now
- }
- *current = state
- report.State = state
- return nil
- })
- if err != nil {
- return nil, err
- }
- return report, nil
-}
-
-func teardownHostTap(ctx context.Context, rootDir string, cfg config.NetworkConfig, runner commandRunner) (*HostTapReport, error) {
- rootDir, err := filepath.Abs(rootDir)
- if err != nil {
- return nil, fmt.Errorf("resolve root dir: %w", err)
- }
- store := NewStore(rootDir)
- var report *HostTapReport
- err = store.withHostTap(true, func(current **HostTapState) error {
- state := *current
- if state == nil {
- report = &HostTapReport{Bridge: cfg.Bridge, CIDR: cfg.CIDR, Gateway: cfg.Gateway, NATBackend: cfg.NATBackend}
- return nil
- }
- if state.Owner.Kind != "kumabox" || state.Owner.RootDir != rootDir {
- return fmt.Errorf("%w: host-tap state is owned by %s at %s", ErrNetworkConflict, state.Owner.Kind, state.Owner.RootDir)
- }
- if state.RefCount > 0 {
- return fmt.Errorf("%w: host-tap network still has %d reference(s)", ErrNetworkConflict, state.RefCount)
- }
-
- report = &HostTapReport{
- Bridge: state.Bridge,
- CIDR: state.CIDR,
- Gateway: state.Gateway,
- NATBackend: state.NATBackend,
- State: state,
- }
- if err := removeNAT(ctx, runner, state.NATBackend, state.CIDR); err != nil {
- return err
- }
- report.Changed = append(report.Changed, "nat")
- if exists, err := bridgeExists(state.Bridge); err != nil {
- return err
- } else if exists {
- if err := deleteBridge(state.Bridge); err != nil {
- return err
- }
- report.Changed = append(report.Changed, "bridge")
- }
- *current = nil
- return nil
- })
- if err != nil {
- return nil, err
- }
- return report, nil
-}
-
-func validateHostTapOwnership(rootDir string, cfg config.NetworkConfig, state *HostTapState) error {
- exists, err := bridgeExists(cfg.Bridge)
- if err != nil {
- return err
- }
- if state == nil {
- if exists {
- return fmt.Errorf("%w: bridge %s already exists without KumaBox owner state", ErrNetworkConflict, cfg.Bridge)
- }
- return nil
- }
- if state.Owner.Kind != "kumabox" || state.Owner.RootDir != rootDir {
- return fmt.Errorf("%w: bridge %s is owned by %s at %s", ErrNetworkConflict, cfg.Bridge, state.Owner.Kind, state.Owner.RootDir)
- }
- if state.Bridge != "" && state.Bridge != cfg.Bridge {
- return fmt.Errorf("%w: host-tap state bridge %s does not match configured bridge %s", ErrNetworkConflict, state.Bridge, cfg.Bridge)
- }
- return nil
-}
-
-func validateHostTapConfig(cfg config.NetworkConfig) error {
- if cfg.Bridge == "" {
- return fmt.Errorf("network bridge must not be empty")
- }
- if cfg.CIDR == "" {
- return fmt.Errorf("network CIDR must not be empty")
- }
- if cfg.Gateway == "" {
- return fmt.Errorf("network gateway must not be empty")
- }
- gateway := net.ParseIP(cfg.Gateway)
- if gateway == nil || gateway.To4() == nil {
- return fmt.Errorf("network gateway must be IPv4")
- }
- _, ipNet, err := net.ParseCIDR(cfg.CIDR)
- if err != nil {
- return fmt.Errorf("parse network CIDR: %w", err)
- }
- if !ipNet.Contains(gateway) {
- return fmt.Errorf("network gateway %s is outside %s", cfg.Gateway, cfg.CIDR)
- }
- return nil
-}
-
-func ensureBridge(cfg config.NetworkConfig) (bool, error) {
- exists, err := bridgeExists(cfg.Bridge)
- if err != nil {
- return false, err
- }
- if exists {
- return false, nil
- }
- bridge := &netlink.Bridge{
- LinkAttrs: netlink.LinkAttrs{
- Name: cfg.Bridge,
- },
- }
- if err := netlink.LinkAdd(bridge); err != nil {
- return false, err
- }
- return true, nil
-}
-
-func bridgeExists(bridge string) (bool, error) {
- if _, err := netlink.LinkByName(bridge); err == nil {
- return true, nil
- } else if isLinkNotFound(err) {
- return false, nil
- } else {
- return false, err
- }
-}
-
-func ensureGateway(cfg config.NetworkConfig) (bool, error) {
- link, err := netlink.LinkByName(cfg.Bridge)
- if err != nil {
- return false, err
- }
- prefix, err := cidrPrefix(cfg.CIDR)
- if err != nil {
- return false, err
- }
- addr := &netlink.Addr{
- IPNet: &net.IPNet{
- IP: net.ParseIP(cfg.Gateway).To4(),
- Mask: net.CIDRMask(prefix, 32),
- },
- }
- addrs, err := netlink.AddrList(link, netlink.FAMILY_V4)
- if err != nil {
- return false, err
- }
- for _, existing := range addrs {
- if existing.IP.Equal(addr.IP) && bytes.Equal(existing.Mask, addr.Mask) {
- return false, nil
- }
- }
- if err := netlink.AddrAdd(link, addr); err != nil {
- if errors.Is(err, syscall.EEXIST) {
- return false, nil
- }
- return false, err
- }
- return true, nil
-}
-
-func setBridgeUp(bridge string) error {
- link, err := netlink.LinkByName(bridge)
- if err != nil {
- return err
- }
- return netlink.LinkSetUp(link)
-}
-
-func deleteBridge(bridge string) error {
- link, err := netlink.LinkByName(bridge)
- if err != nil {
- if isLinkNotFound(err) {
- return nil
- }
- return err
- }
- return netlink.LinkDel(link)
-}
-
-func isLinkNotFound(err error) bool {
- var notFound netlink.LinkNotFoundError
- return errors.As(err, ¬Found)
-}
-
-func ensureIPForward(ctx context.Context, runner commandRunner) error {
- _, err := runner.Run(ctx, "sysctl", "-w", "net.ipv4.ip_forward=1")
- return err
-}
-
-func ensureNAT(ctx context.Context, runner commandRunner, cfg config.NetworkConfig) (string, bool, error) {
- backend, err := resolveNATBackend(cfg.NATBackend)
- if err != nil {
- return "", false, err
- }
- switch backend {
- case NATBackendNone:
- return backend, false, nil
- case NATBackendIPTables:
- return ensureIptablesNAT(ctx, runner, cfg.CIDR)
- case NATBackendNFT:
- return ensureNftNAT(ctx, runner, cfg.CIDR)
- default:
- return "", false, fmt.Errorf("unsupported NAT backend %q", backend)
- }
-}
-
-func resolveNATBackend(configured string) (string, error) {
- switch configured {
- case "", NATBackendAuto:
- if _, err := exec.LookPath("iptables"); err == nil {
- return NATBackendIPTables, nil
- }
- if _, err := exec.LookPath("nft"); err == nil {
- return NATBackendNFT, nil
- }
- return "", fmt.Errorf("neither iptables nor nft is available")
- case NATBackendIPTables, NATBackendNFT, NATBackendNone:
- return configured, nil
- default:
- return "", fmt.Errorf("unsupported NAT backend %q", configured)
- }
-}
-
-func ensureIptablesNAT(ctx context.Context, runner commandRunner, cidr string) (string, bool, error) {
- _, err := runner.Run(ctx, "iptables", "-t", "nat", "-C", "POSTROUTING", "-s", cidr, "-j", "MASQUERADE")
- if err == nil {
- return NATBackendIPTables, false, nil
- }
- if _, err := runner.Run(ctx, "iptables", "-t", "nat", "-A", "POSTROUTING", "-s", cidr, "-j", "MASQUERADE"); err != nil {
- return "", false, err
- }
- return NATBackendIPTables, true, nil
-}
-
-func ensureNftNAT(ctx context.Context, runner commandRunner, cidr string) (string, bool, error) {
- out, _ := runner.Run(ctx, "nft", "-a", "list", "chain", "inet", nftTable, nftChain)
- if len(nftNATRuleHandles(out, cidr)) > 0 {
- return NATBackendNFT, false, nil
- }
- _, _ = runner.Run(ctx, "nft", "add", "table", "inet", nftTable)
- _, _ = runner.Run(ctx, "nft", "add", "chain", "inet", nftTable, nftChain, "{", "type", "nat", "hook", "postrouting", "priority", "srcnat", ";", "}")
- if _, err := runner.Run(ctx, "nft", "add", "rule", "inet", nftTable, nftChain, "ip", "saddr", cidr, "masquerade"); err != nil {
- return "", false, err
- }
- return NATBackendNFT, true, nil
-}
-
-func removeNAT(ctx context.Context, runner commandRunner, backend, cidr string) error {
- switch backend {
- case "", NATBackendNone:
- return nil
- case NATBackendIPTables:
- for {
- if _, err := runner.Run(ctx, "iptables", "-t", "nat", "-C", "POSTROUTING", "-s", cidr, "-j", "MASQUERADE"); err != nil {
- return nil
- }
- if _, err := runner.Run(ctx, "iptables", "-t", "nat", "-D", "POSTROUTING", "-s", cidr, "-j", "MASQUERADE"); err != nil {
- return err
- }
- }
- case NATBackendNFT:
- out, err := runner.Run(ctx, "nft", "-a", "list", "chain", "inet", nftTable, nftChain)
- if err != nil {
- return nil
- }
- for _, handle := range nftNATRuleHandles(out, cidr) {
- if _, err := runner.Run(ctx, "nft", "delete", "rule", "inet", nftTable, nftChain, "handle", handle); err != nil {
- return err
- }
- }
- return nil
- default:
- return fmt.Errorf("unsupported NAT backend %q", backend)
- }
-}
-
-func cidrPrefix(cidr string) (int, error) {
- _, ipNet, err := net.ParseCIDR(cidr)
- if err != nil {
- return 0, fmt.Errorf("parse network CIDR: %w", err)
- }
- ones, bits := ipNet.Mask.Size()
- if bits != 32 {
- return 0, fmt.Errorf("network CIDR must be IPv4")
- }
- return ones, nil
-}
diff --git a/internal/network/hosttap_other.go b/internal/network/hosttap_other.go
deleted file mode 100644
index ed32bf0..0000000
--- a/internal/network/hosttap_other.go
+++ /dev/null
@@ -1,28 +0,0 @@
-//go:build !linux
-
-package network
-
-import (
- "context"
- "fmt"
- "runtime"
-
- "github.com/kumabox/kumabox/internal/config"
-)
-
-func EnsureHostTap(_ context.Context, _ string, _ config.NetworkConfig) (*HostTapReport, error) {
- return nil, fmt.Errorf("host-tap networking requires Linux (running on %s)", runtime.GOOS)
-}
-
-func EnsureHostTapWithStore(
- _ context.Context,
- _ string,
- _ config.NetworkConfig,
- _ *Store,
-) (*HostTapReport, error) {
- return nil, fmt.Errorf("host-tap networking requires Linux (running on %s)", runtime.GOOS)
-}
-
-func TeardownHostTap(_ context.Context, _ string, _ config.NetworkConfig) (*HostTapReport, error) {
- return nil, fmt.Errorf("host-tap networking requires Linux (running on %s)", runtime.GOOS)
-}
diff --git a/internal/network/index_codec.go b/internal/network/index_codec.go
deleted file mode 100644
index d635d4a..0000000
--- a/internal/network/index_codec.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package network
-
-import (
- stdjson "encoding/json"
- "fmt"
-
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-const networkIndexTable = "network-index"
-const networkIndexRecord = "root"
-
-type networkIndex = index
-
-const networkLeaseTable = "network-leases"
-const networkLeaseRecord = "root"
-
-func (idx *networkIndex) init() {
- if idx.SchemaVersion == "" {
- idx.SchemaVersion = indexSchemaVersion
- }
- if idx.Networks == nil {
- idx.Networks = map[string]*Record{}
- }
-}
-
-type leaseCodec struct{}
-
-func (leases *leaseIndex) init() {
- if leases.SchemaVersion == "" {
- leases.SchemaVersion = leaseSchemaVersion
- }
- if leases.Leases == nil {
- leases.Leases = map[string]*Lease{}
- }
-}
-
-func (leaseCodec) Decode(raw []byte) (*metajson.Model, error) {
- model := metajson.NewModel()
- if len(raw) == 0 {
- return model, nil
- }
- var leases leaseIndex
- if err := stdjson.Unmarshal(raw, &leases); err != nil {
- return nil, fmt.Errorf("parse network leases: %w", err)
- }
- leases.init()
- encoded, err := stdjson.Marshal(leases)
- if err != nil {
- return nil, fmt.Errorf("encode network leases record: %w", err)
- }
- model.Tables[networkLeaseTable] = map[string]stdjson.RawMessage{networkLeaseRecord: encoded}
- return model, nil
-}
-
-func (leaseCodec) Encode(model *metajson.Model) ([]byte, error) {
- if model == nil {
- return nil, fmt.Errorf("network leases metadata model must not be nil")
- }
- raw := model.Tables[networkLeaseTable][networkLeaseRecord]
- if len(raw) == 0 {
- leases := leaseIndex{}
- leases.init()
- raw, _ = stdjson.Marshal(leases)
- }
- var leases leaseIndex
- if err := stdjson.Unmarshal(raw, &leases); err != nil {
- return nil, fmt.Errorf("parse network leases record: %w", err)
- }
- leases.init()
- return stdjson.MarshalIndent(leases, "", " ")
-}
-
-type indexCodec struct{}
-
-func (indexCodec) Decode(raw []byte) (*metajson.Model, error) {
- model := metajson.NewModel()
- if len(raw) == 0 {
- return model, nil
- }
- var index networkIndex
- if err := stdjson.Unmarshal(raw, &index); err != nil {
- return nil, fmt.Errorf("parse network index: %w", err)
- }
- index.init()
- encoded, err := stdjson.Marshal(index)
- if err != nil {
- return nil, fmt.Errorf("encode network index record: %w", err)
- }
- model.Tables[networkIndexTable] = map[string]stdjson.RawMessage{networkIndexRecord: encoded}
- return model, nil
-}
-
-func (indexCodec) Encode(model *metajson.Model) ([]byte, error) {
- if model == nil {
- return nil, fmt.Errorf("network index metadata model must not be nil")
- }
- raw := model.Tables[networkIndexTable][networkIndexRecord]
- if len(raw) == 0 {
- index := networkIndex{}
- index.init()
- raw, _ = stdjson.Marshal(index)
- }
- var index networkIndex
- if err := stdjson.Unmarshal(raw, &index); err != nil {
- return nil, fmt.Errorf("parse network index record: %w", err)
- }
- index.init()
- return stdjson.MarshalIndent(index, "", " ")
-}
diff --git a/internal/network/nft.go b/internal/network/nft.go
deleted file mode 100644
index d5efe3c..0000000
--- a/internal/network/nft.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package network
-
-import "strings"
-
-func nftNATRuleHandles(output []byte, cidr string) []string {
- var handles []string
- for line := range strings.Lines(string(output)) {
- fields := strings.Fields(line)
- if !containsFieldSequence(fields, []string{"ip", "saddr", cidr, "masquerade"}) {
- continue
- }
- for index := len(fields) - 2; index >= 0; index-- {
- if fields[index] == "handle" {
- handles = append(handles, strings.TrimSuffix(fields[index+1], ";"))
- break
- }
- }
- }
- return handles
-}
-
-func containsFieldSequence(fields, sequence []string) bool {
- if len(sequence) == 0 || len(fields) < len(sequence) {
- return false
- }
- for start := 0; start <= len(fields)-len(sequence); start++ {
- matched := true
- for index := range sequence {
- if fields[start+index] != sequence[index] {
- matched = false
- break
- }
- }
- if matched {
- return true
- }
- }
- return false
-}
diff --git a/internal/network/nft_test.go b/internal/network/nft_test.go
deleted file mode 100644
index a1090fb..0000000
--- a/internal/network/nft_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package network
-
-import (
- "reflect"
- "testing"
-)
-
-func TestNftNATRuleHandlesSelectsOnlyRequestedCIDR(t *testing.T) {
- t.Parallel()
-
- output := []byte(`table inet kumabox {
- chain postrouting {
- type nat hook postrouting priority srcnat; policy accept;
- ip saddr 10.20.0.0/24 masquerade comment "first" # handle 7
- ip saddr 10.30.0.0/24 masquerade # handle 9
- ip daddr 10.20.0.0/24 masquerade # handle 11
- }
-}`)
-
- if got, want := nftNATRuleHandles(output, "10.20.0.0/24"), []string{"7"}; !reflect.DeepEqual(got, want) {
- t.Fatalf("nftNATRuleHandles() = %v, want %v", got, want)
- }
-}
-
-func TestNftNATRuleHandlesReturnsAllDuplicateHandles(t *testing.T) {
- t.Parallel()
-
- output := []byte("ip saddr 10.20.0.0/24 masquerade # handle 4\n" +
- "ip saddr 10.20.0.0/24 masquerade # handle 5\n")
- if got, want := nftNATRuleHandles(output, "10.20.0.0/24"), []string{"4", "5"}; !reflect.DeepEqual(got, want) {
- t.Fatalf("nftNATRuleHandles() = %v, want %v", got, want)
- }
-}
diff --git a/internal/network/provider.go b/internal/network/provider.go
deleted file mode 100644
index e02be69..0000000
--- a/internal/network/provider.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package network
-
-import (
- "fmt"
-
- "github.com/kumabox/kumabox/internal/config"
-)
-
-func ResolveProvider(cfg config.NetworkConfig) (string, error) {
- switch cfg.Mode {
- case ProviderNone, ProviderHostTap, ProviderCNI:
- return cfg.Mode, nil
- default:
- return "", fmt.Errorf("NETWORK_PROVIDER_NOT_CONFIGURED: unsupported network mode %q", cfg.Mode)
- }
-}
diff --git a/internal/network/store.go b/internal/network/store.go
deleted file mode 100644
index 18e3121..0000000
--- a/internal/network/store.go
+++ /dev/null
@@ -1,504 +0,0 @@
-package network
-
-import (
- "context"
- "errors"
- "fmt"
- "path/filepath"
- "reflect"
- "sort"
- "time"
-
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-const indexSchemaVersion = "kumabox.network.index.v1"
-const leaseSchemaVersion = "kumabox.network.leases.v1"
-const hostTapSchemaVersion = "kumabox.network.hostTap.v1"
-
-// Store persists network provider state under a KumaBox root directory.
-//
-// The store owns three related files: provider records, IP leases, and global
-// host-tap bridge ownership. Each file has its own flock because lifecycle and
-// network commands may touch them independently.
-type Store struct {
- engine meta.MetaEngine
- leaseEngine meta.MetaEngine
- hostTapEngine meta.MetaEngine
- indexPath string
- indexLock string
- leasePath string
- leaseLock string
- hostTapPath string
- hostTapLock string
-}
-
-var (
- networkIndexCollection = meta.NewCollection[networkIndex]("networks", networkIndexTable)
- leaseIndexCollection = meta.NewCollection[leaseIndex]("leases", networkLeaseTable)
- hostTapCollection = meta.NewCollection[HostTapState]("host-tap", hostTapTable)
-)
-
-type index struct {
- SchemaVersion string `json:"schemaVersion"`
- Networks map[string]*Record `json:"networks"`
-}
-
-// Lease records exclusive ownership of one guest IP address.
-type Lease struct {
- VMID string `json:"vmId"`
- MAC string `json:"mac"`
- TAP string `json:"tap"`
- CreatedAt time.Time `json:"createdAt"`
-}
-
-type leaseIndex struct {
- SchemaVersion string `json:"schemaVersion"`
- CIDR string `json:"cidr"`
- Leases map[string]*Lease `json:"leases"`
-}
-
-// NewStore returns a network store rooted under rootDir.
-func NewStore(rootDir string) *Store {
- namespaces := JSONNamespaces(rootDir)
- return NewStoreWithEngines(rootDir,
- mustOpenNetworkEngine(namespaces[0]),
- mustOpenNetworkEngine(namespaces[1]),
- mustOpenNetworkEngine(namespaces[2]),
- )
-}
-
-// JSONNamespaces describe network provider, lease, and host-tap state used by
-// the JSON metadata backend.
-func JSONNamespaces(rootDir string) []metajson.Namespace {
- networkDir := filepath.Join(rootDir, "network")
- return []metajson.Namespace{
- {Name: "networks", FilePath: filepath.Join(networkDir, "index.json"), LockPath: filepath.Join(networkDir, "index.lock"), Codec: indexCodec{}},
- {Name: "leases", FilePath: filepath.Join(networkDir, "leases.json"), LockPath: filepath.Join(networkDir, "leases.lock"), Codec: leaseCodec{}},
- {Name: "host-tap", FilePath: filepath.Join(networkDir, "host-tap.json"), LockPath: filepath.Join(networkDir, "host-tap.lock"), Codec: hostTapCodec{}},
- }
-}
-
-// NewStoreWithEngines creates a network store with separately injectable
-// engines for provider records, leases, and host-tap ownership.
-func NewStoreWithEngines(rootDir string, engine, leaseEngine, hostTapEngine meta.MetaEngine) *Store {
- networkDir := filepath.Join(rootDir, "network")
- return &Store{
- engine: engine,
- leaseEngine: leaseEngine,
- hostTapEngine: hostTapEngine,
- indexPath: filepath.Join(networkDir, "index.json"),
- indexLock: filepath.Join(networkDir, "index.lock"),
- leasePath: filepath.Join(networkDir, "leases.json"),
- leaseLock: filepath.Join(networkDir, "leases.lock"),
- hostTapPath: filepath.Join(networkDir, "host-tap.json"),
- hostTapLock: filepath.Join(networkDir, "host-tap.lock"),
- }
-}
-
-// MetadataEngines exposes network persistence boundaries to migration tools.
-func (s *Store) MetadataEngines() (meta.MetaEngine, meta.MetaEngine, meta.MetaEngine) {
- return s.engine, s.leaseEngine, s.hostTapEngine
-}
-
-func mustOpenNetworkEngine(namespace metajson.Namespace) meta.MetaEngine {
- engine, err := metajson.Open(namespace)
- if err != nil {
- panic(fmt.Sprintf("open network metadata engine: %v", err))
- }
- return engine
-}
-
-// List returns provider records sorted by creation time.
-func (s *Store) List() ([]Record, error) {
- var records []Record
- err := s.withIndex(false, func(idx *networkIndex) error {
- records = make([]Record, 0, len(idx.Networks))
- for _, rec := range idx.Networks {
- if rec != nil {
- records = append(records, *rec)
- }
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
- sort.Slice(records, func(i, j int) bool {
- if records[i].CreatedAt.Equal(records[j].CreatedAt) {
- return records[i].ID < records[j].ID
- }
- return records[i].CreatedAt.Before(records[j].CreatedAt)
- })
- return records, nil
-}
-
-// UpsertRecord inserts or replaces one provider record.
-//
-// Callers use this after the host-side device exists, so inspect can treat the
-// record as the source of provider truth.
-func (s *Store) UpsertRecord(rec Record) error {
- if rec.ID == "" {
- return fmt.Errorf("network record id must not be empty")
- }
- return s.withIndex(true, func(idx *networkIndex) error { idx.Networks[rec.ID] = &rec; return nil })
-}
-
-// DeleteRecord removes a provider record.
-//
-// Device and lease cleanup must be completed before this call; otherwise the
-// metadata needed for a later cleanup retry would be lost.
-func (s *Store) DeleteRecord(id string) error {
- if id == "" {
- return nil
- }
- return s.withIndex(true, func(idx *networkIndex) error { delete(idx.Networks, id); return nil })
-}
-
-// MarkCleanupPending records a failed provider cleanup attempt.
-//
-// Missing records are ignored so delete paths can be retried after partial
-// cleanup without turning "already gone" into a hard failure.
-func (s *Store) MarkCleanupPending(id, reason string) error {
- if id == "" {
- return nil
- }
- return s.withIndex(true, func(idx *networkIndex) error {
- rec, ok := idx.Networks[id]
- if !ok || rec == nil {
- return nil
- }
- now := time.Now().UTC()
- rec.Cleanup = Cleanup{Pending: true, Reason: reason, LastAttemptAt: now.Format(time.RFC3339Nano)}
- rec.UpdatedAt = now
- return nil
- })
-}
-
-// Inspect returns provider state for a VM ID without VM-record comparison.
-//
-// Most CLI calls should prefer InspectVM so drift can be reported.
-func (s *Store) Inspect(vmID string) (*InspectResult, error) {
- return s.InspectVM(vmID, "", "", nil, nil)
-}
-
-// InspectVM compares provider records with the VM's persisted network configs.
-func (s *Store) InspectVM(vmID, vmName, network string, networks []string, configs []Config) (*InspectResult, error) {
- records, err := s.List()
- if err != nil {
- return nil, err
- }
- result := &InspectResult{
- VMID: vmID,
- VMName: vmName,
- Network: network,
- Networks: append([]string(nil), networks...),
- Interfaces: []Record{},
- VMConfigs: cloneConfigs(configs),
- }
- for _, rec := range records {
- if rec.VMID == vmID {
- result.Interfaces = append(result.Interfaces, rec)
- }
- }
- result.Drift = inspectDrift(result.Interfaces, configs)
- return result, nil
-}
-
-func inspectDrift(records []Record, configs []Config) []string {
- drift := []string{}
- recordsByID := make(map[string]Record, len(records))
- for _, rec := range records {
- if rec.ID != "" {
- recordsByID[rec.ID] = rec
- }
- }
- configsByID := make(map[string]Config, len(configs))
- for _, cfg := range configs {
- if cfg.ID != "" {
- configsByID[cfg.ID] = cfg
- }
- }
- for _, cfg := range configs {
- if cfg.ID == "" {
- drift = append(drift, "VM network config is missing id")
- continue
- }
- rec, ok := recordsByID[cfg.ID]
- if !ok {
- drift = append(drift, fmt.Sprintf("VM network config %s is missing provider record", cfg.ID))
- continue
- }
- drift = appendDriftMismatch(drift, cfg.ID, "networkName", cfg.NetworkName, rec.Network)
- drift = appendDriftMismatch(drift, cfg.ID, "tap", cfg.TAP, rec.TAP)
- drift = appendDriftMismatch(drift, cfg.ID, "mac", cfg.MAC, rec.MAC)
- drift = appendDriftMismatch(drift, cfg.ID, "backend", cfg.Backend, rec.Provider)
- drift = appendDriftMismatch(drift, cfg.ID, "bridgeDev", cfg.BridgeDev, rec.BridgeDev)
- if cfg.Network == nil {
- if len(rec.IPs) > 0 || rec.Gateway != "" || len(rec.DNS) > 0 {
- drift = append(drift, fmt.Sprintf("VM network config %s is missing guest network details", cfg.ID))
- }
- continue
- }
- drift = appendDriftMismatch(drift, cfg.ID, "ip", configIPCIDR(cfg), firstString(rec.IPs))
- drift = appendDriftMismatch(drift, cfg.ID, "gateway", cfg.Network.Gateway, rec.Gateway)
- if !reflect.DeepEqual(cfg.Network.DNS, rec.DNS) {
- drift = append(drift, fmt.Sprintf("network %s dns mismatch: vm=%v provider=%v", cfg.ID, cfg.Network.DNS, rec.DNS))
- }
- }
- for _, rec := range records {
- if rec.ID == "" {
- drift = append(drift, fmt.Sprintf("provider record for tap %s is missing id", rec.TAP))
- continue
- }
- if _, ok := configsByID[rec.ID]; !ok {
- drift = append(drift, fmt.Sprintf("provider record %s is missing from VM record", rec.ID))
- }
- }
- return drift
-}
-
-func appendDriftMismatch(drift []string, id, field, vmValue, providerValue string) []string {
- if vmValue == providerValue {
- return drift
- }
- return append(drift, fmt.Sprintf("network %s %s mismatch: vm=%q provider=%q", id, field, vmValue, providerValue))
-}
-
-func configIPCIDR(cfg Config) string {
- if cfg.Network == nil || cfg.Network.IP == "" {
- return ""
- }
- if cfg.Network.Prefix <= 0 {
- return cfg.Network.IP
- }
- return fmt.Sprintf("%s/%d", cfg.Network.IP, cfg.Network.Prefix)
-}
-
-func firstString(values []string) string {
- if len(values) == 0 {
- return ""
- }
- return values[0]
-}
-
-func cloneConfigs(configs []Config) []Config {
- if len(configs) == 0 {
- return nil
- }
- copied := make([]Config, len(configs))
- copy(copied, configs)
- for i := range copied {
- if configs[i].Network == nil {
- continue
- }
- network := *configs[i].Network
- network.DNS = append([]string(nil), configs[i].Network.DNS...)
- copied[i].Network = &network
- }
- return copied
-}
-
-// ListLeases returns a defensive copy of the IP lease map keyed by IP address.
-func (s *Store) ListLeases() (map[string]Lease, error) {
- var out map[string]Lease
- err := s.withLeases(false, func(leases *leaseIndex) error {
- out = make(map[string]Lease, len(leases.Leases))
- for ip, lease := range leases.Leases {
- if lease != nil {
- out[ip] = *lease
- }
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-// ReadHostTapState returns the global host-tap state, if it exists.
-func (s *Store) ReadHostTapState() (*HostTapState, error) {
- return s.readHostTapState()
-}
-
-// IncrementHostTapRef increases the number of VM attachments using host-tap.
-//
-// The state file must already exist; setup is responsible for creating it
-// before VM network attachment proceeds.
-func (s *Store) IncrementHostTapRef(count int) error {
- if count <= 0 {
- return nil
- }
- return s.adjustHostTapRef(count, true)
-}
-
-// DecrementHostTapRef decreases the host-tap attachment count.
-//
-// Missing state is treated as already cleaned up to keep delete idempotent.
-func (s *Store) DecrementHostTapRef(count int) error {
- if count <= 0 {
- return nil
- }
- return s.adjustHostTapRef(-count, false)
-}
-
-func (s *Store) withIndex(write bool, fn func(*networkIndex) error) error {
- ctx := context.Background()
- if write {
- return s.engine.Update(ctx, meta.Scope{Write: "networks"}, meta.CommitDurable, func(writer meta.Writer) error {
- idx, err := s.readNetworkIndex(ctx, writer)
- if err != nil {
- return err
- }
- if err := fn(idx); err != nil {
- return err
- }
- return networkIndexCollection.Upsert(ctx, writer, networkIndexRecord, idx)
- })
- }
- return s.engine.View(ctx, []meta.Namespace{"networks"}, func(reader meta.Reader) error {
- idx, err := s.readNetworkIndex(ctx, reader)
- if err != nil {
- return err
- }
- return fn(idx)
- })
-}
-
-func (s *Store) readNetworkIndex(ctx context.Context, reader meta.Reader) (*networkIndex, error) {
- idx, err := networkIndexCollection.Get(ctx, reader, networkIndexRecord)
- if errors.Is(err, meta.ErrNotFound) {
- idx = &networkIndex{SchemaVersion: indexSchemaVersion, Networks: map[string]*Record{}}
- } else if err != nil {
- return nil, fmt.Errorf("read network index: %w", err)
- }
- if idx.SchemaVersion != "" && idx.SchemaVersion != indexSchemaVersion {
- return nil, fmt.Errorf("unsupported network index schema %q", idx.SchemaVersion)
- }
- if idx.Networks == nil {
- idx.Networks = map[string]*Record{}
- }
- return idx, nil
-}
-
-func (s *Store) withLeases(write bool, fn func(*leaseIndex) error) error {
- ctx := context.Background()
- if write {
- return s.leaseEngine.Update(ctx, meta.Scope{Write: "leases"}, meta.CommitDurable, func(writer meta.Writer) error {
- leases, err := s.readLeaseIndex(ctx, writer)
- if err != nil {
- return err
- }
- if err := fn(leases); err != nil {
- return err
- }
- return leaseIndexCollection.Upsert(ctx, writer, networkLeaseRecord, leases)
- })
- }
- return s.leaseEngine.View(ctx, []meta.Namespace{"leases"}, func(reader meta.Reader) error {
- leases, err := s.readLeaseIndex(ctx, reader)
- if err != nil {
- return err
- }
- return fn(leases)
- })
-}
-
-func (s *Store) readLeaseIndex(ctx context.Context, reader meta.Reader) (*leaseIndex, error) {
- leases, err := leaseIndexCollection.Get(ctx, reader, networkLeaseRecord)
- if errors.Is(err, meta.ErrNotFound) {
- leases = &leaseIndex{SchemaVersion: leaseSchemaVersion, Leases: map[string]*Lease{}}
- } else if err != nil {
- return nil, fmt.Errorf("read network leases: %w", err)
- }
- if leases.SchemaVersion != "" && leases.SchemaVersion != leaseSchemaVersion {
- return nil, fmt.Errorf("unsupported network leases schema %q", leases.SchemaVersion)
- }
- leases.init()
- return leases, nil
-}
-
-func (s *Store) readHostTapState() (*HostTapState, error) {
- var state *HostTapState
- err := s.withHostTap(false, func(current **HostTapState) error {
- state = cloneHostTapState(*current)
- return nil
- })
- return state, err
-}
-
-func (s *Store) writeHostTapState(state *HostTapState) error {
- return s.withHostTap(true, func(current **HostTapState) error {
- *current = cloneHostTapState(state)
- return nil
- })
-}
-
-func (s *Store) adjustHostTapRef(delta int, requireState bool) error {
- return s.withHostTap(true, func(current **HostTapState) error {
- state := *current
- if state == nil {
- if requireState {
- return fmt.Errorf("host-tap state is missing")
- }
- return nil
- }
- state.RefCount += delta
- if state.RefCount < 0 {
- state.RefCount = 0
- }
- state.UpdatedAt = time.Now().UTC()
- return nil
- })
-}
-
-func (s *Store) withHostTap(write bool, fn func(**HostTapState) error) error {
- ctx := context.Background()
- read := func(reader meta.Reader) error {
- state, err := hostTapCollection.Get(ctx, reader, hostTapRecord)
- if errors.Is(err, meta.ErrNotFound) {
- state = nil
- } else if err != nil {
- return fmt.Errorf("read host-tap state: %w", err)
- } else if state.SchemaVersion == "" {
- state.SchemaVersion = hostTapSchemaVersion
- }
- return fn(&state)
- }
- if !write {
- return s.hostTapEngine.View(ctx, []meta.Namespace{"host-tap"}, read)
- }
- return s.hostTapEngine.Update(ctx, meta.Scope{Write: "host-tap"}, meta.CommitDurable, func(writer meta.Writer) error {
- stateFn := func(current *HostTapState, hadState bool) error {
- if current == nil {
- if !hadState {
- return nil
- }
- return hostTapCollection.Delete(ctx, writer, hostTapRecord)
- }
- return hostTapCollection.Upsert(ctx, writer, hostTapRecord, current)
- }
- var state *HostTapState
- hadState := false
- if decoded, err := hostTapCollection.Get(ctx, writer, hostTapRecord); err != nil && !errors.Is(err, meta.ErrNotFound) {
- return err
- } else if err == nil {
- hadState = true
- state = decoded
- }
- if err := fn(&state); err != nil {
- return err
- }
- return stateFn(state, hadState)
- })
-}
-
-func cloneHostTapState(state *HostTapState) *HostTapState {
- if state == nil {
- return nil
- }
- cloned := *state
- return &cloned
-}
diff --git a/internal/network/store_test.go b/internal/network/store_test.go
deleted file mode 100644
index 30d8df3..0000000
--- a/internal/network/store_test.go
+++ /dev/null
@@ -1,268 +0,0 @@
-package network
-
-import (
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestStoreListMissingIndexReturnsEmpty(t *testing.T) {
- store := NewStore(t.TempDir())
- records, err := store.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 0 {
- t.Fatalf("records = %d, want 0", len(records))
- }
-}
-
-func TestStoreRecoversPreviousProviderIndex(t *testing.T) {
- dir := t.TempDir()
- store := NewStore(dir)
- base := Record{Provider: ProviderHostTap, Network: "default", IfName: "eth0", Cleanup: Cleanup{}}
- first := base
- first.ID, first.VMID, first.TAP = "net_first", "kb_first", "kbtap-first"
- second := base
- second.ID, second.VMID, second.TAP = "net_second", "kb_second", "kbtap-second"
- if err := store.UpsertRecord(first); err != nil {
- t.Fatal(err)
- }
- if err := store.UpsertRecord(second); err != nil {
- t.Fatal(err)
- }
-
- indexPath := filepath.Join(dir, "network", "index.json")
- if err := os.WriteFile(indexPath, []byte("{"), 0o600); err != nil {
- t.Fatal(err)
- }
- records, err := store.List()
- if err != nil {
- t.Fatalf("list recovered records: %v", err)
- }
- if len(records) != 1 || records[0].ID != first.ID {
- t.Fatalf("recovered records = %+v", records)
- }
-}
-
-func TestStoreRecoversPreviousLeaseGeneration(t *testing.T) {
- dir := t.TempDir()
- allocator := NewAllocator(dir, testNetworkConfig())
- first, err := allocator.Allocate(AllocateRequest{VMID: "kb_lease_first", Index: 0})
- if err != nil {
- t.Fatal(err)
- }
- if _, err := allocator.Allocate(AllocateRequest{VMID: "kb_lease_second", Index: 0}); err != nil {
- t.Fatal(err)
- }
-
- leasePath := filepath.Join(dir, "network", "leases.json")
- if err := os.WriteFile(leasePath, []byte("{"), 0o600); err != nil {
- t.Fatal(err)
- }
- leases, err := NewStore(dir).ListLeases()
- if err != nil {
- t.Fatalf("list recovered leases: %v", err)
- }
- if len(leases) != 1 {
- t.Fatalf("recovered leases = %+v", leases)
- }
- if _, ok := leases[first.Config.Network.IP]; !ok {
- t.Fatalf("first lease missing after recovery: %+v", leases)
- }
-}
-
-func TestStoreListReadsNetworkIndex(t *testing.T) {
- dir := t.TempDir()
- indexPath := filepath.Join(dir, "network", "index.json")
- if err := os.MkdirAll(filepath.Dir(indexPath), 0o755); err != nil {
- t.Fatal(err)
- }
- raw := []byte(`{
- "schemaVersion": "kumabox.network.index.v1",
- "networks": {
- "net_b": {
- "id": "net_b",
- "vmId": "kb_b",
- "network": "default",
- "provider": "host-tap",
- "ifName": "eth0",
- "tap": "kbtapb",
- "mac": "02:00:00:00:00:02",
- "createdAt": "2026-06-29T00:00:02Z",
- "updatedAt": "2026-06-29T00:00:02Z",
- "cleanup": {"pending": false}
- },
- "net_a": {
- "id": "net_a",
- "vmId": "kb_a",
- "network": "default",
- "provider": "host-tap",
- "ifName": "eth0",
- "tap": "kbtapa",
- "mac": "02:00:00:00:00:01",
- "createdAt": "2026-06-29T00:00:01Z",
- "updatedAt": "2026-06-29T00:00:01Z",
- "cleanup": {"pending": false}
- }
- }
-}`)
- if err := os.WriteFile(indexPath, raw, 0o644); err != nil {
- t.Fatal(err)
- }
-
- records, err := NewStore(dir).List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 2 {
- t.Fatalf("records = %d, want 2", len(records))
- }
- if records[0].ID != "net_a" || records[1].ID != "net_b" {
- t.Fatalf("records not sorted by creation time: %+v", records)
- }
- if records[0].CreatedAt.IsZero() || !records[0].CreatedAt.Equal(time.Date(2026, 6, 29, 0, 0, 1, 0, time.UTC)) {
- t.Fatalf("createdAt = %s", records[0].CreatedAt)
- }
-}
-
-func TestStoreInspectVMReportsDrift(t *testing.T) {
- dir := t.TempDir()
- store := NewStore(dir)
- now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
- rec := Record{
- ID: "net_1",
- VMID: "kb_1",
- Network: "default",
- Provider: ProviderHostTap,
- IfName: "eth0",
- TAP: "kbtap1",
- MAC: "5a:00:00:00:00:01",
- BridgeDev: "kumabox0",
- IPs: []string{"10.88.0.2/16"},
- Gateway: "10.88.0.1",
- DNS: []string{"1.1.1.1"},
- CreatedAt: now,
- UpdatedAt: now,
- }
- if err := store.UpsertRecord(rec); err != nil {
- t.Fatal(err)
- }
-
- result, err := store.InspectVM("kb_1", "p2", "default", []string{"default"}, []Config{{
- ID: "net_1",
- NetworkName: "default",
- TAP: "kbtap1",
- MAC: "5a:00:00:00:00:ff",
- Backend: ProviderHostTap,
- BridgeDev: "kumabox0",
- Network: &GuestInfo{
- IP: "10.88.0.2",
- Gateway: "10.88.0.1",
- Prefix: 16,
- DNS: []string{"1.1.1.1"},
- },
- }})
- if err != nil {
- t.Fatal(err)
- }
- if result.VMID != "kb_1" || result.VMName != "p2" || result.Network != "default" {
- t.Fatalf("unexpected inspect identity: %+v", result)
- }
- if len(result.Networks) != 1 || result.Networks[0] != "default" {
- t.Fatalf("networks = %#v", result.Networks)
- }
- if len(result.Interfaces) != 1 || len(result.VMConfigs) != 1 {
- t.Fatalf("unexpected inspect payload: %+v", result)
- }
- if len(result.Drift) != 1 || result.Drift[0] != `network net_1 mac mismatch: vm="5a:00:00:00:00:ff" provider="5a:00:00:00:00:01"` {
- t.Fatalf("drift = %#v", result.Drift)
- }
-}
-
-func TestStoreMarkCleanupPending(t *testing.T) {
- dir := t.TempDir()
- store := NewStore(dir)
- now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
- rec := Record{
- ID: "net_pending",
- VMID: "kb_pending",
- Network: "default",
- Provider: ProviderHostTap,
- IfName: "eth0",
- TAP: "kbtappending",
- MAC: "5a:00:00:00:00:02",
- CreatedAt: now,
- UpdatedAt: now,
- }
- if err := store.UpsertRecord(rec); err != nil {
- t.Fatal(err)
- }
-
- if err := store.MarkCleanupPending("net_pending", "tap delete failed"); err != nil {
- t.Fatal(err)
- }
- records, err := store.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 1 {
- t.Fatalf("records = %d, want 1", len(records))
- }
- if !records[0].Cleanup.Pending || records[0].Cleanup.Reason != "tap delete failed" {
- t.Fatalf("cleanup = %+v", records[0].Cleanup)
- }
- if records[0].Cleanup.LastAttemptAt == "" {
- t.Fatal("cleanup last attempt time is empty")
- }
- if !records[0].UpdatedAt.After(now) {
- t.Fatalf("updatedAt = %s, want after %s", records[0].UpdatedAt, now)
- }
-}
-
-func TestStoreAdjustHostTapRef(t *testing.T) {
- dir := t.TempDir()
- store := NewStore(dir)
- now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
- if err := store.writeHostTapState(&HostTapState{
- SchemaVersion: hostTapSchemaVersion,
- Bridge: "kumabox0",
- CIDR: "10.88.0.0/16",
- Gateway: "10.88.0.1",
- NATBackend: "iptables",
- Owner: Owner{Kind: "kumabox", RootDir: dir},
- CreatedAt: now,
- UpdatedAt: now,
- }); err != nil {
- t.Fatal(err)
- }
-
- if err := store.IncrementHostTapRef(2); err != nil {
- t.Fatal(err)
- }
- if err := store.DecrementHostTapRef(1); err != nil {
- t.Fatal(err)
- }
- state, err := store.ReadHostTapState()
- if err != nil {
- t.Fatal(err)
- }
- if state.RefCount != 1 {
- t.Fatalf("ref count = %d, want 1", state.RefCount)
- }
- if !state.UpdatedAt.After(now) {
- t.Fatalf("updatedAt = %s, want after %s", state.UpdatedAt, now)
- }
-
- if err := store.DecrementHostTapRef(5); err != nil {
- t.Fatal(err)
- }
- state, err = store.ReadHostTapState()
- if err != nil {
- t.Fatal(err)
- }
- if state.RefCount != 0 {
- t.Fatalf("ref count = %d, want 0", state.RefCount)
- }
-}
diff --git a/internal/network/types.go b/internal/network/types.go
deleted file mode 100644
index 00f2a01..0000000
--- a/internal/network/types.go
+++ /dev/null
@@ -1,169 +0,0 @@
-// Package network manages host-side network intent for KumaBox VMs.
-//
-// The package separates VM render config from provider state. VM records keep a
-// Config copy used by Cloud Hypervisor, while the network store keeps provider
-// records, IP leases, and host-tap bridge ownership used for reconciliation and
-// cleanup.
-package network
-
-import (
- "errors"
- "fmt"
- "time"
-)
-
-var (
- ErrNetworkConflict = errors.New("NETWORK_CONFLICT")
- ErrNetworkUnavailable = errors.New("network unavailable")
-)
-
-const (
- // ProviderHostTap is KumaBox's built-in Linux bridge + TAP provider.
- ProviderHostTap = "host-tap"
-
- // ProviderCNI delegates network setup to the configured CNI conflist.
- ProviderCNI = "cni"
-
- // ProviderNone disables VM network attachment.
- ProviderNone = "none"
-
- NATBackendAuto = "auto"
- NATBackendIPTables = "iptables"
- NATBackendNFT = "nft"
- NATBackendNone = "none"
-)
-
-const maxInterfaceNameLength = 15
-
-const DefaultGuestInterfaceName = "eth0"
-
-func GuestInterfaceName(index int) string {
- if index <= 0 {
- return DefaultGuestInterfaceName
- }
- return fmt.Sprintf("eth%d", index)
-}
-
-type AddSpec struct {
- Index int
- Existing *Config
-}
-
-type Provider interface {
- Type() string
- List() ([]Record, error)
- Inspect(vmRef string) (*InspectResult, error)
-}
-
-// Config is the VM-side network attachment rendered into the VMM config.
-//
-// It is copied into VMRecord so a VM can be restarted with the same tap, MAC,
-// and guest IP even if provider indexes need reconciliation.
-type Config struct {
- ID string `json:"id,omitempty"`
- NetworkName string `json:"networkName,omitempty"`
- TAP string `json:"tap"`
- MAC string `json:"mac"`
- NumQueues int `json:"numQueues"`
- QueueSize int `json:"queueSize"`
- Backend string `json:"backend"`
- BridgeDev string `json:"bridgeDev,omitempty"`
- IfName string `json:"ifName,omitempty"`
- NetnsPath string `json:"netnsPath,omitempty"`
- Network *GuestInfo `json:"network,omitempty"`
-}
-
-// GuestInfo is the static network configuration delivered to the guest.
-//
-// For cloud images this is rendered into cloud-init NoCloud network-config.
-// Direct boot paths may use the same values through a later guest-agent flow.
-type GuestInfo struct {
- IP string `json:"ip,omitempty"`
- Gateway string `json:"gateway,omitempty"`
- Prefix int `json:"prefix,omitempty"`
- DNS []string `json:"dns,omitempty"`
-}
-
-// Cleanup records a provider cleanup failure that needs retry or GC attention.
-//
-// A pending cleanup keeps the provider record in place rather than losing the
-// tap/IP identity required to safely finish deletion later.
-type Cleanup struct {
- Pending bool `json:"pending"`
- Reason string `json:"reason,omitempty"`
- LastAttemptAt string `json:"lastAttemptAt,omitempty"`
-}
-
-// Record is the provider-side view of one VM network interface.
-//
-// Records are indexed outside the VM store so network commands can inspect and
-// reconcile provider state independently from VM lifecycle state.
-type Record struct {
- ID string `json:"id"`
- VMID string `json:"vmId"`
- Network string `json:"network"`
- Provider string `json:"provider"`
- IfName string `json:"ifName"`
- TAP string `json:"tap"`
- MAC string `json:"mac"`
- NumQueues int `json:"numQueues"`
- QueueSize int `json:"queueSize"`
- BridgeDev string `json:"bridgeDev,omitempty"`
- NetnsPath string `json:"netnsPath,omitempty"`
- IPs []string `json:"ips,omitempty"`
- Gateway string `json:"gateway,omitempty"`
- DNS []string `json:"dns,omitempty"`
- Cleanup Cleanup `json:"cleanup"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-// InspectResult compares provider records with the VM's rendered network config.
-//
-// Drift is populated when either side is missing or important fields such as
-// tap, MAC, backend, or guest IP disagree.
-type InspectResult struct {
- VMID string `json:"vmId"`
- VMName string `json:"vmName,omitempty"`
- Network string `json:"network,omitempty"`
- Networks []string `json:"networks,omitempty"`
- Interfaces []Record `json:"interfaces"`
- VMConfigs []Config `json:"vmConfigs,omitempty"`
- Drift []string `json:"drift,omitempty"`
-}
-
-// HostTapState records ownership of the global host-tap bridge/NAT domain.
-//
-// RefCount tracks VM network attachments. VM delete decrements it; network
-// teardown refuses to remove the bridge while references remain.
-type HostTapState struct {
- SchemaVersion string `json:"schemaVersion"`
- Bridge string `json:"bridge"`
- CIDR string `json:"cidr"`
- Gateway string `json:"gateway"`
- NATBackend string `json:"natBackend"`
- Owner Owner `json:"owner"`
- RefCount int `json:"refCount"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-// Owner identifies the KumaBox root that owns a host network resource.
-//
-// This prevents one root directory from tearing down a bridge created by
-// another independent KumaBox state root.
-type Owner struct {
- Kind string `json:"kind"`
- RootDir string `json:"rootDir"`
-}
-
-// HostTapReport describes the changes made by setup or teardown.
-type HostTapReport struct {
- Bridge string `json:"bridge"`
- CIDR string `json:"cidr"`
- Gateway string `json:"gateway"`
- NATBackend string `json:"natBackend"`
- Created bool `json:"created"`
- Changed []string `json:"changed,omitempty"`
- State *HostTapState `json:"state,omitempty"`
-}
diff --git a/internal/network/verify.go b/internal/network/verify.go
deleted file mode 100644
index 41479d4..0000000
--- a/internal/network/verify.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package network
-
-// VerifyConfig checks whether the host-side objects required by a persisted
-// VM network attachment still exist and are usable.
-func VerifyConfig(config Config) error {
- return verifyConfig(config)
-}
diff --git a/internal/network/verify_linux.go b/internal/network/verify_linux.go
deleted file mode 100644
index 7005e27..0000000
--- a/internal/network/verify_linux.go
+++ /dev/null
@@ -1,95 +0,0 @@
-//go:build linux
-
-package network
-
-import (
- "errors"
- "fmt"
- "io/fs"
- "net"
- "os"
-
- "github.com/vishvananda/netlink"
-)
-
-func verifyConfig(config Config) error {
- switch config.Backend {
- case ProviderCNI:
- return verifyCNIConfig(config)
- case ProviderHostTap:
- return verifyHostTapConfig(config)
- case ProviderNone, "":
- return nil
- default:
- return fmt.Errorf("unsupported network backend %q", config.Backend)
- }
-}
-
-func verifyHostTapConfig(config Config) error {
- tap, err := netlink.LinkByName(config.TAP)
- if err != nil {
- if isLinkNotFound(err) {
- return fmt.Errorf("%w: tap %s is missing", ErrNetworkUnavailable, config.TAP)
- }
- return fmt.Errorf("find tap %s: %w", config.TAP, err)
- }
- if tap.Type() != "tun" {
- return fmt.Errorf("%w: link %s has type %s, want tun", ErrNetworkConflict, config.TAP, tap.Type())
- }
- bridge, err := netlink.LinkByName(config.BridgeDev)
- if err != nil {
- if isLinkNotFound(err) {
- return fmt.Errorf("%w: bridge %s is missing", ErrNetworkUnavailable, config.BridgeDev)
- }
- return fmt.Errorf("find bridge %s: %w", config.BridgeDev, err)
- }
- if tap.Attrs().MasterIndex != bridge.Attrs().Index {
- return fmt.Errorf("%w: tap %s is not attached to bridge %s", ErrNetworkConflict, config.TAP, config.BridgeDev)
- }
- if tap.Attrs().Flags&net.FlagUp == 0 || bridge.Attrs().Flags&net.FlagUp == 0 {
- return fmt.Errorf("%w: tap %s or bridge %s is down", ErrNetworkUnavailable, config.TAP, config.BridgeDev)
- }
- return nil
-}
-
-func verifyCNIConfig(config Config) error {
- if config.NetnsPath == "" {
- return fmt.Errorf("%w: CNI network namespace path is empty", ErrNetworkUnavailable)
- }
- if _, err := os.Stat(config.NetnsPath); err != nil {
- if errors.Is(err, fs.ErrNotExist) {
- return fmt.Errorf("%w: CNI network namespace %s is missing", ErrNetworkUnavailable, config.NetnsPath)
- }
- return fmt.Errorf("stat CNI network namespace %s: %w", config.NetnsPath, err)
- }
- return withNetNSPath(config.NetnsPath, func() error {
- guest, err := netlink.LinkByName(config.IfName)
- if err != nil {
- if isLinkNotFound(err) {
- return fmt.Errorf("%w: CNI link %s is missing", ErrNetworkUnavailable, config.IfName)
- }
- return fmt.Errorf("find CNI link %s: %w", config.IfName, err)
- }
- tap, err := netlink.LinkByName(config.TAP)
- if err != nil {
- if isLinkNotFound(err) {
- return fmt.Errorf("%w: CNI tap %s is missing", ErrNetworkUnavailable, config.TAP)
- }
- return fmt.Errorf("find CNI tap %s: %w", config.TAP, err)
- }
- if guest.Attrs().Flags&net.FlagUp == 0 || tap.Attrs().Flags&net.FlagUp == 0 {
- return fmt.Errorf("%w: CNI link %s or tap %s is down", ErrNetworkUnavailable, config.IfName, config.TAP)
- }
- if config.MAC != "" && guest.Attrs().HardwareAddr != nil &&
- !equalMAC(config.MAC, guest.Attrs().HardwareAddr) {
- return fmt.Errorf("%w: CNI link %s MAC is %s, want %s", ErrNetworkConflict,
- config.IfName, guest.Attrs().HardwareAddr, config.MAC)
- }
- return nil
- })
-}
-
-func equalMAC(want string, got net.HardwareAddr) bool {
- parsed, err := net.ParseMAC(want)
- return err == nil && parsed.String() == got.String()
-}
diff --git a/internal/network/verify_other.go b/internal/network/verify_other.go
deleted file mode 100644
index e98d689..0000000
--- a/internal/network/verify_other.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !linux
-
-package network
-
-func verifyConfig(_ Config) error {
- // Host network objects only exist on Linux. Other platforms still use fake
- // backends in unit tests; the real backend rejects them before VM launch.
- return nil
-}
diff --git a/internal/operation/journal.go b/internal/operation/journal.go
deleted file mode 100644
index ff9bad1..0000000
--- a/internal/operation/journal.go
+++ /dev/null
@@ -1,243 +0,0 @@
-// Package operation records durable control-plane operations that may need
-// reconciliation after the host process exits unexpectedly.
-package operation
-
-import (
- "context"
- "crypto/rand"
- "encoding/hex"
- "errors"
- "fmt"
- "path/filepath"
- "time"
-
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-const (
- namespace meta.Namespace = "operations"
- table meta.Table = "records"
-)
-
-const (
- KindVMStart = "vm.start"
- KindVMStop = "vm.stop"
- KindVMDelete = "vm.delete"
- KindVMPause = "vm.pause"
- KindVMResume = "vm.resume"
- KindVMHibernate = "vm.hibernate"
- KindNetworkAttach = "network.attach"
- KindNetworkCleanup = "network.cleanup"
- KindNetworkResize = "network.resize"
- KindDiskAttach = "disk.attach"
- KindDiskDetach = "disk.detach"
- KindFilesystemAttach = "filesystem.attach"
- KindFilesystemDetach = "filesystem.detach"
- KindPCIAttach = "pci.attach"
- KindPCIDetach = "pci.detach"
- KindSnapshotCreateRun = "snapshot.create-running"
- KindSnapshotCloneNative = "snapshot.clone-native"
- KindSnapshotRestoreDisk = "snapshot.restore-portable"
- KindSnapshotRestoreVM = "snapshot.restore-native"
-)
-
-type Status string
-
-const (
- StatusRunning Status = "running"
- StatusSuccess Status = "succeeded"
- StatusFailed Status = "failed"
-)
-
-// Record is the durable intent and result of one control-plane operation.
-type Record struct {
- ID string `json:"id"`
- Kind string `json:"kind"`
- ResourceID string `json:"resourceId"`
- RelatedID string `json:"relatedId,omitempty"`
- Status Status `json:"status"`
- StartedAt time.Time `json:"startedAt"`
- FinishedAt *time.Time `json:"finishedAt,omitempty"`
- Error string `json:"error,omitempty"`
- Attempt int `json:"attempt"`
-}
-
-type Journal struct {
- engine meta.MetaEngine
- collection *meta.Collection[Record]
-}
-
-// NewID returns a process-independent operation identifier.
-func NewID() (string, error) {
- var raw [12]byte
- if _, err := rand.Read(raw[:]); err != nil {
- return "", fmt.Errorf("generate operation id: %w", err)
- }
- return "op_" + hex.EncodeToString(raw[:]), nil
-}
-
-func New(rootDir string) *Journal {
- return NewWithEngine(mustOpenEngine(rootDir))
-}
-
-// JSONNamespace describes the operation journal used by the JSON metadata backend.
-func JSONNamespace(rootDir string) metajson.Namespace {
- return metajson.Namespace{
- Name: string(namespace), FilePath: filepath.Join(rootDir, "operation", "records.json"),
- LockPath: filepath.Join(rootDir, "operation", "records.lock"),
- Codec: metajson.TableCodec{Specs: []metajson.TableSpec{{Key: string(table), Table: string(table)}}},
- }
-}
-
-func NewWithEngine(engine meta.MetaEngine) *Journal {
- return &Journal{engine: engine, collection: meta.NewCollection[Record](namespace, table)}
-}
-
-func (j *Journal) MetadataEngine() meta.MetaEngine { return j.engine }
-
-func (j *Journal) Begin(ctx context.Context, id, kind, resourceID string) (*Record, error) {
- return j.begin(ctx, id, kind, resourceID, "")
-}
-
-func (j *Journal) BeginWithRelated(ctx context.Context, id, kind, resourceID, relatedID string) (*Record, error) {
- return j.begin(ctx, id, kind, resourceID, relatedID)
-}
-
-func (j *Journal) begin(ctx context.Context, id, kind, resourceID, relatedID string) (*Record, error) {
- if id == "" || kind == "" || resourceID == "" {
- return nil, fmt.Errorf("operation id, kind, and resource id are required: %w", meta.ErrScope)
- }
- now := time.Now().UTC()
- record := &Record{ID: id, Kind: kind, ResourceID: resourceID, RelatedID: relatedID, Status: StatusRunning, StartedAt: now, Attempt: 1}
- err := j.engine.Update(ctx, meta.Scope{Write: namespace}, meta.CommitDurable, func(writer meta.Writer) error {
- previous, err := j.collection.Get(ctx, writer, meta.RecordID(id))
- if err == nil {
- record.Attempt = previous.Attempt + 1
- } else if !errors.Is(err, meta.ErrNotFound) {
- return err
- }
- return j.collection.Upsert(ctx, writer, meta.RecordID(id), record)
- })
- if err != nil {
- return nil, err
- }
- return clone(*record), nil
-}
-
-func (j *Journal) Complete(ctx context.Context, id string) (*Record, error) {
- return j.finish(ctx, id, StatusSuccess, "")
-}
-
-func (j *Journal) Fail(ctx context.Context, id, reason string) (*Record, error) {
- if reason == "" {
- return nil, fmt.Errorf("operation failure reason is required: %w", meta.ErrScope)
- }
- return j.finish(ctx, id, StatusFailed, reason)
-}
-
-// BindResource records the concrete resource created by an operation whose
-// output identity was not known when the operation began.
-func (j *Journal) BindResource(ctx context.Context, id, resourceID string) (*Record, error) {
- if id == "" || resourceID == "" {
- return nil, fmt.Errorf("operation id and resource id are required: %w", meta.ErrScope)
- }
- var result Record
- err := j.engine.Update(ctx, meta.Scope{Write: namespace}, meta.CommitDurable, func(writer meta.Writer) error {
- record, err := j.collection.Get(ctx, writer, meta.RecordID(id))
- if err != nil {
- return err
- }
- record.ResourceID = resourceID
- if err := j.collection.Replace(ctx, writer, meta.RecordID(id), record); err != nil {
- return err
- }
- result = *record
- return nil
- })
- if err != nil {
- return nil, err
- }
- return clone(result), nil
-}
-
-func (j *Journal) finish(ctx context.Context, id string, status Status, reason string) (*Record, error) {
- var result Record
- err := j.engine.Update(ctx, meta.Scope{Write: namespace}, meta.CommitDurable, func(writer meta.Writer) error {
- record, err := j.collection.Get(ctx, writer, meta.RecordID(id))
- if err != nil {
- return err
- }
- now := time.Now().UTC()
- record.Status = status
- record.FinishedAt = &now
- record.Error = reason
- if err := j.collection.Replace(ctx, writer, meta.RecordID(id), record); err != nil {
- return err
- }
- result = *record
- return nil
- })
- if err != nil {
- return nil, err
- }
- return clone(result), nil
-}
-
-// Recoverable returns operations left running by a process that did not
-// publish a terminal result. Reconciliation decides whether to retry or fail
-// each operation; the journal does not guess at backend state.
-func (j *Journal) Recoverable(ctx context.Context) ([]Record, error) {
- var records []Record
- err := j.engine.View(ctx, []meta.Namespace{namespace}, func(reader meta.Reader) error {
- return j.collection.Scan(ctx, reader, func(_ meta.RecordID, record *Record) error {
- if record.Status == StatusRunning {
- records = append(records, *record)
- }
- return nil
- })
- })
- return records, err
-}
-
-// Reconcile lets the caller inspect each interrupted operation and decide how
-// to repair it. A successful callback publishes succeeded; an error publishes
-// failed with the callback error. The callback runs outside metadata writes so
-// it may inspect host resources without holding a database transaction.
-func (j *Journal) Reconcile(ctx context.Context, repair func(context.Context, Record) error) error {
- if repair == nil {
- return fmt.Errorf("operation repair callback must not be nil: %w", meta.ErrScope)
- }
- records, err := j.Recoverable(ctx)
- if err != nil {
- return err
- }
- for _, record := range records {
- if err := repair(ctx, record); err != nil {
- if _, markErr := j.Fail(ctx, record.ID, err.Error()); markErr != nil {
- return fmt.Errorf("record operation %s failure: %w", record.ID, markErr)
- }
- continue
- }
- if _, err := j.Complete(ctx, record.ID); err != nil {
- return fmt.Errorf("complete reconciled operation %s: %w", record.ID, err)
- }
- }
- return nil
-}
-
-func clone(record Record) *Record {
- if record.FinishedAt != nil {
- finished := *record.FinishedAt
- record.FinishedAt = &finished
- }
- return &record
-}
-
-func mustOpenEngine(rootDir string) meta.MetaEngine {
- engine, err := metajson.Open(JSONNamespace(rootDir))
- if err != nil {
- panic(fmt.Sprintf("open operation metadata engine: %v", err))
- }
- return engine
-}
diff --git a/internal/operation/journal_test.go b/internal/operation/journal_test.go
deleted file mode 100644
index 9c0496c..0000000
--- a/internal/operation/journal_test.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package operation
-
-import (
- "context"
- "errors"
- "testing"
-
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-func TestJournalRecordsAndRecoversRunningOperation(t *testing.T) {
- engine, err := meta.NewMemoryEngine(string(namespace))
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := engine.Close(); err != nil {
- t.Errorf("close engine: %v", err)
- }
- }()
- journal := NewWithEngine(engine)
- ctx := context.Background()
- started, err := journal.Begin(ctx, "op-1", "run", "vm-1")
- if err != nil {
- t.Fatal(err)
- }
- if started.Status != StatusRunning || started.Attempt != 1 {
- t.Fatalf("started = %+v", started)
- }
- recoverable, err := journal.Recoverable(ctx)
- if err != nil || len(recoverable) != 1 {
- t.Fatalf("recoverable = %+v, err = %v", recoverable, err)
- }
- finished, err := journal.Complete(ctx, "op-1")
- if err != nil {
- t.Fatal(err)
- }
- if finished.Status != StatusSuccess || finished.FinishedAt == nil {
- t.Fatalf("finished = %+v", finished)
- }
- recoverable, err = journal.Recoverable(ctx)
- if err != nil || len(recoverable) != 0 {
- t.Fatalf("recoverable after completion = %+v, err = %v", recoverable, err)
- }
-}
-
-func TestJournalReconcilePublishesRepairResult(t *testing.T) {
- engine, err := meta.NewMemoryEngine(string(namespace))
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := engine.Close(); err != nil {
- t.Errorf("close engine: %v", err)
- }
- }()
- journal := NewWithEngine(engine)
- ctx := context.Background()
- if _, err := journal.Begin(ctx, "op-ok", "delete", "vm-1"); err != nil {
- t.Fatal(err)
- }
- if _, err := journal.Begin(ctx, "op-fail", "network", "vm-2"); err != nil {
- t.Fatal(err)
- }
- if err := journal.Reconcile(ctx, func(_ context.Context, record Record) error {
- if record.ID == "op-fail" {
- return errors.New("host cleanup pending")
- }
- return nil
- }); err != nil {
- t.Fatal(err)
- }
- if recoverable, err := journal.Recoverable(ctx); err != nil || len(recoverable) != 0 {
- t.Fatalf("recoverable after reconcile = %+v, err = %v", recoverable, err)
- }
-}
-
-func TestJournalPreservesRelatedResourceDuringRecovery(t *testing.T) {
- engine, err := meta.NewMemoryEngine(string(namespace))
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := engine.Close(); err != nil {
- t.Errorf("close engine: %v", err)
- }
- }()
- journal := NewWithEngine(engine)
- ctx := context.Background()
- started, err := journal.BeginWithRelated(ctx, "op-restore", KindSnapshotRestoreVM, "vm-1", "snap-1")
- if err != nil {
- t.Fatal(err)
- }
- if started.RelatedID != "snap-1" {
- t.Fatalf("related resource = %q", started.RelatedID)
- }
- if err := journal.Reconcile(ctx, func(_ context.Context, record Record) error {
- if record.ResourceID != "vm-1" || record.RelatedID != "snap-1" {
- t.Fatalf("reconcile record = %+v", record)
- }
- return nil
- }); err != nil {
- t.Fatal(err)
- }
-}
diff --git a/internal/reference/store.go b/internal/reference/store.go
deleted file mode 100644
index 480c960..0000000
--- a/internal/reference/store.go
+++ /dev/null
@@ -1,129 +0,0 @@
-// Package reference stores explicit ownership and dependency relationships
-// between durable resources.
-package reference
-
-import (
- "context"
- "fmt"
- "path/filepath"
- "sort"
- "time"
-
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-const (
- namespace meta.Namespace = "references"
- table meta.Table = "records"
-)
-
-type Record struct {
- ID string `json:"id"`
- SourceKind string `json:"sourceKind"`
- SourceID string `json:"sourceId"`
- TargetKind string `json:"targetKind"`
- TargetID string `json:"targetId"`
- Mode string `json:"mode"`
- CreatedAt time.Time `json:"createdAt"`
-}
-
-type Store struct {
- engine meta.MetaEngine
- collection *meta.Collection[Record]
-}
-
-func New(rootDir string) *Store {
- engine, err := metajson.Open(JSONNamespace(rootDir))
- if err != nil {
- panic(fmt.Sprintf("open reference metadata engine: %v", err))
- }
- return NewWithEngine(engine)
-}
-
-// JSONNamespace describes references used by the JSON metadata backend.
-func JSONNamespace(rootDir string) metajson.Namespace {
- return metajson.Namespace{
- Name: string(namespace), FilePath: filepath.Join(rootDir, "references", "records.json"), LockPath: filepath.Join(rootDir, "references", "records.lock"),
- Codec: metajson.TableCodec{Specs: []metajson.TableSpec{{Key: string(table), Table: string(table)}}},
- }
-}
-
-func NewWithEngine(engine meta.MetaEngine) *Store {
- return &Store{engine: engine, collection: meta.NewCollection[Record](namespace, table)}
-}
-
-func (s *Store) MetadataEngine() meta.MetaEngine { return s.engine }
-
-func (s *Store) Upsert(ctx context.Context, record Record) error {
- if record.ID == "" || record.SourceKind == "" || record.SourceID == "" || record.TargetKind == "" || record.TargetID == "" {
- return fmt.Errorf("reference identity is incomplete: %w", meta.ErrScope)
- }
- if record.CreatedAt.IsZero() {
- record.CreatedAt = time.Now().UTC()
- }
- return s.engine.Update(ctx, meta.Scope{Write: namespace}, meta.CommitDurable, func(writer meta.Writer) error {
- return s.collection.Upsert(ctx, writer, meta.RecordID(record.ID), &record)
- })
-}
-
-func (s *Store) Delete(ctx context.Context, id string) error {
- return s.engine.Update(ctx, meta.Scope{Write: namespace}, meta.CommitDurable, func(writer meta.Writer) error {
- return s.collection.Delete(ctx, writer, meta.RecordID(id))
- })
-}
-
-// DeleteSource removes every relationship owned by one durable resource in a
-// single metadata transaction.
-func (s *Store) DeleteSource(ctx context.Context, kind, id string) error {
- if kind == "" || id == "" {
- return fmt.Errorf("reference source identity is incomplete: %w", meta.ErrScope)
- }
- return s.engine.Update(ctx, meta.Scope{Write: namespace}, meta.CommitDurable, func(writer meta.Writer) error {
- var ids []meta.RecordID
- if err := s.collection.Scan(ctx, writer, func(recordID meta.RecordID, record *Record) error {
- if record.SourceKind == kind && record.SourceID == id {
- ids = append(ids, recordID)
- }
- return nil
- }); err != nil {
- return err
- }
- for _, recordID := range ids {
- if err := s.collection.Delete(ctx, writer, recordID); err != nil {
- return err
- }
- }
- return nil
- })
-}
-
-func (s *Store) ListTarget(ctx context.Context, kind, id string) ([]Record, error) {
- return s.list(ctx, func(record Record) bool { return record.TargetKind == kind && record.TargetID == id })
-}
-
-func (s *Store) ListSource(ctx context.Context, kind, id string) ([]Record, error) {
- return s.list(ctx, func(record Record) bool { return record.SourceKind == kind && record.SourceID == id })
-}
-
-func (s *Store) list(ctx context.Context, matches func(Record) bool) ([]Record, error) {
- var result []Record
- err := s.engine.View(ctx, []meta.Namespace{namespace}, func(reader meta.Reader) error {
- return s.collection.Scan(ctx, reader, func(_ meta.RecordID, record *Record) error {
- if matches(*record) {
- result = append(result, *record)
- }
- return nil
- })
- })
- sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
- return result, err
-}
-
-var _ interface {
- Upsert(context.Context, Record) error
- Delete(context.Context, string) error
- DeleteSource(context.Context, string, string) error
- ListTarget(context.Context, string, string) ([]Record, error)
- ListSource(context.Context, string, string) ([]Record, error)
-} = (*Store)(nil)
diff --git a/internal/reference/store_test.go b/internal/reference/store_test.go
deleted file mode 100644
index 453b2b2..0000000
--- a/internal/reference/store_test.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package reference
-
-import (
- "context"
- "testing"
-
- "github.com/kumabox/kumabox/internal/meta"
-)
-
-func TestStoreListsExplicitTargetReferences(t *testing.T) {
- engine, err := meta.NewMemoryEngine(string(namespace))
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := engine.Close(); err != nil {
- t.Errorf("close metadata engine: %v", err)
- }
- }()
- store := NewWithEngine(engine)
- ctx := context.Background()
- if err := store.Upsert(ctx, Record{ID: "ref-1", SourceKind: "vm", SourceID: "vm-1", TargetKind: "snapshot", TargetID: "snap-1"}); err != nil {
- t.Fatal(err)
- }
- if err := store.Upsert(ctx, Record{ID: "ref-2", SourceKind: "vm", SourceID: "vm-2", TargetKind: "snapshot", TargetID: "snap-2"}); err != nil {
- t.Fatal(err)
- }
- records, err := store.ListTarget(ctx, "snapshot", "snap-1")
- if err != nil || len(records) != 1 || records[0].SourceID != "vm-1" {
- t.Fatalf("target references = %+v, err = %v", records, err)
- }
-}
-
-func TestStoreDeletesAllReferencesForSource(t *testing.T) {
- engine, err := meta.NewMemoryEngine(string(namespace))
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- if err := engine.Close(); err != nil {
- t.Errorf("close metadata engine: %v", err)
- }
- }()
- store := NewWithEngine(engine)
- ctx := context.Background()
- for _, record := range []Record{
- {ID: "snapshot-image:one", SourceKind: "snapshot", SourceID: "one", TargetKind: "image", TargetID: "image-1"},
- {ID: "snapshot-image:two", SourceKind: "snapshot", SourceID: "two", TargetKind: "image", TargetID: "image-1"},
- {ID: "vm-snapshot:vm-1:one", SourceKind: "vm", SourceID: "vm-1", TargetKind: "snapshot", TargetID: "one"},
- } {
- if err := store.Upsert(ctx, record); err != nil {
- t.Fatal(err)
- }
- }
-
- if err := store.DeleteSource(ctx, "snapshot", "one"); err != nil {
- t.Fatal(err)
- }
- if records, err := store.ListSource(ctx, "snapshot", "one"); err != nil || len(records) != 0 {
- t.Fatalf("deleted source references = %+v, err=%v", records, err)
- }
- if records, err := store.ListTarget(ctx, "image", "image-1"); err != nil || len(records) != 1 || records[0].SourceID != "two" {
- t.Fatalf("remaining image references = %+v, err=%v", records, err)
- }
- if records, err := store.ListSource(ctx, "vm", "vm-1"); err != nil || len(records) != 1 {
- t.Fatalf("unrelated references = %+v, err=%v", records, err)
- }
-}
diff --git a/internal/snapshot/capture.go b/internal/snapshot/capture.go
deleted file mode 100644
index 0091556..0000000
--- a/internal/snapshot/capture.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package snapshot
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "golang.org/x/sync/errgroup"
-
- "github.com/kumabox/kumabox/internal/disk"
- "github.com/kumabox/kumabox/internal/fileutil"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// CaptureStopped copies every writable VM disk into a pending snapshot build.
-func CaptureStopped(ctx context.Context, build *Build, rec *vm.VMRecord) (*Manifest, int64, error) {
- if build == nil || rec == nil {
- return nil, 0, errors.New("snapshot build and VM record are required")
- }
- pending := build.Record()
- manifestDisks, allocated, err := CaptureWritableDisks(ctx, pending.StagingDir, rec)
- if err != nil {
- return nil, 0, err
- }
- writable := writableDisks(rec)
-
- manifest := newDiskManifest(pending, rec, manifestDisks, writable)
- if err := fileutil.WriteJSONAtomic(filepath.Join(pending.StagingDir, ManifestFile), manifest, ".snapshot-manifest-*.tmp"); err != nil {
- return nil, 0, fmt.Errorf("write snapshot manifest: %w", err)
- }
- return manifest, allocated, nil
-}
-
-// CaptureWritableDisks copies every managed writable disk into staging. Calls
-// may run while a VM is paused, so copies are bounded and concurrent.
-func CaptureWritableDisks(ctx context.Context, stagingDir string, rec *vm.VMRecord) ([]DiskManifest, int64, error) {
- disks, _, err := copyWritableDisks(ctx, stagingDir, rec, disk.CopyFile)
- if err != nil {
- return nil, 0, err
- }
- return disks, allocatedSize(disks), nil
-}
-
-// StageWritableDisks performs only the copy portion needed inside a running
-// snapshot pause window. The returned manifests are incomplete until passed
-// to FinalizeWritableDisks after the VM resumes.
-func StageWritableDisks(ctx context.Context, stagingDir string, rec *vm.VMRecord) ([]DiskManifest, error) {
- disks, _, err := copyWritableDisks(ctx, stagingDir, rec, disk.StageFile)
- return disks, err
-}
-
-// FinalizeWritableDisks fsyncs and hashes copies created by
-// StageWritableDisks. It is intentionally outside the VM pause window.
-func FinalizeWritableDisks(ctx context.Context, stagingDir string, disks []DiskManifest) ([]DiskManifest, int64, error) {
- finalized := append([]DiskManifest(nil), disks...)
- group, groupCtx := errgroup.WithContext(ctx)
- group.SetLimit(disk.MaxConcurrentFileCopies)
- for i := range finalized {
- i := i
- group.Go(func() error {
- manifestDisk := &finalized[i]
- result, err := disk.FinalizeStagedFile(groupCtx, filepath.Join(stagingDir, filepath.FromSlash(manifestDisk.Path)), disk.CopyResult{Strategy: manifestDisk.CopyStrategy})
- if err != nil {
- return fmt.Errorf("finalize writable disk %s: %w", manifestDisk.ID, err)
- }
- manifestDisk.VirtualSizeBytes = result.LogicalSizeBytes
- manifestDisk.AllocatedSizeBytes = result.AllocatedSizeBytes
- manifestDisk.SHA256 = result.SHA256
- return nil
- })
- }
- if err := group.Wait(); err != nil {
- return nil, 0, err
- }
- return finalized, allocatedSize(finalized), nil
-}
-
-type diskCopier func(context.Context, string, string) (disk.CopyResult, error)
-
-func copyWritableDisks(ctx context.Context, stagingDir string, rec *vm.VMRecord, copyDisk diskCopier) ([]DiskManifest, int64, error) {
- if rec == nil {
- return nil, 0, errors.New("VM record is required")
- }
- writable := writableDisks(rec)
- if len(writable) == 0 {
- return nil, 0, errors.New("DISK_CONFIG_MISSING: VM has no managed writable disks")
- }
- disksDir := filepath.Join(stagingDir, DiskPayloadDir)
- if err := os.MkdirAll(disksDir, 0o700); err != nil {
- return nil, 0, fmt.Errorf("create snapshot disks directory: %w", err)
- }
- manifestDisks := make([]DiskManifest, len(writable))
- group, groupCtx := errgroup.WithContext(ctx)
- group.SetLimit(disk.MaxConcurrentFileCopies)
- for i := range writable {
- i := i
- group.Go(func() error {
- disk := writable[i]
- if err := validateDiskID(disk.ID); err != nil {
- return err
- }
- info, err := os.Stat(disk.Path)
- if err != nil {
- return fmt.Errorf("stat writable disk %s: %w", disk.ID, err)
- }
- if !info.Mode().IsRegular() {
- return fmt.Errorf("writable disk %s is not a regular file", disk.ID)
- }
- ext := filepath.Ext(disk.Path)
- if ext == "" {
- ext = ".img"
- }
- relPath := filepath.Join(DiskPayloadDir, disk.ID+ext)
- result, err := copyDisk(groupCtx, disk.Path, filepath.Join(stagingDir, relPath))
- if err != nil {
- return fmt.Errorf("capture writable disk %s: %w", disk.ID, err)
- }
- manifestDisks[i] = DiskManifest{
- ID: disk.ID, Role: string(disk.EffectiveRole()), Path: filepath.ToSlash(relPath),
- Format: disk.EffectiveFormat(), Filesystem: disk.Filesystem,
- VirtualSizeBytes: result.LogicalSizeBytes, AllocatedSizeBytes: result.AllocatedSizeBytes,
- SHA256: result.SHA256, CopyStrategy: result.Strategy,
- }
- return nil
- })
- }
- if err := group.Wait(); err != nil {
- return nil, 0, err
- }
- return manifestDisks, allocatedSize(manifestDisks), nil
-}
-
-func allocatedSize(disks []DiskManifest) int64 {
- var allocated int64
- for _, disk := range disks {
- allocated += disk.AllocatedSizeBytes
- }
- return allocated
-}
-
-func writableDisks(rec *vm.VMRecord) []vm.StorageConfig {
- writable := make([]vm.StorageConfig, 0)
- for _, disk := range rec.StorageConfigs {
- role := disk.EffectiveRole()
- if role == vm.StorageRoleCOW || role == vm.StorageRoleData {
- writable = append(writable, disk)
- }
- }
- return writable
-}
-
-func newDiskManifest(pending *Record, rec *vm.VMRecord, manifestDisks []DiskManifest, writable []vm.StorageConfig) *Manifest {
- manifest := &Manifest{
- SchemaVersion: "kumabox.snapshot.v1", ID: pending.ID, Name: pending.Name,
- Type: "disk", Consistency: "stopped-disk",
- Source: Source{VMID: rec.ID, VMName: rec.Name}, Disks: manifestDisks,
- CreatedAt: time.Now().UTC(),
- }
- if rec.Image != nil {
- manifest.Source.ImageID = rec.Image.ID
- manifest.Source.ImageDigest = rec.Image.Digest
- }
- for _, disk := range writable {
- if disk.EffectiveRole() == vm.StorageRoleCOW && disk.Base != nil {
- manifest.Base = &Base{
- Family: disk.Base.Family, ImageID: disk.Base.ImageID, Digest: disk.Base.Digest,
- Format: disk.Base.Format, LayerDigests: append([]string(nil), disk.Base.LayerDigests...),
- }
- break
- }
- }
- return manifest
-}
-
-func validateDiskID(id string) error {
- if strings.TrimSpace(id) == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\\`) {
- return fmt.Errorf("DISK_CONFIG_INVALID: disk id %q is not safe", id)
- }
- return nil
-}
diff --git a/internal/snapshot/capture_test.go b/internal/snapshot/capture_test.go
deleted file mode 100644
index 8236382..0000000
--- a/internal/snapshot/capture_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package snapshot
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestCaptureStoppedCopiesWritableDisksAndWritesManifest(t *testing.T) {
- t.Parallel()
- root := t.TempDir()
- source := filepath.Join(root, "cow.ext4")
- content := []byte("snapshot payload")
- if err := os.WriteFile(source, content, 0o600); err != nil {
- t.Fatal(err)
- }
- store := NewStore(root)
- build, err := store.Reserve(context.Background(), "capture")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = build.Abort() })
- rec := &vm.VMRecord{
- ID: "kb_capture", Name: "source", Image: &vm.ImageRef{ID: "img_oci", Digest: "sha256:manifest"},
- StorageConfigs: []vm.StorageConfig{{
- ID: "cow", Role: vm.StorageRoleCOW, Path: source, Format: "raw", Filesystem: "ext4",
- Base: &vm.StorageBase{Family: "oci", ImageID: "img_oci", Digest: "sha256:manifest", LayerDigests: []string{"sha256:layer"}},
- }},
- }
- manifest, size, err := CaptureStopped(context.Background(), build, rec)
- if err != nil {
- t.Fatal(err)
- }
- if len(manifest.Disks) != 1 || manifest.Consistency != "stopped-disk" || size <= 0 {
- t.Fatalf("manifest = %+v, size = %d", manifest, size)
- }
- sum := sha256.Sum256(content)
- if manifest.Disks[0].SHA256 != hex.EncodeToString(sum[:]) {
- t.Fatalf("checksum = %s", manifest.Disks[0].SHA256)
- }
- raw, err := os.ReadFile(filepath.Join(build.Record().StagingDir, "snapshot.json"))
- if err != nil {
- t.Fatal(err)
- }
- var persisted Manifest
- if err := json.Unmarshal(raw, &persisted); err != nil {
- t.Fatal(err)
- }
- if persisted.Disks[0].Path != "disks/cow.ext4" {
- t.Fatalf("payload path = %s", persisted.Disks[0].Path)
- }
-}
diff --git a/internal/snapshot/directory.go b/internal/snapshot/directory.go
deleted file mode 100644
index 356a3fe..0000000
--- a/internal/snapshot/directory.go
+++ /dev/null
@@ -1,186 +0,0 @@
-package snapshot
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/kumabox/kumabox/internal/disk"
- "github.com/kumabox/kumabox/internal/fileutil"
-)
-
-const maxSnapshotDirectoryEntries = 4096
-
-// ExportDirectory atomically publishes an unpacked snapshot payload. The
-// destination must not exist so an interrupted export cannot mix generations.
-func (s *Store) ExportDirectory(ctx context.Context, ref, destination string) (err error) {
- if destination == "" || !filepath.IsAbs(destination) {
- return errors.New("snapshot directory export destination must be an absolute path")
- }
- if _, err := os.Lstat(destination); err == nil {
- return fmt.Errorf("snapshot directory export destination already exists: %s", destination)
- } else if !errors.Is(err, os.ErrNotExist) {
- return fmt.Errorf("stat snapshot directory export destination: %w", err)
- }
- record, lease, err := s.AcquireRead(ctx, ref)
- if err != nil {
- return err
- }
- defer func() { err = errors.Join(err, lease.Release()) }()
- if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
- return fmt.Errorf("create snapshot directory export parent: %w", err)
- }
- temporary, err := os.MkdirTemp(filepath.Dir(destination), ".kumabox-snapshot-dir-*")
- if err != nil {
- return fmt.Errorf("create snapshot directory export staging: %w", err)
- }
- published := false
- defer func() {
- if !published {
- err = errors.Join(err, os.RemoveAll(temporary))
- }
- }()
- if err := copySnapshotTree(ctx, record.DataDir, temporary, false); err != nil {
- return err
- }
- if err := os.Rename(temporary, destination); err != nil {
- return fmt.Errorf("publish snapshot directory export: %w", err)
- }
- published = true
- return syncSnapshotDirectory(filepath.Dir(destination))
-}
-
-// ImportDirectory validates an unpacked snapshot in private staging before
-// publishing it under a new local identity.
-func (s *Store) ImportDirectory(ctx context.Context, source, name, qemuImgBinary string) (record *Record, err error) {
- if source == "" || !filepath.IsAbs(source) {
- return nil, errors.New("snapshot directory import source must be an absolute path")
- }
- info, err := os.Lstat(source)
- if err != nil {
- return nil, fmt.Errorf("stat snapshot directory import source: %w", err)
- }
- if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
- return nil, errors.New("snapshot directory import source must be a real directory")
- }
- build, err := s.Reserve(ctx, name)
- if err != nil {
- return nil, err
- }
- defer build.Abort() //nolint:errcheck
- if err := copySnapshotTree(ctx, source, build.Record().StagingDir, true); err != nil {
- return nil, err
- }
- manifestPath := filepath.Join(build.Record().StagingDir, ManifestFile)
- raw, err := os.ReadFile(manifestPath) //nolint:gosec
- if err != nil {
- return nil, fmt.Errorf("read snapshot directory manifest: %w", err)
- }
- var manifest Manifest
- if err := json.Unmarshal(raw, &manifest); err != nil {
- return nil, fmt.Errorf("SNAPSHOT_CORRUPT: decode manifest: %w", err)
- }
- if err := validateDirectoryPayload(ctx, qemuImgBinary, build.Record().StagingDir, &manifest); err != nil {
- return nil, err
- }
- manifest.ID = build.Record().ID
- manifest.Name = name
- if err := fileutil.WriteJSONAtomic(manifestPath, &manifest, ".snapshot-manifest-*.tmp"); err != nil {
- return nil, err
- }
- _, allocated, err := payloadUsage(build.Record().StagingDir)
- if err != nil {
- return nil, fmt.Errorf("measure imported snapshot directory: %w", err)
- }
- return build.FinalizeContext(ctx, allocated)
-}
-
-func validateDirectoryPayload(ctx context.Context, qemuImgBinary, root string, manifest *Manifest) error {
- switch manifest.SchemaVersion {
- case "kumabox.snapshot.v1":
- if err := validateManifest(manifest); err != nil {
- return err
- }
- checksums := make(map[string]string, len(manifest.Disks))
- for _, disk := range manifest.Disks {
- checksums[disk.Path] = disk.SHA256
- }
- return validateImportedPayload(qemuImgBinary, root, manifest, checksums)
- case NativeSchemaV2:
- if manifest.Type != NativeType || manifest.Native == nil || manifest.Machine == nil || manifest.Devices == nil {
- return errors.New("SNAPSHOT_CORRUPT: native compatibility metadata is incomplete")
- }
- if err := verifyNativeFiles(ctx, root, manifest); err != nil {
- return err
- }
- return verifyNativeConfig(root, manifest)
- default:
- return fmt.Errorf("SNAPSHOT_CORRUPT: unsupported schema %q", manifest.SchemaVersion)
- }
-}
-
-func copySnapshotTree(ctx context.Context, source, destination string, enforceImportLimits bool) error {
- count := 0
- var total int64
- return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- if err := ctx.Err(); err != nil {
- return err
- }
- relative, err := filepath.Rel(source, path)
- if err != nil {
- return err
- }
- if relative == "." {
- return nil
- }
- if strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
- return fmt.Errorf("SNAPSHOT_UNSAFE: path escapes source: %s", path)
- }
- count++
- if enforceImportLimits && count > maxSnapshotDirectoryEntries {
- return errors.New("ARCHIVE_LIMIT_EXCEEDED: too many snapshot directory entries")
- }
- info, err := entry.Info()
- if err != nil {
- return err
- }
- target := filepath.Join(destination, relative)
- switch {
- case info.IsDir():
- return os.MkdirAll(target, 0o700)
- case info.Mode().IsRegular():
- total += info.Size()
- if enforceImportLimits && (info.Size() > maxImportFileSize || total > maxImportTotalSize) {
- return errors.New("ARCHIVE_LIMIT_EXCEEDED: snapshot directory size exceeds limit")
- }
- if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
- return err
- }
- if _, err := disk.CopyFile(ctx, path, target); err != nil {
- return fmt.Errorf("copy snapshot directory payload %s: %w", relative, err)
- }
- return nil
- default:
- return fmt.Errorf("SNAPSHOT_UNSAFE: unsupported entry %s", relative)
- }
- })
-}
-
-func syncSnapshotDirectory(path string) (err error) {
- directory, err := os.Open(path) //nolint:gosec
- if err != nil {
- return fmt.Errorf("open snapshot directory for sync: %w", err)
- }
- defer func() { err = errors.Join(err, directory.Close()) }()
- if err := directory.Sync(); err != nil && !errors.Is(err, os.ErrInvalid) {
- return fmt.Errorf("sync snapshot directory: %w", err)
- }
- return nil
-}
diff --git a/internal/snapshot/directory_test.go b/internal/snapshot/directory_test.go
deleted file mode 100644
index 02f16cb..0000000
--- a/internal/snapshot/directory_test.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package snapshot
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestSnapshotDirectoryExportImport(t *testing.T) {
- root := t.TempDir()
- sourceStore := NewStore(filepath.Join(root, "source"))
- ready := createImportFixture(t, sourceStore, "source")
- exported := filepath.Join(root, "exported")
- if err := sourceStore.ExportDirectory(t.Context(), ready.ID, exported); err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(filepath.Join(exported, ManifestFile)); err != nil {
- t.Fatal(err)
- }
- destinationStore := NewStore(filepath.Join(root, "destination"))
- imported, err := destinationStore.ImportDirectory(t.Context(), exported, "imported", fakeImportQEMUImg(t, root))
- if err != nil {
- t.Fatal(err)
- }
- if imported.Name != "imported" || imported.ID == ready.ID {
- t.Fatalf("imported snapshot = %+v", imported)
- }
- manifest, err := destinationStore.LoadManifest(t.Context(), imported.ID)
- if err != nil {
- t.Fatal(err)
- }
- if manifest.ID != imported.ID || manifest.Name != imported.Name {
- t.Fatalf("imported manifest identity = %+v", manifest)
- }
-}
-
-func TestSnapshotDirectoryRejectsExistingDestinationAndSymlink(t *testing.T) {
- store := NewStore(t.TempDir())
- ready := createImportFixture(t, store, "source")
- existing := t.TempDir()
- if err := store.ExportDirectory(t.Context(), ready.ID, existing); err == nil {
- t.Fatal("expected existing destination error")
- }
- source := t.TempDir()
- if err := os.Symlink("/etc/passwd", filepath.Join(source, "snapshot.json")); err != nil {
- t.Skipf("symlink unavailable: %v", err)
- }
- if _, err := store.ImportDirectory(context.Background(), source, "unsafe", "qemu-img"); err == nil || !strings.Contains(err.Error(), "SNAPSHOT_UNSAFE") {
- t.Fatalf("symlink import error = %v", err)
- }
-}
-
-func createImportFixture(t *testing.T, store *Store, name string) *Record {
- t.Helper()
- content := []byte("directory-snapshot")
- build, err := store.Reserve(t.Context(), name)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = build.Abort() })
- diskPath := filepath.Join(build.Record().StagingDir, "disks", "root.qcow2")
- if err := os.MkdirAll(filepath.Dir(diskPath), 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(diskPath, content, 0o600); err != nil {
- t.Fatal(err)
- }
- sum := sha256.Sum256(content)
- manifest := Manifest{
- SchemaVersion: "kumabox.snapshot.v1", ID: build.Record().ID, Name: name,
- Type: "disk", Consistency: "stopped-disk",
- Disks: []DiskManifest{{
- ID: "root", Role: "cow", Path: "disks/root.qcow2", Format: "qcow2",
- VirtualSizeBytes: int64(len(content)), AllocatedSizeBytes: int64(len(content)),
- SHA256: hex.EncodeToString(sum[:]), CopyStrategy: "stream",
- }},
- }
- raw, err := json.Marshal(manifest)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(build.Record().StagingDir, ManifestFile), raw, 0o600); err != nil {
- t.Fatal(err)
- }
- ready, err := build.Finalize(int64(len(content)))
- if err != nil {
- t.Fatal(err)
- }
- return ready
-}
diff --git a/internal/snapshot/export.go b/internal/snapshot/export.go
deleted file mode 100644
index 3933f00..0000000
--- a/internal/snapshot/export.go
+++ /dev/null
@@ -1,171 +0,0 @@
-package snapshot
-
-import (
- "archive/tar"
- "compress/gzip"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strconv"
- "strings"
-
- "github.com/klauspost/compress/zstd"
-)
-
-const (
- paxSparseMap = "KumaBox.sparse.map"
- paxSparseSize = "KumaBox.sparse.size"
-)
-
-// ExportOptions controls creation of a portable snapshot package.
-type ExportOptions struct {
- Output string
- Compression string
-}
-
-// Export writes a ready snapshot to a temporary file and atomically publishes it.
-func (s *Store) Export(ctx context.Context, ref string, opts ExportOptions) error {
- if opts.Output == "" || !filepath.IsAbs(opts.Output) {
- return errors.New("snapshot export output must be an absolute path")
- }
- if opts.Compression == "" {
- opts.Compression = "none"
- }
- rec, lease, err := s.AcquireRead(ctx, ref)
- if err != nil {
- return err
- }
- defer lease.Release() //nolint:errcheck
- manifestRaw, err := os.ReadFile(filepath.Join(rec.DataDir, ManifestFile)) //nolint:gosec
- if err != nil {
- return fmt.Errorf("read snapshot manifest: %w", err)
- }
- var manifest Manifest
- if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
- return fmt.Errorf("decode snapshot manifest: %w", err)
- }
- if manifest.ID != rec.ID || manifest.SchemaVersion != "kumabox.snapshot.v1" {
- return errors.New("SNAPSHOT_CORRUPT: manifest identity mismatch")
- }
- if err := os.MkdirAll(filepath.Dir(opts.Output), 0o755); err != nil {
- return fmt.Errorf("create export directory: %w", err)
- }
- tmp, err := os.CreateTemp(filepath.Dir(opts.Output), ".kumabox-export-*.partial")
- if err != nil {
- return fmt.Errorf("create export temporary file: %w", err)
- }
- tmpPath := tmp.Name()
- ok := false
- defer func() {
- _ = tmp.Close()
- if !ok {
- _ = os.Remove(tmpPath)
- }
- }()
- closer, writer, err := compressionWriter(tmp, opts.Compression)
- if err != nil {
- return err
- }
- tw := tar.NewWriter(writer)
- if err := writeTarBytes(tw, "manifest.json", manifestRaw); err != nil {
- return err
- }
- for _, disk := range manifest.Disks {
- if err := writeSparseDisk(ctx, tw, filepath.Join(rec.DataDir, filepath.FromSlash(disk.Path)), disk.Path); err != nil {
- return err
- }
- }
- checksums := strings.Builder{}
- for _, disk := range manifest.Disks {
- fmt.Fprintf(&checksums, "%s %s\n", disk.SHA256, disk.Path)
- }
- if err := writeTarBytes(tw, "checksums.txt", []byte(checksums.String())); err != nil {
- return err
- }
- if err := tw.Close(); err != nil {
- return fmt.Errorf("close snapshot tar: %w", err)
- }
- if err := closer(); err != nil {
- return err
- }
- if err := tmp.Sync(); err != nil {
- return fmt.Errorf("sync snapshot export: %w", err)
- }
- if err := tmp.Close(); err != nil {
- return fmt.Errorf("close snapshot export: %w", err)
- }
- if err := os.Rename(tmpPath, opts.Output); err != nil {
- return fmt.Errorf("publish snapshot export: %w", err)
- }
- ok = true
- return nil
-}
-
-func writeTarBytes(tw *tar.Writer, name string, data []byte) error {
- if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil {
- return fmt.Errorf("write %s header: %w", name, err)
- }
- if _, err := tw.Write(data); err != nil {
- return fmt.Errorf("write %s: %w", name, err)
- }
- return nil
-}
-
-func writeSparseDisk(ctx context.Context, tw *tar.Writer, path, name string) (err error) {
- extents, logical, err := sparseExtents(path)
- if err != nil {
- return err
- }
- var physical int64
- parts := make([]string, 0, len(extents)*2)
- for _, extent := range extents {
- physical += extent.Length
- parts = append(parts, strconv.FormatInt(extent.Offset, 10), strconv.FormatInt(extent.Length, 10))
- }
- hdr := &tar.Header{Name: name, Mode: 0o600, Size: physical, Typeflag: tar.TypeReg, Format: tar.FormatPAX, PAXRecords: map[string]string{paxSparseMap: strings.Join(parts, ","), paxSparseSize: strconv.FormatInt(logical, 10)}}
- if err := tw.WriteHeader(hdr); err != nil {
- return fmt.Errorf("write sparse disk header: %w", err)
- }
- file, err := os.Open(path) //nolint:gosec
- if err != nil {
- return fmt.Errorf("open snapshot disk: %w", err)
- }
- defer func() {
- if closeErr := file.Close(); err == nil && closeErr != nil {
- err = fmt.Errorf("close snapshot disk: %w", closeErr)
- }
- }()
- for _, extent := range extents {
- if err := ctx.Err(); err != nil {
- return err
- }
- if _, err := io.CopyN(tw, io.NewSectionReader(file, extent.Offset, extent.Length), extent.Length); err != nil {
- return fmt.Errorf("stream sparse disk extent: %w", err)
- }
- }
- return nil
-}
-
-func compressionWriter(dst io.Writer, compression string) (func() error, io.Writer, error) {
- switch compression {
- case "none":
- return func() error { return nil }, dst, nil
- case "gzip":
- w := gzip.NewWriter(dst)
- return w.Close, w, nil
- case "zstd":
- w, err := zstd.NewWriter(dst)
- if err != nil {
- return nil, nil, fmt.Errorf("create zstd writer: %w", err)
- }
- return w.Close, w, nil
- default:
- return nil, nil, fmt.Errorf("unsupported compression %q", compression)
- }
-}
-
-type extent struct{ Offset, Length int64 }
diff --git a/internal/snapshot/export_linux.go b/internal/snapshot/export_linux.go
deleted file mode 100644
index da09f3b..0000000
--- a/internal/snapshot/export_linux.go
+++ /dev/null
@@ -1,41 +0,0 @@
-//go:build linux
-
-package snapshot
-
-import (
- "errors"
- "fmt"
- "golang.org/x/sys/unix"
- "os"
-
- "github.com/kumabox/kumabox/internal/fileutil"
-)
-
-func sparseExtents(path string) (result []extent, size int64, err error) {
- f, err := os.Open(path) //nolint:gosec
- if err != nil {
- return nil, 0, fmt.Errorf("open sparse disk: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, f, "close sparse disk")
- info, err := f.Stat()
- if err != nil {
- return nil, 0, err
- }
- var extents []extent
- for off := int64(0); off < info.Size(); {
- data, err := unix.Seek(int(f.Fd()), off, unix.SEEK_DATA)
- if errors.Is(err, unix.ENXIO) {
- break
- }
- if err != nil {
- return []extent{{0, info.Size()}}, info.Size(), nil
- }
- hole, err := unix.Seek(int(f.Fd()), data, unix.SEEK_HOLE)
- if err != nil {
- return nil, 0, err
- }
- extents = append(extents, extent{data, hole - data})
- off = hole
- }
- return extents, info.Size(), nil
-}
diff --git a/internal/snapshot/export_other.go b/internal/snapshot/export_other.go
deleted file mode 100644
index 337c754..0000000
--- a/internal/snapshot/export_other.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build !linux
-
-package snapshot
-
-import "os"
-
-func sparseExtents(path string) ([]extent, int64, error) {
- info, err := os.Stat(path)
- if err != nil {
- return nil, 0, err
- }
- if info.Size() == 0 {
- return nil, 0, nil
- }
- return []extent{{0, info.Size()}}, info.Size(), nil
-}
diff --git a/internal/snapshot/export_test.go b/internal/snapshot/export_test.go
deleted file mode 100644
index cfe6f65..0000000
--- a/internal/snapshot/export_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package snapshot
-
-import (
- "archive/tar"
- "context"
- "io"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestStoreExportWritesManifestFirstAndSparseMetadata(t *testing.T) {
- t.Parallel()
- root := t.TempDir()
- store := NewStore(root)
- build, err := store.Reserve(context.Background(), "export")
- if err != nil {
- t.Fatal(err)
- }
- staging := build.Record().StagingDir
- if err := os.MkdirAll(filepath.Join(staging, "disks"), 0o700); err != nil {
- t.Fatal(err)
- }
- disk := filepath.Join(staging, "disks", "cow.ext4")
- f, err := os.Create(disk)
- if err != nil {
- t.Fatal(err)
- }
- if err := f.Truncate(1024 * 1024); err != nil {
- t.Fatal(err)
- }
- if _, err := f.WriteAt([]byte("data"), 512*1024); err != nil {
- t.Fatal(err)
- }
- if err := f.Close(); err != nil {
- t.Fatal(err)
- }
- manifest := []byte(`{"schemaVersion":"kumabox.snapshot.v1","id":"` + build.Record().ID + `","name":"export","type":"disk","consistency":"stopped-disk","disks":[{"id":"cow","role":"cow","path":"disks/cow.ext4","format":"raw","virtualSizeBytes":1048576,"allocatedSizeBytes":4096,"sha256":"test","copyStrategy":"sparse"}]}`)
- if err := os.WriteFile(filepath.Join(staging, "snapshot.json"), manifest, 0o600); err != nil {
- t.Fatal(err)
- }
- ready, err := build.Finalize(4096)
- if err != nil {
- t.Fatal(err)
- }
- output := filepath.Join(root, "out.kbsnap")
- if err := store.Export(context.Background(), ready.ID, ExportOptions{Output: output}); err != nil {
- t.Fatal(err)
- }
- archive, err := os.Open(output)
- if err != nil {
- t.Fatal(err)
- }
- defer archive.Close() //nolint:errcheck
- tr := tar.NewReader(archive)
- hdr, err := tr.Next()
- if err != nil {
- t.Fatal(err)
- }
- if hdr.Name != "manifest.json" {
- t.Fatalf("first entry = %s", hdr.Name)
- }
- if _, err := io.Copy(io.Discard, tr); err != nil {
- t.Fatal(err)
- }
- hdr, err = tr.Next()
- if err != nil {
- t.Fatal(err)
- }
- if hdr.Name != "disks/cow.ext4" || hdr.PAXRecords[paxSparseSize] != "1048576" {
- t.Fatalf("disk header = %+v", hdr)
- }
-}
diff --git a/internal/snapshot/import.go b/internal/snapshot/import.go
deleted file mode 100644
index ff85a5c..0000000
--- a/internal/snapshot/import.go
+++ /dev/null
@@ -1,395 +0,0 @@
-package snapshot
-
-import (
- "archive/tar"
- "bufio"
- "compress/gzip"
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strconv"
- "strings"
-
- "github.com/klauspost/compress/zstd"
-
- "github.com/kumabox/kumabox/internal/disk"
- "github.com/kumabox/kumabox/internal/fileutil"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const (
- maxImportEntries = 128
- maxImportFileSize = int64(64 << 30)
- maxImportTotalSize = int64(128 << 30)
-)
-
-// ImportOptions controls secure package ingestion.
-type ImportOptions struct {
- Input string
- Name string
- QEMUImgBinary string
-}
-
-// Import validates an untrusted package in staging before publishing it.
-func (s *Store) Import(ctx context.Context, opts ImportOptions) (record *Record, err error) {
- if opts.Input == "" {
- return nil, errors.New("snapshot import input must not be empty")
- }
- build, err := s.Reserve(ctx, opts.Name)
- if err != nil {
- return nil, err
- }
- defer build.Abort() //nolint:errcheck
- file, err := os.Open(opts.Input) //nolint:gosec
- if err != nil {
- return nil, fmt.Errorf("open snapshot package: %w", err)
- }
- defer fileutil.CloseAndJoin(&err, file, "close snapshot package")
- reader, closeReader, err := compressionReader(file)
- if err != nil {
- return nil, err
- }
- defer func() {
- if closeErr := closeReader(); err == nil && closeErr != nil {
- err = closeErr
- }
- }()
- manifest, checksums, err := extractPackage(ctx, tar.NewReader(reader), build.Record().StagingDir)
- if err != nil {
- return nil, err
- }
- if err := validateImportedPayload(opts.QEMUImgBinary, build.Record().StagingDir, manifest, checksums); err != nil {
- return nil, err
- }
- manifest.ID = build.Record().ID
- manifest.Name = opts.Name
- if err := fileutil.WriteJSONAtomic(filepath.Join(build.Record().StagingDir, ManifestFile), manifest, ".snapshot-manifest-*.tmp"); err != nil {
- return nil, err
- }
- var size int64
- for _, disk := range manifest.Disks {
- size += disk.AllocatedSizeBytes
- }
- return build.FinalizeContext(ctx, size)
-}
-
-func extractPackage(ctx context.Context, tr *tar.Reader, staging string) (*Manifest, map[string]string, error) {
- seen := make(map[string]struct{})
- var manifest *Manifest
- var checksums map[string]string
- var logicalTotal int64
- for count := 0; ; count++ {
- if count >= maxImportEntries {
- return nil, nil, errors.New("ARCHIVE_LIMIT_EXCEEDED: too many archive entries")
- }
- hdr, err := tr.Next()
- if errors.Is(err, io.EOF) {
- break
- }
- if err != nil {
- return nil, nil, fmt.Errorf("read snapshot archive: %w", err)
- }
- if err := ctx.Err(); err != nil {
- return nil, nil, err
- }
- if hdr.Typeflag != tar.TypeReg {
- return nil, nil, fmt.Errorf("ARCHIVE_UNSAFE: entry %q is not a regular file", hdr.Name)
- }
- name, err := safeArchivePath(hdr.Name)
- if err != nil {
- return nil, nil, err
- }
- if _, exists := seen[name]; exists {
- return nil, nil, fmt.Errorf("ARCHIVE_UNSAFE: duplicate entry %q", name)
- }
- seen[name] = struct{}{}
- if count == 0 && name != "manifest.json" {
- return nil, nil, errors.New("ARCHIVE_UNSAFE: manifest.json must be first")
- }
- switch {
- case name == "manifest.json":
- raw, err := readLimited(tr, hdr.Size, 4<<20)
- if err != nil {
- return nil, nil, err
- }
- var parsed Manifest
- if err := json.Unmarshal(raw, &parsed); err != nil {
- return nil, nil, fmt.Errorf("SNAPSHOT_CORRUPT: decode manifest: %w", err)
- }
- if err := validateManifest(&parsed); err != nil {
- return nil, nil, err
- }
- manifest = &parsed
- case name == "checksums.txt":
- raw, err := readLimited(tr, hdr.Size, 4<<20)
- if err != nil {
- return nil, nil, err
- }
- checksums, err = parseChecksums(string(raw))
- if err != nil {
- return nil, nil, err
- }
- case strings.HasPrefix(name, DiskPathPrefix):
- if manifest == nil || !manifestDeclares(manifest, name) {
- return nil, nil, fmt.Errorf("SNAPSHOT_CORRUPT: undeclared payload %q", name)
- }
- logical, extents, err := parseSparseHeader(hdr)
- if err != nil {
- return nil, nil, err
- }
- logicalTotal += logical
- if logical > maxImportFileSize || logicalTotal > maxImportTotalSize {
- return nil, nil, errors.New("ARCHIVE_LIMIT_EXCEEDED: unpacked disk size exceeds limit")
- }
- if err := extractSparseFile(ctx, tr, filepath.Join(staging, filepath.FromSlash(name)), logical, extents); err != nil {
- return nil, nil, err
- }
- default:
- return nil, nil, fmt.Errorf("ARCHIVE_UNSAFE: entry %q is not allowed", name)
- }
- }
- if manifest == nil || checksums == nil {
- return nil, nil, errors.New("SNAPSHOT_CORRUPT: manifest or checksums missing")
- }
- for _, disk := range manifest.Disks {
- if _, ok := seen[disk.Path]; !ok {
- return nil, nil, fmt.Errorf("SNAPSHOT_CORRUPT: payload %q missing", disk.Path)
- }
- }
- return manifest, checksums, nil
-}
-
-func parseSparseHeader(hdr *tar.Header) (int64, []extent, error) {
- logical, err := strconv.ParseInt(hdr.PAXRecords[paxSparseSize], 10, 64)
- if err != nil || logical < 0 {
- return 0, nil, errors.New("SNAPSHOT_CORRUPT: invalid sparse logical size")
- }
- if logical > maxImportFileSize {
- return 0, nil, errors.New("ARCHIVE_LIMIT_EXCEEDED: sparse file exceeds limit")
- }
- mapValue := hdr.PAXRecords[paxSparseMap]
- if mapValue == "" && hdr.Size == 0 {
- return logical, nil, nil
- }
- parts := strings.Split(mapValue, ",")
- if len(parts)%2 != 0 {
- return 0, nil, errors.New("SNAPSHOT_CORRUPT: invalid sparse map")
- }
- var extents []extent
- var physical, previousEnd int64
- for i := 0; i < len(parts); i += 2 {
- offset, e1 := strconv.ParseInt(parts[i], 10, 64)
- length, e2 := strconv.ParseInt(parts[i+1], 10, 64)
- if e1 != nil || e2 != nil || offset < previousEnd || length <= 0 || offset > logical || length > logical-offset {
- return 0, nil, errors.New("SNAPSHOT_CORRUPT: unsafe sparse extent")
- }
- extents = append(extents, extent{offset, length})
- previousEnd = offset + length
- physical += length
- }
- if physical != hdr.Size {
- return 0, nil, errors.New("SNAPSHOT_CORRUPT: sparse physical size mismatch")
- }
- return logical, extents, nil
-}
-
-func extractSparseFile(ctx context.Context, src io.Reader, path string, logical int64, extents []extent) error {
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
- return err
- }
- dst, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) //nolint:gosec
- if err != nil {
- return fmt.Errorf("create imported disk: %w", err)
- }
- ok := false
- defer func() {
- _ = dst.Close()
- if !ok {
- _ = os.Remove(path)
- }
- }()
- if err := dst.Truncate(logical); err != nil {
- return err
- }
- for _, extent := range extents {
- if err := ctx.Err(); err != nil {
- return err
- }
- if _, err := dst.Seek(extent.Offset, io.SeekStart); err != nil {
- return err
- }
- if _, err := io.CopyN(dst, src, extent.Length); err != nil {
- return fmt.Errorf("extract sparse extent: %w", err)
- }
- }
- if err := dst.Sync(); err != nil {
- return err
- }
- ok = true
- return dst.Close()
-}
-
-func validateImportedPayload(qemuBinary, staging string, manifest *Manifest, checksums map[string]string) error {
- if len(checksums) != len(manifest.Disks) {
- return errors.New("SNAPSHOT_CORRUPT: checksum set does not match declared payloads")
- }
- for _, manifestDisk := range manifest.Disks {
- path := filepath.Join(staging, filepath.FromSlash(manifestDisk.Path))
- digest, err := hashFile(path)
- if err != nil {
- return err
- }
- expected, ok := checksums[manifestDisk.Path]
- if !ok || expected != manifestDisk.SHA256 || digest != expected {
- return fmt.Errorf("CHECKSUM_MISMATCH: disk %s", manifestDisk.ID)
- }
- switch manifestDisk.Format {
- case vm.FormatQCOW2:
- info, err := disk.NewQEMUImg(qemuBinary).Info(context.Background(), path)
- if err != nil || info.Format != "qcow2" {
- return fmt.Errorf("SNAPSHOT_CORRUPT: disk %s is not qcow2", manifestDisk.ID)
- }
- case vm.FormatRaw:
- if manifestDisk.Filesystem == vm.FilesystemEXT4 {
- if err := validateExt4(path); err != nil {
- return err
- }
- }
- default:
- return fmt.Errorf("SNAPSHOT_CORRUPT: unsupported disk format %q", manifestDisk.Format)
- }
- }
- return nil
-}
-
-func validateManifest(m *Manifest) error {
- if m.SchemaVersion != "kumabox.snapshot.v1" || m.Type != "disk" || m.Consistency != "stopped-disk" || len(m.Disks) == 0 {
- return errors.New("SNAPSHOT_CORRUPT: unsupported manifest")
- }
- seen := map[string]struct{}{}
- for _, d := range m.Disks {
- path, err := safeArchivePath(d.Path)
- if err != nil || !strings.HasPrefix(path, DiskPathPrefix) {
- return errors.New("SNAPSHOT_CORRUPT: invalid disk path")
- }
- if _, ok := seen[path]; ok {
- return errors.New("SNAPSHOT_CORRUPT: duplicate disk path")
- }
- seen[path] = struct{}{}
- }
- return nil
-}
-
-func safeArchivePath(name string) (string, error) {
- clean := filepath.ToSlash(filepath.Clean(name))
- if name == "" || filepath.IsAbs(name) || clean != name || clean == "." || strings.HasPrefix(clean, "../") {
- return "", fmt.Errorf("ARCHIVE_UNSAFE: unsafe path %q", name)
- }
- return clean, nil
-}
-func manifestDeclares(m *Manifest, path string) bool {
- for _, d := range m.Disks {
- if d.Path == path {
- return true
- }
- }
- return false
-}
-func readLimited(r io.Reader, size, limit int64) ([]byte, error) {
- if size < 0 || size > limit {
- return nil, errors.New("ARCHIVE_LIMIT_EXCEEDED: metadata entry too large")
- }
- return io.ReadAll(io.LimitReader(r, limit+1))
-}
-func parseChecksums(raw string) (map[string]string, error) {
- out := map[string]string{}
- for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
- fields := strings.Fields(line)
- if len(fields) != 2 || len(fields[0]) != 64 {
- return nil, errors.New("SNAPSHOT_CORRUPT: invalid checksums file")
- }
- path, err := safeArchivePath(fields[1])
- if err != nil {
- return nil, err
- }
- if _, ok := out[path]; ok {
- return nil, errors.New("SNAPSHOT_CORRUPT: duplicate checksum")
- }
- out[path] = fields[0]
- }
- return out, nil
-}
-func hashFile(path string) (string, error) {
- return hashFileContext(context.Background(), path)
-}
-
-func hashFileContext(ctx context.Context, path string) (sum string, err error) {
- f, err := os.Open(path)
- if err != nil {
- return "", err
- }
- defer func() {
- if closeErr := f.Close(); err == nil && closeErr != nil {
- err = fmt.Errorf("close snapshot file: %w", closeErr)
- }
- }()
- h := sha256.New()
- if _, err := io.Copy(h, &contextReader{ctx: ctx, reader: f}); err != nil {
- return "", err
- }
- return hex.EncodeToString(h.Sum(nil)), nil
-}
-
-type contextReader struct {
- ctx context.Context
- reader io.Reader
-}
-
-func (r *contextReader) Read(p []byte) (int, error) {
- if err := r.ctx.Err(); err != nil {
- return 0, err
- }
- return r.reader.Read(p)
-}
-func validateExt4(path string) (err error) {
- f, err := os.Open(path)
- if err != nil {
- return err
- }
- defer func() {
- if closeErr := f.Close(); err == nil && closeErr != nil {
- err = fmt.Errorf("close snapshot disk: %w", closeErr)
- }
- }()
- magic := make([]byte, 2)
- if _, err := f.ReadAt(magic, 1024+56); err != nil || magic[0] != 0x53 || magic[1] != 0xef {
- return errors.New("SNAPSHOT_CORRUPT: raw disk is not ext4")
- }
- return nil
-}
-
-func compressionReader(src io.Reader) (io.Reader, func() error, error) {
- buffered := bufio.NewReader(src)
- magic, _ := buffered.Peek(4)
- if len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b {
- r, err := gzip.NewReader(buffered)
- if err != nil {
- return nil, nil, err
- }
- return r, r.Close, nil
- }
- if len(magic) == 4 && magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd {
- r, err := zstd.NewReader(buffered)
- if err != nil {
- return nil, nil, err
- }
- return r, func() error { r.Close(); return nil }, nil
- }
- return buffered, func() error { return nil }, nil
-}
diff --git a/internal/snapshot/import_test.go b/internal/snapshot/import_test.go
deleted file mode 100644
index a6af2e9..0000000
--- a/internal/snapshot/import_test.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package snapshot
-
-import (
- "archive/tar"
- "bytes"
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestStoreImportValidatesAndAssignsNewIdentity(t *testing.T) {
- t.Parallel()
- root := t.TempDir()
- store := NewStore(root)
- packagePath, sourceID := exportTestPackage(t, store, "source", false)
- fakeQEMU := fakeImportQEMUImg(t, root)
- imported, err := store.Import(context.Background(), ImportOptions{Input: packagePath, Name: "imported", QEMUImgBinary: fakeQEMU})
- if err != nil {
- t.Fatal(err)
- }
- if imported.ID == sourceID || imported.Name != "imported" || imported.State != StateReady {
- t.Fatalf("imported = %+v", imported)
- }
- manifest, err := store.LoadManifest(context.Background(), imported.ID)
- if err != nil {
- t.Fatal(err)
- }
- if manifest.ID != imported.ID || manifest.Name != imported.Name {
- t.Fatalf("manifest identity = %+v", manifest)
- }
-}
-
-func TestStoreImportRejectsChecksumMismatch(t *testing.T) {
- t.Parallel()
- root := t.TempDir()
- store := NewStore(root)
- packagePath, _ := exportTestPackage(t, store, "bad-source", true)
- if _, err := store.Import(context.Background(), ImportOptions{Input: packagePath, Name: "bad-import", QEMUImgBinary: fakeImportQEMUImg(t, root)}); err == nil || !bytes.Contains([]byte(err.Error()), []byte("CHECKSUM_MISMATCH")) {
- t.Fatalf("error = %v", err)
- }
- if _, err := store.Inspect("bad-import"); err == nil {
- t.Fatal("failed import published a snapshot")
- }
-}
-
-func TestStoreImportRejectsPathTraversal(t *testing.T) {
- t.Parallel()
- root := t.TempDir()
- packagePath := filepath.Join(root, "unsafe.kbsnap")
- f, err := os.Create(packagePath)
- if err != nil {
- t.Fatal(err)
- }
- tw := tar.NewWriter(f)
- data := []byte("bad")
- if err := tw.WriteHeader(&tar.Header{Name: "../escape", Typeflag: tar.TypeReg, Size: int64(len(data))}); err != nil {
- t.Fatal(err)
- }
- _, _ = tw.Write(data)
- _ = tw.Close()
- _ = f.Close()
- if _, err := NewStore(root).Import(context.Background(), ImportOptions{Input: packagePath, Name: "unsafe", QEMUImgBinary: "qemu-img"}); err == nil || !bytes.Contains([]byte(err.Error()), []byte("ARCHIVE_UNSAFE")) {
- t.Fatalf("error = %v", err)
- }
- if _, err := os.Stat(filepath.Join(root, "escape")); !os.IsNotExist(err) {
- t.Fatalf("escape path created: %v", err)
- }
-}
-
-func exportTestPackage(t *testing.T, store *Store, name string, badChecksum bool) (string, string) {
- t.Helper()
- build, err := store.Reserve(context.Background(), name)
- if err != nil {
- t.Fatal(err)
- }
- staging := build.Record().StagingDir
- _ = os.MkdirAll(filepath.Join(staging, "disks"), 0o700)
- content := []byte("qcow2-test-payload")
- diskPath := filepath.Join(staging, "disks", "root.qcow2")
- if err := os.WriteFile(diskPath, content, 0o600); err != nil {
- t.Fatal(err)
- }
- sum := sha256.Sum256(content)
- digest := hex.EncodeToString(sum[:])
- if badChecksum {
- digest = string(bytes.Repeat([]byte("0"), 64))
- }
- manifest := Manifest{SchemaVersion: "kumabox.snapshot.v1", ID: build.Record().ID, Name: name, Type: "disk", Consistency: "stopped-disk", Disks: []DiskManifest{{ID: "root", Role: "cow", Path: "disks/root.qcow2", Format: "qcow2", VirtualSizeBytes: int64(len(content)), AllocatedSizeBytes: int64(len(content)), SHA256: digest, CopyStrategy: "stream"}}}
- raw, _ := json.Marshal(manifest)
- if err := os.WriteFile(filepath.Join(staging, "snapshot.json"), raw, 0o600); err != nil {
- t.Fatal(err)
- }
- ready, err := build.Finalize(int64(len(content)))
- if err != nil {
- t.Fatal(err)
- }
- output := filepath.Join(filepath.Dir(store.rootDir), name+".kbsnap")
- if err := store.Export(context.Background(), ready.ID, ExportOptions{Output: output}); err != nil {
- t.Fatal(err)
- }
- return output, ready.ID
-}
-
-func fakeImportQEMUImg(t *testing.T, dir string) string {
- t.Helper()
- path := filepath.Join(dir, "qemu-img-import")
- script := "#!/bin/sh\nprintf '%s\\n' '{\"format\":\"qcow2\",\"virtual-size\":1024}'\n"
- if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
- t.Fatal(err)
- }
- return path
-}
diff --git a/internal/snapshot/index.go b/internal/snapshot/index.go
deleted file mode 100644
index 4c186b0..0000000
--- a/internal/snapshot/index.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package snapshot
-
-import (
- "errors"
- "fmt"
- "strings"
-)
-
-var (
- // ErrNotFound indicates that a snapshot reference did not resolve.
- ErrNotFound = errors.New("snapshot not found")
- // ErrNameConflict indicates that a snapshot name is already reserved.
- ErrNameConflict = errors.New("snapshot name conflict")
- // ErrAmbiguous indicates that an ID prefix resolves to multiple snapshots.
- ErrAmbiguous = errors.New("snapshot ref is ambiguous")
- // ErrInUse indicates that a reader or builder currently owns the snapshot lease.
- ErrInUse = errors.New("snapshot in use")
-)
-
-type snapshotIndex struct {
- SchemaVersion string `json:"schemaVersion"`
- Snapshots map[string]*Record `json:"snapshots"`
- Names map[string]string `json:"names"`
-}
-
-func (idx *snapshotIndex) init() {
- if idx.SchemaVersion == "" {
- idx.SchemaVersion = "kumabox.snapshot.index.v1"
- }
- if idx.Snapshots == nil {
- idx.Snapshots = make(map[string]*Record)
- }
- if idx.Names == nil {
- idx.Names = make(map[string]string)
- }
-}
-
-func (idx *snapshotIndex) resolve(ref string) (string, error) {
- idx.init()
- if _, ok := idx.Snapshots[ref]; ok {
- return ref, nil
- }
- if id, ok := idx.Names[ref]; ok {
- return id, nil
- }
- if len(ref) < 3 {
- return "", fmt.Errorf("%w: %s", ErrNotFound, ref)
- }
- matched := ""
- for id := range idx.Snapshots {
- if !strings.HasPrefix(id, ref) {
- continue
- }
- if matched != "" {
- return "", fmt.Errorf("%w: %s", ErrAmbiguous, ref)
- }
- matched = id
- }
- if matched == "" {
- return "", fmt.Errorf("%w: %s", ErrNotFound, ref)
- }
- return matched, nil
-}
diff --git a/internal/snapshot/index_codec.go b/internal/snapshot/index_codec.go
deleted file mode 100644
index 9106845..0000000
--- a/internal/snapshot/index_codec.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package snapshot
-
-import (
- stdjson "encoding/json"
- "fmt"
-
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-const snapshotIndexTable = "snapshot-index"
-const snapshotIndexRecord = "root"
-
-// indexCodec keeps the existing snapshot index document stable while the
-// metadata engine owns locking and durable publication.
-type indexCodec struct{}
-
-func (indexCodec) Decode(raw []byte) (*metajson.Model, error) {
- model := metajson.NewModel()
- if len(raw) == 0 {
- return model, nil
- }
- var index snapshotIndex
- if err := stdjson.Unmarshal(raw, &index); err != nil {
- return nil, fmt.Errorf("decode snapshot index: %w", err)
- }
- index.init()
- encoded, err := stdjson.Marshal(index)
- if err != nil {
- return nil, fmt.Errorf("encode snapshot index record: %w", err)
- }
- model.Tables[snapshotIndexTable] = map[string]stdjson.RawMessage{
- snapshotIndexRecord: encoded,
- }
- return model, nil
-}
-
-func (indexCodec) Encode(model *metajson.Model) ([]byte, error) {
- if model == nil {
- return nil, fmt.Errorf("snapshot index metadata model must not be nil")
- }
- raw := model.Tables[snapshotIndexTable][snapshotIndexRecord]
- if len(raw) == 0 {
- index := snapshotIndex{}
- index.init()
- raw, _ = stdjson.Marshal(index)
- }
- var index snapshotIndex
- if err := stdjson.Unmarshal(raw, &index); err != nil {
- return nil, fmt.Errorf("parse snapshot index record: %w", err)
- }
- index.init()
- return stdjson.MarshalIndent(index, "", " ")
-}
diff --git a/internal/snapshot/layout.go b/internal/snapshot/layout.go
deleted file mode 100644
index e93f65b..0000000
--- a/internal/snapshot/layout.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package snapshot
-
-// Snapshot payload layout is part of the on-disk compatibility contract.
-const (
- ManifestFile = "snapshot.json"
- NativePayloadDir = "native"
- DiskPayloadDir = "disks"
- NativeConfigFile = "config.json"
- NativeStateFile = "state.json"
- NativeMemoryPrefix = "memory-range"
- NativePathPrefix = NativePayloadDir + "/"
- DiskPathPrefix = DiskPayloadDir + "/"
- NativeType = "native"
- NativeSchemaV2 = "kumabox.snapshot.v2"
-)
diff --git a/internal/snapshot/lease.go b/internal/snapshot/lease.go
deleted file mode 100644
index e370586..0000000
--- a/internal/snapshot/lease.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package snapshot
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "syscall"
- "time"
-)
-
-const leaseRetryInterval = 25 * time.Millisecond
-
-type leaseMode int
-
-const (
- leaseRead leaseMode = iota
- leaseExclusive
-)
-
-// Lease owns one kernel flock until Release is called or the process exits.
-type Lease struct {
- file *os.File
-}
-
-// Release unlocks the snapshot lease. It is safe to call more than once.
-func (l *Lease) Release() error {
- if l == nil || l.file == nil {
- return nil
- }
- file := l.file
- l.file = nil
- unlockErr := syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
- closeErr := file.Close()
- return errors.Join(unlockErr, closeErr)
-}
-
-type leaser struct {
- dir string
-}
-
-func newLeaser(dir string) *leaser {
- return &leaser{dir: dir}
-}
-
-func (l *leaser) acquire(ctx context.Context, id string, mode leaseMode, wait bool) (*Lease, error) {
- if err := validateID(id); err != nil {
- return nil, err
- }
- if err := os.MkdirAll(l.dir, 0o700); err != nil {
- return nil, fmt.Errorf("create snapshot lease directory: %w", err)
- }
- file, err := os.OpenFile(filepath.Join(l.dir, id+".lease"), os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec
- if err != nil {
- return nil, fmt.Errorf("open snapshot lease: %w", err)
- }
- operation := syscall.LOCK_SH | syscall.LOCK_NB
- if mode == leaseExclusive {
- operation = syscall.LOCK_EX | syscall.LOCK_NB
- }
- for {
- if err := syscall.Flock(int(file.Fd()), operation); err == nil {
- return &Lease{file: file}, nil
- } else if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) {
- _ = file.Close()
- return nil, fmt.Errorf("lock snapshot lease: %w", err)
- }
- if !wait {
- _ = file.Close()
- return nil, ErrInUse
- }
- select {
- case <-ctx.Done():
- _ = file.Close()
- return nil, fmt.Errorf("wait for snapshot lease: %w", ctx.Err())
- case <-time.After(leaseRetryInterval):
- }
- }
-}
diff --git a/internal/snapshot/manifest.go b/internal/snapshot/manifest.go
deleted file mode 100644
index 056030f..0000000
--- a/internal/snapshot/manifest.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package snapshot
-
-import "time"
-
-// Manifest is the portable description stored beside snapshot disk payloads.
-type Manifest struct {
- SchemaVersion string `json:"schemaVersion"`
- ID string `json:"id"`
- Name string `json:"name"`
- Type string `json:"type"`
- Consistency string `json:"consistency"`
- Source Source `json:"source"`
- Base *Base `json:"base,omitempty"`
- Disks []DiskManifest `json:"disks"`
- Native *NativeManifest `json:"native,omitempty"`
- Backend *BackendManifest `json:"backend,omitempty"`
- Machine *MachineManifest `json:"machine,omitempty"`
- Boot *BootManifest `json:"boot,omitempty"`
- Devices *DeviceManifest `json:"devices,omitempty"`
- Network *NetworkManifest `json:"network,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
-}
-
-// NativeManifest inventories backend-owned running snapshot payload.
-type NativeManifest struct {
- PayloadDir string `json:"payloadDir"`
- Files []NativeFileManifest `json:"files"`
-}
-
-type NativeFileManifest struct {
- Path string `json:"path"`
- SizeBytes int64 `json:"sizeBytes"`
- SHA256 string `json:"sha256"`
-}
-
-type BackendManifest struct {
- Name string `json:"name"`
- Version string `json:"version"`
- SnapshotFormat string `json:"snapshotFormat"`
-}
-
-type MachineManifest struct {
- Architecture string `json:"architecture"`
- CPUVendor string `json:"cpuVendor"`
- CPUFeatures []string `json:"cpuFeatures,omitempty"`
- VCPUs int `json:"vcpus"`
- MemoryBytes int64 `json:"memoryBytes"`
-}
-
-type BootManifest struct {
- Mode string `json:"mode"`
- KernelDigest string `json:"kernelDigest,omitempty"`
- InitrdDigest string `json:"initrdDigest,omitempty"`
- FirmwareDigest string `json:"firmwareDigest,omitempty"`
-}
-
-type DeviceManifest struct {
- Disks []StorageDeviceManifest `json:"disks"`
- NICs int `json:"nics"`
- Vsock bool `json:"vsock"`
-}
-
-type StorageDeviceManifest struct {
- ID string `json:"id"`
- Role string `json:"role"`
- Path string `json:"path"`
- Readonly bool `json:"readonly"`
- Format string `json:"format"`
-}
-
-type NetworkManifest struct {
- RestorePolicy string `json:"restorePolicy"`
- ClonePolicy string `json:"clonePolicy"`
-}
-
-type Source struct {
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
- ImageID string `json:"imageId,omitempty"`
- ImageDigest string `json:"imageDigest,omitempty"`
-}
-
-type Base struct {
- Family string `json:"family"`
- ImageID string `json:"imageId"`
- Digest string `json:"digest"`
- Format string `json:"format,omitempty"`
- LayerDigests []string `json:"layerDigests,omitempty"`
-}
-
-type DiskManifest struct {
- ID string `json:"id"`
- Role string `json:"role"`
- Path string `json:"path"`
- Format string `json:"format"`
- Filesystem string `json:"filesystem,omitempty"`
- VirtualSizeBytes int64 `json:"virtualSizeBytes"`
- AllocatedSizeBytes int64 `json:"allocatedSizeBytes"`
- SHA256 string `json:"sha256"`
- CopyStrategy string `json:"copyStrategy"`
-}
diff --git a/internal/snapshot/native.go b/internal/snapshot/native.go
deleted file mode 100644
index 21d8ce2..0000000
--- a/internal/snapshot/native.go
+++ /dev/null
@@ -1,192 +0,0 @@
-package snapshot
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/fileutil"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// WriteNativeManifest validates the minimum Cloud Hypervisor payload and
-// writes the publication manifest after the source VM has resumed.
-func WriteNativeManifest(ctx context.Context, build *Build, rec *vm.VMRecord, disks []DiskManifest, host backend.NativeHost) (*Manifest, int64, error) {
- return writeNativeManifest(ctx, build, rec, disks, host, true)
-}
-
-// WriteNativeManifestFast publishes a local running snapshot without reading
-// payloads back for fsync and SHA256. Strict integrity is explicit.
-func WriteNativeManifestFast(ctx context.Context, build *Build, rec *vm.VMRecord, disks []DiskManifest, host backend.NativeHost) (*Manifest, int64, error) {
- return writeNativeManifest(ctx, build, rec, disks, host, false)
-}
-
-func writeNativeManifest(ctx context.Context, build *Build, rec *vm.VMRecord, disks []DiskManifest, host backend.NativeHost, strict bool) (*Manifest, int64, error) {
- if build == nil || rec == nil {
- return nil, 0, errors.New("snapshot build and VM record are required")
- }
- pending := build.Record()
- nativeDir := filepath.Join(pending.StagingDir, NativePayloadDir)
- entries, err := os.ReadDir(nativeDir)
- if err != nil {
- return nil, 0, fmt.Errorf("read native snapshot payload: %w", err)
- }
- files := make([]NativeFileManifest, 0, len(entries))
- hasConfig := false
- hasState := false
- hasMemory := false
- var nativeSize int64
- for _, entry := range entries {
- if !entry.Type().IsRegular() {
- continue
- }
- info, err := entry.Info()
- if err != nil {
- return nil, 0, fmt.Errorf("stat native payload %s: %w", entry.Name(), err)
- }
- switch {
- case entry.Name() == NativeConfigFile:
- hasConfig = true
- case entry.Name() == NativeStateFile:
- hasState = true
- case IsNativeMemoryFile(entry.Name()):
- hasMemory = true
- }
- var digest string
- if strict {
- digest, err = syncAndHashFile(ctx, filepath.Join(nativeDir, entry.Name()))
- if err != nil {
- return nil, 0, fmt.Errorf("checksum native payload %s: %w", entry.Name(), err)
- }
- }
- files = append(files, NativeFileManifest{
- Path: filepath.ToSlash(filepath.Join(NativePayloadDir, entry.Name())),
- SizeBytes: info.Size(),
- SHA256: digest,
- })
- nativeSize += info.Size()
- }
- if !hasConfig || !hasState || !hasMemory {
- return nil, 0, fmt.Errorf("NATIVE_SNAPSHOT_INCOMPLETE: config=%t state=%t memory=%t", hasConfig, hasState, hasMemory)
- }
- sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
- manifest := newDiskManifest(pending, rec, disks, writableDisks(rec))
- manifest.SchemaVersion = NativeSchemaV2
- manifest.Type = NativeType
- manifest.Consistency = "crash"
- manifest.Native = &NativeManifest{PayloadDir: NativePayloadDir, Files: files}
- manifest.Backend = &BackendManifest{Name: host.BackendName, Version: host.BackendVersion, SnapshotFormat: host.SnapshotFormat}
- manifest.Machine = &MachineManifest{
- Architecture: host.Architecture, CPUVendor: host.CPUVendor,
- CPUFeatures: append([]string(nil), host.CPUFeatures...), VCPUs: rec.CPUs, MemoryBytes: rec.EffectiveMemoryBytes(),
- }
- boot, err := buildBootManifest(ctx, rec, strict)
- if err != nil {
- return nil, 0, err
- }
- manifest.Boot = boot
- devices, err := buildNativeDeviceManifest(rec, nativeDir)
- if err != nil {
- return nil, 0, err
- }
- if devices.VCPUs != rec.CPUs || devices.MemoryBytes != rec.EffectiveMemoryBytes() {
- return nil, 0, errors.New("NATIVE_SNAPSHOT_INCOMPATIBLE: backend machine shape differs from VM record")
- }
- manifest.Devices = &devices.DeviceManifest
- manifest.Network = &NetworkManifest{RestorePolicy: "preserve", ClonePolicy: "new"}
- manifest.CreatedAt = time.Now().UTC()
- if err := fileutil.WriteJSONAtomic(filepath.Join(pending.StagingDir, ManifestFile), manifest, ".snapshot-manifest-*.tmp"); err != nil {
- return nil, 0, fmt.Errorf("write native snapshot manifest: %w", err)
- }
- if strict {
- if err := writeChecksums(pending.StagingDir, manifest); err != nil {
- return nil, 0, err
- }
- }
- for _, disk := range disks {
- nativeSize += disk.AllocatedSizeBytes
- }
- return manifest, nativeSize, nil
-}
-
-// IsNativeMemoryFile reports whether name is a Cloud Hypervisor memory
-// payload. Released versions use both memory-ranges and memory-range-* names.
-func IsNativeMemoryFile(name string) bool {
- return strings.HasPrefix(filepath.Base(name), NativeMemoryPrefix)
-}
-
-func syncAndHashFile(ctx context.Context, path string) (string, error) {
- file, err := os.Open(path) //nolint:gosec
- if err != nil {
- return "", err
- }
- if err := file.Sync(); err != nil {
- _ = file.Close()
- return "", err
- }
- if err := file.Close(); err != nil {
- return "", err
- }
- return hashFileContext(ctx, path)
-}
-
-func buildBootManifest(ctx context.Context, rec *vm.VMRecord, strict bool) (*BootManifest, error) {
- boot := &BootManifest{Mode: "direct"}
- assets := []struct {
- path string
- target *string
- }{
- {rec.Kernel, &boot.KernelDigest},
- {rec.Initrd, &boot.InitrdDigest},
- {rec.Firmware, &boot.FirmwareDigest},
- }
- if rec.Firmware != "" {
- boot.Mode = "uefi"
- }
- for _, asset := range assets {
- if asset.path == "" {
- continue
- }
- if strict {
- digest, err := hashFileContext(ctx, asset.path)
- if err != nil {
- return nil, fmt.Errorf("checksum boot asset %s: %w", asset.path, err)
- }
- *asset.target = "sha256:" + digest
- }
- }
- return boot, nil
-}
-
-func writeChecksums(stagingDir string, manifest *Manifest) error {
- var lines strings.Builder
- for _, file := range manifest.Native.Files {
- fmt.Fprintf(&lines, "%s %s\n", file.SHA256, file.Path)
- }
- for _, disk := range manifest.Disks {
- fmt.Fprintf(&lines, "%s %s\n", disk.SHA256, disk.Path)
- }
- path := filepath.Join(stagingDir, "checksums.txt")
- file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec
- if err != nil {
- return fmt.Errorf("create native checksums: %w", err)
- }
- if _, err := file.WriteString(lines.String()); err != nil {
- _ = file.Close()
- return fmt.Errorf("write native checksums: %w", err)
- }
- if err := file.Sync(); err != nil {
- _ = file.Close()
- return fmt.Errorf("sync native checksums: %w", err)
- }
- if err := file.Close(); err != nil {
- return fmt.Errorf("close native checksums: %w", err)
- }
- return nil
-}
diff --git a/internal/snapshot/native_test.go b/internal/snapshot/native_test.go
deleted file mode 100644
index 1446fa8..0000000
--- a/internal/snapshot/native_test.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package snapshot
-
-import (
- "context"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestWriteNativeManifestRejectsIncompletePayload(t *testing.T) {
- t.Parallel()
- store := NewStore(t.TempDir())
- build, err := store.Reserve(context.Background(), "incomplete")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = build.Abort() })
- nativeDir := filepath.Join(build.Record().StagingDir, "native")
- if err := os.MkdirAll(nativeDir, 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(nativeDir, "config.json"), []byte("{}"), 0o600); err != nil {
- t.Fatal(err)
- }
- _, _, err = WriteNativeManifest(context.Background(), build, &vm.VMRecord{ID: "kb", Name: "vm"}, nil, backend.NativeHost{})
- if err == nil {
- t.Fatal("expected incomplete native payload error")
- }
-}
-
-func TestIsNativeMemoryFileSupportsBackendNamingVariants(t *testing.T) {
- t.Parallel()
- tests := map[string]bool{
- "memory-ranges": true,
- "memory-range-0": true,
- "native/memory-ranges": true,
- "memory": false,
- "state.json": false,
- "memory_range_0": false,
- }
- for name, want := range tests {
- if got := IsNativeMemoryFile(name); got != want {
- t.Errorf("IsNativeMemoryFile(%q) = %t, want %t", name, got, want)
- }
- }
-}
diff --git a/internal/snapshot/record.go b/internal/snapshot/record.go
deleted file mode 100644
index 7871015..0000000
--- a/internal/snapshot/record.go
+++ /dev/null
@@ -1,52 +0,0 @@
-// Package snapshot persists portable stopped-snapshot metadata and coordinates
-// access to snapshot payloads across daemonless KumaBox commands.
-package snapshot
-
-import "time"
-
-// State is the durable publication state of a snapshot.
-type State string
-
-const (
- StatePending State = "pending"
- StateReady State = "ready"
- StateDeleting State = "deleting"
-)
-
-// Record is the compact snapshot entry stored in the global index.
-type Record struct {
- ID string `json:"id"`
- Name string `json:"name"`
- State State `json:"state"`
- DataDir string `json:"dataDir"`
- StagingDir string `json:"stagingDir,omitempty"`
- SizeBytes int64 `json:"sizeBytes,omitempty"`
- LogicalBytes int64 `json:"logicalBytes,omitempty"`
- AllocatedBytes int64 `json:"allocatedBytes,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
- LastAccessedAt time.Time `json:"lastAccessedAt"`
- Performance *CaptureMetrics `json:"performance,omitempty"`
-}
-
-// CaptureMetrics separates the guest pause window from work that can happen
-// after the VM has resumed.
-type CaptureMetrics struct {
- PauseDurationMs int64 `json:"pauseDurationMs"`
- NativeCaptureMs int64 `json:"nativeCaptureMs"`
- WritableDiskStageMs int64 `json:"writableDiskStageMs"`
- PublicationDurationMs int64 `json:"publicationDurationMs"`
- TotalDurationMs int64 `json:"totalDurationMs"`
-}
-
-func cloneRecord(rec *Record) *Record {
- if rec == nil {
- return nil
- }
- cloned := *rec
- if rec.Performance != nil {
- metrics := *rec.Performance
- cloned.Performance = &metrics
- }
- return &cloned
-}
diff --git a/internal/snapshot/store.go b/internal/snapshot/store.go
deleted file mode 100644
index 8797446..0000000
--- a/internal/snapshot/store.go
+++ /dev/null
@@ -1,559 +0,0 @@
-package snapshot
-
-import (
- "context"
- "crypto/rand"
- "encoding/hex"
- stdjson "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "syscall"
- "time"
-
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// Store owns the snapshot index, payload directories, staging, and leases.
-type Store struct {
- dataRoot string
- rootDir string
- engine meta.MetaEngine
- leaser *leaser
- vmReader interface {
- List() ([]*vm.VMRecord, error)
- }
-}
-
-var snapshotIndexCollection = meta.NewCollection[snapshotIndex]("snapshots", snapshotIndexTable)
-
-// NewStore creates a snapshot store under rootDir.
-func NewStore(rootDir string) *Store {
- engine := mustOpenSnapshotEngine(JSONNamespace(rootDir))
- return NewStoreWithEngine(rootDir, engine)
-}
-
-// JSONNamespace describes the snapshot index used by the JSON metadata backend.
-func JSONNamespace(rootDir string) metajson.Namespace {
- dir := filepath.Join(rootDir, "snapshot")
- return metajson.Namespace{
- Name: "snapshots",
- FilePath: filepath.Join(dir, "index.json"),
- LockPath: filepath.Join(dir, "index.lock"),
- Codec: indexCodec{},
- }
-}
-
-// NewStoreWithVMReader creates the default JSON snapshot store with an
-// injected read-only VM dependency.
-func NewStoreWithVMReader(rootDir string, vmReader interface {
- List() ([]*vm.VMRecord, error)
-}) *Store {
- store := NewStore(rootDir)
- store.vmReader = vmReader
- return store
-}
-
-// NewStoreWithEngine creates a snapshot store with an injected metadata engine.
-func NewStoreWithEngine(rootDir string, engine meta.MetaEngine) *Store {
- return NewStoreWithEngineAndVMReader(rootDir, engine, vm.New(rootDir))
-}
-
-// NewStoreWithEngineAndVMReader creates a snapshot store with an injected
-// read-only VM dependency used for dependency checks during deletion.
-func NewStoreWithEngineAndVMReader(rootDir string, engine meta.MetaEngine, vmReader interface {
- List() ([]*vm.VMRecord, error)
-}) *Store {
- dir := filepath.Join(rootDir, "snapshot")
- return &Store{dataRoot: rootDir, rootDir: dir, engine: engine, leaser: newLeaser(filepath.Join(dir, "leases")), vmReader: vmReader}
-}
-
-// MetadataEngine exposes the persistence boundary to migration tools.
-func (s *Store) MetadataEngine() meta.MetaEngine { return s.engine }
-
-func mustOpenSnapshotEngine(namespace metajson.Namespace) meta.MetaEngine {
- engine, err := metajson.Open(namespace)
- if err != nil {
- panic(fmt.Sprintf("open snapshot metadata engine: %v", err))
- }
- return engine
-}
-
-// Build is an exclusive pending snapshot transaction.
-type Build struct {
- store *Store
- record *Record
- lease *Lease
- performance *CaptureMetrics
- finished bool
-}
-
-// Record returns a defensive copy of the pending record.
-func (b *Build) Record() *Record { return cloneRecord(b.record) }
-
-// SetPerformance records capture timing for the pending snapshot. It is
-// published atomically with the ready record by Finalize.
-func (b *Build) SetPerformance(metrics CaptureMetrics) error {
- if b == nil || b.finished {
- return errors.New("snapshot build is already finished")
- }
- b.performance = &metrics
- return nil
-}
-
-// Reserve creates a pending record and staging directory while holding the
-// snapshot's exclusive build lease until Finalize or Abort.
-func (s *Store) Reserve(ctx context.Context, name string) (*Build, error) {
- if err := validateName(name); err != nil {
- return nil, err
- }
- id, err := newID()
- if err != nil {
- return nil, err
- }
- lease, err := s.leaser.acquire(ctx, id, leaseExclusive, true)
- if err != nil {
- return nil, err
- }
- stagingDir := filepath.Join(s.rootDir, "staging", "capture-"+id)
- dataDir := filepath.Join(s.rootDir, id)
- now := time.Now().UTC()
- rec := &Record{
- ID: id, Name: name, State: StatePending, DataDir: dataDir,
- StagingDir: stagingDir, CreatedAt: now, UpdatedAt: now, LastAccessedAt: now,
- }
- if err := s.update(func(idx *snapshotIndex) error {
- if _, exists := idx.Names[name]; exists {
- return fmt.Errorf("SNAPSHOT_NAME_CONFLICT: %w: %s", ErrNameConflict, name)
- }
- if err := os.MkdirAll(stagingDir, 0o700); err != nil {
- return fmt.Errorf("create snapshot staging directory: %w", err)
- }
- idx.Snapshots[id] = cloneRecord(rec)
- idx.Names[name] = id
- return nil
- }); err != nil {
- _ = os.RemoveAll(stagingDir)
- _ = lease.Release()
- return nil, err
- }
- return &Build{store: s, record: rec, lease: lease}, nil
-}
-
-// Finalize atomically publishes staged payload after snapshot.json exists.
-func (b *Build) Finalize(sizeBytes int64) (*Record, error) {
- return b.FinalizeContext(context.Background(), sizeBytes)
-}
-
-// FinalizeContext publishes staged payload and is safe to retry when the data
-// directory rename completed but the metadata transaction did not.
-func (b *Build) FinalizeContext(ctx context.Context, sizeBytes int64) (*Record, error) {
- if b == nil || b.finished {
- return nil, errors.New("snapshot build is already finished")
- }
- payloadDir := b.record.StagingDir
- if _, err := os.Stat(payloadDir); errors.Is(err, os.ErrNotExist) {
- payloadDir = b.record.DataDir
- } else if err != nil {
- return nil, fmt.Errorf("stat snapshot staging directory: %w", err)
- }
- manifest := filepath.Join(payloadDir, ManifestFile)
- info, err := os.Stat(manifest)
- if err != nil {
- return nil, fmt.Errorf("validate snapshot manifest: %w", err)
- }
- if !info.Mode().IsRegular() {
- return nil, errors.New("snapshot manifest must be a regular file")
- }
- logicalBytes, allocatedBytes, err := payloadUsage(payloadDir)
- if err != nil {
- return nil, fmt.Errorf("measure snapshot payload: %w", err)
- }
- if allocatedBytes == 0 && sizeBytes > 0 {
- allocatedBytes = sizeBytes
- }
-
- var finalized *Record
- err = b.store.update(func(idx *snapshotIndex) error {
- rec, ok := idx.Snapshots[b.record.ID]
- if !ok || rec.State != StatePending {
- return errors.New("pending snapshot record disappeared before finalize")
- }
- _, dataErr := os.Stat(rec.DataDir)
- _, stagingErr := os.Stat(rec.StagingDir)
- if dataErr == nil && stagingErr == nil {
- return errors.New("snapshot staging and data directories both exist")
- }
- if dataErr != nil && !errors.Is(dataErr, os.ErrNotExist) {
- return fmt.Errorf("stat snapshot data directory: %w", dataErr)
- }
- if stagingErr != nil && !errors.Is(stagingErr, os.ErrNotExist) {
- return fmt.Errorf("stat snapshot staging directory: %w", stagingErr)
- }
- if errors.Is(dataErr, os.ErrNotExist) {
- if errors.Is(stagingErr, os.ErrNotExist) {
- return errors.New("snapshot payload disappeared before finalize")
- }
- if err := fault.Check(ctx, fault.SnapshotBeforePublish); err != nil {
- return err
- }
- if err := os.Rename(rec.StagingDir, rec.DataDir); err != nil {
- return fmt.Errorf("publish snapshot data directory: %w", err)
- }
- if err := fault.Check(ctx, fault.SnapshotAfterRename); err != nil {
- return err
- }
- }
- now := time.Now().UTC()
- rec.State = StateReady
- rec.StagingDir = ""
- rec.SizeBytes = sizeBytes
- rec.LogicalBytes = logicalBytes
- rec.AllocatedBytes = allocatedBytes
- if b.performance != nil {
- metrics := *b.performance
- rec.Performance = &metrics
- }
- rec.UpdatedAt = now
- rec.LastAccessedAt = now
- finalized = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- b.finished = true
- if err := b.lease.Release(); err != nil {
- return nil, fmt.Errorf("release snapshot build lease: %w", err)
- }
- return finalized, nil
-}
-
-func payloadUsage(root string) (logical, allocated int64, err error) {
- err = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- if !entry.Type().IsRegular() {
- return nil
- }
- info, err := entry.Info()
- if err != nil {
- return err
- }
- logical += info.Size()
- if stat, ok := info.Sys().(*syscall.Stat_t); ok {
- allocated += stat.Blocks * 512
- } else {
- allocated += info.Size()
- }
- return nil
- })
- return logical, allocated, err
-}
-
-// Abort rolls back a pending build and removes its staging directory.
-func (b *Build) Abort() error {
- if b == nil || b.finished {
- return nil
- }
- err := b.store.update(func(idx *snapshotIndex) error {
- rec, ok := idx.Snapshots[b.record.ID]
- if ok && rec.State == StatePending {
- delete(idx.Snapshots, rec.ID)
- delete(idx.Names, rec.Name)
- }
- return nil
- })
- removeErr := errors.Join(os.RemoveAll(b.record.StagingDir), os.RemoveAll(b.record.DataDir))
- releaseErr := b.lease.Release()
- b.finished = true
- return errors.Join(err, removeErr, releaseErr)
-}
-
-// List returns ready snapshots ordered by creation time.
-func (s *Store) List() ([]*Record, error) {
- records := make([]*Record, 0)
- err := s.read(func(idx *snapshotIndex) error {
- for _, rec := range idx.Snapshots {
- if rec.State == StateReady {
- records = append(records, cloneRecord(rec))
- }
- }
- return nil
- })
- sort.Slice(records, func(i, j int) bool { return records[i].CreatedAt.Before(records[j].CreatedAt) })
- return records, err
-}
-
-// Scan returns every indexed state for fail-closed GC reconciliation.
-func (s *Store) Scan() ([]*Record, error) {
- records := make([]*Record, 0)
- err := s.read(func(idx *snapshotIndex) error {
- for _, rec := range idx.Snapshots {
- records = append(records, cloneRecord(rec))
- }
- return nil
- })
- return records, err
-}
-
-// IsLeased reports whether a build, reader, restore, or delete owns id.
-func (s *Store) IsLeased(id string) (bool, error) {
- dependent, _, err := s.snapshotDependency(id)
- if err != nil {
- return false, err
- }
- if dependent {
- return true, nil
- }
- lease, err := s.leaser.acquire(context.Background(), id, leaseExclusive, false)
- if errors.Is(err, ErrInUse) {
- return true, nil
- }
- if err != nil {
- return false, err
- }
- return false, lease.Release()
-}
-
-// Inspect resolves a ready snapshot by ID, name, or unambiguous ID prefix.
-func (s *Store) Inspect(ref string) (*Record, error) {
- var result *Record
- err := s.read(func(idx *snapshotIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.Snapshots[id]
- if rec.State != StateReady {
- return fmt.Errorf("SNAPSHOT_NOT_FOUND: %w: %s", ErrNotFound, ref)
- }
- result = cloneRecord(rec)
- return nil
- })
- return result, err
-}
-
-// AcquireRead holds a shared lease for payload inspect/export/restore.
-func (s *Store) AcquireRead(ctx context.Context, ref string) (*Record, *Lease, error) {
- return s.acquireRead(ctx, ref, true)
-}
-
-func (s *Store) acquireRead(ctx context.Context, ref string, touch bool) (*Record, *Lease, error) {
- rec, err := s.Inspect(ref)
- if err != nil {
- return nil, nil, err
- }
- lease, err := s.leaser.acquire(ctx, rec.ID, leaseRead, true)
- if err != nil {
- return nil, nil, err
- }
- var current *Record
- if touch {
- err = s.update(func(idx *snapshotIndex) error {
- candidate := idx.Snapshots[rec.ID]
- if candidate == nil || candidate.State != StateReady {
- return fmt.Errorf("SNAPSHOT_NOT_FOUND: %w: %s", ErrNotFound, rec.ID)
- }
- candidate.LastAccessedAt = time.Now().UTC()
- candidate.UpdatedAt = candidate.LastAccessedAt
- current = cloneRecord(candidate)
- return nil
- })
- } else {
- current, err = s.Inspect(rec.ID)
- }
- if err != nil {
- _ = lease.Release()
- return nil, nil, err
- }
- return current, lease, nil
-}
-
-// LoadManifest reads a ready manifest while holding a shared payload lease.
-func (s *Store) LoadManifest(ctx context.Context, ref string) (*Manifest, error) {
- return s.loadManifest(ctx, ref, true)
-}
-
-// PeekManifest validates and reads a manifest without changing its LRU age.
-// It is intended for GC and dependency scans, not payload consumers.
-func (s *Store) PeekManifest(ctx context.Context, ref string) (*Manifest, error) {
- return s.loadManifest(ctx, ref, false)
-}
-
-func (s *Store) loadManifest(ctx context.Context, ref string, touch bool) (*Manifest, error) {
- rec, lease, err := s.acquireRead(ctx, ref, touch)
- if err != nil {
- return nil, err
- }
- defer lease.Release() //nolint:errcheck
- raw, err := os.ReadFile(filepath.Join(rec.DataDir, ManifestFile)) //nolint:gosec
- if err != nil {
- return nil, fmt.Errorf("read snapshot manifest: %w", err)
- }
- var manifest Manifest
- if err := stdjson.Unmarshal(raw, &manifest); err != nil {
- return nil, fmt.Errorf("decode snapshot manifest: %w", err)
- }
- if (manifest.SchemaVersion != "kumabox.snapshot.v1" && manifest.SchemaVersion != "kumabox.snapshot.v2") || manifest.ID != rec.ID {
- return nil, errors.New("SNAPSHOT_CORRUPT: manifest identity does not match snapshot index")
- }
- return &manifest, nil
-}
-
-// Remove deletes an unused ready snapshot and its payload directory.
-func (s *Store) Remove(ref string) (*Record, error) {
- rec, err := s.Inspect(ref)
- if err != nil {
- return nil, err
- }
- lease, err := s.leaser.acquire(context.Background(), rec.ID, leaseExclusive, false)
- if err != nil {
- if errors.Is(err, ErrInUse) {
- return nil, fmt.Errorf("SNAPSHOT_IN_USE: %w: %s", ErrInUse, rec.Name)
- }
- return nil, err
- }
- defer lease.Release() //nolint:errcheck
- if dependent, vmName, err := s.snapshotDependency(rec.ID); err != nil {
- return nil, err
- } else if dependent {
- return nil, fmt.Errorf("SNAPSHOT_IN_USE: %w: %s is required by VM %s", ErrInUse, rec.Name, vmName)
- }
-
- var removing *Record
- err = s.update(func(idx *snapshotIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- current := idx.Snapshots[id]
- if current.State != StateReady {
- return fmt.Errorf("SNAPSHOT_NOT_FOUND: %w: %s", ErrNotFound, ref)
- }
- current.State = StateDeleting
- current.UpdatedAt = time.Now().UTC()
- removing = cloneRecord(current)
- return nil
- })
- if err != nil {
- return nil, err
- }
- if err := os.RemoveAll(removing.DataDir); err != nil {
- _ = s.update(func(idx *snapshotIndex) error {
- if current := idx.Snapshots[removing.ID]; current != nil && current.State == StateDeleting {
- current.State = StateReady
- current.UpdatedAt = time.Now().UTC()
- }
- return nil
- })
- return nil, fmt.Errorf("remove snapshot data directory: %w", err)
- }
- err = s.update(func(idx *snapshotIndex) error {
- current := idx.Snapshots[removing.ID]
- if current == nil || current.State != StateDeleting {
- return errors.New("snapshot delete transaction lost its index record")
- }
- delete(idx.Snapshots, removing.ID)
- delete(idx.Names, removing.Name)
- return nil
- })
- if err != nil {
- return nil, err
- }
- removing.State = StateReady
- return removing, nil
-}
-
-func (s *Store) snapshotDependency(snapshotID string) (bool, string, error) {
- if s.vmReader == nil {
- return false, "", errors.New("snapshot dependency reader is not configured")
- }
- records, err := s.vmReader.List()
- if err != nil {
- return false, "", fmt.Errorf("inspect snapshot dependencies: %w", err)
- }
- for _, rec := range records {
- if rec.SnapshotDependency != nil && rec.SnapshotDependency.SnapshotID == snapshotID {
- return true, rec.Name, nil
- }
- if rec.Hibernate != nil && rec.Hibernate.SnapshotID == snapshotID {
- return true, rec.Name, nil
- }
- }
- return false, "", nil
-}
-
-func (s *Store) read(fn func(*snapshotIndex) error) error {
- return s.withIndex(false, fn)
-}
-
-func (s *Store) update(fn func(*snapshotIndex) error) error {
- return s.withIndex(true, fn)
-}
-
-func (s *Store) withIndex(write bool, fn func(*snapshotIndex) error) error {
- ctx := context.Background()
- if write {
- return s.engine.Update(ctx, meta.Scope{Write: "snapshots"}, meta.CommitDurable, func(writer meta.Writer) error {
- idx, err := s.readIndex(ctx, writer)
- if err != nil {
- return err
- }
- if err := fn(idx); err != nil {
- return err
- }
- return snapshotIndexCollection.Upsert(ctx, writer, snapshotIndexRecord, idx)
- })
- }
- return s.engine.View(ctx, []meta.Namespace{"snapshots"}, func(reader meta.Reader) error {
- idx, err := s.readIndex(ctx, reader)
- if err != nil {
- return err
- }
- return fn(idx)
- })
-}
-
-func (s *Store) readIndex(ctx context.Context, reader meta.Reader) (*snapshotIndex, error) {
- idx, err := snapshotIndexCollection.Get(ctx, reader, snapshotIndexRecord)
- if errors.Is(err, meta.ErrNotFound) {
- idx = &snapshotIndex{}
- } else if err != nil {
- return nil, fmt.Errorf("read snapshot index: %w", err)
- }
- idx.init()
- return idx, nil
-}
-
-func newID() (string, error) {
- var raw [8]byte
- if _, err := rand.Read(raw[:]); err != nil {
- return "", fmt.Errorf("generate snapshot ID: %w", err)
- }
- return "snap_" + hex.EncodeToString(raw[:]), nil
-}
-
-func validateName(name string) error {
- if strings.TrimSpace(name) == "" {
- return errors.New("snapshot name must not be empty")
- }
- if name == "." || name == ".." || strings.ContainsAny(name, `/\\`) {
- return fmt.Errorf("snapshot name %q is not safe", name)
- }
- return nil
-}
-
-func validateID(id string) error {
- if !strings.HasPrefix(id, "snap_") || strings.ContainsAny(id, `/\\`) {
- return fmt.Errorf("snapshot ID %q is not safe", id)
- }
- return nil
-}
diff --git a/internal/snapshot/store_test.go b/internal/snapshot/store_test.go
deleted file mode 100644
index 2ff5850..0000000
--- a/internal/snapshot/store_test.go
+++ /dev/null
@@ -1,307 +0,0 @@
-package snapshot
-
-import (
- "context"
- "errors"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestStoreReserveFinalizeAndList(t *testing.T) {
- t.Parallel()
- store := NewStore(t.TempDir())
- build, err := store.Reserve(context.Background(), "first")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = build.Abort() })
- if records, err := store.List(); err != nil || len(records) != 0 {
- t.Fatalf("pending list = %+v, err = %v", records, err)
- }
- if _, err := store.Inspect("first"); !errors.Is(err, ErrNotFound) {
- t.Fatalf("inspect pending error = %v, want ErrNotFound", err)
- }
- if err := os.WriteFile(filepath.Join(build.Record().StagingDir, "snapshot.json"), []byte("{}\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- if err := build.SetPerformance(CaptureMetrics{PauseDurationMs: 12, PublicationDurationMs: 34}); err != nil {
- t.Fatal(err)
- }
- ready, err := build.Finalize(4096)
- if err != nil {
- t.Fatal(err)
- }
- if ready.State != StateReady || ready.StagingDir != "" || ready.SizeBytes != 4096 {
- t.Fatalf("finalized record = %+v", ready)
- }
- if ready.Performance == nil || ready.Performance.PauseDurationMs != 12 || ready.Performance.PublicationDurationMs != 34 {
- t.Fatalf("capture performance = %+v", ready.Performance)
- }
- if _, err := os.Stat(filepath.Join(ready.DataDir, "snapshot.json")); err != nil {
- t.Fatal(err)
- }
- records, err := store.List()
- if err != nil || len(records) != 1 || records[0].ID != ready.ID {
- t.Fatalf("ready list = %+v, err = %v", records, err)
- }
- inspected, err := store.Inspect(ready.ID[:10])
- if err != nil || inspected.ID != ready.ID {
- t.Fatalf("inspect prefix = %+v, err = %v", inspected, err)
- }
-}
-
-func TestBuildFinalizeRetriesAfterPayloadRename(t *testing.T) {
- store := NewStore(t.TempDir())
- build, err := store.Reserve(t.Context(), "retry-publish")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = build.Abort() })
- if err := os.WriteFile(filepath.Join(build.Record().StagingDir, ManifestFile), []byte("{}\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- injected := errors.New("injected after payload rename")
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == fault.SnapshotAfterRename {
- return injected
- }
- return nil
- }))
- if _, err := build.FinalizeContext(ctx, 4096); !errors.Is(err, injected) {
- t.Fatalf("FinalizeContext() error = %v, want %v", err, injected)
- }
- if _, err := os.Stat(build.Record().DataDir); err != nil {
- t.Fatalf("renamed payload missing: %v", err)
- }
- if records, err := store.List(); err != nil || len(records) != 0 {
- t.Fatalf("ready snapshots before retry = %+v, err = %v", records, err)
- }
- ready, err := build.FinalizeContext(t.Context(), 4096)
- if err != nil {
- t.Fatal(err)
- }
- if ready.State != StateReady {
- t.Fatalf("retry state = %s, want %s", ready.State, StateReady)
- }
-}
-
-func TestBuildAbortRemovesRenamedUnpublishedPayload(t *testing.T) {
- store := NewStore(t.TempDir())
- build, err := store.Reserve(t.Context(), "abort-renamed")
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(build.Record().StagingDir, ManifestFile), []byte("{}\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- injected := errors.New("injected after payload rename")
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == fault.SnapshotAfterRename {
- return injected
- }
- return nil
- }))
- if _, err := build.FinalizeContext(ctx, 1); !errors.Is(err, injected) {
- t.Fatalf("FinalizeContext() error = %v, want %v", err, injected)
- }
- dataDir := build.Record().DataDir
- if err := build.Abort(); err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(dataDir); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("unpublished payload remains after abort: %v", err)
- }
- if records, err := store.Scan(); err != nil || len(records) != 0 {
- t.Fatalf("snapshot index after abort = %+v, err = %v", records, err)
- }
-}
-
-func TestStoreRecoversPreviousIndexGeneration(t *testing.T) {
- store := NewStore(t.TempDir())
- ready := createReadySnapshot(t, store, "recoverable")
- second, err := store.Reserve(context.Background(), "transient")
- if err != nil {
- t.Fatal(err)
- }
- if err := second.Abort(); err != nil {
- t.Fatal(err)
- }
-
- indexPath := filepath.Join(store.rootDir, "index.json")
- if err := os.WriteFile(indexPath, []byte("{"), 0o600); err != nil {
- t.Fatal(err)
- }
- recovered, err := store.Inspect(ready.ID)
- if err != nil {
- t.Fatalf("inspect recovered snapshot: %v", err)
- }
- if recovered.ID != ready.ID {
- t.Fatalf("recovered ID = %s, want %s", recovered.ID, ready.ID)
- }
-}
-
-func TestStoreReserveRejectsNameConflict(t *testing.T) {
- t.Parallel()
- store := NewStore(t.TempDir())
- first, err := store.Reserve(context.Background(), "duplicate")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = first.Abort() })
- if _, err := store.Reserve(context.Background(), "duplicate"); !errors.Is(err, ErrNameConflict) {
- t.Fatalf("reserve error = %v, want ErrNameConflict", err)
- }
-}
-
-func TestStoreRemoveRejectsActiveReadLease(t *testing.T) {
- t.Parallel()
- store := NewStore(t.TempDir())
- ready := createReadySnapshot(t, store, "leased")
- _, lease, err := store.AcquireRead(context.Background(), ready.ID)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := store.Remove(ready.ID); !errors.Is(err, ErrInUse) {
- t.Fatalf("remove error = %v, want ErrInUse", err)
- }
- if err := lease.Release(); err != nil {
- t.Fatal(err)
- }
- removed, err := store.Remove(ready.Name)
- if err != nil {
- t.Fatal(err)
- }
- if removed.ID != ready.ID {
- t.Fatalf("removed = %+v", removed)
- }
- if _, err := os.Stat(ready.DataDir); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("snapshot payload still exists: %v", err)
- }
-}
-
-func TestStoreAcquireReadTouchesLastAccessButPeekManifestDoesNot(t *testing.T) {
- t.Parallel()
- store := NewStore(t.TempDir())
- ready := createReadySnapshot(t, store, "access-time")
-
- if _, err := store.PeekManifest(t.Context(), ready.ID); err == nil || !strings.Contains(err.Error(), "manifest identity") {
- // createReadySnapshot intentionally writes an empty test manifest. Peek
- // must still avoid touching the record when validation fails.
- if err == nil {
- t.Fatal("expected invalid fixture manifest")
- }
- }
- afterPeek, err := store.Inspect(ready.ID)
- if err != nil {
- t.Fatal(err)
- }
- if !afterPeek.LastAccessedAt.Equal(ready.LastAccessedAt) {
- t.Fatalf("peek changed last access from %s to %s", ready.LastAccessedAt, afterPeek.LastAccessedAt)
- }
-
- time.Sleep(time.Millisecond)
- _, lease, err := store.AcquireRead(t.Context(), ready.ID)
- if err != nil {
- t.Fatal(err)
- }
- if err := lease.Release(); err != nil {
- t.Fatal(err)
- }
- afterRead, err := store.Inspect(ready.ID)
- if err != nil {
- t.Fatal(err)
- }
- if !afterRead.LastAccessedAt.After(ready.LastAccessedAt) {
- t.Fatalf("last access = %s, want after %s", afterRead.LastAccessedAt, ready.LastAccessedAt)
- }
-}
-
-func TestStoreRemoveRejectsDurableVMDependency(t *testing.T) {
- t.Parallel()
- rootDir := t.TempDir()
- store := NewStore(rootDir)
- ready := createReadySnapshot(t, store, "runtime-pinned")
- vmStore := vm.New(rootDir)
- rec, err := vmStore.Create(vm.CreateRequest{
- Name: "dependent", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd", RunDir: filepath.Join(rootDir, "run"), LogDir: filepath.Join(rootDir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := vmStore.BeginRestore(rec.ID, ready.ID, "ondemand"); err != nil {
- t.Fatal(err)
- }
- if _, err := vmStore.CompleteRestore(rec.ID, 1234, filepath.Join(rec.RunDir, "ch.sock"), time.Second, nil); err != nil {
- t.Fatal(err)
- }
- if _, err := store.Remove(ready.ID); !errors.Is(err, ErrInUse) {
- t.Fatalf("remove error = %v, want ErrInUse", err)
- }
- if leased, err := store.IsLeased(ready.ID); err != nil || !leased {
- t.Fatalf("durable lease = %t, err = %v", leased, err)
- }
- if err := vmStore.UpdateStates([]string{rec.ID}, vm.StateStopped); err != nil {
- t.Fatal(err)
- }
- if _, err := store.Remove(ready.ID); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestStoreRemoveRejectsHibernateSnapshot(t *testing.T) {
- t.Parallel()
- rootDir := t.TempDir()
- store := NewStore(rootDir)
- ready := createReadySnapshot(t, store, "hibernate-pinned")
- vmStore := vm.New(rootDir)
- rec, err := vmStore.Create(vm.CreateRequest{
- Name: "hibernated", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd", RunDir: filepath.Join(rootDir, "run"), LogDir: filepath.Join(rootDir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := vmStore.CompleteHibernate(rec.ID, ready.ID); err != nil {
- t.Fatal(err)
- }
- if _, err := store.Remove(ready.ID); !errors.Is(err, ErrInUse) {
- t.Fatalf("remove hibernate snapshot error = %v", err)
- }
-}
-
-func TestBuildFinalizeRequiresManifest(t *testing.T) {
- t.Parallel()
- store := NewStore(t.TempDir())
- build, err := store.Reserve(context.Background(), "missing-manifest")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = build.Abort() })
- if _, err := build.Finalize(0); err == nil {
- t.Fatal("expected missing manifest error")
- }
- if records, err := store.List(); err != nil || len(records) != 0 {
- t.Fatalf("failed build leaked ready record: %+v, err = %v", records, err)
- }
-}
-
-func createReadySnapshot(t *testing.T, store *Store, name string) *Record {
- t.Helper()
- build, err := store.Reserve(context.Background(), name)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(build.Record().StagingDir, "snapshot.json"), []byte("{}\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- ready, err := build.Finalize(1)
- if err != nil {
- t.Fatal(err)
- }
- return ready
-}
diff --git a/internal/snapshot/verify.go b/internal/snapshot/verify.go
deleted file mode 100644
index eba895b..0000000
--- a/internal/snapshot/verify.go
+++ /dev/null
@@ -1,434 +0,0 @@
-package snapshot
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "slices"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-type nativeConfig struct {
- CPUs struct {
- BootVCPUs int `json:"boot_vcpus"`
- } `json:"cpus"`
- Memory struct {
- Size int64 `json:"size"`
- } `json:"memory"`
- Disks []struct {
- Path string `json:"path"`
- Readonly bool `json:"readonly"`
- ImageType string `json:"image_type"`
- } `json:"disks"`
- Nets []json.RawMessage `json:"net"`
- Vsock json.RawMessage `json:"vsock"`
-}
-
-type nativeDeviceManifest struct {
- DeviceManifest
- VCPUs int
- MemoryBytes int64
-}
-
-type NativeVerifyTarget struct {
- VM *vm.VMRecord
- Host backend.NativeHost
-}
-
-// VerifyNative validates payload integrity and restore compatibility without
-// mutating the target VM or acquiring backend resources.
-func (s *Store) VerifyNative(ctx context.Context, ref string, target NativeVerifyTarget) (*Manifest, error) {
- rec, lease, err := s.AcquireRead(ctx, ref)
- if err != nil {
- return nil, err
- }
- defer lease.Release() //nolint:errcheck
- return s.VerifyNativeRecord(ctx, rec, target)
-}
-
-// VerifyNativeRecord validates a record whose caller already holds a read
-// lease. Restore uses this form to keep one lease across preflight, staging,
-// destructive mutation, and backend resume.
-func (s *Store) VerifyNativeRecord(ctx context.Context, rec *Record, target NativeVerifyTarget) (*Manifest, error) {
- if rec == nil {
- return nil, errors.New("SNAPSHOT_NOT_FOUND: snapshot record is required")
- }
- if target.VM == nil {
- return nil, errors.New("SNAPSHOT_INCOMPATIBLE: target VM is required")
- }
- manifest, err := s.VerifyNativePayloadRecord(ctx, rec, target.Host)
- if err != nil {
- return nil, err
- }
- if err := verifyNativeVM(ctx, manifest, target.VM); err != nil {
- return nil, err
- }
- return manifest, nil
-}
-
-// VerifyNativePayloadRecord validates immutable payload and host compatibility
-// without requiring the source VM to still exist. Clone uses this before it
-// allocates any new VM or provider resources.
-func (s *Store) VerifyNativePayloadRecord(ctx context.Context, rec *Record, host backend.NativeHost) (*Manifest, error) {
- if rec == nil {
- return nil, errors.New("SNAPSHOT_NOT_FOUND: snapshot record is required")
- }
- manifest, err := loadNativeManifest(rec)
- if err != nil {
- return nil, err
- }
- if err := verifyNativeFiles(ctx, rec.DataDir, manifest); err != nil {
- return nil, err
- }
- if err := verifyNativeConfig(rec.DataDir, manifest); err != nil {
- return nil, err
- }
- if err := verifyNativeHost(manifest, NativeVerifyTarget{Host: host}); err != nil {
- return nil, err
- }
- return manifest, nil
-}
-
-// VerifyNativeCloneTarget checks the newly allocated clone shape while
-// intentionally allowing new VM paths and network identities.
-func VerifyNativeCloneTarget(ctx context.Context, manifest *Manifest, target *vm.VMRecord) error {
- if manifest == nil || target == nil {
- return errors.New("SNAPSHOT_INCOMPATIBLE: clone target is required")
- }
- if manifest.Machine.VCPUs != target.CPUs || manifest.Machine.MemoryBytes != target.EffectiveMemoryBytes() {
- return errors.New("SNAPSHOT_INCOMPATIBLE: clone vCPU or memory shape mismatch")
- }
- if !cloneDevicesMatch(manifest.Devices, target) {
- return errors.New("SNAPSHOT_INCOMPATIBLE: clone device topology mismatch")
- }
- return verifyNativeVMAssets(ctx, manifest, target)
-}
-
-func loadNativeManifest(rec *Record) (*Manifest, error) {
- raw, err := os.ReadFile(filepath.Join(rec.DataDir, ManifestFile)) //nolint:gosec
- if err != nil {
- return nil, fmt.Errorf("SNAPSHOT_CORRUPT: read manifest: %w", err)
- }
- var manifest Manifest
- if err := json.Unmarshal(raw, &manifest); err != nil {
- return nil, fmt.Errorf("SNAPSHOT_CORRUPT: decode manifest: %w", err)
- }
- if manifest.SchemaVersion != NativeSchemaV2 || manifest.ID != rec.ID || manifest.Type != NativeType {
- return nil, errors.New("SNAPSHOT_INCOMPATIBLE: snapshot is not a native v2 snapshot")
- }
- if manifest.Consistency != "crash" {
- return nil, fmt.Errorf("SNAPSHOT_INCOMPATIBLE: native snapshot consistency %q is unsupported", manifest.Consistency)
- }
- if manifest.Native == nil || manifest.Backend == nil || manifest.Machine == nil || manifest.Boot == nil || manifest.Devices == nil {
- return nil, errors.New("SNAPSHOT_CORRUPT: native compatibility metadata is incomplete")
- }
- return &manifest, nil
-}
-
-func verifyNativeFiles(ctx context.Context, dataDir string, manifest *Manifest) error {
- declared := make(map[string]string, len(manifest.Native.Files)+len(manifest.Disks))
- for _, file := range manifest.Native.Files {
- if _, exists := declared[file.Path]; exists {
- return fmt.Errorf("SNAPSHOT_CORRUPT: duplicate payload %s", file.Path)
- }
- if err := verifyPayloadFile(ctx, dataDir, file.Path, file.SizeBytes, file.SHA256); err != nil {
- return err
- }
- declared[file.Path] = file.SHA256
- }
- for _, disk := range manifest.Disks {
- if _, exists := declared[disk.Path]; exists {
- return fmt.Errorf("SNAPSHOT_CORRUPT: duplicate payload %s", disk.Path)
- }
- if err := verifyPayloadFile(ctx, dataDir, disk.Path, disk.VirtualSizeBytes, disk.SHA256); err != nil {
- return err
- }
- declared[disk.Path] = disk.SHA256
- }
- if err := verifyPayloadInventory(dataDir, declared); err != nil {
- return err
- }
- raw, err := os.ReadFile(filepath.Join(dataDir, "checksums.txt")) //nolint:gosec
- if errors.Is(err, os.ErrNotExist) && allDigestsEmpty(declared) {
- return nil
- }
- if err != nil {
- return fmt.Errorf("SNAPSHOT_CORRUPT: read checksums: %w", err)
- }
- checksums, err := parseChecksums(string(raw))
- if err != nil {
- return err
- }
- if len(checksums) != len(declared) {
- return errors.New("SNAPSHOT_CORRUPT: checksum set does not match native payload inventory")
- }
- for path, digest := range declared {
- if checksums[path] != digest {
- return fmt.Errorf("CHECKSUM_MISMATCH: %s", path)
- }
- }
- return nil
-}
-
-func verifyPayloadInventory(dataDir string, declared map[string]string) error {
- seen := make(map[string]struct{}, len(declared))
- for _, dir := range []string{NativePayloadDir, DiskPayloadDir} {
- entries, err := os.ReadDir(filepath.Join(dataDir, dir))
- if err != nil {
- return fmt.Errorf("SNAPSHOT_CORRUPT: read payload directory %s: %w", dir, err)
- }
- for _, entry := range entries {
- relative := filepath.ToSlash(filepath.Join(dir, entry.Name()))
- if !entry.Type().IsRegular() {
- return fmt.Errorf("SNAPSHOT_CORRUPT: payload %s is not a regular file", relative)
- }
- if _, ok := declared[relative]; !ok {
- return fmt.Errorf("SNAPSHOT_CORRUPT: undeclared payload %s", relative)
- }
- seen[relative] = struct{}{}
- }
- }
- if len(seen) != len(declared) {
- return errors.New("SNAPSHOT_CORRUPT: payload inventory is incomplete")
- }
- return nil
-}
-
-func verifyPayloadFile(ctx context.Context, dataDir, relative string, size int64, expected string) error {
- clean, err := safeArchivePath(relative)
- if err != nil || clean != relative {
- return fmt.Errorf("SNAPSHOT_CORRUPT: unsafe payload path %q", relative)
- }
- path := filepath.Join(dataDir, filepath.FromSlash(clean))
- info, err := os.Lstat(path)
- if err != nil {
- return fmt.Errorf("SNAPSHOT_CORRUPT: payload %s missing: %w", relative, err)
- }
- if !info.Mode().IsRegular() || info.Size() != size {
- return fmt.Errorf("SNAPSHOT_CORRUPT: payload %s shape mismatch", relative)
- }
- if expected == "" {
- return nil
- }
- digest, err := hashFileContext(ctx, path)
- if err != nil {
- return fmt.Errorf("SNAPSHOT_CORRUPT: checksum %s: %w", relative, err)
- }
- if digest != expected {
- return fmt.Errorf("CHECKSUM_MISMATCH: %s", relative)
- }
- return nil
-}
-
-func allDigestsEmpty(declared map[string]string) bool {
- for _, digest := range declared {
- if digest != "" {
- return false
- }
- }
- return true
-}
-
-func verifyNativeConfig(dataDir string, manifest *Manifest) error {
- cfg, err := readNativeConfig(filepath.Join(dataDir, NativePayloadDir, NativeConfigFile))
- if err != nil {
- return err
- }
- if len(cfg.Disks) != len(manifest.Devices.Disks) {
- return errors.New("SNAPSHOT_CORRUPT: native disk topology does not match manifest")
- }
- if cfg.CPUs.BootVCPUs != manifest.Machine.VCPUs || cfg.Memory.Size != manifest.Machine.MemoryBytes || len(cfg.Nets) != manifest.Devices.NICs || (len(cfg.Vsock) > 0) != manifest.Devices.Vsock {
- return errors.New("SNAPSHOT_CORRUPT: native machine topology does not match manifest")
- }
- for i, disk := range manifest.Devices.Disks {
- if cfg.Disks[i].Path != disk.Path || cfg.Disks[i].Readonly != disk.Readonly {
- return fmt.Errorf("SNAPSHOT_CORRUPT: native disk %d does not match manifest", i)
- }
- }
- return nil
-}
-
-func readNativeConfig(path string) (*nativeConfig, error) {
- raw, err := os.ReadFile(path) //nolint:gosec
- if err != nil {
- return nil, fmt.Errorf("SNAPSHOT_CORRUPT: read native config: %w", err)
- }
- var cfg nativeConfig
- if err := json.Unmarshal(raw, &cfg); err != nil {
- return nil, fmt.Errorf("SNAPSHOT_CORRUPT: decode native config: %w", err)
- }
- return &cfg, nil
-}
-
-func buildNativeDeviceManifest(rec *vm.VMRecord, nativeDir string) (*nativeDeviceManifest, error) {
- cfg, err := readNativeConfig(filepath.Join(nativeDir, NativeConfigFile))
- if err != nil {
- return nil, err
- }
- result := &nativeDeviceManifest{
- DeviceManifest: DeviceManifest{Disks: make([]StorageDeviceManifest, 0, len(cfg.Disks)), NICs: len(cfg.Nets), Vsock: len(cfg.Vsock) > 0},
- VCPUs: cfg.CPUs.BootVCPUs, MemoryBytes: cfg.Memory.Size,
- }
- if result.NICs != len(rec.NetworkConfigs) || result.Vsock != (rec.VsockSocket != "") {
- return nil, errors.New("NATIVE_SNAPSHOT_INCOMPATIBLE: backend network or vsock topology differs from VM record")
- }
- matchedStorage := 0
- for _, nativeDisk := range cfg.Disks {
- matched := false
- for _, disk := range rec.StorageConfigs {
- if disk.Path != nativeDisk.Path {
- continue
- }
- if disk.Readonly != nativeDisk.Readonly {
- return nil, fmt.Errorf("NATIVE_SNAPSHOT_INCOMPATIBLE: disk %s readonly state differs from VM record", disk.ID)
- }
- result.Disks = append(result.Disks, StorageDeviceManifest{
- ID: disk.ID, Role: string(disk.EffectiveRole()), Path: disk.Path,
- Readonly: nativeDisk.Readonly, Format: disk.EffectiveFormat(),
- })
- matched = true
- matchedStorage++
- break
- }
- if !matched && rec.Metadata != nil && rec.Metadata.CidataDisk == nativeDisk.Path {
- if !nativeDisk.Readonly {
- return nil, errors.New("NATIVE_SNAPSHOT_INCOMPATIBLE: cidata disk is writable")
- }
- result.Disks = append(result.Disks, StorageDeviceManifest{
- ID: vm.StorageIDCidata, Role: string(vm.StorageRoleCidata), Path: nativeDisk.Path,
- Readonly: nativeDisk.Readonly, Format: vm.FormatRaw,
- })
- matched = true
- }
- if !matched {
- return nil, fmt.Errorf("NATIVE_SNAPSHOT_INCOMPATIBLE: unrecorded disk %s", nativeDisk.Path)
- }
- }
- if matchedStorage != len(rec.StorageConfigs) {
- return nil, errors.New("NATIVE_SNAPSHOT_INCOMPATIBLE: backend disk set differs from VM record")
- }
- return result, nil
-}
-
-func verifyNativeHost(manifest *Manifest, target NativeVerifyTarget) error {
- if manifest.Backend.Name != target.Host.BackendName || manifest.Backend.SnapshotFormat != target.Host.SnapshotFormat {
- return errors.New("SNAPSHOT_INCOMPATIBLE: backend or native snapshot format mismatch")
- }
- if manifest.Backend.Version != target.Host.BackendVersion {
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: backend version %s requires %s", target.Host.BackendVersion, manifest.Backend.Version)
- }
- if manifest.Machine.Architecture != target.Host.Architecture {
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: architecture %s requires %s", target.Host.Architecture, manifest.Machine.Architecture)
- }
- if manifest.Machine.CPUVendor != target.Host.CPUVendor {
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: CPU vendor %s requires %s", target.Host.CPUVendor, manifest.Machine.CPUVendor)
- }
- for _, feature := range manifest.Machine.CPUFeatures {
- if !slices.Contains(target.Host.CPUFeatures, feature) {
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: CPU feature %s is unavailable", feature)
- }
- }
- return nil
-}
-
-func verifyNativeVM(ctx context.Context, manifest *Manifest, target *vm.VMRecord) error {
- if manifest.Source.VMID != target.ID {
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: snapshot belongs to VM %s", manifest.Source.VMID)
- }
- if manifest.Machine.VCPUs != target.CPUs || manifest.Machine.MemoryBytes != target.EffectiveMemoryBytes() {
- return errors.New("SNAPSHOT_INCOMPATIBLE: vCPU or memory shape mismatch")
- }
- if !targetDevicesMatch(manifest.Devices, target) {
- return errors.New("SNAPSHOT_INCOMPATIBLE: device topology mismatch")
- }
- return verifyNativeVMAssets(ctx, manifest, target)
-}
-
-func verifyNativeVMAssets(ctx context.Context, manifest *Manifest, target *vm.VMRecord) error {
- boot, err := buildBootManifest(ctx, target, true)
- if err != nil {
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: resolve boot assets: %w", err)
- }
- if manifest.Boot.KernelDigest != "" && manifest.Boot.KernelDigest != boot.KernelDigest {
- return errors.New("SNAPSHOT_INCOMPATIBLE: kernel asset digest mismatch")
- }
- if manifest.Boot.InitrdDigest != "" && manifest.Boot.InitrdDigest != boot.InitrdDigest {
- return errors.New("SNAPSHOT_INCOMPATIBLE: initrd asset digest mismatch")
- }
- if manifest.Boot.FirmwareDigest != "" && manifest.Boot.FirmwareDigest != boot.FirmwareDigest {
- return errors.New("SNAPSHOT_INCOMPATIBLE: firmware asset digest mismatch")
- }
- if manifest.Source.ImageID != "" {
- if target.Image == nil || target.Image.ID != manifest.Source.ImageID || target.Image.Digest != manifest.Source.ImageDigest {
- return errors.New("SNAPSHOT_INCOMPATIBLE: immutable image digest mismatch")
- }
- }
- if manifest.Base != nil {
- var targetBase *vm.StorageBase
- for _, disk := range target.StorageConfigs {
- if disk.EffectiveRole() == vm.StorageRoleCOW {
- targetBase = disk.Base
- break
- }
- }
- if targetBase == nil || targetBase.Family != manifest.Base.Family || targetBase.ImageID != manifest.Base.ImageID || targetBase.Digest != manifest.Base.Digest || targetBase.Format != manifest.Base.Format || !slices.Equal(targetBase.LayerDigests, manifest.Base.LayerDigests) {
- return errors.New("SNAPSHOT_INCOMPATIBLE: immutable base or layer digest mismatch")
- }
- }
- return nil
-}
-
-func cloneDevicesMatch(devices *DeviceManifest, target *vm.VMRecord) bool {
- if devices == nil || devices.NICs != len(target.NetworkConfigs) || devices.Vsock != (target.VsockSocket != "") {
- return false
- }
- storageByID := make(map[string]vm.StorageConfig, len(target.StorageConfigs))
- for _, disk := range target.StorageConfigs {
- storageByID[disk.ID] = disk
- }
- matched := 0
- for _, device := range devices.Disks {
- if device.Role == string(vm.StorageRoleCidata) {
- if target.Metadata == nil || target.Metadata.CidataDisk == "" || !device.Readonly {
- return false
- }
- continue
- }
- disk, ok := storageByID[device.ID]
- if !ok || string(disk.EffectiveRole()) != device.Role || disk.Readonly != device.Readonly || disk.EffectiveFormat() != device.Format {
- return false
- }
- matched++
- }
- return matched == len(target.StorageConfigs)
-}
-
-func targetDevicesMatch(devices *DeviceManifest, target *vm.VMRecord) bool {
- if devices.NICs != len(target.NetworkConfigs) || devices.Vsock != (target.VsockSocket != "") {
- return false
- }
- storageByPath := make(map[string]vm.StorageConfig, len(target.StorageConfigs))
- for _, disk := range target.StorageConfigs {
- storageByPath[disk.Path] = disk
- }
- matchedStorage := 0
- for _, device := range devices.Disks {
- if device.Role == string(vm.StorageRoleCidata) {
- if target.Metadata == nil || target.Metadata.CidataDisk != device.Path || !device.Readonly {
- return false
- }
- continue
- }
- disk, ok := storageByPath[device.Path]
- if !ok || disk.ID != device.ID || string(disk.EffectiveRole()) != device.Role || disk.Readonly != device.Readonly || disk.EffectiveFormat() != device.Format {
- return false
- }
- matchedStorage++
- }
- return matchedStorage == len(target.StorageConfigs)
-}
diff --git a/internal/snapshot/verify_test.go b/internal/snapshot/verify_test.go
deleted file mode 100644
index a426260..0000000
--- a/internal/snapshot/verify_test.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package snapshot
-
-import (
- "context"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestVerifyNative(t *testing.T) {
- t.Parallel()
-
- store, ready, target, host := buildNativeVerificationFixture(t)
- manifest, err := store.VerifyNative(context.Background(), ready.ID, NativeVerifyTarget{VM: target, Host: host})
- if err != nil {
- t.Fatal(err)
- }
- if manifest.SchemaVersion != "kumabox.snapshot.v2" || manifest.Machine.MemoryBytes != 512<<20 {
- t.Fatalf("manifest = %+v", manifest)
- }
-}
-
-func TestVerifyNativeRejectsPayloadCorruption(t *testing.T) {
- t.Parallel()
-
- store, ready, target, host := buildNativeVerificationFixture(t)
- if err := os.WriteFile(filepath.Join(ready.DataDir, "native", "memory-range-0"), []byte("broken"), 0o600); err != nil {
- t.Fatal(err)
- }
- _, err := store.VerifyNative(context.Background(), ready.ID, NativeVerifyTarget{VM: target, Host: host})
- if err == nil || !strings.Contains(err.Error(), "CHECKSUM_MISMATCH") {
- t.Fatalf("VerifyNative error = %v", err)
- }
-}
-
-func TestVerifyNativeRejectsBackendVersionMismatch(t *testing.T) {
- t.Parallel()
-
- store, ready, target, host := buildNativeVerificationFixture(t)
- host.BackendVersion = "99.0.0"
- _, err := store.VerifyNative(context.Background(), ready.ID, NativeVerifyTarget{VM: target, Host: host})
- if err == nil || !strings.Contains(err.Error(), "SNAPSHOT_INCOMPATIBLE: backend version") {
- t.Fatalf("VerifyNative error = %v", err)
- }
-}
-
-func buildNativeVerificationFixture(t *testing.T) (*Store, *Record, *vm.VMRecord, backend.NativeHost) {
- t.Helper()
- dir := t.TempDir()
- kernel := filepath.Join(dir, "vmlinuz")
- initrd := filepath.Join(dir, "initrd")
- disk := filepath.Join(dir, "data.raw")
- for path, content := range map[string]string{kernel: "kernel", initrd: "initrd", disk: "writable"} {
- if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
- t.Fatal(err)
- }
- }
- target := &vm.VMRecord{
- ID: "kb_source", Name: "source", Backend: "cloud-hypervisor", Kernel: kernel, Initrd: initrd,
- CPUs: 2, MemoryBytes: 512 << 20, VsockSocket: filepath.Join(dir, "vsock.uds"),
- StorageConfigs: []vm.StorageConfig{{
- ID: "data", Role: vm.StorageRoleData, Path: disk, Format: "raw", VirtualSizeBytes: int64(len("writable")),
- }},
- }
- host := backend.NativeHost{
- BackendName: "cloud-hypervisor", BackendVersion: "50.0.0", SnapshotFormat: "cloud-hypervisor-native-v1",
- Architecture: "amd64", CPUVendor: "GenuineIntel", CPUFeatures: []string{"sse4_2"},
- }
- store := NewStore(filepath.Join(dir, "data-root"))
- build, err := store.Reserve(context.Background(), "native")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = build.Abort() })
- staging := build.Record().StagingDir
- if err := os.MkdirAll(filepath.Join(staging, "native"), 0o700); err != nil {
- t.Fatal(err)
- }
- config := `{"cpus":{"boot_vcpus":2},"memory":{"size":536870912},"disks":[{"path":"` + disk + `","readonly":false}],"vsock":{}}`
- for name, content := range map[string]string{"config.json": config, "state.json": "{}", "memory-range-0": "memory"} {
- if err := os.WriteFile(filepath.Join(staging, "native", name), []byte(content), 0o600); err != nil {
- t.Fatal(err)
- }
- }
- if err := os.MkdirAll(filepath.Join(staging, "disks"), 0o700); err != nil {
- t.Fatal(err)
- }
- payload := filepath.Join(staging, "disks", "data.raw")
- if err := os.WriteFile(payload, []byte("writable"), 0o600); err != nil {
- t.Fatal(err)
- }
- digest, err := hashFile(payload)
- if err != nil {
- t.Fatal(err)
- }
- disks := []DiskManifest{{
- ID: "data", Role: "data", Path: "disks/data.raw", Format: "raw",
- VirtualSizeBytes: int64(len("writable")), AllocatedSizeBytes: int64(len("writable")), SHA256: digest, CopyStrategy: "stream",
- }}
- _, size, err := WriteNativeManifest(context.Background(), build, target, disks, host)
- if err != nil {
- t.Fatal(err)
- }
- ready, err := build.Finalize(size)
- if err != nil {
- t.Fatal(err)
- }
- return store, ready, target, host
-}
diff --git a/internal/state/convert.go b/internal/state/convert.go
deleted file mode 100644
index 845579a..0000000
--- a/internal/state/convert.go
+++ /dev/null
@@ -1,528 +0,0 @@
-package state
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/image/oci"
- "github.com/kumabox/kumabox/internal/lock"
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
- metasqlite "github.com/kumabox/kumabox/internal/meta/sqlite"
- "github.com/kumabox/kumabox/internal/metering"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/reference"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const convertedSuffix = ".converted-"
-
-// ConversionResult describes a completed metadata backend switch.
-type ConversionResult struct {
- Backend string `json:"backend"`
- Path string `json:"path,omitempty"`
- Namespaces []ConversionNamespace `json:"namespaces"`
-}
-
-// ConversionNamespace is the verified identity of one logical namespace.
-type ConversionNamespace struct {
- Namespace meta.Namespace `json:"namespace"`
- Records int `json:"records"`
- Digest string `json:"digest"`
-}
-
-type conversionManifest struct {
- Target string `json:"target"`
- StartedAt time.Time `json:"startedAt"`
- Namespaces map[meta.Namespace]*conversionState `json:"namespaces"`
-}
-
-type conversionState struct {
- SourceFiles []string `json:"sourceFiles"`
- Records int `json:"records"`
- Digest string `json:"digest"`
- Done bool `json:"done"`
-}
-
-// ConvertMetadata performs or resumes an offline switch to the backend in cfg.
-// A durable manifest is written before target data, and source files are only
-// retired after every namespace has passed an engine-neutral digest check.
-func ConvertMetadata(ctx context.Context, cfg config.Config) (result ConversionResult, err error) {
- if cfg.Metadata.Backend != "json" && cfg.Metadata.Backend != "sqlite" {
- return result, fmt.Errorf("unsupported metadata conversion target %q", cfg.Metadata.Backend)
- }
- databasePath := SQLiteMetadataPath(cfg)
- manifestPath := filepath.Join(filepath.Dir(databasePath), metasqlite.ConversionManifestName)
- manifest, err := loadConversionManifest(manifestPath)
- if err != nil {
- return result, err
- }
- if manifest != nil && manifest.Target != cfg.Metadata.Backend {
- return result, fmt.Errorf("metadata conversion to %q is already in progress", manifest.Target)
- }
-
- definitions := sqliteDefinitions()
- jsonDefinitions := metadataJSONDefinitions(cfg.Runtime.RootDir)
- if manifest == nil {
- if err := requireFreshTarget(cfg.Metadata.Backend, databasePath, jsonDefinitions); err != nil {
- return result, err
- }
- source, openErr := openConversionSource(cfg.Metadata.Backend, databasePath, definitions, jsonDefinitions)
- if openErr != nil {
- return result, openErr
- }
- if err := checkConversionQuiesced(ctx, cfg.Metadata.Backend, source, definitions, jsonDefinitions); err != nil {
- return result, errors.Join(err, source.Close())
- }
- manifest, err = newConversionManifest(ctx, cfg.Metadata.Backend, databasePath, source, definitions, jsonDefinitions)
- closeErr := source.Close()
- if err != nil {
- return result, errors.Join(err, closeErr)
- }
- if closeErr != nil {
- return result, fmt.Errorf("close metadata conversion source: %w", closeErr)
- }
- if err := saveConversionManifest(manifestPath, manifest); err != nil {
- return result, err
- }
- }
- if !conversionComplete(manifest) {
- if err := copyConversionNamespaces(ctx, cfg.Metadata.Backend, databasePath, definitions, jsonDefinitions, manifestPath, manifest); err != nil {
- return result, err
- }
- }
- if err := fault.Check(ctx, fault.MetadataConvertAfterCopy); err != nil {
- return result, err
- }
- if err := retireConversionSources(ctx, cfg.Metadata.Backend, databasePath, manifest); err != nil {
- return result, err
- }
- if err := removeConversionManifest(manifestPath); err != nil {
- return result, err
- }
- return conversionResult(cfg.Metadata.Backend, databasePath, manifest), nil
-}
-
-func checkConversionQuiesced(ctx context.Context, target string, source meta.MetaEngine, definitions []metasqlite.Namespace, jsonDefinitions []metajson.Namespace) error {
- probeContext, cancel := context.WithTimeout(ctx, time.Second)
- defer cancel()
- if target == "json" {
- err := source.Update(probeContext, meta.Scope{Write: definitions[0].Name}, meta.CommitDurable, func(meta.Writer) error { return nil })
- if err != nil {
- return fmt.Errorf("sqlite metadata source is busy; stop KumaBox commands before converting: %w", err)
- }
- return nil
- }
- for _, definition := range jsonDefinitions {
- key := filepath.Base(definition.LockPath)
- key = strings.TrimSuffix(key, filepath.Ext(key))
- fileLock, err := lock.NewLocker(filepath.Dir(definition.LockPath)).Acquire(probeContext, key)
- if err != nil {
- return fmt.Errorf("json metadata namespace %s is busy; stop KumaBox commands before converting: %w", definition.Name, err)
- }
- if err := fileLock.Release(); err != nil {
- return err
- }
- }
- return nil
-}
-
-// ConvertJSONToSQLite is retained for callers of the previous one-way API.
-func ConvertJSONToSQLite(ctx context.Context, rootDir, databasePath string) (statuses []metasqlite.NamespaceStatus, err error) {
- cfg := config.Default()
- cfg.Runtime.RootDir = rootDir
- cfg.Metadata.Backend = "sqlite"
- cfg.Metadata.Path = databasePath
- if _, err := ConvertMetadata(ctx, cfg); err != nil {
- return nil, err
- }
- store, err := metasqlite.Open(SQLiteMetadataPath(cfg), sqliteDefinitions()...)
- if err != nil {
- return nil, err
- }
- defer func() { err = errors.Join(err, store.Close()) }()
- return store.Status(ctx)
-}
-
-func copyConversionNamespaces(ctx context.Context, target, databasePath string, definitions []metasqlite.Namespace, jsonDefinitions []metajson.Namespace, manifestPath string, manifest *conversionManifest) (err error) {
- source, err := openConversionSource(target, databasePath, definitions, jsonDefinitions)
- if err != nil {
- return err
- }
- defer func() { err = errors.Join(err, source.Close()) }()
- destination, err := openConversionTarget(ctx, target, databasePath, definitions, jsonDefinitions)
- if err != nil {
- return err
- }
- defer func() { err = errors.Join(err, destination.Close()) }()
- for _, definition := range definitions {
- state := manifest.Namespaces[definition.Name]
- if state == nil {
- return fmt.Errorf("metadata namespace %q is missing from conversion manifest", definition.Name)
- }
- if state.Done {
- continue
- }
- if err := copyConversionNamespace(ctx, source, destination, definition, state); err != nil {
- return fmt.Errorf("convert metadata namespace %s: %w", definition.Name, err)
- }
- if target == "sqlite" {
- sqliteStore, ok := destination.(*metasqlite.Store)
- if !ok {
- return fmt.Errorf("sqlite conversion target has unexpected type %T", destination)
- }
- if err := sqliteStore.MarkConverted(ctx, definition.Name, "json", state.Digest, state.Records); err != nil {
- return err
- }
- } else if err := duplicateJSONGeneration(jsonDefinitions, definition.Name); err != nil {
- return err
- }
- state.Done = true
- if err := saveConversionManifest(manifestPath, manifest); err != nil {
- return err
- }
- if err := fault.Check(ctx, fault.MetadataConvertNamespace); err != nil {
- return err
- }
- }
- for _, definition := range definitions {
- state := manifest.Namespaces[definition.Name]
- digest, records, err := namespaceDigest(ctx, source, definition)
- if err != nil {
- return err
- }
- if digest != state.Digest || records != state.Records {
- return fmt.Errorf("metadata source changed during conversion in namespace %s", definition.Name)
- }
- }
- return nil
-}
-
-func copyConversionNamespace(ctx context.Context, source, destination meta.MetaEngine, definition metasqlite.Namespace, state *conversionState) error {
- sourceDigest, sourceRecords, err := namespaceDigest(ctx, source, definition)
- if err != nil {
- return err
- }
- if sourceDigest != state.Digest || sourceRecords != state.Records {
- return fmt.Errorf("source changed after conversion manifest was written")
- }
- targetDigest, targetRecords, err := namespaceDigest(ctx, destination, definition)
- if err != nil {
- return err
- }
- if targetDigest == state.Digest && targetRecords == state.Records {
- return nil
- }
- if targetRecords != 0 {
- return fmt.Errorf("target is not fresh: contains %d record(s)", targetRecords)
- }
- if _, err := meta.TransferWithReport(ctx, source, destination, []meta.TableSet{{Namespace: definition.Name, Tables: definition.Tables}}); err != nil {
- return err
- }
- targetDigest, targetRecords, err = namespaceDigest(ctx, destination, definition)
- if err != nil {
- return err
- }
- if targetDigest != state.Digest || targetRecords != state.Records {
- return fmt.Errorf("target verification failed: records=%d want=%d", targetRecords, state.Records)
- }
- return nil
-}
-
-func newConversionManifest(ctx context.Context, target, databasePath string, source meta.MetaEngine, definitions []metasqlite.Namespace, jsonDefinitions []metajson.Namespace) (*conversionManifest, error) {
- manifest := &conversionManifest{Target: target, StartedAt: time.Now().UTC(), Namespaces: make(map[meta.Namespace]*conversionState, len(definitions))}
- for _, definition := range definitions {
- digest, records, err := namespaceDigest(ctx, source, definition)
- if err != nil {
- return nil, err
- }
- manifest.Namespaces[definition.Name] = &conversionState{
- SourceFiles: conversionSourceFiles(target, databasePath, jsonDefinitions, definition.Name),
- Records: records,
- Digest: digest,
- }
- }
- return manifest, nil
-}
-
-func namespaceDigest(ctx context.Context, engine meta.MetaEngine, definition metasqlite.Namespace) (string, int, error) {
- hash := sha256.New()
- records := 0
- err := engine.View(ctx, []meta.Namespace{definition.Name}, func(reader meta.Reader) error {
- for _, table := range definition.Tables {
- type row struct {
- id meta.RecordID
- raw json.RawMessage
- }
- var rows []row
- if err := reader.ScanRaw(ctx, definition.Name, table, func(id meta.RecordID, raw json.RawMessage) error {
- rows = append(rows, row{id: id, raw: append(json.RawMessage(nil), raw...)})
- return nil
- }); err != nil {
- return err
- }
- sort.Slice(rows, func(i, j int) bool { return rows[i].id < rows[j].id })
- for _, row := range rows {
- _, _ = fmt.Fprintf(hash, "%s\x00%s\x00%s\x00%s\n", definition.Name, table, row.id, row.raw)
- records++
- }
- }
- return nil
- })
- if err != nil {
- return "", 0, err
- }
- return hex.EncodeToString(hash.Sum(nil)), records, nil
-}
-
-func openConversionSource(target, databasePath string, definitions []metasqlite.Namespace, jsonDefinitions []metajson.Namespace) (meta.MetaEngine, error) {
- if target == "sqlite" {
- return metajson.Open(jsonDefinitions...)
- }
- if _, err := os.Stat(databasePath); err != nil {
- return nil, fmt.Errorf("open sqlite conversion source: %w", err)
- }
- return metasqlite.OpenForRecovery(databasePath, definitions...)
-}
-
-func openConversionTarget(ctx context.Context, target, databasePath string, definitions []metasqlite.Namespace, jsonDefinitions []metajson.Namespace) (meta.MetaEngine, error) {
- if target == "json" {
- return metajson.Open(jsonDefinitions...)
- }
- if _, err := os.Stat(databasePath); errors.Is(err, os.ErrNotExist) {
- if err := metasqlite.InitForRecovery(ctx, databasePath, definitions...); err != nil {
- return nil, err
- }
- } else if err != nil {
- return nil, err
- }
- return metasqlite.OpenForRecovery(databasePath, definitions...)
-}
-
-func requireFreshTarget(target, databasePath string, jsonDefinitions []metajson.Namespace) error {
- if target == "sqlite" {
- if _, err := os.Stat(databasePath); err == nil {
- return fmt.Errorf("sqlite conversion target %s already exists", databasePath)
- } else if !errors.Is(err, os.ErrNotExist) {
- return err
- }
- return nil
- }
- for _, definition := range jsonDefinitions {
- for _, path := range []string{definition.FilePath, definition.FilePath + ".prev"} {
- if _, err := os.Stat(path); err == nil {
- return fmt.Errorf("json conversion target %s already exists", path)
- } else if !errors.Is(err, os.ErrNotExist) {
- return err
- }
- }
- }
- return nil
-}
-
-func retireConversionSources(ctx context.Context, target, databasePath string, manifest *conversionManifest) error {
- if target == "json" {
- if _, err := os.Stat(databasePath); err == nil {
- if err := metasqlite.Checkpoint(ctx, databasePath); err != nil {
- return err
- }
- } else if !errors.Is(err, os.ErrNotExist) {
- return err
- }
- }
- stamp := time.Now().UTC().Format("20060102T150405Z")
- seen := make(map[string]struct{})
- for _, state := range manifest.Namespaces {
- for _, path := range state.SourceFiles {
- if _, exists := seen[path]; exists {
- continue
- }
- seen[path] = struct{}{}
- for _, candidate := range sourceFileCandidates(target, path) {
- if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
- continue
- } else if err != nil {
- return err
- }
- if err := os.Rename(candidate, candidate+convertedSuffix+stamp); err != nil {
- return fmt.Errorf("retire metadata source %s: %w", candidate, err)
- }
- if err := syncDirectory(filepath.Dir(candidate)); err != nil {
- return err
- }
- if err := fault.Check(ctx, fault.MetadataConvertRetired); err != nil {
- return err
- }
- }
- }
- }
- return nil
-}
-
-func sourceFileCandidates(target, path string) []string {
- if target == "json" {
- return []string{path, path + "-wal", path + "-shm"}
- }
- return []string{path}
-}
-
-func conversionSourceFiles(target, databasePath string, definitions []metajson.Namespace, namespace meta.Namespace) []string {
- if target == "json" {
- return []string{databasePath}
- }
- for _, definition := range definitions {
- if definition.Name == string(namespace) {
- return []string{definition.FilePath, definition.FilePath + ".prev"}
- }
- }
- return nil
-}
-
-func duplicateJSONGeneration(definitions []metajson.Namespace, namespace meta.Namespace) error {
- for _, definition := range definitions {
- if definition.Name != string(namespace) {
- continue
- }
- raw, err := os.ReadFile(definition.FilePath) //nolint:gosec
- if errors.Is(err, os.ErrNotExist) {
- return nil
- }
- if err != nil {
- return err
- }
- return writeAtomicFile(definition.FilePath+".prev", raw, 0o600)
- }
- return fmt.Errorf("json metadata namespace %q is not declared", namespace)
-}
-
-func metadataJSONDefinitions(rootDir string) []metajson.Namespace {
- definitions := []metajson.Namespace{
- vm.JSONNamespace(rootDir),
- image.JSONNamespace(rootDir),
- snapshot.JSONNamespace(rootDir),
- }
- definitions = append(definitions, kbnetwork.JSONNamespaces(rootDir)...)
- definitions = append(definitions,
- oci.JSONNamespace(rootDir),
- operation.JSONNamespace(rootDir),
- reference.JSONNamespace(rootDir),
- metering.JSONNamespace(rootDir),
- )
- return definitions
-}
-
-func loadConversionManifest(path string) (*conversionManifest, error) {
- raw, err := os.ReadFile(path) //nolint:gosec
- if errors.Is(err, os.ErrNotExist) {
- return nil, nil
- }
- if err != nil {
- return nil, fmt.Errorf("read metadata conversion manifest: %w", err)
- }
- var manifest conversionManifest
- if err := json.Unmarshal(raw, &manifest); err != nil {
- return nil, fmt.Errorf("decode metadata conversion manifest: %w", err)
- }
- if manifest.Target == "" || len(manifest.Namespaces) == 0 {
- return nil, fmt.Errorf("metadata conversion manifest is incomplete: %w", meta.ErrCorrupt)
- }
- return &manifest, nil
-}
-
-func saveConversionManifest(path string, manifest *conversionManifest) error {
- raw, err := json.MarshalIndent(manifest, "", " ")
- if err != nil {
- return fmt.Errorf("encode metadata conversion manifest: %w", err)
- }
- raw = append(raw, '\n')
- return writeAtomicFile(path, raw, 0o600)
-}
-
-func removeConversionManifest(path string) error {
- if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
- return fmt.Errorf("remove metadata conversion manifest: %w", err)
- }
- return syncDirectory(filepath.Dir(path))
-}
-
-func writeAtomicFile(path string, raw []byte, mode os.FileMode) (err error) {
- if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
- return err
- }
- temporary, err := os.CreateTemp(filepath.Dir(path), ".kumabox-metadata-*")
- if err != nil {
- return err
- }
- temporaryPath := temporary.Name()
- defer func() {
- if err != nil {
- _ = os.Remove(temporaryPath)
- }
- }()
- if err := temporary.Chmod(mode); err != nil {
- _ = temporary.Close()
- return err
- }
- if _, err := temporary.Write(raw); err != nil {
- _ = temporary.Close()
- return err
- }
- if err := temporary.Sync(); err != nil {
- _ = temporary.Close()
- return err
- }
- if err := temporary.Close(); err != nil {
- return err
- }
- if err := os.Rename(temporaryPath, path); err != nil {
- return err
- }
- return syncDirectory(filepath.Dir(path))
-}
-
-func syncDirectory(path string) (err error) {
- directory, err := os.Open(path)
- if err != nil {
- return err
- }
- defer func() { err = errors.Join(err, directory.Close()) }()
- if err := directory.Sync(); err != nil && !errors.Is(err, os.ErrInvalid) {
- return err
- }
- return nil
-}
-
-func conversionComplete(manifest *conversionManifest) bool {
- for _, state := range manifest.Namespaces {
- if !state.Done {
- return false
- }
- }
- return true
-}
-
-func conversionResult(target, databasePath string, manifest *conversionManifest) ConversionResult {
- result := ConversionResult{Backend: target}
- if target == "sqlite" {
- result.Path = databasePath
- }
- for namespace, state := range manifest.Namespaces {
- result.Namespaces = append(result.Namespaces, ConversionNamespace{Namespace: namespace, Records: state.Records, Digest: state.Digest})
- }
- sort.Slice(result.Namespaces, func(i, j int) bool { return result.Namespaces[i].Namespace < result.Namespaces[j].Namespace })
- return result
-}
diff --git a/internal/state/metering_test.go b/internal/state/metering_test.go
deleted file mode 100644
index bae3e41..0000000
--- a/internal/state/metering_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package state
-
-import (
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/metering"
-)
-
-func TestMeteringStoreBackendContract(t *testing.T) {
- for _, backend := range []string{"json", "sqlite"} {
- t.Run(backend, func(t *testing.T) {
- cfg := config.Default()
- cfg.Runtime.RootDir = t.TempDir()
- cfg.Metadata.Backend = backend
- if backend == "sqlite" {
- if err := InitSQLiteMetadata(t.Context(), cfg); err != nil {
- t.Fatal(err)
- }
- }
- stores, err := Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- if stores.Metadata != nil {
- defer func() {
- if err := stores.Metadata.Close(); err != nil {
- t.Error(err)
- }
- }()
- }
- at := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
- event := metering.Event{ID: metering.EventID("vm-1", metering.KindComputeStart, at), Kind: metering.KindComputeStart, VMID: "vm-1", VMName: "demo", Reason: metering.ReasonBoot, Shape: metering.Shape{VCPUs: 1, MemoryBytes: 512}, EmittedAt: at}
- if err := stores.Metering.Append(t.Context(), event); err != nil {
- t.Fatal(err)
- }
- events, err := stores.Metering.Events(t.Context(), "demo")
- if err != nil {
- t.Fatal(err)
- }
- if len(events) != 1 || events[0].ID != event.ID {
- t.Fatalf("events = %+v", events)
- }
- })
- }
-}
diff --git a/internal/state/resources.go b/internal/state/resources.go
deleted file mode 100644
index 95108b9..0000000
--- a/internal/state/resources.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package state
-
-import (
- "context"
-
- "github.com/kumabox/kumabox/internal/backend"
- kbimage "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/image/oci"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/reference"
- "github.com/kumabox/kumabox/internal/snapshot"
-)
-
-// ImageState is the durable image metadata capability used by image
-// lifecycle code. Disk import and deletion remain outside this interface's
-// persistence responsibility and are coordinated by the caller.
-type ImageState interface {
- Create(kbimage.CreateRequest) (*kbimage.ImageRecord, error)
- ImportLocal(kbimage.ImportRequest) (*kbimage.ImageRecord, error)
- Pull(kbimage.PullRequest) (*kbimage.ImageRecord, error)
- Inspect(string) (*kbimage.ImageRecord, error)
- List() ([]*kbimage.ImageRecord, error)
- Remove(kbimage.RemoveRequest) (*kbimage.ImageRecord, error)
-}
-
-// SnapshotState is the durable snapshot index and payload lifecycle
-// capability. Build and lease types make publication and reader ownership
-// explicit to callers.
-type SnapshotState interface {
- Reserve(context.Context, string) (*snapshot.Build, error)
- Import(context.Context, snapshot.ImportOptions) (*snapshot.Record, error)
- ImportDirectory(context.Context, string, string, string) (*snapshot.Record, error)
- Export(context.Context, string, snapshot.ExportOptions) error
- ExportDirectory(context.Context, string, string) error
- List() ([]*snapshot.Record, error)
- Scan() ([]*snapshot.Record, error)
- IsLeased(string) (bool, error)
- Inspect(string) (*snapshot.Record, error)
- AcquireRead(context.Context, string) (*snapshot.Record, *snapshot.Lease, error)
- LoadManifest(context.Context, string) (*snapshot.Manifest, error)
- PeekManifest(context.Context, string) (*snapshot.Manifest, error)
- Remove(string) (*snapshot.Record, error)
- VerifyNative(context.Context, string, snapshot.NativeVerifyTarget) (*snapshot.Manifest, error)
- VerifyNativeRecord(context.Context, *snapshot.Record, snapshot.NativeVerifyTarget) (*snapshot.Manifest, error)
- VerifyNativePayloadRecord(context.Context, *snapshot.Record, backend.NativeHost) (*snapshot.Manifest, error)
-}
-
-// NetworkState is the provider metadata capability. Host device operations
-// are deliberately not hidden behind this interface; callers must complete
-// provider cleanup before deleting the durable record.
-type NetworkState interface {
- List() ([]kbnetwork.Record, error)
- UpsertRecord(kbnetwork.Record) error
- DeleteRecord(string) error
- MarkCleanupPending(string, string) error
- Inspect(string) (*kbnetwork.InspectResult, error)
- InspectVM(string, string, string, []string, []kbnetwork.Config) (*kbnetwork.InspectResult, error)
- ListLeases() (map[string]kbnetwork.Lease, error)
- ReadHostTapState() (*kbnetwork.HostTapState, error)
- IncrementHostTapRef(int) error
- DecrementHostTapRef(int) error
-}
-
-var _ ImageState = (*kbimage.Store)(nil)
-var _ SnapshotState = (*snapshot.Store)(nil)
-var _ NetworkState = (*kbnetwork.Store)(nil)
-
-// OCIState is the content metadata capability used by image workflows.
-type OCIState interface {
- Pull(context.Context, oci.PullRequest) (*oci.PullResult, error)
-}
-
-// OperationState records control-plane work that can require reconciliation.
-type OperationState interface {
- Begin(context.Context, string, string, string) (*operation.Record, error)
- BeginWithRelated(context.Context, string, string, string, string) (*operation.Record, error)
- BindResource(context.Context, string, string) (*operation.Record, error)
- Complete(context.Context, string) (*operation.Record, error)
- Fail(context.Context, string, string) (*operation.Record, error)
- Recoverable(context.Context) ([]operation.Record, error)
- Reconcile(context.Context, func(context.Context, operation.Record) error) error
-}
-
-var _ OCIState = (*oci.Store)(nil)
-var _ OperationState = (*operation.Journal)(nil)
-
-type ReferenceState interface {
- Upsert(context.Context, reference.Record) error
- Delete(context.Context, string) error
- DeleteSource(context.Context, string, string) error
- ListTarget(context.Context, string, string) ([]reference.Record, error)
- ListSource(context.Context, string, string) ([]reference.Record, error)
-}
-
-var _ ReferenceState = (*reference.Store)(nil)
diff --git a/internal/state/set.go b/internal/state/set.go
deleted file mode 100644
index e5eeaf2..0000000
--- a/internal/state/set.go
+++ /dev/null
@@ -1,116 +0,0 @@
-// Package state defines and opens KumaBox's durable state capabilities.
-package state
-
-import (
- "context"
- "fmt"
- "path/filepath"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/image/oci"
- "github.com/kumabox/kumabox/internal/lock"
- "github.com/kumabox/kumabox/internal/meta"
- metasqlite "github.com/kumabox/kumabox/internal/meta/sqlite"
- "github.com/kumabox/kumabox/internal/metering"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/reference"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// Set is the complete persisted resource composition used by one
-// KumaBox process. Store implementations can be replaced before handing the
-// set to Runtime, GC, or a CLI command.
-type Set struct {
- VM VMState
- Images ImageState
- Snapshots SnapshotState
- Networks NetworkState
- OCI OCIState
- Operations OperationState
- References ReferenceState
- Metering *metering.Store
- Metadata meta.MetaEngine
- Guard *lock.Guard
-}
-
-// Open composes every persisted resource over the configured
-// metadata backend. All SQLite-backed resources share one database and one
-// transaction boundary; the JSON path keeps the existing file layout.
-func Open(cfg config.Config) (Set, error) {
- if err := metasqlite.RefuseConversion(SQLiteMetadataPath(cfg)); err != nil {
- return Set{}, err
- }
- if cfg.Metadata.Backend != "sqlite" {
- return OpenJSON(cfg.Runtime.RootDir), nil
- }
- path := SQLiteMetadataPath(cfg)
- engine, err := metasqlite.Open(path, sqliteDefinitions()...)
- if err != nil {
- return Set{}, fmt.Errorf("open configured metadata backend: %w", err)
- }
- vm := vm.NewWithEngine(cfg.Runtime.RootDir, engine)
- return Set{
- VM: vm,
- Images: image.NewWithEngine(cfg.Runtime.RootDir, engine),
- Snapshots: snapshot.NewStoreWithEngineAndVMReader(cfg.Runtime.RootDir, engine, vm),
- Networks: kbnetwork.NewStoreWithEngines(cfg.Runtime.RootDir, engine, engine, engine),
- OCI: oci.NewStoreWithEngine(cfg.Runtime.RootDir, engine),
- Operations: operation.NewWithEngine(engine),
- References: reference.NewWithEngine(engine),
- Metering: metering.NewWithEngine(engine),
- Metadata: engine,
- Guard: lock.NewGuard(cfg.Runtime.RootDir),
- }, nil
-}
-
-// InitSQLiteMetadata creates the configured SQLite metadata database. Normal
-// store construction deliberately refuses to create it implicitly.
-func InitSQLiteMetadata(ctx context.Context, cfg config.Config) error {
- if cfg.Metadata.Backend != "sqlite" {
- return fmt.Errorf("metadata initialization requires the sqlite backend, got %q", cfg.Metadata.Backend)
- }
- return metasqlite.Init(ctx, SQLiteMetadataPath(cfg), sqliteDefinitions()...)
-}
-
-// SQLiteMetadataPath resolves the single database path used by all SQLite
-// resource stores.
-func SQLiteMetadataPath(cfg config.Config) string {
- if cfg.Metadata.Path != "" {
- return cfg.Metadata.Path
- }
- return filepath.Join(cfg.Runtime.RootDir, "metadata", "kumabox.db")
-}
-
-func sqliteDefinitions() []metasqlite.Namespace {
- return []metasqlite.Namespace{
- {Name: "vms", Tables: []meta.Table{"vm-index"}},
- {Name: "images", Tables: []meta.Table{"image-index"}},
- {Name: "snapshots", Tables: []meta.Table{"snapshot-index"}},
- {Name: "networks", Tables: []meta.Table{"network-index"}},
- {Name: "leases", Tables: []meta.Table{"network-leases"}},
- {Name: "host-tap", Tables: []meta.Table{"host-tap"}},
- {Name: "oci-content", Tables: []meta.Table{"oci-content"}},
- {Name: "operations", Tables: []meta.Table{"records"}},
- {Name: "references", Tables: []meta.Table{"records"}},
- {Name: metering.Namespace, Tables: []meta.Table{metering.Table}},
- }
-}
-
-// OpenJSON creates the default JSON-backed resource stores.
-func OpenJSON(rootDir string) Set {
- vm := vm.New(rootDir)
- return Set{
- VM: vm,
- Images: image.New(rootDir),
- Snapshots: snapshot.NewStoreWithVMReader(rootDir, vm),
- Networks: kbnetwork.NewStore(rootDir),
- OCI: oci.NewStore(rootDir),
- Operations: operation.New(rootDir),
- References: reference.New(rootDir),
- Metering: metering.New(rootDir),
- Guard: lock.NewGuard(rootDir),
- }
-}
diff --git a/internal/state/set_test.go b/internal/state/set_test.go
deleted file mode 100644
index e4258fa..0000000
--- a/internal/state/set_test.go
+++ /dev/null
@@ -1,261 +0,0 @@
-package state
-
-import (
- "context"
- "errors"
- "path/filepath"
- "testing"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/image/oci"
- "github.com/kumabox/kumabox/internal/meta"
- metasqlite "github.com/kumabox/kumabox/internal/meta/sqlite"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/reference"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestNewStoreSetForConfigUsesOneSQLiteEngine(t *testing.T) {
- cfg := config.Default()
- cfg.Runtime.RootDir = t.TempDir()
- cfg.Metadata.Backend = "sqlite"
- if err := InitSQLiteMetadata(t.Context(), cfg); err != nil {
- t.Fatal(err)
- }
- stores, err := Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- if stores.Metadata == nil || stores.VM == nil || stores.Images == nil || stores.Snapshots == nil || stores.Networks == nil || stores.OCI == nil || stores.Operations == nil || stores.Metering == nil {
- t.Fatalf("incomplete store set: %+v", stores)
- }
- statusStore, ok := stores.Metadata.(*metasqlite.Store)
- if !ok {
- t.Fatalf("metadata engine type = %T", stores.Metadata)
- }
- status, err := statusStore.Status(context.Background())
- if err != nil || len(status) != len(sqliteDefinitions()) {
- t.Fatalf("namespace status = %d, err = %v", len(status), err)
- }
- if err := stores.Metadata.Close(); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestInitSQLiteMetadataUpgradesPreMeteringDatabase(t *testing.T) {
- cfg := config.Default()
- cfg.Runtime.RootDir = t.TempDir()
- cfg.Metadata.Backend = "sqlite"
- definitions := sqliteDefinitions()
- legacyDefinitions := definitions[:len(definitions)-1]
- if err := metasqlite.Init(t.Context(), SQLiteMetadataPath(cfg), legacyDefinitions...); err != nil {
- t.Fatal(err)
- }
- legacy, err := metasqlite.Open(SQLiteMetadataPath(cfg), legacyDefinitions...)
- if err != nil {
- t.Fatal(err)
- }
- if err := legacy.Update(t.Context(), meta.Scope{Write: "vms"}, meta.CommitDurable, func(writer meta.Writer) error {
- return writer.PutRaw(t.Context(), "vms", "vm-index", "vm-before-upgrade", []byte(`{"name":"preserved"}`))
- }); err != nil {
- t.Fatal(err)
- }
- if err := legacy.Close(); err != nil {
- t.Fatal(err)
- }
-
- if err := InitSQLiteMetadata(t.Context(), cfg); err != nil {
- t.Fatal(err)
- }
- stores, err := Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- if err := stores.Metadata.Close(); err != nil {
- t.Errorf("close upgraded metadata: %v", err)
- }
- })
- if err := stores.Metadata.View(t.Context(), []meta.Namespace{"vms"}, func(reader meta.Reader) error {
- raw, found, err := reader.GetRaw(t.Context(), "vms", "vm-index", "vm-before-upgrade")
- if err != nil {
- return err
- }
- if !found || string(raw) != `{"name":"preserved"}` {
- t.Fatalf("preserved VM metadata = %s, found = %v", raw, found)
- }
- return nil
- }); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestConvertJSONToSQLiteCreatesCompletedNamespaceState(t *testing.T) {
- root := t.TempDir()
- status, err := ConvertJSONToSQLite(context.Background(), root, "")
- if err != nil {
- t.Fatal(err)
- }
- if len(status) != len(sqliteDefinitions()) {
- t.Fatalf("converted namespace count = %d", len(status))
- }
- for _, namespace := range status {
- if namespace.State != "converted" || namespace.Source != "json" {
- t.Fatalf("namespace conversion status = %+v", namespace)
- }
- }
-}
-
-func TestConvertMetadataRoundTripsJSONAndSQLite(t *testing.T) {
- cfg := testMetadataConfig(t)
- seedJSONMetadata(t, cfg.Runtime.RootDir)
-
- cfg.Metadata.Backend = "sqlite"
- toSQLite, err := ConvertMetadata(t.Context(), cfg)
- if err != nil {
- t.Fatal(err)
- }
- if toSQLite.Backend != "sqlite" || len(toSQLite.Namespaces) != len(sqliteDefinitions()) {
- t.Fatalf("sqlite conversion result = %+v", toSQLite)
- }
- assertConvertedRecords(t, cfg)
-
- cfg.Metadata.Backend = "json"
- toJSON, err := ConvertMetadata(t.Context(), cfg)
- if err != nil {
- t.Fatal(err)
- }
- if toJSON.Backend != "json" || len(toJSON.Namespaces) != len(sqliteDefinitions()) {
- t.Fatalf("json conversion result = %+v", toJSON)
- }
- assertConvertedRecords(t, cfg)
-}
-
-func TestConvertMetadataResumesAfterCommittedNamespace(t *testing.T) {
- cfg := testMetadataConfig(t)
- seedJSONMetadata(t, cfg.Runtime.RootDir)
- cfg.Metadata.Backend = "sqlite"
-
- injected := errors.New("injected conversion interruption")
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == fault.MetadataConvertNamespace {
- return injected
- }
- return nil
- }))
- _, err := ConvertMetadata(ctx, cfg)
- if !errors.Is(err, injected) {
- t.Fatalf("interrupted conversion error = %v", err)
- }
- if _, err := Open(cfg); err == nil {
- t.Fatal("ordinary store open succeeded while conversion manifest existed")
- }
- if _, err := ConvertMetadata(t.Context(), cfg); err != nil {
- t.Fatal(err)
- }
- assertConvertedRecords(t, cfg)
-}
-
-func TestConvertMetadataResumesWhileRetiringSQLiteSource(t *testing.T) {
- cfg := testMetadataConfig(t)
- seedJSONMetadata(t, cfg.Runtime.RootDir)
- cfg.Metadata.Backend = "sqlite"
- if _, err := ConvertMetadata(t.Context(), cfg); err != nil {
- t.Fatal(err)
- }
-
- cfg.Metadata.Backend = "json"
- injected := errors.New("injected source retirement interruption")
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == fault.MetadataConvertRetired {
- return injected
- }
- return nil
- }))
- _, err := ConvertMetadata(ctx, cfg)
- if !errors.Is(err, injected) {
- t.Fatalf("interrupted retirement error = %v", err)
- }
- if _, err := ConvertMetadata(t.Context(), cfg); err != nil {
- t.Fatal(err)
- }
- assertConvertedRecords(t, cfg)
-}
-
-func testMetadataConfig(t *testing.T) config.Config {
- t.Helper()
- rootDir := t.TempDir()
- cfg := config.Default()
- cfg.Runtime.RootDir = rootDir
- cfg.Runtime.RunDir = filepath.Join(rootDir, "run")
- cfg.Runtime.LogDir = filepath.Join(rootDir, "log")
- return cfg
-}
-
-func seedJSONMetadata(t *testing.T, rootDir string) {
- t.Helper()
- stores := OpenJSON(rootDir)
- if _, err := stores.Operations.Begin(t.Context(), "op-convert", operation.KindVMStart, "vm-convert"); err != nil {
- t.Fatal(err)
- }
- if err := stores.References.Upsert(t.Context(), reference.Record{
- ID: "ref-convert", SourceKind: "vm", SourceID: "vm-convert", TargetKind: "image", TargetID: "image-convert",
- }); err != nil {
- t.Fatal(err)
- }
- closeJSONStoreSet(t, stores)
-}
-
-func assertConvertedRecords(t *testing.T, cfg config.Config) {
- t.Helper()
- stores, err := Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- if cfg.Metadata.Backend == "sqlite" {
- defer func() {
- if err := stores.Metadata.Close(); err != nil {
- t.Errorf("close sqlite metadata: %v", err)
- }
- }()
- } else {
- defer closeJSONStoreSet(t, stores)
- }
- recoverable, err := stores.Operations.Recoverable(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(recoverable) != 1 || recoverable[0].ID != "op-convert" {
- t.Fatalf("converted operations = %+v", recoverable)
- }
- references, err := stores.References.ListTarget(t.Context(), "image", "image-convert")
- if err != nil {
- t.Fatal(err)
- }
- if len(references) != 1 || references[0].ID != "ref-convert" {
- t.Fatalf("converted references = %+v", references)
- }
-}
-
-func closeJSONStoreSet(t *testing.T, stores Set) {
- t.Helper()
- engines := []interface{ Close() error }{
- stores.VM.(*vm.Store).MetadataEngine(),
- stores.Images.(*image.Store).MetadataEngine(),
- stores.Snapshots.(*snapshot.Store).MetadataEngine(),
- stores.OCI.(*oci.Store).MetadataEngine(),
- stores.Operations.(*operation.Journal).MetadataEngine(),
- stores.References.(*reference.Store).MetadataEngine(),
- }
- network, leases, hostTap := stores.Networks.(*kbnetwork.Store).MetadataEngines()
- engines = append(engines, network, leases, hostTap)
- for _, engine := range engines {
- if err := engine.Close(); err != nil {
- t.Errorf("close json metadata: %v", err)
- }
- }
-}
diff --git a/internal/state/vm.go b/internal/state/vm.go
deleted file mode 100644
index d567866..0000000
--- a/internal/state/vm.go
+++ /dev/null
@@ -1,67 +0,0 @@
-// Package state defines the persisted resource capabilities used by runtime
-// orchestration. Implementations may use JSON files, SQLite, or another
-// durable store without changing lifecycle code.
-package state
-
-import (
- "context"
- "time"
-
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// VMReader contains read-only VM access. Callers that only inspect state
-// should depend on this interface instead of the complete VM mutation API.
-type VMReader interface {
- Inspect(string) (*vm.VMRecord, error)
- List() ([]*vm.VMRecord, error)
- RootDir() string
-}
-
-// VMEvents reports that persisted VM metadata may have changed. Notifications
-// are hints: consumers must always reread VM records because events may be
-// coalesced by the metadata backend.
-type VMEvents interface {
- Events(context.Context) (<-chan struct{}, func(), error)
-}
-
-// VMRecords contains VM record creation and attachment mutations.
-type VMRecords interface {
- Create(vm.CreateRequest) (*vm.VMRecord, error)
- Delete(string) error
- SetNetworkConfigs(string, []kbnetwork.Config) (*vm.VMRecord, error)
- SetAttachedDisks(string, []vm.AttachedDisk) (*vm.VMRecord, error)
- SetAttachedFilesystems(string, []vm.AttachedFilesystem) (*vm.VMRecord, error)
- SetAttachedPCIDevices(string, []vm.AttachedPCIDevice) (*vm.VMRecord, error)
-}
-
-// VMUpdater contains durable VM record updates. Ordinary state changes use
-// UpdateStates; the remaining methods carry additional lifecycle data.
-type VMUpdater interface {
- UpdateStates([]string, vm.VMState) error
- MarkStarted(string, int, string) (*vm.VMRecord, error)
- UpdatePerformance(string, vm.PerformanceMetrics) (*vm.VMRecord, error)
- CompleteHibernate(string, string) (*vm.VMRecord, error)
- SetError(string, string) (*vm.VMRecord, error)
-}
-
-// VMRestore contains durable markers for destructive and completed restores.
-type VMRestore interface {
- BeginRestore(string, string, string) (*vm.VMRecord, error)
- FailRestore(string, string) (*vm.VMRecord, error)
- CompleteRestore(string, int, string, time.Duration, *vm.RestoreResult) (*vm.VMRecord, error)
-}
-
-// VMState is the complete VM resource state API consumed by the runtime.
-//
-// It is kept as a compatibility composition for existing constructors. New
-// code should depend on the narrow capability it actually uses.
-type VMState interface {
- VMReader
- VMRecords
- VMUpdater
- VMRestore
-}
-
-var _ VMState = (*vm.Store)(nil)
diff --git a/internal/version/version.go b/internal/version/version.go
deleted file mode 100644
index 87ad24d..0000000
--- a/internal/version/version.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package version
-
-// These values are overridden by the Makefile at build time.
-var (
- Version = "0.0.0-dev"
- Commit = "unknown"
- BuildTime = "unknown"
-)
-
-type BuildInfo struct {
- Version string `json:"version"`
- Commit string `json:"commit"`
- BuildTime string `json:"buildTime"`
-}
-
-func Info() BuildInfo {
- return BuildInfo{
- Version: Version,
- Commit: Commit,
- BuildTime: BuildTime,
- }
-}
diff --git a/internal/version/version_test.go b/internal/version/version_test.go
deleted file mode 100644
index 469bfd1..0000000
--- a/internal/version/version_test.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package version
-
-import "testing"
-
-func TestInfo(t *testing.T) {
- info := Info()
- if info.Version == "" {
- t.Fatal("version must not be empty")
- }
- if info.Commit == "" {
- t.Fatal("commit must not be empty")
- }
- if info.BuildTime == "" {
- t.Fatal("build time must not be empty")
- }
-}
diff --git a/internal/vm/index.go b/internal/vm/index.go
deleted file mode 100644
index c6e4a21..0000000
--- a/internal/vm/index.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package vm
-
-import (
- "errors"
- "fmt"
- "strings"
-)
-
-const backendCloudHypervisor = "cloud-hypervisor"
-
-var (
- ErrNotFound = errors.New("vm not found")
- ErrNameConflict = errors.New("vm name already exists")
- ErrAmbiguous = errors.New("vm ref is ambiguous")
-)
-
-type vmIndex struct {
- VMs map[string]*VMRecord `json:"vms"`
- Names map[string]string `json:"names"`
-}
-
-func (idx *vmIndex) init() {
- if idx.VMs == nil {
- idx.VMs = make(map[string]*VMRecord)
- }
- if idx.Names == nil {
- idx.Names = make(map[string]string)
- }
-}
-
-func (idx *vmIndex) resolve(ref string) (string, error) {
- idx.init()
- if _, ok := idx.VMs[ref]; ok {
- return ref, nil
- }
- if id, ok := idx.Names[ref]; ok {
- return id, nil
- }
- if len(ref) < 3 {
- return "", ErrNotFound
- }
-
- var matched string
- for id := range idx.VMs {
- if !strings.HasPrefix(id, ref) {
- continue
- }
- if matched != "" {
- return "", fmt.Errorf("%w: %s", ErrAmbiguous, ref)
- }
- matched = id
- }
- if matched == "" {
- return "", ErrNotFound
- }
- return matched, nil
-}
diff --git a/internal/vm/index_codec.go b/internal/vm/index_codec.go
deleted file mode 100644
index 926c779..0000000
--- a/internal/vm/index_codec.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package vm
-
-import (
- stdjson "encoding/json"
- "fmt"
-
- metajson "github.com/kumabox/kumabox/internal/meta/json"
-)
-
-const vmIndexTable = "vm-index"
-const vmIndexRecord = "root"
-
-// indexCodec keeps the legacy VM index document stable while storing it
-// through the engine-neutral metadata transaction boundary.
-type indexCodec struct{}
-
-func (indexCodec) Decode(raw []byte) (*metajson.Model, error) {
- model := metajson.NewModel()
- if len(raw) == 0 {
- return model, nil
- }
- var index vmIndex
- if err := stdjson.Unmarshal(raw, &index); err != nil {
- return nil, fmt.Errorf("parse VM index: %w", err)
- }
- index.init()
- encoded, err := stdjson.Marshal(index)
- if err != nil {
- return nil, fmt.Errorf("encode VM index record: %w", err)
- }
- model.Tables[vmIndexTable] = map[string]stdjson.RawMessage{
- vmIndexRecord: encoded,
- }
- return model, nil
-}
-
-func (indexCodec) Encode(model *metajson.Model) ([]byte, error) {
- if model == nil {
- return nil, fmt.Errorf("VM index metadata model must not be nil")
- }
- raw := model.Tables[vmIndexTable][vmIndexRecord]
- if len(raw) == 0 {
- index := vmIndex{}
- index.init()
- raw, _ = stdjson.Marshal(index)
- }
- var index vmIndex
- if err := stdjson.Unmarshal(raw, &index); err != nil {
- return nil, fmt.Errorf("parse VM index record: %w", err)
- }
- index.init()
- return stdjson.MarshalIndent(index, "", " ")
-}
diff --git a/internal/vm/nocloud/fat12.go b/internal/vm/nocloud/fat12.go
deleted file mode 100644
index 26c0951..0000000
--- a/internal/vm/nocloud/fat12.go
+++ /dev/null
@@ -1,332 +0,0 @@
-package nocloud
-
-import (
- "encoding/binary"
- "fmt"
- "io"
- "sort"
- "strings"
- "time"
- "unicode/utf16"
-)
-
-const (
- fatSectorSize = 512
- fatTotalSectors = 2048
- fatSectorsPerClus = 1
- fatReservedSec = 1
- fatNumFATs = 2
- fatSectorsPerFAT = 6
- fatRootEntryCount = 128
- fatDirEntrySize = 32
- fatRootDirSectors = fatRootEntryCount * fatDirEntrySize / fatSectorSize
- fatFirstDataSec = fatReservedSec + fatNumFATs*fatSectorsPerFAT + fatRootDirSectors
- fatEntryEOC = 0xFFF
- fatMediaDesc = 0xF8
-)
-
-type fat12DataEntry struct {
- data []byte
- numClusters int
-}
-
-type fat12Builder struct {
- label string
- fat []byte
- rootDir []byte
- data []fat12DataEntry
- nextCluster uint16
- rootUsed int
- shortSeq int
-}
-
-// WriteFAT12 writes a small deterministic FAT12 filesystem image.
-//
-// Cloud-init accepts CIDATA on a vfat disk, and FAT12 is simple enough to build
-// without invoking mkfs tools on the host. The image is intentionally tiny
-// because it only carries NoCloud text files.
-func WriteFAT12(w io.Writer, label string, files map[string][]byte) error {
- builder := newFAT12Builder(label)
- names := make([]string, 0, len(files))
- for name := range files {
- names = append(names, name)
- }
- sort.Strings(names)
- for _, name := range names {
- if err := builder.addFile(name, files[name]); err != nil {
- return err
- }
- }
- return builder.writeTo(w)
-}
-
-func newFAT12Builder(label string) *fat12Builder {
- builder := &fat12Builder{
- label: label,
- fat: make([]byte, fatSectorsPerFAT*fatSectorSize),
- rootDir: make([]byte, fatRootEntryCount*fatDirEntrySize),
- nextCluster: 2,
- }
- setFATEntry(builder.fat, 0, 0xFF8)
- setFATEntry(builder.fat, 1, fatEntryEOC)
- builder.addVolumeLabel()
- return builder
-}
-
-func (b *fat12Builder) addVolumeLabel() {
- name := padLabel(b.label)
- off := b.rootUsed * fatDirEntrySize
- copy(b.rootDir[off:], name[:])
- b.rootDir[off+11] = 0x08
- putTimestamps(b.rootDir[off:], time.Now())
- b.rootUsed++
-}
-
-func (b *fat12Builder) addFile(name string, content []byte) error {
- numClusters := (len(content) + fatSectorSize - 1) / fatSectorSize
- var startCluster uint16
- if numClusters > 0 {
- if int(b.nextCluster)+numClusters > (fatTotalSectors-fatFirstDataSec)+2 {
- return fmt.Errorf("fat12: not enough space for %s", name)
- }
- startCluster = b.nextCluster
- for i := 0; i < numClusters; i++ {
- cluster := int(b.nextCluster) + i
- if i == numClusters-1 {
- setFATEntry(b.fat, cluster, fatEntryEOC)
- } else {
- setFATEntry(b.fat, cluster, uint16(cluster+1)) //nolint:gosec
- }
- }
- b.data = append(b.data, fat12DataEntry{data: content, numClusters: numClusters})
- b.nextCluster += uint16(numClusters)
- }
-
- lfn := needsLFN(name)
- var shortName [11]byte
- if lfn {
- b.shortSeq++
- shortName = generateShortName(name, b.shortSeq)
- for _, entry := range makeLFNEntries(name, shortName) {
- if _, err := b.writeDirEntry(entry); err != nil {
- return err
- }
- }
- } else {
- shortName = toShortName(name)
- }
-
- off, err := b.writeDirEntry(shortName[:])
- if err != nil {
- return err
- }
- b.rootDir[off+11] = 0x20
- putTimestamps(b.rootDir[off:], time.Now())
- binary.LittleEndian.PutUint16(b.rootDir[off+26:], startCluster)
- binary.LittleEndian.PutUint32(b.rootDir[off+28:], uint32(len(content))) //nolint:gosec
- return nil
-}
-
-func (b *fat12Builder) writeDirEntry(entry []byte) (int, error) {
- if b.rootUsed >= fatRootEntryCount {
- return 0, fmt.Errorf("fat12: root directory full")
- }
- off := b.rootUsed * fatDirEntrySize
- copy(b.rootDir[off:], entry)
- b.rootUsed++
- return off, nil
-}
-
-func (b *fat12Builder) writeTo(w io.Writer) error {
- if _, err := w.Write(b.bootSector()); err != nil {
- return err
- }
- for i := 0; i < fatNumFATs; i++ {
- if _, err := w.Write(b.fat); err != nil {
- return err
- }
- }
- if _, err := w.Write(b.rootDir); err != nil {
- return err
- }
-
- sector := make([]byte, fatSectorSize)
- dataSectors := 0
- for _, entry := range b.data {
- for i := 0; i < entry.numClusters; i++ {
- clear(sector)
- start := i * fatSectorSize
- if start < len(entry.data) {
- copy(sector, entry.data[start:min(start+fatSectorSize, len(entry.data))])
- }
- if _, err := w.Write(sector); err != nil {
- return err
- }
- dataSectors++
- }
- }
-
- clear(sector)
- for i := 0; i < fatTotalSectors-fatFirstDataSec-dataSectors; i++ {
- if _, err := w.Write(sector); err != nil {
- return err
- }
- }
- return nil
-}
-
-func (b *fat12Builder) bootSector() []byte {
- boot := make([]byte, fatSectorSize)
- boot[0], boot[1], boot[2] = 0xEB, 0x3C, 0x90
- copy(boot[3:], "KUMABOX ")
- binary.LittleEndian.PutUint16(boot[11:], fatSectorSize)
- boot[13] = fatSectorsPerClus
- binary.LittleEndian.PutUint16(boot[14:], fatReservedSec)
- boot[16] = fatNumFATs
- binary.LittleEndian.PutUint16(boot[17:], fatRootEntryCount)
- binary.LittleEndian.PutUint16(boot[19:], fatTotalSectors)
- boot[21] = fatMediaDesc
- binary.LittleEndian.PutUint16(boot[22:], fatSectorsPerFAT)
- binary.LittleEndian.PutUint16(boot[24:], 32)
- binary.LittleEndian.PutUint16(boot[26:], 64)
- boot[36] = 0x80
- boot[38] = 0x29
- binary.LittleEndian.PutUint32(boot[39:], uint32(time.Now().UnixNano())) //nolint:gosec
- label := padLabel(b.label)
- copy(boot[43:54], label[:])
- copy(boot[54:62], "FAT12 ")
- boot[510], boot[511] = 0x55, 0xAA
- return boot
-}
-
-func setFATEntry(fat []byte, cluster int, val uint16) {
- off := cluster + cluster/2
- if off+1 >= len(fat) {
- return
- }
- word := uint16(fat[off]) | uint16(fat[off+1])<<8
- if cluster%2 == 0 {
- word = (word & 0xF000) | (val & 0x0FFF)
- } else {
- word = (word & 0x000F) | ((val & 0x0FFF) << 4)
- }
- fat[off] = byte(word)
- fat[off+1] = byte(word >> 8)
-}
-
-func needsLFN(name string) bool {
- upper := strings.ToUpper(name)
- base, ext := splitName(upper)
- return len(base) > 8 || len(ext) > 3 || name != upper || strings.Count(name, ".") > 1
-}
-
-func blankSFN() [11]byte {
- var b [11]byte
- for i := range b {
- b[i] = ' '
- }
- return b
-}
-
-func splitName(upper string) (string, string) {
- if dot := strings.LastIndex(upper, "."); dot >= 0 {
- return upper[:dot], upper[dot+1:]
- }
- return upper, ""
-}
-
-func toShortName(name string) [11]byte {
- result := blankSFN()
- base, ext := splitName(strings.ToUpper(name))
- copy(result[:8], base)
- copy(result[8:], ext)
- return result
-}
-
-func generateShortName(name string, seq int) [11]byte {
- result := blankSFN()
- base, ext := splitName(strings.ToUpper(name))
- base = strings.ReplaceAll(base, ".", "")
- tail := fmt.Sprintf("~%d", seq)
- maxBase := 8 - len(tail)
- if len(base) > maxBase {
- base = base[:maxBase]
- }
- copy(result[:8], base+tail)
- if len(ext) > 3 {
- ext = ext[:3]
- }
- copy(result[8:], ext)
- return result
-}
-
-func makeLFNEntries(name string, shortName [11]byte) [][]byte {
- runes := utf16.Encode([]rune(name))
- checksum := lfnChecksum(shortName)
- numEntries := (len(runes) + 12) / 13
-
- entries := make([][]byte, numEntries)
- for i := 0; i < numEntries; i++ {
- entry := make([]byte, fatDirEntrySize)
- seq := byte(i + 1)
- if i == numEntries-1 {
- seq |= 0x40
- }
- entry[0] = seq
- entry[11] = 0x0F
- entry[13] = checksum
- base := i * 13
- putLFNChars(entry[1:11], runes, base, 5)
- putLFNChars(entry[14:26], runes, base+5, 6)
- putLFNChars(entry[28:32], runes, base+11, 2)
- entries[i] = entry
- }
-
- for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
- entries[i], entries[j] = entries[j], entries[i]
- }
- return entries
-}
-
-func putLFNChars(dst []byte, runes []uint16, offset, count int) {
- for j := 0; j < count; j++ {
- idx := offset + j
- pos := j * 2
- switch {
- case idx < len(runes):
- binary.LittleEndian.PutUint16(dst[pos:], runes[idx])
- case idx == len(runes):
- default:
- binary.LittleEndian.PutUint16(dst[pos:], 0xFFFF)
- }
- }
-}
-
-func lfnChecksum(shortName [11]byte) byte {
- var sum byte
- for _, b := range shortName {
- sum = ((sum >> 1) | (sum << 7)) + b
- }
- return sum
-}
-
-func padLabel(label string) [11]byte {
- result := blankSFN()
- copy(result[:], strings.ToUpper(label))
- return result
-}
-
-func putTimestamps(entry []byte, t time.Time) {
- date, fatTime := encodeFATDateTime(t)
- binary.LittleEndian.PutUint16(entry[14:], fatTime)
- binary.LittleEndian.PutUint16(entry[16:], date)
- binary.LittleEndian.PutUint16(entry[18:], date)
- binary.LittleEndian.PutUint16(entry[22:], fatTime)
- binary.LittleEndian.PutUint16(entry[24:], date)
-}
-
-func encodeFATDateTime(t time.Time) (uint16, uint16) {
- date := uint16((t.Year()-1980)<<9) | uint16(int(t.Month())<<5) | uint16(t.Day()) //nolint:gosec
- fatTime := uint16(t.Hour()<<11) | uint16(t.Minute()<<5) | uint16(t.Second()/2) //nolint:gosec
- return date, fatTime
-}
diff --git a/internal/vm/nocloud/metadata.go b/internal/vm/nocloud/metadata.go
deleted file mode 100644
index 6fa7884..0000000
--- a/internal/vm/nocloud/metadata.go
+++ /dev/null
@@ -1,206 +0,0 @@
-// Package nocloud renders NoCloud seed data for cloud-image guests.
-//
-// Cloud images rely on cloud-init to set hostname, users, and first-boot
-// networking. KumaBox writes the standard NoCloud files both as plain files for
-// inspection and as a small CIDATA disk consumed by the guest.
-package nocloud
-
-import (
- "bytes"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
- "text/template"
-)
-
-// CidataLabel is the volume label cloud-init uses to discover NoCloud media.
-const CidataLabel = "CIDATA"
-
-// Config is the input used to render NoCloud metadata, user-data, and network
-// configuration.
-type Config struct {
- InstanceID string
- Hostname string
- Username string
- Networks []Network
- Mounts []Mount
-}
-
-// Mount describes one cloud-init mount entry for a managed data disk.
-type Mount struct {
- Device string
- MountPoint string
- Filesystem string
- Options string
-}
-
-// Network describes one guest interface in cloud-init network-config format.
-//
-// Interfaces are matched by MAC so guest interface names can be set
-// deterministically even if kernel enumeration order changes.
-type Network struct {
- MAC string
- IP string
- Prefix int
- Gateway string
- DNS []string
-}
-
-// Rendered contains the three NoCloud files before they are written to disk.
-type Rendered struct {
- MetaData []byte
- UserData []byte
- NetworkConfig []byte
-}
-
-var (
- metaDataTemplate = template.Must(template.New("meta-data").Parse(`instance-id: {{.InstanceID}}
-local-hostname: {{.Hostname}}
-`))
-
- userDataTemplate = template.Must(template.New("user-data").Parse(`#cloud-config
-hostname: {{.Hostname}}
-manage_etc_hosts: true
-users:
- - default
- - name: {{.Username}}
- sudo: ALL=(ALL) NOPASSWD:ALL
- shell: /bin/bash
-ssh_pwauth: false
-{{- if .Mounts}}
-mounts:
-{{- range .Mounts}}
- - ["{{.Device}}", "{{.MountPoint}}", "{{.Filesystem}}", "{{.Options}}", "0", "2"]
-{{- end}}
-{{- end}}
-`))
-
- networkConfigTemplate = template.Must(template.New("network-config").Parse(`version: 2
-ethernets:
-{{- if .Networks }}
-{{- range $i, $net := .Networks }}
- eth{{$i}}:
- match:
- macaddress: "{{$net.MAC}}"
- set-name: eth{{$i}}
- addresses:
- - {{$net.IP}}/{{$net.Prefix}}
-{{- if $net.Gateway }}
- gateway4: {{$net.Gateway}}
-{{- end }}
-{{- if $net.DNS }}
- nameservers:
- addresses:
-{{- range $dns := $net.DNS }}
- - {{$dns}}
-{{- end }}
-{{- end }}
- optional: true
-{{- end }}
-{{- else }}
- fallback:
- match:
- name: "e*"
- dhcp4: true
- optional: true
-{{- end }}
-`))
-)
-
-// Render builds NoCloud meta-data, user-data, and network-config files.
-func Render(cfg Config) (*Rendered, error) {
- if cfg.InstanceID == "" {
- return nil, fmt.Errorf("instance ID must not be empty")
- }
- if cfg.Hostname == "" {
- return nil, fmt.Errorf("hostname must not be empty")
- }
- if cfg.Username == "" {
- cfg.Username = "kumabox"
- }
- var rendered Rendered
- if err := executeTemplate(metaDataTemplate, cfg, &rendered.MetaData); err != nil {
- return nil, fmt.Errorf("render meta-data: %w", err)
- }
- if err := executeTemplate(userDataTemplate, cfg, &rendered.UserData); err != nil {
- return nil, fmt.Errorf("render user-data: %w", err)
- }
- if err := executeTemplate(networkConfigTemplate, cfg, &rendered.NetworkConfig); err != nil {
- return nil, fmt.Errorf("render network-config: %w", err)
- }
- return &rendered, nil
-}
-
-// WriteNoCloud writes NoCloud files and a CIDATA disk image.
-//
-// The directory files are useful for debugging. The disk image is what Cloud
-// Hypervisor attaches to the guest during firmware/cloud-image boots.
-func WriteNoCloud(dir, diskPath string, cfg Config) (err error) {
- rendered, err := Render(cfg)
- if err != nil {
- return err
- }
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return fmt.Errorf("create cidata dir: %w", err)
- }
- files := map[string][]byte{
- "meta-data": rendered.MetaData,
- "user-data": rendered.UserData,
- "network-config": rendered.NetworkConfig,
- }
- for name, data := range files {
- path := filepath.Join(dir, name)
- if err := os.WriteFile(path, data, 0o644); err != nil {
- return fmt.Errorf("write %s: %w", name, err)
- }
- }
-
- if err := os.MkdirAll(filepath.Dir(diskPath), 0o755); err != nil {
- return fmt.Errorf("create cidata disk dir: %w", err)
- }
- file, err := os.OpenFile(diskPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
- if err != nil {
- return fmt.Errorf("create cidata disk: %w", err)
- }
- defer func() {
- if closeErr := file.Close(); err == nil && closeErr != nil {
- err = fmt.Errorf("close cidata disk: %w", closeErr)
- }
- }()
- if err := WriteFAT12(file, CidataLabel, files); err != nil {
- return fmt.Errorf("write cidata disk: %w", err)
- }
- return nil
-}
-
-func executeTemplate(tmpl *template.Template, cfg Config, out *[]byte) error {
- var buf bytes.Buffer
- if err := tmpl.Execute(&buf, cfg); err != nil {
- return err
- }
- *out = bytes.Clone(buf.Bytes())
- return nil
-}
-
-// ContainsNoCloudFiles performs a lightweight smoke check for rendered seed data.
-func ContainsNoCloudFiles(raw []byte) bool {
- s := string(raw)
- return strings.Contains(s, "instance-id:") &&
- strings.Contains(s, "#cloud-config") &&
- strings.Contains(s, "version: 2")
-}
-
-// WriteNoCloudImage writes only the CIDATA disk image to w.
-func WriteNoCloudImage(w io.Writer, cfg Config) error {
- rendered, err := Render(cfg)
- if err != nil {
- return err
- }
- return WriteFAT12(w, CidataLabel, map[string][]byte{
- "meta-data": rendered.MetaData,
- "user-data": rendered.UserData,
- "network-config": rendered.NetworkConfig,
- })
-}
diff --git a/internal/vm/nocloud/metadata_test.go b/internal/vm/nocloud/metadata_test.go
deleted file mode 100644
index 4931a70..0000000
--- a/internal/vm/nocloud/metadata_test.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package nocloud
-
-import (
- "bytes"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestRenderNoCloudFiles(t *testing.T) {
- rendered, err := Render(Config{
- InstanceID: "kb_test",
- Hostname: "p1-meta",
- })
- if err != nil {
- t.Fatal(err)
- }
-
- metaData := string(rendered.MetaData)
- if !strings.Contains(metaData, "instance-id: kb_test") {
- t.Fatalf("meta-data = %s", metaData)
- }
- if !strings.Contains(metaData, "local-hostname: p1-meta") {
- t.Fatalf("meta-data = %s", metaData)
- }
-
- userData := string(rendered.UserData)
- if !strings.Contains(userData, "#cloud-config") {
- t.Fatalf("user-data = %s", userData)
- }
- if !strings.Contains(userData, "name: kumabox") {
- t.Fatalf("user-data = %s", userData)
- }
-
- networkConfig := string(rendered.NetworkConfig)
- if !strings.Contains(networkConfig, "dhcp4: true") {
- t.Fatalf("network-config = %s", networkConfig)
- }
-}
-
-func TestRenderStaticNetworkConfig(t *testing.T) {
- rendered, err := Render(Config{
- InstanceID: "kb_test",
- Hostname: "p2-net",
- Networks: []Network{{
- MAC: "02:00:00:00:00:11",
- IP: "10.88.0.2",
- Prefix: 16,
- Gateway: "10.88.0.1",
- DNS: []string{"1.1.1.1"},
- }},
- })
- if err != nil {
- t.Fatal(err)
- }
- networkConfig := string(rendered.NetworkConfig)
- for _, want := range []string{
- `macaddress: "02:00:00:00:00:11"`,
- "set-name: eth0",
- "10.88.0.2/16",
- "gateway4: 10.88.0.1",
- "1.1.1.1",
- } {
- if !strings.Contains(networkConfig, want) {
- t.Fatalf("network-config missing %q:\n%s", want, networkConfig)
- }
- }
- if strings.Contains(networkConfig, "dhcp4: true") {
- t.Fatalf("static network-config should not include DHCP fallback:\n%s", networkConfig)
- }
-}
-
-func TestRenderManagedDataDiskMount(t *testing.T) {
- rendered, err := Render(Config{
- InstanceID: "kb_test", Hostname: "data", Mounts: []Mount{{Device: "/dev/disk/by-id/virtio-workspace", MountPoint: "/mnt/workspace", Filesystem: "ext4", Options: "defaults,nofail"}},
- })
- if err != nil {
- t.Fatal(err)
- }
- userData := string(rendered.UserData)
- for _, want := range []string{"mounts:", "/dev/disk/by-id/virtio-workspace", "/mnt/workspace", "defaults,nofail"} {
- if !strings.Contains(userData, want) {
- t.Fatalf("user-data missing %q:\n%s", want, userData)
- }
- }
-}
-
-func TestWriteNoCloudImage(t *testing.T) {
- var buf bytes.Buffer
- if err := WriteNoCloudImage(&buf, Config{
- InstanceID: "kb_test",
- Hostname: "p1-meta",
- }); err != nil {
- t.Fatal(err)
- }
- if buf.Len() != fatSectorSize*fatTotalSectors {
- t.Fatalf("image size = %d", buf.Len())
- }
- if !bytes.Contains(buf.Bytes()[43:54], []byte("CIDATA")) {
- t.Fatalf("CIDATA label not found in boot sector")
- }
- if !ContainsNoCloudFiles(buf.Bytes()) {
- t.Fatalf("NoCloud files not found in FAT image")
- }
-}
-
-func TestWriteNoCloudWritesSourceFilesAndDisk(t *testing.T) {
- dir := t.TempDir()
- cidataDir := filepath.Join(dir, "cidata")
- cidataDisk := filepath.Join(dir, "cidata.img")
-
- if err := WriteNoCloud(cidataDir, cidataDisk, Config{
- InstanceID: "kb_test",
- Hostname: "p1-meta",
- }); err != nil {
- t.Fatal(err)
- }
-
- for _, name := range []string{"meta-data", "user-data", "network-config"} {
- if _, err := os.Stat(filepath.Join(cidataDir, name)); err != nil {
- t.Fatalf("%s missing: %v", name, err)
- }
- }
- info, err := os.Stat(cidataDisk)
- if err != nil {
- t.Fatal(err)
- }
- if info.Size() != fatSectorSize*fatTotalSectors {
- t.Fatalf("cidata size = %d", info.Size())
- }
-}
diff --git a/internal/vm/record.go b/internal/vm/record.go
deleted file mode 100644
index 6e26415..0000000
--- a/internal/vm/record.go
+++ /dev/null
@@ -1,723 +0,0 @@
-// Package vm defines and persists KumaBox VM intent and observed state.
-//
-// A VM record stores what KumaBox wants to run: disks, boot mode, network
-// attachments, and managed directories. Runtime reconciliation augments that
-// intent with observed state from the backend, but the store itself does not
-// talk to Cloud Hypervisor or the host network.
-package vm
-
-import (
- "errors"
- "fmt"
- "path/filepath"
- "strings"
- "time"
-
- kbnetwork "github.com/kumabox/kumabox/internal/network"
-)
-
-const defaultMemoryBytes int64 = 512 << 20
-
-const (
- FormatRaw = "raw"
- FormatQCOW2 = "qcow2"
- FilesystemEXT4 = "ext4"
- FilesystemEROFS = "erofs"
- FilesystemNone = "none"
- StorageIDCOW = "cow"
- StorageIDCidata = "cidata"
- StorageSerialCOW = "kumabox-cow"
- BaseFamilyOCI = "oci"
-)
-
-func LayerID(index int) string {
- return fmt.Sprintf("layer%d", index)
-}
-
-func LayerSerial(index int) string {
- return fmt.Sprintf("kumabox-layer%d", index)
-}
-
-// VMState is KumaBox's persisted lifecycle state.
-//
-// It is updated by lifecycle operations such as start, stop, and delete. It is
-// not a direct probe of the VMM process; callers should compare it with
-// ObservedState when reconciling stale records.
-type VMState string
-
-const (
- StateCreated VMState = "created"
- StateRunning VMState = "running"
- StatePaused VMState = "paused"
- StateStopped VMState = "stopped"
- StateError VMState = "error"
-)
-
-// ObservedState is the runtime state observed from the backend.
-//
-// Observed state may diverge from VMState when a daemonless command exits, the
-// VMM crashes, or host resources disappear. KumaBox records this separately so
-// CLI output can show both desired/persisted state and current reality.
-type ObservedState string
-
-const (
- ObservedStateCreated ObservedState = "CREATED"
- ObservedStateRunning ObservedState = "RUNNING"
- ObservedStatePaused ObservedState = "PAUSED"
- ObservedStateStopped ObservedState = "STOPPED"
- ObservedStateFailed ObservedState = "FAILED"
- ObservedStateUnknown ObservedState = "UNKNOWN"
-)
-
-// Observation captures one backend reconciliation result.
-//
-// Observations are transient values returned by backend probes. Runtime may
-// copy the latest observation into VMRecord fields and append lifecycle events
-// to the VM log directory.
-type Observation struct {
- State ObservedState `json:"state"`
- Reason string `json:"reason,omitempty"`
- CheckedAt time.Time `json:"checkedAt"`
-}
-
-// VMRecord is the durable VM metadata stored in the backend index.
-//
-// The record intentionally keeps VM identity, boot configuration, network
-// attachment intent, and managed paths in one document. Provider-specific
-// indexes, such as host-tap leases, remain outside the VM index and are linked
-// by NetworkConfigs.
-type VMRecord struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Backend string `json:"backend"`
- State VMState `json:"state"`
- ObservedState ObservedState `json:"observedState,omitempty"`
- ObservedReason string `json:"observedReason,omitempty"`
- ObservedAt *time.Time `json:"observedAt,omitempty"`
- PID int `json:"pid,omitempty"`
- APISocket string `json:"apiSocket,omitempty"`
- VsockSocket string `json:"vsockSocket,omitempty"`
- Error string `json:"error,omitempty"`
- Restore *RestoreStatus `json:"restore,omitempty"`
- LastRestore *RestoreResult `json:"lastRestore,omitempty"`
- Performance *PerformanceMetrics `json:"performance,omitempty"`
- SnapshotDependency *SnapshotDependency `json:"snapshotDependency,omitempty"`
- Hibernate *HibernateStatus `json:"hibernate,omitempty"`
- RootDisk string `json:"rootDisk"`
- Kernel string `json:"kernel,omitempty"`
- Initrd string `json:"initrd,omitempty"`
- KernelCmdline string `json:"kernelCmdline,omitempty"`
- Firmware string `json:"firmware,omitempty"`
- Image *ImageRef `json:"image,omitempty"`
- CPUs int `json:"cpus"`
- MemoryBytes int64 `json:"memoryBytes"`
- SharedMemory bool `json:"sharedMemory,omitempty"`
- Metadata *Metadata `json:"metadata,omitempty"`
- StorageConfigs []StorageConfig `json:"storageConfigs,omitempty"`
- AttachedDisks []AttachedDisk `json:"attachedDisks,omitempty"`
- AttachedFilesystems []AttachedFilesystem `json:"attachedFilesystems,omitempty"`
- AttachedPCIDevices []AttachedPCIDevice `json:"attachedPCIDevices,omitempty"`
- NetworkConfigs []kbnetwork.Config `json:"networkConfigs,omitempty"`
- Network string `json:"network,omitempty"`
- Networks []string `json:"networks,omitempty"`
- NetworkStatus *kbnetwork.InspectResult `json:"networkStatus,omitempty"`
- RunDir string `json:"runDir"`
- LogDir string `json:"logDir"`
- Config string `json:"config"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
- StartedAt *time.Time `json:"startedAt,omitempty"`
- StoppedAt *time.Time `json:"stoppedAt,omitempty"`
- FirstBooted bool `json:"firstBooted,omitempty"`
-}
-
-// RestoreStatus is the durable recovery marker for an in-place native
-// restore. Its presence means writable state may have been replaced and a
-// normal cold start must fail closed until restore succeeds or the VM is
-// deleted.
-type RestoreStatus struct {
- SnapshotID string `json:"snapshotId"`
- Mode string `json:"mode"`
- State string `json:"state"`
- Error string `json:"error,omitempty"`
- StartedAt time.Time `json:"startedAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-// RestoreResult records the latest completed native restore for operational
-// latency inspection without retaining the transient dirty marker.
-type RestoreResult struct {
- SnapshotID string `json:"snapshotId"`
- Mode string `json:"mode"`
- DurationMs int64 `json:"durationMs"`
- NativeStageDurationMs int64 `json:"nativeStageDurationMs"`
- DiskStageDurationMs int64 `json:"diskStageDurationMs"`
- DiskCommitDurationMs int64 `json:"diskCommitDurationMs"`
- BackendRestoreDurationMs int64 `json:"backendRestoreDurationMs"`
- IdentityDurationMs int64 `json:"identityDurationMs"`
- ReadinessDurationMs int64 `json:"readinessDurationMs"`
- GuestAgentWarning string `json:"guestAgentWarning,omitempty"`
- CompletedAt time.Time `json:"completedAt"`
-}
-
-// PerformanceMetrics records the user-visible lifecycle milestones for the
-// latest create-and-start or start operation. Phase times are wall-clock
-// timestamps for inspection; duration fields are calculated from a monotonic
-// clock before persistence.
-type PerformanceMetrics struct {
- Operation string `json:"operation"`
- ImageDigest string `json:"imageDigest,omitempty"`
- EnvironmentFingerprint string `json:"environmentFingerprint,omitempty"`
- CommandStartedAt time.Time `json:"commandStartedAt"`
- ImageResolvedAt *time.Time `json:"imageResolvedAt,omitempty"`
- StorageReadyAt *time.Time `json:"storageReadyAt,omitempty"`
- NetworkReadyAt *time.Time `json:"networkReadyAt,omitempty"`
- VMMSpawnedAt *time.Time `json:"vmmSpawnedAt,omitempty"`
- VMMAPIReadyAt *time.Time `json:"vmmAPIReadyAt,omitempty"`
- AgentConnectedAt *time.Time `json:"agentConnectedAt,omitempty"`
- FirstExecCompletedAt *time.Time `json:"firstExecCompletedAt,omitempty"`
- VMMAPIReadyDurationMs int64 `json:"vmmAPIReadyDurationMs,omitempty"`
- AgentReadyDurationMs int64 `json:"agentReadyDurationMs,omitempty"`
- FirstExecDurationMs int64 `json:"firstExecDurationMs,omitempty"`
- ReadyDurationMs int64 `json:"readyDurationMs,omitempty"`
-}
-
-// SnapshotDependency pins native memory payload while a delayed restore mode
-// may still fault pages from the source snapshot.
-type SnapshotDependency struct {
- SnapshotID string `json:"snapshotId"`
- Mode string `json:"mode"`
- Since time.Time `json:"since"`
-}
-
-// HibernateStatus prevents a cold start from discarding a resumable native
-// memory state. Restore clears it only after the VM has resumed successfully.
-type HibernateStatus struct {
- SnapshotID string `json:"snapshotId"`
- CreatedAt time.Time `json:"createdAt"`
-}
-
-func (r *VMRecord) EffectiveMemoryBytes() int64 {
- if r == nil || r.MemoryBytes <= 0 {
- return defaultMemoryBytes
- }
- return r.MemoryBytes
-}
-
-// Metadata describes the generated cloud-init NoCloud seed attached to a VM.
-//
-// Firmware/cloud-image boots use this seed for hostname, user-data, and static
-// network configuration. Direct kernel/initrd boots may not need metadata.
-type Metadata struct {
- Type string `json:"type"`
- CidataDir string `json:"cidataDir"`
- CidataDisk string `json:"cidataDisk"`
-}
-
-// ImageRef records the managed image used to create or run a VM.
-//
-// The root disk path is copied into the VM record so lifecycle operations do
-// not need to resolve mutable image names after creation.
-type ImageRef struct {
- ID string `json:"id"`
- Name string `json:"name"`
- RootDisk string `json:"rootDisk"`
- BootMode string `json:"bootMode,omitempty"`
- Digest string `json:"digest,omitempty"`
- LayerDigests []string `json:"layerDigests,omitempty"`
-}
-
-// StorageRole describes the semantic purpose of a VM block device.
-type StorageRole string
-
-const (
- StorageRoleLayer StorageRole = "layer"
- StorageRoleBase StorageRole = "base"
- StorageRoleCOW StorageRole = "cow"
- StorageRoleData StorageRole = "data"
- StorageRoleCidata StorageRole = "cidata"
-)
-
-// StorageBase pins the immutable image assets backing a writable root disk.
-// Paths are local resolution hints; digests are the portable identity.
-type StorageBase struct {
- Family string `json:"family"`
- ImageID string `json:"imageId,omitempty"`
- Digest string `json:"digest,omitempty"`
- Format string `json:"format,omitempty"`
- Path string `json:"path,omitempty"`
- LayerDigests []string `json:"layerDigests,omitempty"`
-}
-
-// StorageConfig describes one block device owned or referenced by a VM.
-type StorageConfig struct {
- ID string `json:"id"`
- Role StorageRole `json:"role,omitempty"`
- Path string `json:"path"`
- Readonly bool `json:"readonly"`
- DirectIO *bool `json:"directIO,omitempty"`
- Format string `json:"format,omitempty"`
- Serial string `json:"serial,omitempty"`
- Filesystem string `json:"filesystem,omitempty"`
- MountPoint string `json:"mountPoint,omitempty"`
- VirtualSizeBytes int64 `json:"virtualSizeBytes,omitempty"`
- Base *StorageBase `json:"base,omitempty"`
- Type string `json:"type,omitempty"` // Legacy P3 field.
- ImageType string `json:"imageType,omitempty"` // Legacy P3 field.
- SourceLayer string `json:"sourceLayer,omitempty"`
- SizeBytes int64 `json:"sizeBytes,omitempty"` // Legacy P3 field.
-}
-
-// DataDiskRequest describes a managed writable disk created together with a VM.
-// The VM record stores the normalized result as a StorageConfig.
-type DataDiskRequest struct {
- Name string
- SizeBytes int64
- Filesystem string
- MountPoint string
- MountSet bool
- DirectIO *bool
-}
-
-type AttachedDisk struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Path string `json:"path"`
- ReadOnly bool `json:"readonly,omitempty"`
-}
-
-type AttachedFilesystem struct {
- ID string `json:"id"`
- Tag string `json:"tag"`
- Socket string `json:"socket"`
-}
-
-type AttachedPCIDevice struct {
- ID string `json:"id"`
- PCI string `json:"pci"`
-}
-
-// EffectiveRole returns Role or its legacy Type equivalent.
-func (c StorageConfig) EffectiveRole() StorageRole {
- if c.Role != "" {
- return c.Role
- }
- return StorageRole(c.Type)
-}
-
-// EffectiveFormat returns Format or its legacy ImageType equivalent.
-func (c StorageConfig) EffectiveFormat() string {
- if c.Format != "" {
- return c.Format
- }
- return c.ImageType
-}
-
-// EffectiveVirtualSize returns VirtualSizeBytes or its legacy SizeBytes value.
-func (c StorageConfig) EffectiveVirtualSize() int64 {
- if c.VirtualSizeBytes > 0 {
- return c.VirtualSizeBytes
- }
- return c.SizeBytes
-}
-
-func newRecord(id string, req CreateRequest, rootDir string, now time.Time) (*VMRecord, error) {
- rootDisk, err := normalizePath(req.RootDisk)
- if err != nil {
- return nil, err
- }
- kernel, err := normalizePath(req.Kernel)
- if err != nil {
- return nil, err
- }
- initrd, err := normalizePath(req.Initrd)
- if err != nil {
- return nil, err
- }
- firmware, err := normalizePath(req.Firmware)
- if err != nil {
- return nil, err
- }
- runDir, err := normalizePath(filepath.Join(req.RunDir, "vms", id))
- if err != nil {
- return nil, err
- }
- logDir, err := normalizePath(filepath.Join(req.LogDir, "vms", id))
- if err != nil {
- return nil, err
- }
-
- networks, err := normalizeNetworks(req.Network, req.Networks)
- if err != nil {
- return nil, err
- }
- network := primaryNetwork(networks)
- cpus := normalizeCPUs(req.CPUs)
- storageConfigs := normalizeStorageConfigs(req.StorageConfigs, rootDir, id)
- dataConfigs, err := normalizeDataDisks(req.DataDisks, rootDir, id)
- if err != nil {
- return nil, err
- }
- storageConfigs = append(storageConfigs, dataConfigs...)
- if overlay := cloudImageRootOverlay(storageConfigs); overlay != "" {
- rootDisk = overlay
- }
- rec := &VMRecord{
- ID: id,
- Name: req.Name,
- Backend: backendCloudHypervisor,
- State: StateCreated,
- RootDisk: rootDisk,
- Kernel: kernel,
- Initrd: initrd,
- KernelCmdline: req.KernelCmdline,
- Firmware: firmware,
- Image: cloneImageRef(req.Image),
- CPUs: cpus,
- MemoryBytes: normalizeMemoryBytes(req.MemoryBytes),
- SharedMemory: req.SharedMemory,
- StorageConfigs: storageConfigs,
- Network: network,
- Networks: cloneStrings(networks),
- RunDir: runDir,
- LogDir: logDir,
- Config: filepath.Join(runDir, "cloud-hypervisor.json"),
- VsockSocket: filepath.Join(runDir, "vsock.uds"),
- CreatedAt: now,
- UpdatedAt: now,
- }
- if firmware != "" {
- rec.Metadata = &Metadata{
- Type: "nocloud",
- CidataDir: filepath.Join(runDir, "cidata"),
- CidataDisk: filepath.Join(runDir, "cidata.img"),
- }
- }
- return rec, nil
-}
-
-func normalizeCPUs(cpus int) int {
- if cpus <= 0 {
- return 1
- }
- return cpus
-}
-
-func normalizeMemoryBytes(memoryBytes int64) int64 {
- if memoryBytes <= 0 {
- return defaultMemoryBytes
- }
- return memoryBytes
-}
-
-func cloneRecord(rec *VMRecord) *VMRecord {
- if rec == nil {
- return nil
- }
- copied := *rec
- if rec.ObservedAt != nil {
- observedAt := *rec.ObservedAt
- copied.ObservedAt = &observedAt
- }
- if rec.Metadata != nil {
- metadata := *rec.Metadata
- copied.Metadata = &metadata
- }
- if rec.Restore != nil {
- restore := *rec.Restore
- copied.Restore = &restore
- }
- if rec.LastRestore != nil {
- lastRestore := *rec.LastRestore
- copied.LastRestore = &lastRestore
- }
- if rec.Performance != nil {
- performance := *rec.Performance
- performance.ImageResolvedAt = cloneTime(rec.Performance.ImageResolvedAt)
- performance.StorageReadyAt = cloneTime(rec.Performance.StorageReadyAt)
- performance.NetworkReadyAt = cloneTime(rec.Performance.NetworkReadyAt)
- performance.VMMSpawnedAt = cloneTime(rec.Performance.VMMSpawnedAt)
- performance.VMMAPIReadyAt = cloneTime(rec.Performance.VMMAPIReadyAt)
- performance.AgentConnectedAt = cloneTime(rec.Performance.AgentConnectedAt)
- performance.FirstExecCompletedAt = cloneTime(rec.Performance.FirstExecCompletedAt)
- copied.Performance = &performance
- }
- if rec.SnapshotDependency != nil {
- dependency := *rec.SnapshotDependency
- copied.SnapshotDependency = &dependency
- }
- if rec.Hibernate != nil {
- hibernate := *rec.Hibernate
- copied.Hibernate = &hibernate
- }
- copied.Image = cloneImageRef(rec.Image)
- copied.StorageConfigs = cloneStorageConfigs(rec.StorageConfigs)
- copied.AttachedDisks = append([]AttachedDisk(nil), rec.AttachedDisks...)
- copied.AttachedFilesystems = append([]AttachedFilesystem(nil), rec.AttachedFilesystems...)
- copied.AttachedPCIDevices = append([]AttachedPCIDevice(nil), rec.AttachedPCIDevices...)
- copied.Networks = cloneStrings(rec.Networks)
- copied.NetworkConfigs = cloneNetworkConfigs(rec.NetworkConfigs)
- copied.NetworkStatus = cloneNetworkStatus(rec.NetworkStatus)
- if rec.StartedAt != nil {
- startedAt := *rec.StartedAt
- copied.StartedAt = &startedAt
- }
- if rec.StoppedAt != nil {
- stoppedAt := *rec.StoppedAt
- copied.StoppedAt = &stoppedAt
- }
- return &copied
-}
-
-func cloneTime(value *time.Time) *time.Time {
- if value == nil {
- return nil
- }
- copied := *value
- return &copied
-}
-
-func normalizeStorageConfigs(configs []StorageConfig, rootDir, vmID string) []StorageConfig {
- if len(configs) == 0 {
- return nil
- }
- normalized := make([]StorageConfig, 0, len(configs))
- for i, cfg := range configs {
- if cfg.Role == "" {
- cfg.Role = StorageRole(cfg.Type)
- }
- if cfg.Format == "" {
- cfg.Format = cfg.ImageType
- }
- if cfg.VirtualSizeBytes == 0 {
- cfg.VirtualSizeBytes = cfg.SizeBytes
- }
- cfg.Type = ""
- cfg.ImageType = ""
- cfg.SizeBytes = 0
- if cfg.ID == "" {
- cfg.ID = fmt.Sprintf("storage%d", i)
- }
- if cfg.Role == StorageRoleCOW && cfg.Path == "" {
- name := "cow.ext4"
- if cfg.Base != nil && cfg.Base.Family == "cloudimg" {
- name = "root.overlay.qcow2"
- }
- cfg.Path = filepath.Join(rootDir, "storage", "vms", vmID, name)
- }
- if cfg.Role == StorageRoleData && cfg.Path == "" {
- ext := ".raw"
- if cfg.Format == FormatQCOW2 {
- ext = ".qcow2"
- }
- cfg.Path = filepath.Join(rootDir, "storage", "vms", vmID, "data-"+cfg.ID+ext)
- }
- if abs, err := normalizePath(cfg.Path); err == nil {
- cfg.Path = abs
- }
- normalized = append(normalized, cfg)
- }
- return normalized
-}
-
-func normalizeDataDisks(disks []DataDiskRequest, rootDir, vmID string) ([]StorageConfig, error) {
- if len(disks) == 0 {
- return nil, nil
- }
- configs := make([]StorageConfig, 0, len(disks))
- seen := make(map[string]struct{}, len(disks))
- for i, disk := range disks {
- if err := validateDataDiskRequest(disk); err != nil {
- return nil, fmt.Errorf("data disk %d: %w", i, err)
- }
- if _, exists := seen[disk.Name]; exists {
- return nil, fmt.Errorf("duplicate data disk name %q", disk.Name)
- }
- seen[disk.Name] = struct{}{}
- filesystem := disk.Filesystem
- if filesystem == "" {
- filesystem = FilesystemEXT4
- }
- id := "data-" + disk.Name
- mountPoint := disk.MountPoint
- if !disk.MountSet && filesystem != FilesystemNone {
- mountPoint = "/mnt/" + disk.Name
- }
- configs = append(configs, StorageConfig{
- ID: id, Role: StorageRoleData,
- Path: filepath.Join(rootDir, "storage", "vms", vmID, id+".raw"),
- Readonly: false, DirectIO: disk.DirectIO, Format: FormatRaw,
- Serial: disk.Name, Filesystem: filesystem, MountPoint: mountPoint,
- VirtualSizeBytes: disk.SizeBytes,
- })
- }
- return configs, nil
-}
-
-func validateDataDiskRequest(disk DataDiskRequest) error {
- if !validStorageName(disk.Name) {
- return fmt.Errorf("name %q must start with a letter and contain only letters, digits, '_' or '-'", disk.Name)
- }
- if disk.SizeBytes < 16<<20 {
- return fmt.Errorf("size must be at least 16MiB")
- }
- filesystem := disk.Filesystem
- if filesystem == "" {
- filesystem = FilesystemEXT4
- }
- if filesystem != FilesystemEXT4 && filesystem != FilesystemNone {
- return fmt.Errorf("filesystem %q is unsupported", filesystem)
- }
- if disk.MountPoint != "" {
- if !filepath.IsAbs(disk.MountPoint) || disk.MountPoint == "/" || strings.ContainsAny(disk.MountPoint, "\x00\n") {
- return fmt.Errorf("mount point %q must be an absolute non-root path", disk.MountPoint)
- }
- }
- if filesystem == FilesystemNone && disk.MountPoint != "" {
- return fmt.Errorf("mount point requires a filesystem")
- }
- return nil
-}
-
-func validStorageName(value string) bool {
- if len(value) == 0 || len(value) > 20 || value[0] < 'a' || value[0] > 'z' {
- return false
- }
- for _, char := range value[1:] {
- if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '_' && char != '-' {
- return false
- }
- }
- return true
-}
-
-func cloudImageRootOverlay(configs []StorageConfig) string {
- for _, cfg := range configs {
- if cfg.EffectiveRole() == StorageRoleCOW && cfg.Base != nil && cfg.Base.Family == "cloudimg" {
- return cfg.Path
- }
- }
- return ""
-}
-
-func cloneStorageConfigs(configs []StorageConfig) []StorageConfig {
- if len(configs) == 0 {
- return nil
- }
- copied := append([]StorageConfig(nil), configs...)
- for i := range copied {
- if configs[i].Base == nil {
- continue
- }
- base := *configs[i].Base
- base.LayerDigests = cloneStrings(configs[i].Base.LayerDigests)
- copied[i].Base = &base
- }
- return copied
-}
-
-func cloneNetworkStatus(status *kbnetwork.InspectResult) *kbnetwork.InspectResult {
- if status == nil {
- return nil
- }
- copied := *status
- copied.Interfaces = append([]kbnetwork.Record(nil), status.Interfaces...)
- copied.VMConfigs = cloneNetworkConfigs(status.VMConfigs)
- copied.Drift = append([]string(nil), status.Drift...)
- return &copied
-}
-
-func cloneNetworkConfigs(configs []kbnetwork.Config) []kbnetwork.Config {
- if len(configs) == 0 {
- return nil
- }
- copied := make([]kbnetwork.Config, len(configs))
- copy(copied, configs)
- for i := range copied {
- if configs[i].Network != nil {
- network := *configs[i].Network
- network.DNS = append([]string(nil), configs[i].Network.DNS...)
- copied[i].Network = &network
- }
- }
- return copied
-}
-
-func cloneImageRef(ref *ImageRef) *ImageRef {
- if ref == nil {
- return nil
- }
- copied := *ref
- copied.LayerDigests = cloneStrings(ref.LayerDigests)
- return &copied
-}
-
-func normalizeNetworks(network string, networks []string) ([]string, error) {
- values := append([]string(nil), networks...)
- if len(values) == 0 && network != "" {
- values = append(values, network)
- }
- if len(values) == 0 {
- values = append(values, "none")
- }
- for i, value := range values {
- if value == "" {
- return nil, errors.New("network value must not be empty")
- }
- values[i] = value
- }
- if len(values) > 1 {
- var family string
- for _, value := range values {
- if value == kbnetwork.ProviderNone {
- return nil, errors.New("network none cannot be combined with other networks")
- }
- currentFamily := networkProviderFamily(value)
- if family == "" {
- family = currentFamily
- continue
- }
- if currentFamily != family {
- return nil, errors.New("multiple networks must use the same provider family")
- }
- }
- }
- return values, nil
-}
-
-func networkProviderFamily(network string) string {
- if kbnetwork.IsCNISelection(network) {
- return kbnetwork.ProviderCNI
- }
- if network == "default" || network == kbnetwork.ProviderHostTap {
- return kbnetwork.ProviderHostTap
- }
- if strings.HasPrefix(network, kbnetwork.ProviderHostTap+":") {
- return kbnetwork.ProviderHostTap
- }
- return network
-}
-
-func primaryNetwork(networks []string) string {
- if len(networks) == 0 {
- return ""
- }
- if len(networks) == 1 {
- return networks[0]
- }
- return "multi"
-}
-
-func cloneStrings(values []string) []string {
- if len(values) == 0 {
- return nil
- }
- return append([]string(nil), values...)
-}
-
-func normalizePath(path string) (string, error) {
- if path == "" {
- return "", nil
- }
- return filepath.Abs(path)
-}
diff --git a/internal/vm/runtime/batch.go b/internal/vm/runtime/batch.go
deleted file mode 100644
index 901e2a7..0000000
--- a/internal/vm/runtime/batch.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package runtime
-
-import (
- "context"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/batch"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// BatchOptions controls the amount of parallel lifecycle work. A zero
-// concurrency uses the host CPU count.
-type BatchOptions struct {
- Concurrency int
-}
-
-// BatchFailure describes one VM that could not complete a batch operation.
-type BatchFailure = batch.Failure
-
-// BatchResult is the stable, input-ordered outcome of a best-effort batch.
-type BatchResult struct {
- Succeeded []*vm.VMRecord `json:"succeeded"`
- Failed []BatchFailure `json:"failed,omitempty"`
- err error
-}
-
-// Err joins all per-VM failures while retaining their original error chains.
-func (r BatchResult) Err() error {
- return r.err
-}
-
-// StartVMsContext starts each distinct VM reference using bounded concurrency.
-func (r *Runtime) StartVMsContext(ctx context.Context, refs []string, opts BatchOptions) BatchResult {
- return runVMBatch(ctx, refs, opts, r.StartVMContext)
-}
-
-// StopVMsContext stops each distinct VM reference using bounded concurrency.
-func (r *Runtime) StopVMsContext(
- ctx context.Context,
- refs []string,
- stopOpts backend.StopOptions,
- batchOpts BatchOptions,
-) BatchResult {
- return runVMBatch(ctx, refs, batchOpts, func(ctx context.Context, ref string) (*vm.VMRecord, error) {
- return r.StopVMContext(ctx, ref, stopOpts)
- })
-}
-
-// PauseVMs pauses each distinct VM reference using bounded concurrency.
-func (r *Runtime) PauseVMs(ctx context.Context, refs []string, opts BatchOptions) BatchResult {
- return runVMBatch(ctx, refs, opts, r.PauseVM)
-}
-
-// ResumeVMs resumes each distinct VM reference using bounded concurrency.
-func (r *Runtime) ResumeVMs(ctx context.Context, refs []string, opts BatchOptions) BatchResult {
- return runVMBatch(ctx, refs, opts, r.ResumeVM)
-}
-
-// DeleteVMsContext deletes each distinct VM reference using bounded concurrency.
-func (r *Runtime) DeleteVMsContext(ctx context.Context, refs []string, force bool, opts BatchOptions) BatchResult {
- return runVMBatch(ctx, refs, opts, func(ctx context.Context, ref string) (*vm.VMRecord, error) {
- return r.DeleteVMContext(ctx, ref, force)
- })
-}
-
-func runVMBatch(
- ctx context.Context,
- refs []string,
- opts BatchOptions,
- fn func(context.Context, string) (*vm.VMRecord, error),
-) BatchResult {
- refs = batch.Distinct(refs)
- if len(refs) == 0 {
- return BatchResult{Succeeded: []*vm.VMRecord{}}
- }
- result := batch.Run(ctx, refs, batch.Options{Concurrency: opts.Concurrency}, "VM",
- func(ctx context.Context, _ int, ref string) (*vm.VMRecord, error) {
- return fn(ctx, ref)
- })
- return BatchResult{Succeeded: result.Succeeded, Failed: result.Failed, err: result.Err()}
-}
diff --git a/internal/vm/runtime/batch_test.go b/internal/vm/runtime/batch_test.go
deleted file mode 100644
index 8f99019..0000000
--- a/internal/vm/runtime/batch_test.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestRunVMBatchBestEffortPreservesOrderAndDeduplicates(t *testing.T) {
- wantErr := errors.New("start failed")
- var calls sync.Map
-
- result := runVMBatch(t.Context(), []string{"first", "failed", "first", "last"}, BatchOptions{Concurrency: 3},
- func(_ context.Context, ref string) (*vm.VMRecord, error) {
- count, _ := calls.LoadOrStore(ref, new(atomic.Int32))
- count.(*atomic.Int32).Add(1)
- if ref == "failed" {
- return nil, wantErr
- }
- return &vm.VMRecord{Name: ref}, nil
- })
-
- if len(result.Succeeded) != 2 || result.Succeeded[0].Name != "first" || result.Succeeded[1].Name != "last" {
- t.Fatalf("succeeded = %+v", result.Succeeded)
- }
- if len(result.Failed) != 1 || result.Failed[0].Ref != "failed" || result.Failed[0].Error != wantErr.Error() {
- t.Fatalf("failed = %+v", result.Failed)
- }
- if !errors.Is(result.Err(), wantErr) {
- t.Fatalf("error = %v, want wrapped %v", result.Err(), wantErr)
- }
- count, ok := calls.Load("first")
- if !ok || count.(*atomic.Int32).Load() != 1 {
- t.Fatalf("first call count = %v, want 1", count)
- }
-}
-
-func TestRunVMBatchHonorsConcurrencyLimit(t *testing.T) {
- var active atomic.Int32
- var peak atomic.Int32
- release := make(chan struct{})
- started := make(chan struct{}, 4)
-
- done := make(chan BatchResult, 1)
- go func() {
- done <- runVMBatch(t.Context(), []string{"a", "b", "c", "d"}, BatchOptions{Concurrency: 2},
- func(_ context.Context, ref string) (*vm.VMRecord, error) {
- current := active.Add(1)
- for {
- previous := peak.Load()
- if current <= previous || peak.CompareAndSwap(previous, current) {
- break
- }
- }
- started <- struct{}{}
- <-release
- active.Add(-1)
- return &vm.VMRecord{Name: ref}, nil
- })
- }()
-
- for range 2 {
- select {
- case <-started:
- case <-time.After(time.Second):
- t.Fatal("batch did not start two workers")
- }
- }
- select {
- case <-started:
- t.Fatal("batch exceeded concurrency limit")
- case <-time.After(20 * time.Millisecond):
- }
- close(release)
-
- result := <-done
- if err := result.Err(); err != nil {
- t.Fatal(err)
- }
- if peak.Load() != 2 {
- t.Fatalf("peak concurrency = %d, want 2", peak.Load())
- }
-}
-
-func TestRunVMBatchReportsCanceledItems(t *testing.T) {
- ctx, cancel := context.WithCancel(t.Context())
- cancel()
-
- result := runVMBatch(ctx, []string{"a", "b"}, BatchOptions{Concurrency: 1},
- func(context.Context, string) (*vm.VMRecord, error) {
- t.Fatal("operation ran after context cancellation")
- return nil, nil
- })
-
- if len(result.Succeeded) != 0 || len(result.Failed) != 2 {
- t.Fatalf("result = %+v", result)
- }
- if !errors.Is(result.Err(), context.Canceled) {
- t.Fatalf("error = %v, want context canceled", result.Err())
- }
-}
diff --git a/internal/vm/runtime/console.go b/internal/vm/runtime/console.go
deleted file mode 100644
index 11c956b..0000000
--- a/internal/vm/runtime/console.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package runtime
-
-import (
- "context"
- "fmt"
- "io"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func (r *Runtime) OpenConsole(ctx context.Context, ref string) (io.ReadWriteCloser, error) {
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- observed := r.applyObservation(rec)
- if observed.ObservedState != vm.ObservedStateRunning {
- return nil, fmt.Errorf("VM_NOT_RUNNING: VM %s is not running", rec.Name)
- }
- controller, ok := r.backend.(backend.ConsoleController)
- if !ok {
- return nil, fmt.Errorf("BACKEND_OPERATION_UNSUPPORTED: backend does not support console")
- }
- return controller.OpenConsole(ctx, observed)
-}
diff --git a/internal/vm/runtime/device_state.go b/internal/vm/runtime/device_state.go
deleted file mode 100644
index d30c986..0000000
--- a/internal/vm/runtime/device_state.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// RefreshDeviceState reconciles durable hotplug metadata with one live
-// vm.info response while holding the VM operation lock.
-func (r *Runtime) RefreshDeviceState(ctx context.Context, ref string) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for device inspection: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- inspector, ok := r.backend.(backend.DeviceInspector)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not inspect devices")
- }
- live, err := inspector.InspectDevices(ctx, rec)
- if err != nil {
- return nil, err
- }
- updated, err := r.vmRecords.SetAttachedDisks(rec.ID, toVMDisks(live.Disks))
- if err != nil {
- return nil, err
- }
- updated, err = r.vmRecords.SetAttachedFilesystems(updated.ID, toVMFilesystems(live.Filesystems))
- if err != nil {
- return nil, err
- }
- updated, err = r.vmRecords.SetAttachedPCIDevices(updated.ID, toVMPCIDevices(live.PCIDevices))
- if err != nil {
- return nil, err
- }
- return updated, nil
-}
-
-func toVMDisks(items []backend.AttachedDisk) []vm.AttachedDisk {
- result := make([]vm.AttachedDisk, 0, len(items))
- for _, item := range items {
- result = append(result, vm.AttachedDisk{ID: item.ID, Name: item.Name, Path: item.Path, ReadOnly: item.ReadOnly})
- }
- return result
-}
-
-func toVMFilesystems(items []backend.AttachedFilesystem) []vm.AttachedFilesystem {
- result := make([]vm.AttachedFilesystem, 0, len(items))
- for _, item := range items {
- result = append(result, vm.AttachedFilesystem{ID: item.ID, Tag: item.Tag, Socket: item.Socket})
- }
- return result
-}
-
-func toVMPCIDevices(items []backend.AttachedPCIDevice) []vm.AttachedPCIDevice {
- result := make([]vm.AttachedPCIDevice, 0, len(items))
- for _, item := range items {
- result = append(result, vm.AttachedPCIDevice{ID: item.ID, PCI: item.PCI})
- }
- return result
-}
diff --git a/internal/vm/runtime/disk.go b/internal/vm/runtime/disk.go
deleted file mode 100644
index ac7e997..0000000
--- a/internal/vm/runtime/disk.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func (r *Runtime) AttachDisk(ctx context.Context, ref string, spec backend.DiskSpec) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- if !filepath.IsAbs(spec.Path) {
- return nil, fmt.Errorf("disk path must be absolute")
- }
- if _, err := os.Stat(spec.Path); err != nil {
- return nil, fmt.Errorf("stat disk: %w", err)
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for disk attach: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
- controller, ok := r.backend.(backend.DiskController)
- if !ok {
- return nil, fmt.Errorf("backend does not support disk attach")
- }
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- operationID, err := r.beginOperation(ctx, operation.KindDiskAttach, rec.ID)
- if err != nil {
- return nil, err
- }
- attached, opErr := controller.AttachDisk(ctx, rec, spec)
- if opErr == nil {
- disks := append([]vm.AttachedDisk(nil), rec.AttachedDisks...)
- disks = append(disks, vm.AttachedDisk{ID: attached.ID, Name: attached.Name, Path: attached.Path, ReadOnly: attached.ReadOnly})
- _, opErr = r.vmRecords.SetAttachedDisks(rec.ID, disks)
- }
- opErr = r.finishOperation(ctx, operationID, opErr)
- updated, inspectErr := r.vmReader.Inspect(rec.ID)
- return updated, errors.Join(opErr, inspectErr)
-}
-
-func (r *Runtime) DetachDisk(ctx context.Context, ref, name string) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for disk detach: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
- controller, ok := r.backend.(backend.DiskController)
- if !ok {
- return nil, fmt.Errorf("backend does not support disk detach")
- }
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- operationID, err := r.beginOperation(ctx, operation.KindDiskDetach, rec.ID)
- if err != nil {
- return nil, err
- }
- opErr := controller.DetachDisk(ctx, rec, name)
- if opErr == nil {
- disks := make([]vm.AttachedDisk, 0, len(rec.AttachedDisks))
- for _, disk := range rec.AttachedDisks {
- if disk.Name != name {
- disks = append(disks, disk)
- }
- }
- _, opErr = r.vmRecords.SetAttachedDisks(rec.ID, disks)
- }
- opErr = r.finishOperation(ctx, operationID, opErr)
- updated, inspectErr := r.vmReader.Inspect(rec.ID)
- return updated, errors.Join(opErr, inspectErr)
-}
-
-func (r *Runtime) ListDisks(ctx context.Context, ref string) ([]backend.AttachedDisk, error) {
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- controller, ok := r.backend.(backend.DiskController)
- if !ok {
- return nil, fmt.Errorf("backend does not support disk list")
- }
- return controller.ListDisks(ctx, rec)
-}
diff --git a/internal/vm/runtime/disk_prepare.go b/internal/vm/runtime/disk_prepare.go
deleted file mode 100644
index e878df6..0000000
--- a/internal/vm/runtime/disk_prepare.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package runtime
-
-import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/kumabox/kumabox/internal/disk"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-type storageCoordinator struct {
- *Runtime
-}
-
-func (s *storageCoordinator) prepare(ctx context.Context, rec *vm.VMRecord) error {
- return prepareStorageWithQEMUImg(ctx, rec, s.vmReader.RootDir(), s.qemuImg)
-}
-
-func (s *storageCoordinator) removeManagedDirs(rec *vm.VMRecord) error {
- return removeManagedDirs(rec, s.vmReader.RootDir())
-}
-
-func removeManagedDirs(rec *vm.VMRecord, rootDir string) error {
- storageDir := filepath.Join(rootDir, "storage", "vms", rec.ID)
- for _, dir := range []string{rec.RunDir, rec.LogDir, storageDir} {
- if dir == "" {
- continue
- }
- if err := os.RemoveAll(dir); err != nil {
- return fmt.Errorf("remove managed directory %s: %w", dir, err)
- }
- }
- return nil
-}
-
-func prepareStorage(rec *vm.VMRecord, rootDir string) error {
- return prepareStorageWithQEMUImg(context.Background(), rec, rootDir, disk.NewQEMUImg("qemu-img"))
-}
-
-func prepareStorageWithQEMUImg(ctx context.Context, rec *vm.VMRecord, rootDir string, qemuImg *disk.QEMUImg) error {
- if err := vm.ValidateStorageContract(rec, rootDir); err != nil {
- return err
- }
- for _, cfg := range rec.StorageConfigs {
- switch cfg.EffectiveRole() {
- case vm.StorageRoleLayer:
- if cfg.Path == "" {
- return fmt.Errorf("storage layer %s path must not be empty", cfg.ID)
- }
- info, err := os.Stat(cfg.Path)
- if err != nil {
- return fmt.Errorf("stat storage layer %s: %w", cfg.ID, err)
- }
- if info.IsDir() {
- return fmt.Errorf("storage layer %s must be a file: %s", cfg.ID, cfg.Path)
- }
- case vm.StorageRoleCOW:
- if cfg.Base != nil && cfg.Base.Family == "cloudimg" {
- if err := qemuImg.EnsureOverlay(ctx, disk.OverlaySpec{
- Path: cfg.Path,
- BasePath: cfg.Base.Path,
- BaseFormat: cfg.Base.Format,
- }); err != nil {
- return fmt.Errorf("prepare cloud image COW %s: %w", cfg.ID, err)
- }
- continue
- }
- if err := prepareCOW(cfg); err != nil {
- return err
- }
- case vm.StorageRoleData:
- if err := prepareDataDisk(cfg); err != nil {
- return err
- }
- }
- }
- return nil
-}
-
-func prepareDataDisk(cfg vm.StorageConfig) error {
- if cfg.Path == "" {
- return fmt.Errorf("data storage path must not be empty")
- }
- sizeBytes := cfg.EffectiveVirtualSize()
- if sizeBytes < 16<<20 {
- return fmt.Errorf("data storage %s size must be at least 16MiB", cfg.ID)
- }
- if info, err := os.Stat(cfg.Path); err == nil && info.Mode().IsRegular() && info.Size() == sizeBytes {
- return nil
- } else if err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("stat data storage %s: %w", cfg.ID, err)
- }
- if err := os.MkdirAll(filepath.Dir(cfg.Path), 0o755); err != nil {
- return fmt.Errorf("create data storage dir: %w", err)
- }
- file, err := os.OpenFile(cfg.Path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o600) //nolint:gosec
- if err != nil {
- return fmt.Errorf("create data storage %s: %w", cfg.ID, err)
- }
- if err := file.Truncate(sizeBytes); err != nil {
- _ = file.Close()
- return fmt.Errorf("size data storage %s: %w", cfg.ID, err)
- }
- if err := file.Close(); err != nil {
- return fmt.Errorf("close data storage %s: %w", cfg.ID, err)
- }
- if cfg.Filesystem == "" || cfg.Filesystem == vm.FilesystemNone {
- return nil
- }
- out, err := mkfsExt4(cfg.Path)
- if err != nil {
- _ = os.Remove(cfg.Path)
- return fmt.Errorf("mkfs.ext4 data storage %s: %w: %s", cfg.ID, err, strings.TrimSpace(string(out)))
- }
- return nil
-}
-
-func prepareCOW(cfg vm.StorageConfig) error {
- if cfg.Path == "" {
- return fmt.Errorf("COW storage path must not be empty")
- }
- sizeBytes := cfg.EffectiveVirtualSize()
- if sizeBytes <= 0 {
- return fmt.Errorf("COW storage %s size must be positive", cfg.ID)
- }
- if info, err := os.Stat(cfg.Path); err == nil && info.Mode().IsRegular() && info.Size() == sizeBytes {
- return nil
- } else if err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("stat COW storage %s: %w", cfg.ID, err)
- }
- if err := os.MkdirAll(filepath.Dir(cfg.Path), 0o755); err != nil {
- return fmt.Errorf("create COW storage dir: %w", err)
- }
- file, err := os.OpenFile(cfg.Path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o600) //nolint:gosec
- if err != nil {
- return fmt.Errorf("create COW storage %s: %w", cfg.ID, err)
- }
- if err := file.Truncate(sizeBytes); err != nil {
- _ = file.Close()
- return fmt.Errorf("size COW storage %s: %w", cfg.ID, err)
- }
- if err := file.Close(); err != nil {
- return fmt.Errorf("close COW storage %s: %w", cfg.ID, err)
- }
- out, err := mkfsExt4(cfg.Path)
- if err != nil {
- _ = os.Remove(cfg.Path)
- return fmt.Errorf("mkfs.ext4 COW storage %s: %w: %s", cfg.ID, err, strings.TrimSpace(string(out)))
- }
- return nil
-}
diff --git a/internal/vm/runtime/events.go b/internal/vm/runtime/events.go
deleted file mode 100644
index ff983d6..0000000
--- a/internal/vm/runtime/events.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package runtime
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "time"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-type eventRecord struct {
- Time time.Time `json:"time"`
- Type string `json:"type"`
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
- State vm.VMState `json:"state"`
- ObservedState vm.ObservedState `json:"observedState"`
- Reason string `json:"reason,omitempty"`
- PID int `json:"pid,omitempty"`
- APISocket string `json:"apiSocket,omitempty"`
-}
-
-func writeVMEvent(rec *vm.VMRecord, eventType string, obs vm.Observation) (err error) {
- if rec.LogDir == "" {
- return nil
- }
- if err := os.MkdirAll(rec.LogDir, 0o755); err != nil {
- return fmt.Errorf("create VM log dir: %w", err)
- }
-
- path := filepath.Join(rec.LogDir, "events.log")
- file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
- if err != nil {
- return fmt.Errorf("open events log: %w", err)
- }
- defer func() {
- if closeErr := file.Close(); err == nil && closeErr != nil {
- err = fmt.Errorf("close VM events log: %w", closeErr)
- }
- }()
-
- event := eventRecord{
- Time: obs.CheckedAt,
- Type: eventType,
- VMID: rec.ID,
- VMName: rec.Name,
- State: rec.State,
- ObservedState: obs.State,
- Reason: obs.Reason,
- PID: rec.PID,
- APISocket: rec.APISocket,
- }
- if err := json.NewEncoder(file).Encode(event); err != nil {
- return fmt.Errorf("write events log: %w", err)
- }
- return nil
-}
diff --git a/internal/vm/runtime/filesystem.go b/internal/vm/runtime/filesystem.go
deleted file mode 100644
index 16f50ec..0000000
--- a/internal/vm/runtime/filesystem.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func (r *Runtime) AttachFilesystem(ctx context.Context, ref string, spec backend.FilesystemSpec) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, err
- }
- defer lock.Release() //nolint:errcheck
- controller, ok := r.backend.(backend.FilesystemController)
- if !ok {
- return nil, fmt.Errorf("backend does not support virtio-fs")
- }
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- opID, err := r.beginOperation(ctx, operation.KindFilesystemAttach, rec.ID)
- if err != nil {
- return nil, err
- }
- attached, opErr := controller.AttachFilesystem(ctx, rec, spec)
- if opErr == nil {
- disks := append([]vm.AttachedFilesystem(nil), rec.AttachedFilesystems...)
- disks = append(disks, vm.AttachedFilesystem{ID: attached.ID, Tag: attached.Tag, Socket: attached.Socket})
- _, opErr = r.vmRecords.SetAttachedFilesystems(rec.ID, disks)
- }
- opErr = r.finishOperation(ctx, opID, opErr)
- updated, inspectErr := r.vmReader.Inspect(rec.ID)
- return updated, errors.Join(opErr, inspectErr)
-}
-
-func (r *Runtime) DetachFilesystem(ctx context.Context, ref, tag string) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, err
- }
- defer lock.Release() //nolint:errcheck
- controller, ok := r.backend.(backend.FilesystemController)
- if !ok {
- return nil, fmt.Errorf("backend does not support virtio-fs")
- }
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- opID, err := r.beginOperation(ctx, operation.KindFilesystemDetach, rec.ID)
- if err != nil {
- return nil, err
- }
- opErr := controller.DetachFilesystem(ctx, rec, tag)
- if opErr == nil {
- kept := make([]vm.AttachedFilesystem, 0, len(rec.AttachedFilesystems))
- for _, fs := range rec.AttachedFilesystems {
- if fs.Tag != tag {
- kept = append(kept, fs)
- }
- }
- _, opErr = r.vmRecords.SetAttachedFilesystems(rec.ID, kept)
- }
- opErr = r.finishOperation(ctx, opID, opErr)
- updated, inspectErr := r.vmReader.Inspect(rec.ID)
- return updated, errors.Join(opErr, inspectErr)
-}
-
-func (r *Runtime) ListFilesystems(ctx context.Context, ref string) ([]backend.AttachedFilesystem, error) {
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- controller, ok := r.backend.(backend.FilesystemController)
- if !ok {
- return nil, fmt.Errorf("backend does not support virtio-fs")
- }
- return controller.ListFilesystems(ctx, rec)
-}
diff --git a/internal/vm/runtime/guest_reseed.go b/internal/vm/runtime/guest_reseed.go
deleted file mode 100644
index 45ad266..0000000
--- a/internal/vm/runtime/guest_reseed.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package runtime
-
-import (
- "context"
- "fmt"
- "time"
-
- agentclient "github.com/kumabox/kumabox/internal/agent/client"
- "github.com/kumabox/kumabox/internal/agent/protocol"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const (
- guestReseedTimeout = 15 * time.Second
- guestReseedAttemptTimeout = 5 * time.Second
-)
-
-var reseedRestoredGuest = reseedGuest
-
-// ReseedGuestVM injects fresh entropy into a running guest. Machine identity
-// regeneration is intended for clones, not an in-place restore of the same VM.
-func (r *Runtime) ReseedGuestVM(ctx context.Context, ref string, regenerateMachineID bool) (*vm.VMRecord, error) {
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for reseed: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- observed := r.applyObservation(rec)
- if observed.ObservedState != vm.ObservedStateRunning {
- return nil, fmt.Errorf("VM_NOT_RUNNING: VM %s is not running", rec.Name)
- }
- if err := reseedGuest(ctx, rec.VsockSocket, regenerateMachineID); err != nil {
- return nil, err
- }
- return observed, nil
-}
-
-func reseedGuest(ctx context.Context, socket string, regenerateMachineID bool) error {
- if socket == "" {
- return fmt.Errorf("AGENT_NOT_READY: VM has no guest agent vsock socket")
- }
- reseedCtx, cancel := context.WithTimeout(ctx, guestReseedTimeout)
- defer cancel()
- pong, err := agentclient.Ping(reseedCtx, socket)
- if err != nil {
- return fmt.Errorf("wait for guest agent reseed: %w", err)
- }
- if err := requireAgentCapability(pong, agentclient.CapabilityReseed); err != nil {
- return err
- }
- attemptCtx, attemptCancel := context.WithTimeout(reseedCtx, guestReseedAttemptTimeout)
- defer attemptCancel()
- if _, err := agentclient.Reseed(attemptCtx, socket, regenerateMachineID); err != nil {
- return fmt.Errorf("reseed guest: %w", err)
- }
- return nil
-}
-
-func requireAgentCapability(pong *agentclient.PingPongResponse, capability protocol.Capability) error {
- if pong.Supports(capability) {
- return nil
- }
- if pong == nil {
- return fmt.Errorf("AGENT_CAPABILITY_MISSING: guest agent response is empty; required capability %q", capability)
- }
- version := pong.Version
- if version == "" {
- version = "unknown"
- }
- return fmt.Errorf(
- "AGENT_CAPABILITY_MISSING: guest agent %s does not advertise %q (capabilities=%v); rebuild the managed image with the current kumabox-agent",
- version, capability, pong.Capabilities,
- )
-}
diff --git a/internal/vm/runtime/guest_reseed_test.go b/internal/vm/runtime/guest_reseed_test.go
deleted file mode 100644
index 1856734..0000000
--- a/internal/vm/runtime/guest_reseed_test.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package runtime
-
-import (
- "strings"
- "testing"
-
- agentclient "github.com/kumabox/kumabox/internal/agent/client"
-)
-
-func TestRequireAgentCapability(t *testing.T) {
- tests := []struct {
- name string
- pong *agentclient.PingPongResponse
- wantErr string
- }{
- {
- name: "supported",
- pong: &agentclient.PingPongResponse{Capabilities: []string{"reseed"}},
- },
- {
- name: "missing",
- pong: &agentclient.PingPongResponse{Version: "0.3.2", Capabilities: []string{"identity"}},
- wantErr: "0.3.2 does not advertise \"reseed\"",
- },
- {
- name: "empty response",
- wantErr: "response is empty",
- },
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- err := requireAgentCapability(test.pong, agentclient.CapabilityReseed)
- if test.wantErr == "" {
- if err != nil {
- t.Fatal(err)
- }
- return
- }
- if err == nil || !strings.Contains(err.Error(), test.wantErr) {
- t.Fatalf("error = %v, want substring %q", err, test.wantErr)
- }
- })
- }
-}
diff --git a/internal/vm/runtime/hibernate.go b/internal/vm/runtime/hibernate.go
deleted file mode 100644
index cae641b..0000000
--- a/internal/vm/runtime/hibernate.go
+++ /dev/null
@@ -1,158 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/metering"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-type HibernateOptions struct {
- Name string
-}
-
-type HibernateResult struct {
- VM *vm.VMRecord `json:"vm"`
- Snapshot *snapshot.Record `json:"snapshot"`
-}
-
-// HibernateVM durably captures a paused VM and terminates the VMM without a
-// resume gap. Persistence failure resumes the original process.
-func (r *Runtime) HibernateVM(ctx context.Context, ref string, opts HibernateOptions) (result *HibernateResult, resultErr error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- if opts.Name == "" {
- return nil, errors.New("hibernate snapshot name must not be empty")
- }
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- operationID, err := r.beginOperationWithRelated(ctx, operation.KindVMHibernate, rec.ID, opts.Name)
- if err != nil {
- return nil, err
- }
- defer func() { resultErr = r.finishOperation(ctx, operationID, resultErr) }()
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for hibernate: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- rec = r.applyObservation(rec)
- if rec.ObservedState != vm.ObservedStateRunning {
- return nil, fmt.Errorf("VM_NOT_RUNNING: VM %s observed state is %s", rec.Name, rec.ObservedState)
- }
- controller, ok := r.backend.(backend.StateController)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not support pause/resume")
- }
- snapshotter, ok := r.backend.(backend.NativeSnapshotter)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not support native snapshots")
- }
- inspector, ok := r.backend.(backend.NativeHostInspector)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not expose native compatibility")
- }
-
- snapshotStore := r.data.Snapshots
- build, err := snapshotStore.Reserve(ctx, opts.Name)
- if err != nil {
- return nil, err
- }
- defer build.Abort() //nolint:errcheck
- pending := build.Record()
- nativeDir := filepath.Join(pending.StagingDir, snapshot.NativePayloadDir)
- if err := os.MkdirAll(nativeDir, 0o700); err != nil {
- return nil, fmt.Errorf("create hibernate staging: %w", err)
- }
-
- if err := controller.PauseVM(ctx, rec); err != nil {
- return nil, fmt.Errorf("pause VM for hibernate: %w", err)
- }
-
- ready, persistErr := r.persistHibernationSnapshot(ctx, build, rec, snapshotter, inspector, nativeDir)
- if persistErr != nil {
- return nil, errors.Join(persistErr, r.recoverHibernateGuest(ctx, controller, rec, true))
- }
- if _, err := r.backend.StopVM(rec, backend.StopOptions{Force: true, Timeout: forcedStopTimeout}); err != nil {
- recoverErr := r.recoverHibernateGuest(ctx, controller, rec, true)
- removeErr := error(nil)
- if recoverErr == nil {
- _, removeErr = snapshotStore.Remove(ready.ID)
- }
- return nil, errors.Join(fmt.Errorf("terminate hibernated VMM: %w", err), recoverErr, removeErr)
- }
- hibernated, err := r.vmUpdater.CompleteHibernate(rec.ID, ready.ID)
- if err != nil {
- _, _ = r.vmUpdater.SetError(rec.ID, "hibernate snapshot is durable but stopped state publication failed")
- return nil, fmt.Errorf("publish hibernated VM state: %w", err)
- }
- r.recordComputeStop(ctx, hibernated, metering.ReasonHibernate)
- if rec.Image != nil {
- if err := r.recordSnapshotImageReference(ctx, ready.ID, rec.Image.ID); err != nil {
- return nil, fmt.Errorf("record hibernate image reference: %w", err)
- }
- }
- if err := r.recordVMSnapshotReference(ctx, hibernated.ID, ready.ID); err != nil {
- return nil, fmt.Errorf("record hibernate snapshot reference: %w", err)
- }
- _ = writeVMEvent(hibernated, "vm.hibernate.completed", vm.Observation{
- State: vm.ObservedStateStopped, Reason: "hibernated to native snapshot " + ready.ID, CheckedAt: time.Now().UTC(),
- })
- return &HibernateResult{VM: r.applyObservation(hibernated), Snapshot: ready}, nil
-}
-
-func (r *Runtime) persistHibernationSnapshot(ctx context.Context, build *snapshot.Build, rec *vm.VMRecord, snapshotter backend.NativeSnapshotter, inspector backend.NativeHostInspector, nativeDir string) (*snapshot.Record, error) {
- pending := build.Record()
- stagedDisks, _, _, err := captureNativeWindow(ctx, snapshotter, rec, nativeDir, pending.StagingDir)
- if err != nil {
- return nil, err
- }
- disks, _, err := snapshot.FinalizeWritableDisks(ctx, pending.StagingDir, stagedDisks)
- if err != nil {
- return nil, fmt.Errorf("finalize hibernate disks: %w", err)
- }
- host, err := inspector.InspectNativeHost(ctx, rec)
- if err != nil {
- return nil, fmt.Errorf("inspect native compatibility: %w", err)
- }
- _, totalSize, err := snapshot.WriteNativeManifest(ctx, build, rec, disks, host)
- if err != nil {
- return nil, err
- }
- ready, err := build.FinalizeContext(ctx, totalSize)
- if err != nil {
- return nil, err
- }
- return ready, nil
-}
-
-func (r *Runtime) recoverHibernateGuest(ctx context.Context, controller backend.StateController, rec *vm.VMRecord, paused bool) error {
- cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), snapshotCleanupTimeout)
- defer cancel()
- var resumeErr error
- if paused {
- if err := controller.ResumeVM(cleanupCtx, rec); err != nil {
- r.persistSnapshotResumeFailure(rec)
- resumeErr = fmt.Errorf("resume VM after failed hibernate: %w", err)
- }
- }
- return resumeErr
-}
diff --git a/internal/vm/runtime/hibernate_test.go b/internal/vm/runtime/hibernate_test.go
deleted file mode 100644
index d3e5618..0000000
--- a/internal/vm/runtime/hibernate_test.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestHibernateVMPersistsBeforeStopping(t *testing.T) {
- rt, store, rec, _ := newRunningSnapshotRuntime(t)
- backendState := vm.ObservedStateRunning
- steps := make([]string, 0, 3)
- rt.backend = backendFake{
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: backendState, CheckedAt: time.Now().UTC()}
- },
- pause: func(context.Context, *vm.VMRecord) error {
- steps = append(steps, "pause")
- backendState = vm.ObservedStatePaused
- return nil
- },
- snapshot: func(_ context.Context, _ *vm.VMRecord, destination string) error {
- steps = append(steps, "snapshot")
- return writeNativeSnapshotFixture(destination, rec)
- },
- stop: func(*vm.VMRecord, backend.StopOptions) (*backend.StopResult, error) {
- if records, err := snapshot.NewStore(store.RootDir()).List(); err != nil || len(records) != 1 {
- t.Fatalf("snapshot was not durable before stop: %+v, %v", records, err)
- }
- steps = append(steps, "stop")
- backendState = vm.ObservedStateStopped
- return &backend.StopResult{}, nil
- },
- resume: func(context.Context, *vm.VMRecord) error {
- t.Fatal("successful hibernate resumed the VM")
- return nil
- },
- }
-
- result, err := rt.HibernateVM(context.Background(), rec.ID, HibernateOptions{Name: "nap"})
- if err != nil {
- t.Fatal(err)
- }
- if strings.Join(steps, ",") != "pause,snapshot,stop" {
- t.Fatalf("steps = %v", steps)
- }
- if result.VM.State != vm.StateStopped || result.VM.Hibernate == nil || result.VM.Hibernate.SnapshotID != result.Snapshot.ID {
- t.Fatalf("hibernate result = %+v", result)
- }
- if _, err := rt.StartVMContext(context.Background(), rec.ID); err == nil || !strings.Contains(err.Error(), "VM_HIBERNATED") {
- t.Fatalf("cold start error = %v", err)
- }
-}
-
-func TestHibernateVMResumesWhenPersistenceFails(t *testing.T) {
- rt, store, rec, _ := newRunningSnapshotRuntime(t)
- backendState := vm.ObservedStateRunning
- resumed := false
- rt.backend = backendFake{
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: backendState, CheckedAt: time.Now().UTC()}
- },
- pause: func(context.Context, *vm.VMRecord) error {
- backendState = vm.ObservedStatePaused
- return nil
- },
- snapshot: func(context.Context, *vm.VMRecord, string) error {
- return errors.New("injected persistence failure")
- },
- resume: func(context.Context, *vm.VMRecord) error {
- resumed = true
- backendState = vm.ObservedStateRunning
- return nil
- },
- }
- if _, err := rt.HibernateVM(context.Background(), rec.ID, HibernateOptions{Name: "failed-nap"}); err == nil {
- t.Fatal("expected hibernate failure")
- }
- if !resumed {
- t.Fatal("VM was not resumed after persistence failure")
- }
- if records, err := snapshot.NewStore(store.RootDir()).Scan(); err != nil || len(records) != 0 {
- t.Fatalf("failed hibernate leaked snapshots: %+v, %v", records, err)
- }
- persisted, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if persisted.State != vm.StateRunning || persisted.Hibernate != nil {
- t.Fatalf("source state = %+v", persisted)
- }
-}
-
-func TestMarkRestoredClearsHibernateState(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- rec, err := store.Create(vm.CreateRequest{
- Name: "wake", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd", RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := store.CompleteHibernate(rec.ID, "snap_nap"); err != nil {
- t.Fatal(err)
- }
- if _, err := store.BeginRestore(rec.ID, "snap_nap", "copy"); err != nil {
- t.Fatal(err)
- }
- woken, err := store.CompleteRestore(rec.ID, 42, filepath.Join(rec.RunDir, "ch.sock"), time.Second, nil)
- if err != nil {
- t.Fatal(err)
- }
- if woken.Hibernate != nil || woken.State != vm.StateRunning {
- t.Fatalf("woken record = %+v", woken)
- }
-}
diff --git a/internal/vm/runtime/logs.go b/internal/vm/runtime/logs.go
deleted file mode 100644
index 2add2fa..0000000
--- a/internal/vm/runtime/logs.go
+++ /dev/null
@@ -1,273 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
- "time"
-)
-
-const (
- // LogSourceConsole is the guest serial console stream.
- //
- // This is the default because it contains kernel, cloud-init, and login
- // output needed to debug early boot before guest-agent support exists.
- LogSourceConsole = "console"
-
- // LogSourceStdout is Cloud Hypervisor's stdout stream.
- LogSourceStdout = "stdout"
-
- // LogSourceStderr is Cloud Hypervisor's stderr stream.
- LogSourceStderr = "stderr"
-
- // LogSourceVMM returns both Cloud Hypervisor process streams.
- LogSourceVMM = "vmm"
-
- // LogSourceAll returns guest console plus VMM process streams.
- LogSourceAll = "all"
-)
-
-// LogOptions controls which VM logs are returned and how much content is read.
-type LogOptions struct {
- Tail int
- Source string
-}
-
-// VMLogFile is one log file returned by a logs request.
-type VMLogFile struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Content string `json:"content"`
-}
-
-// VMLogs groups all log files selected for a VM.
-type VMLogs struct {
- VMID string `json:"vmId"`
- Name string `json:"name"`
- Files []VMLogFile `json:"files"`
-}
-
-// VMLogChunk is one append-only unit emitted while following logs.
-type VMLogChunk struct {
- VMID string `json:"vmId"`
- VMName string `json:"vmName"`
- Name string `json:"name"`
- Path string `json:"path"`
- Content string `json:"content"`
-}
-
-// LogsVM reads selected VM logs without requiring the VM to be running.
-//
-// Missing log files are skipped. This lets logs work consistently for created,
-// failed, stopped, and deleted-after-failure states where only some streams may
-// have been produced.
-func (r *Runtime) LogsVM(ref string, opts LogOptions) (*VMLogs, error) {
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
-
- logs := &VMLogs{
- VMID: rec.ID,
- Name: rec.Name,
- }
- for _, name := range logFileNames(opts.Source) {
- path := filepath.Join(rec.LogDir, name)
- content, err := readLogTail(path, opts.Tail)
- if err != nil {
- if errors.Is(err, os.ErrNotExist) {
- continue
- }
- return nil, fmt.Errorf("read log %s: %w", path, err)
- }
- logs.Files = append(logs.Files, VMLogFile{
- Name: name,
- Path: path,
- Content: content,
- })
- }
- return logs, nil
-}
-
-// FollowLogsVM emits existing tail content and then appended bytes until ctx
-// is cancelled. Polling deliberately handles files created after subscription,
-// truncation on VM restart, and atomic file replacement without fsnotify.
-func (r *Runtime) FollowLogsVM(
- ctx context.Context,
- ref string,
- opts LogOptions,
- interval time.Duration,
- emit func(VMLogChunk) error,
-) error {
- if interval <= 0 {
- return fmt.Errorf("log follow interval must be positive")
- }
- if emit == nil {
- return fmt.Errorf("log follow emitter is required")
- }
- if !ValidLogSource(opts.Source) {
- return fmt.Errorf("invalid log source %q", opts.Source)
- }
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return err
- }
- files := make([]followedLog, 0, len(logFileNames(opts.Source)))
- for _, name := range logFileNames(opts.Source) {
- files = append(files, followedLog{name: name, path: filepath.Join(rec.LogDir, name)})
- }
-
- poll := func(initial bool) error {
- for i := range files {
- content, changed, err := files[i].read(initial, opts.Tail)
- if err != nil {
- return fmt.Errorf("follow log %s: %w", files[i].path, err)
- }
- if changed && content != "" {
- if err := emit(VMLogChunk{VMID: rec.ID, VMName: rec.Name, Name: files[i].name, Path: files[i].path, Content: content}); err != nil {
- return err
- }
- }
- }
- return nil
- }
- if err := poll(true); err != nil {
- return err
- }
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- return nil
- case <-ticker.C:
- if err := poll(false); err != nil {
- return err
- }
- }
- }
-}
-
-type followedLog struct {
- name string
- path string
- info os.FileInfo
- offset int64
-}
-
-func (f *followedLog) read(initial bool, tail int) (string, bool, error) {
- file, err := os.Open(f.path) //nolint:gosec
- if errors.Is(err, os.ErrNotExist) {
- f.info = nil
- f.offset = 0
- return "", false, nil
- }
- if err != nil {
- return "", false, err
- }
- defer func() { _ = file.Close() }()
- info, err := file.Stat()
- if err != nil {
- return "", false, err
- }
- firstAppearance := f.info == nil
- reset := firstAppearance || !os.SameFile(f.info, info) || info.Size() < f.offset
- start := f.offset
- if reset {
- start = 0
- }
- if (initial || firstAppearance) && start == 0 && tail > 0 {
- start, err = tailOffset(file, tail)
- if err != nil {
- return "", false, err
- }
- }
- if start > info.Size() {
- start = 0
- }
- raw, err := io.ReadAll(io.NewSectionReader(file, start, info.Size()-start))
- if err != nil {
- return "", false, err
- }
- f.info = info
- f.offset = info.Size()
- return string(raw), reset || len(raw) > 0, nil
-}
-
-func tailOffset(file *os.File, tail int) (int64, error) {
- info, err := file.Stat()
- if err != nil {
- return 0, err
- }
- raw, err := io.ReadAll(file)
- if err != nil {
- return 0, err
- }
- content := string(raw)
- trimmed := strings.TrimSuffix(content, "\n")
- lines := strings.Split(trimmed, "\n")
- if len(lines) <= tail {
- return 0, nil
- }
- kept := strings.Join(lines[len(lines)-tail:], "\n")
- if strings.HasSuffix(content, "\n") {
- kept += "\n"
- }
- return info.Size() - int64(len(kept)), nil
-}
-
-func logFileNames(source string) []string {
- if source == "" {
- source = LogSourceConsole
- }
- switch source {
- case LogSourceConsole:
- return []string{"console.log"}
- case LogSourceStdout:
- return []string{"cloud-hypervisor.stdout.log"}
- case LogSourceStderr:
- return []string{"cloud-hypervisor.stderr.log"}
- case LogSourceVMM:
- return []string{"cloud-hypervisor.stdout.log", "cloud-hypervisor.stderr.log"}
- case LogSourceAll:
- return []string{"console.log", "cloud-hypervisor.stdout.log", "cloud-hypervisor.stderr.log"}
- default:
- return nil
- }
-}
-
-// LogFileNames returns the stable file order selected by source.
-func LogFileNames(source string) []string {
- return append([]string(nil), logFileNames(source)...)
-}
-
-// ValidLogSource reports whether source is accepted by LogsVM.
-//
-// An empty source is valid and resolves to the guest console.
-func ValidLogSource(source string) bool {
- return source == "" || len(logFileNames(source)) > 0
-}
-
-func readLogTail(path string, tail int) (string, error) {
- raw, err := os.ReadFile(path) //nolint:gosec
- if err != nil {
- return "", err
- }
- content := string(raw)
- if tail <= 0 {
- return content, nil
- }
-
- lines := strings.SplitAfter(content, "\n")
- if len(lines) > 0 && lines[len(lines)-1] == "" {
- lines = lines[:len(lines)-1]
- }
- if len(lines) > tail {
- lines = lines[len(lines)-tail:]
- }
- return strings.Join(lines, ""), nil
-}
diff --git a/internal/vm/runtime/logs_follow_test.go b/internal/vm/runtime/logs_follow_test.go
deleted file mode 100644
index 480e001..0000000
--- a/internal/vm/runtime/logs_follow_test.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package runtime
-
-import (
- "context"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestFollowedLogReadsTailAppendTruncateAndReplacement(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- path := filepath.Join(dir, "vmm.log")
- if err := os.WriteFile(path, []byte("one\ntwo\nthree\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- log := followedLog{name: "vmm.log", path: path}
- content, changed, err := log.read(true, 2)
- if err != nil || !changed || content != "two\nthree\n" {
- t.Fatalf("initial read content=%q changed=%t err=%v", content, changed, err)
- }
- file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := file.WriteString("four\n"); err != nil {
- t.Fatal(err)
- }
- if err := file.Close(); err != nil {
- t.Fatal(err)
- }
- content, changed, err = log.read(false, 0)
- if err != nil || !changed || content != "four\n" {
- t.Fatalf("append read content=%q changed=%t err=%v", content, changed, err)
- }
- if err := os.WriteFile(path, []byte("new-boot\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- content, changed, err = log.read(false, 0)
- if err != nil || !changed || content != "new-boot\n" {
- t.Fatalf("truncate read content=%q changed=%t err=%v", content, changed, err)
- }
- replacement := filepath.Join(dir, "replacement.log")
- if err := os.WriteFile(replacement, []byte("replacement\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- if err := os.Rename(replacement, path); err != nil {
- t.Fatal(err)
- }
- content, changed, err = log.read(false, 0)
- if err != nil || !changed || content != "replacement\n" {
- t.Fatalf("replacement read content=%q changed=%t err=%v", content, changed, err)
- }
-}
-
-func TestFollowLogsVMWaitsForFileAndStopsWithContext(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- rec, err := store.Create(vm.CreateRequest{
- Name: "follow", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- rt := NewWithBackend(store, backendFake{})
- ctx, cancel := context.WithCancel(t.Context())
- defer cancel()
- done := make(chan error, 1)
- chunks := make(chan VMLogChunk, 1)
- go func() {
- done <- rt.FollowLogsVM(ctx, rec.ID, LogOptions{Source: LogSourceStderr, Tail: 1}, 10*time.Millisecond, func(chunk VMLogChunk) error {
- chunks <- chunk
- return nil
- })
- }()
- path := filepath.Join(rec.LogDir, "cloud-hypervisor.stderr.log")
- if err := os.MkdirAll(rec.LogDir, 0o755); err != nil {
- t.Fatal(err)
- }
- temporary := filepath.Join(rec.LogDir, ".delayed.log")
- if err := os.WriteFile(temporary, []byte("old-line\nlate-line\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- if err := os.Rename(temporary, path); err != nil {
- t.Fatal(err)
- }
- select {
- case chunk := <-chunks:
- if chunk.Content != "late-line\n" || chunk.Name != "cloud-hypervisor.stderr.log" {
- t.Fatalf("chunk = %+v", chunk)
- }
- cancel()
- case <-time.After(5 * time.Second):
- t.Fatal("follow did not observe a delayed log file")
- }
- if err := <-done; err != nil {
- t.Fatal(err)
- }
-}
-
-func TestFollowLogsVMRejectsInvalidArguments(t *testing.T) {
- t.Parallel()
-
- rt := &Runtime{}
- if err := rt.FollowLogsVM(t.Context(), "vm", LogOptions{}, 0, func(VMLogChunk) error { return nil }); err == nil {
- t.Fatal("expected non-positive interval error")
- }
- if err := rt.FollowLogsVM(t.Context(), "vm", LogOptions{}, time.Second, nil); err == nil {
- t.Fatal("expected nil emitter error")
- }
- if err := rt.FollowLogsVM(t.Context(), "vm", LogOptions{Source: "invalid"}, time.Second, func(VMLogChunk) error { return nil }); err == nil {
- t.Fatal("expected invalid source error")
- }
-}
diff --git a/internal/vm/runtime/metering.go b/internal/vm/runtime/metering.go
deleted file mode 100644
index a9db944..0000000
--- a/internal/vm/runtime/metering.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package runtime
-
-import (
- "context"
- "time"
-
- "github.com/kumabox/kumabox/internal/metering"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func (r *Runtime) recordComputeStart(ctx context.Context, rec *vm.VMRecord, reason metering.Reason) {
- if r.data.Metering == nil || rec == nil || rec.StartedAt == nil {
- return
- }
- _ = r.data.Metering.Append(ctx, computeEvent(rec, metering.KindComputeStart, reason, *rec.StartedAt))
-}
-
-func (r *Runtime) recordComputeStop(ctx context.Context, rec *vm.VMRecord, reason metering.Reason) {
- if r.data.Metering == nil || rec == nil || rec.StoppedAt == nil {
- return
- }
- _ = r.data.Metering.Append(ctx, computeEvent(rec, metering.KindComputeStop, reason, *rec.StoppedAt))
-}
-
-func (r *Runtime) requireComputeStop(ctx context.Context, rec *vm.VMRecord, reason metering.Reason) error {
- if r.data.Metering == nil || rec == nil || rec.StoppedAt == nil {
- return nil
- }
- return r.data.Metering.Append(ctx, computeEvent(rec, metering.KindComputeStop, reason, *rec.StoppedAt))
-}
-
-func computeEvent(rec *vm.VMRecord, kind metering.Kind, reason metering.Reason, at time.Time) metering.Event {
- return metering.Event{
- ID: metering.EventID(rec.ID, kind, at), Kind: kind, VMID: rec.ID, VMName: rec.Name,
- Reason: reason, Shape: metering.Shape{VCPUs: rec.CPUs, MemoryBytes: rec.MemoryBytes}, EmittedAt: at,
- }
-}
-
-// ReconcileMetering idempotently reconstructs lifecycle endpoints represented
-// by durable VM timestamps. It does not guess timestamps from wall-clock time.
-func (r *Runtime) ReconcileMetering(ctx context.Context) error {
- if r.data.Metering == nil {
- return nil
- }
- records, err := r.vmReader.List()
- if err != nil {
- return err
- }
- events, err := r.data.Metering.Events(ctx, "")
- if err != nil {
- return err
- }
- existing := make(map[string]struct{}, len(events))
- for _, event := range events {
- existing[event.ID] = struct{}{}
- }
- for _, rec := range records {
- if rec.StartedAt != nil {
- reason := metering.ReasonBoot
- if rec.FirstBooted {
- reason = metering.ReasonRestart
- }
- event := computeEvent(rec, metering.KindComputeStart, reason, *rec.StartedAt)
- if _, ok := existing[event.ID]; !ok {
- if err := r.data.Metering.Append(ctx, event); err != nil {
- return err
- }
- }
- }
- if rec.StoppedAt != nil {
- reason := metering.ReasonStopUser
- if rec.State == vm.StatePaused {
- reason = metering.ReasonPause
- }
- event := computeEvent(rec, metering.KindComputeStop, reason, *rec.StoppedAt)
- if _, ok := existing[event.ID]; ok {
- continue
- }
- if err := r.data.Metering.Append(ctx, event); err != nil {
- return err
- }
- }
- }
- return nil
-}
diff --git a/internal/vm/runtime/metering_test.go b/internal/vm/runtime/metering_test.go
deleted file mode 100644
index 7e4f8e8..0000000
--- a/internal/vm/runtime/metering_test.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package runtime
-
-import (
- "context"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/metering"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestLifecycleRecordsComputeUsageIntervals(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- backendState := vm.ObservedStateCreated
- rt := NewWithBackend(store, backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- backendState = vm.ObservedStateRunning
- return &backend.StartResult{PID: 1234, APISocket: "ch.sock"}, nil
- },
- stop: func(*vm.VMRecord, backend.StopOptions) (*backend.StopResult, error) {
- backendState = vm.ObservedStateStopped
- return &backend.StopResult{}, nil
- },
- pause: func(context.Context, *vm.VMRecord) error { backendState = vm.ObservedStatePaused; return nil },
- resume: func(context.Context, *vm.VMRecord) error {
- backendState = vm.ObservedStateRunning
- return nil
- },
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: backendState, CheckedAt: time.Now().UTC()}
- },
- })
- rec, err := rt.CreateVM(vm.CreateRequest{Name: "metered", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd", RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"), Network: "none", CPUs: 2, MemoryBytes: 1024})
- if err != nil {
- t.Fatal(err)
- }
- if _, err := rt.StartVMContext(t.Context(), rec.ID); err != nil {
- t.Fatal(err)
- }
- if _, err := rt.PauseVM(t.Context(), rec.ID); err != nil {
- t.Fatal(err)
- }
- if _, err := rt.ResumeVM(t.Context(), rec.ID); err != nil {
- t.Fatal(err)
- }
- if _, err := rt.StopVMContext(t.Context(), rec.ID, backend.StopOptions{}); err != nil {
- t.Fatal(err)
- }
- usage, err := rt.data.Metering.Usage(t.Context(), metering.Query{VMRef: rec.ID})
- if err != nil {
- t.Fatal(err)
- }
- if len(usage) != 2 || usage[0].StartReason != metering.ReasonBoot || usage[0].EndReason != metering.ReasonPause || usage[1].StartReason != metering.ReasonResume || usage[1].EndReason != metering.ReasonStopUser {
- t.Fatalf("usage = %+v", usage)
- }
- for _, interval := range usage {
- if interval.VCPUs != 2 || interval.MemoryBytes != 1024 || interval.EndedAt == nil {
- t.Fatalf("interval = %+v", interval)
- }
- }
-}
-
-func TestReconcileMeteringIsIdempotent(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- rt := NewWithBackend(store, backendFake{})
- rec, err := store.Create(vm.CreateRequest{Name: "reconcile", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd", RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log")})
- if err != nil {
- t.Fatal(err)
- }
- if _, err := store.MarkStarted(rec.ID, 1234, "ch.sock"); err != nil {
- t.Fatal(err)
- }
- if err := rt.ReconcileMetering(t.Context()); err != nil {
- t.Fatal(err)
- }
- if err := rt.ReconcileMetering(t.Context()); err != nil {
- t.Fatal(err)
- }
- events, err := rt.data.Metering.Events(t.Context(), rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if len(events) != 1 || events[0].Kind != metering.KindComputeStart {
- t.Fatalf("events = %+v", events)
- }
-}
diff --git a/internal/vm/runtime/native_clone.go b/internal/vm/runtime/native_clone.go
deleted file mode 100644
index 8621b95..0000000
--- a/internal/vm/runtime/native_clone.go
+++ /dev/null
@@ -1,325 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- agentclient "github.com/kumabox/kumabox/internal/agent/client"
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/lock"
- "github.com/kumabox/kumabox/internal/metering"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const (
- cloneIdentityTimeout = 15 * time.Second
- cloneIdentityAttemptTimeout = 5 * time.Second
- cloneIdentityRetryInterval = 500 * time.Millisecond
-)
-
-var configureGuestIdentity = configureCloneIdentity
-
-func guestAgentWarning(err error) string {
- if err == nil {
- return ""
- }
- return "VM is running, but guest post-restore configuration was incomplete: " + err.Error()
-}
-
-// NativeCloneOptions defines the new VM identity. Machine and storage shape
-// are inherited from the native snapshot and cannot be resized during clone.
-type NativeCloneOptions struct {
- Name string
- Networks []string
- Mode RestoreMode
-}
-
-// CloneNativeSnapshot creates a new running VM from native state while
-// assigning fresh host storage, vsock, and provider network identities.
-func (r *Runtime) CloneNativeSnapshot(ctx context.Context, snapshotRef string, opts NativeCloneOptions) (result *vm.VMRecord, resultErr error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- if opts.Name == "" {
- return nil, errors.New("clone VM name must not be empty")
- }
- operationID, err := r.beginOperationWithRelated(ctx, operation.KindSnapshotCloneNative, snapshotRef, snapshotRef)
- if err != nil {
- return nil, err
- }
- defer func() { resultErr = r.finishOperation(ctx, operationID, resultErr) }()
- mode, err := normalizeRestoreMode(opts.Mode)
- if err != nil {
- return nil, err
- }
- opts.Mode = mode
- restoreStarted := time.Now()
- cloner, ok := r.backend.(backend.NativeCloner)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not support native clone")
- }
- inspector, ok := r.backend.(backend.NativeHostInspector)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not expose native compatibility")
- }
-
- snapshotStore := r.data.Snapshots
- snapshotRec, lease, err := snapshotStore.AcquireRead(ctx, snapshotRef)
- if err != nil {
- return nil, err
- }
- defer lease.Release() //nolint:errcheck
- host, err := inspector.InspectNativeHost(ctx, nil)
- if err != nil {
- return nil, fmt.Errorf("inspect native compatibility: %w", err)
- }
- if err := requireRestoreMode(host, opts.Mode); err != nil {
- return nil, err
- }
- manifest, err := snapshotStore.VerifyNativePayloadRecord(ctx, snapshotRec, host)
- if err != nil {
- return nil, fmt.Errorf("snapshot preflight: %w", err)
- }
- networks, err := cloneNetworkSelections(opts.Networks, manifest.Devices.NICs)
- if err != nil {
- return nil, err
- }
- image, err := r.data.Images.Inspect(manifest.Source.ImageID)
- if err != nil {
- return nil, fmt.Errorf("BASE_IMAGE_MISSING: resolve image %s: %w", manifest.Source.ImageID, err)
- }
- imageLock, err := r.resourceGuard.LockEntity(ctx, lock.EntityImage, image.ID)
- if err != nil {
- return nil, err
- }
- defer imageLock.Release() //nolint:errcheck
- image, err = r.data.Images.Inspect(image.ID)
- if err != nil {
- return nil, fmt.Errorf("BASE_IMAGE_MISSING: revalidate image %s: %w", manifest.Source.ImageID, err)
- }
- req, err := restoreCreateRequest(RestoreOptions{
- Name: opts.Name, CPUs: manifest.Machine.VCPUs, MemoryBytes: manifest.Machine.MemoryBytes, Networks: networks,
- }, image, manifest, r.cfg)
- if err != nil {
- return nil, err
- }
- rec, err := r.vmRecords.Create(req)
- if err != nil {
- return nil, err
- }
- if err := r.bindOperationResource(ctx, operationID, rec.ID); err != nil {
- _ = r.vmRecords.Delete(rec.ID)
- return nil, fmt.Errorf("bind clone operation resource: %w", err)
- }
- if err := r.recordVMImageReference(ctx, rec); err != nil {
- _ = r.vmRecords.Delete(rec.ID)
- return nil, fmt.Errorf("record clone image reference: %w", err)
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- _ = r.vmRecords.Delete(rec.ID)
- return nil, fmt.Errorf("lock clone VM %s: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
-
- var backendResult *backend.StartResult
- committed := false
- defer func() {
- if committed {
- return
- }
- if backendResult != nil {
- cleanup := *rec
- cleanup.PID = backendResult.PID
- cleanup.APISocket = backendResult.APISocket
- _, _ = r.backend.StopVM(&cleanup, backend.StopOptions{Force: true})
- }
- // A native restore can fail after its destructive disk boundary. Keep
- // the VM and its provider attachments in an explicit error state so an
- // operator can inspect and delete it deliberately. Removing the record
- // here made clone failures indistinguishable from successful cleanup.
- if resultErr != nil {
- if _, markErr := r.vmRestore.FailRestore(rec.ID, resultErr.Error()); markErr != nil {
- resultErr = errors.Join(resultErr, fmt.Errorf("preserve failed clone state: %w", markErr))
- }
- return
- }
- r.network.rollbackNetwork(rec)
- _ = r.disk.removeManagedDirs(rec)
- _ = r.vmRecords.Delete(rec.ID)
- }()
-
- if err := r.network.attachNetwork(ctx, rec); err != nil {
- return nil, err
- }
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- if err := snapshot.VerifyNativeCloneTarget(ctx, manifest, rec); err != nil {
- return nil, err
- }
- if err := r.backend.RenderConfig(rec); err != nil {
- return nil, fmt.Errorf("render clone launch config: %w", err)
- }
- staged, stageMetrics, err := stageNativeRestore(ctx, snapshotRec, manifest, rec)
- if err != nil {
- return nil, err
- }
- defer staged.cleanup() //nolint:errcheck
- if err := fault.Check(ctx, fault.CloneAfterStage); err != nil {
- return nil, err
- }
- dirty, err := r.vmRestore.BeginRestore(rec.ID, snapshotRec.ID, string(opts.Mode))
- if err != nil {
- return nil, err
- }
- diskCommitStarted := time.Now()
- if err := staged.commitDisks(); err != nil {
- return nil, fmt.Errorf("replace clone writable disks: %w", err)
- }
- if err := fault.Check(ctx, fault.CloneAfterDiskCommit); err != nil {
- return nil, err
- }
- diskCommitDuration := time.Since(diskCommitStarted)
- backendRestoreStarted := time.Now()
- backendResult, err = cloner.CloneVM(ctx, dirty, staged.nativeDir, string(opts.Mode))
- if err != nil {
- return nil, fmt.Errorf("restore clone backend state: %w", err)
- }
- backendRestoreDuration := time.Since(backendRestoreStarted)
- stopRestoredBackend := func(cause error) error {
- cleanup := *dirty
- cleanup.PID = backendResult.PID
- cleanup.APISocket = backendResult.APISocket
- _, stopErr := r.backend.StopVM(&cleanup, backend.StopOptions{Force: true})
- if stopErr != nil && restoreModePinsSnapshot(opts.Mode) {
- staged.retainNativePayload()
- }
- backendResult = nil
- return errors.Join(cause, stopErr)
- }
- identityStarted := time.Now()
- // Identity configuration is a best-effort guest capability. Do not tear
- // down a successfully restored VMM when the image has no compatible agent.
- identityErr := configureGuestIdentity(ctx, rec.VsockSocket, rec)
- identityDuration := time.Since(identityStarted)
- if err := r.recordVMSnapshotReference(ctx, dirty.ID, snapshotRec.ID); err != nil {
- return nil, stopRestoredBackend(fmt.Errorf("record clone snapshot reference: %w", err))
- }
- cloned, err := r.vmRestore.CompleteRestore(rec.ID, backendResult.PID, backendResult.APISocket, time.Since(restoreStarted), &vm.RestoreResult{
- NativeStageDurationMs: stageMetrics.nativeStageDuration.Milliseconds(),
- DiskStageDurationMs: stageMetrics.diskStageDuration.Milliseconds(),
- DiskCommitDurationMs: diskCommitDuration.Milliseconds(),
- BackendRestoreDurationMs: backendRestoreDuration.Milliseconds(),
- IdentityDurationMs: identityDuration.Milliseconds(),
- GuestAgentWarning: guestAgentWarning(identityErr),
- })
- if err != nil {
- removeErr := r.removeVMSnapshotReference(ctx, dirty.ID, snapshotRec.ID)
- return nil, stopRestoredBackend(errors.Join(err, removeErr))
- }
- r.recordComputeStart(ctx, cloned, metering.ReasonClone)
- if restoreModePinsSnapshot(opts.Mode) {
- staged.retainNativePayload()
- }
- committed = true
- _ = writeVMEvent(cloned, "snapshot.clone.completed", vm.Observation{
- State: vm.ObservedStateRunning, Reason: "cloned from native snapshot " + snapshotRec.ID, CheckedAt: time.Now().UTC(),
- })
- return r.applyObservation(cloned), nil
-}
-
-func cloneNetworkSelections(requested []string, nicCount int) ([]string, error) {
- if nicCount == 0 {
- if len(requested) > 0 && (len(requested) != 1 || requested[0] != "none") {
- return nil, errors.New("SNAPSHOT_INCOMPATIBLE: networkless snapshot cannot gain NICs during clone")
- }
- return []string{"none"}, nil
- }
- if len(requested) == 0 {
- requested = make([]string, nicCount)
- for i := range requested {
- requested[i] = "default"
- }
- }
- if len(requested) != nicCount {
- return nil, fmt.Errorf("SNAPSHOT_INCOMPATIBLE: snapshot has %d NICs, clone requested %d", nicCount, len(requested))
- }
- for _, network := range requested {
- if network == "none" {
- return nil, errors.New("SNAPSHOT_INCOMPATIBLE: none cannot be mixed with native clone NICs")
- }
- }
- return append([]string(nil), requested...), nil
-}
-
-func configureCloneIdentity(ctx context.Context, socket string, rec *vm.VMRecord) error {
- request := agentclient.IdentityRequest{Hostname: rec.Name, Interfaces: make([]agentclient.InterfaceIdentity, 0, len(rec.NetworkConfigs))}
- for i, config := range rec.NetworkConfigs {
- identity := agentclient.InterfaceIdentity{Name: config.IfName, MAC: config.MAC}
- if identity.Name == "" {
- identity.Name = kbnetwork.GuestInterfaceName(i)
- }
- if config.Network != nil {
- identity.IP = config.Network.IP
- identity.Prefix = config.Network.Prefix
- identity.Gateway = config.Network.Gateway
- identity.DNS = append([]string(nil), config.Network.DNS...)
- }
- request.Interfaces = append(request.Interfaces, identity)
- }
- identityCtx, cancel := context.WithTimeout(ctx, cloneIdentityTimeout)
- defer cancel()
- pong, err := agentclient.Ping(identityCtx, socket)
- if err != nil {
- return fmt.Errorf("wait for clone guest agent: %w", err)
- }
- if err := requireAgentCapability(pong, agentclient.CapabilityIdentity); err != nil {
- return err
- }
- var lastErr error
- for {
- attemptCtx, attemptCancel := context.WithTimeout(identityCtx, cloneIdentityAttemptTimeout)
- _, err := agentclient.ConfigureIdentity(attemptCtx, socket, request)
- attemptCancel()
- if err == nil {
- break
- }
- lastErr = err
- if identityCtx.Err() != nil {
- return fmt.Errorf("configure clone guest identity: last attempt: %v: %w", lastErr, identityCtx.Err())
- }
-
- retry := time.NewTimer(cloneIdentityRetryInterval)
- select {
- case <-identityCtx.Done():
- if !retry.Stop() {
- select {
- case <-retry.C:
- default:
- }
- }
- return fmt.Errorf("configure clone guest identity: last attempt: %v: %w", lastErr, identityCtx.Err())
- case <-retry.C:
- }
- }
- if err := requireAgentCapability(pong, agentclient.CapabilityReseed); err != nil {
- return err
- }
- reseedCtx, reseedCancel := context.WithTimeout(identityCtx, cloneIdentityAttemptTimeout)
- defer reseedCancel()
- if _, err := agentclient.Reseed(reseedCtx, socket, true); err != nil {
- return fmt.Errorf("reseed clone guest identity: %w", err)
- }
- return nil
-}
diff --git a/internal/vm/runtime/native_clone_test.go b/internal/vm/runtime/native_clone_test.go
deleted file mode 100644
index 18e032a..0000000
--- a/internal/vm/runtime/native_clone_test.go
+++ /dev/null
@@ -1,417 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestCloneNativeSnapshotCreatesIndependentRunningVM(t *testing.T) {
- rt, store, source, ready := newNativeCloneRuntime(t)
- originalIdentity := configureGuestIdentity
- configureGuestIdentity = func(_ context.Context, socket string, rec *vm.VMRecord) error {
- if rec.ID == source.ID || rec.Name != "clone" {
- t.Fatalf("clone identity = %s/%s", rec.ID, rec.Name)
- }
- if socket != rec.VsockSocket {
- t.Fatalf("identity socket = %q, want %q", socket, rec.VsockSocket)
- }
- return nil
- }
- defer func() { configureGuestIdentity = originalIdentity }()
-
- state := vm.ObservedStateRunning
- rt.backend = backendFake{
- render: func(*vm.VMRecord) error { return nil },
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: state, CheckedAt: time.Now().UTC()}
- },
- clone: func(_ context.Context, rec *vm.VMRecord, nativeDir, mode string) (*backend.StartResult, error) {
- if rec.ID == source.ID || rec.Restore == nil || mode != "copy" {
- t.Fatalf("clone backend record = %+v", rec)
- }
- memoryPath := filepath.Join(nativeDir, "memory-range-0")
- memoryInfo, err := os.Stat(memoryPath)
- if err != nil {
- t.Fatal(err)
- }
- sourceInfo, err := os.Stat(filepath.Join(ready.DataDir, snapshot.NativePayloadDir, "memory-range-0"))
- if err != nil {
- t.Fatal(err)
- }
- if !os.SameFile(sourceInfo, memoryInfo) {
- t.Fatal("copy clone copied native memory before Cloud Hypervisor restore")
- }
- return &backend.StartResult{PID: 9876, APISocket: filepath.Join(rec.RunDir, "ch.sock")}, nil
- },
- }
-
- cloned, err := rt.CloneNativeSnapshot(context.Background(), ready.ID, NativeCloneOptions{Name: "clone", Networks: []string{"none"}})
- if err != nil {
- t.Fatal(err)
- }
- if cloned.ID == source.ID || cloned.State != vm.StateRunning || cloned.PID != 9876 || cloned.Restore != nil {
- t.Fatalf("cloned record = %+v", cloned)
- }
- if cloned.StorageConfigs[1].Path == source.StorageConfigs[1].Path {
- t.Fatal("clone reused source writable disk")
- }
- content, err := os.ReadFile(cloned.StorageConfigs[1].Path)
- if err != nil {
- t.Fatal(err)
- }
- if string(content) != "source-cow" {
- t.Fatalf("clone disk = %q", content)
- }
- persistedSource, err := store.Inspect(source.ID)
- if err != nil {
- t.Fatal(err)
- }
- if persistedSource.State != vm.StateRunning || persistedSource.PID != source.PID {
- t.Fatalf("source changed = %+v", persistedSource)
- }
- if cloned.LastRestore == nil || cloned.LastRestore.DiskStageDurationMs < 0 || cloned.LastRestore.IdentityDurationMs < 0 || cloned.LastRestore.ReadinessDurationMs < 0 {
- t.Fatalf("clone metrics = %+v", cloned.LastRestore)
- }
- if cloned.LastRestore.GuestAgentWarning != "" {
- t.Fatalf("successful identity update warning = %q", cloned.LastRestore.GuestAgentWarning)
- }
- if _, err := os.Stat(filepath.Join(cloned.RunDir, ".restore-staging")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("copy clone retained native staging: %v", err)
- }
-}
-
-func TestCloneNativeSnapshotPreservesFailedBackend(t *testing.T) {
- rt, store, _, ready := newNativeCloneRuntime(t)
- cloneErr := errors.New("injected clone failure")
- rt.backend = backendFake{
- render: func(*vm.VMRecord) error { return nil },
- clone: func(context.Context, *vm.VMRecord, string, string) (*backend.StartResult, error) {
- return nil, cloneErr
- },
- }
- if _, err := rt.CloneNativeSnapshot(context.Background(), ready.ID, NativeCloneOptions{Name: "failed-clone", Networks: []string{"none"}}); !errors.Is(err, cloneErr) {
- t.Fatalf("clone error = %v", err)
- }
- preserved, err := store.Inspect("failed-clone")
- if err != nil {
- t.Fatalf("inspect failed clone: %v", err)
- }
- if preserved.State != vm.StateError || preserved.Restore == nil || preserved.Restore.State != "failed" {
- t.Fatalf("failed clone = %+v", preserved)
- }
-}
-
-func TestCloneNativeSnapshotFailureBoundaries(t *testing.T) {
- tests := []struct {
- name string
- point fault.Point
- }{
- {name: "after stage", point: fault.CloneAfterStage},
- {name: "after disk commit", point: fault.CloneAfterDiskCommit},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- rt, store, _, ready := newNativeCloneRuntime(t)
- rt.backend = backendFake{render: func(*vm.VMRecord) error { return nil }}
- injected := errors.New("injected clone interruption")
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == tt.point {
- return injected
- }
- return nil
- }))
- if _, err := rt.CloneNativeSnapshot(ctx, ready.ID, NativeCloneOptions{Name: "boundary-clone", Networks: []string{"none"}}); !errors.Is(err, injected) {
- t.Fatalf("CloneNativeSnapshot() error = %v, want %v", err, injected)
- }
- preserved, err := store.Inspect("boundary-clone")
- if err != nil {
- t.Fatal(err)
- }
- if preserved.State != vm.StateError || preserved.Restore == nil || preserved.Restore.State != "failed" {
- t.Fatalf("failed clone state = %+v", preserved)
- }
- if _, err := os.Stat(filepath.Join(preserved.RunDir, ".restore-staging")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("restore staging remains: %v", err)
- }
- if leased, err := snapshot.NewStore(store.RootDir()).IsLeased(ready.ID); err != nil || leased {
- t.Fatalf("snapshot lease after failure = %t, err = %v", leased, err)
- }
- })
- }
-}
-
-func TestCloneNativeSnapshotPinsDelayedMemoryPayload(t *testing.T) {
- tests := []struct {
- name string
- mode RestoreMode
- }{
- {name: "ondemand", mode: RestoreModeOnDemand},
- {name: "mmap", mode: RestoreModeMmap},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- rt, store, _, ready := newNativeCloneRuntime(t)
- originalIdentity := configureGuestIdentity
- configureGuestIdentity = func(context.Context, string, *vm.VMRecord) error { return nil }
- defer func() { configureGuestIdentity = originalIdentity }()
-
- rt.backend = backendFake{
- nativeHost: func(context.Context, *vm.VMRecord) (backend.NativeHost, error) {
- return backend.NativeHost{
- BackendName: "cloud-hypervisor", BackendVersion: "test", SnapshotFormat: "cloud-hypervisor-native-v1",
- Architecture: "test", CPUVendor: "test", RestoreModes: []string{"copy", string(test.mode)},
- }, nil
- },
- render: func(*vm.VMRecord) error { return nil },
- clone: func(_ context.Context, rec *vm.VMRecord, _ string, mode string) (*backend.StartResult, error) {
- if mode != string(test.mode) {
- t.Fatalf("backend mode = %q, want %q", mode, test.mode)
- }
- return &backend.StartResult{PID: 9876, APISocket: filepath.Join(rec.RunDir, "ch.sock")}, nil
- },
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: vm.ObservedStateRunning, CheckedAt: time.Now().UTC()}
- },
- }
-
- cloned, err := rt.CloneNativeSnapshot(context.Background(), ready.ID, NativeCloneOptions{
- Name: test.name + "-clone", Networks: []string{"none"}, Mode: test.mode,
- })
- if err != nil {
- t.Fatal(err)
- }
- if cloned.SnapshotDependency == nil || cloned.SnapshotDependency.SnapshotID != ready.ID || cloned.SnapshotDependency.Mode != string(test.mode) {
- t.Fatalf("snapshot dependency = %+v", cloned.SnapshotDependency)
- }
- if _, err := os.Stat(filepath.Join(cloned.RunDir, ".restore-staging", snapshot.NativePayloadDir, "memory-range-0")); err != nil {
- t.Fatalf("%s clone discarded delayed memory payload: %v", test.mode, err)
- }
- if _, err := snapshot.NewStore(store.RootDir()).Remove(ready.ID); !errors.Is(err, snapshot.ErrInUse) {
- t.Fatalf("remove %s snapshot error = %v", test.mode, err)
- }
- })
- }
-}
-
-func TestCloneNativeSnapshotPublishesRunningVMWhenGuestAgentIsUnavailable(t *testing.T) {
- rt, store, _, ready := newNativeCloneRuntime(t)
- agentErr := errors.New("agent unavailable")
- originalIdentity := configureGuestIdentity
- configureGuestIdentity = func(context.Context, string, *vm.VMRecord) error { return agentErr }
- defer func() { configureGuestIdentity = originalIdentity }()
-
- rt.backend = backendFake{
- render: func(*vm.VMRecord) error { return nil },
- clone: func(_ context.Context, rec *vm.VMRecord, _ string, _ string) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 9876, APISocket: filepath.Join(rec.RunDir, "ch.sock")}, nil
- },
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: vm.ObservedStateRunning, CheckedAt: time.Now().UTC()}
- },
- }
-
- cloned, err := rt.CloneNativeSnapshot(context.Background(), ready.ID, NativeCloneOptions{
- Name: "agentless-clone", Networks: []string{"none"}, Mode: RestoreModeCopy,
- })
- if err != nil {
- t.Fatal(err)
- }
- if cloned.State != vm.StateRunning || cloned.LastRestore == nil {
- t.Fatalf("cloned record = %+v", cloned)
- }
- if !strings.Contains(cloned.LastRestore.GuestAgentWarning, agentErr.Error()) {
- t.Fatalf("guest agent warning = %q", cloned.LastRestore.GuestAgentWarning)
- }
- persisted, err := store.Inspect(cloned.ID)
- if err != nil {
- t.Fatal(err)
- }
- if persisted.State != vm.StateRunning || persisted.Restore != nil {
- t.Fatalf("persisted clone = %+v", persisted)
- }
-}
-
-func TestCloneNativeSnapshotStopsBackendWhenSnapshotReferenceFails(t *testing.T) {
- rt, store, _, ready := newNativeCloneRuntime(t)
- referenceErr := errors.New("injected reference failure")
- rt.data.References = failingReferenceState{ReferenceState: rt.data.References, err: referenceErr}
- originalIdentity := configureGuestIdentity
- configureGuestIdentity = func(context.Context, string, *vm.VMRecord) error { return nil }
- t.Cleanup(func() { configureGuestIdentity = originalIdentity })
-
- var restoredBackendStopped bool
- rt.backend = backendFake{
- nativeHost: func(context.Context, *vm.VMRecord) (backend.NativeHost, error) {
- return backend.NativeHost{
- BackendName: "cloud-hypervisor", BackendVersion: "test", SnapshotFormat: "cloud-hypervisor-native-v1",
- Architecture: "test", CPUVendor: "test", RestoreModes: []string{"copy", "ondemand"},
- }, nil
- },
- render: func(*vm.VMRecord) error { return nil },
- clone: func(_ context.Context, rec *vm.VMRecord, _ string, _ string) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 9876, APISocket: filepath.Join(rec.RunDir, "ch.sock")}, nil
- },
- stop: func(stopped *vm.VMRecord, _ backend.StopOptions) (*backend.StopResult, error) {
- restoredBackendStopped = stopped.PID == 9876
- return &backend.StopResult{}, nil
- },
- }
-
- _, err := rt.CloneNativeSnapshot(context.Background(), ready.ID, NativeCloneOptions{
- Name: "reference-failure-clone", Networks: []string{"none"}, Mode: RestoreModeOnDemand,
- })
- if !errors.Is(err, referenceErr) {
- t.Fatalf("clone error = %v, want %v", err, referenceErr)
- }
- if !restoredBackendStopped {
- t.Fatal("restored clone backend was not stopped after reference failure")
- }
- persisted, err := store.Inspect("reference-failure-clone")
- if err != nil {
- t.Fatal(err)
- }
- if persisted.State != vm.StateError || persisted.PID != 0 {
- t.Fatalf("failed clone record = %+v", persisted)
- }
- if _, err := os.Stat(filepath.Join(persisted.RunDir, ".restore-staging")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("clone staging remains after backend stop: %v", err)
- }
-}
-
-func TestCloneNativeSnapshotPreservesOnDemandMemoryWhenRollbackStopFails(t *testing.T) {
- rt, store, _, ready := newNativeCloneRuntime(t)
- referenceErr := errors.New("injected reference failure")
- stopErr := errors.New("injected stop failure")
- rt.data.References = failingReferenceState{ReferenceState: rt.data.References, err: referenceErr}
- originalIdentity := configureGuestIdentity
- configureGuestIdentity = func(context.Context, string, *vm.VMRecord) error { return nil }
- t.Cleanup(func() { configureGuestIdentity = originalIdentity })
- rt.backend = backendFake{
- nativeHost: func(context.Context, *vm.VMRecord) (backend.NativeHost, error) {
- return backend.NativeHost{
- BackendName: "cloud-hypervisor", BackendVersion: "test", SnapshotFormat: "cloud-hypervisor-native-v1",
- Architecture: "test", CPUVendor: "test", RestoreModes: []string{"ondemand"},
- }, nil
- },
- render: func(*vm.VMRecord) error { return nil },
- clone: func(_ context.Context, rec *vm.VMRecord, _ string, _ string) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 9876, APISocket: filepath.Join(rec.RunDir, "ch.sock")}, nil
- },
- stop: func(*vm.VMRecord, backend.StopOptions) (*backend.StopResult, error) {
- return nil, stopErr
- },
- }
-
- _, err := rt.CloneNativeSnapshot(t.Context(), ready.ID, NativeCloneOptions{
- Name: "stop-failure-clone", Networks: []string{"none"}, Mode: RestoreModeOnDemand,
- })
- if !errors.Is(err, referenceErr) || !errors.Is(err, stopErr) {
- t.Fatalf("clone error = %v, want reference and stop failures", err)
- }
- persisted, err := store.Inspect("stop-failure-clone")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(filepath.Join(persisted.RunDir, ".restore-staging", "native", "memory-range-0")); err != nil {
- t.Fatalf("on-demand memory payload was removed while backend may be running: %v", err)
- }
-}
-
-func newNativeCloneRuntime(t *testing.T) (*Runtime, *vm.Store, *vm.VMRecord, *snapshot.Record) {
- t.Helper()
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- kernel := filepath.Join(dir, "vmlinuz")
- initrd := filepath.Join(dir, "initrd")
- layer := filepath.Join(dir, "layer.erofs")
- for path, content := range map[string]string{kernel: "kernel", initrd: "initrd", layer: "layer"} {
- if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
- t.Fatal(err)
- }
- }
- const manifestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- const layerDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
- image, err := image.New(rootDir).Create(image.CreateRequest{
- Name: "clone-image", Boot: image.Boot{Mode: "direct", Kernel: kernel, Initrd: initrd, Cmdline: "console=ttyS0"},
- OCI: &image.OCI{DigestRef: "example.invalid/image@" + manifestDigest, Layers: []image.OCILayer{{
- Index: 0, Digest: layerDigest, EROFS: &image.EROFSLayer{Path: layer, Filesystem: "erofs", SizeBytes: 5, SourceLayer: layerDigest},
- }}, BuiltAt: time.Now().UTC()},
- })
- if err != nil {
- t.Fatal(err)
- }
- store := vm.New(rootDir)
- source, err := store.Create(vm.CreateRequest{
- Name: "source", Kernel: kernel, Initrd: initrd, Network: "none",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- Image: &vm.ImageRef{ID: image.ID, Name: image.Name, BootMode: "direct", Digest: manifestDigest, LayerDigests: []string{layerDigest}},
- StorageConfigs: []vm.StorageConfig{
- {ID: "layer0", Role: vm.StorageRoleLayer, Path: layer, Readonly: true, Format: "raw", Filesystem: "erofs", Serial: "kumabox-layer0", SourceLayer: layerDigest, VirtualSizeBytes: 5},
- {ID: "cow", Role: vm.StorageRoleCOW, Format: "raw", Filesystem: "ext4", Serial: "kumabox-cow", VirtualSizeBytes: 10, Base: &vm.StorageBase{Family: "oci", ImageID: image.ID, Digest: manifestDigest, LayerDigests: []string{layerDigest}}},
- },
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Dir(source.StorageConfigs[1].Path), 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(source.StorageConfigs[1].Path, []byte("source-cow"), 0o600); err != nil {
- t.Fatal(err)
- }
- source, err = store.MarkStarted(source.ID, 1234, filepath.Join(source.RunDir, "ch.sock"))
- if err != nil {
- t.Fatal(err)
- }
-
- snapshotStore := snapshot.NewStore(rootDir)
- build, err := snapshotStore.Reserve(context.Background(), "clone-source")
- if err != nil {
- t.Fatal(err)
- }
- staging := build.Record().StagingDir
- nativeDir := filepath.Join(staging, "native")
- if err := os.MkdirAll(nativeDir, 0o700); err != nil {
- t.Fatal(err)
- }
- configJSON := fmt.Sprintf(`{"cpus":{"boot_vcpus":1},"memory":{"size":536870912},"disks":[{"path":%q,"readonly":true},{"path":%q,"readonly":false}],"net":[],"vsock":{}}`, layer, source.StorageConfigs[1].Path)
- for name, content := range map[string]string{"config.json": configJSON, "state.json": "{}", "memory-range-0": "memory"} {
- if err := os.WriteFile(filepath.Join(nativeDir, name), []byte(content), 0o600); err != nil {
- t.Fatal(err)
- }
- }
- disks, _, err := snapshot.CaptureWritableDisks(context.Background(), staging, source)
- if err != nil {
- t.Fatal(err)
- }
- _, size, err := snapshot.WriteNativeManifest(context.Background(), build, source, disks, backend.NativeHost{
- BackendName: "cloud-hypervisor", BackendVersion: "test", SnapshotFormat: "cloud-hypervisor-native-v1", Architecture: "test", CPUVendor: "test",
- })
- if err != nil {
- t.Fatal(err)
- }
- ready, err := build.Finalize(size)
- if err != nil {
- t.Fatal(err)
- }
- cfg := config.Default()
- cfg.Runtime.RootDir = rootDir
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
- rt := NewWithBackend(store, backendFake{})
- rt.cfg = cfg
- return rt, store, source, ready
-}
diff --git a/internal/vm/runtime/native_restore.go b/internal/vm/runtime/native_restore.go
deleted file mode 100644
index 346de27..0000000
--- a/internal/vm/runtime/native_restore.go
+++ /dev/null
@@ -1,327 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "syscall"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/disk"
- "github.com/kumabox/kumabox/internal/fileutil"
- "github.com/kumabox/kumabox/internal/metering"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
- "golang.org/x/sync/errgroup"
-)
-
-// NativeRestoreOptions controls in-place restoration of a running snapshot.
-type NativeRestoreOptions struct {
- Mode RestoreMode
-}
-
-type stagedRestore struct {
- nativeDir string
- preserveNative bool
- disks []stagedRestoreDisk
-}
-
-type stagedRestoreDisk struct {
- id string
- target string
- staged string
-}
-
-type restoreStageMetrics struct {
- nativeStageDuration time.Duration
- diskStageDuration time.Duration
-}
-
-// RestoreNativeVM restores native memory, device state, and writable disks
-// into the original VM identity. Snapshot and VM operation locks are held for
-// the complete transaction.
-func (r *Runtime) RestoreNativeVM(ctx context.Context, vmRef, snapshotRef string, opts NativeRestoreOptions) (result *vm.VMRecord, resultErr error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- mode, err := normalizeRestoreMode(opts.Mode)
- if err != nil {
- return nil, err
- }
- opts.Mode = mode
- restoreStarted := time.Now()
- rec, err := r.vmReader.Inspect(vmRef)
- if err != nil {
- return nil, err
- }
- operationID, err := r.beginOperationWithRelated(ctx, operation.KindSnapshotRestoreVM, rec.ID, snapshotRef)
- if err != nil {
- return nil, err
- }
- defer func() { resultErr = r.finishOperation(ctx, operationID, resultErr) }()
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for restore: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
-
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- if rec.State != vm.StateRunning && rec.State != vm.StateStopped && rec.State != vm.StateError {
- return nil, fmt.Errorf("VM_RESTORE_INVALID_STATE: VM %s is %s", rec.Name, rec.State)
- }
- restorer, ok := r.backend.(backend.NativeRestorer)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not support native restore")
- }
- inspector, ok := r.backend.(backend.NativeHostInspector)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not expose native compatibility")
- }
-
- snapshotStore := r.data.Snapshots
- snapshotRec, lease, err := snapshotStore.AcquireRead(ctx, snapshotRef)
- if err != nil {
- return nil, err
- }
- defer lease.Release() //nolint:errcheck
- host, err := inspector.InspectNativeHost(ctx, rec)
- if err != nil {
- return nil, fmt.Errorf("inspect native compatibility: %w", err)
- }
- if err := requireRestoreMode(host, opts.Mode); err != nil {
- return nil, err
- }
- manifest, err := snapshotStore.VerifyNativeRecord(ctx, snapshotRec, snapshot.NativeVerifyTarget{VM: rec, Host: host})
- if err != nil {
- return nil, fmt.Errorf("snapshot preflight: %w", err)
- }
- if manifest.Network == nil || manifest.Network.RestorePolicy != "preserve" {
- return nil, errors.New("SNAPSHOT_INCOMPATIBLE: snapshot does not preserve VM network identity")
- }
- if err := r.backend.RenderConfig(rec); err != nil {
- return nil, fmt.Errorf("render restore launch config: %w", err)
- }
- staged, stageMetrics, err := stageNativeRestore(ctx, snapshotRec, manifest, rec)
- if err != nil {
- return nil, err
- }
- defer staged.cleanup() //nolint:errcheck
-
- observed := r.applyObservation(rec)
- if observed.ObservedState == vm.ObservedStateRunning || observed.ObservedState == vm.ObservedStatePaused {
- if _, err := r.stopVMLocked(ctx, rec.ID, backend.StopOptions{Force: true, Timeout: forcedStopTimeout}, metering.ReasonRestore); err != nil {
- return nil, fmt.Errorf("stop VM for restore: %w", err)
- }
- }
- dirty, err := r.vmRestore.BeginRestore(rec.ID, snapshotRec.ID, string(opts.Mode))
- if err != nil {
- return nil, fmt.Errorf("mark restore dirty: %w", err)
- }
- fail := func(cause error) (*vm.VMRecord, error) {
- _, markErr := r.vmRestore.FailRestore(rec.ID, cause.Error())
- return nil, errors.Join(cause, markErr)
- }
- diskCommitStarted := time.Now()
- if err := staged.commitDisks(); err != nil {
- return fail(fmt.Errorf("replace writable disks: %w", err))
- }
- diskCommitDuration := time.Since(diskCommitStarted)
- backendRestoreStarted := time.Now()
- backendResult, err := restorer.RestoreVM(ctx, dirty, staged.nativeDir, string(opts.Mode))
- if err != nil {
- return fail(fmt.Errorf("restore backend state: %w", err))
- }
- backendRestoreDuration := time.Since(backendRestoreStarted)
- stopRestoredBackend := func(cause error) (*vm.VMRecord, error) {
- cleanupRec := *dirty
- cleanupRec.PID = backendResult.PID
- cleanupRec.APISocket = backendResult.APISocket
- _, stopErr := r.backend.StopVM(&cleanupRec, backend.StopOptions{Force: true})
- if stopErr != nil && restoreModePinsSnapshot(opts.Mode) {
- staged.retainNativePayload()
- }
- return fail(errors.Join(cause, stopErr))
- }
- identityStarted := time.Now()
- reseedErr := reseedRestoredGuest(ctx, rec.VsockSocket, false)
- identityDuration := time.Since(identityStarted)
- if err := r.recordVMSnapshotReference(ctx, dirty.ID, snapshotRec.ID); err != nil {
- return stopRestoredBackend(fmt.Errorf("record restore snapshot reference: %w", err))
- }
- // Backend restore is the lifecycle boundary. Agent-dependent commands
- // report their own availability without quarantining this running VM.
- restored, err := r.vmRestore.CompleteRestore(rec.ID, backendResult.PID, backendResult.APISocket, time.Since(restoreStarted), &vm.RestoreResult{
- NativeStageDurationMs: stageMetrics.nativeStageDuration.Milliseconds(),
- DiskStageDurationMs: stageMetrics.diskStageDuration.Milliseconds(),
- DiskCommitDurationMs: diskCommitDuration.Milliseconds(),
- BackendRestoreDurationMs: backendRestoreDuration.Milliseconds(),
- IdentityDurationMs: identityDuration.Milliseconds(),
- GuestAgentWarning: guestAgentWarning(reseedErr),
- })
- if err != nil {
- removeErr := r.removeVMSnapshotReference(ctx, dirty.ID, snapshotRec.ID)
- return stopRestoredBackend(errors.Join(fmt.Errorf("publish restored VM state: %w", err), removeErr))
- }
- r.recordComputeStart(ctx, restored, metering.ReasonRestore)
- if restoreModePinsSnapshot(opts.Mode) {
- staged.retainNativePayload()
- }
- _ = writeVMEvent(restored, "snapshot.restore.completed", vm.Observation{
- State: vm.ObservedStateRunning, Reason: "native snapshot " + snapshotRec.ID + " restored", CheckedAt: time.Now().UTC(),
- })
- return r.applyObservation(restored), nil
-}
-
-func stageNativeRestore(ctx context.Context, snapshotRec *snapshot.Record, manifest *snapshot.Manifest, rec *vm.VMRecord) (*stagedRestore, restoreStageMetrics, error) {
- var metrics restoreStageMetrics
- nativeStageStarted := time.Now()
- root := filepath.Join(rec.RunDir, ".restore-staging")
- if err := os.RemoveAll(root); err != nil {
- return nil, metrics, fmt.Errorf("clear restore staging: %w", err)
- }
- nativeDir := filepath.Join(root, snapshot.NativePayloadDir)
- if err := os.MkdirAll(nativeDir, 0o700); err != nil {
- return nil, metrics, fmt.Errorf("create native restore staging: %w", err)
- }
- staged := &stagedRestore{nativeDir: nativeDir}
- ok := false
- defer func() {
- if !ok {
- _ = staged.cleanup()
- }
- }()
- for _, file := range manifest.Native.Files {
- if !strings.HasPrefix(file.Path, snapshot.NativePathPrefix) || filepath.Base(file.Path) != strings.TrimPrefix(file.Path, snapshot.NativePathPrefix) {
- return nil, metrics, fmt.Errorf("SNAPSHOT_CORRUPT: invalid native payload path %s", file.Path)
- }
- source := filepath.Join(snapshotRec.DataDir, filepath.FromSlash(file.Path))
- destination := filepath.Join(nativeDir, filepath.Base(file.Path))
- // Cloud Hypervisor owns eager-copy versus delayed paging. The host must
- // not make a second full copy before vm.restore in either case.
- if snapshot.IsNativeMemoryFile(file.Path) {
- if err := linkNativeMemory(source, destination); err != nil {
- return nil, metrics, fmt.Errorf("link native memory payload %s: %w", file.Path, err)
- }
- continue
- }
- result, err := disk.CopyFile(ctx, source, destination)
- if err != nil {
- return nil, metrics, fmt.Errorf("stage native payload %s: %w", file.Path, err)
- }
- if file.SHA256 != "" && result.SHA256 != file.SHA256 {
- return nil, metrics, fmt.Errorf("CHECKSUM_MISMATCH: staged %s", file.Path)
- }
- }
- metrics.nativeStageDuration = time.Since(nativeStageStarted)
- diskStageStarted := time.Now()
- targets := make(map[string]vm.StorageConfig, len(rec.StorageConfigs))
- for _, disk := range rec.StorageConfigs {
- if disk.EffectiveRole() == vm.StorageRoleCOW || disk.EffectiveRole() == vm.StorageRoleData {
- targets[disk.ID] = disk
- }
- }
- staged.disks = make([]stagedRestoreDisk, len(manifest.Disks))
- group, groupCtx := errgroup.WithContext(ctx)
- group.SetLimit(disk.MaxConcurrentFileCopies)
- for index, manifestDisk := range manifest.Disks {
- index, manifestDisk := index, manifestDisk
- group.Go(func() error {
- target, found := targets[manifestDisk.ID]
- if !found {
- return fmt.Errorf("SNAPSHOT_INCOMPATIBLE: no writable target for disk %s", manifestDisk.ID)
- }
- if err := os.MkdirAll(filepath.Dir(target.Path), 0o700); err != nil {
- return fmt.Errorf("create target directory for disk %s: %w", manifestDisk.ID, err)
- }
- stagedPath := filepath.Join(filepath.Dir(target.Path), ".kumabox-restore-"+snapshotRec.ID+"-"+filepath.Base(target.Path))
- if err := os.Remove(stagedPath); err != nil && !errors.Is(err, os.ErrNotExist) {
- return fmt.Errorf("clear staged disk %s: %w", manifestDisk.ID, err)
- }
- source := filepath.Join(snapshotRec.DataDir, filepath.FromSlash(manifestDisk.Path))
- result, err := disk.CopyFile(groupCtx, source, stagedPath)
- if err != nil {
- return fmt.Errorf("stage writable disk %s: %w", manifestDisk.ID, err)
- }
- if manifestDisk.SHA256 != "" && result.SHA256 != manifestDisk.SHA256 {
- return fmt.Errorf("CHECKSUM_MISMATCH: staged disk %s", manifestDisk.ID)
- }
- staged.disks[index] = stagedRestoreDisk{id: manifestDisk.ID, target: target.Path, staged: stagedPath}
- return nil
- })
- }
- if err := group.Wait(); err != nil {
- return nil, metrics, err
- }
- if len(staged.disks) != len(targets) {
- return nil, metrics, errors.New("SNAPSHOT_INCOMPATIBLE: writable disk set is incomplete")
- }
- metrics.diskStageDuration = time.Since(diskStageStarted)
- ok = true
- return staged, metrics, nil
-}
-
-func linkNativeMemory(source, destination string) error {
- if err := os.Link(source, destination); err == nil {
- return nil
- } else if !errors.Is(err, syscall.EXDEV) {
- return err
- }
- if err := os.Symlink(source, destination); err != nil {
- return fmt.Errorf("cross-filesystem symlink: %w", err)
- }
- return nil
-}
-
-func (s *stagedRestore) commitDisks() error {
- for _, disk := range s.disks {
- if err := os.Rename(disk.staged, disk.target); err != nil {
- return fmt.Errorf("replace disk %s: %w", disk.id, err)
- }
- if err := syncDirectory(filepath.Dir(disk.target)); err != nil {
- return fmt.Errorf("sync disk %s directory: %w", disk.id, err)
- }
- }
- return nil
-}
-
-func (s *stagedRestore) cleanup() error {
- if s == nil {
- return nil
- }
- var errs []error
- if !s.preserveNative {
- errs = append(errs, os.RemoveAll(filepath.Dir(s.nativeDir)))
- }
- for _, disk := range s.disks {
- if err := os.Remove(disk.staged); err != nil && !errors.Is(err, os.ErrNotExist) {
- errs = append(errs, err)
- }
- }
- return errors.Join(errs...)
-}
-
-func (s *stagedRestore) retainNativePayload() {
- if s != nil {
- s.preserveNative = true
- }
-}
-
-func syncDirectory(path string) (err error) {
- dir, err := os.Open(path) //nolint:gosec
- if err != nil {
- return err
- }
- defer fileutil.CloseAndJoin(&err, dir, "close restore directory")
- return dir.Sync()
-}
diff --git a/internal/vm/runtime/native_restore_test.go b/internal/vm/runtime/native_restore_test.go
deleted file mode 100644
index 8d08fa3..0000000
--- a/internal/vm/runtime/native_restore_test.go
+++ /dev/null
@@ -1,281 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/reference"
- "github.com/kumabox/kumabox/internal/state"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestLinkNativeMemorySharesSourceInode(t *testing.T) {
- dir := t.TempDir()
- source := filepath.Join(dir, "memory-range-0")
- destination := filepath.Join(dir, "linked-memory-range-0")
- if err := os.WriteFile(source, []byte("memory"), 0o600); err != nil {
- t.Fatal(err)
- }
- if err := linkNativeMemory(source, destination); err != nil {
- t.Fatal(err)
- }
- sourceInfo, err := os.Stat(source)
- if err != nil {
- t.Fatal(err)
- }
- destinationInfo, err := os.Stat(destination)
- if err != nil {
- t.Fatal(err)
- }
- if !os.SameFile(sourceInfo, destinationInfo) {
- t.Fatal("linked memory does not share the source inode")
- }
-}
-
-func TestRestoreNativeVMReplacesWritableStateAndResumesIdentity(t *testing.T) {
- rt, store, rec, sourceDisk := newRunningSnapshotRuntime(t)
- backendState := vm.ObservedStateRunning
- rt.backend = nativeRestoreBackend(t, rec, &backendState, nil)
- originalReseed := reseedRestoredGuest
- t.Cleanup(func() { reseedRestoredGuest = originalReseed })
- var reseedCalled bool
- reseedRestoredGuest = func(_ context.Context, socket string, regenerateMachineID bool) error {
- reseedCalled = true
- if socket != rec.VsockSocket || regenerateMachineID {
- t.Fatalf("restore reseed socket=%q regenerateMachineID=%t", socket, regenerateMachineID)
- }
- return nil
- }
-
- ready, err := rt.CreateRunningSnapshot(context.Background(), rec.ID, "restore-source")
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(sourceDisk, []byte("after-snapshot"), 0o600); err != nil {
- t.Fatal(err)
- }
-
- restored, err := rt.RestoreNativeVM(context.Background(), rec.ID, ready.ID, NativeRestoreOptions{Mode: "copy"})
- if err != nil {
- t.Fatal(err)
- }
- if restored.ID != rec.ID || restored.Name != rec.Name || restored.State != vm.StateRunning || restored.Restore != nil {
- t.Fatalf("restored record = %+v", restored)
- }
- content, err := os.ReadFile(sourceDisk)
- if err != nil {
- t.Fatal(err)
- }
- if string(content) != "writable" {
- t.Fatalf("restored disk = %q", content)
- }
- persisted, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if persisted.Restore != nil || persisted.PID != 4321 {
- t.Fatalf("persisted record = %+v", persisted)
- }
- if persisted.LastRestore == nil || persisted.LastRestore.BackendRestoreDurationMs < 0 || persisted.LastRestore.ReadinessDurationMs < 0 {
- t.Fatalf("restore metrics = %+v", persisted.LastRestore)
- }
- if !reseedCalled || persisted.LastRestore.GuestAgentWarning != "" {
- t.Fatalf("restore reseed called=%t warning=%q", reseedCalled, persisted.LastRestore.GuestAgentWarning)
- }
-}
-
-func TestRestoreNativeVMFailureQuarantinesColdStart(t *testing.T) {
- rt, store, rec, _ := newRunningSnapshotRuntime(t)
- backendState := vm.ObservedStateRunning
- restoreErr := errors.New("injected backend restore failure")
- rt.backend = nativeRestoreBackend(t, rec, &backendState, restoreErr)
- ready, err := rt.CreateRunningSnapshot(context.Background(), rec.ID, "restore-failure")
- if err != nil {
- t.Fatal(err)
- }
-
- if _, err := rt.RestoreNativeVM(context.Background(), rec.ID, ready.ID, NativeRestoreOptions{}); !errors.Is(err, restoreErr) {
- t.Fatalf("restore error = %v", err)
- }
- persisted, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if persisted.State != vm.StateError || persisted.Restore == nil || persisted.Restore.State != "failed" {
- t.Fatalf("failed restore record = %+v", persisted)
- }
- if _, err := rt.StartVM(rec.ID); err == nil || !containsError(err, "VM_RESTORE_DIRTY") {
- t.Fatalf("start error = %v", err)
- }
-}
-
-func TestRestoreNativeVMSucceedsWithoutGuestAgent(t *testing.T) {
- rt, store, rec, _ := newRunningSnapshotRuntime(t)
- backendState := vm.ObservedStateRunning
- rt.backend = nativeRestoreBackend(t, rec, &backendState, nil)
- agentErr := errors.New("agent unavailable")
- reseedRestoredGuest = func(context.Context, string, bool) error { return agentErr }
- ready, err := rt.CreateRunningSnapshot(context.Background(), rec.ID, "restore-readiness-failure")
- if err != nil {
- t.Fatal(err)
- }
-
- restored, err := rt.RestoreNativeVM(context.Background(), rec.ID, ready.ID, NativeRestoreOptions{})
- if err != nil {
- t.Fatalf("restore error = %v", err)
- }
- persisted, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if restored.State != vm.StateRunning || persisted.State != vm.StateRunning || persisted.Restore != nil {
- t.Fatalf("restored record = %+v persisted = %+v", restored, persisted)
- }
- if restored.LastRestore == nil || !strings.Contains(restored.LastRestore.GuestAgentWarning, agentErr.Error()) {
- t.Fatalf("restore warning = %+v", restored.LastRestore)
- }
-}
-
-func TestRestoreNativeVMStopsBackendWhenSnapshotReferenceFails(t *testing.T) {
- rt, store, rec, _ := newRunningSnapshotRuntime(t)
- backendState := vm.ObservedStateRunning
- referenceErr := errors.New("injected reference failure")
- backendImpl := nativeRestoreBackend(t, rec, &backendState, nil)
- baseStop := backendImpl.stop
- var restoredBackendStopped bool
- backendImpl.stop = func(stopped *vm.VMRecord, options backend.StopOptions) (*backend.StopResult, error) {
- if stopped.PID == 4321 {
- restoredBackendStopped = true
- }
- return baseStop(stopped, options)
- }
- rt.backend = backendImpl
- ready, err := rt.CreateRunningSnapshot(context.Background(), rec.ID, "restore-reference-failure")
- if err != nil {
- t.Fatal(err)
- }
- rt.data.References = failingReferenceState{ReferenceState: rt.data.References, err: referenceErr}
-
- _, err = rt.RestoreNativeVM(context.Background(), rec.ID, ready.ID, NativeRestoreOptions{})
- if !errors.Is(err, referenceErr) {
- t.Fatalf("restore error = %v, want %v", err, referenceErr)
- }
- if !restoredBackendStopped {
- t.Fatal("restored backend was not stopped after reference failure")
- }
- persisted, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if persisted.State != vm.StateError || persisted.PID != 0 {
- t.Fatalf("failed restore record = %+v", persisted)
- }
- if _, err := os.Stat(filepath.Join(rec.RunDir, ".restore-staging")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("restore staging remains after backend stop: %v", err)
- }
-}
-
-func TestRestoreNativeVMPreservesOnDemandMemoryWhenRollbackStopFails(t *testing.T) {
- rt, _, rec, _ := newRunningSnapshotRuntime(t)
- backendState := vm.ObservedStateRunning
- referenceErr := errors.New("injected reference failure")
- stopErr := errors.New("injected stop failure")
- backendImpl := nativeRestoreBackend(t, rec, &backendState, nil)
- backendImpl.nativeHost = func(context.Context, *vm.VMRecord) (backend.NativeHost, error) {
- return backend.NativeHost{RestoreModes: []string{string(RestoreModeOnDemand)}}, nil
- }
- backendImpl.stop = func(stopped *vm.VMRecord, _ backend.StopOptions) (*backend.StopResult, error) {
- if stopped.PID == 4321 {
- return nil, stopErr
- }
- backendState = vm.ObservedStateStopped
- return &backend.StopResult{}, nil
- }
- rt.backend = backendImpl
- ready, err := rt.CreateRunningSnapshot(t.Context(), rec.ID, "restore-stop-failure")
- if err != nil {
- t.Fatal(err)
- }
- rt.data.References = failingReferenceState{ReferenceState: rt.data.References, err: referenceErr}
-
- _, err = rt.RestoreNativeVM(t.Context(), rec.ID, ready.ID, NativeRestoreOptions{Mode: RestoreModeOnDemand})
- if !errors.Is(err, referenceErr) || !errors.Is(err, stopErr) {
- t.Fatalf("restore error = %v, want reference and stop failures", err)
- }
- if _, err := os.Stat(filepath.Join(rec.RunDir, ".restore-staging", "native", "memory-range-0")); err != nil {
- t.Fatalf("on-demand memory payload was removed while backend may be running: %v", err)
- }
-}
-
-type failingReferenceState struct {
- state.ReferenceState
- err error
-}
-
-func (s failingReferenceState) Upsert(ctx context.Context, record reference.Record) error {
- if record.TargetKind == referenceKindSnapshot {
- return s.err
- }
- return s.ReferenceState.Upsert(ctx, record)
-}
-
-func nativeRestoreBackend(t *testing.T, rec *vm.VMRecord, state *vm.ObservedState, restoreErr error) backendFake {
- t.Helper()
- originalReseed := reseedRestoredGuest
- reseedRestoredGuest = func(context.Context, string, bool) error { return nil }
- t.Cleanup(func() { reseedRestoredGuest = originalReseed })
- return backendFake{
- render: func(*vm.VMRecord) error { return nil },
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: *state, CheckedAt: time.Now().UTC()}
- },
- pause: func(context.Context, *vm.VMRecord) error {
- *state = vm.ObservedStatePaused
- return nil
- },
- resume: func(context.Context, *vm.VMRecord) error {
- *state = vm.ObservedStateRunning
- return nil
- },
- snapshot: func(_ context.Context, _ *vm.VMRecord, destination string) error {
- files := map[string]string{
- "config.json": fmt.Sprintf(`{"cpus":{"boot_vcpus":1},"memory":{"size":536870912},"disks":[{"path":%q,"readonly":false}],"vsock":{}}`, rec.StorageConfigs[0].Path),
- "state.json": "{}", "memory-range-0": "memory",
- }
- for name, content := range files {
- if err := os.WriteFile(filepath.Join(destination, name), []byte(content), 0o600); err != nil {
- return err
- }
- }
- return nil
- },
- stop: func(*vm.VMRecord, backend.StopOptions) (*backend.StopResult, error) {
- *state = vm.ObservedStateStopped
- return &backend.StopResult{}, nil
- },
- restore: func(_ context.Context, dirty *vm.VMRecord, sourceDir, mode string) (*backend.StartResult, error) {
- if dirty.Restore == nil || dirty.Restore.State != "dirty" || (mode != "copy" && mode != "ondemand") {
- t.Fatalf("restore input = %+v mode=%s", dirty.Restore, mode)
- }
- if _, err := os.Stat(filepath.Join(sourceDir, "memory-range-0")); err != nil {
- t.Fatal(err)
- }
- if restoreErr != nil {
- return nil, restoreErr
- }
- *state = vm.ObservedStateRunning
- return &backend.StartResult{PID: 4321, APISocket: filepath.Join(rec.RunDir, "ch.sock")}, nil
- },
- }
-}
-
-func containsError(err error, text string) bool {
- return err != nil && strings.Contains(err.Error(), text)
-}
diff --git a/internal/vm/runtime/native_snapshot.go b/internal/vm/runtime/native_snapshot.go
deleted file mode 100644
index e421852..0000000
--- a/internal/vm/runtime/native_snapshot.go
+++ /dev/null
@@ -1,161 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const snapshotCleanupTimeout = 30 * time.Second
-
-// CreateRunningSnapshot captures native backend state and writable disks from
-// one pause window, then publishes the snapshot after the source VM resumes.
-func (r *Runtime) CreateRunningSnapshot(ctx context.Context, ref, name string) (result *snapshot.Record, resultErr error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- captureStarted := time.Now()
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- operationID, err := r.beginOperationWithRelated(ctx, operation.KindSnapshotCreateRun, rec.ID, name)
- if err != nil {
- return nil, err
- }
- defer func() { resultErr = r.finishOperation(ctx, operationID, resultErr) }()
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for running snapshot: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
-
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- rec = r.applyObservation(rec)
- if rec.ObservedState != vm.ObservedStateRunning {
- return nil, fmt.Errorf("VM_NOT_RUNNING: VM %s observed state is %s", rec.Name, rec.ObservedState)
- }
- controller, ok := r.backend.(backend.StateController)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not support pause/resume")
- }
- snapshotter, ok := r.backend.(backend.NativeSnapshotter)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not support native snapshots")
- }
- hostInspector, ok := r.backend.(backend.NativeHostInspector)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not expose native compatibility")
- }
-
- build, err := r.data.Snapshots.Reserve(ctx, name)
- if err != nil {
- return nil, err
- }
- defer build.Abort() //nolint:errcheck
- pending := build.Record()
- nativeDir := filepath.Join(pending.StagingDir, snapshot.NativePayloadDir)
- if err := os.MkdirAll(nativeDir, 0o700); err != nil {
- return nil, fmt.Errorf("create native snapshot staging: %w", err)
- }
-
- if err := controller.PauseVM(ctx, rec); err != nil {
- return nil, fmt.Errorf("pause VM for snapshot: %w", err)
- }
- pausedAt := time.Now()
- stagedDisks, nativeCaptureMs, diskStageMs, captureErr := captureNativeWindow(ctx, snapshotter, rec, nativeDir, pending.StagingDir)
- cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), snapshotCleanupTimeout)
- resumeErr := controller.ResumeVM(cleanupCtx, rec)
- resumedAt := time.Now()
- cancel()
- if captureErr != nil || resumeErr != nil {
- if resumeErr != nil {
- r.persistSnapshotResumeFailure(rec)
- }
- return nil, errors.Join(
- wrapOptional("capture native snapshot", captureErr),
- wrapOptional("resume VM after snapshot", resumeErr),
- )
- }
- // Running snapshots follow the fast local path: resume before durability
- // work. Strict fsync and hashing belong to explicit verification/export.
- disks := stagedDisks
- if err := build.SetPerformance(snapshot.CaptureMetrics{
- PauseDurationMs: resumedAt.Sub(pausedAt).Milliseconds(),
- NativeCaptureMs: nativeCaptureMs,
- WritableDiskStageMs: diskStageMs,
- PublicationDurationMs: time.Since(resumedAt).Milliseconds(),
- TotalDurationMs: time.Since(captureStarted).Milliseconds(),
- }); err != nil {
- return nil, err
- }
-
- host, err := hostInspector.InspectNativeHost(ctx, rec)
- if err != nil {
- return nil, fmt.Errorf("inspect native compatibility: %w", err)
- }
- _, totalSize, err := snapshot.WriteNativeManifestFast(ctx, build, rec, disks, host)
- if err != nil {
- return nil, err
- }
- ready, err := build.FinalizeContext(ctx, totalSize)
- if err != nil {
- return nil, err
- }
- if rec.Image != nil {
- if err := r.recordSnapshotImageReference(ctx, ready.ID, rec.Image.ID); err != nil {
- _, _ = r.data.Snapshots.Remove(ready.ID)
- return nil, fmt.Errorf("record snapshot image reference: %w", err)
- }
- }
- _ = writeVMEvent(rec, "snapshot.capture.completed", vm.Observation{
- State: vm.ObservedStateRunning,
- Reason: fmt.Sprintf("native crash-consistent snapshot %s captured", ready.ID),
- CheckedAt: time.Now().UTC(),
- })
- return ready, nil
-}
-
-func captureNativeWindow(ctx context.Context, snapshotter backend.NativeSnapshotter, rec *vm.VMRecord, nativeDir, stagingDir string) ([]snapshot.DiskManifest, int64, int64, error) {
- nativeStarted := time.Now()
- if err := snapshotter.SnapshotVM(ctx, rec, nativeDir); err != nil {
- return nil, 0, 0, fmt.Errorf("capture backend state: %w", err)
- }
- nativeDuration := time.Since(nativeStarted).Milliseconds()
- diskStarted := time.Now()
- disks, err := snapshot.StageWritableDisks(ctx, stagingDir, rec)
- if err != nil {
- return nil, nativeDuration, time.Since(diskStarted).Milliseconds(), fmt.Errorf("capture writable disks: %w", err)
- }
- return disks, nativeDuration, time.Since(diskStarted).Milliseconds(), nil
-}
-
-func (r *Runtime) persistSnapshotResumeFailure(rec *vm.VMRecord) {
- observation := r.backend.ObserveVM(rec)
- if observation.State == vm.ObservedStatePaused {
- _ = r.vmUpdater.UpdateStates([]string{rec.ID}, vm.StatePaused)
- return
- }
- _, _ = r.vmUpdater.SetError(rec.ID, "failed to resume VM after running snapshot")
-}
-
-func wrapOptional(operation string, err error) error {
- if err == nil {
- return nil
- }
- return fmt.Errorf("%s: %w", operation, err)
-}
diff --git a/internal/vm/runtime/native_snapshot_test.go b/internal/vm/runtime/native_snapshot_test.go
deleted file mode 100644
index d936c38..0000000
--- a/internal/vm/runtime/native_snapshot_test.go
+++ /dev/null
@@ -1,160 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestCreateRunningSnapshotCapturesOnePauseWindow(t *testing.T) {
- t.Parallel()
-
- rt, store, rec, sourceDisk := newRunningSnapshotRuntime(t)
- backendState := vm.ObservedStateRunning
- steps := make([]string, 0, 3)
- rt.backend = backendFake{
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: backendState, CheckedAt: time.Now().UTC()}
- },
- pause: func(context.Context, *vm.VMRecord) error {
- steps = append(steps, "pause")
- backendState = vm.ObservedStatePaused
- return nil
- },
- snapshot: func(_ context.Context, _ *vm.VMRecord, destination string) error {
- steps = append(steps, "snapshot")
- for name, content := range map[string]string{
- "config.json": fmt.Sprintf(`{"cpus":{"boot_vcpus":1},"memory":{"size":536870912},"disks":[{"path":%q,"readonly":false}],"vsock":{}}`, rec.StorageConfigs[0].Path),
- "state.json": "{}", "memory-range-0": "memory",
- } {
- if err := os.WriteFile(filepath.Join(destination, name), []byte(content), 0o600); err != nil {
- return err
- }
- }
- return nil
- },
- resume: func(context.Context, *vm.VMRecord) error {
- steps = append(steps, "resume")
- backendState = vm.ObservedStateRunning
- return nil
- },
- }
-
- ready, err := rt.CreateRunningSnapshot(context.Background(), rec.ID, "running")
- if err != nil {
- t.Fatal(err)
- }
- if got := steps; len(got) != 3 || got[0] != "pause" || got[1] != "snapshot" || got[2] != "resume" {
- t.Fatalf("capture steps = %v", got)
- }
- manifest, err := snapshot.NewStore(store.RootDir()).LoadManifest(context.Background(), ready.ID)
- if err != nil {
- t.Fatal(err)
- }
- if manifest.SchemaVersion != "kumabox.snapshot.v2" || manifest.Type != "native" || manifest.Consistency != "crash" || manifest.Native == nil || len(manifest.Native.Files) != 3 {
- t.Fatalf("manifest = %+v", manifest)
- }
- if manifest.Backend == nil || manifest.Machine == nil || manifest.Machine.MemoryBytes != 512<<20 || manifest.Native.Files[0].SHA256 != "" {
- t.Fatalf("compatibility metadata = %+v", manifest)
- }
- if ready.Performance == nil || ready.Performance.TotalDurationMs < ready.Performance.PauseDurationMs {
- t.Fatalf("capture performance = %+v", ready.Performance)
- }
- if _, err := os.Stat(filepath.Join(ready.DataDir, "disks", "cow.raw")); err != nil {
- t.Fatal(err)
- }
- if raw, err := os.ReadFile(sourceDisk); err != nil || string(raw) != "writable" {
- t.Fatalf("source disk changed: %q, %v", raw, err)
- }
-}
-
-func TestCreateRunningSnapshotResumesAfterCaptureFailure(t *testing.T) {
- t.Parallel()
-
- rt, store, rec, _ := newRunningSnapshotRuntime(t)
- backendState := vm.ObservedStateRunning
- resumed := false
- rt.backend = backendFake{
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: backendState, CheckedAt: time.Now().UTC()}
- },
- pause: func(context.Context, *vm.VMRecord) error {
- backendState = vm.ObservedStatePaused
- return nil
- },
- snapshot: func(context.Context, *vm.VMRecord, string) error {
- return errors.New("injected capture failure")
- },
- resume: func(context.Context, *vm.VMRecord) error {
- resumed = true
- backendState = vm.ObservedStateRunning
- return nil
- },
- }
-
- if _, err := rt.CreateRunningSnapshot(context.Background(), rec.ID, "failed"); err == nil {
- t.Fatal("expected capture failure")
- }
- if !resumed {
- t.Fatal("VM was not resumed after capture failure")
- }
- if records, err := snapshot.NewStore(store.RootDir()).Scan(); err != nil || len(records) != 0 {
- t.Fatalf("failed capture leaked snapshot records: %+v, %v", records, err)
- }
-}
-
-func writeNativeSnapshotFixture(destination string, rec *vm.VMRecord) error {
- for name, content := range map[string]string{
- "config.json": fmt.Sprintf(`{"cpus":{"boot_vcpus":1},"memory":{"size":536870912},"disks":[{"path":%q,"readonly":false}],"vsock":{}}`, rec.StorageConfigs[0].Path),
- "state.json": "{}", "memory-range-0": "memory",
- } {
- if err := os.WriteFile(filepath.Join(destination, name), []byte(content), 0o600); err != nil {
- return err
- }
- }
- return nil
-}
-
-func newRunningSnapshotRuntime(t *testing.T) (*Runtime, *vm.Store, *vm.VMRecord, string) {
- t.Helper()
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- kernel := filepath.Join(dir, "vmlinuz")
- initrd := filepath.Join(dir, "initrd")
- if err := os.WriteFile(kernel, []byte("kernel"), 0o600); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(initrd, []byte("initrd"), 0o600); err != nil {
- t.Fatal(err)
- }
- rec, err := store.Create(vm.CreateRequest{
- Name: "source", Kernel: kernel, Initrd: initrd,
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"), Network: "none",
- StorageConfigs: []vm.StorageConfig{{
- ID: "cow", Role: vm.StorageRoleData, Format: "raw", Filesystem: "ext4",
- VirtualSizeBytes: int64(len("writable")),
- }},
- })
- if err != nil {
- t.Fatal(err)
- }
- sourceDisk := rec.StorageConfigs[0].Path
- if err := os.MkdirAll(filepath.Dir(sourceDisk), 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(sourceDisk, []byte("writable"), 0o600); err != nil {
- t.Fatal(err)
- }
- rec, err = store.MarkStarted(rec.ID, 1234, filepath.Join(rec.RunDir, "ch.sock"))
- if err != nil {
- t.Fatal(err)
- }
- return NewWithBackend(store, backendFake{}), store, rec, sourceDisk
-}
diff --git a/internal/vm/runtime/native_verify.go b/internal/vm/runtime/native_verify.go
deleted file mode 100644
index 7420326..0000000
--- a/internal/vm/runtime/native_verify.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/snapshot"
-)
-
-// VerifyNativeSnapshot performs a read-only restore preflight against an
-// existing VM. It shares the same compatibility path used by restore.
-func (r *Runtime) VerifyNativeSnapshot(ctx context.Context, snapshotRef, vmRef string) (*snapshot.Manifest, error) {
- rec, err := r.vmReader.Inspect(vmRef)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for snapshot verification: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- inspector, ok := r.backend.(backend.NativeHostInspector)
- if !ok {
- return nil, errors.New("BACKEND_OPERATION_UNSUPPORTED: backend does not expose native compatibility")
- }
- host, err := inspector.InspectNativeHost(ctx, rec)
- if err != nil {
- return nil, fmt.Errorf("inspect native compatibility: %w", err)
- }
- return r.data.Snapshots.VerifyNative(ctx, snapshotRef, snapshot.NativeVerifyTarget{VM: rec, Host: host})
-}
diff --git a/internal/vm/runtime/network_coordinator.go b/internal/vm/runtime/network_coordinator.go
deleted file mode 100644
index c4852da..0000000
--- a/internal/vm/runtime/network_coordinator.go
+++ /dev/null
@@ -1,214 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
-
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// networkCoordinator owns host-side network allocation, provider state, and
-// rollback. It embeds Runtime only to share the already-injected stores and
-// configuration; VM lifecycle code reaches network operations through this
-// boundary instead of implementing provider details itself.
-type networkCoordinator struct {
- *Runtime
-}
-
-func (r *Runtime) initNetworkCoordinator() {
- r.network = &networkCoordinator{Runtime: r}
- r.disk = &storageCoordinator{Runtime: r}
-}
-
-func (r *networkCoordinator) providerStore() (*kbnetwork.Store, error) {
- store, ok := r.data.Networks.(*kbnetwork.Store)
- if !ok {
- return nil, fmt.Errorf("network provider operations require a concrete network store")
- }
- return store, nil
-}
-
-type recoveredNetwork struct {
- config kbnetwork.Config
- previous *kbnetwork.Record
- hostRefAdded bool
-}
-
-const networkRollbackTimeout = 30 * time.Second
-
-func (r *networkCoordinator) ensureNetwork(ctx context.Context, rec *vm.VMRecord) error {
- if rec == nil || len(rec.NetworkConfigs) == 0 {
- return nil
- }
- records, err := r.data.Networks.List()
- if err != nil {
- return fmt.Errorf("list network provider records: %w", err)
- }
- providerRecords := make(map[string]kbnetwork.Record, len(records))
- for _, record := range records {
- providerRecords[record.ID] = record
- }
-
- recovered := make([]recoveredNetwork, 0, len(rec.NetworkConfigs))
- for index := range rec.NetworkConfigs {
- persisted := rec.NetworkConfigs[index]
- if err := verifyNetworkConfig(persisted); err == nil {
- if err := r.repairNetworkRecord(rec.ID, persisted, providerRecords[persisted.ID]); err != nil {
- return r.networkRecoveryError(ctx, rec, persisted, err, recovered)
- }
- continue
- } else if !errors.Is(err, kbnetwork.ErrNetworkUnavailable) {
- return r.networkRecoveryError(ctx, rec, persisted,
- fmt.Errorf("verify VM network: %w", err), recovered)
- }
-
- selection := networkSelectionForConfig(rec, persisted)
- allocation, hostRefAdded, err := r.attachNetworkConfigWithExisting(ctx, rec, selection, index, &persisted)
- if err != nil {
- return r.networkRecoveryError(ctx, rec, persisted, err, recovered)
- }
- if err := validateRecoveredNetwork(persisted, allocation.Config); err != nil {
- current := recoveredNetwork{config: allocation.Config, hostRefAdded: hostRefAdded}
- if previous, ok := providerRecords[persisted.ID]; ok {
- current.previous = &previous
- }
- recovered = append(recovered, current)
- return r.networkRecoveryError(ctx, rec, persisted, err, recovered)
- }
- current := recoveredNetwork{config: allocation.Config, hostRefAdded: hostRefAdded}
- if previous, ok := providerRecords[persisted.ID]; ok {
- current.previous = &previous
- }
- recovered = append(recovered, current)
- }
- return nil
-}
-
-func (r *networkCoordinator) repairNetworkRecord(
- vmID string,
- config kbnetwork.Config,
- existing kbnetwork.Record,
-) error {
- if existing.ID != "" && existing.VMID != vmID {
- return fmt.Errorf("%w: network record %s belongs to VM %s", kbnetwork.ErrNetworkConflict, config.ID, existing.VMID)
- }
- if existing.ID != "" && networkRecordMatchesConfig(existing, config) {
- return nil
- }
- now := time.Now().UTC()
- record := networkRecordFromConfig(vmID, config, now)
- if existing.ID != "" {
- record.CreatedAt = existing.CreatedAt
- record.Cleanup = existing.Cleanup
- }
- if err := r.data.Networks.UpsertRecord(record); err != nil {
- return fmt.Errorf("repair network provider record %s: %w", config.ID, err)
- }
- return nil
-}
-
-func (r *networkCoordinator) networkRecoveryError(
- ctx context.Context,
- rec *vm.VMRecord,
- config kbnetwork.Config,
- recoveryErr error,
- recovered []recoveredNetwork,
-) error {
- rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), networkRollbackTimeout)
- defer cancel()
- rollbackErr := r.rollbackRecoveredNetworks(rollbackCtx, rec, recovered)
- if rollbackErr != nil {
- recoveryErr = errors.Join(recoveryErr, fmt.Errorf("rollback recovered networks: %w", rollbackErr))
- }
- return fmt.Errorf("recover VM %s network %s: %w", rec.Name, config.ID, recoveryErr)
-}
-
-func (r *networkCoordinator) rollbackRecoveredNetworks(
- ctx context.Context,
- rec *vm.VMRecord,
- recovered []recoveredNetwork,
-) error {
- var rollbackErrs []error
- for i := len(recovered) - 1; i >= 0; i-- {
- item := recovered[i]
- if item.config.Backend == kbnetwork.ProviderCNI {
- err := deleteCNI(ctx, r.cfg.Runtime.RootDir, r.cfg.Network, kbnetwork.CNIDeleteRequest{
- VMID: rec.ID,
- Network: networkSelectionForConfig(rec, item.config),
- IfName: cniIfName(item.config),
- TAP: item.config.TAP,
- NetNSPath: item.config.NetnsPath,
- PreserveNetNS: true,
- })
- if err != nil {
- rollbackErrs = append(rollbackErrs, err)
- }
- } else if err := deleteHostTap(item.config.TAP); err != nil {
- rollbackErrs = append(rollbackErrs, err)
- }
- if err := r.data.Networks.DeleteRecord(item.config.ID); err != nil {
- rollbackErrs = append(rollbackErrs, err)
- }
- if item.hostRefAdded {
- if err := r.data.Networks.DecrementHostTapRef(1); err != nil {
- rollbackErrs = append(rollbackErrs, err)
- }
- }
- if item.previous != nil {
- if err := r.data.Networks.UpsertRecord(*item.previous); err != nil {
- rollbackErrs = append(rollbackErrs, err)
- }
- }
- }
- return errors.Join(rollbackErrs...)
-}
-
-func validateRecoveredNetwork(want, got kbnetwork.Config) error {
- if want.ID != got.ID || want.Backend != got.Backend || want.TAP != got.TAP ||
- !strings.EqualFold(want.MAC, got.MAC) || want.IfName != got.IfName ||
- want.NetnsPath != got.NetnsPath || want.NetworkName != got.NetworkName {
- return fmt.Errorf("%w: recovered network identity changed: want=%+v got=%+v",
- kbnetwork.ErrNetworkConflict, want, got)
- }
- if want.Network == nil && got.Network == nil {
- return nil
- }
- if want.Network == nil || got.Network == nil || want.Network.IP != got.Network.IP ||
- want.Network.Prefix != got.Network.Prefix || want.Network.Gateway != got.Network.Gateway {
- return fmt.Errorf("%w: recovered guest network changed: want=%+v got=%+v",
- kbnetwork.ErrNetworkConflict, want.Network, got.Network)
- }
- return nil
-}
-
-func networkRecordMatchesConfig(record kbnetwork.Record, config kbnetwork.Config) bool {
- want := networkRecordFromConfig(record.VMID, config, record.UpdatedAt)
- return record.ID == want.ID && record.Network == want.Network && record.Provider == want.Provider &&
- record.IfName == want.IfName && record.TAP == want.TAP && strings.EqualFold(record.MAC, want.MAC) &&
- record.NumQueues == want.NumQueues && record.QueueSize == want.QueueSize &&
- record.BridgeDev == want.BridgeDev && record.NetnsPath == want.NetnsPath &&
- strings.Join(record.IPs, ",") == strings.Join(want.IPs, ",") && record.Gateway == want.Gateway &&
- strings.Join(record.DNS, ",") == strings.Join(want.DNS, ",")
-}
-
-func networkRecordFromConfig(vmID string, networkConfig kbnetwork.Config, now time.Time) kbnetwork.Record {
- record := kbnetwork.Record{
- ID: networkConfig.ID, VMID: vmID, Network: networkConfig.NetworkName,
- Provider: networkConfig.Backend, IfName: networkConfig.IfName, TAP: networkConfig.TAP,
- MAC: networkConfig.MAC, NumQueues: networkConfig.NumQueues, QueueSize: networkConfig.QueueSize,
- BridgeDev: networkConfig.BridgeDev, NetnsPath: networkConfig.NetnsPath,
- CreatedAt: now, UpdatedAt: now,
- }
- if networkConfig.Network != nil {
- if networkConfig.Network.IP != "" {
- record.IPs = []string{fmt.Sprintf("%s/%d", networkConfig.Network.IP, networkConfig.Network.Prefix)}
- }
- record.Gateway = networkConfig.Network.Gateway
- record.DNS = append([]string(nil), networkConfig.Network.DNS...)
- }
- return record
-}
diff --git a/internal/vm/runtime/network_recovery_test.go b/internal/vm/runtime/network_recovery_test.go
deleted file mode 100644
index 71fcbf8..0000000
--- a/internal/vm/runtime/network_recovery_test.go
+++ /dev/null
@@ -1,272 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "path/filepath"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/config"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/state"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestStartVMRecoversPersistedNetworks(t *testing.T) {
- for _, metadataBackend := range []string{"json", "sqlite"} {
- t.Run(metadataBackend, func(t *testing.T) {
- rt, rec := newNetworkRecoveryRuntime(t, metadataBackend, 2)
- withVerifyNetworkConfig(t, func(kbnetwork.Config) error {
- return fmt.Errorf("%w: test network is missing", kbnetwork.ErrNetworkUnavailable)
- })
-
- var requests []kbnetwork.CNIAddRequest
- withAddCNI(t, func(
- _ context.Context,
- _ string,
- _ config.NetworkConfig,
- req kbnetwork.CNIAddRequest,
- ) (*kbnetwork.Allocation, error) {
- requests = append(requests, req)
- return allocationForExisting(t, rec.ID, req), nil
- })
-
- started, err := rt.StartVMContext(t.Context(), rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if started.State != vm.StateRunning {
- t.Fatalf("state = %s, want running", started.State)
- }
- if len(requests) != 2 {
- t.Fatalf("CNI recovery requests = %d, want 2", len(requests))
- }
- for index, req := range requests {
- if req.Existing == nil {
- t.Fatalf("request %d has no persisted network config", index)
- }
- want := rec.NetworkConfigs[index]
- if req.Existing.TAP != want.TAP || req.Existing.MAC != want.MAC ||
- req.Existing.Network.IP != want.Network.IP {
- t.Fatalf("request %d identity = %+v, want %+v", index, req.Existing, want)
- }
- }
- records, err := rt.data.Networks.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 2 {
- t.Fatalf("provider records = %d, want 2", len(records))
- }
- })
- }
-}
-
-func TestStartVMRollsBackPartialNetworkRecovery(t *testing.T) {
- rt, rec := newNetworkRecoveryRuntime(t, "json", 2)
- withVerifyNetworkConfig(t, func(kbnetwork.Config) error {
- return fmt.Errorf("%w: test network is missing", kbnetwork.ErrNetworkUnavailable)
- })
- recoveryErr := errors.New("second CNI recovery failed")
- withAddCNI(t, func(
- _ context.Context,
- _ string,
- _ config.NetworkConfig,
- req kbnetwork.CNIAddRequest,
- ) (*kbnetwork.Allocation, error) {
- if req.Index == 1 {
- return nil, recoveryErr
- }
- return allocationForExisting(t, rec.ID, req), nil
- })
- var deleted []kbnetwork.CNIDeleteRequest
- withDeleteCNI(t, func(
- _ context.Context,
- _ string,
- _ config.NetworkConfig,
- req kbnetwork.CNIDeleteRequest,
- ) error {
- deleted = append(deleted, req)
- return nil
- })
-
- _, err := rt.StartVMContext(t.Context(), rec.ID)
- if !errors.Is(err, recoveryErr) {
- t.Fatalf("start error = %v, want %v", err, recoveryErr)
- }
- if len(deleted) != 1 || deleted[0].IfName != "eth0" || !deleted[0].PreserveNetNS {
- t.Fatalf("rollback deletes = %+v", deleted)
- }
- records, err := rt.data.Networks.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 0 {
- t.Fatalf("provider records after rollback = %+v", records)
- }
-}
-
-func TestStartVMRepairsMissingNetworkProviderRecord(t *testing.T) {
- for _, metadataBackend := range []string{"json", "sqlite"} {
- t.Run(metadataBackend, func(t *testing.T) {
- rt, rec := newNetworkRecoveryRuntime(t, metadataBackend, 1)
- withVerifyNetworkConfig(t, func(kbnetwork.Config) error { return nil })
- withAddCNI(t, func(
- context.Context,
- string,
- config.NetworkConfig,
- kbnetwork.CNIAddRequest,
- ) (*kbnetwork.Allocation, error) {
- t.Fatal("healthy network must not be recreated")
- return nil, nil
- })
-
- if _, err := rt.StartVMContext(t.Context(), rec.ID); err != nil {
- t.Fatal(err)
- }
- records, err := rt.data.Networks.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 1 || records[0].ID != rec.NetworkConfigs[0].ID || records[0].VMID != rec.ID {
- t.Fatalf("repaired provider records = %+v", records)
- }
- })
- }
-}
-
-func TestConcurrentStartRecoversNetworkOnce(t *testing.T) {
- rt, rec := newNetworkRecoveryRuntime(t, "json", 1)
- var healthy atomic.Bool
- withVerifyNetworkConfig(t, func(kbnetwork.Config) error {
- if healthy.Load() {
- return nil
- }
- return fmt.Errorf("%w: test network is missing", kbnetwork.ErrNetworkUnavailable)
- })
- var recoveryCalls atomic.Int32
- withAddCNI(t, func(
- _ context.Context,
- _ string,
- _ config.NetworkConfig,
- req kbnetwork.CNIAddRequest,
- ) (*kbnetwork.Allocation, error) {
- recoveryCalls.Add(1)
- healthy.Store(true)
- return allocationForExisting(t, rec.ID, req), nil
- })
-
- var wg sync.WaitGroup
- errs := make(chan error, 2)
- for range 2 {
- wg.Add(1)
- go func() {
- defer wg.Done()
- _, err := rt.StartVMContext(t.Context(), rec.ID)
- errs <- err
- }()
- }
- wg.Wait()
- close(errs)
- for err := range errs {
- if err != nil {
- t.Fatal(err)
- }
- }
- if got := recoveryCalls.Load(); got != 1 {
- t.Fatalf("network recovery calls = %d, want 1", got)
- }
-}
-
-func newNetworkRecoveryRuntime(t *testing.T, metadataBackend string, interfaceCount int) (*Runtime, *vm.VMRecord) {
- t.Helper()
- rootDir := filepath.Join(t.TempDir(), "data")
- cfg := testRuntimeConfig(rootDir)
- cfg.Metadata.Backend = metadataBackend
- if metadataBackend == "sqlite" {
- cfg.Metadata.Path = filepath.Join(rootDir, "metadata", "kumabox.db")
- if err := state.InitSQLiteMetadata(t.Context(), cfg); err != nil {
- t.Fatal(err)
- }
- }
- stores, err := state.Open(cfg)
- if err != nil {
- t.Fatal(err)
- }
- if stores.Metadata != nil {
- t.Cleanup(func() {
- if err := stores.Metadata.Close(); err != nil {
- t.Errorf("close metadata: %v", err)
- }
- })
- }
- var nextPID atomic.Int32
- rt, err := NewWithBackendAndState(stores, backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- pid := nextPID.Add(1)
- return &backend.StartResult{PID: int(pid), APISocket: fmt.Sprintf("/tmp/ch-%d.sock", pid)}, nil
- },
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: vm.ObservedStateRunning, CheckedAt: time.Now().UTC()}
- },
- })
- if err != nil {
- t.Fatal(err)
- }
- rt.cfg = cfg
- rec, err := stores.VM.Create(vm.CreateRequest{
- Name: "network-recovery", RootDisk: "base.qcow2", Kernel: "vmlinuz", Initrd: "initrd.img",
- Network: "multi", RunDir: filepath.Join(rootDir, "run"), LogDir: filepath.Join(rootDir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- configs := make([]kbnetwork.Config, 0, interfaceCount)
- for index := range interfaceCount {
- configs = append(configs, recoveryNetworkConfig(rec.ID, index))
- }
- rec, err = stores.VM.SetNetworkConfigs(rec.ID, configs)
- if err != nil {
- t.Fatal(err)
- }
- return rt, rec
-}
-
-func recoveryNetworkConfig(vmID string, index int) kbnetwork.Config {
- return kbnetwork.Config{
- ID: kbnetwork.NetworkID(vmID, index), NetworkName: fmt.Sprintf("cni:net%d", index),
- TAP: fmt.Sprintf("kbtap%d", index), MAC: fmt.Sprintf("5a:00:00:00:00:%02x", index+1),
- NumQueues: 2, QueueSize: 512, Backend: kbnetwork.ProviderCNI,
- IfName: fmt.Sprintf("eth%d", index), NetnsPath: kbnetwork.NetNSPath(vmID),
- Network: &kbnetwork.GuestInfo{
- IP: fmt.Sprintf("10.90.0.%d", index+2), Gateway: "10.90.0.1", Prefix: 24,
- },
- }
-}
-
-func allocationForExisting(t *testing.T, vmID string, req kbnetwork.CNIAddRequest) *kbnetwork.Allocation {
- t.Helper()
- if req.Existing == nil {
- t.Fatal("recovery request is missing Existing config")
- }
- now := time.Now().UTC()
- return &kbnetwork.Allocation{
- Config: *req.Existing,
- Record: networkRecordFromConfig(vmID, *req.Existing, now),
- }
-}
-
-func withVerifyNetworkConfig(t *testing.T, fn func(kbnetwork.Config) error) {
- t.Helper()
- previous := verifyNetworkConfig
- verifyNetworkConfig = fn
- t.Cleanup(func() {
- verifyNetworkConfig = previous
- })
-}
diff --git a/internal/vm/runtime/network_resize.go b/internal/vm/runtime/network_resize.go
deleted file mode 100644
index 2664a28..0000000
--- a/internal/vm/runtime/network_resize.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/kumabox/kumabox/internal/backend"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func (r *Runtime) ResizeNetwork(ctx context.Context, ref string, target int) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- if target < 0 {
- return nil, fmt.Errorf("network count must not be negative")
- }
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for network resize: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
- controller, ok := r.backend.(backend.NetworkController)
- if !ok {
- return nil, fmt.Errorf("backend does not support network resize")
- }
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- if rec.State != vm.StateRunning {
- return nil, fmt.Errorf("VM must be running")
- }
- opID, err := r.beginOperation(ctx, operation.KindNetworkResize, rec.ID)
- if err != nil {
- return nil, err
- }
- opErr := r.resizeNetworksLocked(ctx, controller, rec, target)
- opErr = r.finishOperation(ctx, opID, opErr)
- updated, inspectErr := r.vmReader.Inspect(rec.ID)
- return updated, errors.Join(opErr, inspectErr)
-}
-
-func (r *Runtime) resizeNetworksLocked(ctx context.Context, controller backend.NetworkController, rec *vm.VMRecord, target int) error {
- current := len(rec.NetworkConfigs)
- if target == current {
- return nil
- }
- if target > current {
- selection := "default"
- if len(rec.Networks) > 0 {
- selection = rec.Networks[0]
- }
- added := make([]kbnetwork.Config, 0, target-current)
- for index := current; index < target; index++ {
- allocation, err := r.network.attachNetworkConfig(ctx, rec, selection, index)
- if err != nil {
- for _, previous := range added {
- _ = controller.DetachNetwork(ctx, rec, previous)
- }
- r.network.rollbackNetworkConfigs(rec, added)
- return err
- }
- if err := controller.AttachNetwork(ctx, rec, allocation.Config); err != nil {
- for _, previous := range added {
- _ = controller.DetachNetwork(ctx, rec, previous)
- }
- r.network.rollbackNetworkConfigs(rec, append(added, allocation.Config))
- return err
- }
- added = append(added, allocation.Config)
- }
- _, err := r.vmRecords.SetNetworkConfigs(rec.ID, append(append([]kbnetwork.Config(nil), rec.NetworkConfigs...), added...))
- return err
- }
- for index := current - 1; index >= target; index-- {
- network := rec.NetworkConfigs[index]
- if err := controller.DetachNetwork(ctx, rec, network); err != nil {
- return err
- }
- providerStore, err := r.network.providerStore()
- if err != nil {
- return err
- }
- if err := cleanupNetworkConfig(ctx, r.data.Networks, kbnetwork.NewAllocatorWithStore(providerStore, r.cfg.Network), r.cfg, rec, network, false); err != nil {
- return err
- }
- }
- _, err := r.vmRecords.SetNetworkConfigs(rec.ID, append([]kbnetwork.Config(nil), rec.NetworkConfigs[:target]...))
- return err
-}
diff --git a/internal/vm/runtime/operations.go b/internal/vm/runtime/operations.go
deleted file mode 100644
index b3cbc35..0000000
--- a/internal/vm/runtime/operations.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/operation"
-)
-
-func (r *Runtime) beginOperation(ctx context.Context, kind, resourceID string) (string, error) {
- return r.beginOperationWithRelated(ctx, kind, resourceID, "")
-}
-
-func (r *Runtime) beginOperationWithRelated(ctx context.Context, kind, resourceID, relatedID string) (string, error) {
- if r.operations == nil {
- return "", nil
- }
- id, err := operation.NewID()
- if err != nil {
- return "", err
- }
- if _, err := r.operations.BeginWithRelated(ctx, id, kind, resourceID, relatedID); err != nil {
- return "", err
- }
- return id, nil
-}
-
-func (r *Runtime) finishOperation(ctx context.Context, id string, operationErr error) error {
- if id == "" || r.operations == nil {
- return operationErr
- }
- if errors.Is(operationErr, fault.ErrInterrupted) {
- return operationErr
- }
- var recordErr error
- if operationErr != nil {
- _, recordErr = r.operations.Fail(ctx, id, operationErr.Error())
- } else {
- _, recordErr = r.operations.Complete(ctx, id)
- }
- if recordErr != nil {
- return errors.Join(operationErr, fmt.Errorf("record operation %s: %w", id, recordErr))
- }
- return operationErr
-}
-
-func (r *Runtime) bindOperationResource(ctx context.Context, operationID, resourceID string) error {
- if operationID == "" || r.operations == nil {
- return nil
- }
- _, err := r.operations.BindResource(ctx, operationID, resourceID)
- return err
-}
diff --git a/internal/vm/runtime/operations_test.go b/internal/vm/runtime/operations_test.go
deleted file mode 100644
index 5a07af5..0000000
--- a/internal/vm/runtime/operations_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package runtime
-
-import (
- "errors"
- "testing"
-
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/operation"
-)
-
-func TestFinishOperationPreservesInterruptedIntent(t *testing.T) {
- rt := &Runtime{operations: operation.New(t.TempDir())}
- id, err := rt.beginOperation(t.Context(), operation.KindVMDelete, "vm-1")
- if err != nil {
- t.Fatal(err)
- }
- interrupted := fault.Interrupt(fault.DeleteBeforeRecordDelete)
- if err := rt.finishOperation(t.Context(), id, interrupted); !errors.Is(err, fault.ErrInterrupted) {
- t.Fatalf("finishOperation() error = %v, want ErrInterrupted", err)
- }
- recoverable, err := rt.operations.Recoverable(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(recoverable) != 1 || recoverable[0].ID != id || recoverable[0].Status != operation.StatusRunning {
- t.Fatalf("recoverable operations = %+v", recoverable)
- }
-}
diff --git a/internal/vm/runtime/pci.go b/internal/vm/runtime/pci.go
deleted file mode 100644
index faa0ae9..0000000
--- a/internal/vm/runtime/pci.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func (r *Runtime) AttachPCIDevice(ctx context.Context, ref string, spec backend.PCIDeviceSpec) (*vm.VMRecord, error) {
- return r.changePCIDevice(ctx, ref, spec, true)
-}
-func (r *Runtime) changePCIDevice(ctx context.Context, ref string, spec backend.PCIDeviceSpec, attach bool) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, err
- }
- defer lock.Release() //nolint:errcheck
- controller, ok := r.backend.(backend.PCIDeviceController)
- if !ok {
- return nil, fmt.Errorf("backend does not support VFIO PCI")
- }
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- kind := operation.KindPCIAttach
- if !attach {
- kind = operation.KindPCIDetach
- }
- opID, err := r.beginOperation(ctx, kind, rec.ID)
- if err != nil {
- return nil, err
- }
- var opErr error
- if attach {
- var device backend.AttachedPCIDevice
- device, opErr = controller.AttachPCIDevice(ctx, rec, spec)
- if opErr == nil {
- devices := append([]vm.AttachedPCIDevice(nil), rec.AttachedPCIDevices...)
- devices = append(devices, vm.AttachedPCIDevice{ID: device.ID, PCI: device.PCI})
- _, opErr = r.vmRecords.SetAttachedPCIDevices(rec.ID, devices)
- }
- } else {
- opErr = controller.DetachPCIDevice(ctx, rec, spec.ID)
- if opErr == nil {
- devices := make([]vm.AttachedPCIDevice, 0)
- for _, device := range rec.AttachedPCIDevices {
- if device.ID != spec.ID {
- devices = append(devices, device)
- }
- }
- _, opErr = r.vmRecords.SetAttachedPCIDevices(rec.ID, devices)
- }
- }
- opErr = r.finishOperation(ctx, opID, opErr)
- updated, inspectErr := r.vmReader.Inspect(rec.ID)
- return updated, errors.Join(opErr, inspectErr)
-}
-func (r *Runtime) DetachPCIDevice(ctx context.Context, ref, id string) (*vm.VMRecord, error) {
- return r.changePCIDevice(ctx, ref, backend.PCIDeviceSpec{ID: id}, false)
-}
-func (r *Runtime) ListPCIDevices(ctx context.Context, ref string) ([]backend.AttachedPCIDevice, error) {
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- controller, ok := r.backend.(backend.PCIDeviceController)
- if !ok {
- return nil, fmt.Errorf("backend does not support VFIO PCI")
- }
- return controller.ListPCIDevices(ctx, rec)
-}
diff --git a/internal/vm/runtime/performance.go b/internal/vm/runtime/performance.go
deleted file mode 100644
index 431f914..0000000
--- a/internal/vm/runtime/performance.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package runtime
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "os"
- "runtime"
- "strings"
- "time"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-type lifecycleMetrics struct {
- started time.Time
- value vm.PerformanceMetrics
-}
-
-func newLifecycleMetrics(operation string, started time.Time, rec *vm.VMRecord) *lifecycleMetrics {
- metrics := &lifecycleMetrics{started: started, value: vm.PerformanceMetrics{
- Operation: operation,
- CommandStartedAt: started.UTC(),
- EnvironmentFingerprint: environmentFingerprint(rec),
- }}
- if rec != nil && rec.Image != nil {
- metrics.value.ImageDigest = rec.Image.Digest
- }
- return metrics
-}
-
-func (m *lifecycleMetrics) bindRecord(rec *vm.VMRecord) {
- if rec == nil {
- return
- }
- m.value.EnvironmentFingerprint = environmentFingerprint(rec)
- if rec.Image != nil {
- m.value.ImageDigest = rec.Image.Digest
- }
-}
-
-func phaseTime(at time.Time) *time.Time {
- value := at.UTC()
- return &value
-}
-
-func (m *lifecycleMetrics) markImageResolved(at time.Time) {
- m.value.ImageResolvedAt = phaseTime(at)
-}
-
-func (m *lifecycleMetrics) markStorageReady(at time.Time) {
- m.value.StorageReadyAt = phaseTime(at)
-}
-
-func (m *lifecycleMetrics) markNetworkReady(at time.Time) {
- m.value.NetworkReadyAt = phaseTime(at)
-}
-
-func (m *lifecycleMetrics) markVMMSpawned(at time.Time) {
- m.value.VMMSpawnedAt = phaseTime(at)
-}
-
-func (m *lifecycleMetrics) markVMMAPIReady(at time.Time) {
- m.value.VMMAPIReadyAt = phaseTime(at)
- m.value.VMMAPIReadyDurationMs = at.Sub(m.started).Milliseconds()
- m.value.ReadyDurationMs = m.value.VMMAPIReadyDurationMs
-}
-
-func (m *lifecycleMetrics) snapshot() vm.PerformanceMetrics {
- return m.value
-}
-
-func environmentFingerprint(rec *vm.VMRecord) string {
- input := struct {
- GOOS string
- GOARCH string
- GoVersion string
- Kernel string
- Backend string
- CPUs int
- Memory int64
- Network string
- }{
- GOOS: runtime.GOOS,
- GOARCH: runtime.GOARCH,
- GoVersion: runtime.Version(),
- Kernel: kernelRelease(),
- Backend: backendName(rec),
- }
- if rec != nil {
- input.CPUs = rec.CPUs
- input.Memory = rec.MemoryBytes
- input.Network = rec.Network
- }
- raw, _ := json.Marshal(input)
- sum := sha256.Sum256(raw)
- return "sha256:" + hex.EncodeToString(sum[:])
-}
-
-func backendName(rec *vm.VMRecord) string {
- if rec == nil {
- return ""
- }
- return rec.Backend
-}
-
-func kernelRelease() string {
- raw, err := os.ReadFile("/proc/sys/kernel/osrelease")
- if err != nil {
- return "unknown"
- }
- return strings.TrimSpace(string(raw))
-}
diff --git a/internal/vm/runtime/performance_test.go b/internal/vm/runtime/performance_test.go
deleted file mode 100644
index 04d38b0..0000000
--- a/internal/vm/runtime/performance_test.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package runtime
-
-import (
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestLifecycleMetricsUseVMMAPIAsLifecycleReadiness(t *testing.T) {
- started := time.Now()
- metrics := newLifecycleMetrics("run", started, &vm.VMRecord{
- Backend: "cloud-hypervisor",
- CPUs: 1,
- Image: &vm.ImageRef{Digest: "sha256:image"},
- })
- imageAt := started.Add(10 * time.Millisecond)
- storageAt := imageAt.Add(10 * time.Millisecond)
- networkAt := storageAt.Add(10 * time.Millisecond)
- vmmAt := networkAt.Add(10 * time.Millisecond)
- apiAt := vmmAt.Add(10 * time.Millisecond)
- metrics.markImageResolved(imageAt)
- metrics.markStorageReady(storageAt)
- metrics.markNetworkReady(networkAt)
- metrics.markVMMSpawned(vmmAt)
- metrics.markVMMAPIReady(apiAt)
-
- got := metrics.snapshot()
- if got.ImageDigest != "sha256:image" || got.EnvironmentFingerprint == "" {
- t.Fatalf("identity metrics = %+v", got)
- }
- if got.ReadyDurationMs < 40 {
- t.Fatalf("ready duration = %dms, want at least 40ms", got.ReadyDurationMs)
- }
- if got.VMMAPIReadyDurationMs < 40 || got.ReadyDurationMs != got.VMMAPIReadyDurationMs {
- t.Fatalf("phase durations = %+v", got)
- }
- if got.ImageResolvedAt == nil || got.VMMAPIReadyAt == nil {
- t.Fatalf("missing phase timestamps = %+v", got)
- }
- if !got.ImageResolvedAt.Before(*got.VMMAPIReadyAt) {
- t.Fatalf("phase timestamps are not ordered: %+v", got)
- }
-}
diff --git a/internal/vm/runtime/reconcile.go b/internal/vm/runtime/reconcile.go
deleted file mode 100644
index f9b4954..0000000
--- a/internal/vm/runtime/reconcile.go
+++ /dev/null
@@ -1,157 +0,0 @@
-package runtime
-
-import (
- "context"
- "fmt"
-
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// ReconcileOperations closes operation records left running by an interrupted
-// control-plane process. It only publishes success when durable state already
-// proves that the operation completed; it never retries a backend action.
-func (r *Runtime) ReconcileOperations(ctx context.Context) error {
- if r.operations == nil {
- return nil
- }
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return err
- }
- defer mutation.Release() //nolint:errcheck
- if err := r.operations.Reconcile(ctx, r.reconcileOperation); err != nil {
- return err
- }
- return r.ReconcileMetering(ctx)
-}
-
-func (r *Runtime) reconcileOperation(ctx context.Context, record operation.Record) error {
- switch record.Kind {
- case operation.KindVMStart:
- return r.requireVMState(record, vm.ObservedStateRunning)
- case operation.KindVMStop:
- return r.requireVMState(record, vm.ObservedStateStopped)
- case operation.KindVMPause:
- return r.requireVMState(record, vm.ObservedStatePaused)
- case operation.KindVMResume:
- return r.requireVMState(record, vm.ObservedStateRunning)
- case operation.KindVMDelete:
- if _, err := r.vmReader.Inspect(record.ResourceID); err != nil {
- return nil
- }
- return fmt.Errorf("VM_DELETE_INCOMPLETE: VM %s still exists", record.ResourceID)
- case operation.KindNetworkAttach:
- return r.requireNetworkAttached(record)
- case operation.KindNetworkCleanup:
- return r.requireNetworkClean(record)
- case operation.KindNetworkResize:
- return r.requireVMExists(record)
- case operation.KindDiskAttach, operation.KindDiskDetach:
- return r.requireVMExists(record)
- case operation.KindFilesystemAttach, operation.KindFilesystemDetach:
- return r.requireVMExists(record)
- case operation.KindPCIAttach, operation.KindPCIDetach:
- return r.requireVMExists(record)
- case operation.KindSnapshotCreateRun:
- return r.requireSnapshotForVM(ctx, record, record.RelatedID)
- case operation.KindSnapshotCloneNative:
- return r.requireVMRestore(record)
- case operation.KindSnapshotRestoreVM:
- return r.requireVMRestore(record)
- case operation.KindVMHibernate:
- return r.requireSnapshotForVM(ctx, record, record.RelatedID)
- case operation.KindSnapshotRestoreDisk:
- return r.requireVMExists(record)
- default:
- return fmt.Errorf("OPERATION_KIND_UNKNOWN: %s", record.Kind)
- }
-}
-
-func (r *Runtime) requireVMExists(record operation.Record) error {
- if _, err := r.vmReader.Inspect(record.ResourceID); err != nil {
- return fmt.Errorf("SNAPSHOT_RESTORE_INCOMPLETE: restored VM %s is unavailable: %w", record.ResourceID, err)
- }
- return nil
-}
-
-func (r *Runtime) requireVMRestore(record operation.Record) error {
- rec, err := r.vmReader.Inspect(record.ResourceID)
- if err != nil {
- return err
- }
- if rec.LastRestore != nil && rec.LastRestore.SnapshotID == record.RelatedID {
- return nil
- }
- if rec.SnapshotDependency != nil && rec.SnapshotDependency.SnapshotID == record.RelatedID {
- return nil
- }
- return fmt.Errorf("SNAPSHOT_RESTORE_INCOMPLETE: VM %s has no completed restore from %s", rec.ID, record.RelatedID)
-}
-
-func (r *Runtime) requireSnapshotForVM(ctx context.Context, record operation.Record, snapshotRef string) error {
- if r.data.Snapshots == nil {
- return fmt.Errorf("SNAPSHOT_RECONCILIATION_UNAVAILABLE: snapshot state is not configured")
- }
- snapshots, err := r.data.Snapshots.Scan()
- if err != nil {
- return err
- }
- for _, candidate := range snapshots {
- if candidate == nil || candidate.State != snapshot.StateReady {
- continue
- }
- if candidate.ID != snapshotRef && candidate.Name != snapshotRef {
- continue
- }
- manifest, err := r.data.Snapshots.PeekManifest(ctx, candidate.ID)
- if err != nil {
- return err
- }
- if manifest.Source.VMID == record.ResourceID {
- return nil
- }
- }
- return fmt.Errorf("SNAPSHOT_OPERATION_INCOMPLETE: no ready snapshot %s for VM %s", snapshotRef, record.ResourceID)
-}
-
-func (r *Runtime) requireNetworkAttached(record operation.Record) error {
- if r.data.Networks == nil {
- return fmt.Errorf("NETWORK_RECONCILIATION_UNAVAILABLE: network state is not configured")
- }
- result, err := r.data.Networks.Inspect(record.ResourceID)
- if err != nil {
- return err
- }
- if len(result.Interfaces) == 0 {
- return fmt.Errorf("NETWORK_ATTACH_INCOMPLETE: VM %s has no provider interface", record.ResourceID)
- }
- return nil
-}
-
-func (r *Runtime) requireNetworkClean(record operation.Record) error {
- if r.data.Networks == nil {
- return fmt.Errorf("NETWORK_RECONCILIATION_UNAVAILABLE: network state is not configured")
- }
- result, err := r.data.Networks.Inspect(record.ResourceID)
- if err != nil {
- return err
- }
- if len(result.Interfaces) != 0 {
- return fmt.Errorf("NETWORK_CLEANUP_INCOMPLETE: VM %s still has %d provider interface(s)", record.ResourceID, len(result.Interfaces))
- }
- return nil
-}
-
-func (r *Runtime) requireVMState(record operation.Record, expected vm.ObservedState) error {
- rec, err := r.vmReader.Inspect(record.ResourceID)
- if err != nil {
- return err
- }
- observed := r.applyObservation(rec)
- if observed.ObservedState != expected {
- return fmt.Errorf("VM_STATE_MISMATCH: VM %s is %s, want %s", observed.ID, observed.ObservedState, expected)
- }
- return nil
-}
diff --git a/internal/vm/runtime/references.go b/internal/vm/runtime/references.go
deleted file mode 100644
index 9438368..0000000
--- a/internal/vm/runtime/references.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package runtime
-
-import (
- "context"
-
- "github.com/kumabox/kumabox/internal/reference"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const (
- referenceKindVM = "vm"
- referenceKindImage = "image"
- referenceKindSnapshot = "snapshot"
-)
-
-func imageReferenceID(vmID string) string { return "vm-image:" + vmID }
-func snapshotImageReferenceID(snapshotID string) string {
- return "snapshot-image:" + snapshotID
-}
-func vmSnapshotReferenceID(vmID, snapshotID string) string {
- return "vm-snapshot:" + vmID + ":" + snapshotID
-}
-
-func (r *Runtime) recordVMImageReference(ctx context.Context, rec *vm.VMRecord) error {
- if r.data.References == nil || rec == nil || rec.Image == nil || rec.Image.ID == "" {
- return nil
- }
- return r.data.References.Upsert(ctx, reference.Record{
- ID: imageReferenceID(rec.ID), SourceKind: referenceKindVM, SourceID: rec.ID,
- TargetKind: referenceKindImage, TargetID: rec.Image.ID, Mode: "runtime",
- })
-}
-
-func (r *Runtime) recordSnapshotImageReference(ctx context.Context, snapshotID, imageID string) error {
- if r.data.References == nil || snapshotID == "" || imageID == "" {
- return nil
- }
- return r.data.References.Upsert(ctx, reference.Record{
- ID: snapshotImageReferenceID(snapshotID), SourceKind: referenceKindSnapshot, SourceID: snapshotID,
- TargetKind: referenceKindImage, TargetID: imageID, Mode: "base",
- })
-}
-
-func (r *Runtime) recordVMSnapshotReference(ctx context.Context, vmID, snapshotID string) error {
- if r.data.References == nil || vmID == "" || snapshotID == "" {
- return nil
- }
- return r.data.References.Upsert(ctx, reference.Record{
- ID: vmSnapshotReferenceID(vmID, snapshotID), SourceKind: referenceKindVM, SourceID: vmID,
- TargetKind: referenceKindSnapshot, TargetID: snapshotID, Mode: "restore",
- })
-}
-
-func (r *Runtime) removeVMSnapshotReference(ctx context.Context, vmID, snapshotID string) error {
- if r.data.References == nil || vmID == "" || snapshotID == "" {
- return nil
- }
- return r.data.References.Delete(ctx, vmSnapshotReferenceID(vmID, snapshotID))
-}
-
-func (r *Runtime) removeVMReferences(ctx context.Context, vmID string) error {
- if r.data.References == nil {
- return nil
- }
- return r.data.References.DeleteSource(ctx, referenceKindVM, vmID)
-}
diff --git a/internal/vm/runtime/restore.go b/internal/vm/runtime/restore.go
deleted file mode 100644
index 17a4836..0000000
--- a/internal/vm/runtime/restore.go
+++ /dev/null
@@ -1,263 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/disk"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/lock"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// RestoreOptions defines the new VM identity and runtime attachments.
-type RestoreOptions struct {
- Name string
- CPUs int
- MemoryBytes int64
- Networks []string
-}
-
-// RestoreSnapshot creates a new CREATED VM from portable writable disk state.
-func (r *Runtime) RestoreSnapshot(ctx context.Context, ref string, opts RestoreOptions) (result *vm.VMRecord, resultErr error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- if opts.Name == "" {
- return nil, errors.New("restore VM name must not be empty")
- }
- if opts.CPUs < 0 {
- return nil, errors.New("restore VM CPUs must be greater than zero")
- }
- if opts.CPUs == 0 {
- opts.CPUs = 1
- }
- if len(opts.Networks) == 0 {
- opts.Networks = []string{"none"}
- }
- operationID, err := r.beginOperationWithRelated(ctx, operation.KindSnapshotRestoreDisk, ref, ref)
- if err != nil {
- return nil, err
- }
- defer func() { resultErr = r.finishOperation(ctx, operationID, resultErr) }()
-
- snapshotStore := r.data.Snapshots
- snapshotRec, lease, err := snapshotStore.AcquireRead(ctx, ref)
- if err != nil {
- return nil, err
- }
- defer lease.Release() //nolint:errcheck
-
- manifest, err := snapshotStore.LoadManifest(ctx, snapshotRec.ID)
- if err != nil {
- return nil, err
- }
- image, err := r.data.Images.Inspect(manifest.Source.ImageID)
- if err != nil {
- return nil, fmt.Errorf("BASE_IMAGE_MISSING: resolve image %s: %w", manifest.Source.ImageID, err)
- }
- imageLock, err := r.resourceGuard.LockEntity(ctx, lock.EntityImage, image.ID)
- if err != nil {
- return nil, err
- }
- defer imageLock.Release() //nolint:errcheck
- image, err = r.data.Images.Inspect(image.ID)
- if err != nil {
- return nil, fmt.Errorf("BASE_IMAGE_MISSING: revalidate image %s: %w", manifest.Source.ImageID, err)
- }
- req, err := restoreCreateRequest(opts, image, manifest, r.cfg)
- if err != nil {
- return nil, err
- }
- rec, err := r.vmRecords.Create(req)
- if err != nil {
- return nil, err
- }
- if err := r.bindOperationResource(ctx, operationID, rec.ID); err != nil {
- _ = r.vmRecords.Delete(rec.ID)
- return nil, fmt.Errorf("bind restore operation resource: %w", err)
- }
- if err := r.recordVMImageReference(ctx, rec); err != nil {
- _ = r.vmRecords.Delete(rec.ID)
- return nil, fmt.Errorf("record restored image reference: %w", err)
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- _ = r.vmRecords.Delete(rec.ID)
- return nil, err
- }
- defer lock.Release() //nolint:errcheck
-
- ok := false
- defer func() {
- if !ok {
- r.network.rollbackNetwork(rec)
- _ = r.disk.removeManagedDirs(rec)
- _ = r.vmRecords.Delete(rec.ID)
- }
- }()
- if err := restoreWritableDisks(ctx, rec, snapshotRec.DataDir, manifest, r.qemuImg); err != nil {
- return nil, err
- }
- if err := r.network.attachNetwork(ctx, rec); err != nil {
- return nil, err
- }
- if updated, inspectErr := r.vmReader.Inspect(rec.ID); inspectErr == nil {
- rec = updated
- }
- if err := r.disk.prepare(ctx, rec); err != nil {
- return nil, err
- }
- if err := r.backend.RenderConfig(rec); err != nil {
- return nil, err
- }
- ok = true
- return r.applyObservation(rec), nil
-}
-
-func restoreCreateRequest(opts RestoreOptions, image *image.ImageRecord, manifest *snapshot.Manifest, cfg config.Config) (vm.CreateRequest, error) {
- if manifest.Base == nil || image.ID != manifest.Base.ImageID {
- return vm.CreateRequest{}, errors.New("BASE_IMAGE_MISMATCH: snapshot base does not match local image")
- }
- digest := image.RootDisk.SHA256
- if digest != "" && !strings.HasPrefix(digest, "sha256:") {
- digest = "sha256:" + digest
- }
- imageRef := &vm.ImageRef{ID: image.ID, Name: image.Name, RootDisk: image.RootDisk.Path, BootMode: image.Boot.Mode, Digest: manifest.Base.Digest}
- req := vm.CreateRequest{Name: opts.Name, CPUs: opts.CPUs, MemoryBytes: opts.MemoryBytes, Networks: opts.Networks, Image: imageRef, RunDir: cfg.Runtime.RunDir, LogDir: cfg.Runtime.LogDir}
- var configs []vm.StorageConfig
- switch manifest.Base.Family {
- case "cloudimg":
- if digest != manifest.Base.Digest || image.RootDisk.Format != vm.FormatQCOW2 {
- return vm.CreateRequest{}, errors.New("BASE_IMAGE_MISMATCH: local cloud image digest or format differs")
- }
- req.RootDisk = image.RootDisk.Path
- req.Firmware = image.Boot.Firmware
- case "oci":
- if image.OCI == nil {
- return vm.CreateRequest{}, errors.New("BASE_IMAGE_MISMATCH: local image has no OCI metadata")
- }
- manifestDigest := image.OCI.DigestRef
- if _, value, found := strings.Cut(manifestDigest, "@"); found {
- manifestDigest = value
- }
- if manifestDigest != manifest.Base.Digest {
- return vm.CreateRequest{}, errors.New("BASE_IMAGE_MISMATCH: local OCI manifest differs")
- }
- req.Kernel = image.Boot.Kernel
- req.Initrd = image.Boot.Initrd
- req.KernelCmdline = image.Boot.Cmdline
- imageRef.LayerDigests = append([]string(nil), manifest.Base.LayerDigests...)
- if len(image.OCI.Layers) != len(manifest.Base.LayerDigests) {
- return vm.CreateRequest{}, errors.New("BASE_IMAGE_MISMATCH: OCI layer count differs")
- }
- for i, layer := range image.OCI.Layers {
- if layer.Digest != manifest.Base.LayerDigests[i] || layer.EROFS == nil {
- return vm.CreateRequest{}, errors.New("BASE_IMAGE_MISMATCH: OCI layer digest differs")
- }
- serial := layer.Serial
- if serial == "" {
- serial = vm.LayerSerial(i)
- }
- configs = append(configs, vm.StorageConfig{ID: vm.LayerID(i), Role: vm.StorageRoleLayer, Path: layer.EROFS.Path, Readonly: true, Format: vm.FormatRaw, Filesystem: vm.FilesystemEROFS, Serial: serial, SourceLayer: layer.Digest, VirtualSizeBytes: layer.EROFS.SizeBytes})
- }
- default:
- return vm.CreateRequest{}, fmt.Errorf("unsupported snapshot base family %q", manifest.Base.Family)
- }
-
- diskIDs := make(map[string]struct{}, len(manifest.Disks))
- cowCount := 0
- for _, disk := range manifest.Disks {
- if disk.ID == "" || disk.ID == "." || disk.ID == ".." || strings.ContainsAny(disk.ID, `/\\`) {
- return vm.CreateRequest{}, fmt.Errorf("DISK_CONFIG_INVALID: unsafe snapshot disk id %q", disk.ID)
- }
- if _, exists := diskIDs[disk.ID]; exists {
- return vm.CreateRequest{}, fmt.Errorf("DISK_CONFIG_INVALID: duplicate snapshot disk id %q", disk.ID)
- }
- diskIDs[disk.ID] = struct{}{}
- role := vm.StorageRole(disk.Role)
- if role != vm.StorageRoleCOW && role != vm.StorageRoleData {
- return vm.CreateRequest{}, errors.New("DISK_CONFIG_INVALID: snapshot contains non-writable payload")
- }
- storageConfig := vm.StorageConfig{ID: disk.ID, Role: role, Format: disk.Format, Filesystem: disk.Filesystem, VirtualSizeBytes: disk.VirtualSizeBytes}
- if role == vm.StorageRoleCOW {
- cowCount++
- storageConfig.Base = &vm.StorageBase{Family: manifest.Base.Family, ImageID: image.ID, Digest: manifest.Base.Digest, Format: manifest.Base.Format, Path: image.RootDisk.Path, LayerDigests: append([]string(nil), manifest.Base.LayerDigests...)}
- if manifest.Base.Family == vm.BaseFamilyOCI {
- storageConfig.Serial = vm.StorageSerialCOW
- }
- }
- configs = append(configs, storageConfig)
- }
- if cowCount != 1 {
- return vm.CreateRequest{}, fmt.Errorf("DISK_CONFIG_INVALID: snapshot contains %d root COW disks, want 1", cowCount)
- }
- req.StorageConfigs = configs
- return req, nil
-}
-
-func restoreWritableDisks(ctx context.Context, rec *vm.VMRecord, snapshotDir string, manifest *snapshot.Manifest, qemuImg *disk.QEMUImg) error {
- byID := make(map[string]snapshot.DiskManifest, len(manifest.Disks))
- for _, disk := range manifest.Disks {
- byID[disk.ID] = disk
- }
- for _, target := range rec.StorageConfigs {
- role := target.EffectiveRole()
- if role != vm.StorageRoleCOW && role != vm.StorageRoleData {
- continue
- }
- manifestDisk, found := byID[target.ID]
- if !found {
- return fmt.Errorf("DISK_CONFIG_MISSING: snapshot disk %s", target.ID)
- }
- source, err := snapshotDiskPath(snapshotDir, manifestDisk.Path)
- if err != nil {
- return fmt.Errorf("resolve snapshot disk %s: %w", manifestDisk.ID, err)
- }
- if err := os.MkdirAll(filepath.Dir(target.Path), 0o700); err != nil {
- return fmt.Errorf("create restored disk directory: %w", err)
- }
- result, err := disk.CopyFile(ctx, source, target.Path)
- if err != nil {
- return fmt.Errorf("restore disk %s: %w", manifestDisk.ID, err)
- }
- if result.SHA256 != manifestDisk.SHA256 {
- return fmt.Errorf("CHECKSUM_MISMATCH: restored disk %s", manifestDisk.ID)
- }
- if target.Base != nil && target.Base.Family == "cloudimg" {
- if err := qemuImg.RebaseOverlay(ctx, target.Path, target.Base.Path, target.Base.Format); err != nil {
- return fmt.Errorf("rebase restored disk %s: %w", manifestDisk.ID, err)
- }
- }
- }
- return nil
-}
-
-func snapshotDiskPath(snapshotDir, relative string) (string, error) {
- if relative == "" || filepath.IsAbs(relative) {
- return "", errors.New("snapshot disk path must be relative")
- }
- clean := filepath.Clean(filepath.FromSlash(relative))
- if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
- return "", errors.New("snapshot disk path escapes payload directory")
- }
- path := filepath.Join(snapshotDir, clean)
- info, err := os.Lstat(path)
- if err != nil {
- return "", fmt.Errorf("stat snapshot disk: %w", err)
- }
- if !info.Mode().IsRegular() {
- return "", errors.New("snapshot disk is not a regular file")
- }
- return path, nil
-}
diff --git a/internal/vm/runtime/restore_mode.go b/internal/vm/runtime/restore_mode.go
deleted file mode 100644
index 8f94c00..0000000
--- a/internal/vm/runtime/restore_mode.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package runtime
-
-import (
- "fmt"
- "slices"
-
- "github.com/kumabox/kumabox/internal/backend"
-)
-
-type RestoreMode string
-
-const (
- RestoreModeCopy RestoreMode = "copy"
- RestoreModeOnDemand RestoreMode = "ondemand"
- RestoreModeMmap RestoreMode = "mmap"
-)
-
-func normalizeRestoreMode(mode RestoreMode) (RestoreMode, error) {
- if mode == "" {
- return RestoreModeCopy, nil
- }
- switch mode {
- case RestoreModeCopy, RestoreModeOnDemand, RestoreModeMmap:
- return mode, nil
- default:
- return "", fmt.Errorf("RESTORE_MODE_UNSUPPORTED: %s", mode)
- }
-}
-
-func requireRestoreMode(host backend.NativeHost, mode RestoreMode) error {
- if mode == RestoreModeCopy {
- return nil
- }
- if slices.Contains(host.RestoreModes, string(mode)) {
- return nil
- }
- return fmt.Errorf(
- "RESTORE_MODE_UNSUPPORTED: cloud-hypervisor %s does not advertise %s memory restore",
- host.BackendVersion,
- mode,
- )
-}
-
-func restoreModePinsSnapshot(mode RestoreMode) bool {
- return mode == RestoreModeOnDemand || mode == RestoreModeMmap
-}
diff --git a/internal/vm/runtime/restore_mode_test.go b/internal/vm/runtime/restore_mode_test.go
deleted file mode 100644
index 0f764f3..0000000
--- a/internal/vm/runtime/restore_mode_test.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package runtime
-
-import (
- "testing"
-
- "github.com/kumabox/kumabox/internal/backend"
-)
-
-func TestRequireRestoreModeFailsClosed(t *testing.T) {
- host := backend.NativeHost{BackendVersion: "51.0.0", RestoreModes: []string{"copy", "mmap"}}
- if err := requireRestoreMode(host, "copy"); err != nil {
- t.Fatal(err)
- }
- if err := requireRestoreMode(host, "mmap"); err != nil {
- t.Fatal(err)
- }
- if err := requireRestoreMode(host, "ondemand"); err == nil {
- t.Fatal("unadvertised mode was accepted")
- }
-}
diff --git a/internal/vm/runtime/restore_test.go b/internal/vm/runtime/restore_test.go
deleted file mode 100644
index 1bae470..0000000
--- a/internal/vm/runtime/restore_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-package runtime
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/image"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestRestoreSnapshotCreatesIndependentOCIVM(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- layerPath := filepath.Join(dir, "layer.erofs")
- if err := os.WriteFile(layerPath, []byte("layer"), 0o600); err != nil {
- t.Fatal(err)
- }
-
- const manifestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- const layerDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
- image, err := image.New(rootDir).Create(image.CreateRequest{
- Name: "restore-image",
- Boot: image.Boot{Mode: "direct", Kernel: filepath.Join(dir, "vmlinuz"), Initrd: filepath.Join(dir, "initrd"), Cmdline: "console=ttyS0"},
- OCI: &image.OCI{
- DigestRef: "example.invalid/image@" + manifestDigest,
- Layers: []image.OCILayer{{
- Index: 0, Digest: layerDigest,
- EROFS: &image.EROFSLayer{Path: layerPath, Filesystem: "erofs", SizeBytes: 5, SourceLayer: layerDigest},
- }},
- BuiltAt: time.Now().UTC(),
- },
- })
- if err != nil {
- t.Fatal(err)
- }
-
- payload := make([]byte, 4096)
- copy(payload, "restored writable state")
- sum := sha256.Sum256(payload)
- snapshotStore := snapshot.NewStore(rootDir)
- build, err := snapshotStore.Reserve(context.Background(), "restore-source")
- if err != nil {
- t.Fatal(err)
- }
- staging := build.Record().StagingDir
- if err := os.MkdirAll(filepath.Join(staging, "disks"), 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(staging, "disks", "cow.ext4"), payload, 0o600); err != nil {
- t.Fatal(err)
- }
- manifest := snapshot.Manifest{
- SchemaVersion: "kumabox.snapshot.v1", ID: build.Record().ID, Name: "restore-source",
- Type: "disk", Consistency: "stopped-disk",
- Source: snapshot.Source{VMID: "source-vm", VMName: "source", ImageID: image.ID, ImageDigest: manifestDigest},
- Base: &snapshot.Base{Family: "oci", ImageID: image.ID, Digest: manifestDigest, LayerDigests: []string{layerDigest}},
- Disks: []snapshot.DiskManifest{{
- ID: "cow", Role: "cow", Path: "disks/cow.ext4", Format: "raw", Filesystem: "ext4",
- VirtualSizeBytes: int64(len(payload)), AllocatedSizeBytes: int64(len(payload)), SHA256: hex.EncodeToString(sum[:]), CopyStrategy: "stream-copy",
- }},
- CreatedAt: time.Now().UTC(),
- }
- raw, err := json.Marshal(manifest)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(staging, "snapshot.json"), raw, 0o600); err != nil {
- t.Fatal(err)
- }
- ready, err := build.Finalize(int64(len(payload)))
- if err != nil {
- t.Fatal(err)
- }
-
- cfg := config.Default()
- cfg.Runtime.RootDir = rootDir
- cfg.Runtime.RunDir = filepath.Join(dir, "run")
- cfg.Runtime.LogDir = filepath.Join(dir, "log")
- rt := NewWithBackend(vm.New(rootDir), backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = cfg
-
- restored, err := rt.RestoreSnapshot(context.Background(), ready.ID, RestoreOptions{Name: "restored", CPUs: 2})
- if err != nil {
- t.Fatal(err)
- }
- if restored.State != vm.StateCreated || restored.ID == manifest.Source.VMID {
- t.Fatalf("restored identity/state = %s/%s", restored.ID, restored.State)
- }
- if restored.CPUs != 2 || restored.Network != "none" {
- t.Fatalf("restored runtime options = cpus %d network %s", restored.CPUs, restored.Network)
- }
- if len(restored.StorageConfigs) != 2 {
- t.Fatalf("storage count = %d, want layer and COW", len(restored.StorageConfigs))
- }
- got, err := os.ReadFile(restored.StorageConfigs[1].Path)
- if err != nil {
- t.Fatal(err)
- }
- if string(got) != string(payload) {
- t.Fatal("restored COW payload differs from snapshot")
- }
- wantOwner := filepath.Join(rootDir, "storage", "vms", restored.ID)
- if filepath.Dir(restored.StorageConfigs[1].Path) != wantOwner {
- t.Fatalf("restored COW path = %s, want owner %s", restored.StorageConfigs[1].Path, wantOwner)
- }
-}
-
-func TestSnapshotDiskPathRejectsTraversalAndSymlink(t *testing.T) {
- dir := t.TempDir()
- regular := filepath.Join(dir, "disk.raw")
- if err := os.WriteFile(regular, []byte("disk"), 0o600); err != nil {
- t.Fatal(err)
- }
- if _, err := snapshotDiskPath(dir, "disk.raw"); err != nil {
- t.Fatalf("regular disk rejected: %v", err)
- }
- if _, err := snapshotDiskPath(dir, "../disk.raw"); err == nil {
- t.Fatal("traversal path was accepted")
- }
- if err := os.Symlink(regular, filepath.Join(dir, "link.raw")); err != nil {
- t.Fatal(err)
- }
- if _, err := snapshotDiskPath(dir, "link.raw"); err == nil {
- t.Fatal("symlink disk was accepted")
- }
-}
diff --git a/internal/vm/runtime/runtime.go b/internal/vm/runtime/runtime.go
deleted file mode 100644
index 4687866..0000000
--- a/internal/vm/runtime/runtime.go
+++ /dev/null
@@ -1,948 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "os/exec"
- "path/filepath"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/backend/cloudhypervisor"
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/disk"
- "github.com/kumabox/kumabox/internal/fault"
- "github.com/kumabox/kumabox/internal/lock"
- "github.com/kumabox/kumabox/internal/metering"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/snapshot"
- "github.com/kumabox/kumabox/internal/state"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const forcedStopTimeout = 5 * time.Second
-
-const defaultQEMUImgBinary = "qemu-img"
-
-// Runtime coordinates VM lifecycle operations across the store, backend, and
-// host-side providers.
-//
-// KumaBox is daemonless, so each command must reconcile persisted intent with
-// the current backend process state before making lifecycle decisions.
-type Runtime struct {
- vmReader state.VMReader
- vmRecords state.VMRecords
- vmUpdater state.VMUpdater
- vmRestore state.VMRestore
- operations state.OperationState
- data state.Set
- backend backend.Lifecycle
- cfg config.Config
- vmLocks *lock.Locker
- resourceGuard *lock.Guard
- qemuImg *disk.QEMUImg
- network *networkCoordinator
- disk *storageCoordinator
-}
-
-// CreateStoppedSnapshot captures managed writable disks while holding the VM
-// operation lock for the full consistency boundary.
-func (r *Runtime) CreateStoppedSnapshot(ctx context.Context, ref, name string) (*snapshot.Record, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for snapshot: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- observed := r.applyObservation(rec)
- if observed.ObservedState == vm.ObservedStateRunning || observed.State == vm.StateRunning {
- return nil, fmt.Errorf("VM_RUNNING: VM %s must be stopped before snapshot", rec.Name)
- }
- if observed.State != vm.StateStopped {
- return nil, fmt.Errorf("VM_NOT_STOPPED: VM %s state is %s", rec.Name, observed.State)
- }
- build, err := r.data.Snapshots.Reserve(ctx, name)
- if err != nil {
- return nil, err
- }
- defer build.Abort() //nolint:errcheck
- _, sizeBytes, err := snapshot.CaptureStopped(ctx, build, observed)
- if err != nil {
- return nil, err
- }
- ready, err := build.FinalizeContext(ctx, sizeBytes)
- if err != nil {
- return nil, err
- }
- if rec.Image != nil {
- if err := r.recordSnapshotImageReference(ctx, ready.ID, rec.Image.ID); err != nil {
- _, _ = r.data.Snapshots.Remove(ready.ID)
- return nil, fmt.Errorf("record snapshot image reference: %w", err)
- }
- }
- return ready, nil
-}
-
-var deleteHostTap = kbnetwork.DeleteHostTap
-var addCNI = kbnetwork.AddCNI
-var deleteCNI = kbnetwork.DeleteCNI
-var deleteCNINetNS = kbnetwork.DeleteCNINetNS
-var verifyNetworkConfig = kbnetwork.VerifyConfig
-var mkfsExt4 = func(path string) ([]byte, error) {
- return exec.Command("mkfs.ext4", "-F", path).CombinedOutput() //nolint:gosec
-}
-
-// New creates a Runtime backed by the configured Cloud Hypervisor backend.
-func New(cfg config.Config) (*Runtime, error) {
- data, err := state.Open(cfg)
- if err != nil {
- return nil, fmt.Errorf("open configured state: %w", err)
- }
- rt, err := NewWithBackendAndState(data, cloudhypervisor.NewBackend(cfg))
- if err != nil {
- return nil, err
- }
- rt.cfg = cfg
- rt.qemuImg = disk.NewQEMUImg(cfg.Storage.QEMUImgBinary)
- return rt, nil
-}
-
-// NewWithBackend creates a Runtime with an injected VM store and backend.
-func NewWithBackend(vmState state.VMState, vmBackend backend.Lifecycle) *Runtime {
- data := openStateWithVM(vmState.RootDir(), vmState)
- rt := &Runtime{
- vmReader: vmState,
- vmRecords: vmState,
- vmUpdater: vmState,
- vmRestore: vmState,
- operations: data.Operations,
- data: data,
- backend: vmBackend,
- vmLocks: lock.NewLocker(filepath.Join(vmState.RootDir(), "locks", "vms")),
- resourceGuard: data.Guard,
- qemuImg: disk.NewQEMUImg(defaultQEMUImgBinary),
- }
- rt.initNetworkCoordinator()
- return rt
-}
-
-// NewWithBackendAndState creates a Runtime with explicit durable state.
-func NewWithBackendAndState(data state.Set, vmBackend backend.Lifecycle) (*Runtime, error) {
- if data.VM == nil {
- return nil, errors.New("runtime state must include VM records")
- }
- if data.Guard == nil {
- data.Guard = lock.NewGuard(data.VM.RootDir())
- }
- rt := &Runtime{
- vmReader: data.VM,
- vmRecords: data.VM,
- vmUpdater: data.VM,
- vmRestore: data.VM,
- operations: data.Operations,
- data: data,
- backend: vmBackend,
- vmLocks: lock.NewLocker(filepath.Join(data.VM.RootDir(), "locks", "vms")),
- resourceGuard: data.Guard,
- qemuImg: disk.NewQEMUImg(defaultQEMUImgBinary),
- }
- rt.initNetworkCoordinator()
- return rt, nil
-}
-
-// CreateVM creates a VM record and renders its backend configuration.
-//
-// Network allocation is part of creation because the rendered VMM config needs
-// stable tap/MAC/IP values. If rendering fails, runtime rolls back any provider
-// resources before removing the VM record.
-func (r *Runtime) CreateVM(req vm.CreateRequest) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(context.Background())
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
- return r.createVMContext(context.Background(), req, nil)
-}
-
-func (r *Runtime) createVMContext(ctx context.Context, req vm.CreateRequest, metrics *lifecycleMetrics) (*vm.VMRecord, error) {
- if req.Image != nil && req.Image.ID != "" {
- imageLock, err := r.resourceGuard.LockEntity(ctx, lock.EntityImage, req.Image.ID)
- if err != nil {
- return nil, err
- }
- defer imageLock.Release() //nolint:errcheck
- }
- rec, err := r.vmRecords.Create(req)
- if err != nil {
- return nil, err
- }
- if metrics != nil {
- metrics.bindRecord(rec)
- metrics.markImageResolved(time.Now())
- }
- if err := r.network.attachNetwork(ctx, rec); err != nil {
- _ = r.vmRecords.Delete(rec.ID)
- return nil, err
- }
- if updated, err := r.vmReader.Inspect(rec.ID); err == nil {
- rec = updated
- }
- if metrics != nil {
- metrics.bindRecord(rec)
- metrics.markNetworkReady(time.Now())
- }
- if err := r.disk.prepare(ctx, rec); err != nil {
- r.network.rollbackNetwork(rec)
- _ = r.disk.removeManagedDirs(rec)
- _ = r.vmRecords.Delete(rec.ID)
- return nil, err
- }
- if metrics != nil {
- metrics.markStorageReady(time.Now())
- }
- if err := r.backend.RenderConfig(rec); err != nil {
- r.network.rollbackNetwork(rec)
- _ = r.disk.removeManagedDirs(rec)
- _ = r.vmRecords.Delete(rec.ID)
- return nil, err
- }
- if err := r.recordVMImageReference(ctx, rec); err != nil {
- r.network.rollbackNetwork(rec)
- _ = r.disk.removeManagedDirs(rec)
- _ = r.vmRecords.Delete(rec.ID)
- return nil, fmt.Errorf("record VM image reference: %w", err)
- }
- return r.applyObservation(rec), nil
-}
-
-// StartVM starts an existing VM and records backend runtime details.
-//
-// The backend config is rendered again immediately before start. That keeps the
-// run directory recoverable after tmp cleanup and allows later phases to update
-// generated metadata without mutating durable VM intent.
-func (r *Runtime) StartVM(ref string) (*vm.VMRecord, error) {
- return r.StartVMContext(context.Background(), ref)
-}
-
-// StartVMContext starts an existing VM while holding its cross-process
-// operation lock. Waiting for the lock observes ctx cancellation.
-func (r *Runtime) StartVMContext(ctx context.Context, ref string) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- commandStarted := time.Now()
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- operationID, err := r.beginOperation(ctx, operation.KindVMStart, rec.ID)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, r.finishOperation(ctx, operationID, fmt.Errorf("lock VM %s for start: %w", rec.ID, err))
- }
- defer lock.Release() //nolint:errcheck
- metrics := newLifecycleMetrics("start", commandStarted, rec)
- metrics.markImageResolved(commandStarted)
- result, startErr := r.startVMLocked(ctx, rec.ID, metrics)
- return result, r.finishOperation(ctx, operationID, startErr)
-}
-
-func (r *Runtime) startVMLocked(ctx context.Context, ref string, metrics *lifecycleMetrics) (*vm.VMRecord, error) {
- if err := ctx.Err(); err != nil {
- return nil, fmt.Errorf("start VM: %w", err)
- }
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- if rec.Restore != nil {
- return nil, fmt.Errorf("VM_RESTORE_DIRTY: VM %s has an incomplete restore from snapshot %s; retry restore or delete the VM", rec.Name, rec.Restore.SnapshotID)
- }
- if rec.Hibernate != nil {
- return nil, fmt.Errorf("VM_HIBERNATED: VM %s must be restored from snapshot %s", rec.Name, rec.Hibernate.SnapshotID)
- }
- startReason := metering.ReasonBoot
- if rec.StartedAt != nil {
- startReason = metering.ReasonRestart
- }
- if metrics == nil {
- metrics = newLifecycleMetrics("start", time.Now(), rec)
- }
- metrics.bindRecord(rec)
- if err := r.network.ensureNetwork(ctx, rec); err != nil {
- if _, markErr := r.vmUpdater.SetError(rec.ID, err.Error()); markErr != nil {
- return nil, markErr
- }
- return nil, err
- }
- metrics.markNetworkReady(time.Now())
- if err := r.disk.prepare(ctx, rec); err != nil {
- if _, markErr := r.vmUpdater.SetError(rec.ID, err.Error()); markErr != nil {
- return nil, markErr
- }
- return nil, err
- }
- metrics.markStorageReady(time.Now())
-
- if err := r.backend.RenderConfig(rec); err != nil {
- if _, markErr := r.vmUpdater.SetError(rec.ID, err.Error()); markErr != nil {
- return nil, markErr
- }
- return nil, err
- }
- if err := ctx.Err(); err != nil {
- return nil, fmt.Errorf("start VM: %w", err)
- }
-
- metrics.markVMMSpawned(time.Now())
- result, err := r.backend.StartVM(rec)
- if err != nil {
- if _, markErr := r.vmUpdater.SetError(rec.ID, err.Error()); markErr != nil {
- return nil, markErr
- }
- return nil, err
- }
- metrics.markVMMAPIReady(time.Now())
- started, err := r.vmUpdater.MarkStarted(rec.ID, result.PID, result.APISocket)
- if err != nil {
- return nil, err
- }
- // A responsive VMM API is the lifecycle boundary. Guest-agent capability
- // is checked independently by agent and exec commands.
- updated, err := r.vmUpdater.UpdatePerformance(started.ID, metrics.snapshot())
- if err != nil {
- return nil, err
- }
- r.recordComputeStart(ctx, updated, startReason)
- return r.applyObservation(updated), nil
-}
-
-// RunVM creates and starts a VM.
-func (r *Runtime) RunVM(req vm.CreateRequest) (*vm.VMRecord, error) {
- return r.RunVMContext(context.Background(), req)
-}
-
-// RunVMContext creates and starts a VM with cancellation propagated to start.
-func (r *Runtime) RunVMContext(ctx context.Context, req vm.CreateRequest) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- metrics := newLifecycleMetrics("run", time.Now(), nil)
- rec, err := r.createVMContext(ctx, req, metrics)
- if err != nil {
- return nil, err
- }
- started, err := r.startVMWithMetrics(ctx, rec.ID, metrics)
- if err != nil {
- return nil, err
- }
- return started, nil
-}
-
-func (r *Runtime) startVMWithMetrics(ctx context.Context, ref string, metrics *lifecycleMetrics) (*vm.VMRecord, error) {
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for start: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
- return r.startVMLocked(ctx, rec.ID, metrics)
-}
-
-// StopVM stops a running VM and updates its persisted state.
-//
-// Stop does not release network leases, delete tap devices, or remove provider
-// records. Those resources are part of the VM's restartable identity and are
-// released only by DeleteVM.
-func (r *Runtime) StopVM(ref string, opts backend.StopOptions) (*vm.VMRecord, error) {
- return r.StopVMContext(context.Background(), ref, opts)
-}
-
-// StopVMContext stops a VM while holding its cross-process operation lock.
-func (r *Runtime) StopVMContext(ctx context.Context, ref string, opts backend.StopOptions) (*vm.VMRecord, error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- operationID, err := r.beginOperation(ctx, operation.KindVMStop, rec.ID)
- if err != nil {
- return nil, err
- }
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, r.finishOperation(ctx, operationID, fmt.Errorf("lock VM %s for stop: %w", rec.ID, err))
- }
- defer lock.Release() //nolint:errcheck
- result, stopErr := r.stopVMLocked(ctx, rec.ID, opts, metering.ReasonStopUser)
- return result, r.finishOperation(ctx, operationID, stopErr)
-}
-
-func (r *Runtime) stopVMLocked(ctx context.Context, ref string, opts backend.StopOptions, reason metering.Reason) (*vm.VMRecord, error) {
- if err := ctx.Err(); err != nil {
- return nil, fmt.Errorf("stop VM: %w", err)
- }
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- observed := r.applyObservation(rec)
- computeOpen := observed.StartedAt != nil && observed.StoppedAt == nil
- if (observed.State == vm.StateRunning || observed.State == vm.StatePaused) &&
- observed.ObservedState != vm.ObservedStateRunning && observed.ObservedState != vm.ObservedStatePaused {
- if err := r.vmUpdater.UpdateStates([]string{observed.ID}, vm.StateStopped); err != nil {
- return nil, err
- }
- stopped, err := r.vmReader.Inspect(observed.ID)
- if err != nil {
- return nil, err
- }
- if computeOpen {
- r.recordComputeStop(ctx, stopped, metering.ReasonStopCrash)
- }
- _ = writeVMEvent(stopped, "backend.stop.completed", vm.Observation{
- State: vm.ObservedStateStopped,
- Reason: "VM was already not running",
- CheckedAt: time.Now().UTC(),
- })
- return r.applyObservation(stopped), nil
- }
- if observed.ObservedState != vm.ObservedStateRunning && observed.ObservedState != vm.ObservedStatePaused {
- return observed, nil
- }
-
- if _, err := r.backend.StopVM(observed, opts); err != nil {
- if _, markErr := r.vmUpdater.SetError(observed.ID, err.Error()); markErr != nil {
- return nil, markErr
- }
- return nil, err
- }
- if err := r.vmUpdater.UpdateStates([]string{observed.ID}, vm.StateStopped); err != nil {
- return nil, err
- }
- stopped, err := r.vmReader.Inspect(observed.ID)
- if err != nil {
- return nil, err
- }
- if computeOpen {
- r.recordComputeStop(ctx, stopped, reason)
- }
- _ = writeVMEvent(stopped, "backend.stop.completed", vm.Observation{
- State: vm.ObservedStateStopped,
- Reason: "VM stopped",
- CheckedAt: time.Now().UTC(),
- })
- return r.applyObservation(stopped), nil
-}
-
-// DeleteVM removes a VM record and KumaBox-managed state.
-//
-// A running VM must be deleted with force so runtime can stop the backend first.
-// Network cleanup is performed before deleting the VM record; if cleanup fails,
-// the record remains available for inspect/logs/retry and the provider record is
-// marked cleanup-pending.
-func (r *Runtime) DeleteVM(ref string, force bool) (*vm.VMRecord, error) {
- return r.DeleteVMContext(context.Background(), ref, force)
-}
-
-// DeleteVMContext deletes a VM while serializing stop and cleanup under one
-// operation lock.
-func (r *Runtime) DeleteVMContext(ctx context.Context, ref string, force bool) (result *vm.VMRecord, resultErr error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- operationID, err := r.beginOperation(ctx, operation.KindVMDelete, rec.ID)
- if err != nil {
- return nil, err
- }
- defer func() { resultErr = r.finishOperation(ctx, operationID, resultErr) }()
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for delete: %w", rec.ID, err)
- }
- defer lock.Release() //nolint:errcheck
-
- if err := ctx.Err(); err != nil {
- return nil, fmt.Errorf("delete VM: %w", err)
- }
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- observed := r.applyObservation(rec)
- deleteComputeOpen := observed.StartedAt != nil && observed.StoppedAt == nil
- if observed.ObservedState == vm.ObservedStateRunning || observed.ObservedState == vm.ObservedStatePaused {
- if !force {
- return nil, fmt.Errorf("VM %s is running or paused; use --force to stop and delete", ref)
- }
- observed, err = r.stopVMLocked(ctx, rec.ID, backend.StopOptions{Force: true}, metering.ReasonDelete)
- if err != nil {
- return nil, err
- }
- if deleteComputeOpen {
- if err := r.requireComputeStop(ctx, observed, metering.ReasonDelete); err != nil {
- return nil, fmt.Errorf("record final VM usage: %w", err)
- }
- }
- }
- if observed.StartedAt != nil && observed.StoppedAt == nil {
- if err := r.vmUpdater.UpdateStates([]string{observed.ID}, vm.StateStopped); err != nil {
- return nil, err
- }
- observed, err = r.vmReader.Inspect(observed.ID)
- if err != nil {
- return nil, err
- }
- if err := r.requireComputeStop(ctx, observed, metering.ReasonDelete); err != nil {
- return nil, fmt.Errorf("record final VM usage: %w", err)
- }
- }
-
- if err := r.network.cleanupNetwork(ctx, observed); err != nil {
- return nil, err
- }
- if err := r.removeVMReferences(ctx, observed.ID); err != nil {
- return nil, fmt.Errorf("remove VM references: %w", err)
- }
-
- _ = writeVMEvent(observed, "backend.delete.completed", vm.Observation{
- State: observed.ObservedState,
- Reason: "VM deleted",
- CheckedAt: time.Now().UTC(),
- })
- if err := r.disk.removeManagedDirs(observed); err != nil {
- return nil, err
- }
- if err := fault.Check(ctx, fault.DeleteBeforeRecordDelete); err != nil {
- return nil, err
- }
- if err := r.vmRecords.Delete(observed.ID); err != nil {
- return nil, err
- }
- return observed, nil
-}
-
-// InspectVM returns a VM record with a fresh backend observation.
-func (r *Runtime) InspectVM(ref string) (*vm.VMRecord, error) {
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- observed := r.applyObservation(rec)
- observed.NetworkStatus = r.network.inspectNetwork(observed)
- return observed, nil
-}
-
-// ListVMs returns all VM records with fresh backend observations.
-func (r *Runtime) ListVMs() ([]*vm.VMRecord, error) {
- records, err := r.vmReader.List()
- if err != nil {
- return nil, err
- }
- for _, rec := range records {
- r.applyObservation(rec)
- }
- return records, nil
-}
-
-func (r *Runtime) applyObservation(rec *vm.VMRecord) *vm.VMRecord {
- if rec == nil {
- return nil
- }
- obs := r.backend.ObserveVM(rec)
- rec.ObservedState = obs.State
- rec.ObservedReason = obs.Reason
- rec.ObservedAt = &obs.CheckedAt
- if (rec.State == vm.StateRunning || rec.State == vm.StatePaused) &&
- obs.State != vm.ObservedStateRunning && obs.State != vm.ObservedStatePaused {
- _ = writeVMEvent(rec, "backend.exit.detected", obs)
- }
- return rec
-}
-
-func (r *networkCoordinator) inspectNetwork(rec *vm.VMRecord) *kbnetwork.InspectResult {
- if rec == nil {
- return nil
- }
- result, err := r.data.Networks.InspectVM(rec.ID, rec.Name, rec.Network, rec.Networks, rec.NetworkConfigs)
- if err != nil {
- return &kbnetwork.InspectResult{
- VMID: rec.ID,
- VMName: rec.Name,
- Network: rec.Network,
- Networks: append([]string(nil), rec.Networks...),
- Interfaces: []kbnetwork.Record{},
- VMConfigs: rec.NetworkConfigs,
- Drift: []string{err.Error()},
- }
- }
- return result
-}
-
-func (r *networkCoordinator) attachNetwork(ctx context.Context, rec *vm.VMRecord) (resultErr error) {
- operationID, err := r.beginOperation(ctx, operation.KindNetworkAttach, rec.ID)
- if err != nil {
- return err
- }
- defer func() { resultErr = r.finishOperation(ctx, operationID, resultErr) }()
- selections := networkSelections(rec)
- if len(selections) == 0 {
- return nil
- }
- attached := make([]kbnetwork.Config, 0, len(selections))
- for index, selection := range selections {
- allocation, err := r.attachNetworkConfig(ctx, rec, selection, index)
- if err != nil {
- r.rollbackNetworkConfigs(rec, attached)
- return err
- }
- attached = append(attached, allocation.Config)
- }
- if len(attached) == 0 {
- return nil
- }
- if _, err := r.vmRecords.SetNetworkConfigs(rec.ID, attached); err != nil {
- r.rollbackNetworkConfigs(rec, attached)
- return err
- }
- return nil
-}
-
-func (r *networkCoordinator) attachNetworkConfig(ctx context.Context, rec *vm.VMRecord, selection string, index int) (*kbnetwork.Allocation, error) {
- allocation, _, err := r.attachNetworkConfigWithExisting(ctx, rec, selection, index, nil)
- return allocation, err
-}
-
-func (r *networkCoordinator) attachNetworkConfigWithExisting(
- ctx context.Context,
- rec *vm.VMRecord,
- selection string,
- index int,
- existing *kbnetwork.Config,
-) (*kbnetwork.Allocation, bool, error) {
- if kbnetwork.IsCNISelection(selection) {
- allocation, err := r.attachCNIConfig(ctx, rec, selection, index, existing)
- return allocation, false, err
- }
- if selection != "default" && selection != kbnetwork.ProviderHostTap {
- return nil, false, fmt.Errorf("unsupported network %q", selection)
- }
- // Provider state is created before the VM is rendered so Cloud Hypervisor
- // always receives a concrete tap device name. The reverse cleanup path below
- // keeps lease/index/tap state consistent if any later step fails.
- if err := config.EnsureRuntimeDirs(r.cfg); err != nil {
- return nil, false, err
- }
- networkStore, err := r.providerStore()
- if err != nil {
- return nil, false, err
- }
- previousHostState, err := networkStore.ReadHostTapState()
- if err != nil {
- return nil, false, err
- }
- if _, err := kbnetwork.EnsureHostTapWithStore(ctx, r.cfg.Runtime.RootDir, r.cfg.Network, networkStore); err != nil {
- return nil, false, err
- }
- allocator := kbnetwork.NewAllocatorWithStore(networkStore, r.cfg.Network)
- allocation, err := allocator.Allocate(kbnetwork.AllocateRequest{
- VMID: rec.ID,
- Network: selection,
- Index: index,
- CPU: rec.CPUs,
- Existing: existing,
- })
- if err != nil {
- return nil, false, err
- }
- if err := kbnetwork.AttachHostTap(allocation.Record); err != nil {
- if existing == nil {
- _ = allocator.ReleaseIP(allocation.Config.Network.IP)
- }
- return nil, false, err
- }
- if err := networkStore.UpsertRecord(allocation.Record); err != nil {
- _ = deleteHostTap(allocation.Record.TAP)
- if existing == nil {
- _ = allocator.ReleaseIP(allocation.Config.Network.IP)
- }
- return nil, false, err
- }
- hostRefAdded := existing == nil || previousHostState == nil
- if hostRefAdded {
- if err := networkStore.IncrementHostTapRef(1); err != nil {
- _ = networkStore.DeleteRecord(allocation.Record.ID)
- _ = deleteHostTap(allocation.Record.TAP)
- if existing == nil {
- _ = allocator.ReleaseIP(allocation.Config.Network.IP)
- }
- return nil, false, err
- }
- }
- return allocation, hostRefAdded, nil
-}
-
-func (r *networkCoordinator) attachCNIConfig(
- ctx context.Context,
- rec *vm.VMRecord,
- selection string,
- index int,
- existing *kbnetwork.Config,
-) (*kbnetwork.Allocation, error) {
- if err := config.EnsureRuntimeDirs(r.cfg); err != nil {
- return nil, err
- }
- allocation, err := addCNI(ctx, r.cfg.Runtime.RootDir, r.cfg.Network, kbnetwork.CNIAddRequest{
- VMID: rec.ID,
- Network: selection,
- Index: index,
- CPU: rec.CPUs,
- Existing: existing,
- })
- if err != nil {
- return nil, err
- }
- if err := fault.Check(ctx, fault.NetworkAfterAdd); err != nil {
- rollbackErr := deleteCNI(ctx, r.cfg.Runtime.RootDir, r.cfg.Network, kbnetwork.CNIDeleteRequest{
- VMID: rec.ID, Network: selection, IfName: allocation.Record.IfName,
- TAP: allocation.Record.TAP, NetNSPath: allocation.Record.NetnsPath,
- })
- return nil, errors.Join(err, rollbackErr)
- }
- networkStore := r.data.Networks
- if err := networkStore.UpsertRecord(allocation.Record); err != nil {
- _ = deleteCNI(ctx, r.cfg.Runtime.RootDir, r.cfg.Network, kbnetwork.CNIDeleteRequest{
- VMID: rec.ID,
- Network: selection,
- IfName: allocation.Record.IfName,
- TAP: allocation.Record.TAP,
- NetNSPath: allocation.Record.NetnsPath,
- })
- return nil, err
- }
- return allocation, nil
-}
-
-func (r *networkCoordinator) rollbackNetwork(rec *vm.VMRecord) {
- if rec == nil {
- return
- }
- r.rollbackNetworkConfigs(rec, rec.NetworkConfigs)
-}
-
-func (r *networkCoordinator) cleanupNetwork(ctx context.Context, rec *vm.VMRecord) (resultErr error) {
- operationID, err := r.beginOperation(ctx, operation.KindNetworkCleanup, rec.ID)
- if err != nil {
- return err
- }
- defer func() { resultErr = r.finishOperation(ctx, operationID, resultErr) }()
- if rec == nil || len(rec.NetworkConfigs) == 0 {
- return nil
- }
- store := r.data.Networks
- providerStore, err := r.providerStore()
- if err != nil {
- return err
- }
- allocator := kbnetwork.NewAllocatorWithStore(providerStore, r.cfg.Network)
- var cleanupErrs []error
- cniCount := countCNIConfigs(rec.NetworkConfigs)
- cniCleanupFailed := false
- for _, nc := range rec.NetworkConfigs {
- preserveCNI := false
- if nc.Backend == kbnetwork.ProviderCNI {
- preserveCNI = true
- }
- if err := cleanupNetworkConfig(ctx, store, allocator, r.cfg, rec, nc, preserveCNI); err != nil {
- // Preserve the provider record when cleanup fails. A later GC or
- // explicit retry needs the original tap/IP metadata to finish the
- // cleanup safely.
- if nc.Backend == kbnetwork.ProviderCNI {
- cniCleanupFailed = true
- }
- reason := err.Error()
- if markErr := store.MarkCleanupPending(nc.ID, reason); markErr != nil {
- cleanupErrs = append(cleanupErrs, fmt.Errorf("mark network cleanup pending for %s: %w", nc.ID, markErr))
- }
- cleanupErrs = append(cleanupErrs, fmt.Errorf("cleanup network %s: %w", nc.ID, err))
- }
- }
- if cniCount > 0 && !cniCleanupFailed {
- if err := deleteCNINetNS(rec.ID, cniNetNSPath(rec.NetworkConfigs)); err != nil {
- cleanupErrs = append(cleanupErrs, fmt.Errorf("delete CNI netns for VM %s: %w", rec.ID, err))
- }
- }
- if err := errors.Join(cleanupErrs...); err != nil {
- return fmt.Errorf("delete VM network resources: %w", err)
- }
- return nil
-}
-
-func cleanupNetworkConfig(
- ctx context.Context,
- store state.NetworkState,
- allocator *kbnetwork.Allocator,
- cfg config.Config,
- rec *vm.VMRecord,
- nc kbnetwork.Config,
- preserveCNINetNS bool,
-) error {
- if nc.Backend == kbnetwork.ProviderCNI {
- if err := deleteCNI(ctx, cfg.Runtime.RootDir, cfg.Network, kbnetwork.CNIDeleteRequest{
- VMID: rec.ID,
- Network: networkSelectionForConfig(rec, nc),
- IfName: cniIfName(nc),
- TAP: nc.TAP,
- NetNSPath: nc.NetnsPath,
- PreserveNetNS: preserveCNINetNS,
- }); err != nil {
- return err
- }
- if err := fault.Check(ctx, fault.NetworkAfterDelete); err != nil {
- return err
- }
- if err := store.DeleteRecord(nc.ID); err != nil {
- return fmt.Errorf("delete network provider record %s: %w", nc.ID, err)
- }
- return nil
- }
- if err := deleteHostTap(nc.TAP); err != nil {
- return fmt.Errorf("delete tap %s: %w", nc.TAP, err)
- }
- if nc.Network != nil && nc.Network.IP != "" {
- if err := allocator.ReleaseIP(nc.Network.IP); err != nil {
- return fmt.Errorf("release IP %s: %w", nc.Network.IP, err)
- }
- }
- if err := store.DecrementHostTapRef(1); err != nil {
- return err
- }
- if err := store.DeleteRecord(nc.ID); err != nil {
- return fmt.Errorf("delete network provider record %s: %w", nc.ID, err)
- }
- return nil
-}
-
-func countCNIConfigs(configs []kbnetwork.Config) int {
- count := 0
- for _, cfg := range configs {
- if cfg.Backend == kbnetwork.ProviderCNI {
- count++
- }
- }
- return count
-}
-
-func cniNetNSPath(configs []kbnetwork.Config) string {
- for _, cfg := range configs {
- if cfg.Backend == kbnetwork.ProviderCNI && cfg.NetnsPath != "" {
- return cfg.NetnsPath
- }
- }
- return ""
-}
-
-func cniIfName(nc kbnetwork.Config) string {
- if nc.IfName != "" {
- return nc.IfName
- }
- return nc.TAP
-}
-
-func networkSelections(rec *vm.VMRecord) []string {
- if rec == nil {
- return nil
- }
- selections := append([]string(nil), rec.Networks...)
- if len(selections) == 0 && rec.Network != "" {
- selections = append(selections, rec.Network)
- }
- filtered := selections[:0]
- for _, selection := range selections {
- if selection == "" || selection == kbnetwork.ProviderNone {
- continue
- }
- filtered = append(filtered, selection)
- }
- return filtered
-}
-
-func networkSelectionForConfig(rec *vm.VMRecord, nc kbnetwork.Config) string {
- if nc.NetworkName != "" {
- return nc.NetworkName
- }
- if rec != nil && rec.Network != "" && rec.Network != "multi" {
- return rec.Network
- }
- return ""
-}
-
-func (r *networkCoordinator) rollbackNetworkConfigs(rec *vm.VMRecord, configs []kbnetwork.Config) {
- store, err := r.providerStore()
- if err != nil {
- return
- }
- allocator := kbnetwork.NewAllocatorWithStore(store, r.cfg.Network)
- cniRemaining := countCNIConfigs(configs)
- for i := len(configs) - 1; i >= 0; i-- {
- nc := configs[i]
- if nc.Backend == kbnetwork.ProviderCNI {
- cniRemaining--
- _ = store.DeleteRecord(nc.ID)
- _ = deleteCNI(context.Background(), r.cfg.Runtime.RootDir, r.cfg.Network, kbnetwork.CNIDeleteRequest{
- VMID: rec.ID,
- Network: networkSelectionForConfig(rec, nc),
- IfName: cniIfName(nc),
- TAP: nc.TAP,
- NetNSPath: nc.NetnsPath,
- PreserveNetNS: cniRemaining > 0,
- })
- continue
- }
- _ = store.DeleteRecord(nc.ID)
- _ = deleteHostTap(nc.TAP)
- if nc.Network != nil {
- _ = allocator.ReleaseIP(nc.Network.IP)
- }
- _ = store.DecrementHostTapRef(1)
- }
-}
diff --git a/internal/vm/runtime/runtime_test.go b/internal/vm/runtime/runtime_test.go
deleted file mode 100644
index 78e5f45..0000000
--- a/internal/vm/runtime/runtime_test.go
+++ /dev/null
@@ -1,1433 +0,0 @@
-package runtime
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/config"
- "github.com/kumabox/kumabox/internal/fault"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-type backendFake struct {
- render func(*vm.VMRecord) error
- start func(*vm.VMRecord) (*backend.StartResult, error)
- stop func(*vm.VMRecord, backend.StopOptions) (*backend.StopResult, error)
- pause func(context.Context, *vm.VMRecord) error
- resume func(context.Context, *vm.VMRecord) error
- snapshot func(context.Context, *vm.VMRecord, string) error
- nativeHost func(context.Context, *vm.VMRecord) (backend.NativeHost, error)
- restore func(context.Context, *vm.VMRecord, string, string) (*backend.StartResult, error)
- clone func(context.Context, *vm.VMRecord, string, string) (*backend.StartResult, error)
- observe func(*vm.VMRecord) vm.Observation
-}
-
-func (b backendFake) CloneVM(ctx context.Context, rec *vm.VMRecord, sourceDir, mode string) (*backend.StartResult, error) {
- if b.clone != nil {
- return b.clone(ctx, rec, sourceDir, mode)
- }
- return nil, errors.New("clone is not configured")
-}
-
-func (b backendFake) RestoreVM(ctx context.Context, rec *vm.VMRecord, sourceDir, mode string) (*backend.StartResult, error) {
- if b.restore != nil {
- return b.restore(ctx, rec, sourceDir, mode)
- }
- return nil, errors.New("restore is not configured")
-}
-
-func (b backendFake) RenderConfig(rec *vm.VMRecord) error {
- return b.render(rec)
-}
-
-func (b backendFake) StartVM(rec *vm.VMRecord) (*backend.StartResult, error) {
- return b.start(rec)
-}
-
-func (b backendFake) StopVM(rec *vm.VMRecord, opts backend.StopOptions) (*backend.StopResult, error) {
- if b.stop != nil {
- return b.stop(rec, opts)
- }
- return &backend.StopResult{}, nil
-}
-
-func (b backendFake) PauseVM(ctx context.Context, rec *vm.VMRecord) error {
- if b.pause != nil {
- return b.pause(ctx, rec)
- }
- return nil
-}
-
-func (b backendFake) ResumeVM(ctx context.Context, rec *vm.VMRecord) error {
- if b.resume != nil {
- return b.resume(ctx, rec)
- }
- return nil
-}
-
-func (b backendFake) SnapshotVM(ctx context.Context, rec *vm.VMRecord, destination string) error {
- if b.snapshot != nil {
- return b.snapshot(ctx, rec, destination)
- }
- return nil
-}
-
-func (b backendFake) InspectNativeHost(ctx context.Context, rec *vm.VMRecord) (backend.NativeHost, error) {
- if b.nativeHost != nil {
- return b.nativeHost(ctx, rec)
- }
- return backend.NativeHost{
- BackendName: "cloud-hypervisor", BackendVersion: "test", SnapshotFormat: "cloud-hypervisor-native-v1",
- Architecture: "test", CPUVendor: "test",
- }, nil
-}
-
-func (b backendFake) ObserveVM(rec *vm.VMRecord) vm.Observation {
- if b.observe != nil {
- return b.observe(rec)
- }
- return vm.Observation{
- State: vm.ObservedStateCreated,
- Reason: "test observation",
- CheckedAt: time.Now().UTC(),
- }
-}
-
-func TestCreateVMRollsBackRecordOnRenderFailure(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- renderErr := errors.New("render failed")
- rt := NewWithBackend(store, backendFake{
- render: func(*vm.VMRecord) error { return renderErr },
- })
-
- _, err := rt.CreateVM(vm.CreateRequest{
- Name: "rollback",
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if !errors.Is(err, renderErr) {
- t.Fatalf("error = %v, want %v", err, renderErr)
- }
-
- if _, err := store.Inspect("rollback"); !errors.Is(err, vm.ErrNotFound) {
- t.Fatalf("inspect after rollback error = %v", err)
- }
-}
-
-func TestStartVMMarksRunningWithoutGuestAgent(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- rt := NewWithBackend(
- store,
- backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 1234, APISocket: "/tmp/ch.sock"}, nil
- },
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{
- State: vm.ObservedStateRunning,
- Reason: "running",
- CheckedAt: time.Now().UTC(),
- }
- },
- },
- )
-
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "start-me",
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- Image: &vm.ImageRef{ID: "img_direct", BootMode: "direct"},
- })
- if err != nil {
- t.Fatal(err)
- }
-
- started, err := rt.StartVM(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if started.State != vm.StateRunning {
- t.Fatalf("state = %s", started.State)
- }
- if started.PID != 1234 || started.APISocket != "/tmp/ch.sock" {
- t.Fatalf("runtime fields = pid %d socket %s", started.PID, started.APISocket)
- }
- if started.ObservedState != vm.ObservedStateRunning {
- t.Fatalf("observed state = %s", started.ObservedState)
- }
- if started.Performance == nil || started.Performance.ReadyDurationMs != started.Performance.VMMAPIReadyDurationMs {
- t.Fatalf("lifecycle readiness did not stop at VMM API readiness: %+v", started.Performance)
- }
-}
-
-func TestStartVMContextSerializesSameVM(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- entered := make(chan struct{})
- release := make(chan struct{})
- rt := NewWithBackend(store, backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- close(entered)
- <-release
- return &backend.StartResult{PID: 1234, APISocket: "/tmp/ch.sock"}, nil
- },
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: vm.ObservedStateRunning, CheckedAt: time.Now().UTC()}
- },
- })
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "locked", RootDisk: "base.qcow2", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
-
- firstDone := make(chan error, 1)
- go func() {
- _, startErr := rt.StartVMContext(context.Background(), rec.ID)
- firstDone <- startErr
- }()
- <-entered
-
- ctx, cancel := context.WithTimeout(context.Background(), 75*time.Millisecond)
- defer cancel()
- _, err = rt.StartVMContext(ctx, rec.ID)
- if !errors.Is(err, context.DeadlineExceeded) {
- t.Fatalf("second start error = %v, want context deadline", err)
- }
- close(release)
- if err := <-firstDone; err != nil {
- t.Fatalf("first start error = %v", err)
- }
-}
-
-func TestVMOperationLockDoesNotBlockDifferentVM(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- rt := NewWithBackend(store, backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 1234, APISocket: "/tmp/ch.sock"}, nil
- },
- })
- first, err := rt.CreateVM(vm.CreateRequest{
- Name: "first", RootDisk: "base.qcow2", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- second, err := rt.CreateVM(vm.CreateRequest{
- Name: "second", RootDisk: "base.qcow2", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- lock, err := rt.vmLocks.Acquire(context.Background(), first.ID)
- if err != nil {
- t.Fatal(err)
- }
- defer lock.Release() //nolint:errcheck
-
- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
- defer cancel()
- if _, err := rt.StartVMContext(ctx, second.ID); err != nil {
- t.Fatalf("different VM was blocked: %v", err)
- }
-}
-
-func TestStartVMRerendersAfterFirstBoot(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- var renderFirstBooted []bool
- rt := NewWithBackend(
- store,
- backendFake{
- render: func(rec *vm.VMRecord) error {
- renderFirstBooted = append(renderFirstBooted, rec.FirstBooted)
- return nil
- },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 1234, APISocket: filepath.Join(dir, "run", "ch.sock")}, nil
- },
- observe: func(rec *vm.VMRecord) vm.Observation {
- state := vm.ObservedStateCreated
- if rec.State == vm.StateRunning {
- state = vm.ObservedStateRunning
- }
- if rec.State == vm.StateStopped {
- state = vm.ObservedStateStopped
- }
- return vm.Observation{
- State: state,
- Reason: string(state),
- CheckedAt: time.Now().UTC(),
- }
- },
- },
- )
-
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "cloudimg",
- RootDisk: "ubuntu.img",
- Firmware: "CLOUDHV.fd",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := rt.StartVM(rec.ID); err != nil {
- t.Fatal(err)
- }
- if _, err := rt.StopVM(rec.ID, backend.StopOptions{Timeout: time.Second}); err != nil {
- t.Fatal(err)
- }
- if _, err := rt.StartVM(rec.ID); err != nil {
- t.Fatal(err)
- }
-
- if len(renderFirstBooted) != 3 {
- t.Fatalf("render calls = %v", renderFirstBooted)
- }
- if renderFirstBooted[0] || renderFirstBooted[1] {
- t.Fatalf("first boot renders should include cidata: %v", renderFirstBooted)
- }
- if !renderFirstBooted[2] {
- t.Fatalf("second start should render with firstBooted=true: %v", renderFirstBooted)
- }
-}
-
-func TestStartVMMarksErrorOnStartFailure(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- startErr := errors.New("start failed")
- rt := NewWithBackend(
- store,
- backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) { return nil, startErr },
- },
- )
-
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "fail-me",
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
-
- if _, err := rt.StartVM(rec.ID); !errors.Is(err, startErr) {
- t.Fatalf("start error = %v", err)
- }
- updated, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if updated.State != vm.StateError || updated.Error == "" {
- t.Fatalf("updated record = %+v", updated)
- }
-}
-
-func TestInspectVMReconcilesStaleRunningRecord(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- checkedAt := time.Date(2026, 6, 29, 1, 2, 3, 0, time.UTC)
- rt := NewWithBackend(
- store,
- backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 4321, APISocket: filepath.Join(dir, "run", "ch.sock")}, nil
- },
- observe: func(rec *vm.VMRecord) vm.Observation {
- if rec.State == vm.StateRunning {
- return vm.Observation{
- State: vm.ObservedStateStopped,
- Reason: "process 4321 is not alive",
- CheckedAt: checkedAt,
- }
- }
- return vm.Observation{
- State: vm.ObservedStateCreated,
- Reason: "created",
- CheckedAt: checkedAt,
- }
- },
- },
- )
-
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "stale",
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := rt.StartVM(rec.ID); err != nil {
- t.Fatal(err)
- }
-
- inspected, err := rt.InspectVM(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if inspected.State != vm.StateRunning {
- t.Fatalf("persisted state = %s", inspected.State)
- }
- if inspected.ObservedState != vm.ObservedStateStopped {
- t.Fatalf("observed state = %s", inspected.ObservedState)
- }
- if inspected.ObservedReason == "" || inspected.ObservedAt == nil {
- t.Fatalf("missing observation detail: %+v", inspected)
- }
-
- raw, err := os.ReadFile(filepath.Join(inspected.LogDir, "events.log"))
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(raw), "backend.exit.detected") {
- t.Fatalf("events log missing backend.exit.detected: %s", raw)
- }
-}
-
-func TestStopVMMarksStopped(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- stopCalled := false
- rt := NewWithBackend(
- store,
- backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 12345, APISocket: filepath.Join(dir, "run", "ch.sock")}, nil
- },
- stop: func(rec *vm.VMRecord, opts backend.StopOptions) (*backend.StopResult, error) {
- stopCalled = true
- if rec.PID != 12345 {
- t.Fatalf("stop pid = %d", rec.PID)
- }
- if opts.Timeout <= 0 {
- t.Fatal("expected timeout")
- }
- return &backend.StopResult{}, nil
- },
- observe: func(rec *vm.VMRecord) vm.Observation {
- state := vm.ObservedStateCreated
- reason := "created"
- if rec.State == vm.StateRunning {
- state = vm.ObservedStateRunning
- reason = "running"
- }
- if rec.State == vm.StateStopped {
- state = vm.ObservedStateStopped
- reason = "stopped"
- }
- return vm.Observation{
- State: state,
- Reason: reason,
- CheckedAt: time.Now().UTC(),
- }
- },
- },
- )
-
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "stop-me",
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := rt.StartVM(rec.ID); err != nil {
- t.Fatal(err)
- }
-
- stopped, err := rt.StopVM(rec.ID, backend.StopOptions{Timeout: time.Second})
- if err != nil {
- t.Fatal(err)
- }
- if !stopCalled {
- t.Fatal("backend stop was not called")
- }
- if stopped.State != vm.StateStopped {
- t.Fatalf("state = %s", stopped.State)
- }
- if stopped.PID != 0 || stopped.APISocket != "" {
- t.Fatalf("runtime fields not cleared: %+v", stopped)
- }
- if stopped.ObservedState != vm.ObservedStateStopped {
- t.Fatalf("observed state = %s", stopped.ObservedState)
- }
-
- raw, err := os.ReadFile(filepath.Join(stopped.LogDir, "events.log"))
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(raw), "backend.stop.completed") {
- t.Fatalf("events log missing backend.stop.completed: %s", raw)
- }
-}
-
-func TestLogsVMTailsKnownLogFiles(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- rt := NewWithBackend(
- store,
- backendFake{
- render: func(*vm.VMRecord) error { return nil },
- },
- )
-
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "logs",
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(rec.LogDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(rec.LogDir, "cloud-hypervisor.stdout.log"), []byte("one\ntwo\nthree\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(rec.LogDir, "cloud-hypervisor.stderr.log"), []byte("err-one\nerr-two\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(rec.LogDir, "console.log"), []byte("console-one\nconsole-two\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- logs, err := rt.LogsVM("logs", LogOptions{Tail: 1})
- if err != nil {
- t.Fatal(err)
- }
- if logs.VMID != rec.ID || logs.Name != rec.Name {
- t.Fatalf("logs identity = %+v", logs)
- }
- if len(logs.Files) != 1 {
- t.Fatalf("log file count = %d", len(logs.Files))
- }
- if logs.Files[0].Name != "console.log" || logs.Files[0].Content != "console-two\n" {
- t.Fatalf("console tail = %+v", logs.Files[0])
- }
-
- vmmLogs, err := rt.LogsVM("logs", LogOptions{Tail: 2, Source: LogSourceVMM})
- if err != nil {
- t.Fatal(err)
- }
- if len(vmmLogs.Files) != 2 {
- t.Fatalf("vmm log file count = %d", len(vmmLogs.Files))
- }
- if vmmLogs.Files[0].Name != "cloud-hypervisor.stdout.log" || vmmLogs.Files[0].Content != "two\nthree\n" {
- t.Fatalf("stdout tail = %+v", vmmLogs.Files[0])
- }
- if vmmLogs.Files[1].Name != "cloud-hypervisor.stderr.log" || vmmLogs.Files[1].Content != "err-one\nerr-two\n" {
- t.Fatalf("stderr tail = %+v", vmmLogs.Files[1])
- }
-}
-
-func TestDeleteVMRemovesRecordAndManagedDirsOnly(t *testing.T) {
- dir := t.TempDir()
- rootDisk := filepath.Join(dir, "fixtures", "base.qcow2")
- if err := os.MkdirAll(filepath.Dir(rootDisk), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(rootDisk, []byte("root disk"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- store := vm.New(filepath.Join(dir, "data"))
- rt := NewWithBackend(
- store,
- backendFake{
- render: func(*vm.VMRecord) error { return nil },
- },
- )
-
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "delete-me",
- RootDisk: rootDisk,
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(rec.RunDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(rec.LogDir, 0o755); err != nil {
- t.Fatal(err)
- }
- storageDir := filepath.Join(store.RootDir(), "storage", "vms", rec.ID)
- if err := os.MkdirAll(storageDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(storageDir, "cow.ext4"), []byte("owned"), 0o600); err != nil {
- t.Fatal(err)
- }
-
- deleted, err := rt.DeleteVM("delete-me", false)
- if err != nil {
- t.Fatal(err)
- }
- if deleted.ID != rec.ID {
- t.Fatalf("deleted ID = %s, want %s", deleted.ID, rec.ID)
- }
- if _, err := store.Inspect(rec.ID); !errors.Is(err, vm.ErrNotFound) {
- t.Fatalf("inspect after delete error = %v", err)
- }
- if _, err := os.Stat(rootDisk); err != nil {
- t.Fatalf("root disk should remain: %v", err)
- }
- if _, err := os.Stat(rec.RunDir); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("run dir still exists or unexpected error: %v", err)
- }
- if _, err := os.Stat(rec.LogDir); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("log dir still exists or unexpected error: %v", err)
- }
- if _, err := os.Stat(storageDir); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("storage owner dir still exists or unexpected error: %v", err)
- }
-}
-
-func TestDeleteVMRequiresForceForRunningVM(t *testing.T) {
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- stopCalled := false
- rt := NewWithBackend(
- store,
- backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 12345, APISocket: filepath.Join(dir, "run", "ch.sock")}, nil
- },
- stop: func(*vm.VMRecord, backend.StopOptions) (*backend.StopResult, error) {
- stopCalled = true
- return &backend.StopResult{}, nil
- },
- observe: func(rec *vm.VMRecord) vm.Observation {
- state := vm.ObservedStateCreated
- if rec.State == vm.StateRunning {
- state = vm.ObservedStateRunning
- }
- if rec.State == vm.StateStopped {
- state = vm.ObservedStateStopped
- }
- return vm.Observation{
- State: state,
- Reason: string(state),
- CheckedAt: time.Now().UTC(),
- }
- },
- },
- )
-
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "running-delete",
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := rt.StartVM(rec.ID); err != nil {
- t.Fatal(err)
- }
-
- if _, err := rt.DeleteVM(rec.ID, false); err == nil {
- t.Fatal("expected delete running VM without force to fail")
- }
- if stopCalled {
- t.Fatal("stop should not be called without force")
- }
- if _, err := store.Inspect(rec.ID); err != nil {
- t.Fatalf("record should remain after failed delete: %v", err)
- }
-
- if _, err := rt.DeleteVM(rec.ID, true); err != nil {
- t.Fatal(err)
- }
- if !stopCalled {
- t.Fatal("force delete did not stop VM")
- }
- if _, err := store.Inspect(rec.ID); !errors.Is(err, vm.ErrNotFound) {
- t.Fatalf("inspect after force delete error = %v", err)
- }
-}
-
-func TestStopVMPreservesNetworkResources(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := vm.New(rootDir)
- rt := NewWithBackend(
- store,
- backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 12345, APISocket: filepath.Join(dir, "run", "ch.sock")}, nil
- },
- observe: func(rec *vm.VMRecord) vm.Observation {
- state := vm.ObservedStateCreated
- if rec.State == vm.StateRunning {
- state = vm.ObservedStateRunning
- }
- if rec.State == vm.StateStopped {
- state = vm.ObservedStateStopped
- }
- return vm.Observation{
- State: state,
- Reason: string(state),
- CheckedAt: time.Now().UTC(),
- }
- },
- },
- )
- rt.cfg = testRuntimeConfig(rootDir)
- withVerifyNetworkConfig(t, func(kbnetwork.Config) error { return nil })
-
- rec, allocation := createVMWithNetwork(t, rt, store, "stop-network")
- if _, err := rt.StartVM(rec.ID); err != nil {
- t.Fatal(err)
- }
- if _, err := rt.StopVM(rec.ID, backend.StopOptions{Timeout: time.Second}); err != nil {
- t.Fatal(err)
- }
-
- networkStore := kbnetwork.NewStore(rootDir)
- records, err := networkStore.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 1 || records[0].ID != allocation.Record.ID {
- t.Fatalf("network records after stop = %+v", records)
- }
- leases, err := networkStore.ListLeases()
- if err != nil {
- t.Fatal(err)
- }
- if _, ok := leases[allocation.Config.Network.IP]; !ok {
- t.Fatalf("lease was removed on stop: %+v", leases)
- }
-}
-
-func TestDeleteVMCleansNetworkResources(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := vm.New(rootDir)
- rt := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = testRuntimeConfig(rootDir)
- deletedTaps := []string{}
- withDeleteHostTap(t, func(tap string) error {
- deletedTaps = append(deletedTaps, tap)
- return nil
- })
-
- rec, allocation := createVMWithNetwork(t, rt, store, "delete-network")
- if _, err := rt.DeleteVM(rec.ID, false); err != nil {
- t.Fatal(err)
- }
- if len(deletedTaps) != 1 || deletedTaps[0] != allocation.Record.TAP {
- t.Fatalf("deleted taps = %+v", deletedTaps)
- }
- if _, err := store.Inspect(rec.ID); !errors.Is(err, vm.ErrNotFound) {
- t.Fatalf("inspect after delete error = %v", err)
- }
- networkStore := kbnetwork.NewStore(rootDir)
- records, err := networkStore.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 0 {
- t.Fatalf("network records after delete = %+v", records)
- }
- leases, err := networkStore.ListLeases()
- if err != nil {
- t.Fatal(err)
- }
- if len(leases) != 0 {
- t.Fatalf("leases after delete = %+v", leases)
- }
-}
-
-func TestDeleteVMRetriesAfterManagedCleanup(t *testing.T) {
- rootDir := t.TempDir()
- store := vm.New(rootDir)
- rt := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = testRuntimeConfig(rootDir)
- rec, err := store.Create(vm.CreateRequest{
- Name: "retry-delete", RootDisk: filepath.Join(rootDir, "root.raw"),
- Kernel: filepath.Join(rootDir, "vmlinuz"), Initrd: filepath.Join(rootDir, "initrd"),
- RunDir: filepath.Join(rootDir, "run"), LogDir: filepath.Join(rootDir, "log"), Network: "none",
- })
- if err != nil {
- t.Fatal(err)
- }
- injected := fault.Interrupt(fault.DeleteBeforeRecordDelete)
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == fault.DeleteBeforeRecordDelete {
- return injected
- }
- return nil
- }))
- if _, err := rt.DeleteVMContext(ctx, rec.ID, false); !errors.Is(err, injected) {
- t.Fatalf("DeleteVMContext() error = %v, want %v", err, injected)
- }
- if _, err := store.Inspect(rec.ID); err != nil {
- t.Fatalf("VM record unavailable for retry: %v", err)
- }
- recovered := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- recovered.cfg = testRuntimeConfig(rootDir)
- if _, err := recovered.DeleteVMContext(t.Context(), rec.ID, false); err != nil {
- t.Fatalf("retry DeleteVMContext(): %v", err)
- }
- if err := recovered.ReconcileOperations(t.Context()); err != nil {
- t.Fatalf("ReconcileOperations(): %v", err)
- }
- if recoverable, err := recovered.operations.Recoverable(t.Context()); err != nil || len(recoverable) != 0 {
- t.Fatalf("recoverable operations after retry = %+v, err = %v", recoverable, err)
- }
- if _, err := store.Inspect(rec.ID); !errors.Is(err, vm.ErrNotFound) {
- t.Fatalf("VM after retry error = %v, want ErrNotFound", err)
- }
-}
-
-func TestDeleteVMMarksNetworkCleanupPendingOnFailure(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := vm.New(rootDir)
- rt := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = testRuntimeConfig(rootDir)
- tapErr := errors.New("tap delete failed")
- withDeleteHostTap(t, func(string) error { return tapErr })
-
- rec, allocation := createVMWithNetwork(t, rt, store, "pending-network")
- if _, err := rt.DeleteVM(rec.ID, false); !errors.Is(err, tapErr) {
- t.Fatalf("delete error = %v, want %v", err, tapErr)
- }
- if _, err := store.Inspect(rec.ID); err != nil {
- t.Fatalf("VM record should remain after cleanup failure: %v", err)
- }
- networkStore := kbnetwork.NewStore(rootDir)
- records, err := networkStore.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 1 || records[0].ID != allocation.Record.ID {
- t.Fatalf("network records after failure = %+v", records)
- }
- if !records[0].Cleanup.Pending || !strings.Contains(records[0].Cleanup.Reason, "tap delete failed") {
- t.Fatalf("cleanup = %+v", records[0].Cleanup)
- }
- leases, err := networkStore.ListLeases()
- if err != nil {
- t.Fatal(err)
- }
- if _, ok := leases[allocation.Config.Network.IP]; !ok {
- t.Fatalf("lease should remain after tap delete failure: %+v", leases)
- }
-}
-
-func TestDeleteVMCleansCNIResources(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := vm.New(rootDir)
- rt := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = testRuntimeConfig(rootDir)
- withAddCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIAddRequest) (*kbnetwork.Allocation, error) {
- return testCNIAllocation(req.VMID), nil
- })
- deleted := []kbnetwork.CNIDeleteRequest{}
- withDeleteCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIDeleteRequest) error {
- deleted = append(deleted, req)
- return nil
- })
- withDeleteCNINetNS(t, func(string, string) error { return nil })
-
- rec := createVMWithCNIConfig(t, rt, "delete-cni")
- if _, err := rt.DeleteVM(rec.ID, false); err != nil {
- t.Fatal(err)
- }
- if len(deleted) != 1 {
- t.Fatalf("deleted cni calls = %+v", deleted)
- }
- if deleted[0].VMID != rec.ID || deleted[0].Network != "cni:default" || deleted[0].IfName != "eth0" || deleted[0].TAP != "kbcni0" {
- t.Fatalf("delete request = %+v", deleted[0])
- }
- records, err := kbnetwork.NewStore(rootDir).List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 0 {
- t.Fatalf("network records after delete = %+v", records)
- }
-}
-
-func TestCNIFailureBoundariesRollbackAndRetry(t *testing.T) {
- t.Run("add rolls back provider side effect", func(t *testing.T) {
- rootDir := t.TempDir()
- store := vm.New(rootDir)
- rt := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = testRuntimeConfig(rootDir)
- withAddCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIAddRequest) (*kbnetwork.Allocation, error) {
- return testCNIAllocation(req.VMID), nil
- })
- var deleted []kbnetwork.CNIDeleteRequest
- withDeleteCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIDeleteRequest) error {
- deleted = append(deleted, req)
- return nil
- })
- injected := errors.New("injected after CNI ADD")
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == fault.NetworkAfterAdd {
- return injected
- }
- return nil
- }))
- _, err := rt.createVMContext(ctx, vm.CreateRequest{
- Name: "cni-add-boundary", RootDisk: "base.qcow2", Kernel: "vmlinuz", Initrd: "initrd.img",
- Network: "cni:default", RunDir: filepath.Join(rootDir, "run"), LogDir: filepath.Join(rootDir, "log"),
- }, nil)
- if !errors.Is(err, injected) {
- t.Fatalf("createVMContext() error = %v, want %v", err, injected)
- }
- if len(deleted) != 1 {
- t.Fatalf("CNI rollback calls = %d, want 1", len(deleted))
- }
- records, err := kbnetwork.NewStore(rootDir).List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 0 {
- t.Fatalf("provider records after ADD rollback = %+v", records)
- }
- if records, err := store.List(); err != nil || len(records) != 0 {
- t.Fatalf("VM records after ADD rollback = %+v, err = %v", records, err)
- }
- })
-
- t.Run("delete retains record for retry", func(t *testing.T) {
- rootDir := t.TempDir()
- store := vm.New(rootDir)
- rt := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = testRuntimeConfig(rootDir)
- withAddCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIAddRequest) (*kbnetwork.Allocation, error) {
- return testCNIAllocation(req.VMID), nil
- })
- deletes := 0
- withDeleteCNI(t, func(context.Context, string, config.NetworkConfig, kbnetwork.CNIDeleteRequest) error {
- deletes++
- return nil
- })
- withDeleteCNINetNS(t, func(string, string) error { return nil })
- rec := createVMWithCNIConfig(t, rt, "cni-del-boundary")
- injected := errors.New("injected after CNI DEL")
- ctx := fault.WithInjector(t.Context(), fault.InjectorFunc(func(point fault.Point) error {
- if point == fault.NetworkAfterDelete {
- return injected
- }
- return nil
- }))
- if _, err := rt.DeleteVMContext(ctx, rec.ID, false); !errors.Is(err, injected) {
- t.Fatalf("DeleteVMContext() error = %v, want %v", err, injected)
- }
- providerRecords, err := kbnetwork.NewStore(rootDir).List()
- if err != nil {
- t.Fatal(err)
- }
- if len(providerRecords) != 1 || !providerRecords[0].Cleanup.Pending {
- t.Fatalf("provider record after DEL interruption = %+v", providerRecords)
- }
- if _, err := rt.DeleteVMContext(t.Context(), rec.ID, false); err != nil {
- t.Fatalf("retry DeleteVMContext(): %v", err)
- }
- if deletes != 2 {
- t.Fatalf("CNI DEL calls = %d, want 2", deletes)
- }
- })
-}
-
-func TestDeleteVMCleansMultipleCNIResourcesAndNetNS(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := vm.New(rootDir)
- rt := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = testRuntimeConfig(rootDir)
- withAddCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIAddRequest) (*kbnetwork.Allocation, error) {
- return testIndexedCNIAllocation(req.VMID, req.Network, req.Index), nil
- })
- deleted := []kbnetwork.CNIDeleteRequest{}
- withDeleteCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIDeleteRequest) error {
- deleted = append(deleted, req)
- return nil
- })
- deletedNetNS := []string{}
- withDeleteCNINetNS(t, func(vmID, netnsPath string) error {
- deletedNetNS = append(deletedNetNS, vmID+" "+netnsPath)
- return nil
- })
-
- rec := createVMWithMultiCNIConfig(t, rt, "delete-multi-cni")
- if _, err := rt.DeleteVM(rec.ID, false); err != nil {
- t.Fatal(err)
- }
- if len(deleted) != 2 {
- t.Fatalf("deleted cni calls = %+v", deleted)
- }
- if deleted[0].IfName != "eth0" || deleted[0].TAP != "kbcni0" || !deleted[0].PreserveNetNS {
- t.Fatalf("first delete request = %+v", deleted[0])
- }
- if deleted[1].IfName != "eth1" || deleted[1].TAP != "kbcni1" || !deleted[1].PreserveNetNS {
- t.Fatalf("second delete request = %+v", deleted[1])
- }
- if len(deletedNetNS) != 1 || deletedNetNS[0] != rec.ID+" /proc/self/ns/net" {
- t.Fatalf("deleted netns = %+v", deletedNetNS)
- }
- records, err := kbnetwork.NewStore(rootDir).List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 0 {
- t.Fatalf("network records after delete = %+v", records)
- }
-}
-
-func TestDeleteVMMarksCNICleanupPendingOnFailure(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := vm.New(rootDir)
- rt := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = testRuntimeConfig(rootDir)
- withAddCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIAddRequest) (*kbnetwork.Allocation, error) {
- return testCNIAllocation(req.VMID), nil
- })
- delErr := errors.New("cni del failed")
- withDeleteCNI(t, func(context.Context, string, config.NetworkConfig, kbnetwork.CNIDeleteRequest) error {
- return delErr
- })
-
- rec := createVMWithCNIConfig(t, rt, "pending-cni")
- if _, err := rt.DeleteVM(rec.ID, false); !errors.Is(err, delErr) {
- t.Fatalf("delete error = %v, want %v", err, delErr)
- }
- records, err := kbnetwork.NewStore(rootDir).List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 1 || !records[0].Cleanup.Pending || !strings.Contains(records[0].Cleanup.Reason, "cni del failed") {
- t.Fatalf("records after failure = %+v", records)
- }
-}
-
-func TestDeleteVMMultiCNIPreservesNetNSOnPartialFailure(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := vm.New(rootDir)
- rt := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = testRuntimeConfig(rootDir)
- withAddCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIAddRequest) (*kbnetwork.Allocation, error) {
- return testIndexedCNIAllocation(req.VMID, req.Network, req.Index), nil
- })
- delErr := errors.New("cni del eth0 failed")
- withDeleteCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIDeleteRequest) error {
- if req.IfName == "eth0" {
- return delErr
- }
- return nil
- })
- netnsDeleted := false
- withDeleteCNINetNS(t, func(string, string) error {
- netnsDeleted = true
- return nil
- })
-
- rec := createVMWithMultiCNIConfig(t, rt, "pending-multi-cni")
- if _, err := rt.DeleteVM(rec.ID, false); !errors.Is(err, delErr) {
- t.Fatalf("delete error = %v, want %v", err, delErr)
- }
- if netnsDeleted {
- t.Fatal("netns should be preserved when one CNI NIC cleanup fails")
- }
- records, err := kbnetwork.NewStore(rootDir).List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 1 || records[0].IfName != "eth0" || !records[0].Cleanup.Pending {
- t.Fatalf("records after partial failure = %+v", records)
- }
-}
-
-func TestCreateVMAttachesMultipleNetworkConfigs(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := vm.New(rootDir)
- rt := NewWithBackend(store, backendFake{render: func(*vm.VMRecord) error { return nil }})
- rt.cfg = testRuntimeConfig(rootDir)
-
- var requests []kbnetwork.CNIAddRequest
- withAddCNI(t, func(_ context.Context, _ string, _ config.NetworkConfig, req kbnetwork.CNIAddRequest) (*kbnetwork.Allocation, error) {
- requests = append(requests, req)
- allocation := testIndexedCNIAllocation(req.VMID, req.Network, req.Index)
- allocation.Record.NumQueues = req.CPU * 2
- allocation.Config.NumQueues = req.CPU * 2
- return allocation, nil
- })
-
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "multi-cni",
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- CPUs: 3,
- Networks: []string{"cni:front", "cni:back"},
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if rec.Network != "multi" || len(rec.Networks) != 2 {
- t.Fatalf("network intent = network:%s networks:%#v", rec.Network, rec.Networks)
- }
- if len(requests) != 2 || requests[0].Index != 0 || requests[1].Index != 1 {
- t.Fatalf("cni add requests = %+v", requests)
- }
- if requests[0].CPU != 3 || requests[1].CPU != 3 {
- t.Fatalf("cni add request cpus = %+v", requests)
- }
- if requests[0].Network != "cni:front" || requests[1].Network != "cni:back" {
- t.Fatalf("cni add request networks = %+v", requests)
- }
- if len(rec.NetworkConfigs) != 2 {
- t.Fatalf("network configs = %+v", rec.NetworkConfigs)
- }
- if rec.NetworkConfigs[0].NetworkName != "cni:front" || rec.NetworkConfigs[0].IfName != "eth0" {
- t.Fatalf("first network config = %+v", rec.NetworkConfigs[0])
- }
- if rec.NetworkConfigs[1].NetworkName != "cni:back" || rec.NetworkConfigs[1].IfName != "eth1" {
- t.Fatalf("second network config = %+v", rec.NetworkConfigs[1])
- }
- if rec.NetworkConfigs[0].NumQueues != 6 || rec.NetworkConfigs[1].NumQueues != 6 {
- t.Fatalf("network config queues = %+v", rec.NetworkConfigs)
- }
-}
-
-func testRuntimeConfig(rootDir string) config.Config {
- cfg := config.Default()
- cfg.Runtime.RootDir = rootDir
- cfg.Runtime.RunDir = filepath.Join(filepath.Dir(rootDir), "run")
- cfg.Runtime.LogDir = filepath.Join(filepath.Dir(rootDir), "log")
- return cfg
-}
-
-func TestNewReturnsConfiguredStoreError(t *testing.T) {
- cfg := testRuntimeConfig(t.TempDir())
- cfg.Metadata.Backend = "sqlite"
- cfg.Metadata.Path = t.TempDir()
-
- rt, err := New(cfg)
- if err == nil {
- t.Fatal("New succeeded with a directory as the SQLite database path")
- }
- if rt != nil {
- t.Fatalf("runtime = %#v, want nil on construction failure", rt)
- }
-}
-
-func withDeleteHostTap(t *testing.T, fn func(string) error) {
- t.Helper()
- previous := deleteHostTap
- deleteHostTap = fn
- t.Cleanup(func() {
- deleteHostTap = previous
- })
-}
-
-func withDeleteCNI(
- t *testing.T,
- fn func(context.Context, string, config.NetworkConfig, kbnetwork.CNIDeleteRequest) error,
-) {
- t.Helper()
- previous := deleteCNI
- deleteCNI = fn
- t.Cleanup(func() {
- deleteCNI = previous
- })
-}
-
-func withAddCNI(
- t *testing.T,
- fn func(context.Context, string, config.NetworkConfig, kbnetwork.CNIAddRequest) (*kbnetwork.Allocation, error),
-) {
- t.Helper()
- previous := addCNI
- addCNI = fn
- t.Cleanup(func() {
- addCNI = previous
- })
-}
-
-func withDeleteCNINetNS(t *testing.T, fn func(string, string) error) {
- t.Helper()
- previous := deleteCNINetNS
- deleteCNINetNS = fn
- t.Cleanup(func() {
- deleteCNINetNS = previous
- })
-}
-
-func createVMWithNetwork(
- t *testing.T,
- rt *Runtime,
- store *vm.Store,
- name string,
-) (*vm.VMRecord, *kbnetwork.Allocation) {
- t.Helper()
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: name,
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(t.TempDir(), "run"),
- LogDir: filepath.Join(t.TempDir(), "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- allocation, err := kbnetwork.NewAllocator(rt.cfg.Runtime.RootDir, rt.cfg.Network).Allocate(kbnetwork.AllocateRequest{
- VMID: rec.ID,
- Network: "default",
- Index: 0,
- CPU: 1,
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := kbnetwork.NewStore(rt.cfg.Runtime.RootDir).UpsertRecord(allocation.Record); err != nil {
- t.Fatal(err)
- }
- if _, err := store.SetNetworkConfigs(rec.ID, []kbnetwork.Config{allocation.Config}); err != nil {
- t.Fatal(err)
- }
- updated, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- return updated, allocation
-}
-
-func createVMWithCNIConfig(t *testing.T, rt *Runtime, name string) *vm.VMRecord {
- t.Helper()
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: name,
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- Network: "cni:default",
- RunDir: filepath.Join(t.TempDir(), "run"),
- LogDir: filepath.Join(t.TempDir(), "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- return rec
-}
-
-func createVMWithMultiCNIConfig(t *testing.T, rt *Runtime, name string) *vm.VMRecord {
- t.Helper()
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: name,
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- Networks: []string{"cni:front", "cni:back"},
- RunDir: filepath.Join(t.TempDir(), "run"),
- LogDir: filepath.Join(t.TempDir(), "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- return rec
-}
-
-func testCNIAllocation(vmID string) *kbnetwork.Allocation {
- return testIndexedCNIAllocation(vmID, "cni:default", 0)
-}
-
-func testIndexedCNIAllocation(vmID, networkName string, index int) *kbnetwork.Allocation {
- netCfg := kbnetwork.Config{
- ID: kbnetwork.NetworkID(vmID, index),
- NetworkName: networkName,
- TAP: fmt.Sprintf("kbcni%d", index),
- MAC: "5a:00:00:00:00:55",
- NumQueues: 2,
- QueueSize: 256,
- Backend: kbnetwork.ProviderCNI,
- IfName: fmt.Sprintf("eth%d", index),
- NetnsPath: "/proc/self/ns/net",
- }
- record := kbnetwork.Record{
- ID: netCfg.ID,
- VMID: vmID,
- Network: networkName,
- Provider: kbnetwork.ProviderCNI,
- IfName: netCfg.IfName,
- TAP: netCfg.TAP,
- MAC: netCfg.MAC,
- NumQueues: netCfg.NumQueues,
- QueueSize: netCfg.QueueSize,
- NetnsPath: netCfg.NetnsPath,
- CreatedAt: time.Now().UTC(),
- UpdatedAt: time.Now().UTC(),
- }
- return &kbnetwork.Allocation{Record: record, Config: netCfg}
-}
-
-func TestPrepareStorageCreatesCOWAndChecksLayers(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- layer := filepath.Join(dir, "layer.erofs")
- if err := os.WriteFile(layer, []byte("erofs"), 0o600); err != nil {
- t.Fatal(err)
- }
- cow := filepath.Join(rootDir, "storage", "vms", "kb_storage", "cow.ext4")
- oldMkfs := mkfsExt4
- mkfsExt4 = func(path string) ([]byte, error) {
- if path != cow {
- t.Fatalf("mkfs path = %s, want %s", path, cow)
- }
- return []byte("ok"), nil
- }
- defer func() { mkfsExt4 = oldMkfs }()
-
- rec := &vm.VMRecord{
- ID: "kb_storage",
- RunDir: filepath.Join(dir, "run", "vms", "kb_storage"),
- Image: &vm.ImageRef{
- ID: "img_oci",
- BootMode: "direct",
- },
- StorageConfigs: []vm.StorageConfig{
- {ID: "layer0", Role: vm.StorageRoleLayer, Path: layer, Readonly: true, Format: "raw", Filesystem: "erofs"},
- {
- ID: "cow",
- Role: vm.StorageRoleCOW,
- Path: cow,
- Format: "raw",
- Filesystem: "ext4",
- VirtualSizeBytes: 2 * 1024 * 1024,
- Base: &vm.StorageBase{
- Family: "oci",
- ImageID: "img_oci",
- Digest: "sha256:manifest",
- LayerDigests: []string{"sha256:layer"},
- },
- },
- },
- }
- if err := prepareStorage(rec, rootDir); err != nil {
- t.Fatal(err)
- }
- info, err := os.Stat(cow)
- if err != nil {
- t.Fatal(err)
- }
- if info.Size() != 2*1024*1024 {
- t.Fatalf("cow size = %d", info.Size())
- }
-}
-
-func TestPrepareStorageRejectsMissingLayer(t *testing.T) {
- dir := t.TempDir()
- err := prepareStorage(&vm.VMRecord{
- ID: "kb_missing",
- RunDir: filepath.Join(dir, "run", "vms", "kb_missing"),
- StorageConfigs: []vm.StorageConfig{
- {ID: "layer0", Role: vm.StorageRoleLayer, Path: "/missing/layer.erofs", Readonly: true, Format: "raw", Filesystem: "erofs"},
- },
- }, filepath.Join(dir, "data"))
- if err == nil {
- t.Fatal("expected missing layer error")
- }
-}
-
-func TestCreateStoppedSnapshotCapturesManagedCOW(t *testing.T) {
- t.Parallel()
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := vm.New(rootDir)
- rec, err := store.Create(vm.CreateRequest{
- Name: "snapshot-source", Kernel: "vmlinuz", Initrd: "initrd", RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- Image: &vm.ImageRef{ID: "img_oci", Digest: "sha256:manifest"},
- StorageConfigs: []vm.StorageConfig{{
- ID: "cow", Role: vm.StorageRoleCOW, Format: "raw", Filesystem: "ext4", VirtualSizeBytes: 4096,
- Base: &vm.StorageBase{Family: "oci", ImageID: "img_oci", Digest: "sha256:manifest", LayerDigests: []string{"sha256:layer"}},
- }},
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Dir(rec.StorageConfigs[0].Path), 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(rec.StorageConfigs[0].Path, []byte("writable"), 0o600); err != nil {
- t.Fatal(err)
- }
- if err := store.UpdateStates([]string{rec.ID}, vm.StateStopped); err != nil {
- t.Fatal(err)
- }
- rt := NewWithBackend(store, backendFake{observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: vm.ObservedStateStopped, Reason: "stopped", CheckedAt: time.Now().UTC()}
- }})
- snap, err := rt.CreateStoppedSnapshot(context.Background(), rec.ID, "snap-one")
- if err != nil {
- t.Fatal(err)
- }
- if snap.State != "ready" || snap.SizeBytes <= 0 {
- t.Fatalf("snapshot = %+v", snap)
- }
-}
diff --git a/internal/vm/runtime/state.go b/internal/vm/runtime/state.go
deleted file mode 100644
index 58206b0..0000000
--- a/internal/vm/runtime/state.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package runtime
-
-import (
- "context"
- "fmt"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/metering"
- "github.com/kumabox/kumabox/internal/operation"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-// PauseVM pauses a running VM while holding its cross-process operation lock.
-func (r *Runtime) PauseVM(ctx context.Context, ref string) (*vm.VMRecord, error) {
- return r.transitionVMState(ctx, ref, vm.StatePaused)
-}
-
-// ResumeVM resumes a paused VM while holding its cross-process operation lock.
-func (r *Runtime) ResumeVM(ctx context.Context, ref string) (*vm.VMRecord, error) {
- return r.transitionVMState(ctx, ref, vm.StateRunning)
-}
-
-func (r *Runtime) transitionVMState(ctx context.Context, ref string, target vm.VMState) (result *vm.VMRecord, resultErr error) {
- mutation, err := r.resourceGuard.BeginMutation(ctx)
- if err != nil {
- return nil, err
- }
- defer mutation.Release() //nolint:errcheck
-
- rec, err := r.vmReader.Inspect(ref)
- if err != nil {
- return nil, err
- }
- operationID, err := r.beginOperation(ctx, liveStateOperation(target), rec.ID)
- if err != nil {
- return nil, err
- }
- defer func() { resultErr = r.finishOperation(ctx, operationID, resultErr) }()
- lock, err := r.vmLocks.Acquire(ctx, rec.ID)
- if err != nil {
- return nil, fmt.Errorf("lock VM %s for %s: %w", rec.ID, target, err)
- }
- defer lock.Release() //nolint:errcheck
-
- rec, err = r.vmReader.Inspect(rec.ID)
- if err != nil {
- return nil, err
- }
- observed := r.applyObservation(rec)
- controller, ok := r.backend.(backend.StateController)
- if !ok {
- return nil, fmt.Errorf("BACKEND_OPERATION_UNSUPPORTED: backend does not support %s", target)
- }
-
- switch target {
- case vm.StatePaused:
- if observed.ObservedState == vm.ObservedStatePaused {
- updated, persistErr := r.persistLiveState(observed.ID, target)
- if persistErr != nil {
- return nil, persistErr
- }
- return r.applyObservation(updated), nil
- }
- if observed.ObservedState != vm.ObservedStateRunning {
- return nil, fmt.Errorf("VM_NOT_RUNNING: VM %s observed state is %s", observed.Name, observed.ObservedState)
- }
- if err := controller.PauseVM(ctx, observed); err != nil {
- return nil, fmt.Errorf("pause VM %s: %w", observed.Name, err)
- }
- case vm.StateRunning:
- if observed.ObservedState == vm.ObservedStateRunning {
- updated, persistErr := r.persistLiveState(observed.ID, target)
- if persistErr != nil {
- return nil, persistErr
- }
- return r.applyObservation(updated), nil
- }
- if observed.ObservedState != vm.ObservedStatePaused {
- return nil, fmt.Errorf("VM_NOT_PAUSED: VM %s observed state is %s", observed.Name, observed.ObservedState)
- }
- if err := controller.ResumeVM(ctx, observed); err != nil {
- return nil, fmt.Errorf("resume VM %s: %w", observed.Name, err)
- }
- default:
- return nil, fmt.Errorf("unsupported live state transition target %s", target)
- }
-
- updated, err := r.persistLiveState(observed.ID, target)
- if err != nil {
- return nil, err
- }
- if target == vm.StatePaused {
- r.recordComputeStop(ctx, updated, metering.ReasonPause)
- } else {
- r.recordComputeStart(ctx, updated, metering.ReasonResume)
- }
- expected := vm.ObservedStatePaused
- eventType := "backend.pause.completed"
- if target == vm.StateRunning {
- expected = vm.ObservedStateRunning
- eventType = "backend.resume.completed"
- }
- updated = r.applyObservation(updated)
- if updated.ObservedState != expected {
- return nil, fmt.Errorf("BACKEND_STATE_MISMATCH: %s succeeded but backend observed state is %s", target, updated.ObservedState)
- }
- _ = writeVMEvent(updated, eventType, vm.Observation{
- State: expected,
- Reason: "VM " + string(target),
- CheckedAt: time.Now().UTC(),
- })
- return updated, nil
-}
-
-func liveStateOperation(target vm.VMState) string {
- switch target {
- case vm.StatePaused:
- return operation.KindVMPause
- case vm.StateRunning:
- return operation.KindVMResume
- default:
- return "vm.state-transition"
- }
-}
-
-func (r *Runtime) persistLiveState(ref string, state vm.VMState) (*vm.VMRecord, error) {
- if err := r.vmUpdater.UpdateStates([]string{ref}, state); err != nil {
- return nil, err
- }
- return r.vmReader.Inspect(ref)
-}
diff --git a/internal/vm/runtime/state_open.go b/internal/vm/runtime/state_open.go
deleted file mode 100644
index d101da8..0000000
--- a/internal/vm/runtime/state_open.go
+++ /dev/null
@@ -1,11 +0,0 @@
-package runtime
-
-import (
- "github.com/kumabox/kumabox/internal/state"
-)
-
-func openStateWithVM(rootDir string, vmState state.VMState) state.Set {
- data := state.OpenJSON(rootDir)
- data.VM = vmState
- return data
-}
diff --git a/internal/vm/runtime/state_test.go b/internal/vm/runtime/state_test.go
deleted file mode 100644
index 7994a35..0000000
--- a/internal/vm/runtime/state_test.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package runtime
-
-import (
- "context"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/backend"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestPauseResumePersistsLiveState(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- backendState := vm.ObservedStateRunning
- pauseCalls := 0
- resumeCalls := 0
- rt := NewWithBackend(store, backendFake{
- render: func(*vm.VMRecord) error { return nil },
- start: func(*vm.VMRecord) (*backend.StartResult, error) {
- return &backend.StartResult{PID: 1234, APISocket: "/tmp/ch.sock"}, nil
- },
- pause: func(context.Context, *vm.VMRecord) error {
- pauseCalls++
- backendState = vm.ObservedStatePaused
- return nil
- },
- resume: func(context.Context, *vm.VMRecord) error {
- resumeCalls++
- backendState = vm.ObservedStateRunning
- return nil
- },
- observe: func(*vm.VMRecord) vm.Observation {
- return vm.Observation{State: backendState, CheckedAt: time.Now().UTC()}
- },
- })
- rec, err := rt.CreateVM(vm.CreateRequest{
- Name: "state", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"), Network: "none",
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := store.MarkStarted(rec.ID, 1234, "/tmp/ch.sock"); err != nil {
- t.Fatal(err)
- }
-
- paused, err := rt.PauseVM(context.Background(), rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if paused.State != vm.StatePaused || paused.ObservedState != vm.ObservedStatePaused || paused.PID != 1234 {
- t.Fatalf("paused record = %+v", paused)
- }
- if _, err := rt.PauseVM(context.Background(), rec.ID); err != nil {
- t.Fatal(err)
- }
- resumed, err := rt.ResumeVM(context.Background(), rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if resumed.State != vm.StateRunning || resumed.ObservedState != vm.ObservedStateRunning || resumed.PID != 1234 {
- t.Fatalf("resumed record = %+v", resumed)
- }
- if _, err := rt.ResumeVM(context.Background(), rec.ID); err != nil {
- t.Fatal(err)
- }
- if pauseCalls != 1 || resumeCalls != 1 {
- t.Fatalf("transition calls pause=%d resume=%d", pauseCalls, resumeCalls)
- }
-}
diff --git a/internal/vm/runtime/watch.go b/internal/vm/runtime/watch.go
deleted file mode 100644
index 07cb0d9..0000000
--- a/internal/vm/runtime/watch.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package runtime
-
-import (
- "context"
- "fmt"
- "sort"
- "time"
-
- "github.com/kumabox/kumabox/internal/state"
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-const (
- VMEventAdded = "ADDED"
- VMEventModified = "MODIFIED"
- VMEventDeleted = "DELETED"
-)
-
-// VMStatusEvent describes one change in the selected VM set.
-type VMStatusEvent struct {
- Event string `json:"event"`
- VM *vm.VMRecord `json:"vm"`
-}
-
-// VMStatusUpdate is emitted only when the selected VM status changes.
-type VMStatusUpdate struct {
- Records []*vm.VMRecord
- Events []VMStatusEvent
-}
-
-// WatchVMs emits an initial snapshot and then status changes until ctx is
-// cancelled. Metadata events reduce latency; polling remains the correctness
-// mechanism because both JSON and SQLite engines may coalesce notifications.
-func (r *Runtime) WatchVMs(
- ctx context.Context,
- refs []string,
- interval time.Duration,
- emit func(VMStatusUpdate) error,
-) error {
- if interval <= 0 {
- return fmt.Errorf("watch interval must be positive")
- }
- if emit == nil {
- return fmt.Errorf("watch emitter is required")
- }
-
- changes, release := r.subscribeVMEvents(ctx)
- defer release()
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
-
- var previous map[string]vmStatusEntry
- for {
- if ctx.Err() != nil {
- return nil
- }
- records, err := r.listSelectedVMs(refs)
- if err != nil {
- return err
- }
- current := snapshotVMStatuses(records)
- events := diffVMStatuses(previous, current)
- if previous == nil || len(events) > 0 {
- if err := emit(VMStatusUpdate{Records: records, Events: events}); err != nil {
- return err
- }
- }
- previous = current
-
- select {
- case <-ctx.Done():
- return nil
- case _, ok := <-changes:
- if !ok {
- changes = nil
- }
- case <-ticker.C:
- }
- }
-}
-
-func (r *Runtime) listSelectedVMs(refs []string) ([]*vm.VMRecord, error) {
- records, err := r.ListVMs()
- if err != nil || len(refs) == 0 {
- return records, err
- }
- selected := make([]*vm.VMRecord, 0, len(refs))
- seen := make(map[string]struct{}, len(refs))
- for _, ref := range refs {
- for _, record := range records {
- if record.ID != ref && record.Name != ref {
- continue
- }
- if _, ok := seen[record.ID]; !ok {
- selected = append(selected, record)
- seen[record.ID] = struct{}{}
- }
- break
- }
- }
- return selected, nil
-}
-
-func (r *Runtime) subscribeVMEvents(ctx context.Context) (<-chan struct{}, func()) {
- source, ok := r.vmReader.(state.VMEvents)
- if !ok {
- return nil, func() {}
- }
- changes, release, err := source.Events(ctx)
- if err != nil {
- return nil, func() {}
- }
- return changes, release
-}
-
-type vmStatusEntry struct {
- record *vm.VMRecord
- snapshot vmStatusSnapshot
-}
-
-type vmStatusSnapshot struct {
- Name string
- State vm.VMState
- ObservedState vm.ObservedState
- ObservedReason string
- Backend string
- PID int
- Error string
- UpdatedAt time.Time
-}
-
-func snapshotVMStatuses(records []*vm.VMRecord) map[string]vmStatusEntry {
- result := make(map[string]vmStatusEntry, len(records))
- for _, record := range records {
- if record == nil {
- continue
- }
- result[record.ID] = vmStatusEntry{
- record: record,
- snapshot: vmStatusSnapshot{
- Name: record.Name, State: record.State,
- ObservedState: record.ObservedState, ObservedReason: record.ObservedReason,
- Backend: record.Backend, PID: record.PID, Error: record.Error,
- UpdatedAt: record.UpdatedAt,
- },
- }
- }
- return result
-}
-
-func diffVMStatuses(previous, current map[string]vmStatusEntry) []VMStatusEvent {
- ids := make([]string, 0, len(previous)+len(current))
- seen := make(map[string]struct{}, len(previous)+len(current))
- for id := range current {
- ids = append(ids, id)
- seen[id] = struct{}{}
- }
- for id := range previous {
- if _, ok := seen[id]; !ok {
- ids = append(ids, id)
- }
- }
- sort.Strings(ids)
-
- events := make([]VMStatusEvent, 0, len(ids))
- for _, id := range ids {
- before, existed := previous[id]
- after, exists := current[id]
- switch {
- case !existed && exists:
- events = append(events, VMStatusEvent{Event: VMEventAdded, VM: after.record})
- case existed && !exists:
- events = append(events, VMStatusEvent{Event: VMEventDeleted, VM: before.record})
- case before.snapshot != after.snapshot:
- events = append(events, VMStatusEvent{Event: VMEventModified, VM: after.record})
- }
- }
- return events
-}
diff --git a/internal/vm/runtime/watch_test.go b/internal/vm/runtime/watch_test.go
deleted file mode 100644
index cadb65a..0000000
--- a/internal/vm/runtime/watch_test.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package runtime
-
-import (
- "context"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/kumabox/kumabox/internal/vm"
-)
-
-func TestDiffVMStatusesIgnoresObservationTimestamp(t *testing.T) {
- t.Parallel()
-
- first := time.Unix(10, 0).UTC()
- second := first.Add(time.Second)
- before := &vm.VMRecord{
- ID: "vm-1", Name: "example", State: vm.StateRunning,
- ObservedState: vm.ObservedStateRunning, ObservedAt: &first,
- }
- after := *before
- after.ObservedAt = &second
-
- events := diffVMStatuses(snapshotVMStatuses([]*vm.VMRecord{before}), snapshotVMStatuses([]*vm.VMRecord{&after}))
- if len(events) != 0 {
- t.Fatalf("timestamp-only change emitted events: %+v", events)
- }
-
- after.State = vm.StatePaused
- events = diffVMStatuses(snapshotVMStatuses([]*vm.VMRecord{before}), snapshotVMStatuses([]*vm.VMRecord{&after}))
- if len(events) != 1 || events[0].Event != VMEventModified {
- t.Fatalf("state change events = %+v", events)
- }
-}
-
-func TestWatchVMsUsesMetadataEventsBeforePollingFallback(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- rt := NewWithBackend(store, backendFake{})
- ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
- defer cancel()
-
- updates := make(chan VMStatusUpdate, 2)
- errors := make(chan error, 1)
- go func() {
- errors <- rt.WatchVMs(ctx, nil, time.Hour, func(update VMStatusUpdate) error {
- updates <- update
- return nil
- })
- }()
-
- initial := <-updates
- if len(initial.Records) != 0 || len(initial.Events) != 0 {
- t.Fatalf("initial update = %+v", initial)
- }
- created, err := store.Create(vm.CreateRequest{
- Name: "watched", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
-
- select {
- case update := <-updates:
- if len(update.Events) != 1 || update.Events[0].Event != VMEventAdded || update.Events[0].VM.ID != created.ID {
- t.Fatalf("metadata-triggered update = %+v", update)
- }
- cancel()
- case <-ctx.Done():
- t.Fatal("watch did not wake from metadata event")
- }
- if err := <-errors; err != nil {
- t.Fatal(err)
- }
-}
-
-func TestListSelectedVMsPreservesRequestedOrder(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- rt := NewWithBackend(store, backendFake{})
- for _, name := range []string{"first", "second"} {
- if _, err := store.Create(vm.CreateRequest{
- Name: name, RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- }); err != nil {
- t.Fatal(err)
- }
- }
- records, err := rt.listSelectedVMs([]string{"second", "missing", "first", "second"})
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 2 || records[0].Name != "second" || records[1].Name != "first" {
- t.Fatalf("selected records = %+v", records)
- }
-}
-
-func TestWatchVMsStopsWhenContextIsCancelled(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- rt := NewWithBackend(vm.New(filepath.Join(dir, "data")), backendFake{})
- ctx, cancel := context.WithCancel(t.Context())
- emitted := make(chan struct{}, 1)
- done := make(chan error, 1)
- go func() {
- done <- rt.WatchVMs(ctx, nil, time.Hour, func(VMStatusUpdate) error {
- emitted <- struct{}{}
- return nil
- })
- }()
-
- <-emitted
- cancel()
- select {
- case err := <-done:
- if err != nil {
- t.Fatal(err)
- }
- case <-time.After(time.Second):
- t.Fatal("watch did not stop after context cancellation")
- }
-}
-
-func TestWatchVMsRecoversFinalStateAfterCoalescedEvents(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- store := vm.New(filepath.Join(dir, "data"))
- rt := NewWithBackend(store, backendFake{})
- ctx, cancel := context.WithCancel(t.Context())
- defer cancel()
-
- initial := make(chan struct{})
- unblock := make(chan struct{})
- updates := make(chan VMStatusUpdate, 1)
- done := make(chan error, 1)
- go func() {
- first := true
- done <- rt.WatchVMs(ctx, nil, time.Hour, func(update VMStatusUpdate) error {
- if first {
- first = false
- close(initial)
- <-unblock
- return nil
- }
- updates <- update
- return nil
- })
- }()
- <-initial
-
- first, err := store.Create(vm.CreateRequest{
- Name: "first", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := store.Create(vm.CreateRequest{
- Name: "second", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- }); err != nil {
- t.Fatal(err)
- }
- if err := store.Delete(first.ID); err != nil {
- t.Fatal(err)
- }
- close(unblock)
-
- select {
- case update := <-updates:
- if len(update.Records) != 1 || update.Records[0].Name != "second" {
- t.Fatalf("coalesced update did not recover final state: %+v", update)
- }
- cancel()
- case <-time.After(time.Second):
- t.Fatal("watch did not process coalesced metadata event")
- }
- if err := <-done; err != nil {
- t.Fatal(err)
- }
-}
diff --git a/internal/vm/storage.go b/internal/vm/storage.go
deleted file mode 100644
index 3524c45..0000000
--- a/internal/vm/storage.go
+++ /dev/null
@@ -1,180 +0,0 @@
-package vm
-
-import (
- "errors"
- "fmt"
- "path/filepath"
- "strings"
-)
-
-// ErrInvalidStorageContract identifies a VM record whose disk ownership or
-// immutable backing references violate KumaBox storage invariants.
-var ErrInvalidStorageContract = errors.New("invalid storage contract")
-
-// ValidateStorageContract validates the durable disk model for a VM record.
-// Legacy P3 Type/ImageType records remain readable, but newly modeled writable
-// disks must live under the VM's durable owner directory.
-func ValidateStorageContract(rec *VMRecord, rootDir string) error {
- if rec == nil {
- return storageError("VM record is nil")
- }
- if rec.ID == "" || rec.ID == "." || rec.ID == ".." || strings.ContainsAny(rec.ID, `/\\`) {
- return storageError("VM id %q is not safe for managed paths", rec.ID)
- }
- if len(rec.StorageConfigs) == 0 {
- return nil
- }
-
- ownerDir := filepath.Join(rootDir, "storage", "vms", rec.ID)
- ids := make(map[string]struct{}, len(rec.StorageConfigs))
- cowCount := 0
- for i, storage := range rec.StorageConfigs {
- if storage.ID == "" || storage.ID == "." || storage.ID == ".." || strings.ContainsAny(storage.ID, `/\\`) {
- return storageError("storage %d has unsafe id %q", i, storage.ID)
- }
- if _, exists := ids[storage.ID]; exists {
- return storageError("duplicate storage id %q", storage.ID)
- }
- ids[storage.ID] = struct{}{}
-
- role := storage.EffectiveRole()
- if !validStorageRole(role) {
- return storageError("storage %q has unsupported role %q", storage.ID, role)
- }
- if storage.Path == "" || !filepath.IsAbs(storage.Path) {
- return storageError("storage %q path must be absolute", storage.ID)
- }
- if err := validateStorageAccess(storage, role); err != nil {
- return err
- }
- if err := validateStorageShape(storage, role); err != nil {
- return err
- }
-
- if role == StorageRoleCOW || role == StorageRoleData {
- legacy := storage.Role == "" && storage.Type != ""
- if !pathWithin(storage.Path, ownerDir) && (!legacy || !pathWithin(storage.Path, rec.RunDir)) {
- return storageError("writable storage %q is outside VM owner directory", storage.ID)
- }
- }
- if role == StorageRoleCOW {
- cowCount++
- if err := validateCOWBase(rec, storage); err != nil {
- return err
- }
- }
- }
- if cowCount > 1 {
- return storageError("VM has %d root COW disks; at most one is allowed", cowCount)
- }
- return nil
-}
-
-func validStorageRole(role StorageRole) bool {
- switch role {
- case StorageRoleLayer, StorageRoleBase, StorageRoleCOW, StorageRoleData, StorageRoleCidata:
- return true
- default:
- return false
- }
-}
-
-func validateStorageAccess(storage StorageConfig, role StorageRole) error {
- wantReadonly := role == StorageRoleLayer || role == StorageRoleBase || role == StorageRoleCidata
- if storage.Readonly != wantReadonly {
- access := "writable"
- if wantReadonly {
- access = "read-only"
- }
- return storageError("storage %q with role %q must be %s", storage.ID, role, access)
- }
- return nil
-}
-
-func validateStorageShape(storage StorageConfig, role StorageRole) error {
- format := storage.EffectiveFormat()
- switch role {
- case StorageRoleLayer:
- if format != FormatRaw || storage.Filesystem != FilesystemEROFS {
- return storageError("layer %q must use raw EROFS", storage.ID)
- }
- case StorageRoleCOW:
- if format == FormatRaw && storage.Filesystem == FilesystemEXT4 {
- break
- }
- if format == FormatQCOW2 && storage.Filesystem == "" {
- break
- }
- return storageError("COW storage %q must use raw ext4 or qcow2", storage.ID)
- case StorageRoleData:
- if format != FormatRaw && format != FormatQCOW2 {
- return storageError("data storage %q must use raw or qcow2 format", storage.ID)
- }
- if storage.Filesystem != FilesystemEXT4 && storage.Filesystem != FilesystemNone {
- return storageError("data storage %q must use ext4 or none filesystem", storage.ID)
- }
- if storage.Filesystem == FilesystemNone && storage.MountPoint != "" {
- return storageError("data storage %q with filesystem none cannot have a mount point", storage.ID)
- }
- if storage.MountPoint != "" && (!filepath.IsAbs(storage.MountPoint) || storage.MountPoint == "/" || strings.ContainsAny(storage.MountPoint, "\x00\n")) {
- return storageError("data storage %q mount point must be an absolute non-root path", storage.ID)
- }
- case StorageRoleCidata:
- if format != FormatRaw {
- return storageError("cidata storage %q must use raw format", storage.ID)
- }
- case StorageRoleBase:
- if format != FormatQCOW2 && format != FormatRaw {
- return storageError("base storage %q must use raw or qcow2 format", storage.ID)
- }
- }
- if (role == StorageRoleCOW || role == StorageRoleData) && storage.EffectiveVirtualSize() <= 0 {
- return storageError("writable storage %q virtual size must be positive", storage.ID)
- }
- return nil
-}
-
-func validateCOWBase(rec *VMRecord, storage StorageConfig) error {
- base := storage.Base
- if base == nil {
- if storage.Role == "" && storage.Type != "" {
- return nil
- }
- return storageError("COW storage %q has no immutable base reference", storage.ID)
- }
- if base.Family != "cloudimg" && base.Family != "oci" {
- return storageError("COW storage %q has unsupported base family %q", storage.ID, base.Family)
- }
- if base.ImageID == "" || base.Digest == "" {
- return storageError("COW storage %q base image id and digest are required", storage.ID)
- }
- if rec.Image == nil || rec.Image.ID != base.ImageID {
- return storageError("COW storage %q base image does not match VM image", storage.ID)
- }
- format := storage.EffectiveFormat()
- if base.Family == "cloudimg" {
- if format != FormatQCOW2 || base.Format != FormatQCOW2 || base.Path == "" {
- return storageError("cloudimg COW storage %q requires a qcow2 base path", storage.ID)
- }
- return nil
- }
- if format != FormatRaw || storage.Filesystem != FilesystemEXT4 || len(base.LayerDigests) == 0 {
- return storageError("OCI COW storage %q requires raw ext4 and layer digests", storage.ID)
- }
- return nil
-}
-
-func pathWithin(path, parent string) bool {
- if path == "" || parent == "" {
- return false
- }
- rel, err := filepath.Rel(parent, path)
- if err != nil {
- return false
- }
- return rel != ".." && rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
-}
-
-func storageError(format string, args ...any) error {
- return fmt.Errorf("%w: %s", ErrInvalidStorageContract, fmt.Sprintf(format, args...))
-}
diff --git a/internal/vm/storage_test.go b/internal/vm/storage_test.go
deleted file mode 100644
index 9623b32..0000000
--- a/internal/vm/storage_test.go
+++ /dev/null
@@ -1,182 +0,0 @@
-package vm
-
-import (
- "errors"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestValidateStorageContract(t *testing.T) {
- rootDir := t.TempDir()
- ownerDir := filepath.Join(rootDir, "storage", "vms", "kb_test")
- baseRecord := func() *VMRecord {
- return &VMRecord{
- ID: "kb_test",
- RunDir: filepath.Join(rootDir, "run", "vms", "kb_test"),
- Image: &ImageRef{ID: "img_oci", BootMode: "direct"},
- StorageConfigs: []StorageConfig{
- {
- ID: "layer0",
- Role: StorageRoleLayer,
- Path: filepath.Join(rootDir, "oci", "layer.erofs"),
- Readonly: true,
- Format: "raw",
- Filesystem: "erofs",
- },
- {
- ID: "cow",
- Role: StorageRoleCOW,
- Path: filepath.Join(ownerDir, "cow.ext4"),
- Format: "raw",
- Filesystem: "ext4",
- VirtualSizeBytes: 64 << 20,
- Base: &StorageBase{
- Family: "oci",
- ImageID: "img_oci",
- Digest: "sha256:manifest",
- LayerDigests: []string{"sha256:layer"},
- },
- },
- },
- }
- }
-
- tests := []struct {
- name string
- mutate func(*VMRecord)
- valid bool
- }{
- {name: "valid OCI contract", valid: true},
- {name: "read-only COW", mutate: func(rec *VMRecord) { rec.StorageConfigs[1].Readonly = true }},
- {name: "writable layer", mutate: func(rec *VMRecord) { rec.StorageConfigs[0].Readonly = false }},
- {name: "unsupported role", mutate: func(rec *VMRecord) { rec.StorageConfigs[1].Role = "cache" }},
- {name: "duplicate id", mutate: func(rec *VMRecord) { rec.StorageConfigs[1].ID = "layer0" }},
- {name: "unsafe id", mutate: func(rec *VMRecord) { rec.StorageConfigs[1].ID = "../cow" }},
- {name: "writable path outside owner", mutate: func(rec *VMRecord) { rec.StorageConfigs[1].Path = filepath.Join(rootDir, "escape.ext4") }},
- {name: "missing base digest", mutate: func(rec *VMRecord) { rec.StorageConfigs[1].Base.Digest = "" }},
- {name: "wrong OCI format", mutate: func(rec *VMRecord) { rec.StorageConfigs[1].Format = "qcow2" }},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- rec := cloneRecord(baseRecord())
- if tt.mutate != nil {
- tt.mutate(rec)
- }
- err := ValidateStorageContract(rec, rootDir)
- if tt.valid && err != nil {
- t.Fatalf("ValidateStorageContract() error = %v", err)
- }
- if !tt.valid && !errors.Is(err, ErrInvalidStorageContract) {
- t.Fatalf("ValidateStorageContract() error = %v, want ErrInvalidStorageContract", err)
- }
- })
- }
-}
-
-func TestStoreReadsLegacyP3StorageRecord(t *testing.T) {
- rootDir := t.TempDir()
- backendDir := filepath.Join(rootDir, "backends", backendCloudHypervisor)
- if err := os.MkdirAll(backendDir, 0o755); err != nil {
- t.Fatal(err)
- }
- runDir := filepath.Join(rootDir, "run", "vms", "kb_legacy")
- index := `{
- "vms": {
- "kb_legacy": {
- "id": "kb_legacy",
- "name": "legacy",
- "backend": "cloud-hypervisor",
- "state": "stopped",
- "image": {"id":"img_legacy","name":"legacy","bootMode":"direct"},
- "storageConfigs": [
- {"id":"layer0","type":"layer","path":"/tmp/layer.erofs","readonly":true,"imageType":"raw","filesystem":"erofs"},
- {"id":"cow","type":"cow","path":"` + filepath.ToSlash(filepath.Join(runDir, "cow.ext4")) + `","imageType":"raw","filesystem":"ext4","sizeBytes":67108864}
- ],
- "runDir": "` + filepath.ToSlash(runDir) + `",
- "logDir": "` + filepath.ToSlash(filepath.Join(rootDir, "log", "vms", "kb_legacy")) + `",
- "config": "` + filepath.ToSlash(filepath.Join(runDir, "cloud-hypervisor.json")) + `",
- "createdAt": "2026-07-14T00:00:00Z",
- "updatedAt": "2026-07-14T00:00:00Z"
- }
- },
- "names": {"legacy":"kb_legacy"}
-}`
- if err := os.WriteFile(filepath.Join(backendDir, "index.json"), []byte(index), 0o600); err != nil {
- t.Fatal(err)
- }
-
- rec, err := New(rootDir).Inspect("legacy")
- if err != nil {
- t.Fatal(err)
- }
- if rec.StorageConfigs[0].EffectiveRole() != StorageRoleLayer || rec.StorageConfigs[1].EffectiveRole() != StorageRoleCOW {
- t.Fatalf("legacy roles were not resolved: %+v", rec.StorageConfigs)
- }
- if rec.StorageConfigs[1].EffectiveVirtualSize() != 64<<20 {
- t.Fatalf("legacy size = %d", rec.StorageConfigs[1].EffectiveVirtualSize())
- }
-}
-
-func TestCreatePlacesCOWInDurableOwnerDirectory(t *testing.T) {
- rootDir := t.TempDir()
- store := New(rootDir)
- rec, err := store.Create(CreateRequest{
- Name: "durable-cow",
- Kernel: "vmlinuz",
- Initrd: "initrd",
- Image: &ImageRef{ID: "img_oci", Name: "oci", BootMode: "direct"},
- StorageConfigs: []StorageConfig{
- {ID: "layer0", Role: StorageRoleLayer, Path: filepath.Join(rootDir, "layer.erofs"), Readonly: true, Format: "raw", Filesystem: "erofs"},
- {ID: "cow", Role: StorageRoleCOW, Format: "raw", Filesystem: "ext4", VirtualSizeBytes: 64 << 20, Base: &StorageBase{Family: "oci", ImageID: "img_oci", Digest: "sha256:manifest", LayerDigests: []string{"sha256:layer"}}},
- },
- RunDir: filepath.Join(rootDir, "run"),
- LogDir: filepath.Join(rootDir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- want := filepath.Join(rootDir, "storage", "vms", rec.ID, "cow.ext4")
- if rec.StorageConfigs[1].Path != want {
- t.Fatalf("COW path = %s, want %s", rec.StorageConfigs[1].Path, want)
- }
-}
-
-func TestCreateNormalizesManagedDataDisks(t *testing.T) {
- rootDir := t.TempDir()
- store := New(rootDir)
- rec, err := store.Create(CreateRequest{
- Name: "data-disks", Kernel: "vmlinuz", Initrd: "initrd",
- Image: &ImageRef{ID: "img_oci", Name: "oci", BootMode: "direct"},
- StorageConfigs: []StorageConfig{{ID: "layer0", Role: StorageRoleLayer, Path: filepath.Join(rootDir, "layer.erofs"), Readonly: true, Format: FormatRaw, Filesystem: FilesystemEROFS}, {ID: "cow", Role: StorageRoleCOW, Format: FormatRaw, Filesystem: FilesystemEXT4, VirtualSizeBytes: 64 << 20, Base: &StorageBase{Family: BaseFamilyOCI, ImageID: "img_oci", Digest: "sha256:manifest", LayerDigests: []string{"sha256:layer"}}}},
- DataDisks: []DataDiskRequest{{Name: "workspace", SizeBytes: 16 << 20}},
- RunDir: filepath.Join(rootDir, "run"), LogDir: filepath.Join(rootDir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if len(rec.StorageConfigs) != 3 {
- t.Fatalf("storage count = %d, want 3", len(rec.StorageConfigs))
- }
- disk := rec.StorageConfigs[2]
- if disk.ID != "data-workspace" || disk.Serial != "workspace" || disk.MountPoint != "/mnt/workspace" || disk.Filesystem != FilesystemEXT4 {
- t.Fatalf("data disk = %+v", disk)
- }
- wantPath := filepath.Join(rootDir, "storage", "vms", rec.ID, "data-workspace.raw")
- if disk.Path != wantPath {
- t.Fatalf("data disk path = %s, want %s", disk.Path, wantPath)
- }
-}
-
-func TestCreateRejectsInvalidManagedDataDisk(t *testing.T) {
- rootDir := t.TempDir()
- store := New(rootDir)
- _, err := store.Create(CreateRequest{
- Name: "invalid-data", Kernel: "vmlinuz", Initrd: "initrd", RootDisk: filepath.Join(rootDir, "root.raw"),
- DataDisks: []DataDiskRequest{{Name: "bad.name", SizeBytes: 16 << 20}},
- RunDir: filepath.Join(rootDir, "run"), LogDir: filepath.Join(rootDir, "log"),
- })
- if err == nil {
- t.Fatal("Create() error = nil, want invalid data disk error")
- }
-}
diff --git a/internal/vm/store.go b/internal/vm/store.go
deleted file mode 100644
index b881447..0000000
--- a/internal/vm/store.go
+++ /dev/null
@@ -1,660 +0,0 @@
-package vm
-
-import (
- "context"
- "crypto/rand"
- "encoding/hex"
- "errors"
- "fmt"
- "path/filepath"
- "sort"
- "time"
-
- "github.com/kumabox/kumabox/internal/meta"
- metajson "github.com/kumabox/kumabox/internal/meta/json"
- kbnetwork "github.com/kumabox/kumabox/internal/network"
-)
-
-// Store serializes access to the VM index for one backend.
-//
-// The current implementation uses a single JSON index guarded by flock. This is
-// sufficient for the daemonless CLI model: each command can safely update
-// records without requiring a resident coordinator process.
-type Store struct {
- rootDir string
- engine meta.MetaEngine
-}
-
-var vmIndexCollection = meta.NewCollection[vmIndex]("vms", vmIndexTable)
-
-// New returns a VM store rooted under rootDir.
-//
-// The store path is backend-scoped so future backends can maintain independent
-// indexes without changing the VMRecord shape.
-func New(rootDir string) *Store {
- engine := mustOpenEngine(JSONNamespace(rootDir))
- return NewWithEngine(rootDir, engine)
-}
-
-// JSONNamespace describes the VM index used by the JSON metadata backend.
-func JSONNamespace(rootDir string) metajson.Namespace {
- backendDir := filepath.Join(rootDir, "backends", backendCloudHypervisor)
- return metajson.Namespace{
- Name: "vms",
- FilePath: filepath.Join(backendDir, "index.json"),
- LockPath: filepath.Join(backendDir, "index.lock"),
- Codec: indexCodec{},
- }
-}
-
-// NewWithEngine creates a VM store with an injected metadata engine.
-func NewWithEngine(rootDir string, engine meta.MetaEngine) *Store {
- return &Store{rootDir: rootDir, engine: engine}
-}
-
-// MetadataEngine exposes the store's persistence boundary to migration tools.
-// Runtime code should use the VM state capability instead.
-func (s *Store) MetadataEngine() meta.MetaEngine { return s.engine }
-
-// Events subscribes to coalesced VM metadata change notifications. Callers
-// must reread the store after every notification and retain a polling fallback.
-func (s *Store) Events(ctx context.Context) (<-chan struct{}, func(), error) {
- return s.engine.Events(ctx)
-}
-
-func mustOpenEngine(namespace metajson.Namespace) meta.MetaEngine {
- engine, err := metajson.Open(namespace)
- if err != nil {
- panic(fmt.Sprintf("open VM metadata engine: %v", err))
- }
- return engine
-}
-
-// CreateRequest is the normalized intent needed to create a VM record.
-//
-// Paths are resolved to absolute paths before persistence. The request does not
-// create disks, render VMM config, or allocate network resources; runtime code
-// coordinates those side effects around store.Create.
-type CreateRequest struct {
- Name string
- RootDisk string
- Kernel string
- Initrd string
- KernelCmdline string
- Firmware string
- Image *ImageRef
- CPUs int
- MemoryBytes int64
- Network string
- Networks []string
- StorageConfigs []StorageConfig
- DataDisks []DataDiskRequest
- SharedMemory bool
- RunDir string
- LogDir string
-}
-
-// PreviewRecord normalizes and validates a VM request without persisting it.
-// It is used by dry-run tooling that must share the exact record defaults and
-// path layout with Create while producing no metadata or host side effects.
-func PreviewRecord(req CreateRequest, rootDir, id string) (*VMRecord, error) {
- if err := validateCreateRequest(req); err != nil {
- return nil, err
- }
- if id == "" {
- id = "kb_preview"
- }
- now := time.Now().UTC()
- record, err := newRecord(id, req, rootDir, now)
- if err != nil {
- return nil, err
- }
- if err := ValidateStorageContract(record, rootDir); err != nil {
- return nil, err
- }
- return cloneRecord(record), nil
-}
-
-// Create validates and inserts a VM record.
-//
-// Name uniqueness is enforced inside the store lock. On success the returned
-// record is a defensive copy and may be mutated by the caller without changing
-// the stored index.
-func (s *Store) Create(req CreateRequest) (*VMRecord, error) {
- if err := validateCreateRequest(req); err != nil {
- return nil, err
- }
-
- var created *VMRecord
- err := s.update(func(idx *vmIndex) error {
- if _, ok := idx.Names[req.Name]; ok {
- return fmt.Errorf("%w: %s", ErrNameConflict, req.Name)
- }
-
- id, err := newID()
- if err != nil {
- return err
- }
- for {
- if _, exists := idx.VMs[id]; !exists {
- break
- }
- id, err = newID()
- if err != nil {
- return err
- }
- }
-
- now := time.Now().UTC()
- rec, err := newRecord(id, req, s.rootDir, now)
- if err != nil {
- return fmt.Errorf("create VM record: %w", err)
- }
- if err := ValidateStorageContract(rec, s.rootDir); err != nil {
- return err
- }
-
- idx.VMs[id] = rec
- idx.Names[req.Name] = id
- created = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
-
- return created, nil
-}
-
-// Inspect returns a VM by ID or name.
-//
-// The returned record is a defensive copy. Callers that want live backend
-// information should use runtime.InspectVM, which overlays an Observation.
-func (s *Store) Inspect(ref string) (*VMRecord, error) {
- var rec *VMRecord
- err := s.withIndex(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec = cloneRecord(idx.VMs[id])
- return nil
- })
- if err != nil {
- return nil, err
- }
- return rec, nil
-}
-
-// Delete removes a VM record from the index.
-//
-// Delete intentionally affects only the VM index. Runtime.DeleteVM is
-// responsible for stopping VMMs and cleaning run/log/network resources before
-// calling this method.
-func (s *Store) Delete(ref string) error {
- return s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- if rec != nil {
- delete(idx.Names, rec.Name)
- }
- delete(idx.VMs, id)
- return nil
- })
-}
-
-// UpdateStates applies one state update to every existing VM in refs in a
-// single metadata transaction.
-func (s *Store) UpdateStates(refs []string, state VMState) error {
- if len(refs) == 0 {
- return nil
- }
- return s.update(func(idx *vmIndex) error {
- now := time.Now().UTC()
- for _, ref := range refs {
- id, err := idx.resolve(ref)
- if errors.Is(err, ErrNotFound) {
- continue
- }
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- previous := rec.State
- rec.State = state
- rec.UpdatedAt = now
- if previous == state {
- continue
- }
- switch state {
- case StateRunning:
- rec.StartedAt = &now
- rec.StoppedAt = nil
- case StatePaused:
- rec.StoppedAt = &now
- case StateStopped:
- rec.PID = 0
- rec.APISocket = ""
- rec.Error = ""
- rec.SnapshotDependency = nil
- rec.StoppedAt = &now
- }
- }
- return nil
- })
-}
-
-// MarkStarted records backend process identity after a successful start.
-//
-// For cloud-image boots, marking running also flips FirstBooted so subsequent
-// starts do not regenerate one-shot first-boot metadata unexpectedly.
-func (s *Store) MarkStarted(ref string, pid int, apiSocket string) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- now := time.Now().UTC()
- rec.State = StateRunning
- rec.PID = pid
- rec.APISocket = apiSocket
- rec.Error = ""
- rec.SnapshotDependency = nil
- rec.Hibernate = nil
- rec.StartedAt = &now
- rec.StoppedAt = nil
- if rec.Metadata != nil {
- rec.FirstBooted = true
- }
- rec.UpdatedAt = now
- updated = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return updated, nil
-}
-
-// UpdatePerformance persists the latest lifecycle timing after the VM has
-// reached the product readiness boundary.
-func (s *Store) UpdatePerformance(ref string, metrics PerformanceMetrics) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- rec.Performance = &metrics
- rec.UpdatedAt = time.Now().UTC()
- updated = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return updated, nil
-}
-
-// BeginRestore writes the recovery marker before any writable disk is
-// replaced. Repeated calls deliberately refresh the marker so restore is the
-// recovery path for an interrupted prior attempt.
-func (s *Store) BeginRestore(ref, snapshotID, mode string) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- now := time.Now().UTC()
- rec.State = StateStopped
- rec.PID = 0
- rec.APISocket = ""
- rec.Error = ""
- rec.Restore = &RestoreStatus{
- SnapshotID: snapshotID,
- Mode: mode,
- State: "dirty",
- StartedAt: now,
- UpdatedAt: now,
- }
- rec.UpdatedAt = now
- updated = cloneRecord(rec)
- return nil
- })
- return updated, err
-}
-
-// FailRestore quarantines a VM after the destructive restore boundary.
-// The restore marker is retained so start cannot boot mixed-generation state.
-func (s *Store) FailRestore(ref, message string) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- now := time.Now().UTC()
- rec.State = StateError
- rec.PID = 0
- rec.APISocket = ""
- rec.Error = message
- if rec.Restore == nil {
- rec.Restore = &RestoreStatus{State: "dirty", StartedAt: now}
- }
- rec.Restore.State = "failed"
- rec.Restore.Error = message
- rec.Restore.UpdatedAt = now
- rec.UpdatedAt = now
- updated = cloneRecord(rec)
- return nil
- })
- return updated, err
-}
-
-// CompleteRestore atomically publishes restored process identity and
-// the phase timings collected during the restore or clone transaction.
-func (s *Store) CompleteRestore(ref string, pid int, apiSocket string, duration time.Duration, metrics *RestoreResult) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- if rec.Restore == nil {
- return errors.New("VM_RESTORE_STATE_MISSING: restore transaction is not active")
- }
- now := time.Now().UTC()
- rec.State = StateRunning
- rec.PID = pid
- rec.APISocket = apiSocket
- rec.Error = ""
- lastRestore := &RestoreResult{
- SnapshotID: rec.Restore.SnapshotID,
- Mode: rec.Restore.Mode,
- DurationMs: duration.Milliseconds(),
- CompletedAt: now,
- }
- if metrics != nil {
- lastRestore.NativeStageDurationMs = metrics.NativeStageDurationMs
- lastRestore.DiskStageDurationMs = metrics.DiskStageDurationMs
- lastRestore.DiskCommitDurationMs = metrics.DiskCommitDurationMs
- lastRestore.BackendRestoreDurationMs = metrics.BackendRestoreDurationMs
- lastRestore.IdentityDurationMs = metrics.IdentityDurationMs
- lastRestore.ReadinessDurationMs = metrics.ReadinessDurationMs
- lastRestore.GuestAgentWarning = metrics.GuestAgentWarning
- }
- rec.LastRestore = lastRestore
- if rec.Restore.Mode == "ondemand" || rec.Restore.Mode == "mmap" {
- rec.SnapshotDependency = &SnapshotDependency{
- SnapshotID: rec.Restore.SnapshotID,
- Mode: rec.Restore.Mode,
- Since: now,
- }
- } else {
- rec.SnapshotDependency = nil
- }
- rec.Restore = nil
- rec.Hibernate = nil
- rec.StartedAt = &now
- rec.StoppedAt = nil
- if rec.Metadata != nil {
- rec.FirstBooted = true
- }
- rec.UpdatedAt = now
- updated = cloneRecord(rec)
- return nil
- })
- return updated, err
-}
-
-// CompleteHibernate publishes the durable snapshot linkage only after the VMM
-// has terminated. Network and storage identity remain allocated for wake.
-func (s *Store) CompleteHibernate(ref, snapshotID string) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- now := time.Now().UTC()
- rec.State = StateStopped
- rec.PID = 0
- rec.APISocket = ""
- rec.Error = ""
- rec.SnapshotDependency = nil
- rec.Hibernate = &HibernateStatus{SnapshotID: snapshotID, CreatedAt: now}
- rec.StoppedAt = &now
- rec.UpdatedAt = now
- updated = cloneRecord(rec)
- return nil
- })
- return updated, err
-}
-
-// SetError records a lifecycle failure while preserving the VM record.
-//
-// Keeping the record allows inspect, logs, and delete cleanup to work after a
-// failed render/start/stop operation.
-func (s *Store) SetError(ref string, message string) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- now := time.Now().UTC()
- rec.State = StateError
- rec.Error = message
- rec.UpdatedAt = now
- updated = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return updated, nil
-}
-
-// SetNetworkConfigs stores the VM-side view of allocated network attachments.
-//
-// Provider records and leases live in the network store. Keeping a copy here
-// lets runtime render Cloud Hypervisor config even if provider inspection later
-// reports drift.
-func (s *Store) SetNetworkConfigs(ref string, configs []kbnetwork.Config) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- now := time.Now().UTC()
- rec.NetworkConfigs = cloneNetworkConfigs(configs)
- rec.UpdatedAt = now
- updated = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return updated, nil
-}
-
-func (s *Store) SetAttachedDisks(ref string, disks []AttachedDisk) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- rec.AttachedDisks = append([]AttachedDisk(nil), disks...)
- rec.UpdatedAt = time.Now().UTC()
- updated = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return updated, nil
-}
-
-func (s *Store) SetAttachedFilesystems(ref string, filesystems []AttachedFilesystem) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- rec.AttachedFilesystems = append([]AttachedFilesystem(nil), filesystems...)
- rec.UpdatedAt = time.Now().UTC()
- updated = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return updated, nil
-}
-
-func (s *Store) SetAttachedPCIDevices(ref string, devices []AttachedPCIDevice) (*VMRecord, error) {
- var updated *VMRecord
- err := s.update(func(idx *vmIndex) error {
- id, err := idx.resolve(ref)
- if err != nil {
- return err
- }
- rec := idx.VMs[id]
- rec.AttachedPCIDevices = append([]AttachedPCIDevice(nil), devices...)
- rec.UpdatedAt = time.Now().UTC()
- updated = cloneRecord(rec)
- return nil
- })
- if err != nil {
- return nil, err
- }
- return updated, nil
-}
-
-// List returns all VM records sorted by creation time.
-//
-// Each element is a defensive copy. Runtime.ListVMs may update observations on
-// these copies without changing persisted state.
-func (s *Store) List() ([]*VMRecord, error) {
- var records []*VMRecord
- err := s.withIndex(func(idx *vmIndex) error {
- records = make([]*VMRecord, 0, len(idx.VMs))
- for _, rec := range idx.VMs {
- records = append(records, cloneRecord(rec))
- }
- sort.Slice(records, func(i, j int) bool {
- return records[i].CreatedAt.Before(records[j].CreatedAt)
- })
- return nil
- })
- if err != nil {
- return nil, err
- }
- return records, nil
-}
-
-// RootDir returns the durable state root used by this store.
-func (s *Store) RootDir() string {
- return s.rootDir
-}
-
-func (s *Store) withIndex(fn func(*vmIndex) error) error {
- ctx := context.Background()
- return s.engine.View(ctx, []meta.Namespace{"vms"}, func(reader meta.Reader) error {
- idx, err := s.readIndex(ctx, reader)
- if err != nil {
- return err
- }
- return fn(idx)
- })
-}
-
-func (s *Store) update(fn func(*vmIndex) error) error {
- ctx := context.Background()
- return s.engine.Update(ctx, meta.Scope{Write: "vms"}, meta.CommitDurable, func(writer meta.Writer) error {
- idx, err := s.readIndex(ctx, writer)
- if err != nil {
- return err
- }
- if err := fn(idx); err != nil {
- return err
- }
- return vmIndexCollection.Upsert(ctx, writer, vmIndexRecord, idx)
- })
-}
-
-func (s *Store) readIndex(ctx context.Context, reader meta.Reader) (*vmIndex, error) {
- idx, err := vmIndexCollection.Get(ctx, reader, vmIndexRecord)
- if errors.Is(err, meta.ErrNotFound) {
- idx = &vmIndex{}
- } else if err != nil {
- return nil, fmt.Errorf("read VM index: %w", err)
- }
- idx.init()
- for id, rec := range idx.VMs {
- if err := ValidateStorageContract(rec, s.rootDir); err != nil {
- return nil, fmt.Errorf("validate VM %s storage: %w", id, err)
- }
- }
- return idx, nil
-}
-
-func validateCreateRequest(req CreateRequest) error {
- if req.Name == "" {
- return errors.New("name must not be empty")
- }
- if req.RootDisk == "" && len(req.StorageConfigs) == 0 {
- return errors.New("root disk must not be empty")
- }
- if req.Firmware == "" && req.Kernel == "" {
- return errors.New("kernel must not be empty for direct boot")
- }
- if req.Firmware == "" && req.Initrd == "" {
- return errors.New("initrd must not be empty for direct boot")
- }
- if req.Firmware != "" && (req.Kernel != "" || req.Initrd != "") {
- return errors.New("firmware boot cannot be combined with kernel or initrd")
- }
- if req.RunDir == "" {
- return errors.New("run dir must not be empty")
- }
- if req.LogDir == "" {
- return errors.New("log dir must not be empty")
- }
- if req.CPUs < 0 {
- return errors.New("cpus must be greater than zero")
- }
- if req.MemoryBytes < 0 {
- return errors.New("memory bytes must be greater than zero")
- }
- if _, err := normalizeNetworks(req.Network, req.Networks); err != nil {
- return err
- }
- return nil
-}
-
-func newID() (string, error) {
- var raw [8]byte
- if _, err := rand.Read(raw[:]); err != nil {
- return "", fmt.Errorf("generate VM ID: %w", err)
- }
- return "kb_" + hex.EncodeToString(raw[:]), nil
-}
diff --git a/internal/vm/store_test.go b/internal/vm/store_test.go
deleted file mode 100644
index 2073f45..0000000
--- a/internal/vm/store_test.go
+++ /dev/null
@@ -1,598 +0,0 @@
-package vm
-
-import (
- "errors"
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestCreateInspectList(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
-
- rec, err := store.Create(CreateRequest{
- Name: "p0-store",
- RootDisk: "fixtures/base.qcow2",
- Kernel: "fixtures/vmlinuz",
- Initrd: "fixtures/initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if rec.ID == "" {
- t.Fatal("expected VM ID")
- }
- if rec.State != StateCreated {
- t.Fatalf("state = %s", rec.State)
- }
- if !filepath.IsAbs(rec.RootDisk) {
- t.Fatalf("root disk is not absolute: %s", rec.RootDisk)
- }
- if rec.Config != filepath.Join(rec.RunDir, "cloud-hypervisor.json") {
- t.Fatalf("config path = %s", rec.Config)
- }
-
- got, err := store.Inspect("p0-store")
- if err != nil {
- t.Fatal(err)
- }
- if got.ID != rec.ID {
- t.Fatalf("inspect ID = %s, want %s", got.ID, rec.ID)
- }
-
- list, err := store.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(list) != 1 {
- t.Fatalf("list len = %d", len(list))
- }
-
- indexPath := filepath.Join(dir, "data", "backends", backendCloudHypervisor, "index.json")
- if _, err := os.Stat(indexPath); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestStoreRecoversPreviousIndexGeneration(t *testing.T) {
- dir := t.TempDir()
- rootDir := filepath.Join(dir, "data")
- store := New(rootDir)
- request := func(name string) CreateRequest {
- return CreateRequest{
- Name: name,
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run", name),
- LogDir: filepath.Join(dir, "log", name),
- }
- }
- first, err := store.Create(request("first"))
- if err != nil {
- t.Fatal(err)
- }
- if _, err := store.Create(request("second")); err != nil {
- t.Fatal(err)
- }
-
- indexPath := filepath.Join(rootDir, "backends", backendCloudHypervisor, "index.json")
- if err := os.WriteFile(indexPath, []byte("{"), 0o600); err != nil {
- t.Fatal(err)
- }
- recovered, err := store.Inspect(first.ID)
- if err != nil {
- t.Fatalf("inspect recovered VM: %v", err)
- }
- if recovered.Name != "first" {
- t.Fatalf("recovered VM name = %q", recovered.Name)
- }
- if _, err := store.Inspect("second"); !errors.Is(err, ErrNotFound) {
- t.Fatalf("expected previous generation without second VM, got %v", err)
- }
-}
-
-func TestPreviewRecordDoesNotPersistOrCreateRuntimeFiles(t *testing.T) {
- root := t.TempDir()
- store := New(root)
- record, err := PreviewRecord(CreateRequest{
- Name: "preview", RootDisk: "/images/root.qcow2", Firmware: "/firmware.fd",
- CPUs: 2, MemoryBytes: 512 << 20, Networks: []string{"none"},
- RunDir: filepath.Join(root, "run"), LogDir: filepath.Join(root, "log"),
- }, root, "kb_preview")
- if err != nil {
- t.Fatal(err)
- }
- if record.ID != "kb_preview" || record.Name != "preview" {
- t.Fatalf("preview record = %+v", record)
- }
- records, err := store.List()
- if err != nil {
- t.Fatal(err)
- }
- if len(records) != 0 {
- t.Fatalf("persisted preview records = %d", len(records))
- }
- if _, err := os.Stat(record.RunDir); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("preview run directory stat error = %v", err)
- }
-}
-
-func TestDeleteRemovesRecordAndName(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
- rec, err := store.Create(CreateRequest{
- Name: "delete-me",
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
-
- if err := store.Delete(rec.ID); err != nil {
- t.Fatal(err)
- }
- if _, err := store.Inspect("delete-me"); !errors.Is(err, ErrNotFound) {
- t.Fatalf("inspect after delete error = %v", err)
- }
-}
-
-func TestCreateRejectsDuplicateName(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
- req := CreateRequest{
- Name: "same",
- RootDisk: "base.qcow2",
- Kernel: "vmlinuz",
- Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- }
-
- if _, err := store.Create(req); err != nil {
- t.Fatal(err)
- }
- if _, err := store.Create(req); !errors.Is(err, ErrNameConflict) {
- t.Fatalf("duplicate error = %v", err)
- }
-}
-
-func TestCreateSupportsFirmwareBoot(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
-
- rec, err := store.Create(CreateRequest{
- Name: "uefi",
- RootDisk: "ubuntu.img",
- Firmware: "CLOUDHV.fd",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if rec.Firmware == "" {
- t.Fatal("expected firmware path")
- }
- if rec.Kernel != "" || rec.Initrd != "" {
- t.Fatalf("unexpected direct boot fields: kernel=%q initrd=%q", rec.Kernel, rec.Initrd)
- }
- if rec.Metadata == nil {
- t.Fatal("expected NoCloud metadata")
- }
- if rec.Metadata.Type != "nocloud" {
- t.Fatalf("metadata type = %s", rec.Metadata.Type)
- }
- if rec.Metadata.CidataDir != filepath.Join(rec.RunDir, "cidata") {
- t.Fatalf("cidata dir = %s", rec.Metadata.CidataDir)
- }
- if rec.Metadata.CidataDisk != filepath.Join(rec.RunDir, "cidata.img") {
- t.Fatalf("cidata disk = %s", rec.Metadata.CidataDisk)
- }
-}
-
-func TestCreatePersistsCPUs(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
-
- defaulted, err := store.Create(CreateRequest{
- Name: "default-cpu",
- RootDisk: "ubuntu.img",
- Firmware: "CLOUDHV.fd",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if defaulted.CPUs != 1 {
- t.Fatalf("default cpus = %d", defaulted.CPUs)
- }
-
- custom, err := store.Create(CreateRequest{
- Name: "custom-cpu",
- RootDisk: "ubuntu.img",
- Firmware: "CLOUDHV.fd",
- CPUs: 4,
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if custom.CPUs != 4 {
- t.Fatalf("custom cpus = %d", custom.CPUs)
- }
-}
-
-func TestCreatePersistsMemory(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
-
- defaulted, err := store.Create(CreateRequest{
- Name: "default-memory", RootDisk: "ubuntu.img", Firmware: "CLOUDHV.fd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if defaulted.MemoryBytes != 512<<20 {
- t.Fatalf("default memory = %d", defaulted.MemoryBytes)
- }
-
- custom, err := store.Create(CreateRequest{
- Name: "custom-memory", RootDisk: "ubuntu.img", Firmware: "CLOUDHV.fd", MemoryBytes: 2 << 30,
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if custom.MemoryBytes != 2<<30 {
- t.Fatalf("custom memory = %d", custom.MemoryBytes)
- }
-}
-
-func TestCreatePersistsImageRef(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
- image := &ImageRef{
- ID: "img_123",
- Name: "ubuntu",
- RootDisk: filepath.Join(dir, "images", "base.qcow2"),
- BootMode: "uefi",
- }
-
- rec, err := store.Create(CreateRequest{
- Name: "from-image",
- RootDisk: image.RootDisk,
- Firmware: "CLOUDHV.fd",
- Image: image,
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if rec.Image == nil {
- t.Fatal("expected image ref")
- }
- if rec.Image.ID != image.ID || rec.Image.Name != image.Name || rec.Image.RootDisk != image.RootDisk || rec.Image.BootMode != image.BootMode {
- t.Fatalf("image ref = %+v", rec.Image)
- }
-
- image.Name = "mutated"
- inspected, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if inspected.Image.Name != "ubuntu" {
- t.Fatalf("image ref was not defensively copied: %+v", inspected.Image)
- }
-}
-
-func TestCreatePersistsNetworkAttachments(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
-
- rec, err := store.Create(CreateRequest{
- Name: "multi-net",
- RootDisk: "ubuntu.img",
- Firmware: "CLOUDHV.fd",
- Networks: []string{"cni:front", "cni:back"},
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if rec.Network != "multi" {
- t.Fatalf("legacy network = %s", rec.Network)
- }
- if len(rec.Networks) != 2 || rec.Networks[0] != "cni:front" || rec.Networks[1] != "cni:back" {
- t.Fatalf("networks = %#v", rec.Networks)
- }
-
- inspected, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if len(inspected.Networks) != 2 || inspected.Networks[0] != "cni:front" || inspected.Networks[1] != "cni:back" {
- t.Fatalf("inspected networks = %#v", inspected.Networks)
- }
-}
-
-func TestCreateRejectsNoneWithOtherNetworks(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
-
- _, err := store.Create(CreateRequest{
- Name: "bad-net",
- RootDisk: "ubuntu.img",
- Firmware: "CLOUDHV.fd",
- Networks: []string{"none", "default"},
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err == nil {
- t.Fatal("expected mixed none network error")
- }
-}
-
-func TestCreateRejectsMixedNetworkProviderFamilies(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
-
- _, err := store.Create(CreateRequest{
- Name: "mixed-provider-net",
- RootDisk: "ubuntu.img",
- Firmware: "CLOUDHV.fd",
- Networks: []string{"default", "cni:isolated"},
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err == nil {
- t.Fatal("expected mixed provider family error")
- }
-}
-
-func TestMarkStartedMarksFirmwareVMFirstBooted(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
-
- rec, err := store.Create(CreateRequest{
- Name: "uefi",
- RootDisk: "ubuntu.img",
- Firmware: "CLOUDHV.fd",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if rec.FirstBooted {
- t.Fatal("new VM should not be marked first-booted")
- }
-
- running, err := store.MarkStarted(rec.ID, 1234, filepath.Join(rec.RunDir, "ch.sock"))
- if err != nil {
- t.Fatal(err)
- }
- if !running.FirstBooted {
- t.Fatal("firmware VM should be marked first-booted after successful start")
- }
-}
-
-func TestUpdatePerformancePersistsDefensivePhaseMetrics(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
- rec, err := store.Create(CreateRequest{
- Name: "performance",
- RootDisk: "base.qcow2", Kernel: "vmlinuz", Initrd: "initrd.img",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- phase := time.Now().UTC()
- metrics := PerformanceMetrics{
- Operation: "run", CommandStartedAt: phase,
- ImageResolvedAt: &phase, ReadyDurationMs: 42,
- }
- updated, err := store.UpdatePerformance(rec.ID, metrics)
- if err != nil {
- t.Fatal(err)
- }
- *updated.Performance.ImageResolvedAt = updated.Performance.ImageResolvedAt.Add(time.Hour)
- inspected, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if inspected.Performance == nil || inspected.Performance.ReadyDurationMs != 42 {
- t.Fatalf("performance = %+v", inspected.Performance)
- }
- if inspected.Performance.ImageResolvedAt.Equal(*updated.Performance.ImageResolvedAt) {
- t.Fatal("inspect returned mutable performance timestamp")
- }
-}
-
-func TestCompleteRestoreMarksFirmwareVMFirstBooted(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
-
- rec, err := store.Create(CreateRequest{
- Name: "restored-uefi",
- RootDisk: "ubuntu.img",
- Firmware: "CLOUDHV.fd",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := store.BeginRestore(rec.ID, "snap_test", "copy"); err != nil {
- t.Fatal(err)
- }
- restored, err := store.CompleteRestore(rec.ID, 1234, filepath.Join(rec.RunDir, "ch.sock"), 250*time.Millisecond, nil)
- if err != nil {
- t.Fatal(err)
- }
- if !restored.FirstBooted {
- t.Fatal("restored firmware VM should not regenerate first-boot metadata")
- }
-}
-
-func TestCompleteRestorePinsDelayedMemoryUntilStop(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
- rec, err := store.Create(CreateRequest{
- Name: "delayed", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd", RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := store.BeginRestore(rec.ID, "snap_delayed", "mmap"); err != nil {
- t.Fatal(err)
- }
- restored, err := store.CompleteRestore(rec.ID, 1234, filepath.Join(rec.RunDir, "ch.sock"), 250*time.Millisecond, nil)
- if err != nil {
- t.Fatal(err)
- }
- if restored.SnapshotDependency == nil || restored.SnapshotDependency.SnapshotID != "snap_delayed" {
- t.Fatalf("snapshot dependency = %+v", restored.SnapshotDependency)
- }
- if restored.LastRestore == nil || restored.LastRestore.Mode != "mmap" || restored.LastRestore.DurationMs != 250 {
- t.Fatalf("last restore = %+v", restored.LastRestore)
- }
- if err := store.UpdateStates([]string{rec.ID}, StateStopped); err != nil {
- t.Fatal(err)
- }
- stopped, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if stopped.SnapshotDependency != nil {
- t.Fatalf("stopped VM retained dependency = %+v", stopped.SnapshotDependency)
- }
-}
-
-func TestCompleteRestorePersistsPhaseMetrics(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
- rec, err := store.Create(CreateRequest{
- Name: "timed-restore", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd",
- RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if _, err := store.BeginRestore(rec.ID, "snap_timed", "copy"); err != nil {
- t.Fatal(err)
- }
- restored, err := store.CompleteRestore(rec.ID, 1234, filepath.Join(rec.RunDir, "ch.sock"), time.Second, &RestoreResult{
- NativeStageDurationMs: 11, DiskStageDurationMs: 22, DiskCommitDurationMs: 3,
- BackendRestoreDurationMs: 44, IdentityDurationMs: 55, ReadinessDurationMs: 66,
- GuestAgentWarning: "agent unavailable",
- })
- if err != nil {
- t.Fatal(err)
- }
- if restored.LastRestore == nil || restored.LastRestore.DiskStageDurationMs != 22 || restored.LastRestore.ReadinessDurationMs != 66 {
- t.Fatalf("last restore metrics = %+v", restored.LastRestore)
- }
- persisted, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if persisted.LastRestore == nil || persisted.LastRestore.BackendRestoreDurationMs != 44 || persisted.LastRestore.GuestAgentWarning != "agent unavailable" {
- t.Fatalf("persisted restore metrics = %+v", persisted.LastRestore)
- }
-}
-
-func TestCreateRejectsMixedFirmwareAndDirectBoot(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
-
- _, err := store.Create(CreateRequest{
- Name: "mixed",
- RootDisk: "ubuntu.img",
- Kernel: "vmlinuz",
- Firmware: "CLOUDHV.fd",
- RunDir: filepath.Join(dir, "run"),
- LogDir: filepath.Join(dir, "log"),
- })
- if err == nil {
- t.Fatal("expected mixed boot error")
- }
-}
-
-func TestResolveByIDPrefix(t *testing.T) {
- idx := &vmIndex{
- VMs: map[string]*VMRecord{
- "kb_abcdef": {ID: "kb_abcdef"},
- },
- Names: map[string]string{},
- }
-
- id, err := idx.resolve("kb_abc")
- if err != nil {
- t.Fatal(err)
- }
- if id != "kb_abcdef" {
- t.Fatalf("id = %s", id)
- }
-}
-
-func TestUpdateStatesMaintainsComputeIntervalTimestamps(t *testing.T) {
- dir := t.TempDir()
- store := New(filepath.Join(dir, "data"))
- rec, err := store.Create(CreateRequest{Name: "timestamps", RootDisk: "root.raw", Kernel: "vmlinuz", Initrd: "initrd", RunDir: filepath.Join(dir, "run"), LogDir: filepath.Join(dir, "log")})
- if err != nil {
- t.Fatal(err)
- }
- running, err := store.MarkStarted(rec.ID, 1234, "ch.sock")
- if err != nil {
- t.Fatal(err)
- }
- if running.StartedAt == nil || running.StoppedAt != nil {
- t.Fatalf("running timestamps = start %v stop %v", running.StartedAt, running.StoppedAt)
- }
- if err := store.UpdateStates([]string{rec.ID}, StatePaused); err != nil {
- t.Fatal(err)
- }
- paused, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if paused.StoppedAt == nil {
- t.Fatal("paused VM has no compute stop timestamp")
- }
- pausedAt := *paused.StoppedAt
- if err := store.UpdateStates([]string{rec.ID}, StatePaused); err != nil {
- t.Fatal(err)
- }
- paused, err = store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if !paused.StoppedAt.Equal(pausedAt) {
- t.Fatalf("idempotent pause changed timestamp: %s != %s", paused.StoppedAt, pausedAt)
- }
- if err := store.UpdateStates([]string{rec.ID}, StateRunning); err != nil {
- t.Fatal(err)
- }
- resumed, err := store.Inspect(rec.ID)
- if err != nil {
- t.Fatal(err)
- }
- if resumed.StartedAt == nil || !resumed.StartedAt.After(pausedAt) || resumed.StoppedAt != nil {
- t.Fatalf("resumed timestamps = start %v stop %v", resumed.StartedAt, resumed.StoppedAt)
- }
-}
diff --git a/internal/vm/views.go b/internal/vm/views.go
deleted file mode 100644
index f7ac816..0000000
--- a/internal/vm/views.go
+++ /dev/null
@@ -1,167 +0,0 @@
-package vm
-
-import (
- "time"
-
- kbnetwork "github.com/kumabox/kumabox/internal/network"
-)
-
-// VMIdentity is the stable identity used to address a VM across restarts.
-type VMIdentity struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Backend string `json:"backend"`
- CreatedAt time.Time `json:"createdAt"`
-}
-
-// VMConfig contains desired VM configuration. It is independent of whether a
-// backend process is currently alive.
-type VMConfig struct {
- RootDisk string `json:"rootDisk"`
- Kernel string `json:"kernel,omitempty"`
- Initrd string `json:"initrd,omitempty"`
- KernelCmdline string `json:"kernelCmdline,omitempty"`
- Firmware string `json:"firmware,omitempty"`
- Image *ImageRef `json:"image,omitempty"`
- CPUs int `json:"cpus"`
- MemoryBytes int64 `json:"memoryBytes"`
- Metadata *Metadata `json:"metadata,omitempty"`
- Network string `json:"network,omitempty"`
- Networks []string `json:"networks,omitempty"`
- Storage []StorageConfig `json:"storageConfigs,omitempty"`
-}
-
-// VMRuntimeState contains observed and operation-sensitive state.
-type VMRuntimeState struct {
- Desired VMState `json:"desiredState"`
- Observed ObservedState `json:"observedState,omitempty"`
- ObservedReason string `json:"observedReason,omitempty"`
- ObservedAt *time.Time `json:"observedAt,omitempty"`
- PID int `json:"pid,omitempty"`
- APISocket string `json:"apiSocket,omitempty"`
- VsockSocket string `json:"vsockSocket,omitempty"`
- Error string `json:"error,omitempty"`
- Restore *RestoreStatus `json:"restore,omitempty"`
- LastRestore *RestoreResult `json:"lastRestore,omitempty"`
- Performance *PerformanceMetrics `json:"performance,omitempty"`
- SnapshotDependency *SnapshotDependency `json:"snapshotDependency,omitempty"`
- Hibernate *HibernateStatus `json:"hibernate,omitempty"`
- StartedAt *time.Time `json:"startedAt,omitempty"`
- StoppedAt *time.Time `json:"stoppedAt,omitempty"`
- FirstBooted bool `json:"firstBooted,omitempty"`
-}
-
-// VMAttachments contains host resources allocated for this VM.
-type VMAttachments struct {
- NetworkConfigs []kbnetwork.Config `json:"networkConfigs,omitempty"`
- NetworkStatus *kbnetwork.InspectResult `json:"networkStatus,omitempty"`
- RunDir string `json:"runDir"`
- LogDir string `json:"logDir"`
- Config string `json:"config"`
-}
-
-// VMReferences contains durable resource ownership relationships.
-type VMReferences struct {
- ImageID string `json:"imageId,omitempty"`
- SnapshotIDs []string `json:"snapshotIds,omitempty"`
-}
-
-func (r *VMRecord) IdentityView() VMIdentity {
- if r == nil {
- return VMIdentity{}
- }
- return VMIdentity{ID: r.ID, Name: r.Name, Backend: r.Backend, CreatedAt: r.CreatedAt}
-}
-
-func (r *VMRecord) ConfigView() VMConfig {
- if r == nil {
- return VMConfig{}
- }
- return VMConfig{RootDisk: r.RootDisk, Kernel: r.Kernel, Initrd: r.Initrd, KernelCmdline: r.KernelCmdline, Firmware: r.Firmware, Image: cloneImageRef(r.Image), CPUs: r.CPUs, MemoryBytes: r.MemoryBytes, Metadata: cloneMetadata(r.Metadata), Network: r.Network, Networks: append([]string(nil), r.Networks...), Storage: cloneStorageConfigs(r.StorageConfigs)}
-}
-
-func (r *VMRecord) RuntimeView() VMRuntimeState {
- if r == nil {
- return VMRuntimeState{}
- }
- return VMRuntimeState{Desired: r.State, Observed: r.ObservedState, ObservedReason: r.ObservedReason, ObservedAt: cloneTime(r.ObservedAt), PID: r.PID, APISocket: r.APISocket, VsockSocket: r.VsockSocket, Error: r.Error, Restore: cloneRestoreStatus(r.Restore), LastRestore: cloneRestoreResult(r.LastRestore), Performance: clonePerformance(r.Performance), SnapshotDependency: cloneSnapshotDependency(r.SnapshotDependency), Hibernate: cloneHibernateStatus(r.Hibernate), StartedAt: cloneTime(r.StartedAt), StoppedAt: cloneTime(r.StoppedAt), FirstBooted: r.FirstBooted}
-}
-
-func (r *VMRecord) AttachmentsView() VMAttachments {
- if r == nil {
- return VMAttachments{}
- }
- return VMAttachments{NetworkConfigs: cloneNetworkConfigs(r.NetworkConfigs), NetworkStatus: cloneNetworkStatus(r.NetworkStatus), RunDir: r.RunDir, LogDir: r.LogDir, Config: r.Config}
-}
-
-func (r *VMRecord) ReferencesView() VMReferences {
- if r == nil {
- return VMReferences{}
- }
- refs := VMReferences{}
- if r.Image != nil {
- refs.ImageID = r.Image.ID
- }
- if r.SnapshotDependency != nil && r.SnapshotDependency.SnapshotID != "" {
- refs.SnapshotIDs = []string{r.SnapshotDependency.SnapshotID}
- }
- if r.Hibernate != nil && r.Hibernate.SnapshotID != "" {
- refs.SnapshotIDs = append(refs.SnapshotIDs, r.Hibernate.SnapshotID)
- }
- return refs
-}
-
-func cloneMetadata(value *Metadata) *Metadata {
- if value == nil {
- return nil
- }
- copied := *value
- return &copied
-}
-
-func cloneRestoreStatus(value *RestoreStatus) *RestoreStatus {
- if value == nil {
- return nil
- }
- copied := *value
- return &copied
-}
-
-func cloneRestoreResult(value *RestoreResult) *RestoreResult {
- if value == nil {
- return nil
- }
- copied := *value
- return &copied
-}
-
-func clonePerformance(value *PerformanceMetrics) *PerformanceMetrics {
- if value == nil {
- return nil
- }
- copied := *value
- copied.ImageResolvedAt = cloneTime(value.ImageResolvedAt)
- copied.StorageReadyAt = cloneTime(value.StorageReadyAt)
- copied.NetworkReadyAt = cloneTime(value.NetworkReadyAt)
- copied.VMMSpawnedAt = cloneTime(value.VMMSpawnedAt)
- copied.VMMAPIReadyAt = cloneTime(value.VMMAPIReadyAt)
- copied.AgentConnectedAt = cloneTime(value.AgentConnectedAt)
- copied.FirstExecCompletedAt = cloneTime(value.FirstExecCompletedAt)
- return &copied
-}
-
-func cloneSnapshotDependency(value *SnapshotDependency) *SnapshotDependency {
- if value == nil {
- return nil
- }
- copied := *value
- return &copied
-}
-
-func cloneHibernateStatus(value *HibernateStatus) *HibernateStatus {
- if value == nil {
- return nil
- }
- copied := *value
- return &copied
-}
diff --git a/internal/vm/views_test.go b/internal/vm/views_test.go
deleted file mode 100644
index e30078b..0000000
--- a/internal/vm/views_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package vm
-
-import (
- "testing"
-
- kbnetwork "github.com/kumabox/kumabox/internal/network"
-)
-
-func TestRecordViewsSeparateDesiredAndRuntimeData(t *testing.T) {
- record := &VMRecord{
- ID: "vm-1", Name: "agent", Backend: "cloud-hypervisor", State: StateRunning,
- ObservedState: ObservedStateRunning, RootDisk: "/disk", CPUs: 2, MemoryBytes: 1 << 30,
- Image: &ImageRef{ID: "img-1", LayerDigests: []string{"sha256:a"}},
- NetworkConfigs: []kbnetwork.Config{{ID: "net-1"}},
- }
- config := record.ConfigView()
- runtime := record.RuntimeView()
- attachments := record.AttachmentsView()
- refs := record.ReferencesView()
-
- record.State = StateStopped
- record.ObservedState = ObservedStateStopped
- record.Image.LayerDigests[0] = "sha256:changed"
- record.NetworkConfigs[0].ID = "changed"
-
- if config.CPUs != 2 || config.MemoryBytes != 1<<30 || config.Image.LayerDigests[0] != "sha256:a" {
- t.Fatalf("config view changed with runtime record mutation: %+v", config)
- }
- if runtime.Desired != StateRunning || runtime.Observed != ObservedStateRunning {
- t.Fatalf("runtime view = %+v", runtime)
- }
- if len(attachments.NetworkConfigs) != 1 || attachments.NetworkConfigs[0].ID != "net-1" {
- t.Fatalf("attachments view = %+v", attachments)
- }
- if refs.ImageID != "img-1" {
- t.Fatalf("references view = %+v", refs)
- }
-}
diff --git a/lock/flock/lock.go b/lock/flock/lock.go
new file mode 100644
index 0000000..d47b84a
--- /dev/null
+++ b/lock/flock/lock.go
@@ -0,0 +1,153 @@
+// Package flock provides context-aware advisory file locks and ordered lock sets.
+// Locks coordinate cooperating KumaBox operations; they are not security boundaries.
+package flock
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "time"
+
+ goflock "github.com/gofrs/flock"
+)
+
+// retryInterval bounds polling delay between advisory lock acquisition attempts.
+const retryInterval = 2 * time.Millisecond
+
+// Lock combines in-process acquisition serialization with an advisory cross-process
+// lock. Construct it with New or NewTransient; its zero value is not usable.
+// Acquisitions may compete, but the owner must serialize Unlock and release once.
+type Lock struct {
+ // path identifies the lock file shared by cooperating processes.
+ path string
+ // token reserves this instance until acquisition fails or its owner unlocks.
+ token chan struct{}
+ // held is the owner's locked descriptor; only one acquisition can install it.
+ held *goflock.Flock
+ // transient removes the path before closing and rejects descriptors for old inodes.
+ transient bool
+}
+
+// New creates a persistent lock whose file remains after release. Keeping its inode
+// stable prevents waiters from locking an unlinked file while others use a new one.
+func New(path string) *Lock {
+ return &Lock{path: path, token: make(chan struct{}, 1)}
+}
+
+// NewTransient creates a removable coordination lock. Acquired descriptors are
+// checked against the current path so waiters cannot proceed on an unlinked inode.
+func NewTransient(path string) *Lock {
+ return &Lock{path: path, token: make(chan struct{}, 1), transient: true}
+}
+
+// Lock waits for the local token and the advisory lock until cancellation.
+// Transient locks requeue if a preceding owner removed their file while waiting.
+//
+// local token -> file lock -> transient inode check -> owner
+// ^ |
+// +--- stale inode --+
+func (l *Lock) Lock(ctx context.Context) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if info, err := os.Lstat(l.path); err == nil && !info.Mode().IsRegular() {
+ return fmt.Errorf("lock path %s is not a regular file", l.path)
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ select {
+ case l.token <- struct{}{}:
+ case <-ctx.Done():
+ return fmt.Errorf("wait for lock %s: %w", l.path, ctx.Err())
+ }
+ for {
+ candidate := goflock.New(l.path)
+ ok, err := candidate.TryLockContext(ctx, retryInterval)
+ if err != nil || !ok {
+ closeErr := candidate.Close()
+ <-l.token
+ if err == nil {
+ err = ctx.Err()
+ }
+ return errors.Join(fmt.Errorf("acquire lock %s: %w", l.path, err), closeErr)
+ }
+ l.held = candidate
+ if !l.transient || l.boundToPath() {
+ return nil
+ }
+ if err := candidate.Close(); err != nil {
+ <-l.token
+ l.held = nil
+ return fmt.Errorf("requeue stale lock %s: %w", l.path, err)
+ }
+ l.held = nil
+ }
+}
+
+// TryLock performs one nonblocking attempt. Contention or a stale transient inode
+// returns false without ownership; acquisition and descriptor cleanup errors survive.
+func (l *Lock) TryLock(ctx context.Context) (bool, error) {
+ if err := ctx.Err(); err != nil {
+ return false, err
+ }
+ if info, err := os.Lstat(l.path); err == nil && !info.Mode().IsRegular() {
+ return false, fmt.Errorf("lock path %s is not a regular file", l.path)
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return false, err
+ }
+ select {
+ case l.token <- struct{}{}:
+ default:
+ return false, nil
+ }
+ candidate := goflock.New(l.path)
+ ok, err := candidate.TryLock()
+ if err != nil || !ok {
+ closeErr := candidate.Close()
+ <-l.token
+ return false, errors.Join(err, closeErr)
+ }
+ l.held = candidate
+ if l.transient && !l.boundToPath() {
+ closeErr := candidate.Close()
+ l.held = nil
+ <-l.token
+ return false, closeErr
+ }
+ return true, nil
+}
+
+// Unlock releases ownership even if the acquisition context has been canceled.
+// For transient locks, removal precedes descriptor close so queued owners can detect
+// a stale inode. Calling it without ownership is harmless, but concurrent calls are not.
+func (l *Lock) Unlock(context.Context) error {
+ if l.held == nil {
+ return nil
+ }
+ var removeErr error
+ if l.transient {
+ removeErr = os.Remove(l.path)
+ if errors.Is(removeErr, fs.ErrNotExist) {
+ removeErr = nil
+ }
+ }
+ closeErr := l.held.Close()
+ l.held = nil
+ <-l.token
+ if err := errors.Join(removeErr, closeErr); err != nil {
+ return fmt.Errorf("release lock %s: %w", l.path, err)
+ }
+ return nil
+}
+
+// boundToPath verifies that the locked descriptor still names the current inode.
+func (l *Lock) boundToPath() bool {
+ held, err := l.held.Stat()
+ if err != nil {
+ return false
+ }
+ current, err := os.Stat(l.path)
+ return err == nil && os.SameFile(held, current)
+}
diff --git a/lock/flock/lock_test.go b/lock/flock/lock_test.go
new file mode 100644
index 0000000..d902276
--- /dev/null
+++ b/lock/flock/lock_test.go
@@ -0,0 +1,75 @@
+package flock
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestLockSerializesInstances(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "entity.lock")
+ first, second := New(path), New(path)
+ if err := first.Lock(t.Context()); err != nil {
+ t.Fatalf("first lock: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
+ defer cancel()
+ if err := second.Lock(ctx); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("second lock error = %v, want deadline", err)
+ }
+ if err := first.Unlock(t.Context()); err != nil {
+ t.Fatalf("unlock: %v", err)
+ }
+}
+
+func TestTransientRemovesPath(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "init.lock")
+ lock := NewTransient(path)
+ if err := lock.Lock(t.Context()); err != nil {
+ t.Fatalf("lock: %v", err)
+ }
+ if err := lock.Unlock(t.Context()); err != nil {
+ t.Fatalf("unlock: %v", err)
+ }
+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("lock path remains: %v", err)
+ }
+}
+
+func TestSetReleasesPartialAcquisitionAndPersistentLocksKeepInode(t *testing.T) {
+ base := t.TempDir()
+ a, b := filepath.Join(base, "a.lock"), filepath.Join(base, "b.lock")
+ blocker := New(b)
+ if err := blocker.Lock(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := blocker.Unlock(t.Context()); err != nil {
+ t.Error(err)
+ }
+ }()
+ var set Set
+ ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
+ defer cancel()
+ if err := set.Lock(ctx, b, a, a); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("set error = %v", err)
+ }
+ probe := New(a)
+ if ok, err := probe.TryLock(t.Context()); err != nil || !ok {
+ t.Fatalf("partial lock leaked = %v, %v", ok, err)
+ }
+ before, err := os.Stat(a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := probe.Unlock(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ after, err := os.Stat(a)
+ if err != nil || !os.SameFile(before, after) {
+ t.Fatalf("persistent lock inode changed: %v", err)
+ }
+}
diff --git a/lock/flock/set.go b/lock/flock/set.go
new file mode 100644
index 0000000..35ae016
--- /dev/null
+++ b/lock/flock/set.go
@@ -0,0 +1,40 @@
+package flock
+
+import (
+ "context"
+ "errors"
+ "slices"
+)
+
+// Set owns an ordered group of persistent file locks. Its zero value is usable.
+// One owner must serialize acquisition and release; do not use it concurrently.
+type Set struct {
+ // held records acquisition order for reverse release and partial-failure cleanup.
+ held []*Lock
+}
+
+// Lock sorts and deduplicates paths so cooperating operations acquire in the same
+// order. On failure it releases every lock already held by the set. Callers should
+// acquire their full path set in one call to preserve ordering across operations.
+func (s *Set) Lock(ctx context.Context, paths ...string) error {
+ ordered := slices.Compact(slices.Sorted(slices.Values(paths)))
+ for _, path := range ordered {
+ item := New(path)
+ if err := item.Lock(ctx); err != nil {
+ return errors.Join(err, s.Unlock(ctx))
+ }
+ s.held = append(s.held, item)
+ }
+ return nil
+}
+
+// Unlock releases in reverse acquisition order, attempts every release, and clears
+// the set even when a lock reports a cleanup error.
+func (s *Set) Unlock(ctx context.Context) error {
+ var errs []error
+ for _, item := range slices.Backward(s.held) {
+ errs = append(errs, item.Unlock(ctx))
+ }
+ s.held = nil
+ return errors.Join(errs...)
+}
diff --git a/metadata/memory.go b/metadata/memory.go
new file mode 100644
index 0000000..adf5607
--- /dev/null
+++ b/metadata/memory.go
@@ -0,0 +1,175 @@
+package metadata
+
+import (
+ "context"
+ "errors"
+ "slices"
+ "sync"
+)
+
+// Memory is a snapshotting in-memory Store for business tests and engine contracts.
+// It is not a durable metadata engine.
+type Memory struct {
+ // mu protects the published snapshot and the closed flag.
+ mu sync.RWMutex
+ // writeToken serializes writers with context-aware acquisition.
+ writeToken chan struct{}
+ // records is replaced as a unit after a successful update callback.
+ records map[Collection]map[string][]byte
+ // closed prevents snapshots and commits after Close.
+ closed bool
+}
+
+// NewMemory creates an empty store with exactly the declared collections.
+// Invalid or duplicate declarations fail before a store is returned.
+func NewMemory(collections []Collection) (*Memory, error) {
+ records := make(map[Collection]map[string][]byte)
+ for _, collection := range collections {
+ if _, err := NewCollection(collection.String()); err != nil {
+ return nil, err
+ }
+ if _, exists := records[collection]; exists {
+ return nil, errors.New("duplicate collection")
+ }
+ records[collection] = make(map[string][]byte)
+ }
+ return &Memory{writeToken: make(chan struct{}, 1), records: records}, nil
+}
+
+// snapshot copies every record under the read lock; callbacks then run without
+// blocking readers or exposing the live store to mutation.
+func (s *Memory) snapshot(ctx context.Context) (*memoryTransaction, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if s.closed {
+ return nil, errors.New("metadata store is closed")
+ }
+ records := make(map[Collection]map[string][]byte)
+ for collection, entries := range s.records {
+ records[collection] = make(map[string][]byte)
+ for key, value := range entries {
+ records[collection][key] = slices.Clone(value)
+ }
+ }
+ return &memoryTransaction{records: records}, nil
+}
+
+// View reads a detached snapshot and propagates callback failure or cancellation.
+func (s *Memory) View(ctx context.Context, fn func(Reader) error) error {
+ snapshot, err := s.snapshot(ctx)
+ if err != nil {
+ return err
+ }
+ if err := fn(snapshot); err != nil {
+ return err
+ }
+ return ctx.Err()
+}
+
+// Update serializes writers and swaps in a copied snapshot only after the callback
+// succeeds and cancellation and closure have been checked under the commit lock.
+//
+// writer token -> copy snapshot -> callback -> commit lock -> replace records
+// |
+// +-- failure/cancellation: discard snapshot
+func (s *Memory) Update(ctx context.Context, fn func(Writer) error) error {
+ select {
+ case s.writeToken <- struct{}{}:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ defer func() { <-s.writeToken }()
+ snapshot, err := s.snapshot(ctx)
+ if err != nil {
+ return err
+ }
+ if err := fn(snapshot); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if s.closed {
+ return errors.New("metadata store is closed")
+ }
+ s.records = snapshot.records
+ return nil
+}
+
+// Close prevents future snapshots and commits without invalidating detached bytes.
+func (s *Memory) Close() error { s.mu.Lock(); defer s.mu.Unlock(); s.closed = true; return nil }
+
+// memoryTransaction is a callback-owned copy, not a concurrent transaction handle.
+type memoryTransaction struct {
+ // records contains only collections declared at store construction.
+ records map[Collection]map[string][]byte
+}
+
+// collection checks cancellation and declaration before accessing a record set.
+func (t *memoryTransaction) collection(ctx context.Context, collection Collection) (map[string][]byte, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ records, ok := t.records[collection]
+ if !ok {
+ return nil, errors.New("undeclared metadata collection")
+ }
+ return records, nil
+}
+
+// Get clones stored bytes so reads cannot mutate the transaction.
+func (t *memoryTransaction) Get(ctx context.Context, collection Collection, key string) ([]byte, bool, error) {
+ records, err := t.collection(ctx, collection)
+ if err != nil {
+ return nil, false, err
+ }
+ value, ok := records[key]
+ return slices.Clone(value), ok, nil
+}
+
+// Scan sorts keys for engine-independent ordering and detaches each visited value.
+func (t *memoryTransaction) Scan(ctx context.Context, collection Collection, fn func(string, []byte) error) error {
+ records, err := t.collection(ctx, collection)
+ if err != nil {
+ return err
+ }
+ keys := make([]string, 0, len(records))
+ for key := range records {
+ keys = append(keys, key)
+ }
+ slices.Sort(keys)
+ for _, key := range keys {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if err := fn(key, slices.Clone(records[key])); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Put clones input bytes to keep caller ownership separate from transaction state.
+func (t *memoryTransaction) Put(ctx context.Context, collection Collection, key string, value []byte) error {
+ records, err := t.collection(ctx, collection)
+ if err != nil {
+ return err
+ }
+ records[key] = slices.Clone(value)
+ return nil
+}
+
+// Delete removes a key from the callback snapshot without touching the live store.
+func (t *memoryTransaction) Delete(ctx context.Context, collection Collection, key string) error {
+ records, err := t.collection(ctx, collection)
+ if err != nil {
+ return err
+ }
+ delete(records, key)
+ return nil
+}
diff --git a/metadata/sqlite/store.go b/metadata/sqlite/store.go
new file mode 100644
index 0000000..12caffc
--- /dev/null
+++ b/metadata/sqlite/store.go
@@ -0,0 +1,376 @@
+// Package sqlite implements metadata transactions using SQLite WAL, separate
+// reader and writer pools, and a cross-process initialization lock. Module record
+// payloads remain opaque; image-specific indexes belong to images/catalog.
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "math/rand/v2"
+ "net/url"
+ "os"
+ "path/filepath"
+ "runtime"
+ "time"
+
+ moderncsqlite "modernc.org/sqlite"
+ modernclib "modernc.org/sqlite/lib"
+
+ "github.com/kumabox/kumabox/errdefs"
+ filelock "github.com/kumabox/kumabox/lock/flock"
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/storage"
+)
+
+const (
+ // applicationID distinguishes KumaBox metadata from unrelated SQLite files.
+ applicationID = 0x4B554D41
+ // schemaVersion identifies the current application collection contract.
+ schemaVersion = 4
+ // firstSchemaVersion is the oldest metadata version with an in-place migration.
+ firstSchemaVersion = 1
+ // initLockName serializes schema initialization across processes in this directory.
+ initLockName = "init.lock"
+)
+
+// Options bounds SQLite lock waits and the overall write transaction lifetime.
+// Both durations must be positive.
+type Options struct {
+ // BusyTimeout is the per-connection SQLite busy-handler wait.
+ BusyTimeout time.Duration
+ // RetryLimit bounds writer acquisition, begin retries, and callback execution.
+ RetryLimit time.Duration
+}
+
+// DefaultOptions uses short individual lock waits within a five-second write budget.
+func DefaultOptions() Options {
+ return Options{BusyTimeout: 50 * time.Millisecond, RetryLimit: 5 * time.Second}
+}
+
+// Store is the SQLite implementation of metadata.Store.
+type Store struct {
+ // readers permits concurrent read snapshots against the WAL database.
+ readers *sql.DB
+ // writer has one connection and begins immediate transactions before callbacks.
+ writer *sql.DB
+ // collections is the immutable allowlist shared by transaction handles.
+ collections map[metadata.Collection]struct{}
+ // retryLimit becomes each Update call's context deadline.
+ retryLimit time.Duration
+}
+
+var _ metadata.Store = (*Store)(nil)
+
+// Open validates paths and declarations, initializes or migrates the database
+// under a transient file lock, and verifies identity and declared collections.
+// The caller owns the returned store and must Close it.
+func Open(ctx context.Context, path string, collections []metadata.Collection, options Options) (*Store, error) {
+ if options.BusyTimeout <= 0 || options.RetryLimit <= 0 {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("sqlite timeouts must be positive"))
+ }
+ declared := make(map[metadata.Collection]struct{}, len(collections))
+ for _, collection := range collections {
+ parsed, err := metadata.NewCollection(collection.String())
+ if err != nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if _, exists := declared[parsed]; exists {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("collection %s declared twice", parsed))
+ }
+ declared[parsed] = struct{}{}
+ }
+ for _, file := range []string{path, path + "-wal", path + "-shm"} {
+ if err := storage.CheckPath(file); err != nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ }
+ if err := storage.EnsureDir(filepath.Dir(path)); err != nil {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err)
+ }
+ guard := filelock.NewTransient(filepath.Join(filepath.Dir(path), initLockName))
+ if err := guard.Lock(ctx); err != nil {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeStoreBusy, err)
+ }
+ initErr := initialize(ctx, path, collections, options)
+ unlockErr := guard.Unlock(context.WithoutCancel(ctx))
+ if err := errors.Join(initErr, unlockErr); err != nil {
+ return nil, err
+ }
+
+ readers, err := sql.Open("sqlite", dsn(path, options, false))
+ if err != nil {
+ return nil, mapError(err)
+ }
+ readers.SetMaxOpenConns(max(2, runtime.NumCPU()))
+ writer, err := sql.Open("sqlite", dsn(path, options, true))
+ if err != nil {
+ return nil, errors.Join(mapError(err), readers.Close())
+ }
+ writer.SetMaxOpenConns(1)
+ store := &Store{readers: readers, writer: writer, collections: declared, retryLimit: options.RetryLimit}
+ if err := store.verify(ctx); err != nil {
+ return nil, errors.Join(err, readers.Close(), writer.Close())
+ }
+ return store, nil
+}
+
+// View runs one callback in a read-only SQL transaction and rolls back on failure.
+func (s *Store) View(ctx context.Context, fn func(metadata.Reader) error) error {
+ tx, err := s.readers.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
+ if err != nil {
+ return mapError(err)
+ }
+ handle := &transaction{tx: tx, allowed: s.collections, writable: false}
+ if err := fn(handle); err != nil {
+ return errors.Join(err, rollback(tx))
+ }
+ return commit(ctx, tx)
+}
+
+// Update retries busy transaction acquisition within RetryLimit. An immediate
+// transaction obtains SQLite's writer reservation before invoking the callback,
+// so user callbacks run at most once and are never replayed for lock contention.
+//
+// write deadline -> BEGIN IMMEDIATE -- busy --> jitter and retry
+// |
+// v
+// callback -> success: COMMIT
+// |
+// +-----> failure: ROLLBACK
+func (s *Store) Update(ctx context.Context, fn func(metadata.Writer) error) error {
+ writeCtx, cancel := context.WithTimeout(ctx, s.retryLimit)
+ defer cancel()
+ for {
+ tx, err := s.writer.BeginTx(writeCtx, nil)
+ if err == nil {
+ handle := &transaction{tx: tx, allowed: s.collections, writable: true}
+ if err := fn(handle); err != nil {
+ return errors.Join(err, rollback(tx))
+ }
+ return commit(writeCtx, tx)
+ }
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ if writeCtx.Err() != nil {
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeStoreBusy, err)
+ }
+ if !busy(err) {
+ return mapError(err)
+ }
+ pause := time.Duration(rand.Int64N(int64(2 * time.Millisecond))) //nolint:gosec // scheduling jitter is not cryptographic
+ select {
+ case <-writeCtx.Done():
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeStoreBusy, writeCtx.Err())
+ case <-time.After(pause):
+ }
+ }
+}
+
+// Close releases both pools and preserves errors from each.
+func (s *Store) Close() error { return errors.Join(s.readers.Close(), s.writer.Close()) }
+
+// verify checks database ownership, schema version, and declared collection presence.
+func (s *Store) verify(ctx context.Context) error {
+ var appID, version int
+ if err := s.readers.QueryRowContext(ctx, "PRAGMA application_id").Scan(&appID); err != nil {
+ return mapError(err)
+ }
+ if err := s.readers.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
+ return mapError(err)
+ }
+ if appID != applicationID || version != schemaVersion {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("unexpected metadata identity %#x/version %d", appID, version))
+ }
+ for collection := range s.collections {
+ var present int
+ err := s.readers.QueryRowContext(ctx, "SELECT 1 FROM collections WHERE name = ?", collection.String()).Scan(&present)
+ if errors.Is(err, sql.ErrNoRows) {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("collection %s is not initialized", collection))
+ }
+ if err != nil {
+ return mapError(err)
+ }
+ }
+ return nil
+}
+
+// initialize creates an unidentified empty database or applies a supported
+// forward migration. The caller holds the directory initialization lock for
+// this entire operation.
+func initialize(ctx context.Context, path string, collections []metadata.Collection, options Options) (returnErr error) {
+ query := url.Values{}
+ query.Add("_pragma", fmt.Sprintf("busy_timeout(%d)", options.BusyTimeout.Milliseconds()))
+ query.Add("_txlock", "immediate")
+ db, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: filepath.Clean(path), RawQuery: query.Encode()}).String())
+ if err != nil {
+ return mapError(err)
+ }
+ db.SetMaxOpenConns(1)
+ defer func() { returnErr = errors.Join(returnErr, db.Close()) }()
+ var tables int
+ if err := db.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master").Scan(&tables); err != nil {
+ return mapError(err)
+ }
+ var appID, version int
+ if err := db.QueryRowContext(ctx, "PRAGMA application_id").Scan(&appID); err != nil {
+ return mapError(err)
+ }
+ if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
+ return mapError(err)
+ }
+ if tables > 0 {
+ if appID != applicationID {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("database belongs to application %#x, expected KumaBox %#x", appID, applicationID))
+ }
+ switch version {
+ case schemaVersion:
+ return nil
+ case 1, 2, 3:
+ return migrateCollections(ctx, db, collections, version)
+ default:
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("metadata schema version %d is unsupported; this binary supports versions %d through %d", version, firstSchemaVersion, schemaVersion))
+ }
+ }
+ if appID != 0 || version != 0 {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("empty database has unexpected identity"))
+ }
+ tx, err := db.BeginTx(ctx, nil)
+ if err != nil {
+ return mapError(err)
+ }
+ defer func() {
+ if returnErr != nil {
+ returnErr = errors.Join(returnErr, rollback(tx))
+ }
+ }()
+ statements := []string{
+ "CREATE TABLE collections (name TEXT NOT NULL PRIMARY KEY)",
+ "CREATE TABLE records (collection TEXT NOT NULL, id TEXT NOT NULL, data BLOB NOT NULL, PRIMARY KEY(collection, id), FOREIGN KEY(collection) REFERENCES collections(name))",
+ fmt.Sprintf("PRAGMA application_id = %d", applicationID),
+ fmt.Sprintf("PRAGMA user_version = %d", schemaVersion),
+ }
+ for _, statement := range statements {
+ if _, err := tx.ExecContext(ctx, statement); err != nil {
+ return mapError(err)
+ }
+ }
+ for _, collection := range collections {
+ if _, err := tx.ExecContext(ctx, "INSERT INTO collections(name) VALUES (?)", collection.String()); err != nil {
+ return mapError(err)
+ }
+ }
+ return commit(ctx, tx)
+}
+
+// migrateCollections adds collections introduced after the stored version and
+// publishes the current version only after every declaration is durable. All
+// supported versions use the same collections and records tables, so existing
+// module payloads remain unchanged.
+//
+// BEGIN IMMEDIATE -> register missing collections -> publish version -> COMMIT
+// \---------------- any failure: ROLLBACK -----------------/
+func migrateCollections(ctx context.Context, db *sql.DB, collections []metadata.Collection, from int) (returnErr error) {
+ if from < firstSchemaVersion || from >= schemaVersion {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("cannot migrate metadata schema version %d", from))
+ }
+ tx, err := db.BeginTx(ctx, nil)
+ if err != nil {
+ return mapError(err)
+ }
+ defer func() {
+ if returnErr != nil {
+ returnErr = errors.Join(returnErr, rollback(tx))
+ }
+ }()
+ for _, collection := range collections {
+ if _, err := tx.ExecContext(ctx, "INSERT INTO collections(name) VALUES (?) ON CONFLICT(name) DO NOTHING", collection.String()); err != nil {
+ return mapError(err)
+ }
+ }
+ if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", schemaVersion)); err != nil {
+ return mapError(err)
+ }
+ return commit(ctx, tx)
+}
+
+// dsn configures each connection with WAL durability and foreign-key enforcement;
+// writer connections additionally reserve the write lock when a transaction begins.
+func dsn(path string, options Options, immediate bool) string {
+ query := url.Values{}
+ query.Add("_pragma", "foreign_keys(1)")
+ query.Add("_pragma", "journal_mode(WAL)")
+ query.Add("_pragma", "synchronous(FULL)")
+ query.Add("_pragma", fmt.Sprintf("busy_timeout(%d)", options.BusyTimeout.Milliseconds()))
+ if immediate {
+ query.Add("_txlock", "immediate")
+ }
+ return (&url.URL{Scheme: "file", Path: filepath.Clean(path), RawQuery: query.Encode()}).String()
+}
+
+// mapError translates engine failures into shared policy while retaining causes
+// for errors.Is/errors.As and leaving cancellation or unknown errors intact.
+func mapError(err error) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, os.ErrPermission) {
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err)
+ }
+ var sqliteErr *moderncsqlite.Error
+ if !errors.As(err, &sqliteErr) {
+ return err
+ }
+ switch sqliteErr.Code() & 0xff {
+ case modernclib.SQLITE_BUSY, modernclib.SQLITE_LOCKED:
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeStoreBusy, err)
+ case modernclib.SQLITE_CONSTRAINT:
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeNameTaken, err)
+ case modernclib.SQLITE_CORRUPT, modernclib.SQLITE_NOTADB:
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, err)
+ case modernclib.SQLITE_FULL, modernclib.SQLITE_IOERR, modernclib.SQLITE_CANTOPEN, modernclib.SQLITE_READONLY:
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err)
+ default:
+ return err
+ }
+}
+
+// busy recognizes both database contention and table/schema lock contention.
+func busy(err error) bool {
+ var sqliteErr *moderncsqlite.Error
+ if !errors.As(err, &sqliteErr) {
+ return false
+ }
+ code := sqliteErr.Code() & 0xff
+ return code == modernclib.SQLITE_BUSY || code == modernclib.SQLITE_LOCKED
+}
+
+// commit handles the database/sql race where cancellation automatically rolls a
+// transaction back before Commit observes its context.
+// Preserve the cancellation cause without reporting cancellation after a successful commit.
+func commit(ctx context.Context, tx *sql.Tx) error {
+ if err := ctx.Err(); err != nil {
+ return errors.Join(err, rollback(tx))
+ }
+ err := tx.Commit()
+ if errors.Is(err, sql.ErrTxDone) {
+ if canceled := ctx.Err(); canceled != nil {
+ return canceled
+ }
+ }
+ return mapError(err)
+}
+
+// rollback treats an already completed transaction as successfully cleaned up.
+func rollback(tx *sql.Tx) error {
+ err := tx.Rollback()
+ if errors.Is(err, sql.ErrTxDone) {
+ return nil
+ }
+ return mapError(err)
+}
diff --git a/metadata/sqlite/store_test.go b/metadata/sqlite/store_test.go
new file mode 100644
index 0000000..4128ed2
--- /dev/null
+++ b/metadata/sqlite/store_test.go
@@ -0,0 +1,429 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/metadata"
+)
+
+func TestStoreCommitRollbackAndDetachedReads(t *testing.T) {
+ collection := metadata.Collection("records")
+ store, err := Open(t.Context(), filepath.Join(t.TempDir(), "meta.db"), []metadata.Collection{collection}, DefaultOptions())
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer func() {
+ if err := store.Close(); err != nil {
+ t.Errorf("Close: %v", err)
+ }
+ }()
+
+ if err := store.Update(t.Context(), func(writer metadata.Writer) error {
+ return writer.Put(t.Context(), collection, "one", []byte("value"))
+ }); err != nil {
+ t.Fatalf("Update: %v", err)
+ }
+ rollback := errors.New("rollback")
+ if err := store.Update(t.Context(), func(writer metadata.Writer) error {
+ if err := writer.Put(t.Context(), collection, "two", []byte("discard")); err != nil {
+ return err
+ }
+ return rollback
+ }); !errors.Is(err, rollback) {
+ t.Fatalf("rollback error = %v", err)
+ }
+ if err := store.View(t.Context(), func(reader metadata.Reader) error {
+ value, ok, err := reader.Get(t.Context(), collection, "one")
+ if err != nil || !ok || string(value) != "value" {
+ t.Fatalf("Get one = %q, %v, %v", value, ok, err)
+ }
+ value[0] = 'X'
+ _, ok, err = reader.Get(t.Context(), collection, "two")
+ if err != nil || ok {
+ t.Fatalf("rolled back record exists: %v, %v", ok, err)
+ }
+ return nil
+ }); err != nil {
+ t.Fatalf("View: %v", err)
+ }
+}
+
+func TestStoreSerializesConcurrentWriters(t *testing.T) {
+ collection := metadata.Collection("records")
+ store, err := Open(t.Context(), filepath.Join(t.TempDir(), "meta.db"), []metadata.Collection{collection}, DefaultOptions())
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer func() {
+ if err := store.Close(); err != nil {
+ t.Errorf("Close: %v", err)
+ }
+ }()
+ var wait sync.WaitGroup
+ for index := range 12 {
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ id := string(rune('a' + index))
+ if err := store.Update(context.Background(), func(writer metadata.Writer) error {
+ return writer.Put(context.Background(), collection, id, []byte(id))
+ }); err != nil {
+ t.Errorf("Update %s: %v", id, err)
+ }
+ }()
+ }
+ wait.Wait()
+ count := 0
+ if err := store.View(t.Context(), func(reader metadata.Reader) error {
+ return reader.Scan(t.Context(), collection, func(string, []byte) error {
+ count++
+ return nil
+ })
+ }); err != nil {
+ t.Fatalf("View: %v", err)
+ }
+ if count != 12 {
+ t.Fatalf("record count = %d, want 12", count)
+ }
+}
+
+func TestStoreRejectsForeignDatabaseWithoutChangingJournal(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "meta.db")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := db.Close(); err != nil {
+ t.Error(err)
+ }
+ }()
+ if _, err := db.Exec("CREATE TABLE foreign_data (value TEXT)"); err != nil {
+ t.Fatal(err)
+ }
+ if store, err := Open(t.Context(), path, []metadata.Collection{"records"}, DefaultOptions()); err == nil {
+ if err := store.Close(); err != nil {
+ t.Error(err)
+ }
+ t.Fatal("adopted foreign database")
+ } else if code, _ := errdefs.CodeOf(err); code != errdefs.CodeArtifactCorrupt {
+ t.Fatalf("identity error = %v", err)
+ }
+ var mode string
+ if err := db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
+ t.Fatal(err)
+ }
+ if mode != "delete" {
+ t.Fatalf("modified foreign database journal = %s", mode)
+ }
+}
+
+func TestStoreMigratesVersionOneAndPreservesRecords(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "meta.db")
+ legacy := metadata.Collection("images")
+ added := metadata.Collection("sandboxes")
+ writeVersionOneDatabase(t, path, "CREATE TABLE collections (name TEXT NOT NULL PRIMARY KEY)")
+
+ store, err := Open(t.Context(), path, []metadata.Collection{legacy, added}, DefaultOptions())
+ if err != nil {
+ t.Fatalf("Open migrated database: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := store.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ if err := store.View(t.Context(), func(reader metadata.Reader) error {
+ value, exists, err := reader.Get(t.Context(), legacy, "legacy")
+ if err != nil {
+ return err
+ }
+ if !exists || string(value) != "keep" {
+ return fmt.Errorf("legacy record = %q, %v", value, exists)
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Update(t.Context(), func(writer metadata.Writer) error {
+ return writer.Put(t.Context(), added, "new", []byte("sandbox"))
+ }); err != nil {
+ t.Fatalf("write added collection: %v", err)
+ }
+ var version int
+ if err := store.readers.QueryRowContext(t.Context(), "PRAGMA user_version").Scan(&version); err != nil {
+ t.Fatal(err)
+ }
+ if version != schemaVersion {
+ t.Fatalf("schema version = %d, want %d", version, schemaVersion)
+ }
+}
+
+func TestStoreMigratesVersionTwoAndPreservesSandboxRecords(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "meta.db")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ statements := []string{
+ "CREATE TABLE collections (name TEXT NOT NULL PRIMARY KEY)",
+ "CREATE TABLE records (collection TEXT NOT NULL, id TEXT NOT NULL, data BLOB NOT NULL, PRIMARY KEY(collection, id), FOREIGN KEY(collection) REFERENCES collections(name))",
+ fmt.Sprintf("PRAGMA application_id = %d", applicationID),
+ "PRAGMA user_version = 2",
+ "INSERT INTO collections(name) VALUES ('sandboxes')",
+ "INSERT INTO records(collection,id,data) VALUES ('sandboxes','sandbox-id',x'6b656570')",
+ }
+ for _, statement := range statements {
+ if _, err := db.Exec(statement); err != nil {
+ _ = db.Close()
+ t.Fatal(err)
+ }
+ }
+ if err := db.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(t.Context(), path, []metadata.Collection{"sandboxes", "network_records"}, DefaultOptions())
+ if err != nil {
+ t.Fatalf("Open migrated v2 database: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := store.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ if err := store.View(t.Context(), func(reader metadata.Reader) error {
+ value, exists, err := reader.Get(t.Context(), "sandboxes", "sandbox-id")
+ if err != nil {
+ return err
+ }
+ if !exists || string(value) != "keep" {
+ return fmt.Errorf("sandbox record = %q, %t", value, exists)
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Update(t.Context(), func(writer metadata.Writer) error {
+ return writer.Put(t.Context(), "network_records", "network-id", []byte("network"))
+ }); err != nil {
+ t.Fatalf("write migrated network collection: %v", err)
+ }
+}
+
+func TestStoreMigratesVersionThreeAndAddsSnapshotCollections(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "meta.db")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ statements := []string{
+ "CREATE TABLE collections (name TEXT NOT NULL PRIMARY KEY)",
+ "CREATE TABLE records (collection TEXT NOT NULL, id TEXT NOT NULL, data BLOB NOT NULL, PRIMARY KEY(collection, id), FOREIGN KEY(collection) REFERENCES collections(name))",
+ fmt.Sprintf("PRAGMA application_id = %d", applicationID),
+ "PRAGMA user_version = 3",
+ "INSERT INTO collections(name) VALUES ('sandboxes')",
+ }
+ for _, statement := range statements {
+ if _, err := db.Exec(statement); err != nil {
+ _ = db.Close()
+ t.Fatal(err)
+ }
+ }
+ if err := db.Close(); err != nil {
+ t.Fatal(err)
+ }
+ store, err := Open(t.Context(), path, []metadata.Collection{"sandboxes", "snapshots", "snapshot_names"}, DefaultOptions())
+ if err != nil {
+ t.Fatalf("Open migrated v3 database: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := store.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ if err := store.Update(t.Context(), func(writer metadata.Writer) error {
+ return writer.Put(t.Context(), "snapshots", "snapshot-id", []byte("snapshot"))
+ }); err != nil {
+ t.Fatalf("write migrated snapshot collection: %v", err)
+ }
+}
+
+func TestStoreMigrationFailureRollsBackVersionAndCollections(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "meta.db")
+ writeVersionOneDatabase(t, path, "CREATE TABLE collections (name TEXT NOT NULL PRIMARY KEY CHECK(name <> 'sandboxes'))")
+
+ if store, err := Open(t.Context(), path, []metadata.Collection{"images", "sandboxes"}, DefaultOptions()); err == nil {
+ if err := store.Close(); err != nil {
+ t.Error(err)
+ }
+ t.Fatal("migration unexpectedly succeeded")
+ }
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := db.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ var version int
+ if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
+ t.Fatal(err)
+ }
+ if version != firstSchemaVersion {
+ t.Fatalf("schema version after rollback = %d, want %d", version, firstSchemaVersion)
+ }
+ var added int
+ if err := db.QueryRow("SELECT count(*) FROM collections WHERE name = 'sandboxes'").Scan(&added); err != nil {
+ t.Fatal(err)
+ }
+ if added != 0 {
+ t.Fatal("failed migration published sandbox collection")
+ }
+ var value []byte
+ if err := db.QueryRow("SELECT data FROM records WHERE collection = 'images' AND id = 'legacy'").Scan(&value); err != nil {
+ t.Fatal(err)
+ }
+ if string(value) != "keep" {
+ t.Fatalf("legacy record after rollback = %q", value)
+ }
+}
+
+// writeVersionOneDatabase creates the exact generic table shape used before
+// sandbox collections existed and leaves one image record as migration evidence.
+func writeVersionOneDatabase(t *testing.T, path, collectionsDDL string) {
+ t.Helper()
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ statements := []string{
+ collectionsDDL,
+ "CREATE TABLE records (collection TEXT NOT NULL, id TEXT NOT NULL, data BLOB NOT NULL, PRIMARY KEY(collection, id), FOREIGN KEY(collection) REFERENCES collections(name))",
+ fmt.Sprintf("PRAGMA application_id = %d", applicationID),
+ fmt.Sprintf("PRAGMA user_version = %d", firstSchemaVersion),
+ "INSERT INTO collections(name) VALUES ('images')",
+ "INSERT INTO records(collection,id,data) VALUES ('images','legacy',x'6b656570')",
+ }
+ for _, statement := range statements {
+ if _, err := db.Exec(statement); err != nil {
+ _ = db.Close()
+ t.Fatal(err)
+ }
+ }
+ if err := db.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestStoreBusyIsBoundedAcrossProcessesAndWithinPool(t *testing.T) {
+ for _, shared := range []bool{false, true} {
+ t.Run(fmt.Sprint(shared), func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "meta.db")
+ options := Options{BusyTimeout: 5 * time.Millisecond, RetryLimit: 40 * time.Millisecond}
+ first, err := Open(t.Context(), path, []metadata.Collection{"records"}, options)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := first.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ second := first
+ if !shared {
+ second, err = Open(t.Context(), path, []metadata.Collection{"records"}, options)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := second.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ }
+ ready, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
+ // Keep the first transaction alive longer than the second store's retry bound.
+ first.retryLimit = time.Second
+ go func() {
+ done <- first.Update(t.Context(), func(metadata.Writer) error { close(ready); <-release; return nil })
+ }()
+ <-ready
+ // For the pool case restore the caller bound while the first transaction retains its own context.
+ if shared {
+ first.retryLimit = options.RetryLimit
+ }
+ start := time.Now()
+ err = second.Update(t.Context(), func(metadata.Writer) error { return nil })
+ close(release)
+ if firstErr := <-done; firstErr != nil {
+ t.Fatal(firstErr)
+ }
+ if code, _ := errdefs.CodeOf(err); code != errdefs.CodeStoreBusy {
+ t.Fatalf("busy error = %v", err)
+ }
+ if time.Since(start) > time.Second {
+ t.Fatal("busy wait exceeded bound")
+ }
+ })
+ }
+}
+
+func TestStorePreservesCancellationAfterAutomaticRollback(t *testing.T) {
+ collection := metadata.Collection("records")
+ store, err := Open(t.Context(), filepath.Join(t.TempDir(), "meta.db"), []metadata.Collection{collection}, DefaultOptions())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := store.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ err = store.Update(ctx, func(writer metadata.Writer) error {
+ if err := writer.Put(ctx, collection, "canceled", []byte("discard")); err != nil {
+ return err
+ }
+ cancel()
+ // The writer pool has one connection. A second writer can proceed only
+ // after database/sql automatically rolls back the canceled transaction.
+ return store.Update(t.Context(), func(next metadata.Writer) error {
+ return next.Put(t.Context(), collection, "committed", []byte("keep"))
+ })
+ })
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancellation = %v", err)
+ }
+ if err := store.View(t.Context(), func(reader metadata.Reader) error {
+ _, exists, err := reader.Get(t.Context(), collection, "canceled")
+ if err != nil {
+ return err
+ }
+ if exists {
+ return errors.New("canceled transaction became visible")
+ }
+ value, exists, err := reader.Get(t.Context(), collection, "committed")
+ if err != nil {
+ return err
+ }
+ if !exists || string(value) != "keep" {
+ return errors.New("subsequent writer did not commit")
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/metadata/sqlite/transaction.go b/metadata/sqlite/transaction.go
new file mode 100644
index 0000000..ecc45f4
--- /dev/null
+++ b/metadata/sqlite/transaction.go
@@ -0,0 +1,92 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+
+ "github.com/kumabox/kumabox/metadata"
+)
+
+// transaction restricts a callback-scoped SQL transaction to declared collections.
+// Handles must not escape the Store callback or be used concurrently.
+type transaction struct {
+ // tx owns the snapshot and is committed or rolled back by Store.
+ tx *sql.Tx
+ // allowed references the store's immutable collection allowlist.
+ allowed map[metadata.Collection]struct{}
+ // writable denies mutations even if the driver does not enforce read-only mode.
+ writable bool
+}
+
+// Get detaches driver-owned bytes and represents a missing key without an error.
+func (t *transaction) Get(ctx context.Context, collection metadata.Collection, id string) ([]byte, bool, error) {
+ if err := t.check(collection); err != nil {
+ return nil, false, err
+ }
+ var data []byte
+ err := t.tx.QueryRowContext(ctx, "SELECT data FROM records WHERE collection = ? AND id = ?", collection.String(), id).Scan(&data)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, false, nil
+ }
+ if err != nil {
+ return nil, false, mapError(err)
+ }
+ return append([]byte(nil), data...), true, nil
+}
+
+// Scan visits keys in SQL order, copies payloads, and closes rows on every exit.
+func (t *transaction) Scan(ctx context.Context, collection metadata.Collection, visit func(string, []byte) error) (returnErr error) {
+ if err := t.check(collection); err != nil {
+ return err
+ }
+ rows, err := t.tx.QueryContext(ctx, "SELECT id, data FROM records WHERE collection = ? ORDER BY id", collection.String())
+ if err != nil {
+ return mapError(err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, rows.Close()) }()
+ for rows.Next() {
+ var id string
+ var data []byte
+ if err := rows.Scan(&id, &data); err != nil {
+ return mapError(err)
+ }
+ if err := visit(id, append([]byte(nil), data...)); err != nil {
+ return err
+ }
+ }
+ return mapError(rows.Err())
+}
+
+// Put upserts a copied payload within the current writable transaction.
+func (t *transaction) Put(ctx context.Context, collection metadata.Collection, id string, data []byte) error {
+ if !t.writable {
+ return fmt.Errorf("metadata transaction is read-only")
+ }
+ if err := t.check(collection); err != nil {
+ return err
+ }
+ _, err := t.tx.ExecContext(ctx, "INSERT INTO records(collection,id,data) VALUES(?,?,?) ON CONFLICT(collection,id) DO UPDATE SET data=excluded.data", collection.String(), id, append([]byte(nil), data...))
+ return mapError(err)
+}
+
+// Delete removes a key within the current writable transaction; absence is harmless.
+func (t *transaction) Delete(ctx context.Context, collection metadata.Collection, id string) error {
+ if !t.writable {
+ return fmt.Errorf("metadata transaction is read-only")
+ }
+ if err := t.check(collection); err != nil {
+ return err
+ }
+ _, err := t.tx.ExecContext(ctx, "DELETE FROM records WHERE collection = ? AND id = ?", collection.String(), id)
+ return mapError(err)
+}
+
+// check rejects access outside the engine's declared collections before issuing SQL.
+func (t *transaction) check(collection metadata.Collection) error {
+ if _, ok := t.allowed[collection]; !ok {
+ return fmt.Errorf("metadata collection %q was not declared", collection)
+ }
+ return nil
+}
diff --git a/metadata/store.go b/metadata/store.go
new file mode 100644
index 0000000..5b00d14
--- /dev/null
+++ b/metadata/store.go
@@ -0,0 +1,60 @@
+// Package metadata defines collection-scoped transactions without prescribing
+// a storage engine or interpreting module-owned record payloads.
+package metadata
+
+import (
+ "context"
+ "fmt"
+)
+
+// Store is the engine-neutral metadata transaction boundary. Transaction handles
+// are callback-scoped and must not escape or be used concurrently. Callers declare
+// collections when constructing an engine; undeclared collections are rejected.
+type Store interface {
+ // View invokes its callback against one consistent read snapshot.
+ // Callback failure or cancellation is returned to the caller.
+ View(context.Context, func(Reader) error) error
+ // Update commits all callback writes atomically on success and discards them
+ // on failure. A callback is not retried after it starts.
+ Update(context.Context, func(Writer) error) error
+ // Close releases engine resources; subsequent transactions must fail.
+ Close() error
+}
+
+// Reader reads detached records from a consistent snapshot.
+type Reader interface {
+ // Get returns caller-owned bytes and an existence flag; absence is not an error.
+ Get(context.Context, Collection, string) ([]byte, bool, error)
+ // Scan visits records in key order with detached bytes and stops on callback error.
+ Scan(context.Context, Collection, func(string, []byte) error) error
+}
+
+// Writer mutates records in one atomic transaction.
+type Writer interface {
+ // Reader sees earlier writes in the same transaction.
+ Reader
+ // Put replaces a record, copying its bytes so later caller mutation is harmless.
+ Put(context.Context, Collection, string, []byte) error
+ // Delete removes a record; deleting an absent key succeeds.
+ Delete(context.Context, Collection, string) error
+}
+
+// Collection identifies one fixed module-owned record set.
+type Collection string
+
+// NewCollection validates a fixed collection name: 1-63 lowercase ASCII letters,
+// digits, or underscores, beginning with a letter. Records remain engine-neutral.
+func NewCollection(name string) (Collection, error) {
+ if len(name) == 0 || len(name) > 63 || name[0] < 'a' || name[0] > 'z' {
+ return "", fmt.Errorf("invalid metadata collection %q", name)
+ }
+ for _, c := range name {
+ if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '_' {
+ return "", fmt.Errorf("invalid metadata collection %q", name)
+ }
+ }
+ return Collection(name), nil
+}
+
+// String returns the collection name used by engine adapters.
+func (c Collection) String() string { return string(c) }
diff --git a/metadata/store_test.go b/metadata/store_test.go
new file mode 100644
index 0000000..f430c7a
--- /dev/null
+++ b/metadata/store_test.go
@@ -0,0 +1,174 @@
+package metadata_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "sync"
+ "testing"
+
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/metadata/sqlite"
+)
+
+type storeFactory func(*testing.T, []metadata.Collection) metadata.Store
+
+func runStoreContract(t *testing.T, open storeFactory) {
+ t.Helper()
+ const collection metadata.Collection = "contract"
+ t.Run("atomic rollback and detached bytes", func(t *testing.T) {
+ store := open(t, []metadata.Collection{collection})
+ ctx := t.Context()
+ value := []byte("original")
+ if err := store.Update(ctx, func(w metadata.Writer) error { return w.Put(ctx, collection, "a", value) }); err != nil {
+ t.Fatal(err)
+ }
+ value[0] = 'X'
+ rollback := errors.New("injected failure")
+ err := store.Update(ctx, func(w metadata.Writer) error {
+ if err := w.Put(ctx, collection, "a", []byte("changed")); err != nil {
+ return err
+ }
+ if err := w.Put(ctx, collection, "b", []byte("new")); err != nil {
+ return err
+ }
+ return rollback
+ })
+ if !errors.Is(err, rollback) {
+ t.Fatalf("rollback cause = %v", err)
+ }
+ if err := store.View(ctx, func(r metadata.Reader) error {
+ got, ok, err := r.Get(ctx, collection, "a")
+ if err != nil || !ok || string(got) != "original" {
+ return fmt.Errorf("get = %q, %v, %v", got, ok, err)
+ }
+ got[0] = 'Y'
+ if err := r.Scan(ctx, collection, func(_ string, value []byte) error { value[0] = 'Z'; return nil }); err != nil {
+ return err
+ }
+ got, _, err = r.Get(ctx, collection, "a")
+ if err != nil || string(got) != "original" {
+ return fmt.Errorf("detached Get = %q, %v", got, err)
+ }
+ _, ok, err = r.Get(ctx, collection, "b")
+ if err != nil || ok {
+ return fmt.Errorf("rolled-back record exists = %v, %v", ok, err)
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Run("snapshot survives concurrent commit", func(t *testing.T) {
+ store := open(t, []metadata.Collection{collection})
+ ctx := t.Context()
+ if err := store.Update(ctx, func(w metadata.Writer) error { return w.Put(ctx, collection, "key", []byte("before")) }); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.View(ctx, func(r metadata.Reader) error {
+ before, _, err := r.Get(ctx, collection, "key")
+ if err != nil {
+ return err
+ }
+ if err := store.Update(ctx, func(w metadata.Writer) error { return w.Put(ctx, collection, "key", []byte("after")) }); err != nil {
+ return err
+ }
+ after, _, err := r.Get(ctx, collection, "key")
+ if err != nil || string(before) != "before" || string(after) != "before" {
+ return fmt.Errorf("snapshot changed: %q -> %q, %v", before, after, err)
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Run("cancellation rolls back", func(t *testing.T) {
+ store := open(t, []metadata.Collection{collection})
+ ctx, cancel := context.WithCancel(t.Context())
+ err := store.Update(ctx, func(w metadata.Writer) error {
+ if err := w.Put(ctx, collection, "key", []byte("discard")); err != nil {
+ return err
+ }
+ cancel()
+ return nil
+ })
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancellation = %v", err)
+ }
+ if err := store.View(t.Context(), func(r metadata.Reader) error {
+ _, ok, err := r.Get(t.Context(), collection, "key")
+ if ok {
+ return errors.New("canceled transaction committed")
+ }
+ return err
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Update(ctx, func(metadata.Writer) error { t.Error("canceled callback ran"); return nil }); !errors.Is(err, context.Canceled) {
+ t.Fatalf("pre-canceled Update = %v", err)
+ }
+ })
+ t.Run("concurrent writers and deletion", func(t *testing.T) {
+ store := open(t, []metadata.Collection{collection})
+ ctx := t.Context()
+ var wait sync.WaitGroup
+ for index := range 16 {
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ if err := store.Update(ctx, func(w metadata.Writer) error { return w.Put(ctx, collection, fmt.Sprint(index), []byte("value")) }); err != nil {
+ t.Error(err)
+ }
+ }()
+ }
+ wait.Wait()
+ if err := store.Update(ctx, func(w metadata.Writer) error { return w.Delete(ctx, collection, "0") }); err != nil {
+ t.Fatal(err)
+ }
+ count := 0
+ if err := store.View(ctx, func(r metadata.Reader) error {
+ return r.Scan(ctx, collection, func(string, []byte) error { count++; return nil })
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if count != 15 {
+ t.Fatalf("committed record count = %d, want 15", count)
+ }
+ })
+}
+
+func TestStoreContract(t *testing.T) {
+ factories := []struct {
+ name string
+ open storeFactory
+ }{
+ {"memory", func(t *testing.T, collections []metadata.Collection) metadata.Store {
+ store, err := metadata.NewMemory(collections)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return store
+ }},
+ {"sqlite", func(t *testing.T, collections []metadata.Collection) metadata.Store {
+ store, err := sqlite.Open(t.Context(), filepath.Join(t.TempDir(), "meta.db"), collections, sqlite.DefaultOptions())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return store
+ }},
+ }
+ for _, factory := range factories {
+ t.Run(factory.name, func(t *testing.T) {
+ runStoreContract(t, func(t *testing.T, collections []metadata.Collection) metadata.Store {
+ store := factory.open(t, collections)
+ t.Cleanup(func() {
+ if err := store.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ return store
+ })
+ })
+ }
+}
diff --git a/network/cni/cni.go b/network/cni/cni.go
new file mode 100644
index 0000000..15b61a8
--- /dev/null
+++ b/network/cni/cni.go
@@ -0,0 +1,388 @@
+// Package cni implements network.Provider with CNI plugins, one named network
+// namespace per sandbox, and TAP devices connected through traffic-control
+// redirects.
+package cni
+
+import (
+ "cmp"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "maps"
+ "path/filepath"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/containernetworking/cni/libcni"
+ cnitypes "github.com/containernetworking/cni/pkg/types"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/network"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+// CollectionRecords stores one crash-recoverable aggregate per sandbox.
+const CollectionRecords metadata.Collection = "network_records"
+
+const (
+ recordVersion = 1
+ defaultTAPPrefix = "tap"
+ namedNamespaceDir = "/var/run/netns"
+)
+
+// Collections declares the metadata owned by the CNI adapter.
+func Collections() []metadata.Collection { return []metadata.Collection{CollectionRecords} }
+
+// Options contains immutable host paths and cleanup policy for one provider.
+type Options struct {
+ // ConfDir contains host-installed .conflist files.
+ ConfDir string
+ // BinDir contains host-installed CNI plugin executables.
+ BinDir string
+ // CacheDir is managed persistent state used by the CNI library.
+ CacheDir string
+ // NamespacePrefix separates named namespaces owned by this installation.
+ NamespacePrefix string
+ // CleanupTimeout bounds rollback after caller cancellation.
+ CleanupTimeout time.Duration
+}
+
+// Validate rejects ambiguous or unsafe provider configuration.
+func (o Options) Validate() error {
+ for name, path := range map[string]string{"configuration": o.ConfDir, "binary": o.BinDir, "cache": o.CacheDir} {
+ if !filepath.IsAbs(path) {
+ return fmt.Errorf("CNI %s directory must be absolute", name)
+ }
+ }
+ if o.NamespacePrefix == "" || len(o.NamespacePrefix) > 32 || strings.ContainsAny(o.NamespacePrefix, "/\x00") {
+ return errors.New("CNI namespace prefix is invalid")
+ }
+ for _, character := range o.NamespacePrefix {
+ if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') &&
+ (character < '0' || character > '9') && character != '-' && character != '_' {
+ return errors.New("CNI namespace prefix is invalid")
+ }
+ }
+ if o.CleanupTimeout <= 0 {
+ return errors.New("CNI cleanup timeout must be positive")
+ }
+ return nil
+}
+
+// pluginRuntime executes one parsed CNI network list. The narrow seam keeps lifecycle
+// recovery testable without requiring root privileges or plugin binaries.
+type pluginRuntime interface {
+ AddNetworkList(context.Context, *libcni.NetworkConfigList, *libcni.RuntimeConf) (cnitypes.Result, error)
+ DelNetworkList(context.Context, *libcni.NetworkConfigList, *libcni.RuntimeConf) error
+}
+
+// platform owns Linux namespace, link, TAP, and traffic-control operations.
+type platform interface {
+ EnsureNamespace(string, string) (bool, error)
+ RemoveNamespace(context.Context, string) error
+ NamespaceExists(string) error
+ SetupRedirect(string, string, string, int, string) (string, error)
+ DeleteTAP(string, string) error
+ SetLinkState(string, []string, bool) error
+ VerifyTAP(string, string) error
+}
+
+// Provider is the CNI implementation of network.Provider.
+type Provider struct {
+ options Options
+ store metadata.Store
+ lists map[string]*libcni.NetworkConfigList
+ defaultName string
+ runtime pluginRuntime
+ platform platform
+ loadErr error
+}
+
+var _ network.Provider = (*Provider)(nil)
+
+// New creates a provider. Conflist discovery is intentionally best-effort so a
+// command can still open metadata and report or retry retained cleanup state
+// after host configuration has temporarily disappeared.
+func New(options Options, store metadata.Store) (*Provider, error) {
+ if err := options.Validate(); err != nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if store == nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("CNI metadata store is required"))
+ }
+ if err := storage.EnsureDir(options.CacheDir); err != nil {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, fmt.Errorf("create CNI cache: %w", err))
+ }
+ provider := &Provider{
+ options: options, store: store, platform: newPlatform(),
+ lists: make(map[string]*libcni.NetworkConfigList),
+ }
+ lists, defaultName, err := loadConfLists(options.ConfDir)
+ if err != nil {
+ provider.loadErr = err
+ return provider, nil
+ }
+ provider.lists = lists
+ provider.defaultName = defaultName
+ provider.runtime = libcni.NewCNIConfigWithCacheDir([]string{options.BinDir}, options.CacheDir, nil)
+ return provider, nil
+}
+
+// Type returns the durable provider identity.
+func (*Provider) Type() types.NetworkBackend { return types.NetworkBackendCNI }
+
+// confList resolves an explicit conflist name or the deterministic first file.
+func (p *Provider) confList(name string) (*libcni.NetworkConfigList, error) {
+ if p == nil || p.runtime == nil || len(p.lists) == 0 {
+ if p != nil && p.loadErr != nil {
+ return nil, fmt.Errorf("%w: load .conflist files from %s: %w", network.ErrNotConfigured, p.options.ConfDir, p.loadErr)
+ }
+ return nil, fmt.Errorf("%w: no .conflist files in %s", network.ErrNotConfigured, p.options.ConfDir)
+ }
+ resolved := cmp.Or(name, p.defaultName)
+ list, exists := p.lists[resolved]
+ if !exists {
+ return nil, fmt.Errorf("CNI network %q not found; available networks: %s", resolved, strings.Join(slices.Sorted(maps.Keys(p.lists)), ", "))
+ }
+ return list, nil
+}
+
+// loadConfLists loads only explicit CNI list files. A single-plugin .conf is
+// not silently treated as an application network contract.
+func loadConfLists(dir string) (map[string]*libcni.NetworkConfigList, string, error) {
+ files, err := libcni.ConfFiles(dir, []string{".conflist"})
+ if err != nil {
+ return nil, "", err
+ }
+ if len(files) == 0 {
+ return nil, "", fmt.Errorf("no .conflist files in %s", dir)
+ }
+ slices.Sort(files)
+ result := make(map[string]*libcni.NetworkConfigList, len(files))
+ defaultName := ""
+ for _, path := range files {
+ list, err := libcni.ConfListFromFile(path)
+ if err != nil {
+ return nil, "", fmt.Errorf("parse %s: %w", path, err)
+ }
+ if _, exists := result[list.Name]; exists {
+ return nil, "", fmt.Errorf("CNI network name %q is declared more than once", list.Name)
+ }
+ result[list.Name] = list
+ if defaultName == "" {
+ defaultName = list.Name
+ }
+ }
+ return result, defaultName, nil
+}
+
+type (
+ recordPhase string
+ interfacePhase string
+)
+
+const (
+ phasePreparing recordPhase = "preparing"
+ phaseReady recordPhase = "ready"
+ phaseDeleting recordPhase = "deleting"
+
+ interfaceStaged interfacePhase = "staged"
+ interfaceAdding interfacePhase = "adding"
+ interfaceReady interfacePhase = "ready"
+)
+
+// recordData is an adapter-owned cleanup journal. The aggregate is written
+// before namespace creation, and each NIC reaches adding before plugin code can
+// produce host-side effects.
+type recordData struct {
+ Version int `json:"version"`
+ SandboxID string `json:"sandbox_id"`
+ Network string `json:"network,omitempty"`
+ NamespaceName string `json:"namespace_name"`
+ NamespacePath string `json:"namespace_path"`
+ Phase recordPhase `json:"phase"`
+ Interfaces []interfaceData `json:"interfaces"`
+}
+
+type interfaceData struct {
+ Index int `json:"index"`
+ Name string `json:"name"`
+ TAP string `json:"tap"`
+ Phase interfacePhase `json:"phase"`
+ MAC string `json:"mac,omitempty"`
+ Queues int `json:"queues"`
+ QueueSize int `json:"queue_size"`
+ IPv4 *ipv4Data `json:"ipv4,omitempty"`
+}
+
+type ipv4Data struct {
+ Address string `json:"address"`
+ Gateway string `json:"gateway,omitempty"`
+ Prefix int `json:"prefix"`
+}
+
+func (p *Provider) namespace(id types.SandboxID) (string, string) {
+ name := p.options.NamespacePrefix + id.String()
+ return name, filepath.Join(namedNamespaceDir, name)
+}
+
+func (p *Provider) view(ctx context.Context, id types.SandboxID) (*recordData, error) {
+ var result *recordData
+ err := p.store.View(ctx, func(reader metadata.Reader) error {
+ raw, exists, err := reader.Get(ctx, CollectionRecords, id.String())
+ if err != nil || !exists {
+ return err
+ }
+ result, err = decodeRecord(raw)
+ return err
+ })
+ return result, err
+}
+
+func (p *Provider) update(ctx context.Context, id types.SandboxID, mutate func(*recordData) (*recordData, error)) error {
+ return p.store.Update(ctx, func(writer metadata.Writer) error {
+ raw, exists, err := writer.Get(ctx, CollectionRecords, id.String())
+ if err != nil {
+ return err
+ }
+ var record *recordData
+ if exists {
+ record, err = decodeRecord(raw)
+ if err != nil {
+ return err
+ }
+ }
+ next, err := mutate(record)
+ if err != nil {
+ return err
+ }
+ if next == nil {
+ return writer.Delete(ctx, CollectionRecords, id.String())
+ }
+ if next.SandboxID != id.String() {
+ return errors.New("network record ID differs from its metadata key")
+ }
+ return putRecord(ctx, writer, next)
+ })
+}
+
+func putRecord(ctx context.Context, writer metadata.Writer, record *recordData) error {
+ if err := validateRecord(record); err != nil {
+ return err
+ }
+ raw, err := json.Marshal(record)
+ if err != nil {
+ return err
+ }
+ return writer.Put(ctx, CollectionRecords, record.SandboxID, raw)
+}
+
+func decodeRecord(raw []byte) (*recordData, error) {
+ var record recordData
+ if err := json.Unmarshal(raw, &record); err != nil {
+ return nil, corrupt(err)
+ }
+ if err := validateRecord(&record); err != nil {
+ return nil, corrupt(err)
+ }
+ return &record, nil
+}
+
+func validateRecord(record *recordData) error {
+ if record == nil {
+ return errors.New("network record is missing")
+ }
+ if record.Version != recordVersion {
+ return fmt.Errorf("network record version %d is unsupported", record.Version)
+ }
+ if _, err := types.ParseSandboxID(record.SandboxID); err != nil {
+ return err
+ }
+ if record.NamespaceName == "" || record.NamespacePath != filepath.Join(namedNamespaceDir, record.NamespaceName) {
+ return errors.New("network record namespace is invalid")
+ }
+ if len(record.Interfaces) > 0 && record.Network == "" {
+ return errors.New("network record with interfaces requires a conflist name")
+ }
+ switch record.Phase {
+ case phasePreparing, phaseReady, phaseDeleting:
+ default:
+ return fmt.Errorf("network record phase %q is invalid", record.Phase)
+ }
+ seen := make(map[int]struct{}, len(record.Interfaces))
+ for _, item := range record.Interfaces {
+ if item.Index < 0 || item.Name != interfaceName(item.Index) || item.TAP == "" || item.Queues < 2 || item.Queues%2 != 0 || item.QueueSize <= 0 {
+ return fmt.Errorf("network record interface %d is invalid", item.Index)
+ }
+ switch item.Phase {
+ case interfaceStaged, interfaceAdding:
+ case interfaceReady:
+ if _, err := item.toType(record.Network); err != nil {
+ return err
+ }
+ default:
+ return fmt.Errorf("network record interface phase %q is invalid", item.Phase)
+ }
+ if _, exists := seen[item.Index]; exists {
+ return fmt.Errorf("network record interface index %d is duplicated", item.Index)
+ }
+ seen[item.Index] = struct{}{}
+ }
+ if record.Phase == phaseReady {
+ for _, item := range record.Interfaces {
+ if item.Phase != interfaceReady {
+ return errors.New("ready network record contains an incomplete interface")
+ }
+ }
+ }
+ return nil
+}
+
+func (item interfaceData) toType(networkName string) (types.NetworkInterface, error) {
+ result := types.NetworkInterface{
+ Index: item.Index, Name: item.Name, TAP: item.TAP, MAC: item.MAC,
+ Queues: item.Queues, QueueSize: item.QueueSize, Network: networkName,
+ }
+ if item.IPv4 != nil {
+ result.IPv4 = &types.IPv4Config{Address: item.IPv4.Address, Gateway: item.IPv4.Gateway, Prefix: item.IPv4.Prefix}
+ }
+ return result, result.Validate()
+}
+
+func fromType(value types.NetworkInterface, phase interfacePhase) interfaceData {
+ result := interfaceData{
+ Index: value.Index, Name: value.Name, TAP: value.TAP, Phase: phase,
+ MAC: value.MAC, Queues: value.Queues, QueueSize: value.QueueSize,
+ }
+ if value.IPv4 != nil {
+ result.IPv4 = &ipv4Data{Address: value.IPv4.Address, Gateway: value.IPv4.Gateway, Prefix: value.IPv4.Prefix}
+ }
+ return result
+}
+
+func corrupt(cause error) error {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("decode CNI network record: %w", cause))
+}
+
+func interfaceName(index int) string { return fmt.Sprintf("eth%d", index) }
+
+func findInterface(record *recordData, index int) int {
+ return slices.IndexFunc(record.Interfaces, func(item interfaceData) bool { return item.Index == index })
+}
+
+func removeInterface(record *recordData, index int) {
+ position := findInterface(record, index)
+ if position >= 0 {
+ record.Interfaces = slices.Delete(record.Interfaces, position, position+1)
+ }
+}
+
+// newTestProvider constructs a provider around injected side-effect seams. It
+// stays unexported so production composition always uses New.
+func newTestProvider(options Options, store metadata.Store, lists map[string]*libcni.NetworkConfigList, defaultName string, executor pluginRuntime, host platform) *Provider {
+ return &Provider{options: options, store: store, lists: lists, defaultName: defaultName, runtime: executor, platform: host}
+}
diff --git a/network/cni/cni_test.go b/network/cni/cni_test.go
new file mode 100644
index 0000000..2b28cec
--- /dev/null
+++ b/network/cni/cni_test.go
@@ -0,0 +1,275 @@
+package cni
+
+import (
+ "context"
+ "errors"
+ "net"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/containernetworking/cni/libcni"
+ cnitypes "github.com/containernetworking/cni/pkg/types"
+ current "github.com/containernetworking/cni/pkg/types/100"
+
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/network"
+ "github.com/kumabox/kumabox/types"
+)
+
+type fakeRuntime struct {
+ addError error
+ delError error
+ adds []string
+ dels []string
+}
+
+func (f *fakeRuntime) AddNetworkList(_ context.Context, _ *libcni.NetworkConfigList, runtime *libcni.RuntimeConf) (cnitypes.Result, error) {
+ f.adds = append(f.adds, runtime.IfName)
+ if f.addError != nil {
+ return nil, f.addError
+ }
+ return ¤t.Result{
+ CNIVersion: "1.0.0",
+ IPs: []*current.IPConfig{{
+ Address: net.IPNet{IP: net.ParseIP("10.42.0.7"), Mask: net.CIDRMask(24, 32)},
+ Gateway: net.ParseIP("10.42.0.1"),
+ }},
+ }, nil
+}
+
+func (f *fakeRuntime) DelNetworkList(_ context.Context, _ *libcni.NetworkConfigList, runtime *libcni.RuntimeConf) error {
+ f.dels = append(f.dels, runtime.IfName)
+ return f.delError
+}
+
+type fakePlatform struct {
+ namespace bool
+ removeError error
+ linksUp []bool
+ deletedTAPs []string
+ verifiedTAPs []string
+ ensuredNames []string
+ removedNames []string
+ redirectedNames []string
+}
+
+func (f *fakePlatform) EnsureNamespace(name, _ string) (bool, error) {
+ created := !f.namespace
+ f.namespace = true
+ f.ensuredNames = append(f.ensuredNames, name)
+ return created, nil
+}
+
+func (f *fakePlatform) RemoveNamespace(_ context.Context, name string) error {
+ f.removedNames = append(f.removedNames, name)
+ if f.removeError != nil {
+ return f.removeError
+ }
+ f.namespace = false
+ return nil
+}
+
+func (f *fakePlatform) NamespaceExists(string) error {
+ if !f.namespace {
+ return os.ErrNotExist
+ }
+ return nil
+}
+
+func (f *fakePlatform) SetupRedirect(_, interfaceName, _ string, _ int, overrideMAC string) (string, error) {
+ f.redirectedNames = append(f.redirectedNames, interfaceName)
+ if overrideMAC != "" {
+ return overrideMAC, nil
+ }
+ return "02:00:00:00:00:07", nil
+}
+
+func (f *fakePlatform) DeleteTAP(_, tap string) error {
+ f.deletedTAPs = append(f.deletedTAPs, tap)
+ return nil
+}
+
+func (f *fakePlatform) SetLinkState(_ string, _ []string, up bool) error {
+ f.linksUp = append(f.linksUp, up)
+ return nil
+}
+
+func (f *fakePlatform) VerifyTAP(_, tap string) error {
+ f.verifiedTAPs = append(f.verifiedTAPs, tap)
+ return nil
+}
+
+func TestProviderLifecyclePersistsCleanupIntent(t *testing.T) {
+ provider, executor, host, id := testProvider(t)
+ namespace, err := provider.Prepare(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if namespace != filepath.Join(namedNamespaceDir, "kb-"+id.String()) {
+ t.Fatalf("namespace = %q", namespace)
+ }
+ interfaces, err := provider.Add(t.Context(), id, "bridge", network.AddSpec{Index: 0, Queues: 4})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(interfaces) != 1 || interfaces[0].MAC != "02:00:00:00:00:07" || interfaces[0].IPv4.Address != "10.42.0.7" {
+ t.Fatalf("interfaces = %+v", interfaces)
+ }
+ record, err := provider.view(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record == nil || record.Phase != phaseReady || record.Interfaces[0].Phase != interfaceReady {
+ t.Fatalf("record = %+v", record)
+ }
+ if err := provider.Quiesce(t.Context(), id); err != nil {
+ t.Fatal(err)
+ }
+ if err := provider.Unquiesce(t.Context(), id); err != nil {
+ t.Fatal(err)
+ }
+ if err := provider.Verify(t.Context(), id, interfaces); err != nil {
+ t.Fatal(err)
+ }
+ if err := provider.Delete(t.Context(), id); err != nil {
+ t.Fatal(err)
+ }
+ record, err = provider.view(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record != nil || host.namespace {
+ t.Fatalf("delete retained record=%+v namespace=%t", record, host.namespace)
+ }
+ if !slices.Equal(executor.adds, []string{"eth0"}) || !slices.Equal(executor.dels, []string{"eth0"}) {
+ t.Fatalf("CNI calls add=%v del=%v", executor.adds, executor.dels)
+ }
+ if !slices.Equal(host.linksUp, []bool{false, true}) {
+ t.Fatalf("link states = %v", host.linksUp)
+ }
+}
+
+func TestAddFailureCompensatesWithoutLosingNamespaceOwnership(t *testing.T) {
+ provider, executor, _, id := testProvider(t)
+ executor.addError = errors.New("injected ADD failure")
+ if _, err := provider.Prepare(t.Context(), id); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := provider.Add(t.Context(), id, "bridge", network.AddSpec{Index: 0, Queues: 2}); err == nil {
+ t.Fatal("Add unexpectedly succeeded")
+ }
+ record, err := provider.view(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record == nil || record.Phase != phasePreparing || len(record.Interfaces) != 0 {
+ t.Fatalf("rollback record = %+v", record)
+ }
+ if !slices.Equal(executor.dels, []string{"eth0"}) {
+ t.Fatalf("rollback DEL calls = %v", executor.dels)
+ }
+}
+
+func TestDeleteFailureRetainsOnlyRetryableCleanupState(t *testing.T) {
+ provider, executor, _, id := testProvider(t)
+ if _, err := provider.Prepare(t.Context(), id); err != nil {
+ t.Fatal(err)
+ }
+ interfaces, err := provider.Add(t.Context(), id, "bridge", network.AddSpec{Index: 0, Queues: 2})
+ if err != nil || len(interfaces) != 1 {
+ t.Fatalf("Add = %+v, %v", interfaces, err)
+ }
+ executor.delError = errors.New("injected DEL failure")
+ if err := provider.Delete(t.Context(), id); err == nil {
+ t.Fatal("Delete unexpectedly succeeded")
+ }
+ record, err := provider.view(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record == nil || record.Phase != phaseDeleting || len(record.Interfaces) != 1 {
+ t.Fatalf("failed delete record = %+v", record)
+ }
+ executor.delError = nil
+ if err := provider.Delete(t.Context(), id); err != nil {
+ t.Fatalf("Delete retry: %v", err)
+ }
+ if record, err := provider.view(t.Context(), id); err != nil || record != nil {
+ t.Fatalf("retry retained record=%+v error=%v", record, err)
+ }
+}
+
+func TestLoadConfListsUsesFirstFilenameAndRejectsDuplicateNames(t *testing.T) {
+ directory := t.TempDir()
+ writeConflist(t, directory, "20-second.conflist", "second")
+ writeConflist(t, directory, "10-first.conflist", "first")
+ lists, defaultName, err := loadConfLists(directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if defaultName != "first" || len(lists) != 2 {
+ t.Fatalf("default=%q lists=%v", defaultName, lists)
+ }
+ writeConflist(t, directory, "30-duplicate.conflist", "first")
+ if _, _, err := loadConfLists(directory); err == nil {
+ t.Fatal("duplicate CNI network name was accepted")
+ }
+}
+
+func TestNewWithoutConflistAllowsInspectionButRejectsAdd(t *testing.T) {
+ store, err := metadata.NewMemory(Collections())
+ if err != nil {
+ t.Fatal(err)
+ }
+ options := Options{
+ ConfDir: filepath.Join(t.TempDir(), "missing"), BinDir: "/opt/cni/bin",
+ CacheDir: filepath.Join(t.TempDir(), "cache"), NamespacePrefix: "kb-", CleanupTimeout: time.Second,
+ }
+ provider, err := New(options, store)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := mustID(t)
+ if namespace, err := provider.Prepare(t.Context(), id); err != nil || namespace != "" {
+ t.Fatalf("Prepare = %q, %v", namespace, err)
+ }
+ if _, err := provider.Add(t.Context(), id, "", network.AddSpec{Index: 0, Queues: 2}); !errors.Is(err, network.ErrNotConfigured) {
+ t.Fatalf("Add error = %v", err)
+ }
+}
+
+func testProvider(t *testing.T) (*Provider, *fakeRuntime, *fakePlatform, types.SandboxID) {
+ t.Helper()
+ store, err := metadata.NewMemory(Collections())
+ if err != nil {
+ t.Fatal(err)
+ }
+ executor := &fakeRuntime{}
+ host := &fakePlatform{}
+ list := &libcni.NetworkConfigList{Name: "bridge", CNIVersion: "1.0.0"}
+ options := Options{
+ ConfDir: "/etc/cni/net.d", BinDir: "/opt/cni/bin", CacheDir: filepath.Join(t.TempDir(), "cache"),
+ NamespacePrefix: "kb-", CleanupTimeout: time.Second,
+ }
+ return newTestProvider(options, store, map[string]*libcni.NetworkConfigList{"bridge": list}, "bridge", executor, host), executor, host, mustID(t)
+}
+
+func mustID(t *testing.T) types.SandboxID {
+ t.Helper()
+ id, err := types.ParseSandboxID("123e4567-e89b-42d3-a456-426614174000")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return id
+}
+
+func writeConflist(t *testing.T, directory, name, networkName string) {
+ t.Helper()
+ contents := []byte(`{"cniVersion":"1.0.0","name":"` + networkName + `","plugins":[{"type":"bridge"}]}`)
+ if err := os.WriteFile(filepath.Join(directory, name), contents, 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/network/cni/lifecycle.go b/network/cni/lifecycle.go
new file mode 100644
index 0000000..77dd55d
--- /dev/null
+++ b/network/cni/lifecycle.go
@@ -0,0 +1,494 @@
+package cni
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "slices"
+
+ "github.com/containernetworking/cni/libcni"
+ cnitypes "github.com/containernetworking/cni/pkg/types"
+ current "github.com/containernetworking/cni/pkg/types/100"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/network"
+ "github.com/kumabox/kumabox/types"
+)
+
+// Prepare records namespace ownership before asking the kernel to create it.
+// If no conflist is installed, it returns an empty namespace; Add will report
+// the actionable configuration error when networking is actually requested.
+func (p *Provider) Prepare(ctx context.Context, id types.SandboxID) (string, error) {
+ if err := validID(id); err != nil {
+ return "", err
+ }
+ if _, err := p.confList(""); err != nil {
+ if errors.Is(err, network.ErrNotConfigured) {
+ return "", nil
+ }
+ return "", err
+ }
+ name, path := p.namespace(id)
+ if err := p.update(ctx, id, func(record *recordData) (*recordData, error) {
+ if record == nil {
+ return &recordData{
+ Version: recordVersion, SandboxID: id.String(), NamespaceName: name,
+ NamespacePath: path, Phase: phasePreparing, Interfaces: []interfaceData{},
+ }, nil
+ }
+ if record.Phase == phaseDeleting {
+ return nil, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s network deletion is incomplete", id))
+ }
+ if record.NamespaceName != name || record.NamespacePath != path {
+ return nil, corrupt(errors.New("stored namespace differs from configured namespace"))
+ }
+ return record, nil
+ }); err != nil {
+ return "", fmt.Errorf("record network namespace intent: %w", err)
+ }
+ if _, err := p.platform.EnsureNamespace(name, path); err != nil {
+ return "", fmt.Errorf("ensure network namespace %s: %w", name, err)
+ }
+ return path, nil
+}
+
+// Add stages every NIC before plugin execution, then advances one interface at
+// a time through adding to ready. A crash during ADD therefore leaves enough
+// information for Delete to issue the matching DEL.
+//
+// staged -> adding -> CNI ADD -> TAP/TC -> ready
+// \---- failure ----> CNI DEL -> sweep
+func (p *Provider) Add(ctx context.Context, id types.SandboxID, networkName string, specs ...network.AddSpec) (result []types.NetworkInterface, returnErr error) {
+ if err := validID(id); err != nil {
+ return nil, err
+ }
+ if len(specs) == 0 {
+ return []types.NetworkInterface{}, nil
+ }
+ list, err := p.confList(networkName)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := p.Prepare(ctx, id); err != nil {
+ return nil, err
+ }
+ if err := validateSpecs(specs); err != nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if err := p.stage(ctx, id, list.Name, specs); err != nil {
+ return nil, err
+ }
+
+ touched := make([]int, 0, len(specs))
+ defer func() {
+ if returnErr == nil || len(touched) == 0 {
+ return
+ }
+ rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), p.options.CleanupTimeout)
+ defer cancel()
+ returnErr = errors.Join(returnErr, p.rollback(rollbackCtx, id, list, touched))
+ }()
+
+ result = make([]types.NetworkInterface, 0, len(specs))
+ for _, spec := range specs {
+ item, err := p.interfaceRecord(ctx, id, spec.Index)
+ if err != nil {
+ return nil, err
+ }
+ if item.Phase == interfaceReady {
+ ready, err := item.toType(list.Name)
+ if err != nil {
+ return nil, corrupt(err)
+ }
+ result = append(result, ready)
+ continue
+ }
+ touched = append(touched, spec.Index)
+ if item.Phase == interfaceAdding {
+ if err := p.deleteOne(ctx, id, list, item, true); err != nil {
+ return nil, fmt.Errorf("recover interrupted CNI ADD for %s/%s: %w", id, item.Name, err)
+ }
+ }
+ if err := p.setInterfacePhase(ctx, id, spec.Index, interfaceAdding); err != nil {
+ return nil, err
+ }
+ ready, err := p.addOne(ctx, id, list, item, spec.Existing)
+ if err != nil {
+ return nil, err
+ }
+ if err := p.storeReady(ctx, id, ready); err != nil {
+ return nil, err
+ }
+ result = append(result, ready)
+ }
+ if err := p.update(ctx, id, func(record *recordData) (*recordData, error) {
+ if record == nil {
+ return nil, corrupt(errors.New("network record disappeared while completing ADD"))
+ }
+ record.Phase = phaseReady
+ return record, nil
+ }); err != nil {
+ return nil, fmt.Errorf("commit network readiness: %w", err)
+ }
+ touched = nil
+ slices.SortFunc(result, func(left, right types.NetworkInterface) int { return left.Index - right.Index })
+ return result, nil
+}
+
+func (p *Provider) stage(ctx context.Context, id types.SandboxID, networkName string, specs []network.AddSpec) error {
+ return p.update(ctx, id, func(record *recordData) (*recordData, error) {
+ if record == nil {
+ return nil, corrupt(errors.New("network namespace intent is missing"))
+ }
+ if record.Phase == phaseDeleting {
+ return nil, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s network deletion is incomplete", id))
+ }
+ if record.Network != "" && record.Network != networkName {
+ return nil, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s is already bound to CNI network %q", id, record.Network))
+ }
+ record.Network = networkName
+ for _, spec := range specs {
+ position := findInterface(record, spec.Index)
+ if position >= 0 {
+ continue
+ }
+ tap, err := network.TAPName(defaultTAPPrefix, id, spec.Index)
+ if err != nil {
+ return nil, err
+ }
+ value := types.NetworkInterface{
+ Index: spec.Index, Name: interfaceName(spec.Index), TAP: tap,
+ Queues: spec.Queues, QueueSize: network.DefaultQueueSize, Network: networkName,
+ }
+ if spec.Existing != nil {
+ value.MAC = spec.Existing.MAC
+ value.IPv4 = spec.Existing.IPv4
+ }
+ record.Interfaces = append(record.Interfaces, fromType(value, interfaceStaged))
+ }
+ slices.SortFunc(record.Interfaces, func(left, right interfaceData) int { return left.Index - right.Index })
+ return record, nil
+ })
+}
+
+func (p *Provider) addOne(ctx context.Context, id types.SandboxID, list *libcni.NetworkConfigList, item interfaceData, existing *types.NetworkInterface) (types.NetworkInterface, error) {
+ record, err := p.view(ctx, id)
+ if err != nil || record == nil {
+ return types.NetworkInterface{}, errors.Join(err, errors.New("network record is missing"))
+ }
+ runtimeConfig := &libcni.RuntimeConf{ContainerID: id.String(), NetNS: record.NamespacePath, IfName: item.Name}
+ if existing != nil && existing.IPv4 != nil && existing.IPv4.Address != "" {
+ runtimeConfig.Args = [][2]string{{"IgnoreUnknown", "1"}, {"IP", existing.IPv4.Address}}
+ }
+ cniResult, err := p.runtime.AddNetworkList(ctx, list, runtimeConfig)
+ if err != nil {
+ return types.NetworkInterface{}, fmt.Errorf("CNI ADD %s/%s: %w", id, item.Name, err)
+ }
+ ipv4, err := extractIPv4(cniResult)
+ if err != nil {
+ return types.NetworkInterface{}, fmt.Errorf("parse CNI result for %s/%s: %w", id, item.Name, err)
+ }
+ overrideMAC := item.MAC
+ if existing != nil && existing.MAC != "" {
+ overrideMAC = existing.MAC
+ }
+ mac, err := p.platform.SetupRedirect(record.NamespacePath, item.Name, item.TAP, item.Queues, overrideMAC)
+ if err != nil {
+ return types.NetworkInterface{}, fmt.Errorf("connect TAP for %s/%s: %w", id, item.Name, err)
+ }
+ ready := types.NetworkInterface{
+ Index: item.Index, Name: item.Name, TAP: item.TAP, MAC: mac,
+ Queues: item.Queues, QueueSize: item.QueueSize, Network: list.Name, IPv4: ipv4,
+ }
+ if err := ready.Validate(); err != nil {
+ return types.NetworkInterface{}, err
+ }
+ return ready, nil
+}
+
+func (p *Provider) rollback(ctx context.Context, id types.SandboxID, list *libcni.NetworkConfigList, indices []int) error {
+ var failures []error
+ released := make(map[int]bool, len(indices))
+ for _, index := range indices {
+ item, err := p.interfaceRecord(ctx, id, index)
+ if err != nil {
+ failures = append(failures, err)
+ continue
+ }
+ if err := p.deleteOne(ctx, id, list, item, true); err != nil {
+ failures = append(failures, fmt.Errorf("rollback %s: %w", item.Name, err))
+ continue
+ }
+ released[index] = true
+ }
+ if len(released) > 0 {
+ if err := p.update(ctx, id, func(record *recordData) (*recordData, error) {
+ if record == nil {
+ return nil, nil
+ }
+ for index := range released {
+ removeInterface(record, index)
+ }
+ return record, nil
+ }); err != nil {
+ failures = append(failures, fmt.Errorf("release rollback records: %w", err))
+ }
+ }
+ return errors.Join(failures...)
+}
+
+func (p *Provider) deleteOne(ctx context.Context, id types.SandboxID, list *libcni.NetworkConfigList, item interfaceData, deleteTAP bool) error {
+ record, err := p.view(ctx, id)
+ if err != nil || record == nil {
+ return errors.Join(err, errors.New("network record is missing"))
+ }
+ if item.Phase != interfaceStaged {
+ runtimeConfig := &libcni.RuntimeConf{ContainerID: id.String(), NetNS: record.NamespacePath, IfName: item.Name}
+ if err := p.runtime.DelNetworkList(ctx, list, runtimeConfig); err != nil {
+ return fmt.Errorf("CNI DEL %s/%s: %w", id, item.Name, err)
+ }
+ }
+ if deleteTAP {
+ if err := p.platform.DeleteTAP(record.NamespacePath, item.TAP); err != nil {
+ return fmt.Errorf("delete TAP %s: %w", item.TAP, err)
+ }
+ }
+ return nil
+}
+
+func (p *Provider) setInterfacePhase(ctx context.Context, id types.SandboxID, index int, phase interfacePhase) error {
+ return p.update(ctx, id, func(record *recordData) (*recordData, error) {
+ if record == nil {
+ return nil, corrupt(errors.New("network record is missing"))
+ }
+ position := findInterface(record, index)
+ if position < 0 {
+ return nil, corrupt(fmt.Errorf("network interface %d is missing", index))
+ }
+ record.Interfaces[position].Phase = phase
+ return record, nil
+ })
+}
+
+func (p *Provider) storeReady(ctx context.Context, id types.SandboxID, ready types.NetworkInterface) error {
+ return p.update(ctx, id, func(record *recordData) (*recordData, error) {
+ if record == nil {
+ return nil, corrupt(errors.New("network record is missing"))
+ }
+ position := findInterface(record, ready.Index)
+ if position < 0 {
+ return nil, corrupt(fmt.Errorf("network interface %d is missing", ready.Index))
+ }
+ record.Interfaces[position] = fromType(ready, interfaceReady)
+ return record, nil
+ })
+}
+
+func (p *Provider) interfaceRecord(ctx context.Context, id types.SandboxID, index int) (interfaceData, error) {
+ record, err := p.view(ctx, id)
+ if err != nil {
+ return interfaceData{}, err
+ }
+ if record == nil {
+ return interfaceData{}, corrupt(errors.New("network record is missing"))
+ }
+ position := findInterface(record, index)
+ if position < 0 {
+ return interfaceData{}, corrupt(fmt.Errorf("network interface %d is missing", index))
+ }
+ return record.Interfaces[position], nil
+}
+
+// Verify checks both the namespace and every expected TAP. Metadata alone is
+// never accepted as proof that host plumbing survived a reboot.
+func (p *Provider) Verify(_ context.Context, id types.SandboxID, expected []types.NetworkInterface) error {
+ if err := validID(id); err != nil {
+ return err
+ }
+ _, path := p.namespace(id)
+ if err := p.platform.NamespaceExists(path); err != nil {
+ return fmt.Errorf("network namespace %s: %w", path, err)
+ }
+ for _, item := range expected {
+ if err := item.Validate(); err != nil {
+ return err
+ }
+ if err := p.platform.VerifyTAP(path, item.TAP); err != nil {
+ return fmt.Errorf("verify TAP %s: %w", item.TAP, err)
+ }
+ }
+ return nil
+}
+
+// Recover rebuilds missing host plumbing from the durable guest identities.
+func (p *Provider) Recover(ctx context.Context, id types.SandboxID, networkName string, expected []types.NetworkInterface) ([]types.NetworkInterface, error) {
+ if err := p.Verify(ctx, id, expected); err == nil {
+ if err := p.Unquiesce(ctx, id); err != nil {
+ return nil, err
+ }
+ return slices.Clone(expected), nil
+ }
+ if err := p.Delete(ctx, id); err != nil {
+ return nil, fmt.Errorf("delete incomplete network before recovery: %w", err)
+ }
+ if _, err := p.Prepare(ctx, id); err != nil {
+ return nil, err
+ }
+ specs := make([]network.AddSpec, len(expected))
+ for index := range expected {
+ current := expected[index]
+ specs[index] = network.AddSpec{Index: current.Index, Queues: current.Queues, Existing: ¤t}
+ if networkName == "" {
+ networkName = current.Network
+ }
+ }
+ return p.Add(ctx, id, networkName, specs...)
+}
+
+// Quiesce brings CNI-side veth devices down while retaining identity and TAPs.
+func (p *Provider) Quiesce(ctx context.Context, id types.SandboxID) error {
+ return p.setLinkState(ctx, id, false)
+}
+
+// Unquiesce brings retained CNI-side veth devices back up before launch.
+func (p *Provider) Unquiesce(ctx context.Context, id types.SandboxID) error {
+ return p.setLinkState(ctx, id, true)
+}
+
+func (p *Provider) setLinkState(ctx context.Context, id types.SandboxID, up bool) error {
+ record, err := p.view(ctx, id)
+ if err != nil || record == nil {
+ return err
+ }
+ names := make([]string, 0, len(record.Interfaces))
+ for _, item := range record.Interfaces {
+ if item.Phase == interfaceReady {
+ names = append(names, item.Name)
+ }
+ }
+ if len(names) == 0 {
+ return nil
+ }
+ if err := p.platform.SetLinkState(record.NamespacePath, names, up); err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("set sandbox %s network links up=%t: %w", id, up, err)
+ }
+ return nil
+}
+
+// Delete advances the aggregate to deleting before slow host operations. Each
+// successful DEL is swept independently; failures keep exactly the remaining
+// release context for the next retry.
+//
+// ready -> deleting -> per-NIC DEL -> remove netns -> delete record
+// \ failure: retain only unfinished NICs /
+func (p *Provider) Delete(ctx context.Context, id types.SandboxID) error {
+ if err := validID(id); err != nil {
+ return err
+ }
+ if err := p.update(ctx, id, func(record *recordData) (*recordData, error) {
+ if record == nil {
+ return nil, nil
+ }
+ record.Phase = phaseDeleting
+ return record, nil
+ }); err != nil {
+ return fmt.Errorf("mark network deleting: %w", err)
+ }
+ record, err := p.view(ctx, id)
+ if err != nil || record == nil {
+ return err
+ }
+ released := make(map[int]bool, len(record.Interfaces))
+ var failures []error
+ for _, item := range record.Interfaces {
+ list, listErr := p.confList(record.Network)
+ if item.Phase == interfaceStaged {
+ listErr = nil
+ }
+ if listErr != nil {
+ failures = append(failures, listErr)
+ continue
+ }
+ if err := p.deleteOne(ctx, id, list, item, false); err != nil {
+ failures = append(failures, err)
+ continue
+ }
+ released[item.Index] = true
+ }
+ if len(released) > 0 {
+ if err := p.update(ctx, id, func(current *recordData) (*recordData, error) {
+ if current == nil {
+ return nil, nil
+ }
+ for index := range released {
+ removeInterface(current, index)
+ }
+ return current, nil
+ }); err != nil {
+ failures = append(failures, fmt.Errorf("sweep released network records: %w", err))
+ }
+ }
+ if len(failures) > 0 {
+ return errors.Join(failures...)
+ }
+ if err := p.platform.RemoveNamespace(ctx, record.NamespaceName); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("remove network namespace %s: %w", record.NamespaceName, err)
+ }
+ if err := p.update(ctx, id, func(*recordData) (*recordData, error) { return nil, nil }); err != nil {
+ return fmt.Errorf("finalize network deletion: %w", err)
+ }
+ return nil
+}
+
+func validateSpecs(specs []network.AddSpec) error {
+ seen := make(map[int]struct{}, len(specs))
+ for _, spec := range specs {
+ if spec.Index < 0 || spec.Queues < 2 || spec.Queues%2 != 0 {
+ return fmt.Errorf("NIC %d requires an even queue count of at least two", spec.Index)
+ }
+ if _, exists := seen[spec.Index]; exists {
+ return fmt.Errorf("NIC index %d is duplicated", spec.Index)
+ }
+ seen[spec.Index] = struct{}{}
+ if spec.Existing != nil {
+ if spec.Existing.Index != spec.Index {
+ return fmt.Errorf("NIC %d recovery identity belongs to index %d", spec.Index, spec.Existing.Index)
+ }
+ if err := spec.Existing.Validate(); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func validID(id types.SandboxID) error {
+ _, err := types.ParseSandboxID(id.String())
+ if err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ return nil
+}
+
+func extractIPv4(result cnitypes.Result) (*types.IPv4Config, error) {
+ converted, err := current.NewResultFromResult(result)
+ if err != nil {
+ return nil, err
+ }
+ for _, configuration := range converted.IPs {
+ if configuration == nil || configuration.Address.IP.To4() == nil {
+ continue
+ }
+ prefix, _ := configuration.Address.Mask.Size()
+ result := &types.IPv4Config{Address: configuration.Address.IP.String(), Prefix: prefix}
+ if configuration.Gateway != nil {
+ result.Gateway = configuration.Gateway.String()
+ }
+ return result, result.Validate()
+ }
+ return nil, nil
+}
diff --git a/network/cni/platform_linux.go b/network/cni/platform_linux.go
new file mode 100644
index 0000000..1460dc6
--- /dev/null
+++ b/network/cni/platform_linux.go
@@ -0,0 +1,243 @@
+//go:build linux
+
+package cni
+
+import (
+ "cmp"
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "net"
+ "os"
+ "runtime"
+ "syscall"
+ "time"
+
+ cns "github.com/containernetworking/plugins/pkg/ns"
+ "github.com/vishvananda/netlink"
+ "github.com/vishvananda/netns"
+)
+
+const (
+ tapTXQueueLength = 10000
+ tapGROMaxSize = 65536
+)
+
+type linuxPlatform struct{}
+
+func newPlatform() platform { return linuxPlatform{} }
+
+func (linuxPlatform) EnsureNamespace(name, path string) (_ bool, returnErr error) {
+ if _, err := os.Stat(path); err == nil {
+ return false, nil
+ } else if !errors.Is(err, fs.ErrNotExist) {
+ return false, err
+ }
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ original, err := netns.Get()
+ if err != nil {
+ return false, fmt.Errorf("get current network namespace: %w", err)
+ }
+ defer func() {
+ returnErr = errors.Join(returnErr, netns.Set(original), original.Close())
+ }()
+ created, err := netns.NewNamed(name)
+ if err != nil {
+ return false, fmt.Errorf("create named network namespace %s: %w", name, err)
+ }
+ if err := created.Close(); err != nil {
+ return false, fmt.Errorf("close network namespace %s: %w", name, err)
+ }
+ return true, nil
+}
+
+func (linuxPlatform) RemoveNamespace(ctx context.Context, name string) error {
+ deadline := time.NewTimer(time.Second)
+ defer deadline.Stop()
+ ticker := time.NewTicker(100 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ err := netns.DeleteNamed(name)
+ if err == nil || errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-deadline.C:
+ return err
+ case <-ticker.C:
+ }
+ }
+}
+
+func (linuxPlatform) NamespaceExists(path string) error {
+ _, err := os.Stat(path)
+ return err
+}
+
+func (linuxPlatform) VerifyTAP(namespacePath, tapName string) error {
+ return cns.WithNetNSPath(namespacePath, func(_ cns.NetNS) error {
+ _, err := netlink.LinkByName(tapName)
+ return err
+ })
+}
+
+func (linuxPlatform) SetupRedirect(namespacePath, interfaceName, tapName string, queues int, overrideMAC string) (string, error) {
+ var mac string
+ err := cns.WithNetNSPath(namespacePath, func(_ cns.NetNS) error {
+ var err error
+ mac, err = setupRedirect(interfaceName, tapName, queues, overrideMAC)
+ return err
+ })
+ return mac, err
+}
+
+func setupRedirect(interfaceName, tapName string, queues int, overrideMAC string) (string, error) {
+ source, err := netlink.LinkByName(interfaceName)
+ if err != nil {
+ return "", fmt.Errorf("find CNI link %s: %w", interfaceName, err)
+ }
+ if overrideMAC != "" {
+ hardwareAddress, err := net.ParseMAC(overrideMAC)
+ if err != nil {
+ return "", fmt.Errorf("parse MAC %s: %w", overrideMAC, err)
+ }
+ if err := netlink.LinkSetHardwareAddr(source, hardwareAddress); err != nil {
+ return "", fmt.Errorf("set MAC on %s: %w", interfaceName, err)
+ }
+ }
+ mac := cmp.Or(overrideMAC, source.Attrs().HardwareAddr.String())
+ addresses, err := netlink.AddrList(source, netlink.FAMILY_ALL)
+ if err != nil {
+ return "", fmt.Errorf("list addresses on %s: %w", interfaceName, err)
+ }
+ for _, address := range addresses {
+ if err := netlink.AddrDel(source, &address); err != nil {
+ return "", fmt.Errorf("remove address %s from %s: %w", address.IPNet, interfaceName, err)
+ }
+ }
+ tap, err := createTAP(tapName, queues)
+ if err != nil {
+ return "", err
+ }
+ if source.Attrs().MTU > 0 {
+ if err := netlink.LinkSetMTU(tap, source.Attrs().MTU); err != nil {
+ return "", fmt.Errorf("set TAP %s MTU: %w", tapName, err)
+ }
+ }
+ for _, link := range []netlink.Link{source, tap} {
+ if err := netlink.LinkSetUp(link); err != nil {
+ return "", fmt.Errorf("set link %s up: %w", link.Attrs().Name, err)
+ }
+ qdisc := &netlink.Ingress{QdiscAttrs: netlink.QdiscAttrs{LinkIndex: link.Attrs().Index, Parent: netlink.HANDLE_INGRESS}}
+ if err := netlink.QdiscAdd(qdisc); err != nil {
+ return "", fmt.Errorf("add ingress qdisc to %s: %w", link.Attrs().Name, err)
+ }
+ }
+ if err := redirect(source, tap); err != nil {
+ return "", fmt.Errorf("redirect %s to %s: %w", interfaceName, tapName, err)
+ }
+ if err := redirect(tap, source); err != nil {
+ return "", fmt.Errorf("redirect %s to %s: %w", tapName, interfaceName, err)
+ }
+ return mac, nil
+}
+
+func createTAP(name string, queues int) (netlink.Link, error) {
+ queuePairs := max(1, queues/2)
+ flags := netlink.TUNTAP_VNET_HDR | netlink.TUNTAP_NO_PI
+ if queuePairs == 1 {
+ flags |= netlink.TUNTAP_ONE_QUEUE
+ } else {
+ flags |= netlink.TUNTAP_MULTI_QUEUE_DEFAULTS
+ }
+ tap := &netlink.Tuntap{
+ LinkAttrs: netlink.LinkAttrs{Name: name},
+ Mode: netlink.TUNTAP_MODE_TAP,
+ Queues: queuePairs,
+ Flags: flags,
+ }
+ if err := netlink.LinkAdd(tap); err != nil {
+ return nil, fmt.Errorf("create TAP %s: %w", name, err)
+ }
+ for _, descriptor := range tap.Fds {
+ _ = descriptor.Close()
+ }
+ link, err := netlink.LinkByName(name)
+ if err != nil {
+ return nil, fmt.Errorf("resolve TAP %s: %w", name, err)
+ }
+ // Queue and GRO tuning improve throughput but are not supported by every
+ // kernel. The functional network path must remain available in that case.
+ _ = netlink.LinkSetTxQLen(link, tapTXQueueLength)
+ _ = netlink.LinkSetGROMaxSize(link, tapGROMaxSize)
+ return link, nil
+}
+
+func redirect(source, target netlink.Link) error {
+ return netlink.FilterAdd(&netlink.U32{
+ FilterAttrs: netlink.FilterAttrs{
+ LinkIndex: source.Attrs().Index, Parent: netlink.HANDLE_INGRESS,
+ Priority: 1, Protocol: syscall.ETH_P_ALL,
+ },
+ Sel: &netlink.TcU32Sel{
+ Flags: netlink.TC_U32_TERMINAL,
+ Keys: []netlink.TcU32Key{{Mask: 0, Val: 0, Off: 0, OffMask: 0}},
+ },
+ Actions: []netlink.Action{&netlink.MirredAction{
+ ActionAttrs: netlink.ActionAttrs{Action: netlink.TC_ACT_STOLEN},
+ MirredAction: netlink.TCA_EGRESS_REDIR, Ifindex: target.Attrs().Index,
+ }},
+ })
+}
+
+func (linuxPlatform) DeleteTAP(namespacePath, tapName string) error {
+ err := cns.WithNetNSPath(namespacePath, func(_ cns.NetNS) error {
+ link, err := netlink.LinkByName(tapName)
+ if err != nil {
+ var notFound netlink.LinkNotFoundError
+ if errors.As(err, ¬Found) {
+ return nil
+ }
+ return err
+ }
+ return netlink.LinkDel(link)
+ })
+ var namespaceMissing cns.NSPathNotExistErr
+ if errors.As(err, &namespaceMissing) {
+ return nil
+ }
+ return err
+}
+
+func (linuxPlatform) SetLinkState(namespacePath string, names []string, up bool) error {
+ err := cns.WithNetNSPath(namespacePath, func(_ cns.NetNS) error {
+ for _, name := range names {
+ link, err := netlink.LinkByName(name)
+ if err != nil {
+ var notFound netlink.LinkNotFoundError
+ if errors.As(err, ¬Found) {
+ continue
+ }
+ return err
+ }
+ if up {
+ err = netlink.LinkSetUp(link)
+ } else {
+ err = netlink.LinkSetDown(link)
+ }
+ if err != nil {
+ return fmt.Errorf("set link %s state: %w", name, err)
+ }
+ }
+ return nil
+ })
+ var namespaceMissing cns.NSPathNotExistErr
+ if errors.As(err, &namespaceMissing) {
+ return nil
+ }
+ return err
+}
diff --git a/network/cni/platform_other.go b/network/cni/platform_other.go
new file mode 100644
index 0000000..512ada4
--- /dev/null
+++ b/network/cni/platform_other.go
@@ -0,0 +1,36 @@
+//go:build !linux
+
+package cni
+
+import (
+ "context"
+ "errors"
+)
+
+var errPlatformUnsupported = errors.New("CNI network namespace operations require Linux")
+
+type unsupportedPlatform struct{}
+
+func newPlatform() platform { return unsupportedPlatform{} }
+
+func (unsupportedPlatform) EnsureNamespace(string, string) (bool, error) {
+ return false, errPlatformUnsupported
+}
+
+func (unsupportedPlatform) RemoveNamespace(context.Context, string) error {
+ return errPlatformUnsupported
+}
+
+func (unsupportedPlatform) NamespaceExists(string) error { return errPlatformUnsupported }
+
+func (unsupportedPlatform) SetupRedirect(string, string, string, int, string) (string, error) {
+ return "", errPlatformUnsupported
+}
+
+func (unsupportedPlatform) DeleteTAP(string, string) error { return errPlatformUnsupported }
+
+func (unsupportedPlatform) SetLinkState(string, []string, bool) error {
+ return errPlatformUnsupported
+}
+
+func (unsupportedPlatform) VerifyTAP(string, string) error { return errPlatformUnsupported }
diff --git a/network/network.go b/network/network.go
new file mode 100644
index 0000000..ae831fc
--- /dev/null
+++ b/network/network.go
@@ -0,0 +1,103 @@
+// Package network defines the host network provider contract used by the
+// sandbox service. Concrete CNI and bridge implementations live in child
+// packages and provider-private cleanup state never crosses this boundary.
+package network
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+const (
+ // DefaultQueueSize is the descriptor count used by supported VMM backends.
+ DefaultQueueSize = 512
+ // linuxInterfaceNameLimit excludes the terminating NUL byte.
+ linuxInterfaceNameLimit = 15
+)
+
+// ErrNotConfigured reports that no usable infrastructure network definition
+// is installed on the host.
+var ErrNotConfigured = errors.New("network provider is not configured")
+
+// AddSpec describes one NIC allocation. Existing is set during host recovery
+// so the provider can preserve the durable MAC and IP identity.
+type AddSpec struct {
+ // Index is the stable zero-based NIC position.
+ Index int
+ // Queues overrides the CPU-derived queue count when positive.
+ Queues int
+ // Existing carries the identity that recovery must preserve.
+ Existing *types.NetworkInterface
+}
+
+// Provider owns host network namespaces, CNI allocations, and TAP plumbing for
+// a sandbox. Callers serialize operations for one sandbox identifier.
+type Provider interface {
+ // Type returns the durable backend identity.
+ Type() types.NetworkBackend
+ // Prepare creates or recovers the sandbox network namespace.
+ Prepare(context.Context, types.SandboxID) (string, error)
+ // Add allocates and wires the requested interfaces.
+ Add(context.Context, types.SandboxID, string, ...AddSpec) ([]types.NetworkInterface, error)
+ // Verify proves that the namespace and expected TAP devices are present.
+ Verify(context.Context, types.SandboxID, []types.NetworkInterface) error
+ // Recover reconstructs missing host state while preserving guest identity.
+ Recover(context.Context, types.SandboxID, string, []types.NetworkInterface) ([]types.NetworkInterface, error)
+ // Quiesce disables CNI-side links while a VMM is stopped.
+ Quiesce(context.Context, types.SandboxID) error
+ // Unquiesce restores links immediately before a VMM launch.
+ Unquiesce(context.Context, types.SandboxID) error
+ // Delete releases every allocation and the private namespace. It is
+ // retryable after partial failure.
+ Delete(context.Context, types.SandboxID) error
+}
+
+// AddRange builds fresh NIC requests for a contiguous index range.
+func AddRange(first, count int) []AddSpec {
+ if first < 0 || count <= 0 {
+ return nil
+ }
+ result := make([]AddSpec, count)
+ for offset := range result {
+ result[offset] = AddSpec{Index: first + offset}
+ }
+ return result
+}
+
+// QueueCount returns two virtio queues per vCPU with a minimum RX/TX pair.
+func QueueCount(cpus uint32) int { return max(2, int(cpus)*2) }
+
+// ResolveQueues returns an explicit valid queue count or the CPU-derived
+// default. Invalid explicit values are rejected by the provider.
+func ResolveQueues(requested int, cpus uint32) int {
+ if requested > 0 {
+ return requested
+ }
+ return QueueCount(cpus)
+}
+
+// TAPName derives a deterministic Linux interface name within IFNAMSIZ. The
+// UUID prefix plus NIC index remains unique within a sandbox namespace.
+func TAPName(prefix string, id types.SandboxID, index int) (string, error) {
+ if prefix == "" || index < 0 || strings.ContainsAny(prefix, "/\x00") {
+ return "", errors.New("TAP prefix and NIC index are invalid")
+ }
+ for _, character := range prefix {
+ if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') &&
+ (character < '0' || character > '9') && character != '-' && character != '_' {
+ return "", errors.New("TAP prefix and NIC index are invalid")
+ }
+ }
+ suffix := "-" + strconv.Itoa(index)
+ compact := strings.ReplaceAll(id.String(), "-", "")
+ const identityLength = 8
+ if len(prefix)+identityLength+len(suffix) > linuxInterfaceNameLimit || len(compact) < identityLength {
+ return "", fmt.Errorf("TAP prefix %q and NIC index %d exceed Linux name limits", prefix, index)
+ }
+ return prefix + compact[:identityLength] + suffix, nil
+}
diff --git a/network/network_test.go b/network/network_test.go
new file mode 100644
index 0000000..a3f1b21
--- /dev/null
+++ b/network/network_test.go
@@ -0,0 +1,37 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestQueueCountAndTAPName(t *testing.T) {
+ if got := QueueCount(0); got != 2 {
+ t.Fatalf("QueueCount(0) = %d, want 2", got)
+ }
+ if got := QueueCount(4); got != 8 {
+ t.Fatalf("QueueCount(4) = %d, want 8", got)
+ }
+ id, err := types.ParseSandboxID("123e4567-e89b-42d3-a456-426614174000")
+ if err != nil {
+ t.Fatal(err)
+ }
+ name, err := TAPName("tap", id, 12)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if name != "tap123e4567-12" || len(name) > linuxInterfaceNameLimit {
+ t.Fatalf("TAPName = %q", name)
+ }
+}
+
+func TestAddRangeRejectsInvalidBounds(t *testing.T) {
+ if got := AddRange(-1, 1); got != nil {
+ t.Fatalf("AddRange(-1, 1) = %#v", got)
+ }
+ got := AddRange(2, 2)
+ if len(got) != 2 || got[0].Index != 2 || got[1].Index != 3 {
+ t.Fatalf("AddRange(2, 2) = %#v", got)
+ }
+}
diff --git a/network/registry.go b/network/registry.go
new file mode 100644
index 0000000..feb3384
--- /dev/null
+++ b/network/registry.go
@@ -0,0 +1,69 @@
+package network
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+// Registry routes durable network backend identities to provider adapters.
+// Construction freezes the available set so lifecycle operations never rely
+// on package initialization or registration order.
+type Registry struct {
+ providers map[types.NetworkBackend]Provider
+}
+
+// NewRegistry validates and freezes the supplied provider set.
+func NewRegistry(providers ...Provider) (*Registry, error) {
+ registered := make(map[types.NetworkBackend]Provider, len(providers))
+ for _, provider := range providers {
+ if provider == nil || isNilProvider(provider) {
+ return nil, errors.New("network registry contains a nil provider")
+ }
+ backend := provider.Type()
+ if err := backend.Validate(); err != nil {
+ return nil, fmt.Errorf("register network provider: %w", err)
+ }
+ if _, exists := registered[backend]; exists {
+ return nil, fmt.Errorf("network provider %q is registered more than once", backend)
+ }
+ registered[backend] = provider
+ }
+ return &Registry{providers: registered}, nil
+}
+
+func isNilProvider(provider Provider) bool {
+ value := reflect.ValueOf(provider)
+ switch value.Kind() {
+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
+ return value.IsNil()
+ default:
+ return false
+ }
+}
+
+// Provider returns the adapter for a persisted backend identity.
+func (r *Registry) Provider(backend types.NetworkBackend) (Provider, error) {
+ if err := backend.Validate(); err != nil {
+ return nil, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, err)
+ }
+ if r == nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("network registry is not configured"))
+ }
+ provider, exists := r.providers[backend]
+ if !exists || provider == nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, fmt.Errorf("network backend %q is not available", backend))
+ }
+ return provider, nil
+}
+
+// Len returns the number of providers frozen into the registry.
+func (r *Registry) Len() int {
+ if r == nil {
+ return 0
+ }
+ return len(r.providers)
+}
diff --git a/network/registry_test.go b/network/registry_test.go
new file mode 100644
index 0000000..64127a1
--- /dev/null
+++ b/network/registry_test.go
@@ -0,0 +1,66 @@
+package network
+
+import (
+ "context"
+ "testing"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+type registryProvider struct{ backend types.NetworkBackend }
+
+func (p *registryProvider) Type() types.NetworkBackend { return p.backend }
+func (*registryProvider) Prepare(context.Context, types.SandboxID) (string, error) {
+ return "", nil
+}
+
+func (*registryProvider) Add(context.Context, types.SandboxID, string, ...AddSpec) ([]types.NetworkInterface, error) {
+ return nil, nil
+}
+
+func (*registryProvider) Verify(context.Context, types.SandboxID, []types.NetworkInterface) error {
+ return nil
+}
+
+func (*registryProvider) Recover(context.Context, types.SandboxID, string, []types.NetworkInterface) ([]types.NetworkInterface, error) {
+ return nil, nil
+}
+func (*registryProvider) Quiesce(context.Context, types.SandboxID) error { return nil }
+func (*registryProvider) Unquiesce(context.Context, types.SandboxID) error { return nil }
+func (*registryProvider) Delete(context.Context, types.SandboxID) error { return nil }
+
+func TestRegistryRoutesPersistedBackend(t *testing.T) {
+ provider := ®istryProvider{backend: types.NetworkBackendCNI}
+ registry, err := NewRegistry(provider)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resolved, err := registry.Provider(types.NetworkBackendCNI)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resolved != provider || registry.Len() != 1 {
+ t.Fatalf("resolved provider = %T, len = %d", resolved, registry.Len())
+ }
+}
+
+func TestRegistryRejectsInvalidSetsAndUnavailableBackends(t *testing.T) {
+ var typedNil *registryProvider
+ if _, err := NewRegistry(typedNil); err == nil {
+ t.Fatal("NewRegistry accepted a typed nil provider")
+ }
+ provider := ®istryProvider{backend: types.NetworkBackendCNI}
+ if _, err := NewRegistry(provider, provider); err == nil {
+ t.Fatal("NewRegistry accepted a duplicate provider")
+ }
+ registry, err := NewRegistry()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := registry.Provider(types.NetworkBackendCNI); err == nil {
+ t.Fatal("Provider resolved an unavailable backend")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeHostIncompatible {
+ t.Fatalf("Provider error code = %q, %v; want %q", code, err, errdefs.CodeHostIncompatible)
+ }
+}
diff --git a/oci-images/ubuntu/24.04/Dockerfile b/oci-images/ubuntu/24.04/Dockerfile
deleted file mode 100644
index f8fb11f..0000000
--- a/oci-images/ubuntu/24.04/Dockerfile
+++ /dev/null
@@ -1,67 +0,0 @@
-FROM ubuntu:24.04
-
-ARG TARGETARCH=amd64
-ARG APT_MIRROR=""
-ARG APT_SECURITY_MIRROR=""
-ARG KUMABOX_VERSION=dev
-ENV DEBIAN_FRONTEND=noninteractive
-
-LABEL org.opencontainers.image.title="KumaBox Ubuntu guest"
-LABEL org.opencontainers.image.description="Ubuntu guest prepared for KumaBox direct boot and guest exec"
-LABEL org.opencontainers.image.source="https://github.com/kgpp34/KumaBox"
-LABEL org.opencontainers.image.version="${KUMABOX_VERSION}"
-
-COPY overlay.sh /usr/local/share/kumabox/initramfs/kumabox-overlay
-COPY network.sh /usr/local/share/kumabox/initramfs/kumabox-network
-COPY kumabox-agent-linux-amd64 /usr/local/share/kumabox/kumabox-agent-linux-amd64
-COPY kumabox-agent-linux-arm64 /usr/local/share/kumabox/kumabox-agent-linux-arm64
-COPY kumabox-agent.service /etc/systemd/system/kumabox-agent.service
-COPY kumabox-agent.openrc /usr/local/share/kumabox/kumabox-agent.openrc
-
-RUN if [ -n "$APT_MIRROR" ]; then \
- sed -i "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g; s|http://ports.ubuntu.com/ubuntu-ports|$APT_MIRROR|g" /etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
- fi \
- && if [ -n "$APT_SECURITY_MIRROR" ]; then \
- sed -i "s|http://security.ubuntu.com/ubuntu|$APT_SECURITY_MIRROR|g; s|http://ports.ubuntu.com/ubuntu-ports|$APT_SECURITY_MIRROR|g" /etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
- fi \
- && apt-get update \
- && apt-get install -y --no-install-recommends \
- ca-certificates \
- curl \
- initramfs-tools \
- iproute2 \
- iputils-ping \
- kmod \
- linux-image-virtual \
- openssh-server \
- systemd \
- systemd-resolved \
- systemd-sysv \
- systemd-timesyncd \
- udev \
- util-linux \
- && install -m 0755 /usr/local/share/kumabox/initramfs/kumabox-overlay /etc/initramfs-tools/scripts/kumabox-overlay \
- && install -m 0755 /usr/local/share/kumabox/initramfs/kumabox-network /etc/initramfs-tools/scripts/init-bottom/kumabox-network \
- && case "$TARGETARCH" in \
- amd64) install -m 0755 /usr/local/share/kumabox/kumabox-agent-linux-amd64 /usr/local/bin/kumabox-agent ;; \
- arm64) install -m 0755 /usr/local/share/kumabox/kumabox-agent-linux-arm64 /usr/local/bin/kumabox-agent ;; \
- *) echo "unsupported target architecture: $TARGETARCH" >&2; exit 1 ;; \
- esac \
- && rm -f /usr/local/share/kumabox/kumabox-agent-linux-* \
- && chmod 0755 /usr/local/share/kumabox/kumabox-agent.openrc \
- && printf "erofs\noverlay\next4\nvirtio_blk\nvirtio_pci\nvirtio_ring\nvirtio_net\nvsock\nvmw_vsock_virtio_transport\n" >> /etc/initramfs-tools/modules \
- && sed -i 's/^COMPRESS=.*/COMPRESS=gzip/' /etc/initramfs-tools/initramfs.conf \
- && sed -i '/^IP=/d' /etc/initramfs-tools/initramfs.conf \
- && echo 'IP=off' >> /etc/initramfs-tools/initramfs.conf \
- && update-initramfs -u -k all \
- && truncate -s 0 /etc/fstab \
- && systemctl mask systemd-fsck-root.service systemd-remount-fs.service systemd-fsck@.service \
- && systemctl enable systemd-networkd systemd-resolved systemd-timesyncd \
- && mkdir -p /etc/systemd/network /run/sshd \
- && printf "[Match]\nName=e* v*\n\n[Network]\nDHCP=yes\n\n[DHCPv4]\nClientIdentifier=mac\n" > /etc/systemd/network/20-wired.network \
- && sed -i 's/^#*PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config \
- && systemctl enable ssh kumabox-agent \
- && passwd -l root \
- && rm -rf /var/lib/apt/lists/*
-
-CMD ["/sbin/init"]
diff --git a/oci-images/ubuntu/Dockerfile b/oci-images/ubuntu/Dockerfile
new file mode 100644
index 0000000..882ca79
--- /dev/null
+++ b/oci-images/ubuntu/Dockerfile
@@ -0,0 +1,90 @@
+# syntax=docker/dockerfile:1.7
+
+ARG UBUNTU_IMAGE=ubuntu:24.04
+FROM --platform=$BUILDPLATFORM golang:1.24.4-bookworm AS agent-builder
+
+ARG TARGETARCH
+ARG GOPROXY=https://proxy.golang.org,direct
+WORKDIR /src
+COPY go.mod go.sum ./
+COPY agent ./agent
+COPY errdefs ./errdefs
+COPY types ./types
+COPY version ./version
+COPY cmd/kumabox-agent ./cmd/kumabox-agent
+RUN --mount=type=cache,target=/go/pkg/mod \
+ --mount=type=cache,target=/root/.cache/go-build \
+ CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" GOPROXY="$GOPROXY" \
+ go build -mod=readonly -trimpath -ldflags='-s -w' -o /out/kumabox-agent ./cmd/kumabox-agent
+
+# Pin UBUNTU_IMAGE to a digest in release automation. Keeping it configurable
+# also makes local architecture-specific acceptance builds straightforward.
+FROM ${UBUNTU_IMAGE}
+
+LABEL io.kumabox.boot.profile="overlay-v1"
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+COPY --from=agent-builder /out/kumabox-agent /usr/local/bin/kumabox-agent
+COPY oci-images/ubuntu/overlay.sh /usr/local/lib/kumabox/initramfs/kumabox-overlay
+COPY oci-images/ubuntu/network.sh /usr/local/lib/kumabox/initramfs/kumabox-network
+COPY oci-images/ubuntu/kumabox-agent.service /etc/systemd/system/kumabox-agent.service
+
+RUN set -eu; \
+ apt-get -o Acquire::Retries=5 update \
+ && apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
+ ca-certificates \
+ initramfs-tools \
+ iproute2 \
+ iputils-ping \
+ kmod \
+ linux-image-virtual \
+ systemd \
+ systemd-sysv \
+ systemd-timesyncd \
+ systemd-resolved \
+ curl \
+ udev \
+ && install -m 0755 /usr/local/lib/kumabox/initramfs/kumabox-overlay \
+ /etc/initramfs-tools/scripts/kumabox-overlay \
+ && install -m 0755 /usr/local/lib/kumabox/initramfs/kumabox-network \
+ /etc/initramfs-tools/scripts/init-bottom/kumabox-network \
+ && printf '%s\n' \
+ erofs overlay ext4 virtio_blk virtio_pci virtio_ring virtio_net \
+ vsock vmw_vsock_virtio_transport \
+ >> /etc/initramfs-tools/modules \
+ && sed -i 's/^COMPRESS=.*/COMPRESS=gzip/' /etc/initramfs-tools/initramfs.conf \
+ && sed -i '/^IP=/d' /etc/initramfs-tools/initramfs.conf \
+ && printf 'IP=off\n' >> /etc/initramfs-tools/initramfs.conf \
+ && update-initramfs -u -k all \
+ && for module_dir in /lib/modules/*; do \
+ kernel=${module_dir##*/}; \
+ initrd=/boot/initrd.img-${kernel}; \
+ contents=/tmp/initrd-${kernel}.list; \
+ test -s /boot/vmlinuz-${kernel}; \
+ test -s "$initrd"; \
+ lsinitramfs "$initrd" > "$contents"; \
+ grep -qx 'scripts/kumabox-overlay' "$contents"; \
+ grep -qx 'scripts/init-bottom/kumabox-network' "$contents"; \
+ for module in erofs overlay ext4 virtio_blk virtio_pci virtio_net vsock vmw_vsock_virtio_transport; do \
+ filename=$(modinfo -k "$kernel" -F filename "$module"); \
+ if [ "$filename" != '(builtin)' ]; then \
+ basename=${filename##*/}; \
+ grep -q "/${basename}$" "$contents"; \
+ fi; \
+ done; \
+ rm -f "$contents"; \
+ done \
+ && truncate -s 0 /etc/fstab \
+ && systemctl mask systemd-fsck-root.service systemd-remount-fs.service systemd-fsck@.service \
+ && systemctl enable systemd-networkd systemd-resolved systemd-timesyncd kumabox-agent.service \
+ && install -d -m 0755 /etc/systemd/network \
+ && printf '%s\n' \
+ '[Match]' 'Name=en* eth*' '' '[Network]' 'DHCP=ipv4' '' \
+ '[DHCPv4]' 'ClientIdentifier=mac' \
+ > /etc/systemd/network/20-kumabox.network \
+ && test -x /usr/local/bin/kumabox-agent \
+ && test -L /etc/systemd/system/multi-user.target.wants/kumabox-agent.service \
+ && rm -rf /var/lib/apt/lists/* /usr/local/lib/kumabox
+
+CMD ["/sbin/init"]
diff --git a/oci-images/ubuntu/README.md b/oci-images/ubuntu/README.md
new file mode 100644
index 0000000..1d0b2d5
--- /dev/null
+++ b/oci-images/ubuntu/README.md
@@ -0,0 +1,38 @@
+# KumaBox Ubuntu guest image
+
+This image declares `io.kumabox.boot.profile=overlay-v1`. Its initramfs owns the
+KumaBox host/guest boot contract:
+
+- `boot=kumabox-overlay` selects the root provider;
+- `kumabox.layers=kumabox-layerN,...,kumabox-layer0` lists EROFS lower layers
+ from top to base;
+- `kumabox.cow=kumabox-cow` identifies the ext4 upper/work disk;
+- block devices are resolved by virtio serial, never by `/dev/vdX` order.
+
+Build a local architecture image with BuildKit:
+
+```sh
+docker buildx build --load --platform linux/amd64 \
+ -f oci-images/ubuntu/Dockerfile \
+ -t kumabox/ubuntu:24.04 .
+```
+
+Use `--build-arg GOPROXY=,direct` when the default Go module proxy is
+not reachable from the BuildKit worker.
+
+Release builds must set `UBUNTU_IMAGE` to an immutable Ubuntu manifest digest:
+
+```sh
+docker buildx build --platform linux/amd64,linux/arm64 \
+ --build-arg UBUNTU_IMAGE=ubuntu@sha256: \
+ -f oci-images/ubuntu/Dockerfile \
+ -t ghcr.io/kgpp34/kumabox/ubuntu:24.04 --push .
+```
+
+The Dockerfile fails its build unless the initrd contains the overlay provider
+and every required filesystem, virtio, and vsock capability is either built
+into the kernel or present in the generated initrd.
+
+The same build compiles `kumabox-agent` from the checked-out source, installs
+it in the guest, and enables `kumabox-agent.service`. No prebuilt agent binary
+is required in the build context.
diff --git a/oci-images/ubuntu/agent-stub.sh b/oci-images/ubuntu/agent-stub.sh
deleted file mode 100755
index d05672d..0000000
--- a/oci-images/ubuntu/agent-stub.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/bin/sh
-set -eu
-
-case "${1:-}" in
- serve)
- echo "kumabox-agent stub: real vsock agent is implemented in P3-08/P3-09" >&2
- exec sleep infinity
- ;;
- version|--version)
- echo "kumabox-agent stub"
- ;;
- *)
- echo "usage: kumabox-agent {serve|version}" >&2
- exit 2
- ;;
-esac
diff --git a/oci-images/ubuntu/kumabox-agent.openrc b/oci-images/ubuntu/kumabox-agent.openrc
deleted file mode 100644
index 5f4f956..0000000
--- a/oci-images/ubuntu/kumabox-agent.openrc
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/sbin/openrc-run
-
-name="kumabox-agent"
-description="KumaBox guest agent"
-command="/usr/local/bin/kumabox-agent"
-command_args="serve"
-command_user="root:root"
-command_background="false"
-
-depend() {
- need localmount
- after bootmisc
-}
diff --git a/oci-images/ubuntu/kumabox-agent.service b/oci-images/ubuntu/kumabox-agent.service
index c5cceab..70dbcd9 100644
--- a/oci-images/ubuntu/kumabox-agent.service
+++ b/oci-images/ubuntu/kumabox-agent.service
@@ -1,18 +1,18 @@
[Unit]
-Description=KumaBox agent (vsock command exec)
+Description=KumaBox guest agent
Documentation=https://github.com/kumabox/kumabox
+After=systemd-modules-load.service
[Service]
Type=simple
User=root
Group=root
-ExecStartPre=-/sbin/modprobe vhost_vsock
+ExecStartPre=-/sbin/modprobe vmw_vsock_virtio_transport
ExecStart=/usr/local/bin/kumabox-agent serve
-Environment=AGENT_LOG_LEVEL=info
Restart=always
RestartSec=2s
-StandardOutput=journal+console
-StandardError=journal+console
+StandardOutput=journal
+StandardError=journal
SyslogIdentifier=kumabox-agent
LimitNOFILE=65536
diff --git a/oci-images/ubuntu/network.sh b/oci-images/ubuntu/network.sh
old mode 100755
new mode 100644
index 554bcd8..06b1484
--- a/oci-images/ubuntu/network.sh
+++ b/oci-images/ubuntu/network.sh
@@ -1,84 +1,75 @@
#!/bin/sh
+# Persists initramfs static network facts into the assembled Ubuntu root.
+#
+# kernel ip= parameters
+# |
+# v
+# /run/net-ethN.conf
+# |
+# v
+# MAC-matched systemd-networkd files in the writable overlay
PREREQ=""
-prereqs() { echo "$PREREQ"; }
-case "$1" in prereqs) prereqs; exit 0 ;; esac
-. /scripts/functions
-
-[ -n "${rootmnt:-}" ] || exit 0
+prereqs() {
+ printf '%s\n' "$PREREQ"
+}
-for arg in $(cat /proc/cmdline); do
- case "$arg" in
- kumabox.hostname=*) echo "${arg#kumabox.hostname=}" >"${rootmnt}/etc/hostname" ;;
- esac
-done
+case "$1" in
+prereqs)
+ prereqs
+ exit 0
+ ;;
+esac
-dns_servers=""
-has_static=false
+. /scripts/functions
-for conf_file in /run/net-*.conf; do
- [ -f "$conf_file" ] || continue
- unset DEVICE IPV4ADDR IPV4NETMASK IPV4GATEWAY IPV4DNS0 IPV4DNS1 HWADDR
- . "$conf_file"
- [ -n "${DEVICE:-}" ] || continue
- [ -n "${IPV4ADDR:-}" ] || continue
- [ -n "${HWADDR:-}" ] || [ ! -e "/sys/class/net/${DEVICE}/address" ] || HWADDR="$(cat "/sys/class/net/${DEVICE}/address")"
- [ -n "${HWADDR:-}" ] || continue
+[ -n "$rootmnt" ] || exit 0
- has_static=true
- prefix=0
- old_ifs="$IFS"
- IFS=.
- set -- $IPV4NETMASK
- IFS="$old_ifs"
- 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)) ;;
- esac
- done
+for config_file in /run/net-*.conf; do
+ [ -f "$config_file" ] || continue
+ unset DEVICE IPV4ADDR IPV4NETMASK IPV4GATEWAY IPV4DNS0 IPV4DNS1 HWADDR
+ . "$config_file"
+ [ -n "$DEVICE" ] || continue
+ [ -n "$IPV4ADDR" ] || continue
- mac_name="$(echo "$HWADDR" | tr -d ':')"
- mkdir -p "${rootmnt}/etc/systemd/network"
- {
- printf "[Match]\nMACAddress=%s\n\n[Network]\nAddress=%s/%d\n" "$HWADDR" "$IPV4ADDR" "$prefix"
- [ -n "${IPV4GATEWAY:-}" ] && [ "$IPV4GATEWAY" != "0.0.0.0" ] && printf "Gateway=%s\n" "$IPV4GATEWAY"
- if [ -n "${IPV4DNS0:-}" ] && [ "$IPV4DNS0" != "0.0.0.0" ]; then
- printf "DNS=%s\n" "$IPV4DNS0"
- dns_servers="${dns_servers} ${IPV4DNS0}"
- fi
- if [ -n "${IPV4DNS1:-}" ] && [ "$IPV4DNS1" != "0.0.0.0" ]; then
- printf "DNS=%s\n" "$IPV4DNS1"
- dns_servers="${dns_servers} ${IPV4DNS1}"
- fi
- } >"${rootmnt}/etc/systemd/network/10-${mac_name}.network"
-done
+ if [ -z "$HWADDR" ] && [ -r "/sys/class/net/$DEVICE/address" ]; then
+ HWADDR=$(cat "/sys/class/net/$DEVICE/address")
+ fi
+ [ -n "$HWADDR" ] || continue
-if [ "$has_static" = false ]; then
- mkdir -p "${rootmnt}/etc/systemd/network"
- for sysdev in /sys/class/net/*; do
- [ -e "$sysdev" ] || continue
- dev="${sysdev##*/}"
- case "$dev" in lo|bonding_masters) continue ;; esac
- [ -e "${sysdev}/address" ] || continue
- mac="$(cat "${sysdev}/address")"
- case "$mac" in ""|00:00:00:00:00:00) continue ;; esac
- mac_name="$(echo "$mac" | tr -d ':')"
- {
- printf "[Match]\nMACAddress=%s\n\n[Network]\nDHCP=ipv4\n\n[DHCPv4]\nClientIdentifier=mac\n" "$mac"
- } >"${rootmnt}/etc/systemd/network/10-${mac_name}.network"
- done
-fi
+ prefix=0
+ old_ifs=$IFS
+ IFS=.
+ set -- $IPV4NETMASK
+ IFS=$old_ifs
+ 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)) ;;
+ esac
+ done
-[ -n "$dns_servers" ] || dns_servers="8.8.8.8 8.8.4.4"
-: >"${rootmnt}/etc/resolv.conf"
-for ns in $dns_servers; do
- printf "nameserver %s\n" "$ns" >>"${rootmnt}/etc/resolv.conf"
+ identifier=$(printf '%s' "$HWADDR" | tr -d ':')
+ directory="$rootmnt/etc/systemd/network"
+ mkdir -p "$directory"
+ {
+ printf '[Match]\nMACAddress=%s\n\n' "$HWADDR"
+ printf '[Network]\nAddress=%s/%s\n' "$IPV4ADDR" "$prefix"
+ if [ -n "$IPV4GATEWAY" ] && [ "$IPV4GATEWAY" != "0.0.0.0" ]; then
+ printf 'Gateway=%s\n' "$IPV4GATEWAY"
+ fi
+ if [ -n "$IPV4DNS0" ] && [ "$IPV4DNS0" != "0.0.0.0" ]; then
+ printf 'DNS=%s\n' "$IPV4DNS0"
+ fi
+ if [ -n "$IPV4DNS1" ] && [ "$IPV4DNS1" != "0.0.0.0" ]; then
+ printf 'DNS=%s\n' "$IPV4DNS1"
+ fi
+ } >"$directory/10-kumabox-$identifier.network"
done
diff --git a/oci-images/ubuntu/overlay.sh b/oci-images/ubuntu/overlay.sh
index 30da1e9..38d7267 100755
--- a/oci-images/ubuntu/overlay.sh
+++ b/oci-images/ubuntu/overlay.sh
@@ -1,142 +1,122 @@
#!/bin/sh
+# KumaBox overlay-v1 initramfs root provider.
+#
+# The host attaches immutable EROFS disks as kumabox-layer0..N in manifest
+# order and one ext4 disk as kumabox-cow. The kernel command line reverses the
+# layer serials so OverlayFS sees the top layer first:
+#
+# EROFS disks + ext4 COW
+# |
+# v
+# kumabox.layers=top,...,base kumabox.cow=kumabox-cow
+# | |
+# +---- lowerdir list +---- upper/work
+# \ /
+# overlay root
. /scripts/functions
-boot_phase() {
- phase="$1"
- phase_dir=/run/kumabox
- uptime="$(cut -d' ' -f1 /proc/uptime 2>/dev/null || true)"
- seconds="${uptime%%.*}"
- fraction="${uptime#*.}"
- [ "$seconds" != "$uptime" ] || seconds=0
- [ -n "$fraction" ] || fraction=0
- fraction="$(printf '%s000' "$fraction" | cut -c1-3)"
- mkdir -p "$phase_dir"
- printf 'KumaBox: boot-phase=%s monotonic-ms=%s\n' \
- "$phase" "$((seconds * 1000 + fraction))" >>"$phase_dir/boot-phases"
-}
-
-resolve_disk() {
- serial="$1"
- timeout="${KUMABOX_TIMEOUT:-10}"
- i=0
-
- case "$timeout" in
- ''|*[!0-9]*) timeout=10 ;;
- esac
-
- case "$serial" in
- /dev/*)
- while [ "$i" -lt "$timeout" ]; do
- [ -b "$serial" ] && echo "$serial" && return 0
- sleep 1
- i=$((i + 1))
- done
- echo "KumaBox: device ${serial} not present after ${timeout}s" >&2
- return 1
- ;;
- esac
-
- while [ "$i" -lt "$timeout" ]; do
- by_id="/dev/disk/by-id/virtio-${serial}"
- if [ -b "$by_id" ]; then
- echo "$by_id"
- return 0
- fi
- for sysdev in /sys/block/vd*; do
- [ -d "$sysdev" ] || continue
- dev_serial=""
- if [ -f "$sysdev/serial" ]; then
- dev_serial="$(cat "$sysdev/serial")"
- fi
- if [ -z "$dev_serial" ] && [ -f "$sysdev/device/serial" ]; then
- dev_serial="$(cat "$sysdev/device/serial")"
- fi
- while :; do
- case "$dev_serial" in
- *[[:space:]]) dev_serial="${dev_serial%[[:space:]]}" ;;
- *) break ;;
- esac
- done
- if [ "$dev_serial" = "$serial" ]; then
- echo "/dev/${sysdev##*/}"
- return 0
- fi
- done
- sleep 1
- i=$((i + 1))
- done
- return 1
+# kumabox_device resolves one virtio block serial with a bounded wait. Device
+# letters are deliberately ignored because VMM attachment order is not an ABI.
+kumabox_device() {
+ serial=$1
+ attempt=0
+ while [ "$attempt" -lt "$KUMABOX_DEVICE_TIMEOUT" ]; do
+ for sysdev in /sys/block/*; do
+ [ -d "$sysdev" ] || continue
+ value=
+ if [ -r "$sysdev/serial" ]; then
+ value=$(cat "$sysdev/serial")
+ elif [ -r "$sysdev/device/serial" ]; then
+ value=$(cat "$sysdev/device/serial")
+ fi
+ if [ "$value" = "$serial" ]; then
+ printf '/dev/%s\n' "${sysdev##*/}"
+ return 0
+ fi
+ done
+ sleep 1
+ attempt=$((attempt + 1))
+ done
+ return 1
}
+# mountroot is called by initramfs-tools when boot=kumabox-overlay is selected.
mountroot() {
- boot_phase overlay-start
- log_begin_msg "KumaBox: mounting OCI overlay rootfs"
-
- if ! ls /run/net-*.conf >/dev/null 2>&1; then
- for arg in $(cat /proc/cmdline); do
- case "$arg" in
- ip=*) configure_networking; break ;;
- esac
- done
- fi
-
- modprobe erofs 2>/dev/null || true
- modprobe overlay 2>/dev/null || true
- modprobe ext4 2>/dev/null || true
-
- for arg in $(cat /proc/cmdline); do
- case "$arg" in
- kumabox.layers=*) LAYERS="${arg#kumabox.layers=}" ;;
- kumabox.cow=*) COW="${arg#kumabox.cow=}" ;;
- kumabox.timeout=*) KUMABOX_TIMEOUT="${arg#kumabox.timeout=}" ;;
- esac
- done
-
- [ -n "${LAYERS:-}" ] || panic "kumabox.layers= not set"
- [ -n "${COW:-}" ] || panic "kumabox.cow= not set"
-
- udevadm settle 2>/dev/null || true
-
- internal="/.kumabox"
- mkdir -p "$internal"
-
- lower=""
- layer_devs=""
- old_ifs="$IFS"
- IFS=,
- for serial in $LAYERS; do
- dev="$(resolve_disk "$serial")" || panic "layer device ${serial} not found"
- mnt="${internal}/layers/${serial}"
- mkdir -p "$mnt"
- mount -t erofs -o ro "$dev" "$mnt" || panic "mount layer ${serial} failed"
- [ -n "$lower" ] && lower="${lower}:"
- lower="${lower}${mnt}"
- layer_devs="${layer_devs} ${dev}"
- done
- IFS="$old_ifs"
-
- cow_dev="$(resolve_disk "$COW")" || panic "COW device ${COW} not found"
- mkdir -p "${internal}/cow"
- mount -t ext4 -o noatime "$cow_dev" "${internal}/cow" || panic "mount COW failed"
- mkdir -p "${internal}/cow/upper" "${internal}/cow/work"
-
- overlay_opts="lowerdir=${lower},upperdir=${internal}/cow/upper,workdir=${internal}/cow/work,index=on,redirect_dir=on,metacopy=on,xino=on"
- mount -t overlay overlay -o "$overlay_opts" "$rootmnt" || panic "overlay rootfs failed"
-
- mkdir -p "${rootmnt}/dev" "${rootmnt}/proc" "${rootmnt}/sys" "${rootmnt}/run"
-
- # Every VM gets a fresh machine identity, including native clones.
- rm -f "${rootmnt}/etc/machine-id" 2>/dev/null || true
- : >"${rootmnt}/etc/machine-id"
-
- for dev in $layer_devs; do
- blk="${dev##*/}"
- [ -e "/sys/block/${blk}/queue/scheduler" ] && echo none >"/sys/block/${blk}/queue/scheduler" 2>/dev/null || true
- done
- cow_blk="${cow_dev##*/}"
- [ -e "/sys/block/${cow_blk}/queue/scheduler" ] && echo mq-deadline >"/sys/block/${cow_blk}/queue/scheduler" 2>/dev/null || true
-
- boot_phase overlay-ready
- log_success_msg "KumaBox: OCI overlay rootfs ready"
+ KUMABOX_LAYERS=
+ KUMABOX_COW=
+ KUMABOX_HOSTNAME=
+ KUMABOX_NETWORK=false
+ KUMABOX_DEVICE_TIMEOUT=10
+ for argument in $(cat /proc/cmdline); do
+ case "$argument" in
+ kumabox.layers=*) KUMABOX_LAYERS=${argument#kumabox.layers=} ;;
+ kumabox.cow=*) KUMABOX_COW=${argument#kumabox.cow=} ;;
+ kumabox.hostname=*) KUMABOX_HOSTNAME=${argument#kumabox.hostname=} ;;
+ kumabox.timeout=*) KUMABOX_DEVICE_TIMEOUT=${argument#kumabox.timeout=} ;;
+ ip=*) KUMABOX_NETWORK=true ;;
+ esac
+ done
+
+ case "$KUMABOX_DEVICE_TIMEOUT" in
+ ''|*[!0-9]*) panic "kumabox.timeout must be an integer" ;;
+ esac
+ [ "$KUMABOX_DEVICE_TIMEOUT" -gt 0 ] || panic "kumabox.timeout must be positive"
+ [ -n "$KUMABOX_LAYERS" ] || panic "kumabox.layers is required"
+ [ -n "$KUMABOX_COW" ] || panic "kumabox.cow is required"
+ [ -n "$KUMABOX_HOSTNAME" ] || panic "kumabox.hostname is required"
+ case "$KUMABOX_LAYERS" in
+ ,*|*,|*,,*) panic "kumabox.layers contains an empty serial" ;;
+ esac
+ case "$KUMABOX_COW" in
+ *[!A-Za-z0-9_.-]*) panic "kumabox.cow contains an invalid serial" ;;
+ esac
+ case "$KUMABOX_HOSTNAME" in
+ *[!A-Za-z0-9_.-]*) panic "kumabox.hostname contains an invalid character" ;;
+ esac
+
+ # configure_networking parses every static ip= entry into /run/net-*.conf.
+ # Skipping it for a zero-NIC sandbox avoids the initramfs DHCP wait.
+ if [ "$KUMABOX_NETWORK" = true ] && ! ls /run/net-*.conf >/dev/null 2>&1; then
+ configure_networking
+ fi
+
+ modprobe erofs 2>/dev/null || true
+ modprobe overlay 2>/dev/null || true
+ modprobe ext4 2>/dev/null || true
+ udevadm settle 2>/dev/null || true
+
+ workspace=/.kumabox
+ mkdir -p "$workspace/layers" "$workspace/cow"
+ lowerdirs=
+ old_ifs=$IFS
+ IFS=,
+ for serial in $KUMABOX_LAYERS; do
+ case "$serial" in
+ *[!A-Za-z0-9_.-]*) panic "kumabox.layers contains an invalid serial" ;;
+ esac
+ device=$(kumabox_device "$serial") || panic "KumaBox layer $serial was not found"
+ mountpoint="$workspace/layers/$serial"
+ mkdir -p "$mountpoint"
+ mount -t erofs -o ro "$device" "$mountpoint" || panic "KumaBox layer $serial could not be mounted"
+ if [ -n "$lowerdirs" ]; then
+ lowerdirs="$lowerdirs:$mountpoint"
+ else
+ lowerdirs=$mountpoint
+ fi
+ done
+ IFS=$old_ifs
+
+ cow_device=$(kumabox_device "$KUMABOX_COW") || panic "KumaBox COW disk $KUMABOX_COW was not found"
+ mount -t ext4 -o noatime "$cow_device" "$workspace/cow" || panic "KumaBox COW disk could not be mounted"
+ mkdir -p "$workspace/cow/upper" "$workspace/cow/work"
+ mount -t overlay overlay \
+ -o "lowerdir=$lowerdirs,upperdir=$workspace/cow/upper,workdir=$workspace/cow/work" \
+ "$rootmnt" || panic "KumaBox overlay root could not be mounted"
+
+ mkdir -p "$rootmnt/dev" "$rootmnt/proc" "$rootmnt/sys" "$rootmnt/run" "$rootmnt/etc"
+ rm -f "$rootmnt/etc/machine-id"
+ : >"$rootmnt/etc/machine-id"
+ printf '%s\n' "$KUMABOX_HOSTNAME" >"$rootmnt/etc/hostname"
+ log_success_msg "KumaBox overlay-v1 root is ready"
}
diff --git a/sandbox/catalog/store.go b/sandbox/catalog/store.go
new file mode 100644
index 0000000..158cdc9
--- /dev/null
+++ b/sandbox/catalog/store.go
@@ -0,0 +1,659 @@
+// Package catalog persists sandbox records, names, and image usage in shared metadata.
+// It owns encoding and transaction rules; application orchestration and disk I/O
+// remain in their dedicated packages.
+package catalog
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/types"
+)
+
+const (
+ // CollectionSandboxes stores one aggregate per immutable sandbox ID.
+ CollectionSandboxes metadata.Collection = "sandboxes"
+ // CollectionNames maps a user-facing name to one sandbox ID.
+ CollectionNames metadata.Collection = "sandbox_names"
+)
+
+// Collections declares the record sets required by this adapter.
+func Collections() []metadata.Collection {
+ return []metadata.Collection{CollectionSandboxes, CollectionNames}
+}
+
+// ImageReader resolves an image inside the caller's metadata transaction.
+// Implementations must use reader directly and must not open a nested transaction.
+type ImageReader interface {
+ Resolve(context.Context, metadata.Reader, string) (types.Image, error)
+}
+
+// Store adapts a shared metadata engine to sandbox persistence operations.
+// It neither owns the engine nor modifies sandbox files.
+type Store struct {
+ // store supplies atomic writes spanning sandbox and image collections.
+ store metadata.Store
+ // images rechecks an image binding within the reservation transaction.
+ images ImageReader
+}
+
+// New constructs a sandbox catalog over an existing shared store.
+func New(store metadata.Store, imageReader ImageReader) *Store {
+ return &Store{store: store, images: imageReader}
+}
+
+// recordData is the stable adapter-owned JSON representation of a sandbox aggregate.
+type recordData struct {
+ // ID must equal the CollectionSandboxes key.
+ ID string `json:"id"`
+ // Name is the immutable user-facing sandbox name.
+ Name string `json:"name"`
+ // CPUs is the requested virtual CPU count.
+ CPUs uint32 `json:"cpus"`
+ // Memory is guest memory in bytes.
+ Memory int64 `json:"memory"`
+ // Storage is logical COW capacity in bytes.
+ Storage int64 `json:"storage"`
+ // NICs is the immutable requested network interface count.
+ NICs int `json:"nics,omitempty"`
+ // NetworkName is the resolved CNI conflist name.
+ NetworkName string `json:"network_name,omitempty"`
+ // Network is the resolved provider-to-VMM handoff.
+ Network *networkData `json:"network,omitempty"`
+ // ImageDigest pins the canonical manifest record.
+ ImageDigest string `json:"image_digest"`
+ // VMM identifies the backend that owns runtime artifacts. Empty legacy
+ // records are decoded as cloud-hypervisor.
+ VMM string `json:"vmm,omitempty"`
+ // State is explicitly mapped back into the domain enum.
+ State string `json:"state"`
+ // Generation fences stale state transitions.
+ Generation uint64 `json:"generation"`
+ // Failure retains incomplete cleanup diagnostics only in Error state.
+ Failure *failureData `json:"failure,omitempty"`
+ // CreatedAt records initial reservation time.
+ CreatedAt time.Time `json:"created_at"`
+ // UpdatedAt records the latest transition time.
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// failureData keeps diagnostic operation failure facts out of stable error codes.
+type failureData struct {
+ // Phase locates the failed operation step.
+ Phase string `json:"phase"`
+ // Message preserves operator diagnostics without becoming a stable code.
+ Message string `json:"message"`
+}
+
+// networkData is the stable persisted form of one resolved network setup.
+type networkData struct {
+ Backend string `json:"backend"`
+ Namespace string `json:"namespace"`
+ Interfaces []networkInterfaceData `json:"interfaces"`
+}
+
+// networkInterfaceData stores one NIC without exposing adapter encoding tags
+// through the shared types package.
+type networkInterfaceData struct {
+ Index int `json:"index"`
+ Name string `json:"name"`
+ TAP string `json:"tap"`
+ MAC string `json:"mac"`
+ Queues int `json:"queues"`
+ QueueSize int `json:"queue_size"`
+ Network string `json:"network"`
+ IPv4 *ipv4Data `json:"ipv4,omitempty"`
+}
+
+// ipv4Data stores the optional guest-visible IPv4 assignment.
+type ipv4Data struct {
+ Address string `json:"address"`
+ Gateway string `json:"gateway,omitempty"`
+ Prefix int `json:"prefix"`
+}
+
+// nameData is deliberately small so names can be checked without decoding aggregates.
+type nameData struct {
+ // ID is the owner in CollectionSandboxes.
+ ID string `json:"id"`
+}
+
+// Reserve atomically rechecks the image, claims the name, and writes a Creating record.
+// expected protects against an alias rebound while image artifact locks were acquired.
+func (c *Store) Reserve(ctx context.Context, imageReference string, expected types.Digest, record types.Sandbox) error {
+ if c == nil || c.store == nil || c.images == nil {
+ return errors.New("sandbox catalog is not configured")
+ }
+ if err := record.Validate(); err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if record.State != types.SandboxStateCreating || record.Generation != 1 || record.ImageDigest != expected {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("reservation must be generation-one Creating state for the expected image"))
+ }
+ err := c.store.Update(ctx, func(writer metadata.Writer) error {
+ image, err := c.images.Resolve(ctx, writer, imageReference)
+ if err != nil {
+ return err
+ }
+ if image.ManifestDigest != expected {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("image binding changed while reserving sandbox; retry"))
+ }
+ if _, exists, err := writer.Get(ctx, CollectionSandboxes, record.ID.String()); err != nil {
+ return err
+ } else if exists {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox ID %s already exists", record.ID))
+ }
+ if raw, exists, err := writer.Get(ctx, CollectionNames, record.Config.Name); err != nil {
+ return err
+ } else if exists {
+ var current nameData
+ if err := json.Unmarshal(raw, ¤t); err != nil {
+ return corrupt("sandbox name", err)
+ }
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeNameTaken, fmt.Errorf("sandbox name %q is already used by %s", record.Config.Name, current.ID))
+ }
+ if err := putJSON(ctx, writer, CollectionSandboxes, record.ID.String(), encode(record)); err != nil {
+ return err
+ }
+ return putJSON(ctx, writer, CollectionNames, record.Config.Name, nameData{ID: record.ID.String()})
+ })
+ return errdefs.Context(err, "reserve sandbox", record.Config.Name, "metadata", "choose another name or retry", false)
+}
+
+// MarkCreated atomically publishes resolved network state and the Created
+// transition only when state and generation still match.
+func (c *Store) MarkCreated(ctx context.Context, id types.SandboxID, expected uint64, setup types.NetworkSetup, updated time.Time) (types.Sandbox, error) {
+ if err := setup.Validate(); err != nil {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ var result types.Sandbox
+ err := c.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := load(ctx, writer, id)
+ if err != nil {
+ return err
+ }
+ if record.Generation != expected || record.State != types.SandboxStateCreating {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s changed from expected Creating generation %d", id, expected))
+ }
+ if record.Config.NICs == 0 {
+ if setup.Backend != "" {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("sandbox without NICs cannot commit network setup"))
+ }
+ } else {
+ if setup.Backend == "" || len(setup.Interfaces) != record.Config.NICs {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("networked sandbox requires one resolved interface per requested NIC"))
+ }
+ resolved := setup.Interfaces[0].Network
+ if record.Config.NetworkName != "" && record.Config.NetworkName != resolved {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("resolved network differs from the requested network"))
+ }
+ record.Config.NetworkName = resolved
+ }
+ record.Network = setup
+ record.State = types.SandboxStateCreated
+ record.Generation++
+ record.UpdatedAt = updated
+ if err := record.Validate(); err != nil {
+ return corrupt("sandbox create transition", err)
+ }
+ if err := putJSON(ctx, writer, CollectionSandboxes, id.String(), encode(record)); err != nil {
+ return err
+ }
+ result = record
+ return nil
+ })
+ return result, errdefs.Context(err, "create sandbox", id.String(), "mark created", "inspect the sandbox state before retrying", false)
+}
+
+// MarkError retains ownership and diagnostics when create cleanup cannot finish.
+func (c *Store) MarkError(ctx context.Context, id types.SandboxID, expected uint64, failure types.SandboxFailure, updated time.Time) (types.Sandbox, error) {
+ if failure.Phase == "" || failure.Message == "" {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("error transition requires phase and message"))
+ }
+ return c.transition(ctx, id, expected, types.SandboxStateCreating, types.SandboxStateError, &failure, updated)
+}
+
+// BeginStart records launch ownership before runtime files or a VMM process are
+// created. Retrying an unchanged Starting generation resumes that operation.
+func (c *Store) BeginStart(ctx context.Context, id types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) {
+ var result types.Sandbox
+ err := c.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := load(ctx, writer, id)
+ if err != nil {
+ return err
+ }
+ if record.Generation != expected {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s changed from expected generation %d", id, expected))
+ }
+ if record.State == types.SandboxStateStarting {
+ result = record
+ return nil
+ }
+ switch record.State {
+ case types.SandboxStateCreated, types.SandboxStateStopped, types.SandboxStateError:
+ default:
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s in state %s cannot start", id, record.State))
+ }
+ record.State = types.SandboxStateStarting
+ record.Generation++
+ record.Failure = nil
+ record.UpdatedAt = updated
+ if err := record.Validate(); err != nil {
+ return corrupt("sandbox start transition", err)
+ }
+ if err := putJSON(ctx, writer, CollectionSandboxes, id.String(), encode(record)); err != nil {
+ return err
+ }
+ result = record
+ return nil
+ })
+ return result, errdefs.Context(err, "start sandbox", id.String(), "mark starting", "inspect the sandbox state before retrying", false)
+}
+
+// MarkRunning commits readiness only for the Starting generation that launched
+// the observed process.
+func (c *Store) MarkRunning(ctx context.Context, id types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) {
+ return c.transition(ctx, id, expected, types.SandboxStateStarting, types.SandboxStateRunning, nil, updated)
+}
+
+// MarkStartError retains launch diagnostics and ownership after cleanup was
+// attempted for one Starting generation.
+func (c *Store) MarkStartError(ctx context.Context, id types.SandboxID, expected uint64, failure types.SandboxFailure, updated time.Time) (types.Sandbox, error) {
+ if failure.Phase == "" || failure.Message == "" {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("start error transition requires phase and message"))
+ }
+ return c.transition(ctx, id, expected, types.SandboxStateStarting, types.SandboxStateError, &failure, updated)
+}
+
+// BeginStop records shutdown ownership before signalling the VMM. Retrying an
+// unchanged Stopping generation resumes the same operation.
+func (c *Store) BeginStop(ctx context.Context, id types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) {
+ var result types.Sandbox
+ err := c.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := load(ctx, writer, id)
+ if err != nil {
+ return err
+ }
+ if record.Generation != expected {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s changed from expected generation %d", id, expected))
+ }
+ if record.State == types.SandboxStateStopping {
+ result = record
+ return nil
+ }
+ if record.State != types.SandboxStateRunning {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s in state %s cannot begin stopping", id, record.State))
+ }
+ record.State = types.SandboxStateStopping
+ record.Generation++
+ record.Failure = nil
+ record.UpdatedAt = updated
+ if err := record.Validate(); err != nil {
+ return corrupt("sandbox stop transition", err)
+ }
+ if err := putJSON(ctx, writer, CollectionSandboxes, id.String(), encode(record)); err != nil {
+ return err
+ }
+ result = record
+ return nil
+ })
+ return result, errdefs.Context(err, "stop sandbox", id.String(), "mark stopping", "inspect the sandbox state before retrying", false)
+}
+
+// MarkStopped commits process absence from a lifecycle state that can own a
+// VMM. The caller must prove absence before this generation-fenced transition.
+func (c *Store) MarkStopped(ctx context.Context, id types.SandboxID, expected uint64, from types.SandboxState, updated time.Time) (types.Sandbox, error) {
+ switch from {
+ case types.SandboxStateStarting, types.SandboxStateRunning, types.SandboxStateStopping:
+ default:
+ return types.Sandbox{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("state %s cannot transition to stopped", from))
+ }
+ return c.transition(ctx, id, expected, from, types.SandboxStateStopped, nil, updated)
+}
+
+// Resolve returns one sandbox by exact name or complete ID. Exact names take
+// precedence so UUID-shaped names follow the same lookup rule as image aliases.
+func (c *Store) Resolve(ctx context.Context, reference string) (types.Sandbox, error) {
+ if c == nil || c.store == nil {
+ return types.Sandbox{}, errors.New("sandbox catalog is not configured")
+ }
+ var result types.Sandbox
+ err := c.store.View(ctx, func(reader metadata.Reader) error {
+ var err error
+ result, err = resolveRecord(ctx, reader, reference)
+ return err
+ })
+ return result, errdefs.Context(err, "resolve sandbox", reference, "metadata", "check the sandbox name or ID", false)
+}
+
+// List returns one validated snapshot ordered newest first, with ID as the
+// deterministic tie-breaker. A malformed record fails the whole query.
+func (c *Store) List(ctx context.Context) ([]types.Sandbox, error) {
+ if c == nil || c.store == nil {
+ return nil, errors.New("sandbox catalog is not configured")
+ }
+ result := make([]types.Sandbox, 0)
+ err := c.store.View(ctx, func(reader metadata.Reader) error {
+ return reader.Scan(ctx, CollectionSandboxes, func(id string, raw []byte) error {
+ record, err := decode(raw)
+ if err != nil {
+ return err
+ }
+ if record.ID.String() != id {
+ return corrupt("sandbox ID", errors.New("record key differs from stored ID"))
+ }
+ result = append(result, record)
+ return nil
+ })
+ })
+ slices.SortFunc(result, func(left, right types.Sandbox) int {
+ if order := right.CreatedAt.Compare(left.CreatedAt); order != 0 {
+ return order
+ }
+ return strings.Compare(left.ID.String(), right.ID.String())
+ })
+ return result, errdefs.Context(err, "list sandboxes", "", "metadata", "inspect the sandbox metadata store", false)
+}
+
+// BeginDelete records durable cleanup intent before any owned file is removed.
+// A retained Deleting record resumes without advancing its generation again.
+func (c *Store) BeginDelete(ctx context.Context, id types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) {
+ var result types.Sandbox
+ err := c.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := load(ctx, writer, id)
+ if err != nil {
+ return err
+ }
+ if record.Generation != expected {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s changed from expected generation %d", id, expected))
+ }
+ if record.State == types.SandboxStateDeleting {
+ result = record
+ return nil
+ }
+ switch record.State {
+ case types.SandboxStateCreating, types.SandboxStateCreated, types.SandboxStateStopped, types.SandboxStateError:
+ default:
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s in state %s cannot be removed without stopping it", id, record.State))
+ }
+ record.State = types.SandboxStateDeleting
+ record.Generation++
+ record.Failure = nil
+ record.UpdatedAt = updated
+ if err := record.Validate(); err != nil {
+ return corrupt("sandbox delete transition", err)
+ }
+ if err := putJSON(ctx, writer, CollectionSandboxes, id.String(), encode(record)); err != nil {
+ return err
+ }
+ result = record
+ return nil
+ })
+ return result, errdefs.Context(err, "remove sandbox", id.String(), "mark deleting", "stop the sandbox if it is running, then retry", false)
+}
+
+// FinalizeDelete atomically releases the name and image reference only after
+// the caller has removed every resource derived from the sandbox record.
+func (c *Store) FinalizeDelete(ctx context.Context, id types.SandboxID, expected uint64) error {
+ err := c.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := load(ctx, writer, id)
+ if err != nil {
+ return err
+ }
+ if record.Generation != expected || record.State != types.SandboxStateDeleting {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s is no longer the expected Deleting generation %d", id, expected))
+ }
+ return deleteRecord(ctx, writer, record)
+ })
+ return errdefs.Context(err, "remove sandbox", id.String(), "finalize metadata", "retry removal to finish cleanup", false)
+}
+
+// transition applies one generation-fenced state change and returns the committed record.
+func (c *Store) transition(ctx context.Context, id types.SandboxID, expected uint64, from, to types.SandboxState, failure *types.SandboxFailure, updated time.Time) (types.Sandbox, error) {
+ var result types.Sandbox
+ err := c.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := load(ctx, writer, id)
+ if err != nil {
+ return err
+ }
+ if record.Generation != expected || record.State != from {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s changed from expected %s generation %d", id, from, expected))
+ }
+ record.State = to
+ record.Generation++
+ record.Failure = failure
+ record.UpdatedAt = updated
+ if err := record.Validate(); err != nil {
+ return corrupt("sandbox transition", err)
+ }
+ if err := putJSON(ctx, writer, CollectionSandboxes, id.String(), encode(record)); err != nil {
+ return err
+ }
+ result = record
+ return nil
+ })
+ return result, errdefs.Context(err, "transition sandbox", id.String(), "metadata", "inspect the sandbox state", false)
+}
+
+// Forget removes a failed Creating reservation only if its generation is unchanged.
+// The caller must prove that every resource owned by the record was removed first.
+func (c *Store) Forget(ctx context.Context, id types.SandboxID, expected uint64) error {
+ err := c.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := load(ctx, writer, id)
+ if err != nil {
+ return err
+ }
+ if record.Generation != expected || record.State != types.SandboxStateCreating {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s is no longer the expected Creating reservation", id))
+ }
+ return deleteRecord(ctx, writer, record)
+ })
+ return errdefs.Context(err, "forget sandbox", id.String(), "metadata", "inspect the retained sandbox record", false)
+}
+
+// Usage answers image deletion from the same metadata transaction that removes
+// the last image alias. Every retained sandbox record is a live reference.
+type Usage struct{}
+
+// InUse reports whether any sandbox pins digest without opening another transaction.
+func (Usage) InUse(ctx context.Context, reader metadata.Reader, digest types.Digest) (bool, error) {
+ used := false
+ err := reader.Scan(ctx, CollectionSandboxes, func(_ string, raw []byte) error {
+ record, err := decode(raw)
+ if err != nil {
+ return err
+ }
+ if record.ImageDigest == digest {
+ used = true
+ }
+ return nil
+ })
+ return used, err
+}
+
+// load fetches and validates one sandbox aggregate inside the caller's transaction.
+func load(ctx context.Context, reader metadata.Reader, id types.SandboxID) (types.Sandbox, error) {
+ raw, exists, err := reader.Get(ctx, CollectionSandboxes, id.String())
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ if !exists {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("sandbox %s not found", id))
+ }
+ record, err := decode(raw)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ if record.ID != id {
+ return types.Sandbox{}, corrupt("sandbox ID", errors.New("record key differs from stored ID"))
+ }
+ return record, nil
+}
+
+// resolveRecord prefers an exact name and otherwise accepts a complete ID.
+func resolveRecord(ctx context.Context, reader metadata.Reader, reference string) (types.Sandbox, error) {
+ raw, exists, err := reader.Get(ctx, CollectionNames, reference)
+ if err != nil {
+ return types.Sandbox{}, err
+ }
+ if exists {
+ var binding nameData
+ if err := json.Unmarshal(raw, &binding); err != nil {
+ return types.Sandbox{}, corrupt("sandbox name", err)
+ }
+ id, err := types.ParseSandboxID(binding.ID)
+ if err != nil {
+ return types.Sandbox{}, corrupt("sandbox name owner", err)
+ }
+ record, err := load(ctx, reader, id)
+ if code, ok := errdefs.CodeOf(err); ok && code == errdefs.CodeNotFound {
+ return types.Sandbox{}, corrupt("sandbox name owner", errors.New("sandbox record is missing"))
+ }
+ return record, err
+ }
+ id, err := types.ParseSandboxID(reference)
+ if err != nil {
+ return types.Sandbox{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("sandbox %q not found", reference))
+ }
+ return load(ctx, reader, id)
+}
+
+// deleteRecord verifies name ownership and removes both indexes in one transaction.
+func deleteRecord(ctx context.Context, writer metadata.Writer, record types.Sandbox) error {
+ raw, exists, err := writer.Get(ctx, CollectionNames, record.Config.Name)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return corrupt("sandbox name", errors.New("name binding is missing"))
+ }
+ var name nameData
+ if err := json.Unmarshal(raw, &name); err != nil {
+ return corrupt("sandbox name", err)
+ }
+ if name.ID != record.ID.String() {
+ return corrupt("sandbox name", errors.New("name binding points to another sandbox"))
+ }
+ if err := writer.Delete(ctx, CollectionNames, record.Config.Name); err != nil {
+ return err
+ }
+ return writer.Delete(ctx, CollectionSandboxes, record.ID.String())
+}
+
+// encode maps the domain aggregate to stable adapter-owned storage fields.
+func encode(record types.Sandbox) recordData {
+ data := recordData{
+ ID: record.ID.String(), Name: record.Config.Name, CPUs: record.Config.CPUs,
+ Memory: record.Config.Memory, Storage: record.Config.Storage, NICs: record.Config.NICs,
+ NetworkName: record.Config.NetworkName,
+ ImageDigest: record.ImageDigest.String(), VMM: string(record.VMM), State: string(record.State),
+ Generation: record.Generation, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
+ }
+ if record.Network.Backend != "" {
+ data.Network = encodeNetwork(record.Network)
+ }
+ if record.Failure != nil {
+ data.Failure = &failureData{Phase: record.Failure.Phase, Message: record.Failure.Message}
+ }
+ return data
+}
+
+// decode validates persisted JSON before exposing it to application consumers.
+func decode(raw []byte) (types.Sandbox, error) {
+ var data recordData
+ if err := json.Unmarshal(raw, &data); err != nil {
+ return types.Sandbox{}, corrupt("sandbox", err)
+ }
+ id, err := types.ParseSandboxID(data.ID)
+ if err != nil {
+ return types.Sandbox{}, corrupt("sandbox ID", err)
+ }
+ digest, err := types.ParseDigest(data.ImageDigest)
+ if err != nil {
+ return types.Sandbox{}, corrupt("sandbox image", err)
+ }
+ if data.VMM == "" {
+ data.VMM = string(types.VMMCloudHypervisor)
+ }
+ record := types.Sandbox{
+ ID: id, Config: types.SandboxConfig{
+ Name: data.Name, CPUs: data.CPUs, Memory: data.Memory, Storage: data.Storage,
+ NICs: data.NICs, NetworkName: data.NetworkName,
+ },
+ ImageDigest: digest, VMM: types.VMMType(data.VMM), State: types.SandboxState(data.State), Generation: data.Generation,
+ CreatedAt: data.CreatedAt, UpdatedAt: data.UpdatedAt,
+ }
+ if data.Network != nil {
+ record.Network = decodeNetwork(*data.Network)
+ }
+ if data.Failure != nil {
+ record.Failure = &types.SandboxFailure{Phase: data.Failure.Phase, Message: data.Failure.Message}
+ }
+ if err := record.Validate(); err != nil {
+ return types.Sandbox{}, corrupt("sandbox", err)
+ }
+ return record, nil
+}
+
+func encodeNetwork(setup types.NetworkSetup) *networkData {
+ result := &networkData{
+ Backend: string(setup.Backend), Namespace: setup.Namespace,
+ Interfaces: make([]networkInterfaceData, 0, len(setup.Interfaces)),
+ }
+ for _, networkInterface := range setup.Interfaces {
+ data := networkInterfaceData{
+ Index: networkInterface.Index, Name: networkInterface.Name, TAP: networkInterface.TAP,
+ MAC: networkInterface.MAC, Queues: networkInterface.Queues, QueueSize: networkInterface.QueueSize,
+ Network: networkInterface.Network,
+ }
+ if networkInterface.IPv4 != nil {
+ data.IPv4 = &ipv4Data{
+ Address: networkInterface.IPv4.Address, Gateway: networkInterface.IPv4.Gateway,
+ Prefix: networkInterface.IPv4.Prefix,
+ }
+ }
+ result.Interfaces = append(result.Interfaces, data)
+ }
+ return result
+}
+
+func decodeNetwork(data networkData) types.NetworkSetup {
+ result := types.NetworkSetup{
+ Backend: types.NetworkBackend(data.Backend), Namespace: data.Namespace,
+ Interfaces: make([]types.NetworkInterface, 0, len(data.Interfaces)),
+ }
+ for _, item := range data.Interfaces {
+ networkInterface := types.NetworkInterface{
+ Index: item.Index, Name: item.Name, TAP: item.TAP, MAC: item.MAC,
+ Queues: item.Queues, QueueSize: item.QueueSize, Network: item.Network,
+ }
+ if item.IPv4 != nil {
+ networkInterface.IPv4 = &types.IPv4Config{
+ Address: item.IPv4.Address, Gateway: item.IPv4.Gateway, Prefix: item.IPv4.Prefix,
+ }
+ }
+ result.Interfaces = append(result.Interfaces, networkInterface)
+ }
+ return result
+}
+
+// putJSON keeps all record writes consistently encoded.
+func putJSON(ctx context.Context, writer metadata.Writer, collection metadata.Collection, key string, value any) error {
+ raw, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ return writer.Put(ctx, collection, key, raw)
+}
+
+// corrupt classifies malformed persisted data independently of caller operations.
+func corrupt(entity string, cause error) error {
+ return errdefs.Context(errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, cause), "read sandbox metadata", entity, "decode", "restore metadata from a trusted backup", false)
+}
diff --git a/sandbox/catalog/store_test.go b/sandbox/catalog/store_test.go
new file mode 100644
index 0000000..078e047
--- /dev/null
+++ b/sandbox/catalog/store_test.go
@@ -0,0 +1,320 @@
+package catalog
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/images"
+ imagecatalog "github.com/kumabox/kumabox/images/catalog"
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestDecodeLegacySandboxDefaultsCloudHypervisor(t *testing.T) {
+ created := time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC)
+ raw, err := json.Marshal(recordData{
+ ID: "123e4567-e89b-42d3-a456-426614174000",
+ Name: "legacy",
+ CPUs: 1,
+ Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage,
+ ImageDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ State: string(types.SandboxStateCreated),
+ Generation: 2,
+ CreatedAt: created,
+ UpdatedAt: created,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ record, err := decode(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.VMM != types.VMMCloudHypervisor {
+ t.Fatalf("legacy VMM = %q, want %q", record.VMM, types.VMMCloudHypervisor)
+ }
+}
+
+func TestResolveRejectsDanglingNameBinding(t *testing.T) {
+ store, err := metadata.NewMemory(Collections())
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := types.SandboxID("123e4567-e89b-42d3-a456-426614174000")
+ raw, err := json.Marshal(nameData{ID: id.String()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Update(t.Context(), func(writer metadata.Writer) error {
+ return writer.Put(t.Context(), CollectionNames, "dangling", raw)
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := New(store, imagecatalog.Reader{}).Resolve(t.Context(), "dangling"); err == nil {
+ t.Fatal("resolved a name whose sandbox record is missing")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeArtifactCorrupt {
+ t.Fatalf("Resolve error code = %q, %v; want %q", code, err, errdefs.CodeArtifactCorrupt)
+ }
+}
+
+func TestMarkCreatedAtomicallyPublishesResolvedNetwork(t *testing.T) {
+ store, err := metadata.NewMemory(Collections())
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := types.SandboxID("123e4567-e89b-42d3-a456-426614174000")
+ created := time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC)
+ record := types.Sandbox{
+ ID: id,
+ Config: types.SandboxConfig{
+ Name: "box", CPUs: 2, Memory: types.DefaultSandboxMemory,
+ Storage: types.DefaultSandboxStorage, NICs: 1,
+ },
+ ImageDigest: testDigest(t, 'a'), VMM: types.VMMCloudHypervisor,
+ State: types.SandboxStateCreating, Generation: 1, CreatedAt: created, UpdatedAt: created,
+ }
+ if err := store.Update(t.Context(), func(writer metadata.Writer) error {
+ return putJSON(t.Context(), writer, CollectionSandboxes, id.String(), encode(record))
+ }); err != nil {
+ t.Fatal(err)
+ }
+ setup := types.NetworkSetup{
+ Backend: types.NetworkBackendCNI, Namespace: "/var/run/netns/kumabox-test",
+ Interfaces: []types.NetworkInterface{{
+ Index: 0, Name: "eth0", TAP: "tap0", MAC: "02:00:00:00:00:01",
+ Queues: 4, QueueSize: 512, Network: "bridge",
+ IPv4: &types.IPv4Config{Address: "10.42.0.2", Gateway: "10.42.0.1", Prefix: 24},
+ }},
+ }
+ createdRecord, err := New(store, nil).MarkCreated(t.Context(), id, 1, setup, created.Add(time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if createdRecord.State != types.SandboxStateCreated || createdRecord.Generation != 2 ||
+ createdRecord.Config.NetworkName != "bridge" || createdRecord.Network.Namespace != setup.Namespace ||
+ len(createdRecord.Network.Interfaces) != 1 || createdRecord.Network.Interfaces[0].IPv4 == nil {
+ t.Fatalf("created network record = %+v", createdRecord)
+ }
+ resolved, err := New(store, nil).Resolve(t.Context(), id.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resolved.Config.NetworkName != "bridge" || resolved.Network.Interfaces[0].IPv4.Address != "10.42.0.2" {
+ t.Fatalf("persisted network record = %+v", resolved)
+ }
+}
+
+func TestListReturnsValidatedRecordsNewestFirst(t *testing.T) {
+ store, err := metadata.NewMemory(Collections())
+ if err != nil {
+ t.Fatal(err)
+ }
+ created := time.Date(2026, 9, 16, 10, 0, 0, 0, time.UTC)
+ digest := testDigest(t, 'a')
+ older := types.Sandbox{
+ ID: types.SandboxID("123e4567-e89b-42d3-a456-426614174000"),
+ Config: types.SandboxConfig{Name: "older", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ ImageDigest: digest, VMM: types.VMMCloudHypervisor, State: types.SandboxStateCreated, Generation: 2,
+ CreatedAt: created, UpdatedAt: created,
+ }
+ newer := older
+ newer.ID = types.SandboxID("223e4567-e89b-42d3-a456-426614174000")
+ newer.Config.Name = "newer"
+ newer.CreatedAt, newer.UpdatedAt = created.Add(time.Minute), created.Add(time.Minute)
+ if err := store.Update(t.Context(), func(writer metadata.Writer) error {
+ if err := putJSON(t.Context(), writer, CollectionSandboxes, older.ID.String(), encode(older)); err != nil {
+ return err
+ }
+ return putJSON(t.Context(), writer, CollectionSandboxes, newer.ID.String(), encode(newer))
+ }); err != nil {
+ t.Fatal(err)
+ }
+ records, err := New(store, nil).List(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(records) != 2 || records[0].ID != newer.ID || records[1].ID != older.ID {
+ t.Fatalf("List = %+v", records)
+ }
+ if err := store.Update(t.Context(), func(writer metadata.Writer) error {
+ return putJSON(t.Context(), writer, CollectionSandboxes, "323e4567-e89b-42d3-a456-426614174000", encode(older))
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := New(store, nil).List(t.Context()); err == nil {
+ t.Fatal("List accepted a record whose key differs from its ID")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeArtifactCorrupt {
+ t.Fatalf("List error code = %q, %v; want %q", code, err, errdefs.CodeArtifactCorrupt)
+ }
+}
+
+func TestReservationPinsImageInsideRemovalTransaction(t *testing.T) {
+ collections := append(imagecatalog.Collections(), Collections()...)
+ store, err := metadata.NewMemory(collections)
+ if err != nil {
+ t.Fatal(err)
+ }
+ imageStore := imagecatalog.New(store, imagecatalog.WithImageUsage(Usage{}))
+ sandboxStore := New(store, imagecatalog.Reader{})
+ manifest := testDigest(t, 'a')
+ layerDigest := testDigest(t, 'b')
+ erofsDigest := testDigest(t, 'c')
+ kernelDigest := testDigest(t, 'd')
+ initrdDigest := testDigest(t, 'e')
+ layer := types.Layer{
+ SourceDigest: layerDigest, EROFSDigest: erofsDigest, Size: 4096,
+ BootFiles: []types.BootFile{
+ {Name: "vmlinuz", Digest: kernelDigest, Size: 10},
+ {Name: "initrd.img", Digest: initrdDigest, Size: 20},
+ },
+ }
+ boot, err := images.SelectBoot([]types.Layer{layer})
+ if err != nil {
+ t.Fatal(err)
+ }
+ created := time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC)
+ if err := imageStore.CommitImport(t.Context(), images.ImportCommit{
+ Name: "demo", Manifest: types.Manifest{Digest: manifest, Platform: types.Platform{OS: "linux", Architecture: "amd64"}, Layers: []types.Descriptor{{Digest: layerDigest, Size: 100}}},
+ Layers: []types.Layer{layer}, Boot: boot, Size: layer.Size, Created: created,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ id := types.SandboxID("123e4567-e89b-42d3-a456-426614174000")
+ record := types.Sandbox{
+ ID: id, Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ ImageDigest: manifest, VMM: types.VMMCloudHypervisor, State: types.SandboxStateCreating, Generation: 1, CreatedAt: created, UpdatedAt: created,
+ }
+ if err := sandboxStore.Reserve(t.Context(), "demo", manifest, record); err != nil {
+ t.Fatal(err)
+ }
+ other := record
+ other.ID = types.SandboxID("223e4567-e89b-42d3-a456-426614174000")
+ if err := sandboxStore.Reserve(t.Context(), "demo", manifest, other); err == nil {
+ t.Fatal("reserved a duplicate sandbox name")
+ } else if code, _ := errdefs.CodeOf(err); code != errdefs.CodeNameTaken {
+ t.Fatalf("duplicate name error = %v", err)
+ }
+ if _, err := imageStore.Remove(t.Context(), "demo", manifest); err == nil {
+ t.Fatal("removed an image pinned by a sandbox")
+ } else if code, _ := errdefs.CodeOf(err); code != errdefs.CodeReferenced {
+ t.Fatalf("Remove error = %v", err)
+ }
+ if _, err := imageStore.Resolve(t.Context(), "demo"); err != nil {
+ t.Fatalf("referenced image removal did not roll back: %v", err)
+ }
+ createdRecord, err := sandboxStore.MarkCreated(t.Context(), id, 1, types.NetworkSetup{}, created.Add(time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if createdRecord.State != types.SandboxStateCreated || createdRecord.Generation != 2 {
+ t.Fatalf("created record = %+v", createdRecord)
+ }
+ if _, err := sandboxStore.MarkCreated(t.Context(), id, 1, types.NetworkSetup{}, created.Add(2*time.Second)); err == nil {
+ t.Fatal("stale generation transition succeeded")
+ } else if code, _ := errdefs.CodeOf(err); code != errdefs.CodeStateConflict {
+ t.Fatalf("stale transition error = %v", err)
+ }
+ for _, reference := range []string{"box", id.String()} {
+ resolved, err := sandboxStore.Resolve(t.Context(), reference)
+ if err != nil {
+ t.Fatalf("Resolve %q: %v", reference, err)
+ }
+ if resolved.ID != id || resolved.State != types.SandboxStateCreated {
+ t.Fatalf("Resolve %q = %+v", reference, resolved)
+ }
+ }
+ starting, err := sandboxStore.BeginStart(t.Context(), id, createdRecord.Generation, created.Add(3*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if starting.State != types.SandboxStateStarting || starting.Generation != 3 {
+ t.Fatalf("starting record = %+v", starting)
+ }
+ resumedStart, err := sandboxStore.BeginStart(t.Context(), id, starting.Generation, created.Add(4*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resumedStart.Generation != starting.Generation || !resumedStart.UpdatedAt.Equal(starting.UpdatedAt) {
+ t.Fatalf("resumed start changed record: before=%+v after=%+v", starting, resumedStart)
+ }
+ running, err := sandboxStore.MarkRunning(t.Context(), id, starting.Generation, created.Add(5*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ stopping, err := sandboxStore.BeginStop(t.Context(), id, running.Generation, created.Add(6*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stopping.State != types.SandboxStateStopping || stopping.Generation != 5 {
+ t.Fatalf("stopping record = %+v", stopping)
+ }
+ resumedStop, err := sandboxStore.BeginStop(t.Context(), id, stopping.Generation, created.Add(7*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resumedStop.Generation != stopping.Generation || !resumedStop.UpdatedAt.Equal(stopping.UpdatedAt) {
+ t.Fatalf("resumed stop changed record: before=%+v after=%+v", stopping, resumedStop)
+ }
+ stopped, err := sandboxStore.MarkStopped(t.Context(), id, stopping.Generation, types.SandboxStateStopping, created.Add(8*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ restarting, err := sandboxStore.BeginStart(t.Context(), id, stopped.Generation, created.Add(9*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ failed, err := sandboxStore.MarkStartError(t.Context(), id, restarting.Generation, types.SandboxFailure{Phase: "launch VMM", Message: "exited"}, created.Add(10*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failed.State != types.SandboxStateError || failed.Generation != 8 || failed.Failure == nil {
+ t.Fatalf("failed start record = %+v", failed)
+ }
+ deleting, err := sandboxStore.BeginDelete(t.Context(), id, failed.Generation, created.Add(11*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if deleting.State != types.SandboxStateDeleting || deleting.Generation != 9 {
+ t.Fatalf("deleting record = %+v", deleting)
+ }
+ resumed, err := sandboxStore.BeginDelete(t.Context(), id, deleting.Generation, created.Add(12*time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resumed.Generation != deleting.Generation || !resumed.UpdatedAt.Equal(deleting.UpdatedAt) {
+ t.Fatalf("resumed deletion changed record: before=%+v after=%+v", deleting, resumed)
+ }
+ if _, err := imageStore.Remove(t.Context(), "demo", manifest); err == nil {
+ t.Fatal("removed image before sandbox deletion finalized")
+ } else if code, _ := errdefs.CodeOf(err); code != errdefs.CodeReferenced {
+ t.Fatalf("referenced deleting image error = %v", err)
+ }
+ if err := sandboxStore.FinalizeDelete(t.Context(), id, deleting.Generation); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := sandboxStore.Resolve(t.Context(), "box"); err == nil {
+ t.Fatal("resolved finalized sandbox")
+ } else if code, _ := errdefs.CodeOf(err); code != errdefs.CodeNotFound {
+ t.Fatalf("finalized sandbox error = %v", err)
+ }
+ if _, err := imageStore.Remove(t.Context(), "demo", manifest); err != nil {
+ t.Fatalf("remove image after sandbox finalization: %v", err)
+ }
+}
+
+func testDigest(t *testing.T, char byte) types.Digest {
+ t.Helper()
+ value := make([]byte, 71)
+ copy(value, "sha256:")
+ for index := 7; index < len(value); index++ {
+ value[index] = char
+ }
+ digest, err := types.ParseDigest(string(value))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return digest
+}
diff --git a/sandbox/paths.go b/sandbox/paths.go
new file mode 100644
index 0000000..5f25bf6
--- /dev/null
+++ b/sandbox/paths.go
@@ -0,0 +1,67 @@
+// Package sandbox defines filesystem ownership and paths for sandbox resources.
+// Shared sandbox data contracts live in types; application workflows live in core.
+package sandbox
+
+import (
+ "path/filepath"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+// Paths derives persistent sandbox disks and stable operation locks from shared roots.
+type Paths struct {
+ // roots was validated at construction so every derived path shares one boundary.
+ roots storage.Roots
+}
+
+// NewPaths validates roots without creating any directories.
+func NewPaths(roots storage.Roots) (Paths, error) {
+ validated, err := roots.Validate()
+ if err != nil {
+ return Paths{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ return Paths{roots: validated}, nil
+}
+
+// Ensure creates the persistent sandbox base and stable lock directory.
+func (p Paths) Ensure() error {
+ for _, path := range []string{p.DataDir(), p.LocksDir()} {
+ if err := storage.EnsureDir(path); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// DataDir contains one persistent directory per sandbox ID.
+func (p Paths) DataDir() string { return filepath.Join(p.roots.Data, "sandboxes") }
+
+// LocksDir contains persistent-inode advisory locks for sandbox operations.
+func (p Paths) LocksDir() string { return filepath.Join(p.roots.Run, "locks", "sandboxes") }
+
+// Dir returns a sandbox's persistent directory after validating its ID.
+func (p Paths) Dir(id types.SandboxID) (string, error) {
+ if _, err := types.ParseSandboxID(id.String()); err != nil {
+ return "", err
+ }
+ return storage.Join(p.DataDir(), id.String())
+}
+
+// COW returns the sandbox's private sparse ext4 disk path.
+func (p Paths) COW(id types.SandboxID) (string, error) {
+ dir, err := p.Dir(id)
+ if err != nil {
+ return "", err
+ }
+ return storage.Join(dir, "cow.raw")
+}
+
+// Lock returns the stable operation lock path for an ID.
+func (p Paths) Lock(id types.SandboxID) (string, error) {
+ if _, err := types.ParseSandboxID(id.String()); err != nil {
+ return "", err
+ }
+ return storage.Join(p.LocksDir(), id.String()+".lock")
+}
diff --git a/sandbox/paths_test.go b/sandbox/paths_test.go
new file mode 100644
index 0000000..739ce2e
--- /dev/null
+++ b/sandbox/paths_test.go
@@ -0,0 +1,33 @@
+package sandbox
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestManagedPaths(t *testing.T) {
+ base := t.TempDir()
+ paths, err := NewPaths(storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := paths.Ensure(); err != nil {
+ t.Fatal(err)
+ }
+ id := types.SandboxID("123e4567-e89b-42d3-a456-426614174000")
+ cow, err := paths.COW(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := filepath.Join(paths.DataDir(), id.String(), "cow.raw"); cow != want {
+ t.Fatalf("COW() = %q, want %q", cow, want)
+ }
+ if _, err := paths.COW(types.SandboxID("../escape")); err == nil {
+ t.Fatal("unsafe ID produced a managed path")
+ }
+}
diff --git a/scripts/check.sh b/scripts/check.sh
deleted file mode 100755
index df8ca51..0000000
--- a/scripts/check.sh
+++ /dev/null
@@ -1,310 +0,0 @@
-#!/usr/bin/env bash
-set -Eeuo pipefail
-
-# Standalone host setup and verification for released KumaBox binaries.
-root_dir=${KUMABOX_ROOT_DIR:-/var/lib/kumabox}
-run_dir=${KUMABOX_RUN_DIR:-/var/lib/kumabox/run}
-log_dir=${KUMABOX_LOG_DIR:-/var/log/kumabox}
-cni_bin_dir=${KUMABOX_CNI_BIN_DIR:-/opt/cni/bin}
-cni_config_dir=${KUMABOX_CNI_CONFIG_DIR:-/etc/cni/net.d}
-cloud_hypervisor_version=${KUMABOX_CLOUD_HYPERVISOR_VERSION:-v51.1}
-firmware_version=${KUMABOX_FIRMWARE_VERSION:-0.5.0}
-cni_version=${KUMABOX_CNI_VERSION:-v1.9.0}
-erofs_version=${KUMABOX_EROFS_VERSION:-v1.8.10}
-network_name=${KUMABOX_NETWORK_NAME:-kumabox}
-subnet=${KUMABOX_NETWORK_SUBNET:-10.88.0.0/16}
-metadata_backend=${KUMABOX_METADATA_BACKEND:-json}
-firmware_path=${KUMABOX_FIRMWARE_PATH:-$root_dir/firmware/CLOUDHV.fd}
-upgrade=false
-fix=false
-
-usage() {
- cat <&2; exit 2; }; subnet=$2; shift 2 ;;
- --subnet=*) subnet=${1#*=}; shift ;;
- --metadata-backend) [[ $# -ge 2 ]] || { echo "--metadata-backend requires a value" >&2; exit 2; }; metadata_backend=$2; shift 2 ;;
- --metadata-backend=*) metadata_backend=${1#*=}; shift ;;
- -h|--help) usage; exit 0 ;;
- *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
- esac
-done
-
-[[ $metadata_backend == json || $metadata_backend == sqlite ]] || {
- echo "--metadata-backend must be json or sqlite" >&2
- exit 2
-}
-
-pass_count=0
-warn_count=0
-fail_count=0
-pass() { pass_count=$((pass_count + 1)); printf ' [PASS] %s\n' "$1"; }
-warn() { warn_count=$((warn_count + 1)); printf ' [WARN] %s\n' "$1"; }
-fail() { fail_count=$((fail_count + 1)); printf ' [FAIL] %s\n' "$1"; }
-fixed() { printf ' [FIXED] %s\n' "$1"; }
-info() { printf ' [INFO] %s\n' "$1"; }
-section() { printf '\n==> %s\n' "$1"; }
-exists() { command -v "$1" >/dev/null 2>&1; }
-
-require_root() {
- if [[ $(id -u) -ne 0 ]]; then
- echo "$1 requires root; run through sudo" >&2
- exit 1
- fi
-}
-
-arch=$(uname -m)
-case "$arch" in
- x86_64) go_arch=amd64; ch_suffix=; firmware_suffix= ;;
- aarch64|arm64) go_arch=arm64; ch_suffix=-aarch64; firmware_suffix=-aarch64 ;;
- *) go_arch=; ch_suffix=; firmware_suffix= ;;
-esac
-
-install_packages() {
- section "Host packages"
- if exists apt-get; then
- apt-get update
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
- build-essential autoconf automake ca-certificates curl git iproute2 \
- e2fsprogs iptables jq liblz4-dev liblzma-dev libtool libuuid1 libuuid-dev \
- libzstd-dev nftables pkg-config qemu-utils tar xz-utils zlib1g-dev
- elif exists dnf; then
- dnf install -y autoconf automake ca-certificates curl e2fsprogs gcc git iproute \
- iptables jq libtool libuuid-devel libzstd-devel lz4-devel make \
- nftables pkgconf-pkg-config qemu-img tar xz-devel zlib-devel
- else
- echo "automatic installation currently supports apt-get and dnf" >&2
- exit 1
- fi
- fixed "host packages installed"
-}
-
-download_asset() {
- local url=$1 destination=$2 mode=$3 temporary
- temporary=$(mktemp)
- curl -fsSL --retry 3 -o "$temporary" "$url"
- install -d -m 0755 "$(dirname "$destination")"
- install -m "$mode" "$temporary" "$destination"
- rm -f "$temporary"
-}
-
-erofs_version_ok() {
- local version
- version=$(mkfs.erofs --version 2>&1 | sed -n 's/.*[Vv]\?\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1)
- [[ -n "$version" ]] || return 1
- local major=${version%%.*} minor=${version#*.}
- ((major > 1 || (major == 1 && minor >= 8)))
-}
-
-install_erofs() {
- if exists mkfs.erofs && erofs_version_ok; then
- fixed "mkfs.erofs is already 1.8 or newer"
- return
- fi
- section "erofs-utils ${erofs_version}"
- local temporary source
- temporary=$(mktemp -d)
- curl -fsSL --retry 3 -o "$temporary/erofs.tar.gz" \
- "https://github.com/erofs/erofs-utils/archive/refs/tags/${erofs_version}.tar.gz"
- tar -xzf "$temporary/erofs.tar.gz" -C "$temporary"
- source=$(find "$temporary" -mindepth 1 -maxdepth 1 -type d -name 'erofs-utils-*' | head -n 1)
- [[ -n "$source" ]] || { echo "erofs-utils source archive is invalid" >&2; exit 1; }
- (
- cd "$source"
- ./autogen.sh
- ./configure --prefix=/usr/local
- make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)"
- make install
- )
- erofs_version_ok || { echo "installed mkfs.erofs is older than 1.8" >&2; exit 1; }
- rm -rf "$temporary"
- fixed "erofs-utils ${erofs_version} installed"
-}
-
-install_dependencies() {
- require_root "--upgrade"
- [[ $(uname -s) == Linux && -n "$go_arch" ]] || { echo "unsupported host: $(uname -s)/$arch" >&2; exit 1; }
- install_packages
- section "Cloud Hypervisor ${cloud_hypervisor_version}"
- download_asset \
- "https://github.com/cloud-hypervisor/cloud-hypervisor/releases/download/${cloud_hypervisor_version}/cloud-hypervisor-static${ch_suffix}" \
- /usr/local/bin/cloud-hypervisor 0755
- fixed "cloud-hypervisor installed"
- section "hypervisor firmware ${firmware_version}"
- download_asset \
- "https://github.com/cloud-hypervisor/rust-hypervisor-firmware/releases/download/${firmware_version}/hypervisor-fw${firmware_suffix}" \
- "$firmware_path" 0644
- fixed "firmware installed"
- section "CNI plugins ${cni_version}"
- local temporary
- temporary=$(mktemp -d)
- curl -fsSL --retry 3 -o "$temporary/cni.tgz" \
- "https://github.com/containernetworking/plugins/releases/download/${cni_version}/cni-plugins-linux-${go_arch}-${cni_version}.tgz"
- install -d -m 0755 "$cni_bin_dir"
- tar -xzf "$temporary/cni.tgz" -C "$temporary"
- for plugin in bridge host-local loopback; do
- install -m 0755 "$temporary/$plugin" "$cni_bin_dir/$plugin"
- done
- rm -rf "$temporary"
- fixed "CNI plugins installed"
- install_erofs
-}
-
-gateway_for_subnet() {
- local address=${subnet%/*}
- local prefix=${subnet#*/}
- local first second third fourth
- IFS=. read -r first second third fourth <<< "$address"
- [[ $address != "$subnet" && $prefix =~ ^[0-9]+$ && $prefix -ge 16 && $prefix -le 24 ]] || {
- echo "KUMABOX_NETWORK_SUBNET must be an IPv4 /16 through /24 CIDR" >&2
- exit 1
- }
- for octet in "$first" "$second" "$third" "$fourth"; do
- [[ $octet =~ ^[0-9]+$ && $octet -le 255 ]] || {
- echo "invalid IPv4 subnet: $subnet" >&2
- exit 1
- }
- done
- printf '%s.%s.%s.1' "$first" "$second" "$third"
-}
-
-configure_host() {
- require_root "--fix"
- install -d -m 0750 "$root_dir" "$run_dir" "$root_dir/metadata"
- install -d -m 0755 "$log_dir" "$cni_config_dir" /var/run/netns
- cat > /etc/sysctl.d/99-kumabox.conf <<'EOF'
-net.ipv4.ip_forward = 1
-net.bridge.bridge-nf-call-iptables = 1
-EOF
- modprobe br_netfilter 2>/dev/null || true
- sysctl --system >/dev/null
- local gateway host_iface host_mtu config_path
- gateway=$(gateway_for_subnet)
- host_iface=$(ip route show default | awk '/default/{print $5; exit}')
- host_mtu=$(ip -o link show "$host_iface" 2>/dev/null | sed -n 's/.* mtu \([0-9][0-9]*\).*/\1/p')
- host_mtu=${host_mtu:-1500}
- config_path="$cni_config_dir/10-kumabox.conflist"
- if [[ ! -e "$config_path" ]]; then
- cat > "$config_path" </dev/null || iptables -A FORWARD -i kbcni0 -j ACCEPT
- iptables -C FORWARD -o kbcni0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \
- iptables -A FORWARD -o kbcni0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
- iptables -t mangle -C FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || \
- iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
- fi
- fixed "KumaBox directories, sysctl, and CNI configuration are ready"
-}
-
-if $upgrade; then install_dependencies; fi
-if $fix; then configure_host; fi
-
-section "Host"
-if [[ $(uname -s) == Linux ]]; then pass "Linux host"; else fail "KumaBox requires Linux"; fi
-if [[ -n "$go_arch" ]]; then pass "supported architecture: $arch"; else fail "unsupported architecture: $arch"; fi
-if [[ -r /dev/kvm && -w /dev/kvm ]]; then pass "/dev/kvm is accessible"; else fail "/dev/kvm is missing or inaccessible"; fi
-if [[ -e /dev/net/tun ]]; then pass "/dev/net/tun exists"; else fail "/dev/net/tun is missing"; fi
-
-section "Binaries"
-for binary in cloud-hypervisor qemu-img mkfs.ext4 ip jq; do
- if exists "$binary"; then pass "$binary: $(command -v "$binary")"; else fail "$binary is missing"; fi
-done
-if exists mkfs.erofs && erofs_version_ok; then pass "mkfs.erofs 1.8+"; else fail "mkfs.erofs 1.8+ is required"; fi
-
-section "Firmware"
-if [[ -s $firmware_path ]]; then
- pass "CLOUDHV.fd: $firmware_path"
-else
- fail "CLOUDHV.fd is missing or empty: $firmware_path"
-fi
-
-section "CNI"
-for plugin in bridge host-local loopback; do
- if [[ -x "$cni_bin_dir/$plugin" ]]; then pass "$plugin plugin"; else fail "$plugin plugin is missing"; fi
-done
-if [[ -f "$cni_config_dir/10-kumabox.conflist" ]]; then
- configured_network=$(jq -r '.name // empty' "$cni_config_dir/10-kumabox.conflist" 2>/dev/null || true)
- if [[ $configured_network == "$network_name" ]]; then
- pass "cni:${network_name} configuration"
- else
- fail "10-kumabox.conflist name is ${configured_network:-invalid}; expected ${network_name}"
- fi
-else
- fail "KumaBox CNI conflist is missing"
-fi
-if [[ $(sysctl -n net.ipv4.ip_forward 2>/dev/null || true) == 1 ]]; then pass "IPv4 forwarding"; else fail "IPv4 forwarding is disabled"; fi
-if [[ $(sysctl -n net.bridge.bridge-nf-call-iptables 2>/dev/null || true) == 1 ]]; then pass "bridge netfilter"; else fail "bridge netfilter is disabled"; fi
-
-section "CNI forwarding"
-if exists iptables; then
- if iptables -C FORWARD -i kbcni0 -j ACCEPT 2>/dev/null; then pass "inbound bridge forwarding"; else fail "inbound bridge forwarding rule is missing"; fi
- if iptables -C FORWARD -o kbcni0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; then pass "return bridge forwarding"; else fail "return bridge forwarding rule is missing"; fi
- if iptables -t mangle -C FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null; then pass "TCP MSS path-MTU clamp"; else fail "TCP MSS path-MTU clamp is missing"; fi
-else
- fail "iptables is required by the generated CNI bridge configuration"
-fi
-
-section "Directories"
-for directory in "$root_dir" "$run_dir" "$log_dir"; do
- if [[ -d "$directory" ]]; then pass "$directory"; else fail "$directory is missing"; fi
-done
-
-if [[ $metadata_backend == sqlite && -d $root_dir ]]; then
- metadata_fs=$(stat -f -c %T "$root_dir" 2>/dev/null || echo unknown)
- case "$metadata_fs" in
- nfs*|cifs|smb*|fuse*) fail "SQLite WAL metadata is unsafe on $metadata_fs: $root_dir" ;;
- *) pass "SQLite metadata filesystem: $metadata_fs" ;;
- esac
-fi
-
-printf '\nSummary: pass=%d warn=%d fail=%d\n' "$pass_count" "$warn_count" "$fail_count"
-if ((fail_count > 0)); then
- $fix || info "run: sudo kumabox-check --upgrade"
- exit 1
-fi
diff --git a/scripts/install.sh b/scripts/install.sh
deleted file mode 100755
index 49014bc..0000000
--- a/scripts/install.sh
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/usr/bin/env sh
-set -eu
-
-repository=${KUMABOX_REPOSITORY:-kgpp34/KumaBox}
-version=${KUMABOX_VERSION:-latest}
-install_dir=${KUMABOX_INSTALL_DIR:-/usr/local/bin}
-github_api_url=${KUMABOX_GITHUB_API_URL:-https://api.github.com}
-release_base_url=${KUMABOX_RELEASE_BASE_URL:-}
-
-usage() {
- cat <<'EOF'
-Usage: install.sh [--version VERSION] [--install-dir DIR]
-
-Download a KumaBox GitHub Release, verify its SHA256 checksum, and install
-kumabox and kumabox-check. The script supports Linux amd64 and arm64 hosts.
-
-Environment overrides:
- KUMABOX_REPOSITORY GitHub owner/repository (default: kgpp34/KumaBox)
- KUMABOX_VERSION release tag or latest
- KUMABOX_INSTALL_DIR destination directory (default: /usr/local/bin)
- KUMABOX_GITHUB_API_URL GitHub-compatible API endpoint
- KUMABOX_RELEASE_BASE_URL release directory URL for mirrors and air gaps
-EOF
-}
-
-while [ "$#" -gt 0 ]; do
- case "$1" in
- --version) [ "$#" -ge 2 ] || { echo "--version requires a value" >&2; exit 2; }; version=$2; shift 2 ;;
- --install-dir) [ "$#" -ge 2 ] || { echo "--install-dir requires a value" >&2; exit 2; }; install_dir=$2; shift 2 ;;
- -h|--help) usage; exit 0 ;;
- *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
- esac
-done
-
-[ "$(uname -s)" = Linux ] || { echo "KumaBox requires Linux" >&2; exit 1; }
-case "$(uname -m)" in
- x86_64) arch=amd64 ;;
- aarch64|arm64) arch=arm64 ;;
- *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;;
-esac
-
-for command_name in curl install tar; do
- command -v "$command_name" >/dev/null 2>&1 || { echo "$command_name is required" >&2; exit 1; }
-done
-if command -v sha256sum >/dev/null 2>&1; then
- sha256_file() { sha256sum "$1" | awk '{print $1}'; }
-elif command -v shasum >/dev/null 2>&1; then
- sha256_file() { shasum -a 256 "$1" | awk '{print $1}'; }
-else
- echo "sha256sum or shasum is required" >&2
- exit 1
-fi
-
-if [ "$version" = latest ]; then
- version=$(curl -fsSL --retry 3 "${github_api_url%/}/repos/${repository}/releases/latest" |
- sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
- [ -n "$version" ] || { echo "cannot resolve latest KumaBox release" >&2; exit 1; }
-fi
-
-archive="kumabox-${version}-linux-${arch}.tar.gz"
-if [ -n "$release_base_url" ]; then
- base_url=${release_base_url%/}
-else
- base_url="https://github.com/${repository}/releases/download/${version}"
-fi
-temporary_dir=$(mktemp -d)
-trap 'rm -rf "$temporary_dir"' EXIT HUP INT TERM
-
-echo "Downloading KumaBox ${version} for linux/${arch}"
-curl -fsSL --retry 3 -o "$temporary_dir/$archive" "$base_url/$archive"
-curl -fsSL --retry 3 -o "$temporary_dir/$archive.sha256" "$base_url/$archive.sha256"
-
-expected=$(awk '{print $1; exit}' "$temporary_dir/$archive.sha256")
-actual=$(sha256_file "$temporary_dir/$archive")
-[ -n "$expected" ] && [ "$actual" = "$expected" ] || {
- echo "checksum verification failed for $archive" >&2
- exit 1
-}
-
-mkdir -p "$temporary_dir/extract"
-tar -xzf "$temporary_dir/$archive" -C "$temporary_dir/extract"
-for file in kumabox kumabox-check; do
- [ -f "$temporary_dir/extract/$file" ] || { echo "release archive is missing $file" >&2; exit 1; }
-done
-
-install -d -m 0755 "$install_dir"
-install -m 0755 "$temporary_dir/extract/kumabox" "$install_dir/kumabox"
-install -m 0755 "$temporary_dir/extract/kumabox-check" "$install_dir/kumabox-check"
-
-echo "Installed KumaBox ${version} to $install_dir"
-echo "Next: sudo $install_dir/kumabox-check --upgrade"
diff --git a/scripts/kumabox-check.sh b/scripts/kumabox-check.sh
new file mode 100755
index 0000000..e7c031d
--- /dev/null
+++ b/scripts/kumabox-check.sh
@@ -0,0 +1,436 @@
+#!/usr/bin/env bash
+# scripts/kumabox-check.sh — Pre-flight check and repair tool for KumaBox.
+#
+# Usage:
+# ./scripts/kumabox-check.sh # Check only
+# ./scripts/kumabox-check.sh --fix # Check and fix issues
+# ./scripts/kumabox-check.sh --upgrade # Check, fix, and upgrade dependencies
+
+set -uo pipefail
+
+# sudo commonly supplies a restricted PATH without the sbin directories that
+# contain sysctl, modprobe, iptables and mkfs.ext4 on Ubuntu.
+PATH="${PATH:-}:/usr/local/sbin:/usr/sbin:/sbin"
+export PATH
+
+# ---------------------------------------------------------------------------
+# Configuration (override via environment)
+# ---------------------------------------------------------------------------
+KUMABOX_ROOT_DIR="${KUMABOX_ROOT_DIR:-/var/lib/kumabox}"
+KUMABOX_RUN_DIR="${KUMABOX_RUN_DIR:-/run/kumabox}"
+KUMABOX_LOG_DIR="${KUMABOX_LOG_DIR:-/var/log/kumabox}"
+KUMABOX_CNI_CONF_DIR="${KUMABOX_CNI_CONF_DIR:-/etc/cni/net.d}"
+KUMABOX_CNI_BIN_DIR="${KUMABOX_CNI_BIN_DIR:-/opt/cni/bin}"
+
+# Dependency versions
+CH_VERSION="${CH_VERSION:-v53.0}"
+CNI_VERSION="${CNI_VERSION:-v1.9.1}"
+
+# Architecture detection
+ARCH=$(uname -m)
+case "$ARCH" in
+ x86_64) GO_ARCH="amd64"; CH_SUFFIX="" ;;
+ aarch64) GO_ARCH="arm64"; CH_SUFFIX="-aarch64" ;;
+ *) echo "Unsupported architecture: $ARCH"; exit 1 ;;
+esac
+
+# ---------------------------------------------------------------------------
+# Flags
+# ---------------------------------------------------------------------------
+FIX=false
+UPGRADE=false
+SUBNET=""
+for arg in "$@"; do
+ case "$arg" in
+ --fix) FIX=true ;;
+ --upgrade) FIX=true; UPGRADE=true ;;
+ --subnet=*) SUBNET="${arg#--subnet=}" ;;
+ -h|--help)
+ cat </dev/null | awk '/default/{print $5; exit}')
+ host_mtu=$(ip link show "$host_iface" 2>/dev/null | sed -n 's/.* mtu \([0-9]\{1,\}\).*/\1/p')
+ host_mtu=${host_mtu:-1500}
+
+ info "generating CNI conflist: subnet=${subnet} gateway=${gateway} mtu=${host_mtu}"
+ mkdir -p "$KUMABOX_CNI_CONF_DIR"
+ cat > "$CNI_CONFLIST" </dev/null; then
+ local ver=""
+ case "$name" in
+ cloud-hypervisor) ver=$("$name" --version 2>/dev/null | head -1) || true ;;
+ ch-remote) ver=$("$name" --version 2>/dev/null | head -1) || true ;;
+ mkfs.ext4) ver=$("$name" -V 2>&1 | head -1) || true ;;
+ mkfs.erofs) ver=$("$name" --version 2>&1 | head -1) || true ;;
+ esac
+ if { [ "$name" = "cloud-hypervisor" ] || [ "$name" = "ch-remote" ]; } \
+ && [ "$(binary_version "$ver")" != "${CH_VERSION#v}" ]; then
+ fail "$name (${ver:-unknown}) does not match required ${CH_VERSION}"
+ return
+ fi
+ if [ "$name" = "mkfs.erofs" ] && ! erofs_version_ok "$ver"; then
+ fail "$name (${ver:-unknown}) is older than 1.8 — tar mode silently corrupts layers; apt ships 1.7.x, install erofs-utils >= 1.8 from source"
+ return
+ fi
+ pass "${name}${ver:+ ($ver)}"
+ else
+ fail "$name not found in PATH"
+ if $FIX; then
+ local pkg
+ pkg=$(bin_to_pkg "$name")
+ if [ -n "$pkg" ] && command -v apt-get &>/dev/null; then
+ apt-get install -y "$pkg" &>/dev/null && fixed "apt-get install $pkg" || warn "failed to install $pkg"
+ if [ "$name" = "mkfs.erofs" ] && command -v mkfs.erofs &>/dev/null \
+ && ! erofs_version_ok "$(mkfs.erofs --version 2>&1 | head -1)"; then
+ warn "installed mkfs.erofs is still older than 1.8 — install erofs-utils from source"
+ fi
+ fi
+ fi
+ fi
+}
+
+check_binary cloud-hypervisor
+check_binary ch-remote
+check_binary mkfs.ext4
+check_binary mkfs.erofs
+
+# ---------------------------------------------------------------------------
+# 2. KVM access
+# ---------------------------------------------------------------------------
+header "KVM"
+
+if [ -e /dev/kvm ]; then
+ if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
+ pass "/dev/kvm accessible"
+ else
+ fail "/dev/kvm exists but not readable/writable by $(whoami)"
+ if $FIX; then
+ chmod 666 /dev/kvm 2>/dev/null && fixed "chmod 666 /dev/kvm" || warn "failed to fix (need root?)"
+ fi
+ fi
+else
+ fail "/dev/kvm not found (nested virtualization or bare-metal required)"
+fi
+
+# ---------------------------------------------------------------------------
+# 3. Managed directories
+# ---------------------------------------------------------------------------
+header "Directories"
+
+check_dir() {
+ local dir="$1"
+ if [ -d "$dir" ]; then
+ pass "$dir"
+ else
+ fail "$dir does not exist"
+ if $FIX; then
+ mkdir -p "$dir" && fixed "created $dir" || warn "failed to create $dir"
+ fi
+ fi
+}
+
+check_dir "$KUMABOX_ROOT_DIR"
+
+# SQLite WAL needs coherent shared memory. KumaBox has one metadata engine and
+# does not probe or preserve metadata owned by other runtimes.
+meta_fstype=$(stat -f -c %T "$KUMABOX_ROOT_DIR" 2>/dev/null || echo unknown)
+case "$meta_fstype" in
+ nfs*|cifs|smb*|fuse*)
+ fail "meta root on $meta_fstype: sqlite WAL needs coherent shared memory; kumabox refuses this filesystem"
+ ;;
+ *)
+ pass "meta root filesystem ($meta_fstype) supports WAL"
+ ;;
+esac
+
+check_dir "$KUMABOX_RUN_DIR"
+check_dir "$KUMABOX_LOG_DIR"
+check_dir "${KUMABOX_ROOT_DIR}/meta"
+check_dir "${KUMABOX_ROOT_DIR}/images/layers"
+check_dir "${KUMABOX_ROOT_DIR}/images/boot"
+check_dir "${KUMABOX_ROOT_DIR}/sandboxes"
+check_dir "${KUMABOX_ROOT_DIR}/snapshots"
+check_dir "${KUMABOX_ROOT_DIR}/network/cni-cache"
+check_dir "${KUMABOX_ROOT_DIR}/staging/imports"
+check_dir "${KUMABOX_ROOT_DIR}/staging/snapshots"
+check_dir "${KUMABOX_ROOT_DIR}/staging/restores"
+check_dir "${KUMABOX_RUN_DIR}/locks/images"
+check_dir "${KUMABOX_RUN_DIR}/locks/sandboxes"
+check_dir "${KUMABOX_RUN_DIR}/sandboxes"
+check_dir "${KUMABOX_LOG_DIR}/sandboxes"
+check_dir /run/netns
+
+# ---------------------------------------------------------------------------
+# 4. Sysctl
+# ---------------------------------------------------------------------------
+header "Sysctl"
+
+check_sysctl() {
+ local key="$1"
+ local expected="$2"
+ local actual
+ actual=$(sysctl -n "$key" 2>/dev/null || echo "")
+ if [ "$actual" = "$expected" ]; then
+ pass "$key = $expected"
+ else
+ fail "$key = ${actual:-} (expected $expected)"
+ if $FIX; then
+ sysctl -w "${key}=${expected}" &>/dev/null && fixed "sysctl -w ${key}=${expected}" || warn "failed to set $key"
+ fi
+ fi
+}
+
+check_sysctl net.ipv4.ip_forward 1
+
+# br_netfilter must be loaded for bridge sysctl keys to exist.
+if ! sysctl -n net.bridge.bridge-nf-call-iptables &>/dev/null; then
+ if $FIX; then
+ modprobe br_netfilter 2>/dev/null && fixed "modprobe br_netfilter" || warn "failed to load br_netfilter"
+ fi
+fi
+check_sysctl net.bridge.bridge-nf-call-iptables 1
+
+# ---------------------------------------------------------------------------
+# 5. iptables FORWARD rules for KumaBox bridge
+# ---------------------------------------------------------------------------
+header "iptables FORWARD (kumabox0)"
+
+check_iptables_rule() {
+ local desc="$1"
+ shift
+ if iptables -C "$@" 2>/dev/null; then
+ pass "$desc"
+ else
+ fail "$desc"
+ if $FIX; then
+ iptables -A "$@" 2>/dev/null && fixed "iptables -A $*" || warn "failed to add rule"
+ fi
+ fi
+}
+
+check_iptables_rule "FORWARD -i kumabox0 -j ACCEPT" \
+ FORWARD -i kumabox0 -j ACCEPT
+check_iptables_rule "FORWARD -o kumabox0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" \
+ FORWARD -o kumabox0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
+
+# Clamp TCP MSS to the path MTU: on a host whose egress MTU is below the bridge's
+# (e.g. GCP's 1460), guests otherwise blackhole large TLS/data packets that carry DF.
+mss_desc="mangle FORWARD TCPMSS clamp-mss-to-pmtu"
+if iptables -t mangle -C FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null; then
+ pass "$mss_desc"
+else
+ fail "$mss_desc"
+ if $FIX; then
+ iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null \
+ && fixed "$mss_desc" || warn "failed to add MSS clamp"
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# 6. CNI configuration
+# ---------------------------------------------------------------------------
+header "CNI configuration"
+
+CNI_CONFLIST="${KUMABOX_CNI_CONF_DIR}/10-kumabox.conflist"
+
+if [ -f "$CNI_CONFLIST" ]; then
+ pass "conflist: $(basename "$CNI_CONFLIST")"
+else
+ fail "$CNI_CONFLIST does not exist"
+ if $FIX; then
+ generate_cni_conflist
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# 7. CNI plugins
+# ---------------------------------------------------------------------------
+header "CNI plugins (${KUMABOX_CNI_BIN_DIR})"
+
+CNI_REQUIRED="bridge host-local loopback"
+
+if [ -d "$KUMABOX_CNI_BIN_DIR" ]; then
+ for plugin in $CNI_REQUIRED; do
+ if [ -x "${KUMABOX_CNI_BIN_DIR}/${plugin}" ]; then
+ pass "$plugin"
+ else
+ fail "$plugin not found"
+ fi
+ done
+else
+ fail "$KUMABOX_CNI_BIN_DIR does not exist"
+fi
+
+# ---------------------------------------------------------------------------
+# 8. Upgrade / Install
+# ---------------------------------------------------------------------------
+if $UPGRADE; then
+ tmpdir=$(mktemp -d)
+ trap 'rm -rf "$tmpdir"' EXIT
+
+ # -- cloud-hypervisor --------------------------------------------------
+ header "Install cloud-hypervisor ${CH_VERSION}"
+
+ ch_url="https://github.com/cloud-hypervisor/cloud-hypervisor/releases/download/${CH_VERSION}/cloud-hypervisor-static${CH_SUFFIX}"
+ ch_dest="/usr/local/bin/cloud-hypervisor"
+ info "downloading ${ch_url}"
+ if curl -fsSL -o "${tmpdir}/cloud-hypervisor" "$ch_url"; then
+ install -m 0755 "${tmpdir}/cloud-hypervisor" "$ch_dest"
+ # virtio-net requires CAP_NET_ADMIN for tap devices
+ setcap cap_net_admin+ep "$ch_dest" 2>/dev/null || true
+ fixed "cloud-hypervisor ${CH_VERSION} -> ${ch_dest}"
+ else
+ fail "failed to download cloud-hypervisor from ${ch_url}"
+ fi
+
+ # -- ch-remote ----------------------------------------------------------
+ header "Install ch-remote ${CH_VERSION}"
+
+ chr_url="https://github.com/cloud-hypervisor/cloud-hypervisor/releases/download/${CH_VERSION}/ch-remote-static${CH_SUFFIX}"
+ chr_dest="/usr/local/bin/ch-remote"
+ info "downloading ${chr_url}"
+ if curl -fsSL -o "${tmpdir}/ch-remote" "$chr_url"; then
+ install -m 0755 "${tmpdir}/ch-remote" "$chr_dest"
+ fixed "ch-remote ${CH_VERSION} -> ${chr_dest}"
+ else
+ fail "failed to download ch-remote from ${chr_url}"
+ fi
+
+ # -- CNI plugins --------------------------------------------------------
+ header "Install CNI plugins ${CNI_VERSION}"
+
+ cni_tarball="cni-plugins-linux-${GO_ARCH}-${CNI_VERSION}.tgz"
+ cni_url="https://github.com/containernetworking/plugins/releases/download/${CNI_VERSION}/${cni_tarball}"
+ info "downloading ${cni_url}"
+ if curl -fsSL -o "${tmpdir}/${cni_tarball}" "$cni_url"; then
+ mkdir -p "$KUMABOX_CNI_BIN_DIR"
+ tar -xzf "${tmpdir}/${cni_tarball}" -C "$KUMABOX_CNI_BIN_DIR"
+ fixed "CNI plugins ${CNI_VERSION} -> ${KUMABOX_CNI_BIN_DIR}"
+ info "installed plugins:"
+ for p in "$KUMABOX_CNI_BIN_DIR"/*; do
+ [ -x "$p" ] && info " $(basename "$p")"
+ done
+ else
+ fail "failed to download CNI plugins from ${cni_url}"
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# Summary
+# ---------------------------------------------------------------------------
+header "Summary"
+printf " \033[38;5;42m%d passed\033[0m · \033[38;5;214m%d warnings\033[0m · \033[38;5;203m%d failed\033[0m\n\n" \
+ "$PASS" "$WARN" "$FAIL"
+
+if [ "$FAIL" -gt 0 ] && ! $FIX; then
+ info "Run '$0 --fix' to attempt automatic fixes"
+ info "Run '$0 --upgrade' to install/upgrade cloud-hypervisor and CNI plugins"
+fi
+
+[ "$FAIL" -eq 0 ] || exit 1
diff --git a/snapshot/catalog/store.go b/snapshot/catalog/store.go
new file mode 100644
index 0000000..575b17d
--- /dev/null
+++ b/snapshot/catalog/store.go
@@ -0,0 +1,343 @@
+// Package catalog persists snapshot identities, optional names, and publication
+// state. Artifact capture and removal remain in the snapshot and core packages.
+package catalog
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/types"
+)
+
+const (
+ // CollectionSnapshots stores ready and pending records by immutable ID.
+ CollectionSnapshots metadata.Collection = "snapshots"
+ // CollectionNames maps optional human-readable names to snapshot IDs.
+ CollectionNames metadata.Collection = "snapshot_names"
+)
+
+// Collections declares the record sets required by this adapter.
+func Collections() []metadata.Collection {
+ return []metadata.Collection{CollectionSnapshots, CollectionNames}
+}
+
+// Store adapts shared metadata transactions to snapshot persistence.
+type Store struct{ store metadata.Store }
+
+// New constructs a snapshot catalog without taking ownership of the engine.
+func New(store metadata.Store) *Store { return &Store{store: store} }
+
+type recordData struct {
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ Description string `json:"description,omitempty"`
+ SandboxID string `json:"sandbox_id"`
+ SandboxName string `json:"sandbox_name"`
+ SourceGeneration uint64 `json:"source_generation"`
+ ImageDigest string `json:"image_digest"`
+ VMM string `json:"vmm"`
+ CPUs uint32 `json:"cpus"`
+ Memory int64 `json:"memory"`
+ Storage int64 `json:"storage"`
+ NICs int `json:"nics,omitempty"`
+ NetworkName string `json:"network_name,omitempty"`
+ Size int64 `json:"size"`
+ CreatedAt time.Time `json:"created_at"`
+ Ready bool `json:"ready"`
+ Deleting bool `json:"deleting,omitempty"`
+}
+
+type nameData struct {
+ ID string `json:"id"`
+}
+
+// Reserve atomically holds an ID and optional name before large capture I/O.
+func (s *Store) Reserve(ctx context.Context, snapshot types.Snapshot) error {
+ if s == nil || s.store == nil {
+ return errors.New("snapshot catalog is not configured")
+ }
+ if err := snapshot.Validate(); err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ err := s.store.Update(ctx, func(writer metadata.Writer) error {
+ if _, exists, err := writer.Get(ctx, CollectionSnapshots, snapshot.ID.String()); err != nil {
+ return err
+ } else if exists {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeNameTaken, fmt.Errorf("snapshot ID %s already exists", snapshot.ID))
+ }
+ if snapshot.Name != "" {
+ if _, exists, err := writer.Get(ctx, CollectionNames, snapshot.Name); err != nil {
+ return err
+ } else if exists {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeNameTaken, fmt.Errorf("snapshot name %q already exists", snapshot.Name))
+ }
+ rawName, err := json.Marshal(nameData{ID: snapshot.ID.String()})
+ if err != nil {
+ return err
+ }
+ if err := writer.Put(ctx, CollectionNames, snapshot.Name, rawName); err != nil {
+ return err
+ }
+ }
+ raw, err := json.Marshal(encode(snapshot, false))
+ if err != nil {
+ return err
+ }
+ return writer.Put(ctx, CollectionSnapshots, snapshot.ID.String(), raw)
+ })
+ return errdefs.Context(err, "save snapshot", snapshot.Name, "reserve", "choose another snapshot name", false)
+}
+
+// Commit publishes size and readiness after artifacts are atomically visible.
+func (s *Store) Commit(ctx context.Context, id types.SnapshotID, size int64) (types.Snapshot, error) {
+ var result types.Snapshot
+ err := s.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := load(ctx, writer, id)
+ if err != nil {
+ return err
+ }
+ if record.Ready {
+ result, err = decodeSnapshot(record)
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+ record.Size = size
+ result, err = decodeSnapshot(record)
+ if err != nil {
+ return err
+ }
+ record.Ready = true
+ raw, err := json.Marshal(record)
+ if err != nil {
+ return err
+ }
+ if err := writer.Put(ctx, CollectionSnapshots, id.String(), raw); err != nil {
+ return err
+ }
+ return nil
+ })
+ return result, errdefs.Context(err, "save snapshot", id.String(), "commit", "inspect snapshot storage before retrying", true)
+}
+
+// Forget releases a pending reservation during pre-publication compensation.
+func (s *Store) Forget(ctx context.Context, id types.SnapshotID) error {
+ err := s.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := load(ctx, writer, id)
+ if err != nil {
+ return err
+ }
+ if record.Ready {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("ready snapshot cannot be forgotten"))
+ }
+ if record.Name != "" {
+ if err := writer.Delete(ctx, CollectionNames, record.Name); err != nil {
+ return err
+ }
+ }
+ return writer.Delete(ctx, CollectionSnapshots, id.String())
+ })
+ return err
+}
+
+// Resolve returns one ready snapshot by exact name or complete ID.
+func (s *Store) Resolve(ctx context.Context, reference string) (types.Snapshot, error) {
+ if s == nil || s.store == nil {
+ return types.Snapshot{}, errors.New("snapshot catalog is not configured")
+ }
+ var result types.Snapshot
+ err := s.store.View(ctx, func(reader metadata.Reader) error {
+ record, err := resolve(ctx, reader, reference)
+ if err != nil {
+ return err
+ }
+ if !record.Ready || record.Deleting {
+ return notFound(reference)
+ }
+ result, err = decodeSnapshot(record)
+ if err != nil {
+ return err
+ }
+ return nil
+ })
+ return result, errdefs.Context(err, "resolve snapshot", reference, "metadata", "check the snapshot name or ID", false)
+}
+
+// List returns ready snapshots ordered newest first.
+func (s *Store) List(ctx context.Context) ([]types.Snapshot, error) {
+ var result []types.Snapshot
+ err := s.store.View(ctx, func(reader metadata.Reader) error {
+ return reader.Scan(ctx, CollectionSnapshots, func(id string, raw []byte) error {
+ record, err := decode(raw)
+ if err != nil {
+ return err
+ }
+ if record.ID != id {
+ return corrupt(errors.New("snapshot record key differs from ID"))
+ }
+ if record.Ready && !record.Deleting {
+ snapshot, err := decodeSnapshot(record)
+ if err != nil {
+ return err
+ }
+ result = append(result, snapshot)
+ }
+ return nil
+ })
+ })
+ slices.SortFunc(result, func(left, right types.Snapshot) int {
+ if order := right.CreatedAt.Compare(left.CreatedAt); order != 0 {
+ return order
+ }
+ return strings.Compare(left.ID.String(), right.ID.String())
+ })
+ return result, errdefs.Context(err, "list snapshots", "", "metadata", "inspect snapshot metadata", false)
+}
+
+// BeginDelete records durable deletion intent and returns the artifact owner.
+func (s *Store) BeginDelete(ctx context.Context, reference string) (types.Snapshot, error) {
+ var result types.Snapshot
+ err := s.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := resolve(ctx, writer, reference)
+ if err != nil {
+ return err
+ }
+ if !record.Ready {
+ return notFound(reference)
+ }
+ result, err = decodeSnapshot(record)
+ if err != nil {
+ return err
+ }
+ if record.Deleting {
+ return nil
+ }
+ record.Deleting = true
+ raw, err := json.Marshal(record)
+ if err != nil {
+ return err
+ }
+ return writer.Put(ctx, CollectionSnapshots, record.ID, raw)
+ })
+ return result, errdefs.Context(err, "remove snapshot", reference, "mark deleting", "retry snapshot removal", false)
+}
+
+// FinalizeDelete releases metadata and the optional name after artifacts are absent.
+func (s *Store) FinalizeDelete(ctx context.Context, id types.SnapshotID) error {
+ err := s.store.Update(ctx, func(writer metadata.Writer) error {
+ record, err := load(ctx, writer, id)
+ if err != nil {
+ return err
+ }
+ if !record.Deleting {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("snapshot is not deleting"))
+ }
+ if record.Name != "" {
+ if err := writer.Delete(ctx, CollectionNames, record.Name); err != nil {
+ return err
+ }
+ }
+ return writer.Delete(ctx, CollectionSnapshots, id.String())
+ })
+ return errdefs.Context(err, "remove snapshot", id.String(), "finalize", "retry snapshot removal", true)
+}
+
+func resolve(ctx context.Context, reader metadata.Reader, reference string) (recordData, error) {
+ if reference == "" {
+ return recordData{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SNAPSHOT must not be empty"))
+ }
+ if raw, exists, err := reader.Get(ctx, CollectionNames, reference); err != nil {
+ return recordData{}, err
+ } else if exists {
+ var name nameData
+ if err := json.Unmarshal(raw, &name); err != nil || name.ID == "" {
+ return recordData{}, corrupt(errors.New("invalid snapshot name binding"))
+ }
+ id, err := types.ParseSnapshotID(name.ID)
+ if err != nil {
+ return recordData{}, corrupt(err)
+ }
+ return load(ctx, reader, id)
+ }
+ id, err := types.ParseSnapshotID(reference)
+ if err != nil {
+ return recordData{}, notFound(reference)
+ }
+ return load(ctx, reader, id)
+}
+
+func load(ctx context.Context, reader metadata.Reader, id types.SnapshotID) (recordData, error) {
+ raw, exists, err := reader.Get(ctx, CollectionSnapshots, id.String())
+ if err != nil {
+ return recordData{}, err
+ }
+ if !exists {
+ return recordData{}, notFound(id.String())
+ }
+ return decode(raw)
+}
+
+func decode(raw []byte) (recordData, error) {
+ var record recordData
+ if err := json.Unmarshal(raw, &record); err != nil {
+ return recordData{}, corrupt(err)
+ }
+ if _, err := decodeSnapshot(record); err != nil {
+ return recordData{}, corrupt(err)
+ }
+ return record, nil
+}
+
+func encode(snapshot types.Snapshot, ready bool) recordData {
+ return recordData{
+ ID: snapshot.ID.String(), Name: snapshot.Name, Description: snapshot.Description,
+ SandboxID: snapshot.SandboxID.String(), SandboxName: snapshot.Config.Name,
+ SourceGeneration: snapshot.SourceGeneration,
+ ImageDigest: snapshot.ImageDigest.String(), VMM: string(snapshot.VMM),
+ CPUs: snapshot.Config.CPUs, Memory: snapshot.Config.Memory, Storage: snapshot.Config.Storage,
+ NICs: snapshot.Config.NICs, NetworkName: snapshot.Config.NetworkName,
+ Size: snapshot.Size, CreatedAt: snapshot.CreatedAt.UTC(), Ready: ready,
+ }
+}
+
+func decodeSnapshot(record recordData) (types.Snapshot, error) {
+ id, err := types.ParseSnapshotID(record.ID)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ sandboxID, err := types.ParseSandboxID(record.SandboxID)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ digest, err := types.ParseDigest(record.ImageDigest)
+ if err != nil {
+ return types.Snapshot{}, err
+ }
+ result := types.Snapshot{
+ ID: id, Name: record.Name, Description: record.Description,
+ SandboxID: sandboxID, SourceGeneration: record.SourceGeneration,
+ ImageDigest: digest, VMM: types.VMMType(record.VMM), Size: record.Size,
+ Config: types.SandboxConfig{
+ Name: record.SandboxName, CPUs: record.CPUs, Memory: record.Memory, Storage: record.Storage,
+ NICs: record.NICs, NetworkName: record.NetworkName,
+ },
+ CreatedAt: record.CreatedAt.UTC(),
+ }
+ return result, result.Validate()
+}
+
+func notFound(reference string) error {
+ return errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("snapshot %q was not found", reference))
+}
+
+func corrupt(cause error) error {
+ return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, cause)
+}
diff --git a/snapshot/catalog/store_test.go b/snapshot/catalog/store_test.go
new file mode 100644
index 0000000..e67941c
--- /dev/null
+++ b/snapshot/catalog/store_test.go
@@ -0,0 +1,49 @@
+package catalog
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/metadata"
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestSnapshotCatalogPublishesAndDeletesNameAtomically(t *testing.T) {
+ memory, err := metadata.NewMemory(Collections())
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := New(memory)
+ digest, err := types.ParseDigest("sha256:" + strings.Repeat("a", 64))
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := types.Snapshot{
+ ID: types.SnapshotID("223e4567-e89b-42d3-a456-426614174000"), Name: "checkpoint",
+ SandboxID: types.SandboxID("123e4567-e89b-42d3-a456-426614174000"), SourceGeneration: 4,
+ ImageDigest: digest, VMM: types.VMMCloudHypervisor,
+ Config: types.SandboxConfig{Name: "box", CPUs: 2, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage},
+ CreatedAt: time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC),
+ }
+ if err := store.Reserve(t.Context(), record); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Resolve(t.Context(), "checkpoint"); err == nil {
+ t.Fatal("pending snapshot was visible")
+ }
+ ready, err := store.Commit(t.Context(), record.ID, 42)
+ if err != nil || ready.Size != 42 || ready.Config.Name != "box" {
+ t.Fatalf("Commit = %+v, %v", ready, err)
+ }
+ deleting, err := store.BeginDelete(t.Context(), "checkpoint")
+ if err != nil || deleting.ID != record.ID {
+ t.Fatalf("BeginDelete = %+v, %v", deleting, err)
+ }
+ if err := store.FinalizeDelete(t.Context(), record.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Resolve(t.Context(), "checkpoint"); err == nil {
+ t.Fatal("deleted name still resolves")
+ }
+}
diff --git a/snapshot/paths.go b/snapshot/paths.go
new file mode 100644
index 0000000..fbe2d10
--- /dev/null
+++ b/snapshot/paths.go
@@ -0,0 +1,184 @@
+// Package snapshot owns persistent snapshot artifacts and their storage
+// contracts. Application ordering lives in core and metadata encoding lives in
+// snapshot/catalog.
+package snapshot
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+const cowName = "cow.raw"
+
+// Paths derives final, staging, and lock paths for snapshot artifacts.
+type Paths struct {
+ roots storage.Roots
+}
+
+// NewPaths validates shared roots without touching the filesystem.
+func NewPaths(roots storage.Roots) (Paths, error) {
+ validated, err := roots.Validate()
+ if err != nil {
+ return Paths{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ return Paths{roots: validated}, nil
+}
+
+// Ensure creates shared artifact, staging, and lock parents.
+func (p Paths) Ensure() error {
+ for _, path := range []string{p.DataDir(), p.StagingDir(), p.LocksDir()} {
+ if err := storage.EnsureDir(path); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// DataDir contains one immutable directory per ready snapshot.
+func (p Paths) DataDir() string { return filepath.Join(p.roots.Data, "snapshots") }
+
+// StagingDir contains unpublished captures safe to remove after failure.
+func (p Paths) StagingDir() string { return filepath.Join(p.roots.Data, "staging", "snapshots") }
+
+// LocksDir contains stable snapshot operation locks.
+func (p Paths) LocksDir() string { return filepath.Join(p.roots.Run, "locks", "snapshots") }
+
+// Dir returns the published snapshot directory.
+func (p Paths) Dir(id types.SnapshotID) (string, error) { return p.idDir(p.DataDir(), id) }
+
+// Stage returns the private unpublished capture directory.
+func (p Paths) Stage(id types.SnapshotID) (string, error) { return p.idDir(p.StagingDir(), id) }
+
+// Lock returns the stable operation lock path for one snapshot.
+func (p Paths) Lock(id types.SnapshotID) (string, error) {
+ if _, err := types.ParseSnapshotID(id.String()); err != nil {
+ return "", err
+ }
+ return storage.Join(p.LocksDir(), id.String()+".lock")
+}
+
+// COW returns the captured writable overlay path inside a snapshot directory.
+func (p Paths) COW(id types.SnapshotID) (string, error) {
+ dir, err := p.Dir(id)
+ if err != nil {
+ return "", err
+ }
+ return storage.Join(dir, cowName)
+}
+
+// StageCOW returns the unpublished writable overlay path.
+func (p Paths) StageCOW(id types.SnapshotID) (string, error) {
+ dir, err := p.Stage(id)
+ if err != nil {
+ return "", err
+ }
+ return storage.Join(dir, cowName)
+}
+
+// RestoreCOW returns a private scratch file used to prepare one sandbox's
+// writable disk while its current VMM can continue running.
+func (p Paths) RestoreCOW(snapshotID types.SnapshotID, sandboxID types.SandboxID) (string, error) {
+ if _, err := types.ParseSnapshotID(snapshotID.String()); err != nil {
+ return "", err
+ }
+ if _, err := types.ParseSandboxID(sandboxID.String()); err != nil {
+ return "", err
+ }
+ return storage.Join(p.StagingDir(), snapshotID.String()+"-restore-"+sandboxID.String()+".raw")
+}
+
+// PrepareStage creates an empty private capture directory.
+func (p Paths) PrepareStage(id types.SnapshotID) error {
+ dir, err := p.Stage(id)
+ if err != nil {
+ return err
+ }
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ return fmt.Errorf("create snapshot staging directory: %w", err)
+ }
+ return nil
+}
+
+// Publish atomically makes a fully synchronized capture visible.
+func (p Paths) Publish(id types.SnapshotID) error {
+ stage, err := p.Stage(id)
+ if err != nil {
+ return err
+ }
+ final, err := p.Dir(id)
+ if err != nil {
+ return err
+ }
+ return storage.PublishDir(stage, final)
+}
+
+// RemoveStage removes an unpublished capture after a failed save.
+func (p Paths) RemoveStage(id types.SnapshotID) error {
+ stage, err := p.Stage(id)
+ if err != nil {
+ return err
+ }
+ if err := storage.CheckPath(stage); err != nil {
+ return err
+ }
+ return os.RemoveAll(stage)
+}
+
+// Remove deletes one published artifact directory.
+func (p Paths) Remove(id types.SnapshotID) error {
+ dir, err := p.Dir(id)
+ if err != nil {
+ return err
+ }
+ if err := storage.CheckPath(dir); err != nil {
+ return err
+ }
+ if err := os.RemoveAll(dir); err != nil {
+ return fmt.Errorf("remove snapshot artifacts: %w", err)
+ }
+ return nil
+}
+
+// Size returns the sum of regular-file logical sizes.
+func (p Paths) Size(id types.SnapshotID) (int64, error) {
+ dir, err := p.Dir(id)
+ if err != nil {
+ return 0, err
+ }
+ var size int64
+ err = filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.Type().IsRegular() {
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ size += info.Size()
+ }
+ return nil
+ })
+ return size, err
+}
+
+func (p Paths) idDir(root string, id types.SnapshotID) (string, error) {
+ if _, err := types.ParseSnapshotID(id.String()); err != nil {
+ return "", err
+ }
+ return storage.Join(root, id.String())
+}
+
+// IgnoreAbsence converts cleanup of an already absent path into success.
+func IgnoreAbsence(err error) error {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return err
+}
diff --git a/storage/copy_linux.go b/storage/copy_linux.go
new file mode 100644
index 0000000..5f14a86
--- /dev/null
+++ b/storage/copy_linux.go
@@ -0,0 +1,75 @@
+//go:build linux
+
+package storage
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "syscall"
+
+ "golang.org/x/sys/unix"
+)
+
+// CopySparse copies data extents while preserving holes and the source's
+// logical size. The destination must not already exist.
+func CopySparse(destination, source string) (returnErr error) {
+ input, err := os.Open(source) //nolint:gosec // callers supply validated managed paths
+ if err != nil {
+ return fmt.Errorf("open sparse source: %w", err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, input.Close()) }()
+ info, err := input.Stat()
+ if err != nil || !info.Mode().IsRegular() {
+ return errors.Join(err, errors.New("sparse source must be a regular file"))
+ }
+ output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec // managed staging path
+ if err != nil {
+ return fmt.Errorf("create sparse destination: %w", err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, output.Close()) }()
+ if err := output.Truncate(info.Size()); err != nil {
+ return err
+ }
+ for offset := int64(0); offset < info.Size(); {
+ data, err := unix.Seek(int(input.Fd()), offset, unix.SEEK_DATA)
+ if errors.Is(err, syscall.ENXIO) {
+ break
+ }
+ if errors.Is(err, syscall.EINVAL) {
+ return copyDense(output, input)
+ }
+ if err != nil {
+ return fmt.Errorf("seek sparse data: %w", err)
+ }
+ hole, err := unix.Seek(int(input.Fd()), data, unix.SEEK_HOLE)
+ if err != nil {
+ return fmt.Errorf("seek sparse hole: %w", err)
+ }
+ if _, err := input.Seek(data, io.SeekStart); err != nil {
+ return err
+ }
+ if _, err := output.Seek(data, io.SeekStart); err != nil {
+ return err
+ }
+ if _, err := io.CopyN(output, input, hole-data); err != nil {
+ return fmt.Errorf("copy sparse extent: %w", err)
+ }
+ offset = hole
+ }
+ return output.Sync()
+}
+
+func copyDense(destination, source *os.File) error {
+ if _, err := source.Seek(0, io.SeekStart); err != nil {
+ return err
+ }
+ if _, err := destination.Seek(0, io.SeekStart); err != nil {
+ return err
+ }
+ if _, err := io.Copy(destination, source); err != nil {
+ return err
+ }
+ return destination.Sync()
+}
diff --git a/storage/copy_linux_test.go b/storage/copy_linux_test.go
new file mode 100644
index 0000000..9f6c638
--- /dev/null
+++ b/storage/copy_linux_test.go
@@ -0,0 +1,42 @@
+//go:build linux
+
+package storage
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestCopySparsePreservesLogicalData(t *testing.T) {
+ directory := t.TempDir()
+ source := filepath.Join(directory, "source.raw")
+ file, err := os.OpenFile(source, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := file.WriteAt([]byte("first"), 0); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := file.WriteAt([]byte("last"), 16<<20); err != nil {
+ t.Fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatal(err)
+ }
+ destination := filepath.Join(directory, "destination.raw")
+ if err := CopySparse(destination, source); err != nil {
+ t.Fatal(err)
+ }
+ want, err := os.ReadFile(source)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != string(want) {
+ t.Fatal("sparse copy changed file data")
+ }
+}
diff --git a/storage/copy_other.go b/storage/copy_other.go
new file mode 100644
index 0000000..095820d
--- /dev/null
+++ b/storage/copy_other.go
@@ -0,0 +1,29 @@
+//go:build !linux
+
+package storage
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+)
+
+// CopySparse provides a portable development-host fallback. Production Linux
+// builds use extent-aware copying to preserve holes.
+func CopySparse(destination, source string) (returnErr error) {
+ input, err := os.Open(source) //nolint:gosec // callers supply validated managed paths
+ if err != nil {
+ return fmt.Errorf("open sparse source: %w", err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, input.Close()) }()
+ output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec // managed staging path
+ if err != nil {
+ return fmt.Errorf("create sparse destination: %w", err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, output.Close()) }()
+ if _, err := io.Copy(output, input); err != nil {
+ return err
+ }
+ return output.Sync()
+}
diff --git a/storage/directory.go b/storage/directory.go
new file mode 100644
index 0000000..fe1ab8e
--- /dev/null
+++ b/storage/directory.go
@@ -0,0 +1,69 @@
+package storage
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "slices"
+)
+
+// PublishDir synchronizes a staged directory tree, atomically renames it to an
+// absent final path, and synchronizes both parents. Source and destination must
+// share a filesystem.
+func PublishDir(staged, final string) error {
+ if err := CheckPath(staged); err != nil {
+ return err
+ }
+ if err := CheckPath(final); err != nil {
+ return err
+ }
+ if _, err := os.Lstat(final); err == nil {
+ return fmt.Errorf("publish directory destination %s already exists", final)
+ } else if !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+ if err := SyncTree(staged); err != nil {
+ return err
+ }
+ if err := os.Rename(staged, final); err != nil {
+ return fmt.Errorf("publish directory %s: %w", final, err)
+ }
+ return errors.Join(syncPath(filepath.Dir(final)), syncPath(filepath.Dir(staged)))
+}
+
+// SyncTree flushes regular files and directories from leaves to root. Symlinks
+// and special files are rejected because managed artifact trees must be closed.
+func SyncTree(root string) error {
+ var directories []string
+ err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ switch {
+ case info.IsDir():
+ directories = append(directories, path)
+ case info.Mode().IsRegular():
+ if err := syncPath(path); err != nil {
+ return err
+ }
+ default:
+ return fmt.Errorf("snapshot artifact %s is not a regular file or directory", path)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ for _, directory := range slices.Backward(directories) {
+ if err := syncPath(directory); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/storage/publish.go b/storage/publish.go
new file mode 100644
index 0000000..0214b4d
--- /dev/null
+++ b/storage/publish.go
@@ -0,0 +1,59 @@
+package storage
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// Publish syncs a regular staged file, then atomically renames and syncs both parents.
+// Rename refuses a different filesystem; it never falls back to a partial copy.
+// Callers serialize destination ownership. A sync error after rename can leave the
+// final path present and must not be interpreted as proof that nothing was published.
+//
+// staged regular file -> sync file -> rename to final -> sync both parents
+// |
+// +-- final path visible; later sync may fail
+func Publish(staged, final string) error {
+ if err := CheckPath(staged); err != nil {
+ return err
+ }
+ if err := CheckPath(final); err != nil {
+ return err
+ }
+ info, err := os.Lstat(staged)
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() || info.Size() == 0 {
+ return fmt.Errorf("staged artifact %s is not a nonempty regular file", staged)
+ }
+ if err := EnsureDir(filepath.Dir(final)); err != nil {
+ return err
+ }
+ if err := syncPath(staged); err != nil {
+ return err
+ }
+ if err := os.Rename(staged, final); err != nil {
+ return fmt.Errorf("publish %s: %w", final, err)
+ }
+ return errors.Join(syncPath(filepath.Dir(final)), syncPath(filepath.Dir(staged)))
+}
+
+// syncPath opens relative to a directory handle and closes every descriptor while
+// preserving sync and close failures. It supports regular files and directories.
+func syncPath(path string) error {
+ root, err := os.OpenRoot(filepath.Dir(path))
+ if err != nil {
+ return err
+ }
+ file, err := root.Open(filepath.Base(path))
+ if err != nil {
+ return errors.Join(fmt.Errorf("open for sync %s: %w", path, err), root.Close())
+ }
+ if err := errors.Join(file.Sync(), file.Close(), root.Close()); err != nil {
+ return fmt.Errorf("sync %s: %w", path, err)
+ }
+ return nil
+}
diff --git a/storage/roots.go b/storage/roots.go
new file mode 100644
index 0000000..47824d7
--- /dev/null
+++ b/storage/roots.go
@@ -0,0 +1,174 @@
+// Package storage manages host root boundaries and durable filesystem publication.
+// It supplies path and sync mechanisms without interpreting module artifacts.
+package storage
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const (
+ // DefaultDataRoot holds persistent module data.
+ DefaultDataRoot = "/var/lib/kumabox"
+ // DefaultRunRoot holds host runtime state and advisory lock files.
+ DefaultRunRoot = "/run/kumabox"
+ // DefaultLogRoot holds persistent host logs.
+ DefaultLogRoot = "/var/log/kumabox"
+)
+
+// Roots are the three host roots shared by KumaBox modules.
+type Roots struct {
+ // Data is the persistent artifact and metadata root.
+ Data string
+ // Run is the runtime state and lock root.
+ Run string
+ // Log is the host log root.
+ Log string
+}
+
+// DefaultRoots returns host defaults; callers may override them before Validate.
+func DefaultRoots() Roots {
+ return Roots{Data: DefaultDataRoot, Run: DefaultRunRoot, Log: DefaultLogRoot}
+}
+
+// Validate returns absolute, cleaned, non-overlapping roots, rejecting managed
+// symlinks and non-directory ancestors. Stable macOS system aliases are resolved
+// so two spellings of the same ownership boundary cannot bypass overlap checks.
+func (r Roots) Validate() (Roots, error) {
+ values := []*string{&r.Data, &r.Run, &r.Log}
+ for _, value := range values {
+ if *value == "" {
+ return Roots{}, fmt.Errorf("storage root must not be empty")
+ }
+ absolute, err := filepath.Abs(*value)
+ if err != nil {
+ return Roots{}, fmt.Errorf("resolve storage root %q: %w", *value, err)
+ }
+ *value = filepath.Clean(absolute)
+ // macOS exposes these system directories through stable symlinks.
+ for _, alias := range []string{"/tmp", "/var", "/etc"} {
+ if within(*value, alias) {
+ if resolved, err := filepath.EvalSymlinks(alias); err == nil {
+ relative, err := filepath.Rel(alias, *value)
+ if err != nil {
+ return Roots{}, err
+ }
+ *value = filepath.Join(resolved, relative)
+ }
+ }
+ }
+ if err := CheckPath(*value); err != nil {
+ return Roots{}, err
+ }
+ }
+ paths := []string{r.Data, r.Run, r.Log}
+ for i, left := range paths {
+ for j, right := range paths {
+ if i == j {
+ continue
+ }
+ if within(left, right) {
+ return Roots{}, fmt.Errorf("storage roots overlap: %s and %s", left, right)
+ }
+ }
+ }
+ return r, nil
+}
+
+// CheckPath rejects symlinks in existing managed components and non-directory
+// ancestors, except for the stable /tmp, /var, and /etc system aliases. Missing
+// descendants are allowed. This is a path check, not an atomic filesystem guard.
+func CheckPath(path string) error {
+ absolute, err := filepath.Abs(path)
+ if err != nil {
+ return err
+ }
+ current := string(filepath.Separator)
+ parts := strings.Split(strings.TrimPrefix(absolute, current), string(filepath.Separator))
+ for index, part := range parts {
+ current = filepath.Join(current, part)
+ info, err := os.Lstat(current)
+ if os.IsNotExist(err) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("inspect managed path %s: %w", current, err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 && (current == "/tmp" || current == "/var" || current == "/etc") {
+ resolved, err := filepath.EvalSymlinks(current)
+ if err != nil {
+ return err
+ }
+ current = resolved
+ continue
+ }
+ if info.Mode()&os.ModeSymlink != 0 || (index < len(parts)-1 && !info.IsDir()) {
+ return fmt.Errorf("managed path %s is not a real directory or file", current)
+ }
+ }
+ return nil
+}
+
+// EnsureDir creates and durably publishes each missing directory.
+func EnsureDir(path string) error {
+ if err := CheckPath(path); err != nil {
+ return err
+ }
+ info, err := os.Lstat(path)
+ if err == nil {
+ if !info.IsDir() {
+ return fmt.Errorf("managed directory %s is not a real directory", path)
+ }
+ return nil
+ }
+ if !os.IsNotExist(err) {
+ return fmt.Errorf("inspect directory %s: %w", path, err)
+ }
+ parent := filepath.Dir(path)
+ if parent == path {
+ return fmt.Errorf("cannot create storage root %s", path)
+ }
+ if err := EnsureDir(parent); err != nil {
+ return err
+ }
+ if err := os.Mkdir(path, 0o750); err != nil && !os.IsExist(err) {
+ return fmt.Errorf("create directory %s: %w", path, err)
+ }
+ if err := CheckPath(path); err != nil {
+ return err
+ }
+ info, err = os.Lstat(path)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ return fmt.Errorf("managed directory %s is not a real directory", path)
+ }
+ return syncPath(parent)
+}
+
+// Join returns a lexically contained child path and checks its existing components.
+// Empty, absolute, and escaping elements fail; it does not create the resulting path.
+func Join(root string, elements ...string) (string, error) {
+ for _, element := range elements {
+ if element == "" || filepath.IsAbs(element) || element == ".." || strings.HasPrefix(element, ".."+string(filepath.Separator)) {
+ return "", fmt.Errorf("unsafe managed path element %q", element)
+ }
+ }
+ joined := filepath.Join(append([]string{root}, elements...)...)
+ if !within(joined, root) {
+ return "", fmt.Errorf("managed path %s escapes %s", joined, root)
+ }
+ if err := CheckPath(joined); err != nil {
+ return "", err
+ }
+ return joined, nil
+}
+
+// within compares path components rather than string prefixes, including root itself.
+func within(path, root string) bool {
+ relative, err := filepath.Rel(root, path)
+ return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
+}
diff --git a/storage/roots_test.go b/storage/roots_test.go
new file mode 100644
index 0000000..2b36fd4
--- /dev/null
+++ b/storage/roots_test.go
@@ -0,0 +1,39 @@
+package storage
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestRootsRejectOverlap(t *testing.T) {
+ root := t.TempDir()
+ _, err := (Roots{Data: root, Run: filepath.Join(root, "run"), Log: filepath.Join(root, "log")}).Validate()
+ if err == nil {
+ t.Fatal("Validate accepted overlapping roots")
+ }
+}
+
+func TestJoinRejectsEscape(t *testing.T) {
+ if _, err := Join(t.TempDir(), "..", "escape"); err == nil {
+ t.Fatal("Join accepted parent traversal")
+ }
+}
+
+func TestPathsRejectSymlinkParents(t *testing.T) {
+ base := t.TempDir()
+ outside := t.TempDir()
+ if err := os.Symlink(outside, filepath.Join(base, "images")); err != nil {
+ t.Fatal(err)
+ }
+ if err := EnsureDir(filepath.Join(base, "images", "layers")); err == nil {
+ t.Fatal("followed symlink parent")
+ }
+ if _, err := os.Stat(filepath.Join(outside, "layers")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("wrote outside managed path: %v", err)
+ }
+ if _, err := (Roots{Data: filepath.Join(base, "images", "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log")}).Validate(); err == nil {
+ t.Fatal("accepted symlink root parent")
+ }
+}
diff --git a/test/e2e/e2e.sh b/test/e2e/e2e.sh
deleted file mode 100755
index 3ab8b9e..0000000
--- a/test/e2e/e2e.sh
+++ /dev/null
@@ -1,377 +0,0 @@
-#!/usr/bin/env bash
-set -Eeuo pipefail
-
-# The only Linux end-to-end entry point. It deliberately uses the KumaBox
-# system paths and validates the core OCI, agent, CNI, snapshot, and disk
-# hotplug flows in one isolated run.
-
-repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
-cd "$repo_dir"
-kumabox="$repo_dir/bin/kumabox"
-cloud_hypervisor=cloud-hypervisor
-qemu_img=qemu-img
-image=kumabox-e2e
-image_ref=kumabox/ubuntu:24.04-e2e
-network=cni:kumabox
-storage=64M
-metadata_backend=sqlite
-go_bin=${GO_BIN:-}
-keep=false
-rebuild_image=false
-fs_socket=
-pci_bdf=
-e2e_phase=initialization
-expected_agent_version=
-
-usage() {
- cat <<'EOF'
-Usage: test/e2e/e2e.sh [options]
-
-Builds a Linux guest image and verifies image auto-detection, launch dry-run,
-OCI boot, agent exec, CNI cleanup, package/directory/native snapshots,
-restore/clone, and disk hotplug.
-
-All runtime paths are fixed to KumaBox system defaults:
- /var/lib/kumabox, /var/lib/kumabox/run, /var/log/kumabox
-
-Options:
- --kumabox PATH
- --cloud-hypervisor PATH
- --qemu-img PATH
- --image NAME managed image name, defaults to kumabox-e2e
- --image-ref REF local OCI tag, defaults to kumabox/ubuntu:24.04-e2e
- --network NETWORK VM network, defaults to cni:kumabox
- --storage SIZE
- --metadata-backend json|sqlite
- --go-bin PATH
- --rebuild-image rebuild and re-import the managed OCI image
- --fs-socket PATH verify virtio-fs attach/detach with this socket
- --pci BDF verify VFIO attach/detach with this host PCI device
- --keep preserve E2E VMs and snapshots after success
-EOF
-}
-
-require_value() { [[ -n ${2:-} ]] || { echo "$1 requires a value" >&2; exit 2; }; }
-
-while (($#)); do
- case "$1" in
- --kumabox) require_value "$1" "${2:-}"; kumabox=$2; shift 2 ;;
- --cloud-hypervisor) require_value "$1" "${2:-}"; cloud_hypervisor=$2; shift 2 ;;
- --qemu-img) require_value "$1" "${2:-}"; qemu_img=$2; shift 2 ;;
- --image) require_value "$1" "${2:-}"; image=$2; shift 2 ;;
- --image-ref) require_value "$1" "${2:-}"; image_ref=$2; shift 2 ;;
- --network) require_value "$1" "${2:-}"; network=$2; shift 2 ;;
- --storage) require_value "$1" "${2:-}"; storage=$2; shift 2 ;;
- --metadata-backend) require_value "$1" "${2:-}"; metadata_backend=$2; shift 2 ;;
- --go-bin) require_value "$1" "${2:-}"; go_bin=$2; shift 2 ;;
- --rebuild-image) rebuild_image=true; shift ;;
- --fs-socket) require_value "$1" "${2:-}"; fs_socket=$2; shift 2 ;;
- --pci) require_value "$1" "${2:-}"; pci_bdf=$2; shift 2 ;;
- --keep) keep=true; shift ;;
- -h|--help) usage; exit 0 ;;
- --root-dir|--run-dir|--log-dir|--metadata-path)
- echo "$1 is not supported; E2E uses KumaBox system defaults" >&2; exit 2 ;;
- *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
- esac
-done
-
-[[ "$metadata_backend" == json || "$metadata_backend" == sqlite ]] || { echo "--metadata-backend must be json or sqlite" >&2; exit 2; }
-command -v "$cloud_hypervisor" >/dev/null || { echo "cloud-hypervisor is required" >&2; exit 1; }
-command -v "$qemu_img" >/dev/null || { echo "qemu-img is required" >&2; exit 1; }
-command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
-
-if [[ $(id -u) -eq 0 ]]; then
- run=()
- build_user=${SUDO_USER:-}
- [[ -n "$build_user" && "$build_user" != root ]] || { echo "run this script with sudo from the development user" >&2; exit 1; }
-else
- run=(sudo)
- build_user=$(id -un)
-fi
-
-step() {
- e2e_phase=$1
- printf '\n==> %s\n' "$e2e_phase"
-}
-kb() { "${run[@]}" "$kumabox" --cloud-hypervisor-bin "$cloud_hypervisor" --qemu-img-bin "$qemu_img" --metadata-backend "$metadata_backend" "$@"; }
-
-resolve_go_binary() {
- if [[ -z "$go_bin" ]]; then
- if [[ $(id -u) -eq 0 ]]; then
- go_bin=$(sudo -u "$build_user" -H sh -lc 'command -v go' 2>/dev/null || true)
- else
- go_bin=$(command -v go || true)
- fi
- fi
- [[ -x "$go_bin" ]] || {
- echo "Go binary is required; pass --go-bin \$(go env GOROOT)/bin/go" >&2
- exit 1
- }
- "$go_bin" version | grep -Eq 'go1\.24\.[4-9]|go1\.(2[5-9]|[3-9][0-9])\.' || {
- echo "Go 1.24.4 or newer is required: $("$go_bin" version)" >&2
- exit 1
- }
-}
-
-build_host_binary() {
- resolve_go_binary
- local commit build_time ldflags
- commit=$(git -C "$repo_dir" rev-parse --short HEAD)
- build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)
- ldflags="-X github.com/kumabox/kumabox/internal/version.Version=0.0.0-dev -X github.com/kumabox/kumabox/internal/version.Commit=$commit -X github.com/kumabox/kumabox/internal/version.BuildTime=$build_time"
- mkdir -p "$(dirname "$kumabox")"
- if [[ $(id -u) -eq 0 ]]; then
- sudo -u "$build_user" -H "$go_bin" build -ldflags "$ldflags" -o "$kumabox" ./cmd/kumabox
- else
- "$go_bin" build -ldflags "$ldflags" -o "$kumabox" ./cmd/kumabox
- fi
- local binary_commit
- binary_commit=$("$kumabox" version --json | jq -r '.commit')
- [[ "$binary_commit" == "$commit" ]] || {
- echo "host binary commit mismatch: source=$commit binary=$binary_commit" >&2
- exit 1
- }
- printf 'host binary: commit=%s path=%s\n' "$binary_commit" "$kumabox"
-}
-
-resolve_agent_version() {
- if [[ $(id -u) -eq 0 ]]; then
- expected_agent_version=$(sudo -u "$build_user" -H "$go_bin" run ./cmd/agent version)
- else
- expected_agent_version=$("$go_bin" run ./cmd/agent version)
- fi
-}
-
-names=(e2e-exec e2e-boot e2e-cni e2e-stopped-source e2e-stopped-restored e2e-stopped-dir-restored e2e-native-source e2e-native-clone e2e-hotplug)
-snapshots=(e2e-stopped e2e-stopped-import e2e-stopped-dir-import e2e-native)
-temporary_images=(e2e-local-image)
-
-cleanup() {
- local name snapshot temporary_image
- for name in "${names[@]}"; do kb delete "$name" --force >/dev/null 2>&1 || true; done
- for snapshot in "${snapshots[@]}"; do kb snapshot rm "$snapshot" >/dev/null 2>&1 || true; done
- for temporary_image in "${temporary_images[@]}"; do kb image rm "$temporary_image" >/dev/null 2>&1 || true; done
- "${run[@]}" rm -f /var/lib/kumabox/e2e-hotplug.raw /var/lib/kumabox/e2e-stopped.kbsnap \
- /var/lib/kumabox/e2e-local.qcow2 /var/lib/kumabox/e2e-firmware.fd \
- /var/lib/kumabox/e2e-metadata-backup.db /var/lib/kumabox/e2e-metadata-backup.db.backup.lock 2>/dev/null || true
- "${run[@]}" rm -rf /var/lib/kumabox/e2e-stopped-dir \
- /var/lib/kumabox/run/vms/kb_preview /var/lib/kumabox/storage/vms/kb_preview \
- /var/log/kumabox/vms/kb_preview 2>/dev/null || true
-}
-
-failure_context() {
- local status=$?
- [[ $status -eq 0 ]] && return
- printf '\n==> E2E failure context\n' >&2
- printf 'phase=%s\n' "$e2e_phase" >&2
- kb ps --json >&2 2>&1 || true
- printf '\n==> VM log files\n' >&2
- find /var/log/kumabox/vms -maxdepth 2 -type f \( -name console.log -o -name cloud-hypervisor.stderr.log \) -print 2>/dev/null >&2 || true
-}
-trap failure_context EXIT
-
-build_image() {
- step "build guest agent and OCI image"
- resolve_go_binary
- command -v docker >/dev/null || { echo "docker is required to build $image_ref" >&2; exit 1; }
- local context="$repo_dir/oci-images/ubuntu"
- local agent="$context/kumabox-agent-linux-amd64"
- sudo -u "$build_user" -H env GOOS=linux GOARCH=amd64 CGO_ENABLED=0 "$go_bin" build -o "$agent" "$repo_dir/cmd/agent"
- sudo -u "$build_user" -H docker build --platform linux/amd64 --network=host -f "$context/24.04/Dockerfile" -t "$image_ref" "$context"
- rm -f "$agent"
- if kb image inspect "$image" --json >/dev/null 2>&1; then
- if ! kb image rm "$image" >/dev/null; then
- printf 'cannot replace managed image %q because it is still referenced; remove the listed VMs or rerun E2E after its cleanup succeeds:\n' "$image" >&2
- kb image inspect "$image" --json >&2 || true
- kb ps --json >&2 || true
- exit 1
- fi
- fi
- kb image add "$image_ref" --source daemon --name "$image" --platform linux/amd64 | jq .
-}
-
-ensure_image() {
- if [[ "$rebuild_image" == false ]] && kb image inspect "$image" --json >/dev/null 2>&1; then
- step "reuse managed OCI image: $image"
- return
- fi
- build_image
-}
-
-wait_agent() { kb agent ping "$1" --timeout 90s >/dev/null; }
-run_vm() {
- local name=$1 network_name=$2
- kb run "$image" --name "$name" --network "$network_name" --storage "$storage"
-}
-
-step "build current host binary"
-build_host_binary
-resolve_agent_version
-
-step "clean previous E2E resources"
-cleanup
-if [[ "$metadata_backend" == sqlite ]]; then
- if ! kb metadata status >/dev/null 2>&1; then
- step "initialize SQLite metadata"
- kb metadata init | jq .
- fi
-fi
-ensure_image
-
-step "local image auto-detection"
-"${run[@]}" "$qemu_img" create -q -f qcow2 /var/lib/kumabox/e2e-local.qcow2 8M
-printf 'e2e firmware placeholder\n' | "${run[@]}" tee /var/lib/kumabox/e2e-firmware.fd >/dev/null
-local_image=$(kb image add /var/lib/kumabox/e2e-local.qcow2 \
- --name e2e-local-image --firmware /var/lib/kumabox/e2e-firmware.fd \
- --qemu-img "$qemu_img")
-printf '%s\n' "$local_image" | jq -e '
- .name == "e2e-local-image" and
- .source.type == "local-file" and
- .rootDisk.format == "qcow2" and
- .boot.mode == "uefi"
-' >/dev/null
-kb image rm e2e-local-image >/dev/null
-"${run[@]}" rm -f /var/lib/kumabox/e2e-local.qcow2 /var/lib/kumabox/e2e-firmware.fd
-
-step "launch plan dry-run"
-vm_count_before=$(kb ps --json | jq 'length')
-launch_plan=$(kb debug launch "$image" --storage "$storage" --json)
-printf '%s\n' "$launch_plan" | jq -e '
- .schemaVersion == "kumabox.debug.launch.v1" and
- .dryRun == true and
- .vm.id == "kb_preview" and
- .vm.networks == ["none"] and
- (.launch.args | length > 0)
-' >/dev/null
-vm_count_after=$(kb ps --json | jq 'length')
-[[ "$vm_count_before" == "$vm_count_after" ]] || {
- printf 'debug launch changed VM count: before=%s after=%s\n' "$vm_count_before" "$vm_count_after" >&2
- exit 1
-}
-for preview_path in \
- /var/lib/kumabox/run/vms/kb_preview \
- /var/lib/kumabox/storage/vms/kb_preview \
- /var/log/kumabox/vms/kb_preview; do
- if "${run[@]}" test -e "$preview_path"; then
- printf 'debug launch created preview path: %s\n' "$preview_path" >&2
- exit 1
- fi
-done
-
-step "OCI boot and guest exec"
-run_vm e2e-exec none | jq .
-agent_json=$(kb agent ping e2e-exec --timeout 90s)
-actual_agent_version=$(printf '%s' "$agent_json" | jq -r '.agent.version // empty')
-if [[ "$actual_agent_version" != "$expected_agent_version" ]]; then
- printf 'managed image %q contains kumabox-agent %q, but current source is %q; rerun once with --rebuild-image\n' \
- "$image" "$actual_agent_version" "$expected_agent_version" >&2
- exit 1
-fi
-[[ $(kb exec e2e-exec -- uname -n) == e2e-exec ]]
-[[ $(printf 'roundtrip' | kb exec e2e-exec -- cat) == roundtrip ]]
-[[ $(kb exec --env FOO=bar e2e-exec -- sh -c 'printf %s "$FOO"') == bar ]]
-if unsupported_user_output=$(kb exec --user nobody e2e-exec -- true 2>&1); then
- printf 'guest user policy was not enforced: command unexpectedly succeeded\n' >&2
- exit 1
-elif [[ "$unsupported_user_output" != *USER_UNSUPPORTED* ]]; then
- printf 'guest user policy returned an unexpected error:\n%s\n' "$unsupported_user_output" >&2
- exit 1
-fi
-kb delete e2e-exec --force >/dev/null
-
-step "OCI overlay boot"
-run_vm e2e-boot "$network" | jq .
-wait_agent e2e-boot
-kb exec e2e-boot -- sh -c 'findmnt -n -o FSTYPE / | grep -qx overlay; findmnt -n -o OPTIONS / | grep -q lowerdir=' >/dev/null
-kb delete e2e-boot --force >/dev/null
-
-step "CNI allocation and DEL cleanup"
-cni_json=$(run_vm e2e-cni "$network")
-printf '%s\n' "$cni_json" | jq .
-wait_agent e2e-cni
-gateway=$(printf '%s' "$cni_json" | jq -r '.networkConfigs[0].network.gateway')
-[[ -n "$gateway" && "$gateway" != null ]]
-kb exec e2e-cni -- ping -c 1 -W 3 "$gateway" >/dev/null
-kb delete e2e-cni --force >/dev/null
-[[ $(kb network inspect e2e-cni --json | jq '.interfaces | length') == 0 ]]
-
-step "stopped snapshot export import restore"
-run_vm e2e-stopped-source none >/dev/null
-wait_agent e2e-stopped-source
-kb exec e2e-stopped-source -- sh -c 'printf stopped > /var/tmp/e2e-stopped; sync' >/dev/null
-kb stop e2e-stopped-source --force >/dev/null
-stopped_snapshot=$(kb snapshot create e2e-stopped-source --name e2e-stopped | jq -r .id)
-kb snapshot export "$stopped_snapshot" --output /var/lib/kumabox/e2e-stopped.kbsnap --compression none >/dev/null
-imported_snapshot=$(kb snapshot import /var/lib/kumabox/e2e-stopped.kbsnap --name e2e-stopped-import | jq -r .id)
-kb snapshot restore "$imported_snapshot" --name e2e-stopped-restored --network none >/dev/null
-kb start e2e-stopped-restored >/dev/null
-wait_agent e2e-stopped-restored
-[[ $(kb exec e2e-stopped-restored -- cat /var/tmp/e2e-stopped) == stopped ]]
-kb delete e2e-stopped-restored --force >/dev/null
-
-step "stopped snapshot directory export import restore"
-kb snapshot export "$stopped_snapshot" --to-dir /var/lib/kumabox/e2e-stopped-dir >/dev/null
-directory_snapshot=$(kb snapshot import --from-dir /var/lib/kumabox/e2e-stopped-dir --name e2e-stopped-dir-import | jq -r .id)
-kb snapshot restore "$directory_snapshot" --name e2e-stopped-dir-restored --network none >/dev/null
-kb start e2e-stopped-dir-restored >/dev/null
-wait_agent e2e-stopped-dir-restored
-[[ $(kb exec e2e-stopped-dir-restored -- cat /var/tmp/e2e-stopped) == stopped ]]
-kb delete e2e-stopped-source --force >/dev/null
-kb delete e2e-stopped-dir-restored --force >/dev/null
-
-step "native snapshot and clone"
-step "native source start"
-run_vm e2e-native-source "$network" >/dev/null
-wait_agent e2e-native-source
-kb exec e2e-native-source -- sh -c 'printf native > /var/tmp/e2e-native; sync' >/dev/null
-
-step "native running snapshot capture"
-native_snapshot=$(kb snapshot create e2e-native-source --name e2e-native --type running | jq -r .id)
-
-step "native ondemand clone restore"
-if ! clone_output=$(kb clone "$native_snapshot" --name e2e-native-clone --network "$network" --restore-mode ondemand 2>&1); then
- printf 'native clone failed:\n%s\n' "$clone_output" >&2
- if [[ "$clone_output" != *RESTORE_MODE_UNSUPPORTED* ]]; then
- exit 1
- fi
- printf 'native clone: ondemand unavailable; falling back to copy restore\n'
- kb clone "$native_snapshot" --name e2e-native-clone --network "$network" --restore-mode copy >/dev/null
-fi
-step "native clone post-return agent probe"
-wait_agent e2e-native-clone
-[[ $(kb exec e2e-native-clone -- cat /var/tmp/e2e-native) == native ]]
-kb delete e2e-native-source --force >/dev/null
-kb delete e2e-native-clone --force >/dev/null
-
-step "disk hotplug"
-run_vm e2e-hotplug "$network" >/dev/null
-wait_agent e2e-hotplug
-disk=/var/lib/kumabox/e2e-hotplug.raw
-"${run[@]}" truncate -s 8M "$disk"
-kb disk attach e2e-hotplug --path "$disk" --name e2e-data >/dev/null
-kb device state e2e-hotplug | jq -e '.attachedDisks | any(.[]; .name == "e2e-data")' >/dev/null
-kb disk detach e2e-hotplug --name e2e-data >/dev/null
-kb device state e2e-hotplug | jq -e '(.attachedDisks // []) | length == 0' >/dev/null
-kb network resize e2e-hotplug --nics 2 >/dev/null
-kb network resize e2e-hotplug --nics 1 >/dev/null
-if [[ -n "$fs_socket" ]]; then
- [[ -S "$fs_socket" ]] || { echo "virtio-fs socket is not available: $fs_socket" >&2; exit 1; }
- kb fs attach e2e-hotplug --socket "$fs_socket" --tag e2e-share >/dev/null
- kb device state e2e-hotplug | jq -e '.attachedFilesystems | any(.[]; .tag == "e2e-share")' >/dev/null
- kb fs detach e2e-hotplug --tag e2e-share >/dev/null
-fi
-if [[ -n "$pci_bdf" ]]; then
- kb device attach e2e-hotplug --pci "$pci_bdf" --id e2e-pci >/dev/null
- kb device state e2e-hotplug | jq -e '.attachedPCIDevices | any(.[]; .id == "e2e-pci")' >/dev/null
- kb device detach e2e-hotplug --id e2e-pci >/dev/null
-fi
-kb delete e2e-hotplug --force >/dev/null
-
-if [[ "$metadata_backend" == sqlite ]]; then
- step "SQLite metadata backup"
- kb metadata backup /var/lib/kumabox/e2e-metadata-backup.db | jq -e '.verified == true and .sizeBytes > 0' >/dev/null
-fi
-
-[[ "$keep" == true ]] || cleanup
-trap - EXIT
-printf '\nPASS: KumaBox Linux E2E completed\n'
diff --git a/test/release/check.sh b/test/release/check.sh
deleted file mode 100755
index f9e37a7..0000000
--- a/test/release/check.sh
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/usr/bin/env bash
-set -Eeuo pipefail
-
-repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
-checker="$repo_root/scripts/check.sh"
-
-help=$($checker --help)
-grep -Fq -- '--subnet CIDR' <<< "$help"
-grep -Fq -- '--metadata-backend NAME' <<< "$help"
-
-if "$checker" --subnet >/dev/null 2>&1; then
- echo "kumabox-check accepted --subnet without a value" >&2
- exit 1
-fi
-if "$checker" --metadata-backend invalid >/dev/null 2>&1; then
- echo "kumabox-check accepted an invalid metadata backend" >&2
- exit 1
-fi
-
-echo "release host-check tests passed"
diff --git a/test/release/install.sh b/test/release/install.sh
deleted file mode 100755
index 7fca257..0000000
--- a/test/release/install.sh
+++ /dev/null
@@ -1,61 +0,0 @@
-#!/usr/bin/env bash
-set -Eeuo pipefail
-
-repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
-work_dir=$(mktemp -d)
-trap 'rm -rf "$work_dir"' EXIT
-
-mkdir -p "$work_dir/bin" "$work_dir/release" "$work_dir/payload"
-cat > "$work_dir/bin/uname" <<'EOF'
-#!/usr/bin/env sh
-case "${1:-}" in
- -s) printf 'Linux\n' ;;
- -m) printf 'x86_64\n' ;;
- *) printf 'Linux\n' ;;
-esac
-EOF
-chmod +x "$work_dir/bin/uname"
-
-printf '#!/usr/bin/env sh\nprintf "kumabox fixture\\n"\n' > "$work_dir/payload/kumabox"
-printf '#!/usr/bin/env sh\nprintf "check fixture\\n"\n' > "$work_dir/payload/kumabox-check"
-chmod +x "$work_dir/payload/kumabox" "$work_dir/payload/kumabox-check"
-
-archive=kumabox-vtest-linux-amd64.tar.gz
-tar -C "$work_dir/payload" -czf "$work_dir/release/$archive" kumabox kumabox-check
-if command -v sha256sum >/dev/null 2>&1; then
- sha256sum "$work_dir/release/$archive" > "$work_dir/release/$archive.sha256"
-else
- shasum -a 256 "$work_dir/release/$archive" > "$work_dir/release/$archive.sha256"
-fi
-
-PATH="$work_dir/bin:$PATH" \
- KUMABOX_RELEASE_BASE_URL="file://$work_dir/release" \
- sh "$repo_root/scripts/install.sh" --version vtest --install-dir "$work_dir/install"
-
-"$work_dir/install/kumabox" | grep -Fx 'kumabox fixture'
-"$work_dir/install/kumabox-check" | grep -Fx 'check fixture'
-
-tar -C "$work_dir/payload" -czf "$work_dir/release/$archive" kumabox
-if command -v sha256sum >/dev/null 2>&1; then
- sha256sum "$work_dir/release/$archive" > "$work_dir/release/$archive.sha256"
-else
- shasum -a 256 "$work_dir/release/$archive" > "$work_dir/release/$archive.sha256"
-fi
-if PATH="$work_dir/bin:$PATH" \
- KUMABOX_RELEASE_BASE_URL="file://$work_dir/release" \
- sh "$repo_root/scripts/install.sh" --version vtest --install-dir "$work_dir/incomplete" \
- >/dev/null 2>&1; then
- echo "installer accepted an incomplete release archive" >&2
- exit 1
-fi
-
-printf '0 %s\n' "$archive" > "$work_dir/release/$archive.sha256"
-if PATH="$work_dir/bin:$PATH" \
- KUMABOX_RELEASE_BASE_URL="file://$work_dir/release" \
- sh "$repo_root/scripts/install.sh" --version vtest --install-dir "$work_dir/rejected" \
- >/dev/null 2>&1; then
- echo "installer accepted an invalid checksum" >&2
- exit 1
-fi
-
-echo "release installer tests passed"
diff --git a/testdata/oci-layout/README.md b/testdata/oci-layout/README.md
new file mode 100644
index 0000000..18ed681
--- /dev/null
+++ b/testdata/oci-layout/README.md
@@ -0,0 +1 @@
+Synthetic linux/amd64 OCI fixture for import integrity tests. Boot files are placeholders and cannot boot a VM. No registry or real kernel is required.
diff --git a/testdata/oci-layout/blobs/sha256/32faa29cb5ac59d05ca95d62e87d8460221bbbf72cf1489cecd2b6f521621a94 b/testdata/oci-layout/blobs/sha256/32faa29cb5ac59d05ca95d62e87d8460221bbbf72cf1489cecd2b6f521621a94
new file mode 100644
index 0000000..15e828e
--- /dev/null
+++ b/testdata/oci-layout/blobs/sha256/32faa29cb5ac59d05ca95d62e87d8460221bbbf72cf1489cecd2b6f521621a94
@@ -0,0 +1 @@
+{"architecture":"amd64","config":{},"os":"linux","rootfs":{"diff_ids":["sha256:61308342ad8a3a3b3d436c7884bab8d9062bd87957cbe523c08512b0063b43b8"],"type":"layers"}}
\ No newline at end of file
diff --git a/testdata/oci-layout/blobs/sha256/7015d0740b402b509b498898c82b8e7300d0b0a80d6b75121d0593cd3cbe8c7a b/testdata/oci-layout/blobs/sha256/7015d0740b402b509b498898c82b8e7300d0b0a80d6b75121d0593cd3cbe8c7a
new file mode 100644
index 0000000..ddf2528
--- /dev/null
+++ b/testdata/oci-layout/blobs/sha256/7015d0740b402b509b498898c82b8e7300d0b0a80d6b75121d0593cd3cbe8c7a
@@ -0,0 +1 @@
+{"config":{"digest":"sha256:32faa29cb5ac59d05ca95d62e87d8460221bbbf72cf1489cecd2b6f521621a94","mediaType":"application/vnd.oci.image.config.v1+json","size":163},"layers":[{"digest":"sha256:796bfaa94f0cc4aa292d1d0576401129636cfdd922865438977769077f926f15","mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":152}],"mediaType":"application/vnd.oci.image.manifest.v1+json","schemaVersion":2}
\ No newline at end of file
diff --git a/testdata/oci-layout/blobs/sha256/796bfaa94f0cc4aa292d1d0576401129636cfdd922865438977769077f926f15 b/testdata/oci-layout/blobs/sha256/796bfaa94f0cc4aa292d1d0576401129636cfdd922865438977769077f926f15
new file mode 100644
index 0000000..b496e2b
Binary files /dev/null and b/testdata/oci-layout/blobs/sha256/796bfaa94f0cc4aa292d1d0576401129636cfdd922865438977769077f926f15 differ
diff --git a/testdata/oci-layout/index.json b/testdata/oci-layout/index.json
new file mode 100644
index 0000000..acfbb0c
--- /dev/null
+++ b/testdata/oci-layout/index.json
@@ -0,0 +1 @@
+{"manifests":[{"digest":"sha256:7015d0740b402b509b498898c82b8e7300d0b0a80d6b75121d0593cd3cbe8c7a","mediaType":"application/vnd.oci.image.manifest.v1+json","platform":{"architecture":"amd64","os":"linux"},"size":401}],"mediaType":"application/vnd.oci.image.index.v1+json","schemaVersion":2}
\ No newline at end of file
diff --git a/testdata/oci-layout/oci-layout b/testdata/oci-layout/oci-layout
new file mode 100644
index 0000000..1343d37
--- /dev/null
+++ b/testdata/oci-layout/oci-layout
@@ -0,0 +1 @@
+{"imageLayoutVersion":"1.0.0"}
\ No newline at end of file
diff --git a/types/image.go b/types/image.go
new file mode 100644
index 0000000..a032c47
--- /dev/null
+++ b/types/image.go
@@ -0,0 +1,167 @@
+package types
+
+import (
+ "encoding/hex"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+)
+
+const (
+ // ImageBootProfileLabel is the OCI config label used by an image to declare
+ // the host/guest boot contract it implements.
+ ImageBootProfileLabel = "io.kumabox.boot.profile"
+)
+
+// BootProfile names a versioned contract between the VMM launch plan and the
+// image's early userspace. An empty value means that an older image did not
+// declare a contract; consumers must not infer one from boot filenames.
+type BootProfile string
+
+const (
+ // BootProfileOverlayV1 mounts EROFS layers named kumabox-layerN over an ext4
+ // disk named kumabox-cow. The kernel command line selects kumabox-overlay and
+ // supplies kumabox.layers plus kumabox.cow.
+ BootProfileOverlayV1 BootProfile = "overlay-v1"
+)
+
+// Digest is a validated SHA-256 content identity.
+type Digest struct {
+ // value prevents constructing malformed textual identities outside this package.
+ value [32]byte
+}
+
+// ParseDigest accepts only canonical lowercase sha256:<64 hex digits> identities.
+func ParseDigest(value string) (Digest, error) {
+ if !strings.HasPrefix(value, "sha256:") || len(value) != 71 || value != strings.ToLower(value) {
+ return Digest{}, fmt.Errorf("invalid sha256 digest %q", value)
+ }
+ hexValue := strings.TrimPrefix(value, "sha256:")
+ decoded, err := hex.DecodeString(hexValue)
+ if err != nil || len(decoded) != 32 {
+ return Digest{}, fmt.Errorf("invalid sha256 digest %q", value)
+ }
+ var digest Digest
+ copy(digest.value[:], decoded)
+ return digest, nil
+}
+
+// String returns the canonical algorithm-prefixed identity.
+func (d Digest) String() string { return "sha256:" + hex.EncodeToString(d.value[:]) }
+
+// Hex returns the hexadecimal identity used in managed artifact filenames.
+func (d Digest) Hex() string { return hex.EncodeToString(d.value[:]) }
+
+// IsZero reports the unset identity, which image metadata must not use.
+func (d Digest) IsZero() bool { return d == Digest{} }
+
+// MarshalText encodes the canonical identity for text and JSON serialization.
+func (d Digest) MarshalText() ([]byte, error) { return []byte(d.String()), nil }
+
+// UnmarshalText validates an identity before replacing the receiver.
+func (d *Digest) UnmarshalText(value []byte) error {
+ parsed, err := ParseDigest(string(value))
+ if err != nil {
+ return err
+ }
+ *d = parsed
+ return nil
+}
+
+// Platform selects the operating system and instruction set of an image.
+type Platform struct {
+ // OS is the source operating system; KumaBox currently accepts linux.
+ OS string
+ // Architecture is the source instruction set: amd64 or arm64.
+ Architecture string
+}
+
+// Layer describes one converted artifact and its boot overlay effects.
+// Layers remain in source manifest order, from the base to the top layer.
+type Layer struct {
+ // SourceDigest identifies the original source blob and keys shared artifacts.
+ SourceDigest Digest
+ // EROFSDigest verifies the converted filesystem, not the source blob.
+ EROFSDigest Digest
+ // Size is the converted EROFS size in bytes.
+ Size int64
+ // BootFiles contains extracted regular kernel and initrd candidates.
+ BootFiles []BootFile
+ // Whiteouts names boot candidates hidden from lower layers.
+ Whiteouts []string
+ // BootOpaque hides all boot candidates inherited from lower layers.
+ BootOpaque bool
+}
+
+// BootFile is a regular boot candidate extracted from a source layer.
+type BootFile struct {
+ // Name is an accepted /boot basename, without parent directories.
+ Name string
+ // Digest verifies the extracted artifact, including any kernel decompression.
+ Digest Digest
+ // Size is the extracted artifact size in bytes and must be positive.
+ Size int64
+}
+
+// Boot identifies the surviving kernel and initrd selected across all layers.
+type Boot struct {
+ // Profile is the declared host/guest boot contract. Empty means undeclared.
+ Profile BootProfile
+ // KernelFile is the selected kernel basename within its layer's boot directory.
+ KernelFile string
+ // InitrdFile is the selected initrd basename within its layer's boot directory.
+ InitrdFile string
+ // KernelLayer identifies the source layer that supplied KernelFile.
+ KernelLayer Digest
+ // InitrdLayer identifies the source layer that supplied InitrdFile.
+ InitrdLayer Digest
+}
+
+// Image is a committed manifest together with its local names and artifacts.
+type Image struct {
+ // Names contains local aliases bound to the manifest, sorted by the catalog.
+ Names []string
+ // ManifestDigest identifies the resolved source manifest or its normalized form.
+ ManifestDigest Digest
+ // Platform is the operating system and instruction set of all layers.
+ Platform Platform
+ // Layers preserves manifest order, including repeated source layers.
+ Layers []Layer
+ // Boot records the kernel and initrd selected after applying overlay rules.
+ Boot Boot
+ // Size sums the EROFS sizes in Layers, including repeated occurrences.
+ Size int64
+ // CreatedAt is the initial local import time, not the source image build time.
+ CreatedAt time.Time
+}
+
+// Manifest is the format-independent result of resolving a source for a platform.
+type Manifest struct {
+ // Digest identifies this manifest; archive adapters may synthesize it.
+ Digest Digest
+ // Platform must match the platform requested from Source.Resolve.
+ Platform Platform
+ // BootProfile is copied from the OCI config label without guessing a default.
+ BootProfile BootProfile
+ // Layers lists original source blobs in filesystem overlay order.
+ Layers []Descriptor
+}
+
+// Descriptor identifies a layer blob before decompression or conversion.
+type Descriptor struct {
+ // Digest identifies the source bytes and remains the converted artifact's key.
+ Digest Digest
+ // Size is the source blob size in bytes, not its unpacked or EROFS size.
+ Size int64
+}
+
+// Valid reports whether the platform is supported by KumaBox.
+func (p Platform) Valid() bool {
+ return p.OS == "linux" && (p.Architecture == "amd64" || p.Architecture == "arm64")
+}
+
+// Equal compares the content and boot metadata of two layer artifacts.
+func (a Layer) Equal(b Layer) bool {
+ return a.SourceDigest == b.SourceDigest && a.EROFSDigest == b.EROFSDigest && a.Size == b.Size && a.BootOpaque == b.BootOpaque && slices.Equal(a.BootFiles, b.BootFiles) && slices.Equal(a.Whiteouts, b.Whiteouts)
+}
diff --git a/types/network.go b/types/network.go
new file mode 100644
index 0000000..ce96371
--- /dev/null
+++ b/types/network.go
@@ -0,0 +1,155 @@
+package types
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "path/filepath"
+ "strconv"
+)
+
+// NetworkBackend identifies the host networking implementation that owns a
+// sandbox's durable network resources.
+type NetworkBackend string
+
+const (
+ // NetworkBackendCNI selects a CNI plugin chain running in a private network
+ // namespace.
+ NetworkBackendCNI NetworkBackend = "cni"
+)
+
+// Validate rejects backend names that cannot be routed to an implementation.
+func (b NetworkBackend) Validate() error {
+ switch b {
+ case NetworkBackendCNI:
+ return nil
+ default:
+ return fmt.Errorf("unsupported network backend %q", b)
+ }
+}
+
+// IPv4Config is the guest-visible address returned by an infrastructure
+// network provider.
+type IPv4Config struct {
+ // Address is one IPv4 address without its prefix length.
+ Address string
+ // Gateway is an optional IPv4 default gateway.
+ Gateway string
+ // Prefix is the CIDR prefix length in bits.
+ Prefix int
+}
+
+// Validate rejects malformed or non-IPv4 addresses before they are persisted
+// or rendered into the guest boot contract.
+func (c IPv4Config) Validate() error {
+ ip := net.ParseIP(c.Address)
+ if ip == nil || ip.To4() == nil {
+ return fmt.Errorf("network address %q is not IPv4", c.Address)
+ }
+ if c.Prefix < 0 || c.Prefix > 32 {
+ return fmt.Errorf("network prefix %d is outside 0..32", c.Prefix)
+ }
+ if c.Gateway != "" {
+ gateway := net.ParseIP(c.Gateway)
+ if gateway == nil || gateway.To4() == nil {
+ return fmt.Errorf("network gateway %q is not IPv4", c.Gateway)
+ }
+ }
+ return nil
+}
+
+// NetworkInterface contains the durable handoff from host networking to a VMM.
+// Provider-private cleanup phases and CNI record identifiers are deliberately
+// excluded from this shared value object.
+type NetworkInterface struct {
+ // Index is the zero-based NIC position used to derive the guest name.
+ Index int
+ // Name is the interface name created by CNI inside the private namespace.
+ Name string
+ // TAP is the device opened by the VMM.
+ TAP string
+ // MAC is the stable guest hardware address.
+ MAC string
+ // Queues is the total RX and TX virtio queue count.
+ Queues int
+ // QueueSize is the descriptor count for each virtio queue.
+ QueueSize int
+ // Network is the resolved CNI conflist name.
+ Network string
+ // IPv4 is nil when a plugin intentionally returns no IPv4 address.
+ IPv4 *IPv4Config
+}
+
+// Validate checks the provider-to-VMM handoff independently of persistence and
+// command presentation.
+func (c NetworkInterface) Validate() error {
+ if c.Index < 0 {
+ return errors.New("network interface index must not be negative")
+ }
+ if c.Name != "eth"+strconv.Itoa(c.Index) {
+ return fmt.Errorf("network interface %d must be named eth%d", c.Index, c.Index)
+ }
+ if c.TAP == "" || c.Network == "" {
+ return errors.New("network interface requires TAP and network names")
+ }
+ if _, err := net.ParseMAC(c.MAC); err != nil {
+ return fmt.Errorf("network interface MAC %q: %w", c.MAC, err)
+ }
+ if c.Queues < 2 || c.Queues%2 != 0 || c.QueueSize <= 0 {
+ return errors.New("network interface requires an even queue count of at least two and a positive queue size")
+ }
+ if c.IPv4 != nil {
+ if err := c.IPv4.Validate(); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// NetworkSetup is the complete durable network state of one sandbox. Its zero
+// value represents a sandbox created without networking.
+type NetworkSetup struct {
+ // Backend selects the provider used by later lifecycle operations.
+ Backend NetworkBackend
+ // Namespace is the absolute Linux network namespace path containing the
+ // CNI interfaces and TAP devices.
+ Namespace string
+ // Interfaces are ordered by their stable NIC index.
+ Interfaces []NetworkInterface
+}
+
+// Validate accepts the disabled zero value and otherwise checks a complete,
+// deterministic provider handoff.
+func (s NetworkSetup) Validate() error {
+ if s.Backend == "" {
+ if s.Namespace != "" || len(s.Interfaces) != 0 {
+ return errors.New("network setup without a backend must be empty")
+ }
+ return nil
+ }
+ if err := s.Backend.Validate(); err != nil {
+ return err
+ }
+ if !filepath.IsAbs(s.Namespace) {
+ return errors.New("network namespace must be an absolute path")
+ }
+ seen := make(map[int]struct{}, len(s.Interfaces))
+ previous := -1
+ for position, networkInterface := range s.Interfaces {
+ if err := networkInterface.Validate(); err != nil {
+ return fmt.Errorf("network interface %d: %w", networkInterface.Index, err)
+ }
+ if _, exists := seen[networkInterface.Index]; exists {
+ return fmt.Errorf("network interface index %d is duplicated", networkInterface.Index)
+ }
+ if networkInterface.Index <= previous {
+ return errors.New("network interfaces must be ordered by increasing index")
+ }
+ if networkInterface.Index != position {
+ return errors.New("network interface indices must be contiguous from zero")
+ }
+ seen[networkInterface.Index] = struct{}{}
+ previous = networkInterface.Index
+ }
+ return nil
+}
diff --git a/types/network_test.go b/types/network_test.go
new file mode 100644
index 0000000..ade63a1
--- /dev/null
+++ b/types/network_test.go
@@ -0,0 +1,45 @@
+package types
+
+import "testing"
+
+func TestNetworkSetupValidatesDurableHandoff(t *testing.T) {
+ setup := NetworkSetup{
+ Backend: NetworkBackendCNI,
+ Namespace: "/var/run/netns/kb-sandbox",
+ Interfaces: []NetworkInterface{{
+ Index: 0, Name: "eth0", TAP: "tap12345678-0", MAC: "02:00:00:00:00:01",
+ Queues: 4, QueueSize: 512, Network: "bridge",
+ IPv4: &IPv4Config{Address: "10.42.0.7", Gateway: "10.42.0.1", Prefix: 24},
+ }},
+ }
+ if err := setup.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ setup.Interfaces = append(setup.Interfaces, setup.Interfaces[0])
+ if err := setup.Validate(); err == nil {
+ t.Fatal("duplicate network interface was accepted")
+ }
+}
+
+func TestNetworkSetupZeroValueDisablesNetworking(t *testing.T) {
+ if err := (NetworkSetup{}).Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if err := (NetworkSetup{Namespace: "/var/run/netns/unowned"}).Validate(); err == nil {
+ t.Fatal("namespace without backend was accepted")
+ }
+}
+
+func TestNetworkSetupRejectsNonContiguousInterfaceIndices(t *testing.T) {
+ setup := NetworkSetup{
+ Backend: NetworkBackendCNI,
+ Namespace: "/var/run/netns/kb-sandbox",
+ Interfaces: []NetworkInterface{{
+ Index: 1, Name: "eth1", TAP: "tap12345678-1", MAC: "02:00:00:00:00:02",
+ Queues: 2, QueueSize: 512, Network: "bridge",
+ }},
+ }
+ if err := setup.Validate(); err == nil {
+ t.Fatal("NetworkSetup accepted an interface sequence that does not begin at zero")
+ }
+}
diff --git a/types/sandbox.go b/types/sandbox.go
new file mode 100644
index 0000000..05d6dc1
--- /dev/null
+++ b/types/sandbox.go
@@ -0,0 +1,286 @@
+// Package types defines data contracts shared across KumaBox modules.
+// It contains resource models and value objects, not service interfaces,
+// persistence encodings, or command presentation types.
+package types
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+)
+
+const (
+ // DefaultSandboxCPUs is the default virtual CPU count.
+ DefaultSandboxCPUs uint32 = 2
+ // DefaultSandboxMemory is one gibibyte.
+ DefaultSandboxMemory int64 = 1 << 30
+ // DefaultSandboxStorage is a ten-gibibyte logical sparse COW disk.
+ DefaultSandboxStorage int64 = 10 << 30
+ // MinSandboxMemory rejects guests too small for the supported boot path.
+ MinSandboxMemory int64 = 512 << 20
+ // MinSandboxStorage is the minimum supported COW capacity.
+ MinSandboxStorage int64 = 10 << 30
+ // MaxSandboxCPUs bounds conversion to host-native integer APIs and unreasonable shapes.
+ MaxSandboxCPUs uint32 = 1024
+ // MaxSandboxNICs bounds host resource allocation from one create request.
+ MaxSandboxNICs = 64
+)
+
+var (
+ validSandboxName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$`)
+ validNetworkName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$`)
+)
+
+// VMMType identifies the virtual machine monitor that owns a sandbox's
+// runtime. It is persisted so every later lifecycle operation selects the same
+// backend that created the sandbox.
+type VMMType string
+
+const (
+ // VMMCloudHypervisor selects the Cloud Hypervisor process adapter.
+ VMMCloudHypervisor VMMType = "cloud-hypervisor"
+ // VMMFirecracker reserves the stable identity for the future Firecracker adapter.
+ VMMFirecracker VMMType = "firecracker"
+)
+
+// Validate rejects unknown VMM identities before they reach backend routing.
+func (v VMMType) Validate() error {
+ switch v {
+ case VMMCloudHypervisor, VMMFirecracker:
+ return nil
+ default:
+ return fmt.Errorf("unsupported VMM %q", v)
+ }
+}
+
+// SandboxState records a durable lifecycle fact. Its zero value is invalid so
+// omitted metadata cannot be mistaken for a usable sandbox.
+type SandboxState string
+
+const (
+ // SandboxStateCreating owns the name, image reference, and any partially prepared disk.
+ SandboxStateCreating SandboxState = "creating"
+ // SandboxStateCreated means persistent resources are ready and have never been started.
+ SandboxStateCreated SandboxState = "created"
+ // SandboxStateStarting means a start operation owns runtime preparation.
+ SandboxStateStarting SandboxState = "starting"
+ // SandboxStateRunning means the owned VMM process passed runtime validation.
+ SandboxStateRunning SandboxState = "running"
+ // SandboxStateStopping means a stop operation is driving the process toward exit.
+ SandboxStateStopping SandboxState = "stopping"
+ // SandboxStateStopped means a previously started sandbox has exited.
+ SandboxStateStopped SandboxState = "stopped"
+ // SandboxStateError retains ownership when cleanup or a lifecycle transition is incomplete.
+ SandboxStateError SandboxState = "error"
+ // SandboxStateDeleting retains image and resource ownership until removal finishes.
+ SandboxStateDeleting SandboxState = "deleting"
+)
+
+// SandboxID is a canonical lowercase UUIDv4 used for metadata keys and managed paths.
+type SandboxID string
+
+// NewSandboxID generates a UUIDv4 from the operating system's cryptographic random source.
+func NewSandboxID() (SandboxID, error) {
+ var value [16]byte
+ if _, err := rand.Read(value[:]); err != nil {
+ return "", fmt.Errorf("generate sandbox ID: %w", err)
+ }
+ value[6] = value[6]&0x0f | 0x40
+ value[8] = value[8]&0x3f | 0x80
+ encoded := make([]byte, 36)
+ hex.Encode(encoded[0:8], value[0:4])
+ encoded[8] = '-'
+ hex.Encode(encoded[9:13], value[4:6])
+ encoded[13] = '-'
+ hex.Encode(encoded[14:18], value[6:8])
+ encoded[18] = '-'
+ hex.Encode(encoded[19:23], value[8:10])
+ encoded[23] = '-'
+ hex.Encode(encoded[24:36], value[10:16])
+ return SandboxID(encoded), nil
+}
+
+// ParseSandboxID validates the canonical UUIDv4 representation used by managed paths.
+func ParseSandboxID(value string) (SandboxID, error) {
+ if len(value) != 36 || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' || value[14] != '4' {
+ return "", fmt.Errorf("invalid sandbox ID %q", value)
+ }
+ compact := value[0:8] + value[9:13] + value[14:18] + value[19:23] + value[24:36]
+ decoded, err := hex.DecodeString(compact)
+ if err != nil || len(decoded) != 16 || decoded[8]&0xc0 != 0x80 {
+ return "", fmt.Errorf("invalid sandbox ID %q", value)
+ }
+ for _, char := range value {
+ if char >= 'A' && char <= 'F' {
+ return "", fmt.Errorf("invalid sandbox ID %q", value)
+ }
+ }
+ return SandboxID(value), nil
+}
+
+// String returns the canonical identifier.
+func (id SandboxID) String() string { return string(id) }
+
+// SandboxConfig is the immutable resource request stored with a sandbox.
+type SandboxConfig struct {
+ // Name is the human-readable lookup key and is never used as a path component.
+ Name string
+ // CPUs is the number of virtual CPUs exposed to the guest.
+ CPUs uint32
+ // Memory is guest memory in bytes.
+ Memory int64
+ // Storage is the logical size of the sparse ext4 COW disk in bytes.
+ Storage int64
+ // NICs is the requested network interface count; zero disables networking.
+ NICs int
+ // NetworkName selects one CNI conflist. Empty selects the provider default
+ // and is replaced by the resolved name when creation commits.
+ NetworkName string
+}
+
+// Validate enforces the resource and naming contract before any persistent change.
+func (c SandboxConfig) Validate() error {
+ if !validSandboxName.MatchString(c.Name) {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("sandbox name %q must match %s", c.Name, validSandboxName))
+ }
+ if c.CPUs == 0 || c.CPUs > MaxSandboxCPUs {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("CPU count must be between 1 and %d", MaxSandboxCPUs))
+ }
+ if c.Memory < MinSandboxMemory {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("memory must be at least %d bytes", MinSandboxMemory))
+ }
+ if c.Storage < MinSandboxStorage {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("storage must be at least %d bytes", MinSandboxStorage))
+ }
+ if c.NICs < 0 || c.NICs > MaxSandboxNICs {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("NIC count must be between 0 and %d", MaxSandboxNICs))
+ }
+ if c.NICs == 0 && c.NetworkName != "" {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("network name requires at least one NIC"))
+ }
+ if c.NetworkName != "" && !validNetworkName.MatchString(c.NetworkName) {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("network name %q must match %s", c.NetworkName, validNetworkName))
+ }
+ return nil
+}
+
+// Command describes one process invocation inside a running sandbox. It is a
+// shared value object rather than a guest-agent frame or CLI input model.
+type Command struct {
+ // Args contains the executable followed by its arguments. KumaBox never
+ // inserts a shell between this list and the guest process.
+ Args []string
+ // Env contains caller-provided environment overrides by variable name.
+ Env map[string]string
+}
+
+// Validate rejects malformed commands before a guest-agent connection opens.
+func (c Command) Validate() error {
+ if len(c.Args) == 0 || c.Args[0] == "" {
+ return errors.New("COMMAND must not be empty")
+ }
+ for _, argument := range c.Args {
+ if strings.IndexByte(argument, 0) >= 0 {
+ return errors.New("command arguments must not contain NUL bytes")
+ }
+ }
+ for key, value := range c.Env {
+ if key == "" {
+ return errors.New("environment variable name must not be empty")
+ }
+ if strings.ContainsAny(key, "=\x00") {
+ return fmt.Errorf("environment variable name %q must not contain '=' or NUL bytes", key)
+ }
+ if strings.IndexByte(value, 0) >= 0 {
+ return fmt.Errorf("environment variable %q must not contain NUL bytes", key)
+ }
+ }
+ return nil
+}
+
+// SandboxFailure records why an intermediate sandbox still owns resources and needs inspection.
+type SandboxFailure struct {
+ // Phase locates the failed operation step.
+ Phase string
+ // Message is diagnostic text for operators and is not a stable error code.
+ Message string
+}
+
+// Sandbox is the durable resource aggregate guarded by a generation compare-and-swap.
+type Sandbox struct {
+ // ID is the immutable metadata and filesystem identity.
+ ID SandboxID
+ // Config is the immutable requested guest shape.
+ Config SandboxConfig
+ // ImageDigest pins the exact manifest independently of a mutable local alias.
+ ImageDigest Digest
+ // VMM selects the backend that owns this sandbox's runtime artifacts.
+ VMM VMMType
+ // Network is the resolved provider-to-VMM handoff. It remains empty while a
+ // networked sandbox is still Creating and cleanup may be incomplete.
+ Network NetworkSetup
+ // State controls which operations may consume owned resources.
+ State SandboxState
+ // Generation increments on every state transition and fences stale operations.
+ Generation uint64
+ // Failure is present only when SandboxStateError retains incomplete work.
+ Failure *SandboxFailure
+ // CreatedAt is the first successful identity reservation time.
+ CreatedAt time.Time
+ // UpdatedAt is the latest committed transition time.
+ UpdatedAt time.Time
+}
+
+// Validate rejects incomplete sandbox data before adapters persist or return it.
+func (s Sandbox) Validate() error {
+ if _, err := ParseSandboxID(s.ID.String()); err != nil {
+ return err
+ }
+ if err := s.Config.Validate(); err != nil {
+ return err
+ }
+ if s.ImageDigest.IsZero() || s.Generation == 0 || s.CreatedAt.IsZero() || s.UpdatedAt.IsZero() {
+ return errors.New("sandbox image, generation, and timestamps must be set")
+ }
+ if err := s.VMM.Validate(); err != nil {
+ return err
+ }
+ if err := s.Network.Validate(); err != nil {
+ return err
+ }
+ if s.Config.NICs == 0 && s.Network.Backend != "" {
+ return errors.New("sandbox without NICs must not contain network setup")
+ }
+ if s.Network.Backend != "" {
+ if len(s.Network.Interfaces) != s.Config.NICs {
+ return fmt.Errorf("sandbox has %d network interfaces, expected %d", len(s.Network.Interfaces), s.Config.NICs)
+ }
+ for _, networkInterface := range s.Network.Interfaces {
+ if networkInterface.Network != s.Config.NetworkName {
+ return errors.New("sandbox network interface differs from the resolved network name")
+ }
+ }
+ } else if s.Config.NICs > 0 {
+ switch s.State {
+ case SandboxStateCreating, SandboxStateError, SandboxStateDeleting:
+ default:
+ return errors.New("networked sandbox state requires resolved network setup")
+ }
+ }
+ switch s.State {
+ case SandboxStateCreating, SandboxStateCreated, SandboxStateStarting, SandboxStateRunning,
+ SandboxStateStopping, SandboxStateStopped, SandboxStateError, SandboxStateDeleting:
+ default:
+ return fmt.Errorf("invalid sandbox state %q", s.State)
+ }
+ if (s.State == SandboxStateError) != (s.Failure != nil) {
+ return errors.New("sandbox failure must be present only in error state")
+ }
+ return nil
+}
diff --git a/types/sandbox_test.go b/types/sandbox_test.go
new file mode 100644
index 0000000..6f9ea23
--- /dev/null
+++ b/types/sandbox_test.go
@@ -0,0 +1,68 @@
+package types
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSandboxID(t *testing.T) {
+ id, err := NewSandboxID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if parsed, err := ParseSandboxID(id.String()); err != nil || parsed != id {
+ t.Fatalf("ParseSandboxID(%q) = %q, %v", id, parsed, err)
+ }
+ if id.String()[14] != '4' || !strings.ContainsRune("89ab", rune(id.String()[19])) {
+ t.Fatalf("ID %q is not UUIDv4", id)
+ }
+}
+
+func TestSandboxConfigValidationMatchesCreateContract(t *testing.T) {
+ valid := SandboxConfig{Name: "agent.demo-1", CPUs: 1, Memory: MinSandboxMemory, Storage: MinSandboxStorage}
+ if err := valid.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ for _, test := range []struct {
+ name string
+ config SandboxConfig
+ }{
+ {"name", SandboxConfig{Name: "bad/name", CPUs: 1, Memory: MinSandboxMemory, Storage: MinSandboxStorage}},
+ {"cpus", SandboxConfig{Name: "demo", Memory: MinSandboxMemory, Storage: MinSandboxStorage}},
+ {"memory", SandboxConfig{Name: "demo", CPUs: 1, Memory: MinSandboxMemory - 1, Storage: MinSandboxStorage}},
+ {"storage", SandboxConfig{Name: "demo", CPUs: 1, Memory: MinSandboxMemory, Storage: MinSandboxStorage - 1}},
+ {"NIC count", SandboxConfig{Name: "demo", CPUs: 1, Memory: MinSandboxMemory, Storage: MinSandboxStorage, NICs: MaxSandboxNICs + 1}},
+ {"network without NIC", SandboxConfig{Name: "demo", CPUs: 1, Memory: MinSandboxMemory, Storage: MinSandboxStorage, NetworkName: "default"}},
+ {"network name", SandboxConfig{Name: "demo", CPUs: 1, Memory: MinSandboxMemory, Storage: MinSandboxStorage, NICs: 1, NetworkName: "bad/name"}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ err := test.config.Validate()
+ if err == nil {
+ t.Fatal("invalid spec passed validation")
+ }
+ if strings.Contains(err.Error(), "--") {
+ t.Fatalf("domain validation leaked CLI flag syntax: %v", err)
+ }
+ })
+ }
+}
+
+func TestCommandValidation(t *testing.T) {
+ command := Command{Args: []string{"sh", "-c", "echo"}, Env: map[string]string{"A": "2", "EMPTY": ""}}
+ if err := command.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ for _, invalid := range []Command{
+ {},
+ {Args: []string{""}},
+ {Args: []string{"echo", "bad\x00argument"}},
+ {Args: []string{"env"}, Env: map[string]string{"": "missing-key"}},
+ {Args: []string{"env"}, Env: map[string]string{"BAD=KEY": "value"}},
+ {Args: []string{"env"}, Env: map[string]string{"BAD\x00KEY": "value"}},
+ {Args: []string{"env"}, Env: map[string]string{"KEY": "bad\x00value"}},
+ } {
+ if err := invalid.Validate(); err == nil {
+ t.Fatalf("accepted invalid command %#v", invalid)
+ }
+ }
+}
diff --git a/types/snapshot.go b/types/snapshot.go
new file mode 100644
index 0000000..e8f1d88
--- /dev/null
+++ b/types/snapshot.go
@@ -0,0 +1,85 @@
+package types
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "time"
+)
+
+var validSnapshotName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,62}$`)
+
+// SnapshotID is the immutable UUIDv4 identity of one captured sandbox state.
+type SnapshotID string
+
+// NewSnapshotID generates a snapshot identity from the same cryptographic UUID
+// source used for sandboxes.
+func NewSnapshotID() (SnapshotID, error) {
+ id, err := NewSandboxID()
+ return SnapshotID(id), err
+}
+
+// ParseSnapshotID validates the canonical UUIDv4 representation.
+func ParseSnapshotID(value string) (SnapshotID, error) {
+ if _, err := ParseSandboxID(value); err != nil {
+ return "", fmt.Errorf("invalid snapshot ID %q", value)
+ }
+ return SnapshotID(value), nil
+}
+
+// String returns the canonical snapshot identifier.
+func (id SnapshotID) String() string { return string(id) }
+
+// Snapshot is the durable description of one complete VMM and writable-disk
+// capture. Immutable image layers remain pinned by ImageDigest.
+type Snapshot struct {
+ // ID is the immutable metadata and artifact directory identity.
+ ID SnapshotID
+ // Name is an optional human-readable lookup key.
+ Name string
+ // Description is optional operator context.
+ Description string
+ // SandboxID identifies the source lineage accepted by restore.
+ SandboxID SandboxID
+ // SourceGeneration is the Running generation captured by this snapshot.
+ SourceGeneration uint64
+ // ImageDigest pins the immutable image layers required by the sandbox.
+ ImageDigest Digest
+ // VMM selects the adapter capable of restoring the native snapshot.
+ VMM VMMType
+ // Config is the source sandbox resource and network request.
+ Config SandboxConfig
+ // Size is the allocated snapshot artifact size in bytes.
+ Size int64
+ // CreatedAt records when capture was requested.
+ CreatedAt time.Time
+}
+
+// Validate rejects snapshot facts that cannot safely drive lookup or restore.
+func (s Snapshot) Validate() error {
+ if _, err := ParseSnapshotID(s.ID.String()); err != nil {
+ return err
+ }
+ if s.Name != "" && !validSnapshotName.MatchString(s.Name) {
+ return fmt.Errorf("snapshot name %q must match %s", s.Name, validSnapshotName)
+ }
+ if _, err := ParseSandboxID(s.SandboxID.String()); err != nil {
+ return err
+ }
+ if s.SourceGeneration == 0 {
+ return errors.New("snapshot source generation must be positive")
+ }
+ if _, err := ParseDigest(s.ImageDigest.String()); err != nil {
+ return err
+ }
+ if err := s.VMM.Validate(); err != nil {
+ return err
+ }
+ if err := s.Config.Validate(); err != nil {
+ return err
+ }
+ if s.Size < 0 || s.CreatedAt.IsZero() {
+ return errors.New("snapshot size must be non-negative and creation time must be set")
+ }
+ return nil
+}
diff --git a/types/snapshot_test.go b/types/snapshot_test.go
new file mode 100644
index 0000000..264a9b1
--- /dev/null
+++ b/types/snapshot_test.go
@@ -0,0 +1,31 @@
+package types
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestSnapshotValidation(t *testing.T) {
+ id, err := NewSnapshotID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ digest, err := ParseDigest("sha256:" + strings.Repeat("a", 64))
+ if err != nil {
+ t.Fatal(err)
+ }
+ snapshot := Snapshot{
+ ID: id, Name: "release/one:ready", SandboxID: SandboxID("123e4567-e89b-42d3-a456-426614174000"),
+ SourceGeneration: 4, ImageDigest: digest, VMM: VMMCloudHypervisor,
+ Config: SandboxConfig{Name: "box", CPUs: 1, Memory: DefaultSandboxMemory, Storage: DefaultSandboxStorage},
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := snapshot.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ snapshot.Name = "bad name"
+ if err := snapshot.Validate(); err == nil {
+ t.Fatal("Snapshot.Validate accepted an invalid name")
+ }
+}
diff --git a/version/version.go b/version/version.go
new file mode 100644
index 0000000..23f613d
--- /dev/null
+++ b/version/version.go
@@ -0,0 +1,34 @@
+// Package version reports what build is running.
+package version
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+)
+
+// Build information. Release builds override these through -ldflags.
+var (
+ // Version is the release tag supplied at build time, or the development default.
+ Version = "0.0.0-dev"
+ // Commit identifies the source revision embedded in the binary.
+ Commit = "unknown"
+ // BuildTime is the build timestamp string supplied by the release tooling.
+ BuildTime = "unknown"
+)
+
+// String renders the version the way a human reads it.
+func String() string {
+ return fmt.Sprintf("kumabox %s (commit %s, built %s)", Version, Commit, BuildTime)
+}
+
+// WriteJSON writes the version fields as JSON.
+func WriteJSON(out io.Writer) error {
+ encoder := json.NewEncoder(out)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(map[string]string{
+ "version": Version,
+ "commit": Commit,
+ "build_time": BuildTime,
+ })
+}
diff --git a/vmm/backend.go b/vmm/backend.go
new file mode 100644
index 0000000..987cf8c
--- /dev/null
+++ b/vmm/backend.go
@@ -0,0 +1,144 @@
+package vmm
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "reflect"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+// Backend is the stable process-level contract implemented by every VMM
+// adapter. Durable sandbox transitions remain in core; implementations own
+// process launch, identity, control APIs, console access, and runtime cleanup.
+type Backend interface {
+ Type() types.VMMType
+ Preflight() error
+ Locate(context.Context, types.SandboxID, uint64) (Process, bool, error)
+ Observe(context.Context, types.SandboxID, uint64) (Observation, error)
+ WaitReady(context.Context, Process) error
+ Launch(context.Context, LaunchPlan) (Process, error)
+ Abort(context.Context, Process) error
+ Stop(context.Context, Process) error
+ Console(context.Context, Process) (io.ReadWriteCloser, error)
+ DialVsock(context.Context, Process, uint32) (io.ReadWriteCloser, error)
+ Logs(context.Context, types.SandboxID, LogOptions, io.Writer) error
+ Cleanup(context.Context, types.SandboxID) error
+ RemoveLogs(context.Context, types.SandboxID) error
+}
+
+// SnapshotFile describes one writable disk copied inside the VMM pause window.
+type SnapshotFile struct {
+ // Source is the current sandbox-owned writable disk.
+ Source string
+ // Destination is an absent path inside the private capture directory.
+ Destination string
+}
+
+// SnapshotPlan contains all inputs required for one consistent live capture.
+type SnapshotPlan struct {
+ // Process is the exact VMM generation being captured.
+ Process Process
+ // Destination receives native VMM memory and device-state files.
+ Destination string
+ // WritableFiles are copied while the guest remains paused.
+ WritableFiles []SnapshotFile
+}
+
+// Snapshotter is the optional live-capture capability implemented by VMMs that
+// can pause, save native state, copy writable disks, and resume safely.
+type Snapshotter interface {
+ Snapshot(context.Context, SnapshotPlan) error
+}
+
+// RestorePlan contains the immutable ownership and native capture inputs for a
+// VMM restore launch.
+type RestorePlan struct {
+ // SandboxID owns the restored process and runtime files.
+ SandboxID types.SandboxID
+ // Generation is the durable Starting generation for this launch.
+ Generation uint64
+ // CPUs sizes the process cgroup consistently with a normal launch.
+ CPUs uint32
+ // SnapshotDir contains native VMM state with already restored writable disks.
+ SnapshotDir string
+ // Network supplies the recovered namespace and stable TAP identities.
+ Network types.NetworkSetup
+}
+
+// Restorer is the optional native-state restore capability implemented by VMMs
+// whose snapshot format can resume a stopped process.
+type Restorer interface {
+ Restore(context.Context, RestorePlan) (Process, error)
+}
+
+// RestoreValidator optionally validates native snapshot files before a running
+// sandbox is stopped for restore.
+type RestoreValidator interface {
+ ValidateRestore(context.Context, string) error
+}
+
+// Registry is an immutable routing table from durable VMM identities to their
+// process adapters. Construction validates the complete backend set so runtime
+// lookup cannot depend on package initialization or registration order.
+type Registry struct {
+ backends map[types.VMMType]Backend
+}
+
+// NewRegistry validates and freezes the supplied backend set.
+func NewRegistry(backends ...Backend) (*Registry, error) {
+ registered := make(map[types.VMMType]Backend, len(backends))
+ for _, backend := range backends {
+ if backend == nil || isNilBackend(backend) {
+ return nil, errors.New("VMM registry contains a nil backend")
+ }
+ typ := backend.Type()
+ if err := typ.Validate(); err != nil {
+ return nil, fmt.Errorf("register VMM backend: %w", err)
+ }
+ if _, exists := registered[typ]; exists {
+ return nil, fmt.Errorf("VMM backend %q is registered more than once", typ)
+ }
+ registered[typ] = backend
+ }
+ return &Registry{backends: registered}, nil
+}
+
+// isNilBackend catches typed nil pointers stored inside a non-nil interface.
+func isNilBackend(backend Backend) bool {
+ value := reflect.ValueOf(backend)
+ switch value.Kind() {
+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
+ return value.IsNil()
+ default:
+ return false
+ }
+}
+
+// Backend returns the adapter for a persisted VMM identity. An unknown valid
+// type means this installation lacks the required adapter; an invalid type is
+// treated as corrupt durable state.
+func (r *Registry) Backend(typ types.VMMType) (Backend, error) {
+ if err := typ.Validate(); err != nil {
+ return nil, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, err)
+ }
+ if r == nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("VMM registry is not configured"))
+ }
+ backend, exists := r.backends[typ]
+ if !exists || backend == nil {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, fmt.Errorf("VMM backend %q is not available", typ))
+ }
+ return backend, nil
+}
+
+// Len returns the number of backends frozen into the registry.
+func (r *Registry) Len() int {
+ if r == nil {
+ return 0
+ }
+ return len(r.backends)
+}
diff --git a/vmm/backend_test.go b/vmm/backend_test.go
new file mode 100644
index 0000000..f53a841
--- /dev/null
+++ b/vmm/backend_test.go
@@ -0,0 +1,92 @@
+package vmm
+
+import (
+ "context"
+ "io"
+ "testing"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+)
+
+type registryBackend struct{ typ types.VMMType }
+
+type nilRegistryBackend struct{ Backend }
+
+func (*nilRegistryBackend) Type() types.VMMType { return types.VMMCloudHypervisor }
+
+func (b registryBackend) Type() types.VMMType { return b.typ }
+func (registryBackend) Preflight() error { return nil }
+func (registryBackend) Locate(context.Context, types.SandboxID, uint64) (Process, bool, error) {
+ return Process{}, false, nil
+}
+
+func (registryBackend) Observe(context.Context, types.SandboxID, uint64) (Observation, error) {
+ return Observation{}, nil
+}
+func (registryBackend) WaitReady(context.Context, Process) error { return nil }
+func (registryBackend) Launch(context.Context, LaunchPlan) (Process, error) { return Process{}, nil }
+func (registryBackend) Abort(context.Context, Process) error { return nil }
+func (registryBackend) Stop(context.Context, Process) error { return nil }
+func (registryBackend) Console(context.Context, Process) (io.ReadWriteCloser, error) {
+ return nil, nil
+}
+
+func (registryBackend) DialVsock(context.Context, Process, uint32) (io.ReadWriteCloser, error) {
+ return nil, nil
+}
+
+func (registryBackend) Logs(context.Context, types.SandboxID, LogOptions, io.Writer) error {
+ return nil
+}
+func (registryBackend) Cleanup(context.Context, types.SandboxID) error { return nil }
+func (registryBackend) RemoveLogs(context.Context, types.SandboxID) error { return nil }
+
+func TestRegistryRoutesAndRejectsInvalidSets(t *testing.T) {
+ backend := registryBackend{typ: types.VMMCloudHypervisor}
+ registry, err := NewRegistry(backend)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := registry.Backend(types.VMMCloudHypervisor)
+ if err != nil || got != backend || registry.Len() != 1 {
+ t.Fatalf("Backend() = %#v, %v; len = %d", got, err, registry.Len())
+ }
+ if _, err := NewRegistry(backend, backend); err == nil {
+ t.Fatal("NewRegistry() accepted duplicate backend types")
+ }
+ if _, err := NewRegistry(registryBackend{}); err == nil {
+ t.Fatal("NewRegistry() accepted an invalid backend type")
+ }
+ var nilBackend *nilRegistryBackend
+ if _, err := NewRegistry(nilBackend); err == nil {
+ t.Fatal("NewRegistry() accepted a typed nil backend")
+ }
+ other, err := NewRegistry(registryBackend{typ: types.VMMFirecracker})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := other.Backend(types.VMMCloudHypervisor); err == nil {
+ t.Fatal("independent registry leaked another instance's backend")
+ }
+ if got, err := registry.Backend(types.VMMCloudHypervisor); err != nil || got != backend {
+ t.Fatalf("original registry changed after constructing another instance: %#v, %v", got, err)
+ }
+}
+
+func TestRegistryClassifiesLookupFailures(t *testing.T) {
+ registry, err := NewRegistry()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := registry.Backend(types.VMMCloudHypervisor); err == nil {
+ t.Fatal("Backend() found an unregistered backend")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeHostIncompatible {
+ t.Fatalf("missing backend error = %v", err)
+ }
+ if _, err := registry.Backend(types.VMMType("broken")); err == nil {
+ t.Fatal("Backend() accepted an invalid persisted type")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeArtifactCorrupt {
+ t.Fatalf("invalid type error = %v", err)
+ }
+}
diff --git a/vmm/cloudhypervisor/args.go b/vmm/cloudhypervisor/args.go
new file mode 100644
index 0000000..b5b7dbd
--- /dev/null
+++ b/vmm/cloudhypervisor/args.go
@@ -0,0 +1,64 @@
+package cloudhypervisor
+
+import (
+ "fmt"
+ "runtime"
+ "strings"
+
+ "github.com/kumabox/kumabox/vmm"
+)
+
+const diskQueueSize = 512
+
+// buildArgs renders one direct-boot Cloud Hypervisor command. Disk attachment
+// order remains base-to-top then COW; only the initramfs cmdline reverses layers.
+func buildArgs(plan vmm.LaunchPlan, apiSocket, vsock string) []string {
+ maximumCPUs := max(runtime.NumCPU(), int(plan.CPUs))
+ args := []string{
+ "--api-socket", apiSocket,
+ "--cpus", fmt.Sprintf("boot=%d,max=%d", plan.CPUs, maximumCPUs),
+ "--memory", fmt.Sprintf("size=%d", plan.Memory),
+ "--disk",
+ }
+ for _, disk := range plan.Disks {
+ parts := []string{
+ "path=" + disk.Path,
+ "image_type=raw",
+ fmt.Sprintf("num_queues=%d", plan.CPUs),
+ fmt.Sprintf("queue_size=%d", diskQueueSize),
+ "serial=" + disk.Serial,
+ }
+ if disk.ReadOnly {
+ parts = append(parts, "readonly=on")
+ } else {
+ parts = append(parts, "direct=on", "sparse=on")
+ }
+ args = append(args, strings.Join(parts, ","))
+ }
+ if len(plan.Network.Interfaces) > 0 {
+ args = append(args, "--net")
+ for _, networkInterface := range plan.Network.Interfaces {
+ args = append(args, strings.Join([]string{
+ "tap=" + networkInterface.TAP,
+ "mac=" + networkInterface.MAC,
+ fmt.Sprintf("num_queues=%d", networkInterface.Queues),
+ fmt.Sprintf("queue_size=%d", networkInterface.QueueSize),
+ "offload_tso=on",
+ "offload_ufo=on",
+ "offload_csum=on",
+ }, ","))
+ }
+ }
+ args = append(args,
+ "--kernel", plan.Kernel,
+ "--initramfs", plan.Initrd,
+ "--cmdline", plan.Cmdline,
+ "--rng", "src=/dev/urandom",
+ "--watchdog",
+ "--balloon", fmt.Sprintf("size=%d,deflate_on_oom=on,free_page_reporting=on", plan.Memory/4),
+ "--vsock", fmt.Sprintf("cid=%d,socket=%s", vmm.VsockGuestCID, vsock),
+ "--serial", "off",
+ "--console", "pty",
+ )
+ return args
+}
diff --git a/vmm/cloudhypervisor/args_test.go b/vmm/cloudhypervisor/args_test.go
new file mode 100644
index 0000000..67eeed7
--- /dev/null
+++ b/vmm/cloudhypervisor/args_test.go
@@ -0,0 +1,142 @@
+package cloudhypervisor
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "sync/atomic"
+ "testing"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+func TestQueryStateRequiresUnixSocketAndDecodesRunning(t *testing.T) {
+ directory, err := os.MkdirTemp("/tmp", "kumabox-ch-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := os.RemoveAll(directory); err != nil {
+ t.Error(err)
+ }
+ })
+ socket := filepath.Join(directory, "api.sock")
+ listener, err := net.Listen("unix", socket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var shutdown atomic.Bool
+ server := &http.Server{Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path == "/api/v1/vm.info":
+ _, _ = writer.Write([]byte(`{"state":"Running","config":{"console":{"mode":"Pty","file":"/dev/pts/7"}}}`))
+ case request.Method == http.MethodPut && request.URL.Path == "/api/v1/vm.shutdown":
+ shutdown.Store(true)
+ writer.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(writer, request)
+ }
+ })}
+ go func() { _ = server.Serve(listener) }()
+ t.Cleanup(func() {
+ if err := server.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ })
+ driver := &Driver{}
+ state, err := driver.queryState(t.Context(), socket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state != "Running" {
+ t.Fatalf("state = %q", state)
+ }
+ info, err := driver.queryInfo(t.Context(), socket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Config.Console.Mode != "Pty" || info.Config.Console.File != "/dev/pts/7" {
+ t.Fatalf("console info = %+v", info.Config.Console)
+ }
+ if err := driver.requestShutdown(t.Context(), socket); err != nil {
+ t.Fatal(err)
+ }
+ if !shutdown.Load() {
+ t.Fatal("vm.shutdown request was not received")
+ }
+
+ regular := filepath.Join(t.TempDir(), "not-a-socket")
+ if err := os.WriteFile(regular, []byte("invalid"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := driver.queryState(t.Context(), regular); err == nil {
+ t.Fatal("accepted a regular file as the VMM API socket")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeArtifactCorrupt {
+ t.Fatalf("regular socket error = %v", err)
+ }
+}
+
+func TestOpenConsolePTYRejectsUnmanagedOrRegularPaths(t *testing.T) {
+ regular := filepath.Join(t.TempDir(), "7")
+ if err := os.WriteFile(regular, []byte("not a PTY"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{regular, "/tmp/7", "/dev/pts/not-a-number", "dev/pts/7"} {
+ if _, err := openConsolePTY(path); err == nil {
+ t.Fatalf("accepted invalid console path %q", path)
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeArtifactCorrupt {
+ t.Fatalf("console path %q error = %v", path, err)
+ }
+ }
+}
+
+func TestBuildArgsMatchesDirectBootContract(t *testing.T) {
+ plan := vmm.LaunchPlan{
+ SandboxID: "123e4567-e89b-42d3-a456-426614174000", Generation: 3,
+ CPUs: 2, Memory: 1 << 30, BootProfile: types.BootProfileOverlayV1,
+ Kernel: "/boot/vmlinuz", Initrd: "/boot/initrd.img", Cmdline: "boot=kumabox-overlay",
+ Disks: []vmm.Disk{
+ {Path: "/layers/0.erofs", Serial: "kumabox-layer0", ReadOnly: true},
+ {Path: "/layers/1.erofs", Serial: "kumabox-layer1", ReadOnly: true},
+ {Path: "/sandbox/cow.raw", Serial: vmm.COWSerial},
+ },
+ Network: types.NetworkSetup{
+ Backend: types.NetworkBackendCNI, Namespace: "/var/run/netns/kumabox-test",
+ Interfaces: []types.NetworkInterface{{
+ Index: 0, Name: "eth0", TAP: "tap12345678-0", MAC: "02:00:00:00:00:01",
+ Queues: 4, QueueSize: 512, Network: "bridge",
+ }},
+ },
+ }
+ args := buildArgs(plan, "/run/api.sock", "/run/vsock.uds")
+ want := []string{
+ "--api-socket", "/run/api.sock",
+ "--cpus", fmt.Sprintf("boot=2,max=%d", max(runtime.NumCPU(), 2)),
+ "--memory", "size=1073741824",
+ "--disk",
+ "path=/layers/0.erofs,image_type=raw,num_queues=2,queue_size=512,serial=kumabox-layer0,readonly=on",
+ "path=/layers/1.erofs,image_type=raw,num_queues=2,queue_size=512,serial=kumabox-layer1,readonly=on",
+ "path=/sandbox/cow.raw,image_type=raw,num_queues=2,queue_size=512,serial=kumabox-cow,direct=on,sparse=on",
+ "--net",
+ "tap=tap12345678-0,mac=02:00:00:00:00:01,num_queues=4,queue_size=512,offload_tso=on,offload_ufo=on,offload_csum=on",
+ "--kernel", "/boot/vmlinuz",
+ "--initramfs", "/boot/initrd.img",
+ "--cmdline", "boot=kumabox-overlay",
+ "--rng", "src=/dev/urandom",
+ "--watchdog",
+ "--balloon", "size=268435456,deflate_on_oom=on,free_page_reporting=on",
+ "--vsock", "cid=3,socket=/run/vsock.uds",
+ "--serial", "off",
+ "--console", "pty",
+ }
+ if !slices.Equal(args, want) {
+ t.Fatalf("buildArgs() =\n%q\nwant\n%q", args, want)
+ }
+}
diff --git a/vmm/cloudhypervisor/driver.go b/vmm/cloudhypervisor/driver.go
new file mode 100644
index 0000000..87e4521
--- /dev/null
+++ b/vmm/cloudhypervisor/driver.go
@@ -0,0 +1,533 @@
+// Package cloudhypervisor adapts Cloud Hypervisor's process and Unix HTTP API
+// to KumaBox launch plans. It owns VMM arguments, process identity, readiness,
+// and failed-launch termination; core owns durable sandbox state transitions.
+package cloudhypervisor
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/kumabox/kumabox/cgroup"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+const (
+ defaultStartupTimeout = 10 * time.Second
+ probeInterval = 50 * time.Millisecond
+ probeTimeout = 500 * time.Millisecond
+ maxAPIResponse = 1 << 20
+)
+
+// scopeManager is the cgroup capability consumed by this process adapter.
+type scopeManager interface {
+ Prepare(context.Context, types.SandboxID, uint32) (*os.File, error)
+ PIDs(types.SandboxID) ([]int, error)
+ Remove(context.Context, types.SandboxID) error
+}
+
+// Options configures the executable and bounded readiness wait.
+type Options struct {
+ // Binary is an executable name or absolute path; empty selects cloud-hypervisor.
+ Binary string
+ // StartupTimeout bounds process/API readiness; zero selects ten seconds.
+ StartupTimeout time.Duration
+ // StopGrace bounds the identity-checked SIGTERM to SIGKILL window.
+ StopGrace time.Duration
+ // AbortGrace bounds termination after a failed launch.
+ AbortGrace time.Duration
+}
+
+// Driver launches and observes Cloud Hypervisor processes.
+type Driver struct {
+ // paths owns runtime identity, sockets, command diagnostics, and logs.
+ paths vmm.Paths
+ // scopes places every child directly into a per-sandbox cgroup.
+ scopes scopeManager
+ // binary is resolved by exec only during host preflight.
+ binary string
+ // startupTimeout bounds API readiness for new and recovered starts.
+ startupTimeout time.Duration
+ // stopGrace bounds normal stop escalation after the advisory API request.
+ stopGrace time.Duration
+ // abortGrace bounds cleanup of a launch that never committed Running.
+ abortGrace time.Duration
+}
+
+var _ vmm.Backend = (*Driver)(nil)
+
+// New constructs a driver without probing host capabilities.
+func New(paths vmm.Paths, scopes *cgroup.Manager, options Options) (*Driver, error) {
+ if scopes == nil {
+ return nil, errors.New("cloud hypervisor cgroup manager is required")
+ }
+ if options.Binary == "" {
+ options.Binary = "cloud-hypervisor"
+ }
+ if options.StartupTimeout == 0 {
+ options.StartupTimeout = defaultStartupTimeout
+ }
+ if options.StopGrace == 0 {
+ options.StopGrace = 5 * time.Second
+ }
+ if options.AbortGrace == 0 {
+ options.AbortGrace = 3 * time.Second
+ }
+ if options.StartupTimeout < probeInterval || options.StopGrace <= 0 || options.AbortGrace <= 0 {
+ return nil, errors.New("cloud hypervisor lifecycle timeouts must be positive and startup must cover one probe interval")
+ }
+ return &Driver{
+ paths: paths, scopes: scopes, binary: options.Binary,
+ startupTimeout: options.StartupTimeout, stopGrace: options.StopGrace, abortGrace: options.AbortGrace,
+ }, nil
+}
+
+// Type returns the durable backend identity stored with every owned sandbox.
+func (*Driver) Type() types.VMMType { return types.VMMCloudHypervisor }
+
+// Preflight checks Linux/KVM and the configured binary before Starting is committed.
+func (d *Driver) Preflight() error {
+ if d == nil || d.scopes == nil || d.binary == "" {
+ return errors.New("cloud hypervisor driver is not configured")
+ }
+ if err := platformPreflight(); err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, err)
+ }
+ if _, err := exec.LookPath(d.binary); err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, fmt.Errorf("find cloud-hypervisor: %w", err))
+ }
+ if err := d.paths.Ensure(); err != nil {
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err)
+ }
+ return nil
+}
+
+// Launch starts one detached VMM directly in its cgroup, persists process
+// identity, and waits until vm.info proves Running. Any error after exec kills
+// the owned child and removes reconstructable runtime state.
+//
+// cgroup + private dirs -> exec -> PID/start/boot identity -> process.json
+// |
+// Unix API socket -> vm.info Running
+func (d *Driver) Launch(ctx context.Context, plan vmm.LaunchPlan) (result vmm.Process, returnErr error) {
+ if err := plan.Validate(); err != nil {
+ return vmm.Process{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if err := d.Preflight(); err != nil {
+ return vmm.Process{}, err
+ }
+ if err := d.paths.Prepare(plan.SandboxID); err != nil {
+ return vmm.Process{}, err
+ }
+ var command *exec.Cmd
+ defer func() {
+ if returnErr == nil {
+ return
+ }
+ cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), d.abortGrace+time.Second)
+ defer cancel()
+ switch {
+ case result.PID > 0:
+ returnErr = errors.Join(returnErr, d.Abort(cleanupCtx, result))
+ case command != nil && command.Process != nil:
+ returnErr = errors.Join(returnErr, command.Process.Kill(), command.Wait(), d.scopes.Remove(cleanupCtx, plan.SandboxID), d.paths.Clear(plan.SandboxID))
+ default:
+ returnErr = errors.Join(returnErr, d.scopes.Remove(cleanupCtx, plan.SandboxID), d.paths.Clear(plan.SandboxID))
+ }
+ }()
+ apiSocket, _ := d.paths.APISocket(plan.SandboxID)
+ vsock, _ := d.paths.Vsock(plan.SandboxID)
+ args := buildArgs(plan, apiSocket, vsock)
+ if err := d.paths.WriteCmdline(plan.SandboxID, diagnosticCommand(d.binary, args)); err != nil {
+ return vmm.Process{}, err
+ }
+
+ scope, err := d.scopes.Prepare(ctx, plan.SandboxID, plan.CPUs)
+ if err != nil {
+ return vmm.Process{}, err
+ }
+ defer func() { returnErr = errors.Join(returnErr, scope.Close()) }()
+
+ logPath, _ := d.paths.LogFile(plan.SandboxID)
+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) //nolint:gosec // managed path
+ if err != nil {
+ return vmm.Process{}, fmt.Errorf("open VMM log: %w", err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, logFile.Close()) }()
+
+ command = exec.Command(d.binary, args...) //nolint:gosec // executable is a configured fixed value; no shell is involved
+ command.Stdout, command.Stderr = logFile, logFile
+ configureProcess(command, scope)
+
+ if err := startProcess(command, plan.Network.Namespace); err != nil {
+ return vmm.Process{}, fmt.Errorf("exec cloud-hypervisor: %w", err)
+ }
+ result, err = captureProcess(command.Process.Pid, plan.SandboxID, plan.Generation, filepath.Base(d.binary), apiSocket)
+ if err != nil {
+ return result, fmt.Errorf("capture VMM process identity: %w", err)
+ }
+ if err := d.paths.WriteProcess(result); err != nil {
+ return result, fmt.Errorf("persist VMM process identity: %w", err)
+ }
+ go func() { _ = command.Wait() }()
+ if err := d.WaitReady(ctx, result); err != nil {
+ return result, err
+ }
+ return result, nil
+}
+
+// Locate verifies process generation, boot ID, executable, and unique API
+// argument without depending on VM API health. A missing process file falls
+// back to the owned cgroup to close the exec-before-identity crash window.
+func (d *Driver) Locate(_ context.Context, id types.SandboxID, generation uint64) (vmm.Process, bool, error) {
+ if d == nil || d.scopes == nil {
+ return vmm.Process{}, false, errors.New("cloud hypervisor driver is not configured")
+ }
+ process, err := d.paths.ReadProcess(id)
+ if errors.Is(err, fs.ErrNotExist) {
+ process, err = d.recoverProcess(id, generation)
+ }
+ if err != nil {
+ return vmm.Process{}, false, err
+ }
+ if process.PID == 0 {
+ return vmm.Process{}, false, nil
+ }
+ alive, err := verifyProcess(process)
+ if err != nil {
+ return vmm.Process{}, false, err
+ }
+ if !alive {
+ return vmm.Process{}, false, nil
+ }
+ if process.Generation != generation {
+ return vmm.Process{}, false, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("live VMM belongs to Starting generation %d, expected %d", process.Generation, generation))
+ }
+ return process, true, nil
+}
+
+// Observe combines identity-safe process location with the private vm.info API
+// to distinguish startup from readiness.
+func (d *Driver) Observe(ctx context.Context, id types.SandboxID, generation uint64) (vmm.Observation, error) {
+ process, exists, err := d.Locate(ctx, id, generation)
+ if err != nil {
+ return vmm.Observation{}, err
+ }
+ if !exists {
+ return vmm.Observation{State: vmm.ProcessAbsent}, nil
+ }
+ state, err := d.queryState(ctx, process.APISocket)
+ if err != nil {
+ if socketUnavailable(err) {
+ return vmm.Observation{State: vmm.ProcessStarting, Process: process}, nil
+ }
+ return vmm.Observation{}, err
+ }
+ if state == "Running" {
+ return vmm.Observation{State: vmm.ProcessRunning, Process: process}, nil
+ }
+ return vmm.Observation{State: vmm.ProcessStarting, Process: process}, nil
+}
+
+// WaitReady waits for the exact process identity to expose a Running VM.
+func (d *Driver) WaitReady(ctx context.Context, process vmm.Process) error {
+ return waitReady(ctx, process, d.startupTimeout, d.Observe)
+}
+
+// waitReady owns readiness policy independently of process and HTTP adapters,
+// making early exit, identity changes, cancellation, and timeout directly
+// testable at the policy boundary.
+func waitReady(
+ ctx context.Context,
+ process vmm.Process,
+ timeout time.Duration,
+ observe func(context.Context, types.SandboxID, uint64) (vmm.Observation, error),
+) error {
+ deadline := time.NewTimer(timeout)
+ defer deadline.Stop()
+ ticker := time.NewTicker(probeInterval)
+ defer ticker.Stop()
+ for {
+ observation, err := observe(ctx, process.SandboxID, process.Generation)
+ if err != nil {
+ return err
+ }
+ switch observation.State {
+ case vmm.ProcessRunning:
+ if observation.Process != process {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("VMM process identity changed during startup"))
+ }
+ return nil
+ case vmm.ProcessAbsent:
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("cloud-hypervisor exited before reaching Running; inspect vmm.log"))
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-deadline.C:
+ return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("timed out waiting for Cloud Hypervisor vm.info Running"))
+ case <-ticker.C:
+ }
+ }
+}
+
+// Abort terminates only the exact captured process generation, then removes its
+// empty cgroup and runtime directory. Signal delivery uses a pidfd on Linux.
+func (d *Driver) Abort(ctx context.Context, process vmm.Process) error {
+ if err := process.Validate(); err != nil {
+ return err
+ }
+ if err := terminateProcess(ctx, process, d.abortGrace); err != nil {
+ return err
+ }
+ return d.Cleanup(ctx, process.SandboxID)
+}
+
+// Stop mirrors Cloud Hypervisor direct-boot shutdown semantics: vm.shutdown is
+// advisory, while identity-checked TERM and KILL provide the completion guarantee.
+func (d *Driver) Stop(ctx context.Context, process vmm.Process) error {
+ if err := process.Validate(); err != nil {
+ return err
+ }
+ alive, err := verifyProcess(process)
+ if err != nil {
+ return err
+ }
+ if !alive {
+ return nil
+ }
+ _ = d.requestShutdown(ctx, process.APISocket)
+ return terminateProcess(ctx, process, d.stopGrace)
+}
+
+// Console opens the direct-boot PTY reported by the exact live VMM. The caller
+// owns the returned descriptor and closing it only detaches the console.
+func (d *Driver) Console(ctx context.Context, process vmm.Process) (io.ReadWriteCloser, error) {
+ if err := process.Validate(); err != nil {
+ return nil, err
+ }
+ alive, err := verifyProcess(process)
+ if err != nil {
+ return nil, err
+ }
+ if !alive {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("cloud-hypervisor process is absent"))
+ }
+ info, err := d.queryInfo(ctx, process.APISocket)
+ if err != nil {
+ return nil, err
+ }
+ if info.State != "Running" {
+ return nil, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("cloud-hypervisor state is %q, not Running", info.State))
+ }
+ if info.Config.Console.Mode != "Pty" || info.Config.Console.File == "" {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, fmt.Errorf("cloud-hypervisor console PTY is unavailable in mode %q", info.Config.Console.Mode))
+ }
+ console, err := openConsolePTY(info.Config.Console.File)
+ if err != nil {
+ return nil, err
+ }
+ alive, verifyErr := verifyProcess(process)
+ if verifyErr != nil || !alive {
+ if verifyErr == nil {
+ verifyErr = errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("cloud-hypervisor exited while opening its console"))
+ }
+ return nil, errors.Join(verifyErr, console.Close())
+ }
+ return console, nil
+}
+
+// Logs streams the persistent process output owned by this backend.
+func (d *Driver) Logs(ctx context.Context, id types.SandboxID, options vmm.LogOptions, output io.Writer) error {
+ return d.paths.Logs(ctx, id, options, output)
+}
+
+// Cleanup removes runtime state and an empty cgroup after absence is proven.
+func (d *Driver) Cleanup(ctx context.Context, id types.SandboxID) error {
+ if err := d.scopes.Remove(ctx, id); err != nil {
+ return err
+ }
+ return d.paths.Clear(id)
+}
+
+// RemoveLogs releases persistent diagnostics only during sandbox removal.
+func (d *Driver) RemoveLogs(ctx context.Context, id types.SandboxID) error {
+ return d.paths.RemoveLogs(ctx, id)
+}
+
+// recoverProcess inspects only the sandbox's cgroup and refuses unknown members.
+func (d *Driver) recoverProcess(id types.SandboxID, generation uint64) (vmm.Process, error) {
+ pids, err := d.scopes.PIDs(id)
+ if err != nil {
+ return vmm.Process{}, err
+ }
+ if len(pids) == 0 {
+ return vmm.Process{}, nil
+ }
+ apiSocket, err := d.paths.APISocket(id)
+ if err != nil {
+ return vmm.Process{}, err
+ }
+ var matches []vmm.Process
+ for _, pid := range pids {
+ process, match, err := identifyProcess(pid, id, generation, filepath.Base(d.binary), apiSocket)
+ if err != nil {
+ return vmm.Process{}, err
+ }
+ if match {
+ matches = append(matches, process)
+ }
+ }
+ if len(matches) != 1 || len(pids) != 1 {
+ return vmm.Process{}, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("cgroup contains %d process(es), %d matching the owned VMM", len(pids), len(matches)))
+ }
+ if err := d.paths.WriteProcess(matches[0]); err != nil {
+ return vmm.Process{}, err
+ }
+ return matches[0], nil
+}
+
+// queryState performs one bounded request over the private Unix socket.
+func (d *Driver) queryState(ctx context.Context, socket string) (string, error) {
+ info, err := d.queryInfo(ctx, socket)
+ if err != nil {
+ return "", err
+ }
+ return info.State, nil
+}
+
+// vmInfo contains the readiness and console facts consumed from vm.info.
+type vmInfo struct {
+ State string `json:"state"`
+ Config struct {
+ Console struct {
+ Mode string `json:"mode"`
+ File string `json:"file"`
+ } `json:"console"`
+ } `json:"config"`
+}
+
+// queryInfo performs one bounded vm.info request over the private Unix socket.
+func (d *Driver) queryInfo(ctx context.Context, socket string) (vmInfo, error) {
+ client, closeClient, err := unixAPIClient(socket)
+ if err != nil {
+ return vmInfo{}, err
+ }
+ defer closeClient()
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost/api/v1/vm.info", nil)
+ if err != nil {
+ return vmInfo{}, err
+ }
+ response, err := client.Do(request)
+ if err != nil {
+ return vmInfo{}, err
+ }
+ defer response.Body.Close() //nolint:errcheck // response decode error is authoritative
+ if response.StatusCode != http.StatusOK {
+ _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxAPIResponse))
+ return vmInfo{}, fmt.Errorf("cloud hypervisor vm.info returned HTTP %d", response.StatusCode)
+ }
+ var payload vmInfo
+ decoder := json.NewDecoder(io.LimitReader(response.Body, maxAPIResponse+1))
+ if err := decoder.Decode(&payload); err != nil {
+ return vmInfo{}, fmt.Errorf("decode Cloud Hypervisor vm.info: %w", err)
+ }
+ if payload.State == "" {
+ return vmInfo{}, errors.New("cloud hypervisor vm.info omitted state")
+ }
+ return payload, nil
+}
+
+// openConsolePTY accepts only the kernel-owned /dev/pts/N shape returned by
+// Cloud Hypervisor and verifies the opened descriptor is a character device.
+func openConsolePTY(path string) (*os.File, error) {
+ clean := filepath.Clean(path)
+ index, parseErr := strconv.Atoi(filepath.Base(clean))
+ if !filepath.IsAbs(path) || filepath.Dir(clean) != "/dev/pts" || parseErr != nil || index < 0 {
+ return nil, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("invalid cloud-hypervisor console PTY path %q", path))
+ }
+ info, err := os.Lstat(clean)
+ if err != nil {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, fmt.Errorf("stat console PTY: %w", err))
+ }
+ if info.Mode()&os.ModeCharDevice == 0 {
+ return nil, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("console PTY %s is not a character device", clean))
+ }
+ file, err := os.OpenFile(clean, os.O_RDWR, 0) //nolint:gosec // path is restricted to a validated kernel PTY leaf
+ if err != nil {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, fmt.Errorf("open console PTY: %w", err))
+ }
+ opened, err := file.Stat()
+ if err != nil || opened.Mode()&os.ModeCharDevice == 0 {
+ return nil, errors.Join(errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("opened console is not a character device")), file.Close())
+ }
+ return file, nil
+}
+
+// requestShutdown asks Cloud Hypervisor to stop its VM before process signals
+// are used. Callers deliberately treat failure as advisory.
+func (d *Driver) requestShutdown(ctx context.Context, socket string) error {
+ client, closeClient, err := unixAPIClient(socket)
+ if err != nil {
+ return err
+ }
+ defer closeClient()
+ request, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://localhost/api/v1/vm.shutdown", nil)
+ if err != nil {
+ return err
+ }
+ response, err := client.Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close() //nolint:errcheck // status is authoritative
+ _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxAPIResponse))
+ if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent {
+ return fmt.Errorf("cloud hypervisor vm.shutdown returned HTTP %d", response.StatusCode)
+ }
+ return nil
+}
+
+// unixAPIClient validates the private socket before constructing a bounded
+// HTTP client. The close function releases idle Unix connections.
+func unixAPIClient(socket string) (*http.Client, func(), error) {
+ info, err := os.Lstat(socket)
+ if err != nil {
+ return nil, nil, err
+ }
+ if info.Mode()&os.ModeSocket == 0 {
+ return nil, nil, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("cloud hypervisor API path is not a Unix socket"))
+ }
+ transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", socket)
+ }}
+ return &http.Client{Transport: transport, Timeout: probeTimeout}, transport.CloseIdleConnections, nil
+}
+
+func socketUnavailable(err error) bool {
+ return errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrDeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) ||
+ errors.Is(err, syscall.ECONNREFUSED)
+}
+
+func diagnosticCommand(binary string, args []string) string {
+ parts := make([]string, 0, len(args)+1)
+ parts = append(parts, strconv.Quote(binary))
+ for _, argument := range args {
+ parts = append(parts, strconv.Quote(argument))
+ }
+ return strings.Join(parts, " ")
+}
diff --git a/vmm/cloudhypervisor/driver_test.go b/vmm/cloudhypervisor/driver_test.go
new file mode 100644
index 0000000..e770879
--- /dev/null
+++ b/vmm/cloudhypervisor/driver_test.go
@@ -0,0 +1,195 @@
+package cloudhypervisor
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/cgroup"
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+func TestNewUsesConfiguredLifecyclePolicy(t *testing.T) {
+ base := t.TempDir()
+ paths, err := vmm.NewPaths(storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ scopes, err := cgroup.New("/sys/fs/cgroup/kumabox-test.slice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ options := Options{
+ Binary: "custom-vmm", StartupTimeout: 2 * time.Second,
+ StopGrace: 3 * time.Second, AbortGrace: 4 * time.Second,
+ }
+ driver, err := New(paths, scopes, options)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if driver.binary != options.Binary || driver.startupTimeout != options.StartupTimeout ||
+ driver.stopGrace != options.StopGrace || driver.abortGrace != options.AbortGrace {
+ t.Fatalf("driver policy = %+v", driver)
+ }
+}
+
+func TestNewRejectsInvalidLifecyclePolicy(t *testing.T) {
+ scopes, err := cgroup.New("/sys/fs/cgroup/kumabox-test.slice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := New(vmm.Paths{}, scopes, Options{StartupTimeout: time.Nanosecond}); err == nil {
+ t.Fatal("New() accepted a startup timeout shorter than one probe")
+ }
+}
+
+func TestWaitReadyHandlesProcessAndAPITransitions(t *testing.T) {
+ process := vmm.Process{
+ PID: 42, StartTicks: 100, BootID: "boot",
+ SandboxID: "123e4567-e89b-42d3-a456-426614174000", Generation: 3,
+ Binary: "cloud-hypervisor", APISocket: "/run/api.sock",
+ }
+ failure := errors.New("observe failed")
+ tests := []struct {
+ name string
+ ctx func() context.Context
+ observe func(context.Context, types.SandboxID, uint64) (vmm.Observation, error)
+ wantError error
+ wantCode errdefs.Code
+ }{
+ {
+ name: "running exact process",
+ ctx: t.Context,
+ observe: func(context.Context, types.SandboxID, uint64) (vmm.Observation, error) {
+ return vmm.Observation{State: vmm.ProcessRunning, Process: process}, nil
+ },
+ },
+ {
+ name: "process exits before ready",
+ ctx: t.Context,
+ observe: func(context.Context, types.SandboxID, uint64) (vmm.Observation, error) {
+ return vmm.Observation{State: vmm.ProcessAbsent}, nil
+ },
+ wantCode: errdefs.CodeArtifactUnavailable,
+ },
+ {
+ name: "identity changes",
+ ctx: t.Context,
+ observe: func(context.Context, types.SandboxID, uint64) (vmm.Observation, error) {
+ changed := process
+ changed.StartTicks++
+ return vmm.Observation{State: vmm.ProcessRunning, Process: changed}, nil
+ },
+ wantCode: errdefs.CodeStateConflict,
+ },
+ {
+ name: "observation fails",
+ ctx: t.Context,
+ observe: func(context.Context, types.SandboxID, uint64) (vmm.Observation, error) {
+ return vmm.Observation{}, failure
+ },
+ wantError: failure,
+ },
+ {
+ name: "caller cancels while API is unavailable",
+ ctx: func() context.Context {
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ return ctx
+ },
+ observe: func(context.Context, types.SandboxID, uint64) (vmm.Observation, error) {
+ return vmm.Observation{State: vmm.ProcessStarting, Process: process}, nil
+ },
+ wantError: context.Canceled,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ err := waitReady(test.ctx(), process, time.Second, test.observe)
+ if test.wantError != nil {
+ if !errors.Is(err, test.wantError) {
+ t.Fatalf("waitReady error = %v, want %v", err, test.wantError)
+ }
+ return
+ }
+ if test.wantCode != "" {
+ if code, ok := errdefs.CodeOf(err); !ok || code != test.wantCode {
+ t.Fatalf("waitReady code = %q, %v; error = %v", code, ok, err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestWaitReadyTimesOutWhileAPIIsUnavailable(t *testing.T) {
+ process := vmm.Process{SandboxID: "123e4567-e89b-42d3-a456-426614174000", Generation: 3}
+ err := waitReady(t.Context(), process, probeInterval, func(context.Context, types.SandboxID, uint64) (vmm.Observation, error) {
+ return vmm.Observation{State: vmm.ProcessStarting, Process: process}, nil
+ })
+ if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeArtifactUnavailable {
+ t.Fatalf("timeout code = %q, %v; error = %v", code, ok, err)
+ }
+}
+
+type cleanupScope struct {
+ removeErr error
+ removals int
+}
+
+func (*cleanupScope) Prepare(context.Context, types.SandboxID, uint32) (*os.File, error) {
+ return nil, errors.New("not used")
+}
+func (*cleanupScope) PIDs(types.SandboxID) ([]int, error) { return nil, nil }
+func (s *cleanupScope) Remove(context.Context, types.SandboxID) error {
+ s.removals++
+ return s.removeErr
+}
+
+func TestCleanupRetainsRuntimeUntilCgroupRemovalCanRetry(t *testing.T) {
+ base := t.TempDir()
+ paths, err := vmm.NewPaths(storage.Roots{
+ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := types.SandboxID("123e4567-e89b-42d3-a456-426614174000")
+ if err := paths.Prepare(id); err != nil {
+ t.Fatal(err)
+ }
+ scopeFailure := errors.New("cgroup is still busy")
+ scopes := &cleanupScope{removeErr: scopeFailure}
+ driver := &Driver{paths: paths, scopes: scopes}
+ if err := driver.Cleanup(t.Context(), id); !errors.Is(err, scopeFailure) {
+ t.Fatalf("first cleanup error = %v", err)
+ }
+ runtimeDir, err := paths.RunDir(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(runtimeDir); err != nil {
+ t.Fatalf("runtime state was removed before cgroup cleanup: %v", err)
+ }
+ scopes.removeErr = nil
+ if err := driver.Cleanup(t.Context(), id); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(runtimeDir); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("runtime state remains after retry: %v", err)
+ }
+ if scopes.removals != 2 {
+ t.Fatalf("cgroup removals = %d, want 2", scopes.removals)
+ }
+}
diff --git a/vmm/cloudhypervisor/process_linux.go b/vmm/cloudhypervisor/process_linux.go
new file mode 100644
index 0000000..13626a7
--- /dev/null
+++ b/vmm/cloudhypervisor/process_linux.go
@@ -0,0 +1,231 @@
+//go:build linux
+
+package cloudhypervisor
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/vishvananda/netns"
+
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+// platformPreflight verifies that KVM can be opened by the current identity.
+func platformPreflight() error {
+ device, err := os.OpenFile("/dev/kvm", os.O_RDWR, 0)
+ if err != nil {
+ return fmt.Errorf("open /dev/kvm: %w", err)
+ }
+ return device.Close()
+}
+
+// configureProcess makes the VMM independent of the CLI process group and asks
+// clone3 to place it in the prepared cgroup before it executes user code.
+func configureProcess(command *exec.Cmd, scope *os.File) {
+ command.SysProcAttr = &syscall.SysProcAttr{
+ Setpgid: true,
+ UseCgroupFD: true,
+ CgroupFD: int(scope.Fd()),
+ }
+}
+
+// startProcess starts the child in the requested network namespace. setns is
+// thread-local, so the caller thread is pinned until the original namespace is
+// restored after fork and exec.
+func startProcess(command *exec.Cmd, namespacePath string) (returnErr error) {
+ if namespacePath == "" {
+ return command.Start()
+ }
+ if !filepath.IsAbs(namespacePath) {
+ return errors.New("VMM network namespace path must be absolute")
+ }
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ original, err := netns.Get()
+ if err != nil {
+ return fmt.Errorf("get current network namespace: %w", err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, original.Close()) }()
+ target, err := netns.GetFromPath(namespacePath)
+ if err != nil {
+ return fmt.Errorf("open VMM network namespace %s: %w", namespacePath, err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, target.Close()) }()
+ if err := netns.Set(target); err != nil {
+ return fmt.Errorf("enter VMM network namespace %s: %w", namespacePath, err)
+ }
+ defer func() {
+ if err := netns.Set(original); err != nil {
+ returnErr = errors.Join(returnErr, fmt.Errorf("restore host network namespace: %w", err))
+ }
+ }()
+ return command.Start()
+}
+
+func captureProcess(pid int, id types.SandboxID, generation uint64, binary, apiSocket string) (vmm.Process, error) {
+ start, err := processStartTicks(pid)
+ if err != nil {
+ return vmm.Process{}, err
+ }
+ bootID, err := hostBootID()
+ if err != nil {
+ return vmm.Process{}, err
+ }
+ process := vmm.Process{PID: pid, StartTicks: start, BootID: bootID, SandboxID: id, Generation: generation, Binary: binary, APISocket: apiSocket}
+ return process, process.Validate()
+}
+
+func identifyProcess(pid int, id types.SandboxID, generation uint64, binary, apiSocket string) (vmm.Process, bool, error) {
+ match, err := processCommandMatches(pid, binary, apiSocket)
+ if err != nil {
+ if !processExists(pid) {
+ return vmm.Process{}, false, nil
+ }
+ return vmm.Process{}, false, err
+ }
+ if !match {
+ return vmm.Process{}, false, nil
+ }
+ process, err := captureProcess(pid, id, generation, binary, apiSocket)
+ return process, err == nil, err
+}
+
+func verifyProcess(process vmm.Process) (bool, error) {
+ bootID, err := hostBootID()
+ if err != nil {
+ return false, err
+ }
+ if bootID != process.BootID {
+ return false, nil
+ }
+ start, err := processStartTicks(process.PID)
+ if err != nil {
+ if !processExists(process.PID) {
+ return false, nil
+ }
+ return false, err
+ }
+ if start != process.StartTicks {
+ return false, nil
+ }
+ return processCommandMatches(process.PID, process.Binary, process.APISocket)
+}
+
+// terminateProcess opens a pidfd before identity checks, closing the PID-reuse
+// race between verification and signal delivery.
+func terminateProcess(ctx context.Context, process vmm.Process, grace time.Duration) error {
+ handle, err := os.FindProcess(process.PID)
+ if err != nil {
+ return err
+ }
+ defer handle.Release() //nolint:errcheck // releasing the pidfd cannot change process state
+ match, err := verifyProcess(process)
+ if err != nil {
+ return err
+ }
+ if !match {
+ return nil
+ }
+ if err := handle.Signal(syscall.SIGTERM); err != nil && !errors.Is(err, os.ErrProcessDone) {
+ return err
+ }
+ if waitProcess(ctx, handle, grace) == nil {
+ return nil
+ }
+ if err := handle.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+ return err
+ }
+ return waitProcess(ctx, handle, time.Second)
+}
+
+func waitProcess(ctx context.Context, process *os.Process, timeout time.Duration) error {
+ deadline := time.NewTimer(timeout)
+ defer deadline.Stop()
+ ticker := time.NewTicker(10 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ if err := process.Signal(syscall.Signal(0)); errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
+ return nil
+ } else if err != nil && !errors.Is(err, syscall.EPERM) {
+ return err
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-deadline.C:
+ return errors.New("timed out waiting for VMM process exit")
+ case <-ticker.C:
+ }
+ }
+}
+
+func processExists(pid int) bool {
+ if pid <= 0 {
+ return false
+ }
+ err := syscall.Kill(pid, 0)
+ return err == nil || errors.Is(err, syscall.EPERM)
+}
+
+func processCommandMatches(pid int, binary, apiSocket string) (bool, error) {
+ raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) //nolint:gosec // pid is a positive kernel identity
+ if err != nil {
+ return false, err
+ }
+ fields := strings.Split(strings.TrimSuffix(string(raw), "\x00"), "\x00")
+ if len(fields) == 0 || filepath.Base(fields[0]) != binary {
+ return false, nil
+ }
+ matches := 0
+ for index := 1; index+1 < len(fields); index++ {
+ if fields[index] == "--api-socket" && fields[index+1] == apiSocket {
+ matches++
+ }
+ }
+ return matches == 1, nil
+}
+
+func processStartTicks(pid int) (uint64, error) {
+ raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) //nolint:gosec // pid is a positive kernel identity
+ if err != nil {
+ return 0, err
+ }
+ text := string(raw)
+ end := strings.LastIndexByte(text, ')')
+ if end < 0 {
+ return 0, errors.New("process stat has no command terminator")
+ }
+ fields := strings.Fields(text[end+1:])
+ const startTimeIndex = 19
+ if len(fields) <= startTimeIndex {
+ return 0, errors.New("process stat omitted starttime")
+ }
+ start, err := strconv.ParseUint(fields[startTimeIndex], 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("parse process starttime: %w", err)
+ }
+ return start, nil
+}
+
+func hostBootID() (string, error) {
+ raw, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if err != nil {
+ return "", err
+ }
+ value := strings.TrimSpace(string(raw))
+ if value == "" {
+ return "", errors.New("host boot ID is empty")
+ }
+ return value, nil
+}
diff --git a/vmm/cloudhypervisor/process_linux_test.go b/vmm/cloudhypervisor/process_linux_test.go
new file mode 100644
index 0000000..fac0ccc
--- /dev/null
+++ b/vmm/cloudhypervisor/process_linux_test.go
@@ -0,0 +1,147 @@
+//go:build linux
+
+package cloudhypervisor
+
+import (
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+func TestProcessIdentityHelper(t *testing.T) {
+ if os.Getenv("KUMABOX_PROCESS_HELPER") != "1" {
+ return
+ }
+ if os.Getenv("KUMABOX_IGNORE_TERM") == "1" {
+ signal.Ignore(syscall.SIGTERM)
+ }
+ if err := os.WriteFile(os.Getenv("KUMABOX_READY_FILE"), []byte("ready"), 0o600); err != nil {
+ os.Exit(2)
+ }
+ for {
+ time.Sleep(time.Hour)
+ }
+}
+
+func TestConfigureProcessPlacesChildInPreparedCgroup(t *testing.T) {
+ scope, err := os.Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer scope.Close() //nolint:errcheck
+ command := exec.Command("true")
+ configureProcess(command, scope)
+ if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid || !command.SysProcAttr.UseCgroupFD || command.SysProcAttr.CgroupFD != int(scope.Fd()) {
+ t.Fatalf("process attributes = %+v", command.SysProcAttr)
+ }
+}
+
+func TestCaptureAndVerifyProcessIdentity(t *testing.T) {
+ command, wait := startProcessHelper(t, false, "/run/kumabox/api.sock")
+ process, err := captureProcess(
+ command.Process.Pid,
+ types.SandboxID("123e4567-e89b-42d3-a456-426614174000"),
+ 3,
+ filepath.Base(os.Args[0]),
+ "/run/kumabox/api.sock",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if match, err := verifyProcess(process); err != nil || !match {
+ t.Fatalf("verifyProcess() = %v, %v", match, err)
+ }
+ for _, test := range []struct {
+ name string
+ mutate func(*vmm.Process)
+ }{
+ {name: "starttime", mutate: func(value *vmm.Process) { value.StartTicks++ }},
+ {name: "boot ID", mutate: func(value *vmm.Process) { value.BootID += "-other" }},
+ {name: "binary", mutate: func(value *vmm.Process) { value.Binary = "other-vmm" }},
+ {name: "API socket", mutate: func(value *vmm.Process) { value.APISocket = "/run/kumabox/other.sock" }},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ candidate := process
+ test.mutate(&candidate)
+ if match, err := verifyProcess(candidate); err != nil || match {
+ t.Fatalf("verifyProcess() = %v, %v", match, err)
+ }
+ })
+ }
+ if err := command.Process.Kill(); err != nil {
+ t.Fatal(err)
+ }
+ <-wait
+}
+
+func TestTerminateProcessEscalatesFromTermToKill(t *testing.T) {
+ for _, ignoreTerm := range []bool{false, true} {
+ name := "TERM"
+ if ignoreTerm {
+ name = "KILL"
+ }
+ t.Run(name, func(t *testing.T) {
+ command, wait := startProcessHelper(t, ignoreTerm, "/run/kumabox/api.sock")
+ process, err := captureProcess(
+ command.Process.Pid,
+ types.SandboxID("123e4567-e89b-42d3-a456-426614174000"),
+ 3,
+ filepath.Base(os.Args[0]),
+ "/run/kumabox/api.sock",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := terminateProcess(t.Context(), process, 50*time.Millisecond); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-wait:
+ case <-time.After(time.Second):
+ t.Fatal("helper process was not reaped")
+ }
+ })
+ }
+}
+
+func startProcessHelper(t *testing.T, ignoreTerm bool, apiSocket string) (*exec.Cmd, <-chan error) {
+ t.Helper()
+ ready := filepath.Join(t.TempDir(), "ready")
+ command := exec.Command(os.Args[0], "-test.run=TestProcessIdentityHelper", "--", "--api-socket", apiSocket)
+ command.Env = append(os.Environ(), "KUMABOX_PROCESS_HELPER=1", "KUMABOX_READY_FILE="+ready)
+ if ignoreTerm {
+ command.Env = append(command.Env, "KUMABOX_IGNORE_TERM=1")
+ }
+ if err := command.Start(); err != nil {
+ t.Fatal(err)
+ }
+ wait := make(chan error, 1)
+ go func() {
+ wait <- command.Wait()
+ close(wait)
+ }()
+ t.Cleanup(func() {
+ _ = command.Process.Kill()
+ select {
+ case <-wait:
+ case <-time.After(time.Second):
+ }
+ })
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ if _, err := os.Stat(ready); err == nil {
+ return command, wait
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("helper process did not become ready")
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
diff --git a/vmm/cloudhypervisor/process_other.go b/vmm/cloudhypervisor/process_other.go
new file mode 100644
index 0000000..00cc1fd
--- /dev/null
+++ b/vmm/cloudhypervisor/process_other.go
@@ -0,0 +1,34 @@
+//go:build !linux
+
+package cloudhypervisor
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "time"
+
+ "github.com/kumabox/kumabox/types"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+var errLinuxRequired = errors.New("cloud hypervisor lifecycle requires Linux")
+
+func platformPreflight() error { return errLinuxRequired }
+
+func configureProcess(*exec.Cmd, *os.File) {}
+
+func startProcess(*exec.Cmd, string) error { return errLinuxRequired }
+
+func captureProcess(int, types.SandboxID, uint64, string, string) (vmm.Process, error) {
+ return vmm.Process{}, errLinuxRequired
+}
+
+func identifyProcess(int, types.SandboxID, uint64, string, string) (vmm.Process, bool, error) {
+ return vmm.Process{}, false, errLinuxRequired
+}
+
+func verifyProcess(vmm.Process) (bool, error) { return false, errLinuxRequired }
+
+func terminateProcess(context.Context, vmm.Process, time.Duration) error { return errLinuxRequired }
diff --git a/vmm/cloudhypervisor/restore.go b/vmm/cloudhypervisor/restore.go
new file mode 100644
index 0000000..b3a7160
--- /dev/null
+++ b/vmm/cloudhypervisor/restore.go
@@ -0,0 +1,168 @@
+package cloudhypervisor
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+var (
+ _ vmm.Restorer = (*Driver)(nil)
+ _ vmm.RestoreValidator = (*Driver)(nil)
+)
+
+// ValidateRestore checks the native files Cloud Hypervisor requires before a
+// caller stops the current sandbox process.
+func (*Driver) ValidateRestore(_ context.Context, directory string) error {
+ for _, name := range []string{"config.json", "state.json"} {
+ path := filepath.Join(directory, name)
+ info, err := os.Lstat(path)
+ if err != nil {
+ return fmt.Errorf("inspect %s: %w", name, err)
+ }
+ if !info.Mode().IsRegular() || info.Size() == 0 {
+ return fmt.Errorf("snapshot %s is not a nonempty regular file", name)
+ }
+ }
+ raw, err := os.ReadFile(filepath.Join(directory, "config.json")) //nolint:gosec // managed snapshot path
+ if err != nil {
+ return err
+ }
+ var config map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &config); err != nil || len(config) == 0 {
+ return errors.Join(err, errors.New("snapshot config.json is empty or invalid"))
+ }
+ entries, err := os.ReadDir(directory)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), "memory-range") {
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if info.Mode().IsRegular() && info.Size() > 0 {
+ return nil
+ }
+ }
+ }
+ return errors.New("snapshot has no nonempty memory-range file")
+}
+
+// Restore launches an API-only process in the target sandbox's cgroup and
+// namespace, loads native state, resumes the VM, and proves readiness.
+//
+// runtime dirs -> API-only process -> vm.restore -> vm.resume -> Running
+func (d *Driver) Restore(ctx context.Context, plan vmm.RestorePlan) (result vmm.Process, returnErr error) {
+ if err := plan.Validate(); err != nil {
+ return vmm.Process{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if err := d.Preflight(); err != nil {
+ return vmm.Process{}, err
+ }
+ if err := d.paths.Prepare(plan.SandboxID); err != nil {
+ return vmm.Process{}, err
+ }
+ var command *exec.Cmd
+ defer func() {
+ if returnErr == nil {
+ return
+ }
+ cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), d.abortGrace+time.Second)
+ defer cancel()
+ switch {
+ case result.PID > 0:
+ returnErr = errors.Join(returnErr, d.Abort(cleanupCtx, result))
+ case command != nil && command.Process != nil:
+ returnErr = errors.Join(returnErr, command.Process.Kill(), command.Wait(), d.scopes.Remove(cleanupCtx, plan.SandboxID), d.paths.Clear(plan.SandboxID))
+ default:
+ returnErr = errors.Join(returnErr, d.scopes.Remove(cleanupCtx, plan.SandboxID), d.paths.Clear(plan.SandboxID))
+ }
+ }()
+ apiSocket, _ := d.paths.APISocket(plan.SandboxID)
+ args := []string{"--api-socket", apiSocket}
+ if err := d.paths.WriteCmdline(plan.SandboxID, diagnosticCommand(d.binary, args)); err != nil {
+ return vmm.Process{}, err
+ }
+ scope, err := d.scopes.Prepare(ctx, plan.SandboxID, plan.CPUs)
+ if err != nil {
+ return vmm.Process{}, err
+ }
+ defer func() { returnErr = errors.Join(returnErr, scope.Close()) }()
+ logPath, _ := d.paths.LogFile(plan.SandboxID)
+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) //nolint:gosec // managed path
+ if err != nil {
+ return vmm.Process{}, fmt.Errorf("open VMM log: %w", err)
+ }
+ defer func() { returnErr = errors.Join(returnErr, logFile.Close()) }()
+ command = exec.Command(d.binary, args...) //nolint:gosec // configured executable, no shell
+ command.Stdout, command.Stderr = logFile, logFile
+ configureProcess(command, scope)
+ if err := startProcess(command, plan.Network.Namespace); err != nil {
+ return vmm.Process{}, fmt.Errorf("exec cloud-hypervisor restore process: %w", err)
+ }
+ result, err = captureProcess(command.Process.Pid, plan.SandboxID, plan.Generation, filepath.Base(d.binary), apiSocket)
+ if err != nil {
+ return result, fmt.Errorf("capture restore process identity: %w", err)
+ }
+ if err := d.paths.WriteProcess(result); err != nil {
+ return result, fmt.Errorf("persist restore process identity: %w", err)
+ }
+ go func() { _ = command.Wait() }()
+ if err := d.waitAPISocket(ctx, result); err != nil {
+ return result, err
+ }
+ payload, err := json.Marshal(map[string]string{"source_url": "file://" + plan.SnapshotDir})
+ if err != nil {
+ return result, err
+ }
+ if err := d.snapshotAction(ctx, apiSocket, "vm.restore", payload, snapshotTimeout); err != nil {
+ return result, fmt.Errorf("restore cloud-hypervisor state: %w", err)
+ }
+ if err := d.snapshotAction(ctx, apiSocket, "vm.resume", nil, d.startupTimeout); err != nil {
+ return result, fmt.Errorf("resume restored cloud-hypervisor: %w", err)
+ }
+ if err := d.WaitReady(ctx, result); err != nil {
+ return result, err
+ }
+ return result, nil
+}
+
+func (d *Driver) waitAPISocket(ctx context.Context, process vmm.Process) error {
+ deadline := time.NewTimer(d.startupTimeout)
+ defer deadline.Stop()
+ ticker := time.NewTicker(probeInterval)
+ defer ticker.Stop()
+ for {
+ connection, err := net.DialTimeout("unix", process.APISocket, probeInterval)
+ if err == nil {
+ _ = connection.Close()
+ return nil
+ }
+ located, exists, locateErr := d.Locate(ctx, process.SandboxID, process.Generation)
+ if locateErr != nil {
+ return locateErr
+ }
+ if !exists || located.PID != process.PID || located.StartTicks != process.StartTicks {
+ return errors.New("cloud-hypervisor restore process exited before its API socket became ready")
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-deadline.C:
+ return errors.New("timed out waiting for cloud-hypervisor restore API socket")
+ case <-ticker.C:
+ }
+ }
+}
diff --git a/vmm/cloudhypervisor/restore_test.go b/vmm/cloudhypervisor/restore_test.go
new file mode 100644
index 0000000..4773db8
--- /dev/null
+++ b/vmm/cloudhypervisor/restore_test.go
@@ -0,0 +1,29 @@
+package cloudhypervisor
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestValidateRestoreRequiresCompleteNativeSnapshot(t *testing.T) {
+ directory := t.TempDir()
+ for name, content := range map[string]string{
+ "config.json": `{"cpus":{"boot_vcpus":2}}`,
+ "state.json": `{"version":1}`,
+ "memory-range-0": "memory",
+ } {
+ if err := os.WriteFile(filepath.Join(directory, name), []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := (*Driver)(nil).ValidateRestore(t.Context(), directory); err != nil {
+ t.Fatalf("ValidateRestore() = %v", err)
+ }
+ if err := os.Remove(filepath.Join(directory, "memory-range-0")); err != nil {
+ t.Fatal(err)
+ }
+ if err := (*Driver)(nil).ValidateRestore(t.Context(), directory); err == nil {
+ t.Fatal("ValidateRestore accepted native state without memory")
+ }
+}
diff --git a/vmm/cloudhypervisor/snapshot.go b/vmm/cloudhypervisor/snapshot.go
new file mode 100644
index 0000000..deaa39b
--- /dev/null
+++ b/vmm/cloudhypervisor/snapshot.go
@@ -0,0 +1,88 @@
+package cloudhypervisor
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+const snapshotTimeout = 10 * time.Minute
+
+var _ vmm.Snapshotter = (*Driver)(nil)
+
+// Snapshot pauses the exact owned process, captures native VMM state and every
+// writable disk, then resumes the guest even when capture fails.
+//
+// verify -> pause -> native state -> writable disks -> resume
+// \----------- any error -----------/
+func (d *Driver) Snapshot(ctx context.Context, plan vmm.SnapshotPlan) (returnErr error) {
+ if err := plan.Validate(); err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ observation, err := d.Observe(ctx, plan.Process.SandboxID, plan.Process.Generation)
+ if err != nil {
+ return err
+ }
+ if observation.State != vmm.ProcessRunning || observation.Process.PID != plan.Process.PID || observation.Process.StartTicks != plan.Process.StartTicks {
+ return errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, errors.New("sandbox VMM changed before snapshot capture"))
+ }
+ if err := d.snapshotAction(ctx, plan.Process.APISocket, "vm.pause", nil, probeTimeout); err != nil {
+ return fmt.Errorf("pause cloud-hypervisor: %w", err)
+ }
+ defer func() {
+ resumeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), d.startupTimeout)
+ defer cancel()
+ returnErr = errors.Join(returnErr, d.snapshotAction(resumeCtx, plan.Process.APISocket, "vm.resume", nil, d.startupTimeout))
+ }()
+ payload, err := json.Marshal(map[string]string{"destination_url": "file://" + plan.Destination})
+ if err != nil {
+ return err
+ }
+ if err := d.snapshotAction(ctx, plan.Process.APISocket, "vm.snapshot", payload, snapshotTimeout); err != nil {
+ return fmt.Errorf("capture cloud-hypervisor state: %w", err)
+ }
+ for _, file := range plan.WritableFiles {
+ if err := storage.CopySparse(file.Destination, file.Source); err != nil {
+ return fmt.Errorf("capture writable disk: %w", err)
+ }
+ }
+ return nil
+}
+
+func (d *Driver) snapshotAction(ctx context.Context, socket, endpoint string, payload []byte, timeout time.Duration) error {
+ client, closeClient, err := unixAPIClient(socket)
+ if err != nil {
+ return err
+ }
+ defer closeClient()
+ client.Timeout = timeout
+ request, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://localhost/api/v1/"+endpoint, bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ if len(payload) > 0 {
+ request.Header.Set("Content-Type", "application/json")
+ }
+ response, err := client.Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close() //nolint:errcheck // status and bounded body are authoritative
+ body, readErr := io.ReadAll(io.LimitReader(response.Body, maxAPIResponse))
+ if readErr != nil {
+ return readErr
+ }
+ if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent {
+ return fmt.Errorf("cloud hypervisor %s returned HTTP %d: %s", endpoint, response.StatusCode, bytes.TrimSpace(body))
+ }
+ return nil
+}
diff --git a/vmm/cloudhypervisor/vsock.go b/vmm/cloudhypervisor/vsock.go
new file mode 100644
index 0000000..3e550f2
--- /dev/null
+++ b/vmm/cloudhypervisor/vsock.go
@@ -0,0 +1,107 @@
+package cloudhypervisor
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+ "strings"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/vmm"
+)
+
+const hybridVsockReplyLimit = 256
+
+// DialVsock verifies the exact VMM process and opens one guest port through
+// Cloud Hypervisor's hybrid Unix-socket transport.
+func (d *Driver) DialVsock(ctx context.Context, process vmm.Process, port uint32) (io.ReadWriteCloser, error) {
+ if port == 0 {
+ return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("vsock port must be positive"))
+ }
+ if err := process.Validate(); err != nil {
+ return nil, err
+ }
+ alive, err := verifyProcess(process)
+ if err != nil {
+ return nil, err
+ }
+ if !alive {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("cloud-hypervisor process is absent"))
+ }
+ socket, err := d.paths.Vsock(process.SandboxID)
+ if err != nil {
+ return nil, err
+ }
+ var dialer net.Dialer
+ connection, err := dialer.DialContext(ctx, "unix", socket)
+ if err != nil {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, fmt.Errorf("connect hybrid vsock: %w", err))
+ }
+ stopCancel := context.AfterFunc(ctx, func() { _ = connection.Close() })
+ if _, err := fmt.Fprintf(connection, "CONNECT %d\n", port); err != nil {
+ stopCancel()
+ _ = connection.Close()
+ return nil, fmt.Errorf("write hybrid vsock request: %w", err)
+ }
+ reply, err := readHybridVsockReply(connection)
+ stopCancel()
+ if err != nil {
+ _ = connection.Close()
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return nil, ctxErr
+ }
+ return nil, fmt.Errorf("read hybrid vsock reply: %w", err)
+ }
+ if err := validateHybridVsockReply(reply); err != nil {
+ _ = connection.Close()
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, fmt.Errorf("connect guest vsock port %d: %w", port, err))
+ }
+ alive, err = verifyProcess(process)
+ if err != nil || !alive {
+ _ = connection.Close()
+ if err != nil {
+ return nil, err
+ }
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("cloud-hypervisor exited while opening guest vsock"))
+ }
+ return connection, nil
+}
+
+// validateHybridVsockReply accepts the connection identifier allocated by the
+// VMM. The numeric value is not an echo of the requested guest port.
+func validateHybridVsockReply(reply string) error {
+ fields := strings.Fields(reply)
+ if len(fields) != 2 || fields[0] != "OK" {
+ return fmt.Errorf("unexpected hybrid vsock reply %q", strings.TrimSpace(reply))
+ }
+ assignedPort, err := strconv.ParseUint(fields[1], 10, 32)
+ if err != nil || assignedPort == 0 {
+ return fmt.Errorf("invalid hybrid vsock connection port %q", fields[1])
+ }
+ return nil
+}
+
+// readHybridVsockReply deliberately avoids buffered readers, which could
+// consume bytes belonging to the first agent frame after the handshake line.
+func readHybridVsockReply(reader io.Reader) (string, error) {
+ buffer := make([]byte, 0, 32)
+ one := []byte{0}
+ for {
+ length, err := reader.Read(one)
+ if length > 0 {
+ buffer = append(buffer, one[0])
+ if one[0] == '\n' {
+ return string(buffer), nil
+ }
+ if len(buffer) >= hybridVsockReplyLimit {
+ return "", fmt.Errorf("reply exceeds %d bytes", hybridVsockReplyLimit)
+ }
+ }
+ if err != nil {
+ return "", err
+ }
+ }
+}
diff --git a/vmm/cloudhypervisor/vsock_test.go b/vmm/cloudhypervisor/vsock_test.go
new file mode 100644
index 0000000..e88f346
--- /dev/null
+++ b/vmm/cloudhypervisor/vsock_test.go
@@ -0,0 +1,33 @@
+package cloudhypervisor
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestReadHybridVsockReplyStopsAtNewline(t *testing.T) {
+ reply, err := readHybridVsockReply(strings.NewReader("OK 1024\nagent-frame\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reply != "OK 1024\n" {
+ t.Fatalf("reply = %q", reply)
+ }
+}
+
+func TestReadHybridVsockReplyIsBounded(t *testing.T) {
+ if _, err := readHybridVsockReply(strings.NewReader(strings.Repeat("x", hybridVsockReplyLimit))); err == nil {
+ t.Fatal("accepted an unbounded handshake reply")
+ }
+}
+
+func TestValidateHybridVsockReplyAcceptsAllocatedPort(t *testing.T) {
+ if err := validateHybridVsockReply("OK 1073741824\n"); err != nil {
+ t.Fatal(err)
+ }
+ for _, reply := range []string{"ERR 1024\n", "OK 0\n", "OK invalid\n", "OK 1 extra\n"} {
+ if err := validateHybridVsockReply(reply); err == nil {
+ t.Fatalf("accepted invalid reply %q", reply)
+ }
+ }
+}
diff --git a/vmm/log.go b/vmm/log.go
new file mode 100644
index 0000000..0cffa34
--- /dev/null
+++ b/vmm/log.go
@@ -0,0 +1,265 @@
+package vmm
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+const (
+ logFollowInterval = 100 * time.Millisecond
+ logHeadSize = 256
+)
+
+// LogOptions controls the backend-independent VMM log stream.
+type LogOptions struct {
+ // Tail starts output at the last N lines. Zero streams the complete file.
+ Tail int
+ // Follow waits for appended data and survives VMM log truncation or replacement.
+ Follow bool
+}
+
+// Validate rejects options that have no useful command-line meaning.
+func (o LogOptions) Validate() error {
+ if o.Tail < 0 {
+ return errors.New("log tail must not be negative")
+ }
+ return nil
+}
+
+// Logs streams one backend-owned log without exposing its host path to core.
+// Follow polling deliberately stays synchronous: cancellation has one owner and
+// cannot leak a watcher goroutine after a CLI invocation exits.
+//
+// open -> optional tail -> copy available bytes
+// |
+// follow: poll -> append / rewind / reopen
+func (p Paths) Logs(ctx context.Context, id types.SandboxID, options LogOptions, output io.Writer) (returnErr error) {
+ if err := options.Validate(); err != nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ if output == nil {
+ return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("log output is required"))
+ }
+ path, err := p.LogFile(id)
+ if err != nil {
+ return err
+ }
+ current, err := openLog(path)
+ if err != nil {
+ return err
+ }
+ defer func() { returnErr = errors.Join(returnErr, current.Close()) }()
+
+ if options.Tail > 0 {
+ if err := seekLastLines(current, options.Tail); err != nil {
+ return fmt.Errorf("seek VMM log tail: %w", err)
+ }
+ }
+ if err := copyLog(ctx, output, current); err != nil {
+ if options.Follow && ctx.Err() != nil {
+ return nil
+ }
+ return err
+ }
+ if !options.Follow {
+ return nil
+ }
+
+ offset, err := current.Seek(0, io.SeekCurrent)
+ if err != nil {
+ return fmt.Errorf("locate VMM log offset: %w", err)
+ }
+ signature, err := logSignature(current, 0)
+ if err != nil {
+ return fmt.Errorf("read VMM log signature: %w", err)
+ }
+ ticker := time.NewTicker(logFollowInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+ case <-ticker.C:
+ }
+
+ pathInfo, err := os.Stat(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ // Successful rm owns log deletion. A follower that already opened the
+ // log completes cleanly instead of waiting on an unlinked inode.
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("stat VMM log: %w", err)
+ }
+ openInfo, err := current.Stat()
+ if err != nil {
+ return fmt.Errorf("stat open VMM log: %w", err)
+ }
+ if !os.SameFile(pathInfo, openInfo) {
+ next, err := openLog(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return err
+ }
+ if err := current.Close(); err != nil {
+ _ = next.Close()
+ return fmt.Errorf("close replaced VMM log: %w", err)
+ }
+ current = next
+ offset = 0
+ signature = nil
+ }
+
+ newSignature, err := logSignature(current, len(signature))
+ if err != nil {
+ return fmt.Errorf("read VMM log signature: %w", err)
+ }
+ if pathInfo.Size() < offset || len(signature) > 0 && !bytes.Equal(newSignature, signature) {
+ if _, err := current.Seek(0, io.SeekStart); err != nil {
+ return fmt.Errorf("rewind truncated VMM log: %w", err)
+ }
+ signature, err = logSignature(current, 0)
+ if err != nil {
+ return fmt.Errorf("read truncated VMM log signature: %w", err)
+ }
+ }
+ if err := copyLog(ctx, output, current); err != nil {
+ if ctx.Err() != nil {
+ return nil
+ }
+ return err
+ }
+ offset, err = current.Seek(0, io.SeekCurrent)
+ if err != nil {
+ return fmt.Errorf("locate VMM log offset: %w", err)
+ }
+ if len(signature) == 0 && offset > 0 {
+ signature, err = logSignature(current, 0)
+ if err != nil {
+ return fmt.Errorf("read VMM log signature: %w", err)
+ }
+ }
+ }
+}
+
+// RemoveLogs removes all persistent log artifacts owned by one backend.
+func (p Paths) RemoveLogs(ctx context.Context, id types.SandboxID) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ directory, err := p.LogDir(id)
+ if err != nil {
+ return err
+ }
+ if err := storage.CheckPath(directory); err != nil {
+ return err
+ }
+ if err := os.RemoveAll(directory); err != nil {
+ return fmt.Errorf("remove VMM log directory %s: %w", directory, err)
+ }
+ return nil
+}
+
+func openLog(path string) (*os.File, error) {
+ file, err := os.Open(path) //nolint:gosec // path is derived from validated managed roots and sandbox identity
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, fmt.Errorf("VMM log is unavailable; the sandbox may not have been started yet: %w", err))
+ }
+ if err != nil {
+ return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, fmt.Errorf("open VMM log: %w", err))
+ }
+ return file, nil
+}
+
+// seekLastLines treats a final newline as a terminator rather than an empty
+// extra line, so --tail 1 on "one\ntwo\n" starts at "two".
+func seekLastLines(file *os.File, count int) error {
+ info, err := file.Stat()
+ if err != nil {
+ return err
+ }
+ size := info.Size()
+ if size == 0 {
+ return nil
+ }
+ const chunkSize = 4096
+ buffer := make([]byte, chunkSize)
+ position, found := size, 0
+ for position > 0 {
+ readSize := min(int64(chunkSize), position)
+ position -= readSize
+ if _, err := file.ReadAt(buffer[:readSize], position); err != nil {
+ return err
+ }
+ for index := readSize - 1; index >= 0; index-- {
+ if buffer[index] != '\n' || position+index == size-1 {
+ continue
+ }
+ found++
+ if found == count {
+ _, err := file.Seek(position+index+1, io.SeekStart)
+ return err
+ }
+ }
+ }
+ _, err = file.Seek(0, io.SeekStart)
+ return err
+}
+
+func copyLog(ctx context.Context, output io.Writer, file *os.File) error {
+ buffer := make([]byte, 32*1024)
+ for {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ read, readErr := file.Read(buffer)
+ if read > 0 {
+ written, writeErr := output.Write(buffer[:read])
+ if writeErr != nil {
+ return fmt.Errorf("write VMM log: %w", writeErr)
+ }
+ if written != read {
+ return fmt.Errorf("write VMM log: %w", io.ErrShortWrite)
+ }
+ }
+ if errors.Is(readErr, io.EOF) {
+ return nil
+ }
+ if readErr != nil {
+ return fmt.Errorf("read VMM log: %w", readErr)
+ }
+ }
+}
+
+// logSignature reads a stable prefix without changing the stream offset. When
+// width is nonzero, the original width is retained as the comparison contract.
+func logSignature(file *os.File, width int) ([]byte, error) {
+ info, err := file.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if width == 0 {
+ width = min(logHeadSize, int(info.Size()))
+ }
+ if width == 0 {
+ return nil, nil
+ }
+ signature := make([]byte, width)
+ read, err := file.ReadAt(signature, 0)
+ if err != nil && !errors.Is(err, io.EOF) {
+ return nil, err
+ }
+ return signature[:read], nil
+}
diff --git a/vmm/log_test.go b/vmm/log_test.go
new file mode 100644
index 0000000..b5efbbd
--- /dev/null
+++ b/vmm/log_test.go
@@ -0,0 +1,162 @@
+package vmm
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+)
+
+type lockedBuffer struct {
+ mu sync.Mutex
+ buffer bytes.Buffer
+}
+
+func (b *lockedBuffer) Write(data []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buffer.Write(data)
+}
+
+func (b *lockedBuffer) String() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buffer.String()
+}
+
+func TestLogsTailUsesLinesAndPreservesTrailingNewline(t *testing.T) {
+ paths := testLogPaths(t)
+ writeTestLog(t, paths, "one\ntwo\nthree\n")
+ var output bytes.Buffer
+ if err := paths.Logs(t.Context(), testSandboxID, LogOptions{Tail: 2}, &output); err != nil {
+ t.Fatal(err)
+ }
+ if output.String() != "two\nthree\n" {
+ t.Fatalf("tail output = %q", output.String())
+ }
+
+ output.Reset()
+ if err := paths.Logs(t.Context(), testSandboxID, LogOptions{}, &output); err != nil {
+ t.Fatal(err)
+ }
+ if output.String() != "one\ntwo\nthree\n" {
+ t.Fatalf("complete output = %q", output.String())
+ }
+}
+
+func TestLogsFollowRewindsTruncatedFileAndCancelsCleanly(t *testing.T) {
+ paths := testLogPaths(t)
+ path := writeTestLog(t, paths, "old-one\nold-two\n")
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ var output lockedBuffer
+ done := make(chan error, 1)
+ go func() {
+ done <- paths.Logs(ctx, testSandboxID, LogOptions{Tail: 1, Follow: true}, &output)
+ }()
+ waitForLog(t, &output, "old-two\n")
+ if err := os.WriteFile(path, []byte("new-boot\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ waitForLog(t, &output, "old-two\nnew-boot\n")
+ replacement := filepath.Join(filepath.Dir(path), "replacement.log")
+ if err := os.WriteFile(replacement, []byte("replacement-boot\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(replacement, path); err != nil {
+ t.Fatal(err)
+ }
+ waitForLog(t, &output, "old-two\nnew-boot\nreplacement-boot\n")
+ cancel()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("follow cancellation = %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("follow did not stop after cancellation")
+ }
+}
+
+func TestLogsMissingAndRemovalOwnership(t *testing.T) {
+ paths := testLogPaths(t)
+ var output bytes.Buffer
+ if err := paths.Logs(t.Context(), testSandboxID, LogOptions{}, &output); err == nil {
+ t.Fatal("Logs opened a missing VMM log")
+ } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeArtifactUnavailable {
+ t.Fatalf("missing log error = %v", err)
+ }
+ writeTestLog(t, paths, "diagnostic\n")
+ runDir, _ := paths.RunDir(testSandboxID)
+ if err := paths.RemoveLogs(t.Context(), testSandboxID); err != nil {
+ t.Fatal(err)
+ }
+ if err := paths.RemoveLogs(t.Context(), testSandboxID); err != nil {
+ t.Fatalf("idempotent RemoveLogs = %v", err)
+ }
+ logDir, _ := paths.LogDir(testSandboxID)
+ if _, err := os.Stat(logDir); !os.IsNotExist(err) {
+ t.Fatalf("log directory remains: %v", err)
+ }
+ if _, err := os.Stat(runDir); err != nil {
+ t.Fatalf("runtime directory was removed with logs: %v", err)
+ }
+}
+
+func testLogPaths(t *testing.T) Paths {
+ t.Helper()
+ base := t.TempDir()
+ paths, err := NewPaths(storage.Roots{Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log")})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := paths.Prepare(testSandboxID); err != nil {
+ t.Fatal(err)
+ }
+ return paths
+}
+
+func writeTestLog(t *testing.T, paths Paths, content string) string {
+ t.Helper()
+ path, err := paths.LogFile(testSandboxID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func waitForLog(t *testing.T, output *lockedBuffer, expected string) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if strings.Contains(output.String(), expected) {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("log output %q never contained %q", output.String(), expected)
+}
+
+func TestLogsPreservesWriterFailure(t *testing.T) {
+ paths := testLogPaths(t)
+ writeTestLog(t, paths, "output\n")
+ failure := errors.New("closed output")
+ if err := paths.Logs(t.Context(), testSandboxID, LogOptions{}, failingLogWriter{failure}); !errors.Is(err, failure) {
+ t.Fatalf("writer failure = %v", err)
+ }
+}
+
+type failingLogWriter struct{ error }
+
+func (w failingLogWriter) Write([]byte) (int, error) { return 0, w.error }
diff --git a/vmm/paths.go b/vmm/paths.go
new file mode 100644
index 0000000..9de7ccc
--- /dev/null
+++ b/vmm/paths.go
@@ -0,0 +1,223 @@
+package vmm
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/kumabox/kumabox/errdefs"
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+const (
+ apiSocketName = "api.sock"
+ vsockName = "vsock.uds"
+ processName = "process.json"
+ cmdlineName = "cmdline"
+ logName = "vmm.log"
+)
+
+// Paths derives ephemeral runtime files and persistent VMM logs from shared roots.
+type Paths struct {
+ // roots is validated once so every derived path stays within its owner root.
+ roots storage.Roots
+}
+
+// NewPaths validates roots without creating directories.
+func NewPaths(roots storage.Roots) (Paths, error) {
+ validated, err := roots.Validate()
+ if err != nil {
+ return Paths{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err)
+ }
+ return Paths{roots: validated}, nil
+}
+
+// Ensure creates only shared runtime and log parents. Per-sandbox directories
+// are created by Prepare with private permissions immediately before launch.
+func (p Paths) Ensure() error {
+ return errors.Join(storage.EnsureDir(p.RunBase()), storage.EnsureDir(p.LogBase()))
+}
+
+// RunBase contains reconstructable per-sandbox VMM state.
+func (p Paths) RunBase() string { return filepath.Join(p.roots.Run, "sandboxes") }
+
+// LogBase contains durable per-sandbox VMM logs.
+func (p Paths) LogBase() string { return filepath.Join(p.roots.Log, "sandboxes") }
+
+// RunDir returns one sandbox's private runtime directory.
+func (p Paths) RunDir(id types.SandboxID) (string, error) { return p.idDir(p.RunBase(), id) }
+
+// LogDir returns one sandbox's private log directory.
+func (p Paths) LogDir(id types.SandboxID) (string, error) { return p.idDir(p.LogBase(), id) }
+
+// APISocket is the unique Cloud Hypervisor control endpoint and process marker.
+func (p Paths) APISocket(id types.SandboxID) (string, error) { return p.runFile(id, apiSocketName) }
+
+// Vsock is the private host endpoint for the future guest-agent transport.
+func (p Paths) Vsock(id types.SandboxID) (string, error) { return p.runFile(id, vsockName) }
+
+// ProcessFile stores the PID generation and host boot identity.
+func (p Paths) ProcessFile(id types.SandboxID) (string, error) { return p.runFile(id, processName) }
+
+// Cmdline stores the exact VMM invocation for diagnostics.
+func (p Paths) Cmdline(id types.SandboxID) (string, error) { return p.runFile(id, cmdlineName) }
+
+// LogFile stores stdout and stderr from the owned VMM process.
+func (p Paths) LogFile(id types.SandboxID) (string, error) {
+ dir, err := p.LogDir(id)
+ if err != nil {
+ return "", err
+ }
+ return storage.Join(dir, logName)
+}
+
+// Prepare creates private per-sandbox runtime and log directories.
+func (p Paths) Prepare(id types.SandboxID) error {
+ if err := p.Ensure(); err != nil {
+ return err
+ }
+ for _, directory := range []func(types.SandboxID) (string, error){p.RunDir, p.LogDir} {
+ path, err := directory(id)
+ if err != nil {
+ return err
+ }
+ if err := storage.EnsureDir(path); err != nil {
+ return err
+ }
+ if err := os.Chmod(path, 0o700); err != nil { //nolint:gosec // runtime directories intentionally require owner traversal
+ return fmt.Errorf("set private directory mode on %s: %w", path, err)
+ }
+ }
+ return nil
+}
+
+// WriteProcess atomically replaces process identity after validating every field.
+func (p Paths) WriteProcess(process Process) error {
+ if err := process.Validate(); err != nil {
+ return err
+ }
+ path, err := p.ProcessFile(process.SandboxID)
+ if err != nil {
+ return err
+ }
+ raw, err := json.MarshalIndent(process, "", " ")
+ if err != nil {
+ return err
+ }
+ return writeAtomic(path, append(raw, '\n'), 0o600)
+}
+
+// ReadProcess decodes and validates a complete identity file.
+func (p Paths) ReadProcess(id types.SandboxID) (Process, error) {
+ path, err := p.ProcessFile(id)
+ if err != nil {
+ return Process{}, err
+ }
+ raw, err := os.ReadFile(path) //nolint:gosec // path is derived from a validated ID and root
+ if err != nil {
+ return Process{}, err
+ }
+ var process Process
+ if err := json.Unmarshal(raw, &process); err != nil {
+ return Process{}, fmt.Errorf("decode process identity: %w", err)
+ }
+ if err := process.Validate(); err != nil {
+ return Process{}, fmt.Errorf("validate process identity: %w", err)
+ }
+ if process.SandboxID != id {
+ return Process{}, errors.New("process identity belongs to another sandbox")
+ }
+ return process, nil
+}
+
+// WriteCmdline atomically records a diagnostic rendering before exec.
+func (p Paths) WriteCmdline(id types.SandboxID, command string) error {
+ path, err := p.Cmdline(id)
+ if err != nil {
+ return err
+ }
+ return writeAtomic(path, []byte(command+"\n"), 0o600)
+}
+
+// Clear removes reconstructable files after the process has been proven absent.
+func (p Paths) Clear(id types.SandboxID) error {
+ dir, err := p.RunDir(id)
+ if err != nil {
+ return err
+ }
+ if err := storage.CheckPath(dir); err != nil {
+ return err
+ }
+ if err := os.RemoveAll(dir); err != nil {
+ return fmt.Errorf("remove VMM runtime %s: %w", dir, err)
+ }
+ return nil
+}
+
+func (p Paths) idDir(root string, id types.SandboxID) (string, error) {
+ if _, err := types.ParseSandboxID(id.String()); err != nil {
+ return "", err
+ }
+ return storage.Join(root, id.String())
+}
+
+func (p Paths) runFile(id types.SandboxID, name string) (string, error) {
+ dir, err := p.RunDir(id)
+ if err != nil {
+ return "", err
+ }
+ return storage.Join(dir, name)
+}
+
+// writeAtomic makes a complete file visible in one rename and syncs its parent.
+// Runtime identity is small, but a partial write can authorize the wrong PID.
+func writeAtomic(path string, data []byte, mode os.FileMode) (returnErr error) {
+ if len(data) == 0 {
+ return errors.New("refuse to atomically write empty runtime data")
+ }
+ if err := storage.CheckPath(path); err != nil {
+ return err
+ }
+ dir := filepath.Dir(path)
+ if err := storage.EnsureDir(dir); err != nil {
+ return err
+ }
+ temporary, err := os.CreateTemp(dir, ".runtime-*")
+ if err != nil {
+ return err
+ }
+ temporaryPath := temporary.Name()
+ closed := false
+ defer func() {
+ if !closed {
+ returnErr = errors.Join(returnErr, temporary.Close())
+ }
+ if returnErr != nil {
+ returnErr = errors.Join(returnErr, os.Remove(temporaryPath))
+ }
+ }()
+ if err := temporary.Chmod(mode); err != nil {
+ return err
+ }
+ if _, err := temporary.Write(data); err != nil {
+ return err
+ }
+ if err := temporary.Sync(); err != nil {
+ return err
+ }
+ if err := temporary.Close(); err != nil {
+ return err
+ }
+ closed = true
+ if err := os.Rename(temporaryPath, path); err != nil {
+ return err
+ }
+ directory, err := os.Open(dir) //nolint:gosec // validated managed directory
+ if err != nil {
+ return err
+ }
+ return errors.Join(directory.Sync(), directory.Close())
+}
diff --git a/vmm/paths_test.go b/vmm/paths_test.go
new file mode 100644
index 0000000..d6a8b18
--- /dev/null
+++ b/vmm/paths_test.go
@@ -0,0 +1,45 @@
+package vmm
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/kumabox/kumabox/storage"
+ "github.com/kumabox/kumabox/types"
+)
+
+const testSandboxID = types.SandboxID("123e4567-e89b-42d3-a456-426614174000")
+
+func TestPathsRoundTripPrivateProcessIdentity(t *testing.T) {
+ base := t.TempDir()
+ paths, err := NewPaths(storage.Roots{Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log")})
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := testSandboxID
+ if err := paths.Prepare(id); err != nil {
+ t.Fatal(err)
+ }
+ runDir, _ := paths.RunDir(id)
+ if info, err := os.Stat(runDir); err != nil || info.Mode().Perm() != 0o700 {
+ t.Fatalf("runtime directory = %+v, %v", info, err)
+ }
+ process := Process{PID: 42, StartTicks: 7, BootID: "boot", SandboxID: id, Generation: 3, Binary: "cloud-hypervisor", APISocket: filepath.Join(runDir, "api.sock")}
+ if err := paths.WriteProcess(process); err != nil {
+ t.Fatal(err)
+ }
+ got, err := paths.ReadProcess(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != process {
+ t.Fatalf("process = %+v, want %+v", got, process)
+ }
+ if err := paths.Clear(id); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(runDir); !os.IsNotExist(err) {
+ t.Fatalf("runtime directory remains: %v", err)
+ }
+}
diff --git a/vmm/vmm.go b/vmm/vmm.go
new file mode 100644
index 0000000..239d2b2
--- /dev/null
+++ b/vmm/vmm.go
@@ -0,0 +1,248 @@
+// Package vmm defines launch plans and runtime process facts shared by the
+// application service and virtual-machine-monitor adapters. It contains no
+// lifecycle persistence or CLI presentation.
+package vmm
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "path/filepath"
+ "strings"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+const (
+ // LayerSerialPrefix identifies immutable EROFS disks by manifest position.
+ LayerSerialPrefix = "kumabox-layer"
+ // COWSerial identifies the sandbox-private ext4 overlay disk.
+ COWSerial = "kumabox-cow"
+ // VsockGuestCID is safe because every sandbox has a private host Unix socket.
+ VsockGuestCID uint32 = 3
+)
+
+// Disk is one block device in VMM attachment order.
+type Disk struct {
+ // Path is an absolute managed artifact path on the host.
+ Path string
+ // Serial is the stable guest-visible identity used by early userspace.
+ Serial string
+ // ReadOnly protects shared image layers from guest writes.
+ ReadOnly bool
+}
+
+// LaunchPlan is a complete, immutable request for one VMM process.
+type LaunchPlan struct {
+ // SandboxID owns every runtime path and process created from the plan.
+ SandboxID types.SandboxID
+ // Generation is the durable Starting generation that owns this launch.
+ Generation uint64
+ // CPUs is the number of boot vCPUs.
+ CPUs uint32
+ // Memory is guest RAM in bytes.
+ Memory int64
+ // BootProfile selects the host/guest direct-boot contract.
+ BootProfile types.BootProfile
+ // Kernel is the verified direct-boot kernel artifact.
+ Kernel string
+ // Initrd is the verified early-userspace artifact.
+ Initrd string
+ // Cmdline carries the versioned boot profile parameters.
+ Cmdline string
+ // Disks are attached base-to-top followed by the private COW disk.
+ Disks []Disk
+ // Network is the validated host-to-VMM handoff. Its zero value disables
+ // network attachment and namespace entry.
+ Network types.NetworkSetup
+}
+
+// Validate rejects incomplete plans before an adapter creates runtime state.
+func (p LaunchPlan) Validate() error {
+ if _, err := types.ParseSandboxID(p.SandboxID.String()); err != nil {
+ return err
+ }
+ if p.Generation == 0 || p.CPUs == 0 || p.Memory <= 0 {
+ return errors.New("launch generation, CPUs, and memory must be positive")
+ }
+ if p.BootProfile != types.BootProfileOverlayV1 {
+ return fmt.Errorf("unsupported boot profile %q", p.BootProfile)
+ }
+ if !filepath.IsAbs(p.Kernel) || !filepath.IsAbs(p.Initrd) || p.Cmdline == "" || len(p.Disks) < 2 {
+ return errors.New("launch plan requires absolute boot artifacts, a cmdline, image layers, and COW")
+ }
+ seen := make(map[string]bool, len(p.Disks))
+ for position, disk := range p.Disks {
+ if !filepath.IsAbs(disk.Path) || disk.Serial == "" || seen[disk.Serial] {
+ return errors.New("launch plan contains an invalid or duplicate disk")
+ }
+ seen[disk.Serial] = true
+ last := position == len(p.Disks)-1
+ if last != (disk.Serial == COWSerial && !disk.ReadOnly) {
+ return errors.New("launch plan must end with one writable kumabox-cow disk")
+ }
+ if !last && (!disk.ReadOnly || disk.Serial != fmt.Sprintf("%s%d", LayerSerialPrefix, position)) {
+ return errors.New("image disks must be read-only and serialed by manifest position")
+ }
+ }
+ if err := p.Network.Validate(); err != nil {
+ return fmt.Errorf("launch network: %w", err)
+ }
+ return nil
+}
+
+// OverlayV1Config contains values rendered into the overlay-v1 guest boot
+// contract. Grouping them keeps future boot parameters explicit.
+type OverlayV1Config struct {
+ // LayerCount is the number of immutable image disks.
+ LayerCount int
+ // Hostname is the validated sandbox name applied by early userspace.
+ Hostname string
+ // Interfaces contains persisted guest identities in eth index order.
+ Interfaces []types.NetworkInterface
+ // DNSServers supplies up to two IPv4 resolvers to static kernel IP entries.
+ DNSServers []string
+}
+
+// OverlayV1Cmdline renders the public KumaBox boot ABI. Layer disks attach in
+// base-to-top order, while OverlayFS lowerdirs must be listed top-to-base.
+func OverlayV1Cmdline(config OverlayV1Config) (string, error) {
+ if config.LayerCount <= 0 {
+ return "", errors.New("overlay-v1 requires at least one image layer")
+ }
+ if config.Hostname == "" || strings.ContainsAny(config.Hostname, " \t\r\n\x00") {
+ return "", errors.New("overlay-v1 requires a hostname without whitespace")
+ }
+ serials := make([]string, 0, config.LayerCount)
+ for position := config.LayerCount - 1; position >= 0; position-- {
+ serials = append(serials, fmt.Sprintf("%s%d", LayerSerialPrefix, position))
+ }
+ var commandLine strings.Builder
+ commandLine.WriteString("console=hvc0 loglevel=3 boot=kumabox-overlay kumabox.layers=")
+ commandLine.WriteString(strings.Join(serials, ","))
+ commandLine.WriteString(" kumabox.cow=" + COWSerial + " kumabox.hostname=" + config.Hostname + " clocksource=kvm-clock rw")
+ if len(config.Interfaces) == 0 {
+ return commandLine.String(), nil
+ }
+ commandLine.WriteString(" net.ifnames=0")
+ dns, err := ipv4DNSServers(config.DNSServers)
+ if err != nil {
+ return "", err
+ }
+ for _, networkInterface := range config.Interfaces {
+ if err := networkInterface.Validate(); err != nil {
+ return "", err
+ }
+ if networkInterface.IPv4 == nil {
+ continue
+ }
+ mask := net.IP(net.CIDRMask(networkInterface.IPv4.Prefix, 32)).String()
+ parameter := fmt.Sprintf(" ip=%s::%s:%s:%s:%s:off",
+ networkInterface.IPv4.Address, networkInterface.IPv4.Gateway,
+ mask, config.Hostname, networkInterface.Name,
+ )
+ if len(dns) > 0 {
+ parameter += ":" + dns[0]
+ if len(dns) > 1 {
+ parameter += ":" + dns[1]
+ }
+ }
+ commandLine.WriteString(parameter)
+ }
+ return commandLine.String(), nil
+}
+
+func ipv4DNSServers(configured []string) ([]string, error) {
+ result := make([]string, 0, min(2, len(configured)))
+ for _, server := range configured {
+ address := net.ParseIP(server)
+ if address == nil || address.To4() == nil {
+ return nil, fmt.Errorf("overlay-v1 DNS server %q is not IPv4", server)
+ }
+ if len(result) < 2 {
+ result = append(result, server)
+ }
+ }
+ return result, nil
+}
+
+// Process identifies one Linux process generation independently of PID reuse.
+type Process struct {
+ // PID is the host process ID observed immediately after launch.
+ PID int `json:"pid"`
+ // StartTicks is Linux /proc stat starttime for this PID generation.
+ StartTicks uint64 `json:"start_ticks"`
+ // BootID invalidates all process identities after a host reboot.
+ BootID string `json:"boot_id"`
+ // SandboxID binds the process to one managed runtime directory.
+ SandboxID types.SandboxID `json:"sandbox_id"`
+ // Generation is the Starting catalog generation that launched the process.
+ Generation uint64 `json:"generation"`
+ // Binary is the executable basename required during process verification.
+ Binary string `json:"binary"`
+ // APISocket is the exact unique argument required during process verification.
+ APISocket string `json:"api_socket"`
+}
+
+// Validate rejects identities that cannot safely authorize observation or signals.
+func (p Process) Validate() error {
+ if p.PID <= 0 || p.StartTicks == 0 || p.BootID == "" || p.Generation == 0 || p.Binary == "" || !filepath.IsAbs(p.APISocket) {
+ return errors.New("process identity is incomplete")
+ }
+ if _, err := types.ParseSandboxID(p.SandboxID.String()); err != nil {
+ return err
+ }
+ return nil
+}
+
+// ProcessState summarizes facts proven from process identity and the VMM API.
+type ProcessState string
+
+const (
+ // ProcessAbsent means no owned VMM process is alive.
+ ProcessAbsent ProcessState = "absent"
+ // ProcessStarting means the owned process exists but its API is not Running.
+ ProcessStarting ProcessState = "starting"
+ // ProcessRunning means both identity and vm.info report a running VM.
+ ProcessRunning ProcessState = "running"
+)
+
+// Observation is one fail-closed runtime snapshot.
+type Observation struct {
+ // State is absent, starting, or running.
+ State ProcessState
+ // Process is populated for starting and running observations.
+ Process Process
+}
+
+// Validate rejects capture plans that could write outside their prepared
+// directory or alias a source and destination.
+func (p SnapshotPlan) Validate() error {
+ if err := p.Process.Validate(); err != nil {
+ return err
+ }
+ if !filepath.IsAbs(p.Destination) || len(p.WritableFiles) == 0 {
+ return errors.New("snapshot plan requires an absolute destination and writable files")
+ }
+ for _, file := range p.WritableFiles {
+ if !filepath.IsAbs(file.Source) || !filepath.IsAbs(file.Destination) || file.Source == file.Destination {
+ return errors.New("snapshot writable file paths must be distinct and absolute")
+ }
+ relative, err := filepath.Rel(p.Destination, file.Destination)
+ if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
+ return errors.New("snapshot writable destination escapes capture directory")
+ }
+ }
+ return nil
+}
+
+// Validate rejects incomplete restore ownership before a process is launched.
+func (p RestorePlan) Validate() error {
+ if _, err := types.ParseSandboxID(p.SandboxID.String()); err != nil {
+ return err
+ }
+ if p.Generation == 0 || p.CPUs == 0 || !filepath.IsAbs(p.SnapshotDir) {
+ return errors.New("restore plan requires generation, CPUs, and an absolute snapshot directory")
+ }
+ return p.Network.Validate()
+}
diff --git a/vmm/vmm_test.go b/vmm/vmm_test.go
new file mode 100644
index 0000000..539fb08
--- /dev/null
+++ b/vmm/vmm_test.go
@@ -0,0 +1,106 @@
+package vmm
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/kumabox/kumabox/types"
+)
+
+func TestOverlayV1CmdlineListsLayersTopToBase(t *testing.T) {
+ cmdline, err := OverlayV1Cmdline(OverlayV1Config{LayerCount: 3, Hostname: "demo"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(cmdline, "boot=kumabox-overlay") || !strings.Contains(cmdline, "kumabox.layers=kumabox-layer2,kumabox-layer1,kumabox-layer0") || !strings.Contains(cmdline, "kumabox.cow=kumabox-cow") || !strings.Contains(cmdline, "kumabox.hostname=demo") {
+ t.Fatalf("cmdline = %q", cmdline)
+ }
+}
+
+func TestOverlayV1CmdlineRendersStaticNetworkAndDNS(t *testing.T) {
+ cmdline, err := OverlayV1Cmdline(OverlayV1Config{
+ LayerCount: 1,
+ Hostname: "demo",
+ Interfaces: []types.NetworkInterface{{
+ Index: 0, Name: "eth0", TAP: "tap12345678-0", MAC: "02:00:00:00:00:01",
+ Queues: 4, QueueSize: 512, Network: "bridge",
+ IPv4: &types.IPv4Config{Address: "10.42.0.7", Gateway: "10.42.0.1", Prefix: 24},
+ }},
+ DNSServers: []string{"8.8.8.8", "1.1.1.1", "9.9.9.9"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := " net.ifnames=0 ip=10.42.0.7::10.42.0.1:255.255.255.0:demo:eth0:off:8.8.8.8:1.1.1.1"
+ if !strings.Contains(cmdline, want) {
+ t.Fatalf("cmdline = %q, want suffix %q", cmdline, want)
+ }
+}
+
+func TestLaunchPlanRequiresBaseToTopReadOnlyLayersAndFinalCOW(t *testing.T) {
+ plan := LaunchPlan{
+ SandboxID: "123e4567-e89b-42d3-a456-426614174000", Generation: 3,
+ CPUs: 2, Memory: 1 << 30, BootProfile: types.BootProfileOverlayV1,
+ Kernel: "/images/vmlinuz", Initrd: "/images/initrd.img", Cmdline: "boot=kumabox-overlay",
+ Disks: []Disk{
+ {Path: "/images/base.erofs", Serial: "kumabox-layer0", ReadOnly: true},
+ {Path: "/images/top.erofs", Serial: "kumabox-layer1", ReadOnly: true},
+ {Path: "/sandboxes/cow.raw", Serial: COWSerial},
+ },
+ }
+ if err := plan.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ plan.Disks[1].ReadOnly = false
+ if err := plan.Validate(); err == nil {
+ t.Fatal("accepted a writable shared image layer")
+ }
+}
+
+func TestProcessValidationRequiresCompleteIdentity(t *testing.T) {
+ valid := Process{
+ PID: 42, StartTicks: 100, BootID: "host-boot",
+ SandboxID: "123e4567-e89b-42d3-a456-426614174000", Generation: 3,
+ Binary: "cloud-hypervisor", APISocket: "/run/kumabox/api.sock",
+ }
+ if err := valid.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ tests := []struct {
+ name string
+ mutate func(*Process)
+ }{
+ {name: "PID", mutate: func(process *Process) { process.PID = 0 }},
+ {name: "start time", mutate: func(process *Process) { process.StartTicks = 0 }},
+ {name: "boot ID", mutate: func(process *Process) { process.BootID = "" }},
+ {name: "sandbox ID", mutate: func(process *Process) { process.SandboxID = types.SandboxID("broken") }},
+ {name: "generation", mutate: func(process *Process) { process.Generation = 0 }},
+ {name: "binary", mutate: func(process *Process) { process.Binary = "" }},
+ {name: "API socket", mutate: func(process *Process) { process.APISocket = "relative.sock" }},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ candidate := valid
+ test.mutate(&candidate)
+ if err := candidate.Validate(); err == nil {
+ t.Fatalf("accepted incomplete process identity: %+v", candidate)
+ }
+ })
+ }
+}
+
+func TestRestorePlanRequiresOwnedAbsoluteSnapshot(t *testing.T) {
+ plan := RestorePlan{
+ SandboxID: "123e4567-e89b-42d3-a456-426614174000",
+ Generation: 7,
+ CPUs: 2,
+ SnapshotDir: "/var/lib/kumabox/snapshots/example",
+ }
+ if err := plan.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ plan.SnapshotDir = "relative/snapshot"
+ if err := plan.Validate(); err == nil {
+ t.Fatal("RestorePlan accepted a relative snapshot directory")
+ }
+}