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

KumaBox logo

+

English · 简体中文

+

+ CI + Go 1.24.4+ + Linux amd64 | arm64 + MIT License +

-# 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 +

-[![CI](https://github.com/kgpp34/KumaBox/actions/workflows/ci.yml/badge.svg)](https://github.com/kgpp34/KumaBox/actions/workflows/ci.yml) -[![Go](https://img.shields.io/badge/Go-1.24.4%2B-00ADD8?logo=go&logoColor=white)](https://go.dev/) -[![Platform](https://img.shields.io/badge/platform-Linux-FCC624?logo=linux&logoColor=black)](https://www.kernel.org/) -[![License](https://img.shields.io/badge/license-MIT-green.svg)](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 + +

Who drives KumaBox, how it is driven, and what each sandbox gets

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

KumaBox 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 + +

Sandbox lifecycle: build, run, warm, snapshot, clone

+ +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: +

Design choices of KumaBox, CubeSandbox and E2B

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

+ KumaBox logo +

+ +

+ English · 简体中文 +

+ +

+ CI + Go 1.24.4+ + Linux amd64 | arm64 + MIT License +

+ +

+ 快速开始 · + 架构 · + 对比 · + 路线图 +

+ +AI Agent 会写代码、装依赖、发起网络连接,还会读写没人审过的文件。把这些放在共享内核上跑, +本质上是在赌运气。KumaBox 为每个任务分配一台独立的 **KVM microVM**:独立的内核、独立的磁盘、 +独立的网络命名空间。从 OCI 镜像到一个可用的沙箱,只需要一条命令。 + +> [!WARNING] +> KumaBox 仍在快速迭代中。CLI、元数据格式和快照格式暂不承诺向后兼容。 +> 在首个稳定版发布之前,请在可随时重建的 Linux/KVM 主机上使用。 + +## KumaBox 是什么 + +

谁来驱动 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 架构

+ +**轻量的控制面。** 每次调用 `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` 为准。 + +## 与同类项目的对比 + +

KumaBox、CubeSandbox 与 E2B 的设计选择

+ +[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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +KumaBox +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'KumaBox\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") + } +}