From 367ece179a56fc8bc716204d2f6d2b4bb906b556 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 9 Sep 2026 13:38:04 +0800 Subject: [PATCH 01/45] docs: define P12 rewrite approval gates --- AGENTS.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..484205d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# KumaBox P12 rewrite rules + +## Project and baseline + +- This remains the KumaBox project. P12 rewrites KumaBox's core logic; it does not create a new product or fork Cocoon. +- Preserve `pre-p12-rewrite-20260909`, which points to `08bc8d7545492da96b5c0fc984afc549b4497af1`. Do not move or delete the tag. +- Use Cocoon at `../cocoon@27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` to align lifecycle design, ordering, commit boundaries, and failure recovery. Do not copy its package structure or treat it as KumaBox source code. +- Read `docs/implementation/14-large-scale-agent-cross-node.md` and `docs/implementation/15-p12-core-rewrite-plan-and-progress.md` before planning P12 work. + +## Mandatory feature approval gate + +Before starting any major feature, present all four items to the project owner: + +1. Logic: inputs, outputs, preconditions, state transitions, operation order, failures, cancellation, retry, crash recovery, idempotency, and Cocoon parity. +2. Design: domain boundaries, data ownership, transactions, commit points, locks/leases, external side effects, reconciliation, alternatives, and tradeoffs. +3. Code abstraction: core types and invariants, consumer-owned interfaces, concrete implementations, errors, configuration, and test seams. +4. Project layout: directories and files to add/remove, package responsibilities, import direction, forbidden dependencies, and final legacy deletion scope. + +Do not create or modify Go, proto, SQL, test, script, generated, or other implementation code until the project owner explicitly approves that feature's four-part proposal. Reading, analysis, diagrams, design documents, and progress-ledger updates are allowed before approval. + +Each P12 Epic, each create/start/snapshot/clone/restore/delete/reconcile vertical path, and every change to the domain model, schema, operation state machine, public API, package boundary, or concurrency model is a major feature. Overall plan approval does not approve individual features. Material deviation from an approved proposal invalidates the approval; stop coding and request approval again. From 87561f2539b7936f07db7befc707652c029637d5 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 9 Sep 2026 14:46:24 +0800 Subject: [PATCH 02/45] refactor: reset source tree for P12 rewrite --- .github/workflows/ci.yml | 125 +- .github/workflows/release.yml | 142 -- .gitignore | 2 + Makefile | 38 +- README.md | 282 +--- cmd/agent/main.go | 31 - cmd/kumabox/main.go | 22 - go.mod | 39 - go.sum | 121 -- internal/agent/client/client.go | 297 ---- internal/agent/client/client_test.go | 524 ------ internal/agent/client/stream.go | 179 -- internal/agent/client/tty.go | 109 -- internal/agent/protocol/frame.go | 230 --- internal/agent/protocol/frame_test.go | 131 -- internal/agent/protocol/protocol.go | 31 - internal/agent/server/identity_linux.go | 125 -- internal/agent/server/identity_linux_test.go | 23 - internal/agent/server/identity_networkd.go | 170 -- .../agent/server/identity_networkd_test.go | 95 -- internal/agent/server/identity_other.go | 9 - internal/agent/server/policy.go | 73 - internal/agent/server/policy_test.go | 38 - internal/agent/server/process_linux.go | 34 - internal/agent/server/process_other.go | 28 - internal/agent/server/pty_linux.go | 230 --- internal/agent/server/pty_linux_test.go | 56 - internal/agent/server/pty_other.go | 14 - internal/agent/server/reseed_linux.go | 122 -- internal/agent/server/reseed_linux_test.go | 60 - internal/agent/server/reseed_other.go | 11 - internal/agent/server/server.go | 528 ------ internal/agent/server/server_test.go | 224 --- internal/agent/server/vsock_linux.go | 78 - internal/agent/server/vsock_linux_test.go | 42 - internal/agent/server/vsock_other.go | 13 - internal/archtest/dependencies_test.go | 125 ++ internal/archtest/doc.go | 3 + internal/backend/backend.go | 150 -- internal/backend/cloudhypervisor/api.go | 208 --- .../backend/cloudhypervisor/api_contract.go | 19 - internal/backend/cloudhypervisor/api_test.go | 68 - internal/backend/cloudhypervisor/backend.go | 45 - .../backend/cloudhypervisor/compatibility.go | 160 -- .../cloudhypervisor/compatibility_test.go | 32 - internal/backend/cloudhypervisor/config.go | 626 ------- .../backend/cloudhypervisor/config_test.go | 491 ------ internal/backend/cloudhypervisor/console.go | 66 - .../cloudhypervisor/console_resize_linux.go | 9 - .../cloudhypervisor/console_resize_other.go | 9 - .../backend/cloudhypervisor/console_test.go | 21 - internal/backend/cloudhypervisor/devices.go | 42 - internal/backend/cloudhypervisor/disk.go | 117 -- internal/backend/cloudhypervisor/disk_test.go | 16 - .../backend/cloudhypervisor/filesystem.go | 79 - internal/backend/cloudhypervisor/network.go | 38 - internal/backend/cloudhypervisor/observe.go | 129 -- internal/backend/cloudhypervisor/pci.go | 102 -- internal/backend/cloudhypervisor/restore.go | 326 ---- .../backend/cloudhypervisor/restore_test.go | 163 -- internal/backend/cloudhypervisor/snapshot.go | 31 - internal/backend/cloudhypervisor/start.go | 163 -- .../backend/cloudhypervisor/start_linux.go | 49 - .../backend/cloudhypervisor/start_other.go | 9 - .../backend/cloudhypervisor/start_test.go | 45 - internal/backend/cloudhypervisor/state.go | 17 - internal/backend/cloudhypervisor/stop.go | 153 -- internal/batch/batch.go | 106 -- internal/batch/batch_test.go | 101 -- internal/cli/agent.go | 237 --- internal/cli/agent_test.go | 49 - internal/cli/batch.go | 65 - internal/cli/clone.go | 43 - internal/cli/completion_test.go | 81 - internal/cli/console.go | 154 -- internal/cli/console_test.go | 100 -- internal/cli/data_disk_test.go | 30 - internal/cli/debug.go | 69 - internal/cli/disk.go | 72 - internal/cli/doctor.go | 41 - internal/cli/exec.go | 205 --- internal/cli/exit.go | 42 - internal/cli/filesystem.go | 70 - internal/cli/gc.go | 105 -- internal/cli/hibernate.go | 36 - internal/cli/image.go | 630 ------- internal/cli/metadata.go | 159 -- internal/cli/metadata_test.go | 117 -- internal/cli/network.go | 180 -- internal/cli/output.go | 158 -- internal/cli/parity_test.go | 194 --- internal/cli/pci.go | 84 - internal/cli/requests.go | 247 --- internal/cli/resource_batch.go | 28 - internal/cli/restore.go | 37 - internal/cli/root.go | 213 --- internal/cli/root_test.go | 1463 ----------------- internal/cli/snapshot.go | 363 ---- internal/cli/state.go | 68 - internal/cli/stores.go | 10 - internal/cli/usage.go | 73 - internal/cli/usage_test.go | 20 - internal/cli/values.go | 71 - internal/cli/version.go | 29 - internal/cli/vm.go | 372 ----- internal/cli/vm_watch_test.go | 76 - internal/config/config.go | 269 --- internal/config/config_test.go | 114 -- internal/content/doc.go | 4 + internal/disk/copy.go | 123 -- internal/disk/copy_linux.go | 94 -- internal/disk/copy_other.go | 9 - internal/disk/copy_test.go | 59 - internal/disk/qemuimg.go | 167 -- internal/disk/qemuimg_test.go | 93 -- internal/disk/reflink.go | 32 - internal/disk/reflink_linux.go | 41 - internal/disk/reflink_other.go | 7 - internal/doctor/doctor.go | 258 --- internal/doctor/doctor_test.go | 34 - internal/fault/fault.go | 62 - internal/fault/fault_test.go | 22 - internal/fault/points.go | 18 - internal/fileutil/cleanup.go | 19 - internal/fileutil/json.go | 43 - internal/gc/gc.go | 834 ---------- internal/gc/gc_test.go | 763 --------- internal/gc/snapshot_policy.go | 280 ---- internal/gc/snapshot_policy_test.go | 249 --- internal/image/file_import.go | 256 --- internal/image/file_import_test.go | 101 -- internal/image/index.go | 68 - internal/image/index_codec.go | 49 - internal/image/oci/builder.go | 758 --------- internal/image/oci/builder_test.go | 295 ---- internal/image/oci/index_codec.go | 49 - internal/image/oci/pipeline.go | 56 - internal/image/oci/pipeline_test.go | 55 - internal/image/oci/resolver.go | 139 -- internal/image/oci/resolver_test.go | 57 - internal/image/oci/source.go | 162 -- internal/image/oci/source_test.go | 18 - internal/image/oci/store.go | 381 ----- internal/image/oci/store_test.go | 132 -- internal/image/record.go | 198 --- internal/image/store.go | 614 ------- internal/image/store_test.go | 421 ----- internal/lock/guard.go | 58 - internal/lock/guard_test.go | 65 - internal/lock/locker.go | 102 -- internal/lock/locker_test.go | 101 -- internal/meta/backend_benchmark_test.go | 167 -- internal/meta/collection.go | 106 -- internal/meta/engine.go | 71 - internal/meta/json/codec.go | 122 -- internal/meta/json/store.go | 553 ------- internal/meta/json/store_test.go | 303 ---- internal/meta/memory.go | 301 ---- internal/meta/memory_test.go | 199 --- internal/meta/sqlite/convert.go | 77 - internal/meta/sqlite/init.go | 226 --- internal/meta/sqlite/maintenance.go | 173 -- internal/meta/sqlite/maintenance_test.go | 142 -- internal/meta/sqlite/store.go | 584 ------- internal/meta/sqlite/store_test.go | 402 ----- internal/meta/transfer.go | 130 -- internal/meta/transfer_test.go | 128 -- internal/metering/store.go | 201 --- internal/metering/store_test.go | 48 - internal/network/allocator.go | 315 ---- internal/network/allocator_test.go | 260 --- internal/network/attach_linux.go | 115 -- internal/network/attach_other.go | 16 - internal/network/capability.go | 57 - internal/network/cni.go | 371 ----- internal/network/cni_linux.go | 243 --- internal/network/cni_linux_test.go | 16 - internal/network/cni_other.go | 28 - internal/network/cni_test.go | 223 --- internal/network/hosttap_codec.go | 51 - internal/network/hosttap_linux.go | 423 ----- internal/network/hosttap_other.go | 28 - internal/network/index_codec.go | 110 -- internal/network/nft.go | 39 - internal/network/nft_test.go | 33 - internal/network/provider.go | 16 - internal/network/store.go | 504 ------ internal/network/store_test.go | 268 --- internal/network/types.go | 169 -- internal/network/verify.go | 7 - internal/network/verify_linux.go | 95 -- internal/network/verify_other.go | 9 - internal/operation/doc.go | 4 + internal/operation/journal.go | 243 --- internal/operation/journal_test.go | 105 -- internal/reference/store.go | 129 -- internal/reference/store_test.go | 68 - internal/sandbox/doc.go | 4 + internal/snapshot/capture.go | 184 --- internal/snapshot/capture_test.go | 58 - internal/snapshot/directory.go | 186 --- internal/snapshot/directory_test.go | 95 -- internal/snapshot/export.go | 171 -- internal/snapshot/export_linux.go | 41 - internal/snapshot/export_other.go | 16 - internal/snapshot/export_test.go | 73 - internal/snapshot/import.go | 395 ----- internal/snapshot/import_test.go | 116 -- internal/snapshot/index.go | 63 - internal/snapshot/index_codec.go | 53 - internal/snapshot/layout.go | 15 - internal/snapshot/lease.go | 80 - internal/snapshot/manifest.go | 101 -- internal/snapshot/native.go | 192 --- internal/snapshot/native_test.go | 49 - internal/snapshot/record.go | 52 - internal/snapshot/store.go | 559 ------- internal/snapshot/store_test.go | 307 ---- internal/snapshot/verify.go | 434 ----- internal/snapshot/verify_test.go | 113 -- internal/state/convert.go | 528 ------ internal/state/metering_test.go | 47 - internal/state/resources.go | 96 -- internal/state/set.go | 116 -- internal/state/set_test.go | 261 --- internal/state/vm.go | 67 - internal/tenant/doc.go | 3 + internal/version/version.go | 22 - internal/version/version_test.go | 16 - internal/vm/index.go | 57 - internal/vm/index_codec.go | 53 - internal/vm/nocloud/fat12.go | 332 ---- internal/vm/nocloud/metadata.go | 206 --- internal/vm/nocloud/metadata_test.go | 132 -- internal/vm/record.go | 723 -------- internal/vm/runtime/batch.go | 81 - internal/vm/runtime/batch_test.go | 106 -- internal/vm/runtime/console.go | 26 - internal/vm/runtime/device_state.go | 79 - internal/vm/runtime/disk.go | 113 -- internal/vm/runtime/disk_prepare.go | 154 -- internal/vm/runtime/events.go | 59 - internal/vm/runtime/filesystem.go | 105 -- internal/vm/runtime/guest_reseed.go | 82 - internal/vm/runtime/guest_reseed_test.go | 44 - internal/vm/runtime/hibernate.go | 158 -- internal/vm/runtime/hibernate_test.go | 123 -- internal/vm/runtime/logs.go | 273 --- internal/vm/runtime/logs_follow_test.go | 121 -- internal/vm/runtime/metering.go | 85 - internal/vm/runtime/metering_test.go | 91 - internal/vm/runtime/native_clone.go | 325 ---- internal/vm/runtime/native_clone_test.go | 417 ----- internal/vm/runtime/native_restore.go | 327 ---- internal/vm/runtime/native_restore_test.go | 281 ---- internal/vm/runtime/native_snapshot.go | 161 -- internal/vm/runtime/native_snapshot_test.go | 160 -- internal/vm/runtime/native_verify.go | 37 - internal/vm/runtime/network_coordinator.go | 214 --- internal/vm/runtime/network_recovery_test.go | 272 --- internal/vm/runtime/network_resize.go | 101 -- internal/vm/runtime/operations.go | 55 - internal/vm/runtime/operations_test.go | 28 - internal/vm/runtime/pci.go | 85 - internal/vm/runtime/performance.go | 113 -- internal/vm/runtime/performance_test.go | 44 - internal/vm/runtime/reconcile.go | 157 -- internal/vm/runtime/references.go | 66 - internal/vm/runtime/restore.go | 263 --- internal/vm/runtime/restore_mode.go | 46 - internal/vm/runtime/restore_mode_test.go | 20 - internal/vm/runtime/restore_test.go | 134 -- internal/vm/runtime/runtime.go | 948 ----------- internal/vm/runtime/runtime_test.go | 1433 ---------------- internal/vm/runtime/state.go | 132 -- internal/vm/runtime/state_open.go | 11 - internal/vm/runtime/state_test.go | 74 - internal/vm/runtime/watch.go | 179 -- internal/vm/runtime/watch_test.go | 188 --- internal/vm/storage.go | 180 -- internal/vm/storage_test.go | 182 -- internal/vm/store.go | 660 -------- internal/vm/store_test.go | 598 ------- internal/vm/views.go | 167 -- internal/vm/views_test.go | 38 - oci-images/ubuntu/24.04/Dockerfile | 67 - oci-images/ubuntu/agent-stub.sh | 16 - oci-images/ubuntu/kumabox-agent.openrc | 13 - oci-images/ubuntu/kumabox-agent.service | 20 - oci-images/ubuntu/network.sh | 84 - oci-images/ubuntu/overlay.sh | 142 -- scripts/check.sh | 310 ---- scripts/install.sh | 91 - test/e2e/e2e.sh | 377 ----- test/release/check.sh | 20 - test/release/install.sh | 61 - 296 files changed, 183 insertions(+), 46422 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 cmd/agent/main.go delete mode 100644 cmd/kumabox/main.go delete mode 100644 go.sum delete mode 100644 internal/agent/client/client.go delete mode 100644 internal/agent/client/client_test.go delete mode 100644 internal/agent/client/stream.go delete mode 100644 internal/agent/client/tty.go delete mode 100644 internal/agent/protocol/frame.go delete mode 100644 internal/agent/protocol/frame_test.go delete mode 100644 internal/agent/protocol/protocol.go delete mode 100644 internal/agent/server/identity_linux.go delete mode 100644 internal/agent/server/identity_linux_test.go delete mode 100644 internal/agent/server/identity_networkd.go delete mode 100644 internal/agent/server/identity_networkd_test.go delete mode 100644 internal/agent/server/identity_other.go delete mode 100644 internal/agent/server/policy.go delete mode 100644 internal/agent/server/policy_test.go delete mode 100644 internal/agent/server/process_linux.go delete mode 100644 internal/agent/server/process_other.go delete mode 100644 internal/agent/server/pty_linux.go delete mode 100644 internal/agent/server/pty_linux_test.go delete mode 100644 internal/agent/server/pty_other.go delete mode 100644 internal/agent/server/reseed_linux.go delete mode 100644 internal/agent/server/reseed_linux_test.go delete mode 100644 internal/agent/server/reseed_other.go delete mode 100644 internal/agent/server/server.go delete mode 100644 internal/agent/server/server_test.go delete mode 100644 internal/agent/server/vsock_linux.go delete mode 100644 internal/agent/server/vsock_linux_test.go delete mode 100644 internal/agent/server/vsock_other.go create mode 100644 internal/archtest/dependencies_test.go create mode 100644 internal/archtest/doc.go delete mode 100644 internal/backend/backend.go delete mode 100644 internal/backend/cloudhypervisor/api.go delete mode 100644 internal/backend/cloudhypervisor/api_contract.go delete mode 100644 internal/backend/cloudhypervisor/api_test.go delete mode 100644 internal/backend/cloudhypervisor/backend.go delete mode 100644 internal/backend/cloudhypervisor/compatibility.go delete mode 100644 internal/backend/cloudhypervisor/compatibility_test.go delete mode 100644 internal/backend/cloudhypervisor/config.go delete mode 100644 internal/backend/cloudhypervisor/config_test.go delete mode 100644 internal/backend/cloudhypervisor/console.go delete mode 100644 internal/backend/cloudhypervisor/console_resize_linux.go delete mode 100644 internal/backend/cloudhypervisor/console_resize_other.go delete mode 100644 internal/backend/cloudhypervisor/console_test.go delete mode 100644 internal/backend/cloudhypervisor/devices.go delete mode 100644 internal/backend/cloudhypervisor/disk.go delete mode 100644 internal/backend/cloudhypervisor/disk_test.go delete mode 100644 internal/backend/cloudhypervisor/filesystem.go delete mode 100644 internal/backend/cloudhypervisor/network.go delete mode 100644 internal/backend/cloudhypervisor/observe.go delete mode 100644 internal/backend/cloudhypervisor/pci.go delete mode 100644 internal/backend/cloudhypervisor/restore.go delete mode 100644 internal/backend/cloudhypervisor/restore_test.go delete mode 100644 internal/backend/cloudhypervisor/snapshot.go delete mode 100644 internal/backend/cloudhypervisor/start.go delete mode 100644 internal/backend/cloudhypervisor/start_linux.go delete mode 100644 internal/backend/cloudhypervisor/start_other.go delete mode 100644 internal/backend/cloudhypervisor/start_test.go delete mode 100644 internal/backend/cloudhypervisor/state.go delete mode 100644 internal/backend/cloudhypervisor/stop.go delete mode 100644 internal/batch/batch.go delete mode 100644 internal/batch/batch_test.go delete mode 100644 internal/cli/agent.go delete mode 100644 internal/cli/agent_test.go delete mode 100644 internal/cli/batch.go delete mode 100644 internal/cli/clone.go delete mode 100644 internal/cli/completion_test.go delete mode 100644 internal/cli/console.go delete mode 100644 internal/cli/console_test.go delete mode 100644 internal/cli/data_disk_test.go delete mode 100644 internal/cli/debug.go delete mode 100644 internal/cli/disk.go delete mode 100644 internal/cli/doctor.go delete mode 100644 internal/cli/exec.go delete mode 100644 internal/cli/exit.go delete mode 100644 internal/cli/filesystem.go delete mode 100644 internal/cli/gc.go delete mode 100644 internal/cli/hibernate.go delete mode 100644 internal/cli/image.go delete mode 100644 internal/cli/metadata.go delete mode 100644 internal/cli/metadata_test.go delete mode 100644 internal/cli/network.go delete mode 100644 internal/cli/output.go delete mode 100644 internal/cli/parity_test.go delete mode 100644 internal/cli/pci.go delete mode 100644 internal/cli/requests.go delete mode 100644 internal/cli/resource_batch.go delete mode 100644 internal/cli/restore.go delete mode 100644 internal/cli/root.go delete mode 100644 internal/cli/root_test.go delete mode 100644 internal/cli/snapshot.go delete mode 100644 internal/cli/state.go delete mode 100644 internal/cli/stores.go delete mode 100644 internal/cli/usage.go delete mode 100644 internal/cli/usage_test.go delete mode 100644 internal/cli/values.go delete mode 100644 internal/cli/version.go delete mode 100644 internal/cli/vm.go delete mode 100644 internal/cli/vm_watch_test.go delete mode 100644 internal/config/config.go delete mode 100644 internal/config/config_test.go create mode 100644 internal/content/doc.go delete mode 100644 internal/disk/copy.go delete mode 100644 internal/disk/copy_linux.go delete mode 100644 internal/disk/copy_other.go delete mode 100644 internal/disk/copy_test.go delete mode 100644 internal/disk/qemuimg.go delete mode 100644 internal/disk/qemuimg_test.go delete mode 100644 internal/disk/reflink.go delete mode 100644 internal/disk/reflink_linux.go delete mode 100644 internal/disk/reflink_other.go delete mode 100644 internal/doctor/doctor.go delete mode 100644 internal/doctor/doctor_test.go delete mode 100644 internal/fault/fault.go delete mode 100644 internal/fault/fault_test.go delete mode 100644 internal/fault/points.go delete mode 100644 internal/fileutil/cleanup.go delete mode 100644 internal/fileutil/json.go delete mode 100644 internal/gc/gc.go delete mode 100644 internal/gc/gc_test.go delete mode 100644 internal/gc/snapshot_policy.go delete mode 100644 internal/gc/snapshot_policy_test.go delete mode 100644 internal/image/file_import.go delete mode 100644 internal/image/file_import_test.go delete mode 100644 internal/image/index.go delete mode 100644 internal/image/index_codec.go delete mode 100644 internal/image/oci/builder.go delete mode 100644 internal/image/oci/builder_test.go delete mode 100644 internal/image/oci/index_codec.go delete mode 100644 internal/image/oci/pipeline.go delete mode 100644 internal/image/oci/pipeline_test.go delete mode 100644 internal/image/oci/resolver.go delete mode 100644 internal/image/oci/resolver_test.go delete mode 100644 internal/image/oci/source.go delete mode 100644 internal/image/oci/source_test.go delete mode 100644 internal/image/oci/store.go delete mode 100644 internal/image/oci/store_test.go delete mode 100644 internal/image/record.go delete mode 100644 internal/image/store.go delete mode 100644 internal/image/store_test.go delete mode 100644 internal/lock/guard.go delete mode 100644 internal/lock/guard_test.go delete mode 100644 internal/lock/locker.go delete mode 100644 internal/lock/locker_test.go delete mode 100644 internal/meta/backend_benchmark_test.go delete mode 100644 internal/meta/collection.go delete mode 100644 internal/meta/engine.go delete mode 100644 internal/meta/json/codec.go delete mode 100644 internal/meta/json/store.go delete mode 100644 internal/meta/json/store_test.go delete mode 100644 internal/meta/memory.go delete mode 100644 internal/meta/memory_test.go delete mode 100644 internal/meta/sqlite/convert.go delete mode 100644 internal/meta/sqlite/init.go delete mode 100644 internal/meta/sqlite/maintenance.go delete mode 100644 internal/meta/sqlite/maintenance_test.go delete mode 100644 internal/meta/sqlite/store.go delete mode 100644 internal/meta/sqlite/store_test.go delete mode 100644 internal/meta/transfer.go delete mode 100644 internal/meta/transfer_test.go delete mode 100644 internal/metering/store.go delete mode 100644 internal/metering/store_test.go delete mode 100644 internal/network/allocator.go delete mode 100644 internal/network/allocator_test.go delete mode 100644 internal/network/attach_linux.go delete mode 100644 internal/network/attach_other.go delete mode 100644 internal/network/capability.go delete mode 100644 internal/network/cni.go delete mode 100644 internal/network/cni_linux.go delete mode 100644 internal/network/cni_linux_test.go delete mode 100644 internal/network/cni_other.go delete mode 100644 internal/network/cni_test.go delete mode 100644 internal/network/hosttap_codec.go delete mode 100644 internal/network/hosttap_linux.go delete mode 100644 internal/network/hosttap_other.go delete mode 100644 internal/network/index_codec.go delete mode 100644 internal/network/nft.go delete mode 100644 internal/network/nft_test.go delete mode 100644 internal/network/provider.go delete mode 100644 internal/network/store.go delete mode 100644 internal/network/store_test.go delete mode 100644 internal/network/types.go delete mode 100644 internal/network/verify.go delete mode 100644 internal/network/verify_linux.go delete mode 100644 internal/network/verify_other.go create mode 100644 internal/operation/doc.go delete mode 100644 internal/operation/journal.go delete mode 100644 internal/operation/journal_test.go delete mode 100644 internal/reference/store.go delete mode 100644 internal/reference/store_test.go create mode 100644 internal/sandbox/doc.go delete mode 100644 internal/snapshot/capture.go delete mode 100644 internal/snapshot/capture_test.go delete mode 100644 internal/snapshot/directory.go delete mode 100644 internal/snapshot/directory_test.go delete mode 100644 internal/snapshot/export.go delete mode 100644 internal/snapshot/export_linux.go delete mode 100644 internal/snapshot/export_other.go delete mode 100644 internal/snapshot/export_test.go delete mode 100644 internal/snapshot/import.go delete mode 100644 internal/snapshot/import_test.go delete mode 100644 internal/snapshot/index.go delete mode 100644 internal/snapshot/index_codec.go delete mode 100644 internal/snapshot/layout.go delete mode 100644 internal/snapshot/lease.go delete mode 100644 internal/snapshot/manifest.go delete mode 100644 internal/snapshot/native.go delete mode 100644 internal/snapshot/native_test.go delete mode 100644 internal/snapshot/record.go delete mode 100644 internal/snapshot/store.go delete mode 100644 internal/snapshot/store_test.go delete mode 100644 internal/snapshot/verify.go delete mode 100644 internal/snapshot/verify_test.go delete mode 100644 internal/state/convert.go delete mode 100644 internal/state/metering_test.go delete mode 100644 internal/state/resources.go delete mode 100644 internal/state/set.go delete mode 100644 internal/state/set_test.go delete mode 100644 internal/state/vm.go create mode 100644 internal/tenant/doc.go delete mode 100644 internal/version/version.go delete mode 100644 internal/version/version_test.go delete mode 100644 internal/vm/index.go delete mode 100644 internal/vm/index_codec.go delete mode 100644 internal/vm/nocloud/fat12.go delete mode 100644 internal/vm/nocloud/metadata.go delete mode 100644 internal/vm/nocloud/metadata_test.go delete mode 100644 internal/vm/record.go delete mode 100644 internal/vm/runtime/batch.go delete mode 100644 internal/vm/runtime/batch_test.go delete mode 100644 internal/vm/runtime/console.go delete mode 100644 internal/vm/runtime/device_state.go delete mode 100644 internal/vm/runtime/disk.go delete mode 100644 internal/vm/runtime/disk_prepare.go delete mode 100644 internal/vm/runtime/events.go delete mode 100644 internal/vm/runtime/filesystem.go delete mode 100644 internal/vm/runtime/guest_reseed.go delete mode 100644 internal/vm/runtime/guest_reseed_test.go delete mode 100644 internal/vm/runtime/hibernate.go delete mode 100644 internal/vm/runtime/hibernate_test.go delete mode 100644 internal/vm/runtime/logs.go delete mode 100644 internal/vm/runtime/logs_follow_test.go delete mode 100644 internal/vm/runtime/metering.go delete mode 100644 internal/vm/runtime/metering_test.go delete mode 100644 internal/vm/runtime/native_clone.go delete mode 100644 internal/vm/runtime/native_clone_test.go delete mode 100644 internal/vm/runtime/native_restore.go delete mode 100644 internal/vm/runtime/native_restore_test.go delete mode 100644 internal/vm/runtime/native_snapshot.go delete mode 100644 internal/vm/runtime/native_snapshot_test.go delete mode 100644 internal/vm/runtime/native_verify.go delete mode 100644 internal/vm/runtime/network_coordinator.go delete mode 100644 internal/vm/runtime/network_recovery_test.go delete mode 100644 internal/vm/runtime/network_resize.go delete mode 100644 internal/vm/runtime/operations.go delete mode 100644 internal/vm/runtime/operations_test.go delete mode 100644 internal/vm/runtime/pci.go delete mode 100644 internal/vm/runtime/performance.go delete mode 100644 internal/vm/runtime/performance_test.go delete mode 100644 internal/vm/runtime/reconcile.go delete mode 100644 internal/vm/runtime/references.go delete mode 100644 internal/vm/runtime/restore.go delete mode 100644 internal/vm/runtime/restore_mode.go delete mode 100644 internal/vm/runtime/restore_mode_test.go delete mode 100644 internal/vm/runtime/restore_test.go delete mode 100644 internal/vm/runtime/runtime.go delete mode 100644 internal/vm/runtime/runtime_test.go delete mode 100644 internal/vm/runtime/state.go delete mode 100644 internal/vm/runtime/state_open.go delete mode 100644 internal/vm/runtime/state_test.go delete mode 100644 internal/vm/runtime/watch.go delete mode 100644 internal/vm/runtime/watch_test.go delete mode 100644 internal/vm/storage.go delete mode 100644 internal/vm/storage_test.go delete mode 100644 internal/vm/store.go delete mode 100644 internal/vm/store_test.go delete mode 100644 internal/vm/views.go delete mode 100644 internal/vm/views_test.go delete mode 100644 oci-images/ubuntu/24.04/Dockerfile delete mode 100755 oci-images/ubuntu/agent-stub.sh delete mode 100644 oci-images/ubuntu/kumabox-agent.openrc delete mode 100644 oci-images/ubuntu/kumabox-agent.service delete mode 100755 oci-images/ubuntu/network.sh delete mode 100755 oci-images/ubuntu/overlay.sh delete mode 100755 scripts/check.sh delete mode 100755 scripts/install.sh delete mode 100755 test/e2e/e2e.sh delete mode 100755 test/release/check.sh delete mode 100755 test/release/install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54afd19..3adc0a5 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,7 @@ concurrency: cancel-in-progress: true jobs: - lint: - name: Lint + verify: runs-on: ubuntu-latest steps: - name: Check out source @@ -26,123 +23,15 @@ 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: 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: 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: Verify + run: make verify - 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..7c8b012 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .DS_Store bin/ dist/ +.rewrite-backup/ +.claude/ coverage.out # Local design and implementation notes must never be committed. docs/ diff --git a/Makefile b/Makefile index 175f2f2..59cd3cb 100644 --- a/Makefile +++ b/Makefile @@ -1,35 +1,17 @@ -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) +GO ?= go -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) +.PHONY: build fmt-check test vet verify -.PHONY: build build-agent build-agent-linux-amd64 build-agent-linux-arm64 test test-e2e clean +build: + $(GO) build ./... -build: build-agent - mkdir -p $(BIN_DIR) - go build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) ./cmd/kumabox - -build-agent: - $(MAKE) build-agent-linux-amd64 build-agent-linux-arm64 - -build-agent-linux-amd64: - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o $(GUEST_AGENT_BINARY_AMD64) ./cmd/agent - -build-agent-linux-arm64: - GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o $(GUEST_AGENT_BINARY_ARM64) ./cmd/agent +fmt-check: + @test -z "$$(gofmt -l .)" || (gofmt -l . && exit 1) test: - go test ./... + $(GO) test ./... -test-e2e: - test/e2e/e2e.sh $(E2E_ARGS) +vet: + $(GO) vet ./... -clean: - rm -rf $(BIN_DIR) +verify: fmt-check vet test build diff --git a/README.md b/README.md index 1bd64ab..6eb659e 100644 --- a/README.md +++ b/README.md @@ -4,277 +4,37 @@ # 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. +KumaBox is being rebuilt as a modular infrastructure project for high-density +agent sandboxes. -[![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) +The rewrite follows Cocoon's core lifecycle semantics—such as create, snapshot, +clone, and restore—while keeping KumaBox's architecture and implementation +independent. The current branch is not a usable release until those capabilities +are reintroduced through the approved P12 milestones. -> [!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. +## Rewrite rules -## Highlights +- Infrastructure capability modules are the primary architectural boundary. +- Lifecycle behavior is specified and compared with Cocoon before implementation. +- Each major capability requires an approved logic, design, abstraction, and + directory proposal before code is written. +- Tests and architecture checks are delivered with each capability. -- **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. +Local design and progress records live under `docs/` and are intentionally not +tracked by Git. -## 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. +## Development ```bash -# Install the latest release and verify its checksum. -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. -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 - -# Interact with the guest. Run console in a separate terminal when needed. -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. -sudo kumabox snapshot create my-vm --name base --type running -sudo kumabox clone base --name fresh -sudo kumabox exec fresh -- hostname - -# Clean up. -sudo kumabox delete fresh my-vm --force -sudo kumabox snapshot rm base -sudo kumabox image rm ubuntu -sudo kumabox gc +make verify ``` -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] -``` - -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. - -## Requirements - -| 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 | - -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. - -## 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` | -| 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` | - -Use `kumabox --help` as the authoritative CLI reference. Inspection -and automation-oriented commands support structured JSON output where shown by -their help. - -## Metadata Backends - -JSON metadata is used by default: - -```bash -sudo kumabox ps -``` - -Select SQLite consistently for every command that accesses the same state: - -```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 -``` - -Do not switch backends for an existing resource set without using the metadata -conversion workflow exposed by `kumabox metadata --help`. - -## Build and Test - -```bash -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: - -```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 -``` - -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. +## Recovery -Report reproducible bugs and security concerns through the repository issue -tracker. Do not include secrets, private images, or production snapshots in a -public report. +The pre-rewrite source is recoverable from the protected Git tag +`pre-p12-rewrite-20260909`. A verified local source archive is also stored under +`.rewrite-backup/` in the rewrite workspace. ## License -KumaBox is available under the [MIT License](LICENSE). +[MIT](LICENSE) 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/main.go b/cmd/kumabox/main.go deleted file mode 100644 index 245792c..0000000 --- a/cmd/kumabox/main.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - - "github.com/kumabox/kumabox/internal/cli" -) - -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)) - } -} diff --git a/go.mod b/go.mod index 5033902..76b7179 100644 --- a/go.mod +++ b/go.mod @@ -1,42 +1,3 @@ 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/vishvananda/netlink v1.3.1 - github.com/vishvananda/netns v0.0.5 -) - -require golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect - -require ( - github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect - github.com/docker/cli v27.5.0+incompatible // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/docker/docker-credential-helpers v0.8.2 // indirect - github.com/dustin/go-humanize v1.0.1 // 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/mitchellh/go-homedir v1.1.0 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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 - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.8.2 // indirect - modernc.org/sqlite v1.35.0 -) diff --git a/go.sum b/go.sum deleted file mode 100644 index ce56ada..0000000 --- a/go.sum +++ /dev/null @@ -1,121 +0,0 @@ -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/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/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/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/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/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/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/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/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/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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -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/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/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= -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= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -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/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/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/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= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 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/archtest/dependencies_test.go b/internal/archtest/dependencies_test.go new file mode 100644 index 0000000..642b0ba --- /dev/null +++ b/internal/archtest/dependencies_test.go @@ -0,0 +1,125 @@ +package archtest + +import ( + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" +) + +const modulePath = "github.com/kumabox/kumabox" + +var corePackages = []string{ + modulePath + "/internal/tenant", + modulePath + "/internal/content", + modulePath + "/internal/sandbox", + modulePath + "/internal/operation", +} + +var forbiddenImportPrefixes = []string{ + modulePath + "/internal/legacy", + modulePath + "/internal/meta", + modulePath + "/internal/network/cni", + modulePath + "/internal/state", + modulePath + "/internal/store", + modulePath + "/internal/vm/runtime", + modulePath + "/internal/vmm", +} + +func TestCorePackagesDoNotImportAdaptersOrLegacyCore(t *testing.T) { + t.Parallel() + + packages := listPackages(t, corePackages) + for _, pkg := range packages { + for _, imported := range pkg.Imports { + for _, forbidden := range forbiddenImportPrefixes { + if imported == forbidden || strings.HasPrefix(imported, forbidden+"/") { + t.Errorf("%s imports forbidden package %s", pkg.ImportPath, imported) + } + } + } + } +} + +func TestGlobalLayerDirectoriesDoNotExist(t *testing.T) { + t.Parallel() + + root := repositoryRoot(t) + for _, name := range []string{"app", "domain", "service"} { + path := filepath.Join(root, "internal", name) + _, err := os.Stat(path) + if err == nil { + t.Errorf("global layer directory must not exist: internal/%s", name) + continue + } + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("inspect internal/%s: %v", name, err) + } + } +} + +type listedPackage struct { + ImportPath string + Imports []string +} + +func listPackages(t *testing.T, importPaths []string) []listedPackage { + t.Helper() + + args := append([]string{"list", "-json"}, importPaths...) + command := exec.Command("go", args...) + command.Dir = repositoryRoot(t) + command.Env = append(os.Environ(), "GOCACHE="+t.TempDir()) + + output, err := command.Output() + if err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + t.Fatalf("go list failed: %v\n%s", err, exitError.Stderr) + } + t.Fatalf("go list failed: %v", err) + } + + decoder := json.NewDecoder(strings.NewReader(string(output))) + packages := make([]listedPackage, 0, len(importPaths)) + for { + var pkg listedPackage + err = decoder.Decode(&pkg) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("decode go list output: %v", err) + } + packages = append(packages, pkg) + } + + if len(packages) != len(importPaths) { + t.Fatalf("go list returned %d packages, want %d", len(packages), len(importPaths)) + } + for _, importPath := range importPaths { + if !slices.ContainsFunc(packages, func(pkg listedPackage) bool { + return pkg.ImportPath == importPath + }) { + t.Errorf("go list did not return %s", importPath) + } + } + + return packages +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve architecture test location") + } + return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) +} diff --git a/internal/archtest/doc.go b/internal/archtest/doc.go new file mode 100644 index 0000000..1355a1c --- /dev/null +++ b/internal/archtest/doc.go @@ -0,0 +1,3 @@ +// Package archtest verifies the dependency rules of KumaBox infrastructure +// modules. +package archtest 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/content/doc.go b/internal/content/doc.go new file mode 100644 index 0000000..a9d4576 --- /dev/null +++ b/internal/content/doc.go @@ -0,0 +1,4 @@ +// Package content owns canonical content identity and immutable local content +// semantics. OCI references and remote artifact transport belong to other +// modules. +package content 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/doc.go b/internal/operation/doc.go new file mode 100644 index 0000000..6097d27 --- /dev/null +++ b/internal/operation/doc.go @@ -0,0 +1,4 @@ +// Package operation owns durable-operation identity and progress vocabulary. +// The order of lifecycle steps belongs to the sandbox operation performing +// them. +package operation 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/sandbox/doc.go b/internal/sandbox/doc.go new file mode 100644 index 0000000..0735a07 --- /dev/null +++ b/internal/sandbox/doc.go @@ -0,0 +1,4 @@ +// Package sandbox owns sandbox identity, lifecycle state, and host-side +// lifecycle orchestration. Concrete VMM, network, storage, and persistence +// adapters remain outside this package. +package sandbox 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/tenant/doc.go b/internal/tenant/doc.go new file mode 100644 index 0000000..74dabe1 --- /dev/null +++ b/internal/tenant/doc.go @@ -0,0 +1,3 @@ +// Package tenant owns tenant identity and authorization-scope primitives. +// Resource admission and quota policy belong to the resource module. +package tenant 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/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/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 deleted file mode 100644 index c5cceab..0000000 --- a/oci-images/ubuntu/kumabox-agent.service +++ /dev/null @@ -1,20 +0,0 @@ -[Unit] -Description=KumaBox agent (vsock command exec) -Documentation=https://github.com/kumabox/kumabox - -[Service] -Type=simple -User=root -Group=root -ExecStartPre=-/sbin/modprobe vhost_vsock -ExecStart=/usr/local/bin/kumabox-agent serve -Environment=AGENT_LOG_LEVEL=info -Restart=always -RestartSec=2s -StandardOutput=journal+console -StandardError=journal+console -SyslogIdentifier=kumabox-agent -LimitNOFILE=65536 - -[Install] -WantedBy=multi-user.target diff --git a/oci-images/ubuntu/network.sh b/oci-images/ubuntu/network.sh deleted file mode 100755 index 554bcd8..0000000 --- a/oci-images/ubuntu/network.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/sh - -PREREQ="" -prereqs() { echo "$PREREQ"; } -case "$1" in prereqs) prereqs; exit 0 ;; esac - -. /scripts/functions - -[ -n "${rootmnt:-}" ] || exit 0 - -for arg in $(cat /proc/cmdline); do - case "$arg" in - kumabox.hostname=*) echo "${arg#kumabox.hostname=}" >"${rootmnt}/etc/hostname" ;; - esac -done - -dns_servers="" -has_static=false - -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 - - 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 - - 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 [ "$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 - -[ -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" -done diff --git a/oci-images/ubuntu/overlay.sh b/oci-images/ubuntu/overlay.sh deleted file mode 100755 index 30da1e9..0000000 --- a/oci-images/ubuntu/overlay.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/bin/sh - -. /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 -} - -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" -} 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/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" From 5c5c275b1962c548a8772b7e9067ccccdd44ec20 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 9 Sep 2026 14:50:08 +0800 Subject: [PATCH 03/45] feat: add tenant and content identities --- internal/content/digest.go | 90 +++++++++++++++++++++++++++++++++ internal/content/digest_test.go | 79 +++++++++++++++++++++++++++++ internal/tenant/id.go | 79 +++++++++++++++++++++++++++++ internal/tenant/id_test.go | 78 ++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 internal/content/digest.go create mode 100644 internal/content/digest_test.go create mode 100644 internal/tenant/id.go create mode 100644 internal/tenant/id_test.go diff --git a/internal/content/digest.go b/internal/content/digest.go new file mode 100644 index 0000000..469d470 --- /dev/null +++ b/internal/content/digest.go @@ -0,0 +1,90 @@ +package content + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +const ( + sha256Algorithm = "sha256" + sha256Prefix = sha256Algorithm + ":" + sha256HexLength = sha256.Size * 2 +) + +// ErrInvalidDigest identifies a malformed, unsupported, or zero digest. +var ErrInvalidDigest = errors.New("invalid content digest") + +// Digest is the canonical identity of immutable content. A Digest currently +// supports SHA-256 only. Its zero value is invalid. +type Digest struct { + value string +} + +// SHA256 returns the digest of data. +func SHA256(data []byte) Digest { + sum := sha256.Sum256(data) + return Digest{value: sha256Prefix + hex.EncodeToString(sum[:])} +} + +// ParseDigest parses a canonical SHA-256 digest. +func ParseDigest(value string) (Digest, error) { + if err := validateDigest(value); err != nil { + return Digest{}, err + } + return Digest{value: value}, nil +} + +// String returns the canonical digest. It returns an empty string for the zero +// value. +func (digest Digest) String() string { + return digest.value +} + +// IsZero reports whether digest is the zero value. +func (digest Digest) IsZero() bool { + return digest.value == "" +} + +// Algorithm returns the digest algorithm, or an empty string for the zero +// value. +func (digest Digest) Algorithm() string { + if digest.IsZero() { + return "" + } + return sha256Algorithm +} + +// Encoded returns the lowercase hexadecimal digest, or an empty string for the +// zero value. +func (digest Digest) Encoded() string { + if digest.IsZero() { + return "" + } + return digest.value[len(sha256Prefix):] +} + +// Validate checks that digest is canonical and supported. +func (digest Digest) Validate() error { + return validateDigest(digest.value) +} + +func validateDigest(value string) error { + if !strings.HasPrefix(value, sha256Prefix) { + return fmt.Errorf("%w: only %s is supported", ErrInvalidDigest, sha256Algorithm) + } + + encoded := value[len(sha256Prefix):] + if len(encoded) != sha256HexLength { + return fmt.Errorf("%w: %s must contain %d hexadecimal characters", ErrInvalidDigest, sha256Algorithm, sha256HexLength) + } + if encoded != strings.ToLower(encoded) { + return fmt.Errorf("%w: hexadecimal encoding must be lowercase", ErrInvalidDigest) + } + if _, err := hex.DecodeString(encoded); err != nil { + return fmt.Errorf("%w: decode %s: %v", ErrInvalidDigest, sha256Algorithm, err) + } + return nil +} diff --git a/internal/content/digest_test.go b/internal/content/digest_test.go new file mode 100644 index 0000000..ff2b64f --- /dev/null +++ b/internal/content/digest_test.go @@ -0,0 +1,79 @@ +package content + +import ( + "errors" + "strings" + "testing" +) + +func TestSHA256(t *testing.T) { + t.Parallel() + + digest := SHA256([]byte("abc")) + const want = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + if got := digest.String(); got != want { + t.Fatalf("SHA256(abc) = %q, want %q", got, want) + } + if got := digest.Algorithm(); got != sha256Algorithm { + t.Fatalf("Algorithm() = %q, want %q", got, sha256Algorithm) + } + if got := digest.Encoded(); got != strings.TrimPrefix(want, sha256Prefix) { + t.Fatalf("Encoded() = %q", got) + } + if err := digest.Validate(); err != nil { + t.Fatalf("generated digest is invalid: %v", err) + } +} + +func TestParseDigest(t *testing.T) { + t.Parallel() + + valid := "sha256:" + strings.Repeat("01", sha256HexLength/2) + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "canonical", value: valid}, + {name: "zero", value: "", wantErr: true}, + {name: "unsupported", value: "sha512:" + strings.Repeat("0", 128), wantErr: true}, + {name: "short", value: "sha256:00", wantErr: true}, + {name: "uppercase", value: "sha256:" + strings.Repeat("A", sha256HexLength), wantErr: true}, + {name: "not hexadecimal", value: "sha256:" + strings.Repeat("z", sha256HexLength), wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + digest, err := ParseDigest(test.value) + if test.wantErr { + if !errors.Is(err, ErrInvalidDigest) { + t.Fatalf("ParseDigest(%q) error = %v, want ErrInvalidDigest", test.value, err) + } + return + } + if err != nil { + t.Fatalf("ParseDigest(%q) error = %v", test.value, err) + } + if got := digest.String(); got != test.value { + t.Fatalf("ParseDigest(%q).String() = %q", test.value, got) + } + }) + } +} + +func TestZeroDigest(t *testing.T) { + t.Parallel() + + var digest Digest + if !digest.IsZero() { + t.Fatal("zero Digest is not reported as zero") + } + if digest.Algorithm() != "" || digest.Encoded() != "" || digest.String() != "" { + t.Fatal("zero Digest exposes non-empty data") + } + if !errors.Is(digest.Validate(), ErrInvalidDigest) { + t.Fatalf("zero Digest validation error = %v, want ErrInvalidDigest", digest.Validate()) + } +} diff --git a/internal/tenant/id.go b/internal/tenant/id.go new file mode 100644 index 0000000..2029dc9 --- /dev/null +++ b/internal/tenant/id.go @@ -0,0 +1,79 @@ +package tenant + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +const ( + idPrefix = "tenant_" + generatedBytes = 16 + maxIDSuffixLen = 63 +) + +// ErrInvalidID identifies a malformed or zero tenant ID. +var ErrInvalidID = errors.New("invalid tenant ID") + +// ID is the stable identity of a tenant and its authorization scope. +// Its zero value is invalid. +type ID struct { + value string +} + +// NewID creates a tenant ID using cryptographically secure randomness. +func NewID() (ID, error) { + random := make([]byte, generatedBytes) + if _, err := rand.Read(random); err != nil { + return ID{}, fmt.Errorf("generate tenant ID: %w", err) + } + return ID{value: idPrefix + hex.EncodeToString(random)}, nil +} + +// ParseID parses a canonical tenant ID. +func ParseID(value string) (ID, error) { + if err := validateID(value); err != nil { + return ID{}, err + } + return ID{value: value}, nil +} + +// String returns the canonical tenant ID. It returns an empty string for the +// zero value. +func (id ID) String() string { + return id.value +} + +// IsZero reports whether id is the zero value. +func (id ID) IsZero() bool { + return id.value == "" +} + +// Validate checks that id contains a canonical, non-zero tenant ID. +func (id ID) Validate() error { + return validateID(id.value) +} + +func validateID(value string) error { + if !strings.HasPrefix(value, idPrefix) || len(value) <= len(idPrefix) || len(value) > len(idPrefix)+maxIDSuffixLen { + return fmt.Errorf("%w: must use %q followed by 1 to %d characters", ErrInvalidID, idPrefix, maxIDSuffixLen) + } + + suffix := value[len(idPrefix):] + for index, character := range []byte(suffix) { + if isLowerAlphaNumeric(character) { + continue + } + if character == '-' && index > 0 && index < len(suffix)-1 { + continue + } + return fmt.Errorf("%w: %q is not canonical", ErrInvalidID, value) + } + return nil +} + +func isLowerAlphaNumeric(character byte) bool { + return character >= 'a' && character <= 'z' || character >= '0' && character <= '9' +} diff --git a/internal/tenant/id_test.go b/internal/tenant/id_test.go new file mode 100644 index 0000000..9e2825f --- /dev/null +++ b/internal/tenant/id_test.go @@ -0,0 +1,78 @@ +package tenant + +import ( + "errors" + "strings" + "testing" +) + +func TestNewID(t *testing.T) { + t.Parallel() + + id, err := NewID() + if err != nil { + t.Fatalf("NewID() error = %v", err) + } + if err := id.Validate(); err != nil { + t.Fatalf("generated ID is invalid: %v", err) + } + if got, want := len(id.String()), len(idPrefix)+generatedBytes*2; got != want { + t.Fatalf("len(NewID()) = %d, want %d", got, want) + } +} + +func TestParseID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "generated shape", value: "tenant_0123456789abcdef0123456789abcdef"}, + {name: "external name", value: "tenant_control-plane-1"}, + {name: "zero", value: "", wantErr: true}, + {name: "missing prefix", value: "control-plane", wantErr: true}, + {name: "empty suffix", value: "tenant_", wantErr: true}, + {name: "uppercase", value: "tenant_Control", wantErr: true}, + {name: "leading hyphen", value: "tenant_-control", wantErr: true}, + {name: "trailing hyphen", value: "tenant_control-", wantErr: true}, + {name: "separator", value: "tenant_control/plane", wantErr: true}, + {name: "too long", value: "tenant_" + strings.Repeat("a", maxIDSuffixLen+1), wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + id, err := ParseID(test.value) + if test.wantErr { + if !errors.Is(err, ErrInvalidID) { + t.Fatalf("ParseID(%q) error = %v, want ErrInvalidID", test.value, err) + } + return + } + if err != nil { + t.Fatalf("ParseID(%q) error = %v", test.value, err) + } + if got := id.String(); got != test.value { + t.Fatalf("ParseID(%q).String() = %q", test.value, got) + } + if err := id.Validate(); err != nil { + t.Fatalf("parsed ID is invalid: %v", err) + } + }) + } +} + +func TestZeroID(t *testing.T) { + t.Parallel() + + var id ID + if !id.IsZero() { + t.Fatal("zero ID is not reported as zero") + } + if !errors.Is(id.Validate(), ErrInvalidID) { + t.Fatalf("zero ID validation error = %v, want ErrInvalidID", id.Validate()) + } +} From e1c175b561bdb70d3fe67223a020def631f3ad12 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 9 Sep 2026 14:53:40 +0800 Subject: [PATCH 04/45] feat: define sandbox identity and lifecycle state --- internal/sandbox/id.go | 71 +++++++++++++++ internal/sandbox/id_test.go | 72 +++++++++++++++ internal/sandbox/name.go | 60 +++++++++++++ internal/sandbox/name_test.go | 60 +++++++++++++ internal/sandbox/state.go | 156 +++++++++++++++++++++++++++++++++ internal/sandbox/state_test.go | 123 ++++++++++++++++++++++++++ 6 files changed, 542 insertions(+) create mode 100644 internal/sandbox/id.go create mode 100644 internal/sandbox/id_test.go create mode 100644 internal/sandbox/name.go create mode 100644 internal/sandbox/name_test.go create mode 100644 internal/sandbox/state.go create mode 100644 internal/sandbox/state_test.go diff --git a/internal/sandbox/id.go b/internal/sandbox/id.go new file mode 100644 index 0000000..c5b65f2 --- /dev/null +++ b/internal/sandbox/id.go @@ -0,0 +1,71 @@ +package sandbox + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +const ( + idPrefix = "kb_" + idRandomBytes = 16 + idEncodedLength = idRandomBytes * 2 +) + +// ErrInvalidID identifies a malformed or zero sandbox ID. +var ErrInvalidID = errors.New("invalid sandbox ID") + +// ID is the immutable identity of a sandbox. Its zero value is invalid. +type ID struct { + value string +} + +// NewID creates a sandbox ID using cryptographically secure randomness. +func NewID() (ID, error) { + random := make([]byte, idRandomBytes) + if _, err := rand.Read(random); err != nil { + return ID{}, fmt.Errorf("generate sandbox ID: %w", err) + } + return ID{value: idPrefix + hex.EncodeToString(random)}, nil +} + +// ParseID parses a canonical sandbox ID. +func ParseID(value string) (ID, error) { + if err := validateID(value); err != nil { + return ID{}, err + } + return ID{value: value}, nil +} + +// String returns the canonical sandbox ID. It returns an empty string for the +// zero value. +func (id ID) String() string { + return id.value +} + +// IsZero reports whether id is the zero value. +func (id ID) IsZero() bool { + return id.value == "" +} + +// Validate checks that id contains a canonical, non-zero sandbox ID. +func (id ID) Validate() error { + return validateID(id.value) +} + +func validateID(value string) error { + if !strings.HasPrefix(value, idPrefix) || len(value) != len(idPrefix)+idEncodedLength { + return fmt.Errorf("%w: must use %q followed by %d lowercase hexadecimal characters", ErrInvalidID, idPrefix, idEncodedLength) + } + + encoded := value[len(idPrefix):] + if encoded != strings.ToLower(encoded) { + return fmt.Errorf("%w: hexadecimal encoding must be lowercase", ErrInvalidID) + } + if _, err := hex.DecodeString(encoded); err != nil { + return fmt.Errorf("%w: decode identity: %v", ErrInvalidID, err) + } + return nil +} diff --git a/internal/sandbox/id_test.go b/internal/sandbox/id_test.go new file mode 100644 index 0000000..ef6958c --- /dev/null +++ b/internal/sandbox/id_test.go @@ -0,0 +1,72 @@ +package sandbox + +import ( + "errors" + "strings" + "testing" +) + +func TestNewID(t *testing.T) { + t.Parallel() + + id, err := NewID() + if err != nil { + t.Fatalf("NewID() error = %v", err) + } + if err := id.Validate(); err != nil { + t.Fatalf("generated ID is invalid: %v", err) + } + if got, want := len(id.String()), len(idPrefix)+idEncodedLength; got != want { + t.Fatalf("len(NewID()) = %d, want %d", got, want) + } +} + +func TestParseID(t *testing.T) { + t.Parallel() + + valid := idPrefix + strings.Repeat("01", idRandomBytes) + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "canonical", value: valid}, + {name: "zero", value: "", wantErr: true}, + {name: "wrong prefix", value: "vm_" + strings.Repeat("0", idEncodedLength), wantErr: true}, + {name: "short", value: idPrefix + strings.Repeat("0", idEncodedLength-1), wantErr: true}, + {name: "uppercase", value: idPrefix + strings.Repeat("A", idEncodedLength), wantErr: true}, + {name: "not hexadecimal", value: idPrefix + strings.Repeat("z", idEncodedLength), wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + id, err := ParseID(test.value) + if test.wantErr { + if !errors.Is(err, ErrInvalidID) { + t.Fatalf("ParseID(%q) error = %v, want ErrInvalidID", test.value, err) + } + return + } + if err != nil { + t.Fatalf("ParseID(%q) error = %v", test.value, err) + } + if got := id.String(); got != test.value { + t.Fatalf("ParseID(%q).String() = %q", test.value, got) + } + }) + } +} + +func TestZeroID(t *testing.T) { + t.Parallel() + + var id ID + if !id.IsZero() { + t.Fatal("zero ID is not reported as zero") + } + if !errors.Is(id.Validate(), ErrInvalidID) { + t.Fatalf("zero ID validation error = %v, want ErrInvalidID", id.Validate()) + } +} diff --git a/internal/sandbox/name.go b/internal/sandbox/name.go new file mode 100644 index 0000000..a7cf7d2 --- /dev/null +++ b/internal/sandbox/name.go @@ -0,0 +1,60 @@ +package sandbox + +import ( + "errors" + "fmt" +) + +const maxNameLength = 63 + +// ErrInvalidName identifies a malformed or zero sandbox name. +var ErrInvalidName = errors.New("invalid sandbox name") + +// Name is a canonical, human-selected sandbox name. Its zero value is invalid. +type Name struct { + value string +} + +// ParseName parses a lowercase DNS-label-shaped sandbox name. +func ParseName(value string) (Name, error) { + if err := validateName(value); err != nil { + return Name{}, err + } + return Name{value: value}, nil +} + +// String returns the canonical name. It returns an empty string for the zero +// value. +func (name Name) String() string { + return name.value +} + +// IsZero reports whether name is the zero value. +func (name Name) IsZero() bool { + return name.value == "" +} + +// Validate checks that name is canonical and non-zero. +func (name Name) Validate() error { + return validateName(name.value) +} + +func validateName(value string) error { + if len(value) == 0 || len(value) > maxNameLength { + return fmt.Errorf("%w: length must be between 1 and %d bytes", ErrInvalidName, maxNameLength) + } + for index, character := range []byte(value) { + if isLowerAlphaNumeric(character) { + continue + } + if character == '-' && index > 0 && index < len(value)-1 { + continue + } + return fmt.Errorf("%w: %q is not a lowercase DNS label", ErrInvalidName, value) + } + return nil +} + +func isLowerAlphaNumeric(character byte) bool { + return character >= 'a' && character <= 'z' || character >= '0' && character <= '9' +} diff --git a/internal/sandbox/name_test.go b/internal/sandbox/name_test.go new file mode 100644 index 0000000..f4c8bf7 --- /dev/null +++ b/internal/sandbox/name_test.go @@ -0,0 +1,60 @@ +package sandbox + +import ( + "errors" + "strings" + "testing" +) + +func TestParseName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "simple", value: "agent"}, + {name: "numbered", value: "agent-001"}, + {name: "single character", value: "a"}, + {name: "zero", value: "", wantErr: true}, + {name: "uppercase", value: "Agent", wantErr: true}, + {name: "leading hyphen", value: "-agent", wantErr: true}, + {name: "trailing hyphen", value: "agent-", wantErr: true}, + {name: "underscore", value: "agent_1", wantErr: true}, + {name: "dot", value: "agent.local", wantErr: true}, + {name: "too long", value: strings.Repeat("a", maxNameLength+1), wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + name, err := ParseName(test.value) + if test.wantErr { + if !errors.Is(err, ErrInvalidName) { + t.Fatalf("ParseName(%q) error = %v, want ErrInvalidName", test.value, err) + } + return + } + if err != nil { + t.Fatalf("ParseName(%q) error = %v", test.value, err) + } + if got := name.String(); got != test.value { + t.Fatalf("ParseName(%q).String() = %q", test.value, got) + } + }) + } +} + +func TestZeroName(t *testing.T) { + t.Parallel() + + var name Name + if !name.IsZero() { + t.Fatal("zero Name is not reported as zero") + } + if !errors.Is(name.Validate(), ErrInvalidName) { + t.Fatalf("zero Name validation error = %v, want ErrInvalidName", name.Validate()) + } +} diff --git a/internal/sandbox/state.go b/internal/sandbox/state.go new file mode 100644 index 0000000..2549b32 --- /dev/null +++ b/internal/sandbox/state.go @@ -0,0 +1,156 @@ +package sandbox + +import ( + "errors" + "fmt" +) + +// State is the durable lifecycle state of a sandbox. +type State uint8 + +const ( + StateUnknown State = iota + StateCreating + StateStopped + StateStarting + StateRunning + StatePaused + StateStopping + StateError + StateDeleting +) + +// ErrInvalidState identifies an unknown numeric or textual sandbox state. +var ErrInvalidState = errors.New("invalid sandbox state") + +// ErrInvalidTransition identifies a transition not present in the sandbox +// lifecycle state machine. +var ErrInvalidTransition = errors.New("invalid sandbox state transition") + +// ParseState parses a canonical sandbox state. +func ParseState(value string) (State, error) { + switch value { + case "unknown": + return StateUnknown, nil + case "creating": + return StateCreating, nil + case "stopped": + return StateStopped, nil + case "starting": + return StateStarting, nil + case "running": + return StateRunning, nil + case "paused": + return StatePaused, nil + case "stopping": + return StateStopping, nil + case "error": + return StateError, nil + case "deleting": + return StateDeleting, nil + default: + return StateUnknown, fmt.Errorf("%w: %q", ErrInvalidState, value) + } +} + +// String returns the canonical state name. +func (state State) String() string { + switch state { + case StateUnknown: + return "unknown" + case StateCreating: + return "creating" + case StateStopped: + return "stopped" + case StateStarting: + return "starting" + case StateRunning: + return "running" + case StatePaused: + return "paused" + case StateStopping: + return "stopping" + case StateError: + return "error" + case StateDeleting: + return "deleting" + default: + return fmt.Sprintf("State(%d)", uint8(state)) + } +} + +// IsUnknown reports whether state is the zero-value state. +func (state State) IsUnknown() bool { + return state == StateUnknown +} + +// Validate checks that state is a defined lifecycle state. StateUnknown is a +// defined value; operation-specific validation decides where it is permitted. +func (state State) Validate() error { + if state <= StateDeleting { + return nil + } + return fmt.Errorf("%w: %d", ErrInvalidState, state) +} + +// CanTransition reports whether the lifecycle state machine permits a direct +// transition from source to target. Successful deletion removes the durable +// record, so it is not represented by an additional state. +func CanTransition(source, target State) bool { + if source.Validate() != nil || target.Validate() != nil { + return false + } + + switch source { + case StateUnknown: + return target == StateCreating + case StateCreating: + return target == StateStopped || target == StateStarting || target == StateError || target == StateDeleting + case StateStopped: + return target == StateStarting || target == StateDeleting || target == StateError + case StateStarting: + return target == StateRunning || target == StateError || target == StateStopping + case StateRunning: + return target == StatePaused || target == StateStopping || target == StateError + case StatePaused: + return target == StateRunning || target == StateStopping || target == StateError + case StateStopping: + return target == StateStopped || target == StateError + case StateError: + return target == StateStarting || target == StateStopping || target == StateDeleting + case StateDeleting: + return target == StateError + default: + return false + } +} + +// ValidateTransition returns a TransitionError when a direct state transition +// is not permitted. +func ValidateTransition(source, target State) error { + if err := source.Validate(); err != nil { + return fmt.Errorf("validate source state: %w", err) + } + if err := target.Validate(); err != nil { + return fmt.Errorf("validate target state: %w", err) + } + if !CanTransition(source, target) { + return &TransitionError{From: source, To: target} + } + return nil +} + +// TransitionError describes a rejected direct lifecycle transition. +type TransitionError struct { + From State + To State +} + +func (err *TransitionError) Error() string { + return fmt.Sprintf("%s: %s -> %s", ErrInvalidTransition, err.From, err.To) +} + +// Unwrap supports errors.Is with ErrInvalidTransition. +func (err *TransitionError) Unwrap() error { + return ErrInvalidTransition +} diff --git a/internal/sandbox/state_test.go b/internal/sandbox/state_test.go new file mode 100644 index 0000000..d0da6fd --- /dev/null +++ b/internal/sandbox/state_test.go @@ -0,0 +1,123 @@ +package sandbox + +import ( + "errors" + "testing" +) + +func TestStateRoundTrip(t *testing.T) { + t.Parallel() + + states := []State{ + StateUnknown, + StateCreating, + StateStopped, + StateStarting, + StateRunning, + StatePaused, + StateStopping, + StateError, + StateDeleting, + } + for _, state := range states { + state := state + t.Run(state.String(), func(t *testing.T) { + t.Parallel() + + if err := state.Validate(); err != nil { + t.Fatalf("State(%d).Validate() error = %v", state, err) + } + parsed, err := ParseState(state.String()) + if err != nil { + t.Fatalf("ParseState(%q) error = %v", state.String(), err) + } + if parsed != state { + t.Fatalf("ParseState(%q) = %v, want %v", state.String(), parsed, state) + } + }) + } +} + +func TestStateUnknownIsZeroValue(t *testing.T) { + t.Parallel() + + var state State + if state != StateUnknown || !state.IsUnknown() { + t.Fatalf("zero State = %v, want StateUnknown", state) + } +} + +func TestInvalidState(t *testing.T) { + t.Parallel() + + state := State(255) + if !errors.Is(state.Validate(), ErrInvalidState) { + t.Fatalf("State(255).Validate() error = %v, want ErrInvalidState", state.Validate()) + } + if _, err := ParseState("RUNNING"); !errors.Is(err, ErrInvalidState) { + t.Fatalf("ParseState(RUNNING) error = %v, want ErrInvalidState", err) + } + if CanTransition(state, StateError) { + t.Fatal("invalid source state can transition") + } + if err := ValidateTransition(StateRunning, state); !errors.Is(err, ErrInvalidState) { + t.Fatalf("ValidateTransition to invalid state error = %v, want ErrInvalidState", err) + } +} + +func TestStateTransitionMatrix(t *testing.T) { + t.Parallel() + + states := []State{ + StateUnknown, + StateCreating, + StateStopped, + StateStarting, + StateRunning, + StatePaused, + StateStopping, + StateError, + StateDeleting, + } + allowed := map[State]map[State]bool{ + StateUnknown: {StateCreating: true}, + StateCreating: {StateStopped: true, StateStarting: true, StateError: true, StateDeleting: true}, + StateStopped: {StateStarting: true, StateDeleting: true, StateError: true}, + StateStarting: {StateRunning: true, StateError: true, StateStopping: true}, + StateRunning: {StatePaused: true, StateStopping: true, StateError: true}, + StatePaused: {StateRunning: true, StateStopping: true, StateError: true}, + StateStopping: {StateStopped: true, StateError: true}, + StateError: {StateStarting: true, StateStopping: true, StateDeleting: true}, + StateDeleting: {StateError: true}, + } + + for _, source := range states { + for _, target := range states { + want := allowed[source][target] + if got := CanTransition(source, target); got != want { + t.Errorf("CanTransition(%s, %s) = %t, want %t", source, target, got, want) + } + + err := ValidateTransition(source, target) + if want && err != nil { + t.Errorf("ValidateTransition(%s, %s) error = %v", source, target, err) + } + if !want && !errors.Is(err, ErrInvalidTransition) { + t.Errorf("ValidateTransition(%s, %s) error = %v, want ErrInvalidTransition", source, target, err) + } + } + } +} + +func TestTransitionErrorSupportsErrorsAs(t *testing.T) { + t.Parallel() + + err := ValidateTransition(StateRunning, StateStopped) + var transitionError *TransitionError + if !errors.As(err, &transitionError) { + t.Fatalf("ValidateTransition error = %v, want *TransitionError", err) + } + if transitionError.From != StateRunning || transitionError.To != StateStopped { + t.Fatalf("TransitionError = %#v", transitionError) + } +} From f9455d7787d3928418f7aabc499486ac175038f0 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 9 Sep 2026 15:12:18 +0800 Subject: [PATCH 05/45] refactor: align sandbox primitives with lifecycle model --- internal/sandbox/id.go | 71 --------- internal/sandbox/identity.go | 139 ++++++++++++++++++ .../sandbox/{id_test.go => identity_test.go} | 55 +++++++ internal/sandbox/name.go | 60 -------- internal/sandbox/name_test.go | 60 -------- internal/sandbox/state.go | 105 +++++-------- internal/sandbox/state_test.go | 70 ++++----- 7 files changed, 263 insertions(+), 297 deletions(-) delete mode 100644 internal/sandbox/id.go create mode 100644 internal/sandbox/identity.go rename internal/sandbox/{id_test.go => identity_test.go} (53%) delete mode 100644 internal/sandbox/name.go delete mode 100644 internal/sandbox/name_test.go diff --git a/internal/sandbox/id.go b/internal/sandbox/id.go deleted file mode 100644 index c5b65f2..0000000 --- a/internal/sandbox/id.go +++ /dev/null @@ -1,71 +0,0 @@ -package sandbox - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "strings" -) - -const ( - idPrefix = "kb_" - idRandomBytes = 16 - idEncodedLength = idRandomBytes * 2 -) - -// ErrInvalidID identifies a malformed or zero sandbox ID. -var ErrInvalidID = errors.New("invalid sandbox ID") - -// ID is the immutable identity of a sandbox. Its zero value is invalid. -type ID struct { - value string -} - -// NewID creates a sandbox ID using cryptographically secure randomness. -func NewID() (ID, error) { - random := make([]byte, idRandomBytes) - if _, err := rand.Read(random); err != nil { - return ID{}, fmt.Errorf("generate sandbox ID: %w", err) - } - return ID{value: idPrefix + hex.EncodeToString(random)}, nil -} - -// ParseID parses a canonical sandbox ID. -func ParseID(value string) (ID, error) { - if err := validateID(value); err != nil { - return ID{}, err - } - return ID{value: value}, nil -} - -// String returns the canonical sandbox ID. It returns an empty string for the -// zero value. -func (id ID) String() string { - return id.value -} - -// IsZero reports whether id is the zero value. -func (id ID) IsZero() bool { - return id.value == "" -} - -// Validate checks that id contains a canonical, non-zero sandbox ID. -func (id ID) Validate() error { - return validateID(id.value) -} - -func validateID(value string) error { - if !strings.HasPrefix(value, idPrefix) || len(value) != len(idPrefix)+idEncodedLength { - return fmt.Errorf("%w: must use %q followed by %d lowercase hexadecimal characters", ErrInvalidID, idPrefix, idEncodedLength) - } - - encoded := value[len(idPrefix):] - if encoded != strings.ToLower(encoded) { - return fmt.Errorf("%w: hexadecimal encoding must be lowercase", ErrInvalidID) - } - if _, err := hex.DecodeString(encoded); err != nil { - return fmt.Errorf("%w: decode identity: %v", ErrInvalidID, err) - } - return nil -} diff --git a/internal/sandbox/identity.go b/internal/sandbox/identity.go new file mode 100644 index 0000000..f9dcb13 --- /dev/null +++ b/internal/sandbox/identity.go @@ -0,0 +1,139 @@ +package sandbox + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +const ( + idPrefix = "kb_" + idRandomBytes = 16 + idEncodedLength = idRandomBytes * 2 + maxNameLength = 63 +) + +var ( + // ErrInvalidID identifies a malformed or zero sandbox ID. + ErrInvalidID = errors.New("invalid sandbox ID") + // ErrInvalidName identifies a malformed or zero sandbox name. + ErrInvalidName = errors.New("invalid sandbox name") +) + +// ID is the immutable identity of a sandbox. Its zero value is invalid. +type ID struct { + value string +} + +// NewID creates a sandbox ID using cryptographically secure randomness. +func NewID() (ID, error) { + random := make([]byte, idRandomBytes) + if _, err := rand.Read(random); err != nil { + return ID{}, fmt.Errorf("generate sandbox ID: %w", err) + } + return ID{value: idPrefix + hex.EncodeToString(random)}, nil +} + +// ParseID parses a canonical sandbox ID. +func ParseID(value string) (ID, error) { + if err := validateID(value); err != nil { + return ID{}, err + } + return ID{value: value}, nil +} + +// String returns the canonical sandbox ID. It returns an empty string for the +// zero value. +func (id ID) String() string { + return id.value +} + +// IsZero reports whether id is the zero value. +func (id ID) IsZero() bool { + return id.value == "" +} + +// Validate checks that id contains a canonical, non-zero sandbox ID. +func (id ID) Validate() error { + return validateID(id.value) +} + +func validateID(value string) error { + validLength := len(value) == len(idPrefix)+idEncodedLength + if !strings.HasPrefix(value, idPrefix) || !validLength { + return fmt.Errorf( + "%w: must use %q followed by %d lowercase hexadecimal characters", + ErrInvalidID, + idPrefix, + idEncodedLength, + ) + } + + encoded := value[len(idPrefix):] + if encoded != strings.ToLower(encoded) { + return fmt.Errorf("%w: hexadecimal encoding must be lowercase", ErrInvalidID) + } + if _, err := hex.DecodeString(encoded); err != nil { + return fmt.Errorf("%w: decode identity: %v", ErrInvalidID, err) + } + return nil +} + +// Name is a human-selected sandbox name. It follows grammar: +// an ASCII letter or digit followed by at most 62 letters, digits, dots, +// underscores, or hyphens. Its zero value is invalid. +type Name struct { + value string +} + +// ParseName parses a sandbox name. +func ParseName(value string) (Name, error) { + if err := validateName(value); err != nil { + return Name{}, err + } + return Name{value: value}, nil +} + +// String returns the name. It returns an empty string for the zero value. +func (name Name) String() string { + return name.value +} + +// IsZero reports whether name is the zero value. +func (name Name) IsZero() bool { + return name.value == "" +} + +// Validate checks that name is non-zero and follows the sandbox name grammar. +func (name Name) Validate() error { + return validateName(name.value) +} + +func validateName(value string) error { + if len(value) == 0 || len(value) > maxNameLength { + return fmt.Errorf( + "%w: length must be between 1 and %d bytes", + ErrInvalidName, + maxNameLength, + ) + } + if !isASCIIAlphaNumeric(value[0]) { + return fmt.Errorf("%w: %q must start with an ASCII letter or digit", ErrInvalidName, value) + } + for _, character := range []byte(value[1:]) { + if isASCIIAlphaNumeric(character) || character == '.' || character == '_' || character == '-' { + continue + } + return fmt.Errorf("%w: %q contains an unsupported character", ErrInvalidName, value) + } + return nil +} + +func isASCIIAlphaNumeric(character byte) bool { + isLower := character >= 'a' && character <= 'z' + isUpper := character >= 'A' && character <= 'Z' + isDigit := character >= '0' && character <= '9' + return isLower || isUpper || isDigit +} diff --git a/internal/sandbox/id_test.go b/internal/sandbox/identity_test.go similarity index 53% rename from internal/sandbox/id_test.go rename to internal/sandbox/identity_test.go index ef6958c..8dc0a36 100644 --- a/internal/sandbox/id_test.go +++ b/internal/sandbox/identity_test.go @@ -70,3 +70,58 @@ func TestZeroID(t *testing.T) { t.Fatalf("zero ID validation error = %v, want ErrInvalidID", id.Validate()) } } + +func TestParseName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "simple", value: "agent"}, + {name: "mixed case", value: "Agent-001"}, + {name: "dot and underscore", value: "agent.pool_1"}, + {name: "trailing separator", value: "agent-"}, + {name: "single character", value: "a"}, + {name: "zero", value: "", wantErr: true}, + {name: "leading hyphen", value: "-agent", wantErr: true}, + {name: "leading dot", value: ".agent", wantErr: true}, + {name: "slash", value: "agent/1", wantErr: true}, + {name: "space", value: "agent 1", wantErr: true}, + {name: "non ASCII", value: "智能体", wantErr: true}, + {name: "too long", value: strings.Repeat("a", maxNameLength+1), wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + name, err := ParseName(test.value) + if test.wantErr { + if !errors.Is(err, ErrInvalidName) { + t.Fatalf("ParseName(%q) error = %v, want ErrInvalidName", test.value, err) + } + return + } + if err != nil { + t.Fatalf("ParseName(%q) error = %v", test.value, err) + } + if got := name.String(); got != test.value { + t.Fatalf("ParseName(%q).String() = %q", test.value, got) + } + }) + } +} + +func TestZeroName(t *testing.T) { + t.Parallel() + + var name Name + if !name.IsZero() { + t.Fatal("zero Name is not reported as zero") + } + if !errors.Is(name.Validate(), ErrInvalidName) { + t.Fatalf("zero Name validation error = %v, want ErrInvalidName", name.Validate()) + } +} diff --git a/internal/sandbox/name.go b/internal/sandbox/name.go deleted file mode 100644 index a7cf7d2..0000000 --- a/internal/sandbox/name.go +++ /dev/null @@ -1,60 +0,0 @@ -package sandbox - -import ( - "errors" - "fmt" -) - -const maxNameLength = 63 - -// ErrInvalidName identifies a malformed or zero sandbox name. -var ErrInvalidName = errors.New("invalid sandbox name") - -// Name is a canonical, human-selected sandbox name. Its zero value is invalid. -type Name struct { - value string -} - -// ParseName parses a lowercase DNS-label-shaped sandbox name. -func ParseName(value string) (Name, error) { - if err := validateName(value); err != nil { - return Name{}, err - } - return Name{value: value}, nil -} - -// String returns the canonical name. It returns an empty string for the zero -// value. -func (name Name) String() string { - return name.value -} - -// IsZero reports whether name is the zero value. -func (name Name) IsZero() bool { - return name.value == "" -} - -// Validate checks that name is canonical and non-zero. -func (name Name) Validate() error { - return validateName(name.value) -} - -func validateName(value string) error { - if len(value) == 0 || len(value) > maxNameLength { - return fmt.Errorf("%w: length must be between 1 and %d bytes", ErrInvalidName, maxNameLength) - } - for index, character := range []byte(value) { - if isLowerAlphaNumeric(character) { - continue - } - if character == '-' && index > 0 && index < len(value)-1 { - continue - } - return fmt.Errorf("%w: %q is not a lowercase DNS label", ErrInvalidName, value) - } - return nil -} - -func isLowerAlphaNumeric(character byte) bool { - return character >= 'a' && character <= 'z' || character >= '0' && character <= '9' -} diff --git a/internal/sandbox/name_test.go b/internal/sandbox/name_test.go deleted file mode 100644 index f4c8bf7..0000000 --- a/internal/sandbox/name_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package sandbox - -import ( - "errors" - "strings" - "testing" -) - -func TestParseName(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - value string - wantErr bool - }{ - {name: "simple", value: "agent"}, - {name: "numbered", value: "agent-001"}, - {name: "single character", value: "a"}, - {name: "zero", value: "", wantErr: true}, - {name: "uppercase", value: "Agent", wantErr: true}, - {name: "leading hyphen", value: "-agent", wantErr: true}, - {name: "trailing hyphen", value: "agent-", wantErr: true}, - {name: "underscore", value: "agent_1", wantErr: true}, - {name: "dot", value: "agent.local", wantErr: true}, - {name: "too long", value: strings.Repeat("a", maxNameLength+1), wantErr: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - name, err := ParseName(test.value) - if test.wantErr { - if !errors.Is(err, ErrInvalidName) { - t.Fatalf("ParseName(%q) error = %v, want ErrInvalidName", test.value, err) - } - return - } - if err != nil { - t.Fatalf("ParseName(%q) error = %v", test.value, err) - } - if got := name.String(); got != test.value { - t.Fatalf("ParseName(%q).String() = %q", test.value, got) - } - }) - } -} - -func TestZeroName(t *testing.T) { - t.Parallel() - - var name Name - if !name.IsZero() { - t.Fatal("zero Name is not reported as zero") - } - if !errors.Is(name.Validate(), ErrInvalidName) { - t.Fatalf("zero Name validation error = %v, want ErrInvalidName", name.Validate()) - } -} diff --git a/internal/sandbox/state.go b/internal/sandbox/state.go index 2549b32..ef75ae5 100644 --- a/internal/sandbox/state.go +++ b/internal/sandbox/state.go @@ -5,78 +5,37 @@ import ( "fmt" ) -// State is the durable lifecycle state of a sandbox. -type State uint8 +// State is the durable lifecycle state of a sandbox. Its zero value is +// StateUnknown. +type State string const ( - StateUnknown State = iota - StateCreating - StateStopped - StateStarting - StateRunning - StatePaused - StateStopping - StateError - StateDeleting + StateUnknown State = "" + StateCreating State = "creating" + StateStopped State = "stopped" + StateStarting State = "starting" + StateRunning State = "running" + StatePaused State = "paused" + StateStopping State = "stopping" + StateError State = "error" + StateDeleting State = "deleting" ) -// ErrInvalidState identifies an unknown numeric or textual sandbox state. -var ErrInvalidState = errors.New("invalid sandbox state") - -// ErrInvalidTransition identifies a transition not present in the sandbox -// lifecycle state machine. -var ErrInvalidTransition = errors.New("invalid sandbox state transition") +var ( + // ErrInvalidState identifies an unknown textual sandbox state. + ErrInvalidState = errors.New("invalid sandbox state") + // ErrInvalidTransition identifies a transition not present in the sandbox + // lifecycle state machine. + ErrInvalidTransition = errors.New("invalid sandbox state transition") +) -// ParseState parses a canonical sandbox state. +// ParseState parses and validates a state received at a module boundary. func ParseState(value string) (State, error) { - switch value { - case "unknown": - return StateUnknown, nil - case "creating": - return StateCreating, nil - case "stopped": - return StateStopped, nil - case "starting": - return StateStarting, nil - case "running": - return StateRunning, nil - case "paused": - return StatePaused, nil - case "stopping": - return StateStopping, nil - case "error": - return StateError, nil - case "deleting": - return StateDeleting, nil - default: - return StateUnknown, fmt.Errorf("%w: %q", ErrInvalidState, value) - } -} - -// String returns the canonical state name. -func (state State) String() string { - switch state { - case StateUnknown: - return "unknown" - case StateCreating: - return "creating" - case StateStopped: - return "stopped" - case StateStarting: - return "starting" - case StateRunning: - return "running" - case StatePaused: - return "paused" - case StateStopping: - return "stopping" - case StateError: - return "error" - case StateDeleting: - return "deleting" - default: - return fmt.Sprintf("State(%d)", uint8(state)) + state := State(value) + if err := state.Validate(); err != nil { + return StateUnknown, err } + return state, nil } // IsUnknown reports whether state is the zero-value state. @@ -87,10 +46,20 @@ func (state State) IsUnknown() bool { // Validate checks that state is a defined lifecycle state. StateUnknown is a // defined value; operation-specific validation decides where it is permitted. func (state State) Validate() error { - if state <= StateDeleting { + switch state { + case StateUnknown, + StateCreating, + StateStopped, + StateStarting, + StateRunning, + StatePaused, + StateStopping, + StateError, + StateDeleting: return nil + default: + return fmt.Errorf("%w: %q", ErrInvalidState, state) } - return fmt.Errorf("%w: %d", ErrInvalidState, state) } // CanTransition reports whether the lifecycle state machine permits a direct @@ -147,7 +116,7 @@ type TransitionError struct { } func (err *TransitionError) Error() string { - return fmt.Sprintf("%s: %s -> %s", ErrInvalidTransition, err.From, err.To) + return fmt.Sprintf("%s: %q -> %q", ErrInvalidTransition, err.From, err.To) } // Unwrap supports errors.Is with ErrInvalidTransition. diff --git a/internal/sandbox/state_test.go b/internal/sandbox/state_test.go index d0da6fd..2d4d731 100644 --- a/internal/sandbox/state_test.go +++ b/internal/sandbox/state_test.go @@ -8,31 +8,33 @@ import ( func TestStateRoundTrip(t *testing.T) { t.Parallel() - states := []State{ - StateUnknown, - StateCreating, - StateStopped, - StateStarting, - StateRunning, - StatePaused, - StateStopping, - StateError, - StateDeleting, + tests := []struct { + name string + state State + }{ + {name: "unknown", state: StateUnknown}, + {name: "creating", state: StateCreating}, + {name: "stopped", state: StateStopped}, + {name: "starting", state: StateStarting}, + {name: "running", state: StateRunning}, + {name: "paused", state: StatePaused}, + {name: "stopping", state: StateStopping}, + {name: "error", state: StateError}, + {name: "deleting", state: StateDeleting}, } - for _, state := range states { - state := state - t.Run(state.String(), func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { t.Parallel() - if err := state.Validate(); err != nil { - t.Fatalf("State(%d).Validate() error = %v", state, err) + if err := test.state.Validate(); err != nil { + t.Fatalf("State(%q).Validate() error = %v", test.state, err) } - parsed, err := ParseState(state.String()) + parsed, err := ParseState(string(test.state)) if err != nil { - t.Fatalf("ParseState(%q) error = %v", state.String(), err) + t.Fatalf("ParseState(%q) error = %v", test.state, err) } - if parsed != state { - t.Fatalf("ParseState(%q) = %v, want %v", state.String(), parsed, state) + if parsed != test.state { + t.Fatalf("ParseState(%q) = %q, want %q", test.state, parsed, test.state) } }) } @@ -43,16 +45,16 @@ func TestStateUnknownIsZeroValue(t *testing.T) { var state State if state != StateUnknown || !state.IsUnknown() { - t.Fatalf("zero State = %v, want StateUnknown", state) + t.Fatalf("zero State = %q, want StateUnknown", state) } } func TestInvalidState(t *testing.T) { t.Parallel() - state := State(255) + state := State("booting") if !errors.Is(state.Validate(), ErrInvalidState) { - t.Fatalf("State(255).Validate() error = %v, want ErrInvalidState", state.Validate()) + t.Fatalf("State(booting).Validate() error = %v, want ErrInvalidState", state.Validate()) } if _, err := ParseState("RUNNING"); !errors.Is(err, ErrInvalidState) { t.Fatalf("ParseState(RUNNING) error = %v, want ErrInvalidState", err) @@ -95,29 +97,21 @@ func TestStateTransitionMatrix(t *testing.T) { for _, target := range states { want := allowed[source][target] if got := CanTransition(source, target); got != want { - t.Errorf("CanTransition(%s, %s) = %t, want %t", source, target, got, want) + t.Errorf("CanTransition(%q, %q) = %t, want %t", source, target, got, want) } err := ValidateTransition(source, target) if want && err != nil { - t.Errorf("ValidateTransition(%s, %s) error = %v", source, target, err) + t.Errorf("ValidateTransition(%q, %q) error = %v", source, target, err) } if !want && !errors.Is(err, ErrInvalidTransition) { - t.Errorf("ValidateTransition(%s, %s) error = %v, want ErrInvalidTransition", source, target, err) + t.Errorf( + "ValidateTransition(%q, %q) error = %v, want ErrInvalidTransition", + source, + target, + err, + ) } } } } - -func TestTransitionErrorSupportsErrorsAs(t *testing.T) { - t.Parallel() - - err := ValidateTransition(StateRunning, StateStopped) - var transitionError *TransitionError - if !errors.As(err, &transitionError) { - t.Fatalf("ValidateTransition error = %v, want *TransitionError", err) - } - if transitionError.From != StateRunning || transitionError.To != StateStopped { - t.Fatalf("TransitionError = %#v", transitionError) - } -} From b8238029922beefac318ebd3983f3338d2ab017e Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 9 Sep 2026 15:12:45 +0800 Subject: [PATCH 06/45] feat: add operation identities --- internal/operation/identity.go | 163 ++++++++++++++++++++++++++++ internal/operation/identity_test.go | 142 ++++++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 internal/operation/identity.go create mode 100644 internal/operation/identity_test.go diff --git a/internal/operation/identity.go b/internal/operation/identity.go new file mode 100644 index 0000000..79755c0 --- /dev/null +++ b/internal/operation/identity.go @@ -0,0 +1,163 @@ +package operation + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +const ( + idPrefix = "op_" + idRandomBytes = 16 + idEncodedLength = idRandomBytes * 2 + maxRequestIDLength = 128 + uuidBytes = 16 +) + +var ( + // ErrInvalidID identifies a malformed or zero operation ID. + ErrInvalidID = errors.New("invalid operation ID") + // ErrInvalidRequestID identifies a malformed or zero idempotency key. + ErrInvalidRequestID = errors.New("invalid operation request ID") +) + +// ID is the immutable identity of an operation. Its zero value is invalid. +type ID struct { + value string +} + +// NewID creates an operation ID using cryptographically secure randomness. +func NewID() (ID, error) { + random := make([]byte, idRandomBytes) + if _, err := rand.Read(random); err != nil { + return ID{}, fmt.Errorf("generate operation ID: %w", err) + } + return ID{value: idPrefix + hex.EncodeToString(random)}, nil +} + +// ParseID parses a canonical operation ID. +func ParseID(value string) (ID, error) { + if err := validateID(value); err != nil { + return ID{}, err + } + return ID{value: value}, nil +} + +// String returns the canonical operation ID. It returns an empty string for +// the zero value. +func (id ID) String() string { + return id.value +} + +// IsZero reports whether id is the zero value. +func (id ID) IsZero() bool { + return id.value == "" +} + +// Validate checks that id contains a canonical, non-zero operation ID. +func (id ID) Validate() error { + return validateID(id.value) +} + +func validateID(value string) error { + validLength := len(value) == len(idPrefix)+idEncodedLength + if !strings.HasPrefix(value, idPrefix) || !validLength { + return fmt.Errorf( + "%w: must use %q followed by %d lowercase hexadecimal characters", + ErrInvalidID, + idPrefix, + idEncodedLength, + ) + } + + encoded := value[len(idPrefix):] + if encoded != strings.ToLower(encoded) { + return fmt.Errorf("%w: hexadecimal encoding must be lowercase", ErrInvalidID) + } + if _, err := hex.DecodeString(encoded); err != nil { + return fmt.Errorf("%w: decode identity: %v", ErrInvalidID, err) + } + return nil +} + +// RequestID is a caller-controlled idempotency key. Its zero value is invalid. +type RequestID struct { + value string +} + +// NewRequestID creates a random RFC 4122 UUID version 4 request ID. +func NewRequestID() (RequestID, error) { + random := make([]byte, uuidBytes) + if _, err := rand.Read(random); err != nil { + return RequestID{}, fmt.Errorf("generate operation request ID: %w", err) + } + random[6] = random[6]&0x0f | 0x40 + random[8] = random[8]&0x3f | 0x80 + + encoded := make([]byte, 36) + hex.Encode(encoded[0:8], random[0:4]) + encoded[8] = '-' + hex.Encode(encoded[9:13], random[4:6]) + encoded[13] = '-' + hex.Encode(encoded[14:18], random[6:8]) + encoded[18] = '-' + hex.Encode(encoded[19:23], random[8:10]) + encoded[23] = '-' + hex.Encode(encoded[24:36], random[10:16]) + + return RequestID{value: string(encoded)}, nil +} + +// ParseRequestID parses a canonical caller-provided idempotency key. +func ParseRequestID(value string) (RequestID, error) { + if err := validateRequestID(value); err != nil { + return RequestID{}, err + } + return RequestID{value: value}, nil +} + +// String returns the canonical request ID. It returns an empty string for the +// zero value. +func (id RequestID) String() string { + return id.value +} + +// IsZero reports whether id is the zero value. +func (id RequestID) IsZero() bool { + return id.value == "" +} + +// Validate checks that id is canonical and non-zero. +func (id RequestID) Validate() error { + return validateRequestID(id.value) +} + +func validateRequestID(value string) error { + if len(value) == 0 || len(value) > maxRequestIDLength { + return fmt.Errorf( + "%w: length must be between 1 and %d bytes", + ErrInvalidRequestID, + maxRequestIDLength, + ) + } + + for index, character := range []byte(value) { + if isLowerAlphaNumeric(character) { + continue + } + isSeparator := character == '-' || character == '_' || character == '.' + if isSeparator && index > 0 && index < len(value)-1 { + continue + } + return fmt.Errorf("%w: %q is not canonical", ErrInvalidRequestID, value) + } + return nil +} + +func isLowerAlphaNumeric(character byte) bool { + isLower := character >= 'a' && character <= 'z' + isDigit := character >= '0' && character <= '9' + return isLower || isDigit +} diff --git a/internal/operation/identity_test.go b/internal/operation/identity_test.go new file mode 100644 index 0000000..6b3ef7a --- /dev/null +++ b/internal/operation/identity_test.go @@ -0,0 +1,142 @@ +package operation + +import ( + "errors" + "strings" + "testing" +) + +func TestNewID(t *testing.T) { + t.Parallel() + + id, err := NewID() + if err != nil { + t.Fatalf("NewID() error = %v", err) + } + if err := id.Validate(); err != nil { + t.Fatalf("generated ID is invalid: %v", err) + } + if got, want := len(id.String()), len(idPrefix)+idEncodedLength; got != want { + t.Fatalf("len(NewID()) = %d, want %d", got, want) + } +} + +func TestParseID(t *testing.T) { + t.Parallel() + + valid := idPrefix + strings.Repeat("01", idRandomBytes) + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "canonical", value: valid}, + {name: "zero", value: "", wantErr: true}, + {name: "wrong prefix", value: "kb_" + strings.Repeat("0", idEncodedLength), wantErr: true}, + {name: "short", value: idPrefix + strings.Repeat("0", idEncodedLength-1), wantErr: true}, + {name: "uppercase", value: idPrefix + strings.Repeat("A", idEncodedLength), wantErr: true}, + {name: "not hexadecimal", value: idPrefix + strings.Repeat("z", idEncodedLength), wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + id, err := ParseID(test.value) + if test.wantErr { + if !errors.Is(err, ErrInvalidID) { + t.Fatalf("ParseID(%q) error = %v, want ErrInvalidID", test.value, err) + } + return + } + if err != nil { + t.Fatalf("ParseID(%q) error = %v", test.value, err) + } + if got := id.String(); got != test.value { + t.Fatalf("ParseID(%q).String() = %q", test.value, got) + } + }) + } +} + +func TestZeroID(t *testing.T) { + t.Parallel() + + var id ID + if !id.IsZero() { + t.Fatal("zero ID is not reported as zero") + } + if !errors.Is(id.Validate(), ErrInvalidID) { + t.Fatalf("zero ID validation error = %v, want ErrInvalidID", id.Validate()) + } +} + +func TestNewRequestID(t *testing.T) { + t.Parallel() + + id, err := NewRequestID() + if err != nil { + t.Fatalf("NewRequestID() error = %v", err) + } + if err := id.Validate(); err != nil { + t.Fatalf("generated request ID is invalid: %v", err) + } + value := id.String() + isVersionFour := len(value) == 36 && value[14] == '4' + isRFCVariant := len(value) == 36 && strings.ContainsRune("89ab", rune(value[19])) + if !isVersionFour || !isRFCVariant { + t.Fatalf("NewRequestID() = %q, want canonical UUIDv4", value) + } +} + +func TestParseRequestID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "uuid", value: "2f1c6a8e-31db-4f26-856a-dc55e84ce551"}, + {name: "control plane key", value: "control-plane.request_001"}, + {name: "zero", value: "", wantErr: true}, + {name: "uppercase", value: "REQUEST-1", wantErr: true}, + {name: "leading separator", value: "-request", wantErr: true}, + {name: "trailing separator", value: "request-", wantErr: true}, + {name: "slash", value: "control/request", wantErr: true}, + {name: "space", value: "control request", wantErr: true}, + {name: "too long", value: strings.Repeat("a", maxRequestIDLength+1), wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + id, err := ParseRequestID(test.value) + if test.wantErr { + if !errors.Is(err, ErrInvalidRequestID) { + t.Fatalf("ParseRequestID(%q) error = %v, want ErrInvalidRequestID", test.value, err) + } + return + } + if err != nil { + t.Fatalf("ParseRequestID(%q) error = %v", test.value, err) + } + if got := id.String(); got != test.value { + t.Fatalf("ParseRequestID(%q).String() = %q", test.value, got) + } + }) + } +} + +func TestZeroRequestID(t *testing.T) { + t.Parallel() + + var id RequestID + if !id.IsZero() { + t.Fatal("zero RequestID is not reported as zero") + } + if !errors.Is(id.Validate(), ErrInvalidRequestID) { + t.Fatalf("zero RequestID validation error = %v, want ErrInvalidRequestID", id.Validate()) + } +} From 7af49e34fbd06a3f43621a7cd83ccd555b8ea6be Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Thu, 10 Sep 2026 09:56:54 +0800 Subject: [PATCH 07/45] chore: remove the ChatGPT-authored core packages The four identity packages were near-identical copies of one another and the architecture test package duplicated what a linter should do (DEC-012, DEC-015). Nothing here is reused; the specs govern the rewrite. --- internal/archtest/dependencies_test.go | 125 ------------------- internal/archtest/doc.go | 3 - internal/content/digest.go | 90 -------------- internal/content/digest_test.go | 79 ------------ internal/content/doc.go | 4 - internal/operation/doc.go | 4 - internal/operation/identity.go | 163 ------------------------- internal/operation/identity_test.go | 142 --------------------- internal/sandbox/doc.go | 4 - internal/sandbox/identity.go | 139 --------------------- internal/sandbox/identity_test.go | 127 ------------------- internal/sandbox/state.go | 125 ------------------- internal/sandbox/state_test.go | 117 ------------------ internal/tenant/doc.go | 3 - internal/tenant/id.go | 79 ------------ internal/tenant/id_test.go | 78 ------------ 16 files changed, 1282 deletions(-) delete mode 100644 internal/archtest/dependencies_test.go delete mode 100644 internal/archtest/doc.go delete mode 100644 internal/content/digest.go delete mode 100644 internal/content/digest_test.go delete mode 100644 internal/content/doc.go delete mode 100644 internal/operation/doc.go delete mode 100644 internal/operation/identity.go delete mode 100644 internal/operation/identity_test.go delete mode 100644 internal/sandbox/doc.go delete mode 100644 internal/sandbox/identity.go delete mode 100644 internal/sandbox/identity_test.go delete mode 100644 internal/sandbox/state.go delete mode 100644 internal/sandbox/state_test.go delete mode 100644 internal/tenant/doc.go delete mode 100644 internal/tenant/id.go delete mode 100644 internal/tenant/id_test.go diff --git a/internal/archtest/dependencies_test.go b/internal/archtest/dependencies_test.go deleted file mode 100644 index 642b0ba..0000000 --- a/internal/archtest/dependencies_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package archtest - -import ( - "encoding/json" - "errors" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "slices" - "strings" - "testing" -) - -const modulePath = "github.com/kumabox/kumabox" - -var corePackages = []string{ - modulePath + "/internal/tenant", - modulePath + "/internal/content", - modulePath + "/internal/sandbox", - modulePath + "/internal/operation", -} - -var forbiddenImportPrefixes = []string{ - modulePath + "/internal/legacy", - modulePath + "/internal/meta", - modulePath + "/internal/network/cni", - modulePath + "/internal/state", - modulePath + "/internal/store", - modulePath + "/internal/vm/runtime", - modulePath + "/internal/vmm", -} - -func TestCorePackagesDoNotImportAdaptersOrLegacyCore(t *testing.T) { - t.Parallel() - - packages := listPackages(t, corePackages) - for _, pkg := range packages { - for _, imported := range pkg.Imports { - for _, forbidden := range forbiddenImportPrefixes { - if imported == forbidden || strings.HasPrefix(imported, forbidden+"/") { - t.Errorf("%s imports forbidden package %s", pkg.ImportPath, imported) - } - } - } - } -} - -func TestGlobalLayerDirectoriesDoNotExist(t *testing.T) { - t.Parallel() - - root := repositoryRoot(t) - for _, name := range []string{"app", "domain", "service"} { - path := filepath.Join(root, "internal", name) - _, err := os.Stat(path) - if err == nil { - t.Errorf("global layer directory must not exist: internal/%s", name) - continue - } - if !errors.Is(err, os.ErrNotExist) { - t.Fatalf("inspect internal/%s: %v", name, err) - } - } -} - -type listedPackage struct { - ImportPath string - Imports []string -} - -func listPackages(t *testing.T, importPaths []string) []listedPackage { - t.Helper() - - args := append([]string{"list", "-json"}, importPaths...) - command := exec.Command("go", args...) - command.Dir = repositoryRoot(t) - command.Env = append(os.Environ(), "GOCACHE="+t.TempDir()) - - output, err := command.Output() - if err != nil { - var exitError *exec.ExitError - if errors.As(err, &exitError) { - t.Fatalf("go list failed: %v\n%s", err, exitError.Stderr) - } - t.Fatalf("go list failed: %v", err) - } - - decoder := json.NewDecoder(strings.NewReader(string(output))) - packages := make([]listedPackage, 0, len(importPaths)) - for { - var pkg listedPackage - err = decoder.Decode(&pkg) - if errors.Is(err, io.EOF) { - break - } - if err != nil { - t.Fatalf("decode go list output: %v", err) - } - packages = append(packages, pkg) - } - - if len(packages) != len(importPaths) { - t.Fatalf("go list returned %d packages, want %d", len(packages), len(importPaths)) - } - for _, importPath := range importPaths { - if !slices.ContainsFunc(packages, func(pkg listedPackage) bool { - return pkg.ImportPath == importPath - }) { - t.Errorf("go list did not return %s", importPath) - } - } - - return packages -} - -func repositoryRoot(t *testing.T) string { - t.Helper() - - _, filename, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("resolve architecture test location") - } - return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) -} diff --git a/internal/archtest/doc.go b/internal/archtest/doc.go deleted file mode 100644 index 1355a1c..0000000 --- a/internal/archtest/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package archtest verifies the dependency rules of KumaBox infrastructure -// modules. -package archtest diff --git a/internal/content/digest.go b/internal/content/digest.go deleted file mode 100644 index 469d470..0000000 --- a/internal/content/digest.go +++ /dev/null @@ -1,90 +0,0 @@ -package content - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "strings" -) - -const ( - sha256Algorithm = "sha256" - sha256Prefix = sha256Algorithm + ":" - sha256HexLength = sha256.Size * 2 -) - -// ErrInvalidDigest identifies a malformed, unsupported, or zero digest. -var ErrInvalidDigest = errors.New("invalid content digest") - -// Digest is the canonical identity of immutable content. A Digest currently -// supports SHA-256 only. Its zero value is invalid. -type Digest struct { - value string -} - -// SHA256 returns the digest of data. -func SHA256(data []byte) Digest { - sum := sha256.Sum256(data) - return Digest{value: sha256Prefix + hex.EncodeToString(sum[:])} -} - -// ParseDigest parses a canonical SHA-256 digest. -func ParseDigest(value string) (Digest, error) { - if err := validateDigest(value); err != nil { - return Digest{}, err - } - return Digest{value: value}, nil -} - -// String returns the canonical digest. It returns an empty string for the zero -// value. -func (digest Digest) String() string { - return digest.value -} - -// IsZero reports whether digest is the zero value. -func (digest Digest) IsZero() bool { - return digest.value == "" -} - -// Algorithm returns the digest algorithm, or an empty string for the zero -// value. -func (digest Digest) Algorithm() string { - if digest.IsZero() { - return "" - } - return sha256Algorithm -} - -// Encoded returns the lowercase hexadecimal digest, or an empty string for the -// zero value. -func (digest Digest) Encoded() string { - if digest.IsZero() { - return "" - } - return digest.value[len(sha256Prefix):] -} - -// Validate checks that digest is canonical and supported. -func (digest Digest) Validate() error { - return validateDigest(digest.value) -} - -func validateDigest(value string) error { - if !strings.HasPrefix(value, sha256Prefix) { - return fmt.Errorf("%w: only %s is supported", ErrInvalidDigest, sha256Algorithm) - } - - encoded := value[len(sha256Prefix):] - if len(encoded) != sha256HexLength { - return fmt.Errorf("%w: %s must contain %d hexadecimal characters", ErrInvalidDigest, sha256Algorithm, sha256HexLength) - } - if encoded != strings.ToLower(encoded) { - return fmt.Errorf("%w: hexadecimal encoding must be lowercase", ErrInvalidDigest) - } - if _, err := hex.DecodeString(encoded); err != nil { - return fmt.Errorf("%w: decode %s: %v", ErrInvalidDigest, sha256Algorithm, err) - } - return nil -} diff --git a/internal/content/digest_test.go b/internal/content/digest_test.go deleted file mode 100644 index ff2b64f..0000000 --- a/internal/content/digest_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package content - -import ( - "errors" - "strings" - "testing" -) - -func TestSHA256(t *testing.T) { - t.Parallel() - - digest := SHA256([]byte("abc")) - const want = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - if got := digest.String(); got != want { - t.Fatalf("SHA256(abc) = %q, want %q", got, want) - } - if got := digest.Algorithm(); got != sha256Algorithm { - t.Fatalf("Algorithm() = %q, want %q", got, sha256Algorithm) - } - if got := digest.Encoded(); got != strings.TrimPrefix(want, sha256Prefix) { - t.Fatalf("Encoded() = %q", got) - } - if err := digest.Validate(); err != nil { - t.Fatalf("generated digest is invalid: %v", err) - } -} - -func TestParseDigest(t *testing.T) { - t.Parallel() - - valid := "sha256:" + strings.Repeat("01", sha256HexLength/2) - tests := []struct { - name string - value string - wantErr bool - }{ - {name: "canonical", value: valid}, - {name: "zero", value: "", wantErr: true}, - {name: "unsupported", value: "sha512:" + strings.Repeat("0", 128), wantErr: true}, - {name: "short", value: "sha256:00", wantErr: true}, - {name: "uppercase", value: "sha256:" + strings.Repeat("A", sha256HexLength), wantErr: true}, - {name: "not hexadecimal", value: "sha256:" + strings.Repeat("z", sha256HexLength), wantErr: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - digest, err := ParseDigest(test.value) - if test.wantErr { - if !errors.Is(err, ErrInvalidDigest) { - t.Fatalf("ParseDigest(%q) error = %v, want ErrInvalidDigest", test.value, err) - } - return - } - if err != nil { - t.Fatalf("ParseDigest(%q) error = %v", test.value, err) - } - if got := digest.String(); got != test.value { - t.Fatalf("ParseDigest(%q).String() = %q", test.value, got) - } - }) - } -} - -func TestZeroDigest(t *testing.T) { - t.Parallel() - - var digest Digest - if !digest.IsZero() { - t.Fatal("zero Digest is not reported as zero") - } - if digest.Algorithm() != "" || digest.Encoded() != "" || digest.String() != "" { - t.Fatal("zero Digest exposes non-empty data") - } - if !errors.Is(digest.Validate(), ErrInvalidDigest) { - t.Fatalf("zero Digest validation error = %v, want ErrInvalidDigest", digest.Validate()) - } -} diff --git a/internal/content/doc.go b/internal/content/doc.go deleted file mode 100644 index a9d4576..0000000 --- a/internal/content/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package content owns canonical content identity and immutable local content -// semantics. OCI references and remote artifact transport belong to other -// modules. -package content diff --git a/internal/operation/doc.go b/internal/operation/doc.go deleted file mode 100644 index 6097d27..0000000 --- a/internal/operation/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package operation owns durable-operation identity and progress vocabulary. -// The order of lifecycle steps belongs to the sandbox operation performing -// them. -package operation diff --git a/internal/operation/identity.go b/internal/operation/identity.go deleted file mode 100644 index 79755c0..0000000 --- a/internal/operation/identity.go +++ /dev/null @@ -1,163 +0,0 @@ -package operation - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "strings" -) - -const ( - idPrefix = "op_" - idRandomBytes = 16 - idEncodedLength = idRandomBytes * 2 - maxRequestIDLength = 128 - uuidBytes = 16 -) - -var ( - // ErrInvalidID identifies a malformed or zero operation ID. - ErrInvalidID = errors.New("invalid operation ID") - // ErrInvalidRequestID identifies a malformed or zero idempotency key. - ErrInvalidRequestID = errors.New("invalid operation request ID") -) - -// ID is the immutable identity of an operation. Its zero value is invalid. -type ID struct { - value string -} - -// NewID creates an operation ID using cryptographically secure randomness. -func NewID() (ID, error) { - random := make([]byte, idRandomBytes) - if _, err := rand.Read(random); err != nil { - return ID{}, fmt.Errorf("generate operation ID: %w", err) - } - return ID{value: idPrefix + hex.EncodeToString(random)}, nil -} - -// ParseID parses a canonical operation ID. -func ParseID(value string) (ID, error) { - if err := validateID(value); err != nil { - return ID{}, err - } - return ID{value: value}, nil -} - -// String returns the canonical operation ID. It returns an empty string for -// the zero value. -func (id ID) String() string { - return id.value -} - -// IsZero reports whether id is the zero value. -func (id ID) IsZero() bool { - return id.value == "" -} - -// Validate checks that id contains a canonical, non-zero operation ID. -func (id ID) Validate() error { - return validateID(id.value) -} - -func validateID(value string) error { - validLength := len(value) == len(idPrefix)+idEncodedLength - if !strings.HasPrefix(value, idPrefix) || !validLength { - return fmt.Errorf( - "%w: must use %q followed by %d lowercase hexadecimal characters", - ErrInvalidID, - idPrefix, - idEncodedLength, - ) - } - - encoded := value[len(idPrefix):] - if encoded != strings.ToLower(encoded) { - return fmt.Errorf("%w: hexadecimal encoding must be lowercase", ErrInvalidID) - } - if _, err := hex.DecodeString(encoded); err != nil { - return fmt.Errorf("%w: decode identity: %v", ErrInvalidID, err) - } - return nil -} - -// RequestID is a caller-controlled idempotency key. Its zero value is invalid. -type RequestID struct { - value string -} - -// NewRequestID creates a random RFC 4122 UUID version 4 request ID. -func NewRequestID() (RequestID, error) { - random := make([]byte, uuidBytes) - if _, err := rand.Read(random); err != nil { - return RequestID{}, fmt.Errorf("generate operation request ID: %w", err) - } - random[6] = random[6]&0x0f | 0x40 - random[8] = random[8]&0x3f | 0x80 - - encoded := make([]byte, 36) - hex.Encode(encoded[0:8], random[0:4]) - encoded[8] = '-' - hex.Encode(encoded[9:13], random[4:6]) - encoded[13] = '-' - hex.Encode(encoded[14:18], random[6:8]) - encoded[18] = '-' - hex.Encode(encoded[19:23], random[8:10]) - encoded[23] = '-' - hex.Encode(encoded[24:36], random[10:16]) - - return RequestID{value: string(encoded)}, nil -} - -// ParseRequestID parses a canonical caller-provided idempotency key. -func ParseRequestID(value string) (RequestID, error) { - if err := validateRequestID(value); err != nil { - return RequestID{}, err - } - return RequestID{value: value}, nil -} - -// String returns the canonical request ID. It returns an empty string for the -// zero value. -func (id RequestID) String() string { - return id.value -} - -// IsZero reports whether id is the zero value. -func (id RequestID) IsZero() bool { - return id.value == "" -} - -// Validate checks that id is canonical and non-zero. -func (id RequestID) Validate() error { - return validateRequestID(id.value) -} - -func validateRequestID(value string) error { - if len(value) == 0 || len(value) > maxRequestIDLength { - return fmt.Errorf( - "%w: length must be between 1 and %d bytes", - ErrInvalidRequestID, - maxRequestIDLength, - ) - } - - for index, character := range []byte(value) { - if isLowerAlphaNumeric(character) { - continue - } - isSeparator := character == '-' || character == '_' || character == '.' - if isSeparator && index > 0 && index < len(value)-1 { - continue - } - return fmt.Errorf("%w: %q is not canonical", ErrInvalidRequestID, value) - } - return nil -} - -func isLowerAlphaNumeric(character byte) bool { - isLower := character >= 'a' && character <= 'z' - isDigit := character >= '0' && character <= '9' - return isLower || isDigit -} diff --git a/internal/operation/identity_test.go b/internal/operation/identity_test.go deleted file mode 100644 index 6b3ef7a..0000000 --- a/internal/operation/identity_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package operation - -import ( - "errors" - "strings" - "testing" -) - -func TestNewID(t *testing.T) { - t.Parallel() - - id, err := NewID() - if err != nil { - t.Fatalf("NewID() error = %v", err) - } - if err := id.Validate(); err != nil { - t.Fatalf("generated ID is invalid: %v", err) - } - if got, want := len(id.String()), len(idPrefix)+idEncodedLength; got != want { - t.Fatalf("len(NewID()) = %d, want %d", got, want) - } -} - -func TestParseID(t *testing.T) { - t.Parallel() - - valid := idPrefix + strings.Repeat("01", idRandomBytes) - tests := []struct { - name string - value string - wantErr bool - }{ - {name: "canonical", value: valid}, - {name: "zero", value: "", wantErr: true}, - {name: "wrong prefix", value: "kb_" + strings.Repeat("0", idEncodedLength), wantErr: true}, - {name: "short", value: idPrefix + strings.Repeat("0", idEncodedLength-1), wantErr: true}, - {name: "uppercase", value: idPrefix + strings.Repeat("A", idEncodedLength), wantErr: true}, - {name: "not hexadecimal", value: idPrefix + strings.Repeat("z", idEncodedLength), wantErr: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - id, err := ParseID(test.value) - if test.wantErr { - if !errors.Is(err, ErrInvalidID) { - t.Fatalf("ParseID(%q) error = %v, want ErrInvalidID", test.value, err) - } - return - } - if err != nil { - t.Fatalf("ParseID(%q) error = %v", test.value, err) - } - if got := id.String(); got != test.value { - t.Fatalf("ParseID(%q).String() = %q", test.value, got) - } - }) - } -} - -func TestZeroID(t *testing.T) { - t.Parallel() - - var id ID - if !id.IsZero() { - t.Fatal("zero ID is not reported as zero") - } - if !errors.Is(id.Validate(), ErrInvalidID) { - t.Fatalf("zero ID validation error = %v, want ErrInvalidID", id.Validate()) - } -} - -func TestNewRequestID(t *testing.T) { - t.Parallel() - - id, err := NewRequestID() - if err != nil { - t.Fatalf("NewRequestID() error = %v", err) - } - if err := id.Validate(); err != nil { - t.Fatalf("generated request ID is invalid: %v", err) - } - value := id.String() - isVersionFour := len(value) == 36 && value[14] == '4' - isRFCVariant := len(value) == 36 && strings.ContainsRune("89ab", rune(value[19])) - if !isVersionFour || !isRFCVariant { - t.Fatalf("NewRequestID() = %q, want canonical UUIDv4", value) - } -} - -func TestParseRequestID(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - value string - wantErr bool - }{ - {name: "uuid", value: "2f1c6a8e-31db-4f26-856a-dc55e84ce551"}, - {name: "control plane key", value: "control-plane.request_001"}, - {name: "zero", value: "", wantErr: true}, - {name: "uppercase", value: "REQUEST-1", wantErr: true}, - {name: "leading separator", value: "-request", wantErr: true}, - {name: "trailing separator", value: "request-", wantErr: true}, - {name: "slash", value: "control/request", wantErr: true}, - {name: "space", value: "control request", wantErr: true}, - {name: "too long", value: strings.Repeat("a", maxRequestIDLength+1), wantErr: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - id, err := ParseRequestID(test.value) - if test.wantErr { - if !errors.Is(err, ErrInvalidRequestID) { - t.Fatalf("ParseRequestID(%q) error = %v, want ErrInvalidRequestID", test.value, err) - } - return - } - if err != nil { - t.Fatalf("ParseRequestID(%q) error = %v", test.value, err) - } - if got := id.String(); got != test.value { - t.Fatalf("ParseRequestID(%q).String() = %q", test.value, got) - } - }) - } -} - -func TestZeroRequestID(t *testing.T) { - t.Parallel() - - var id RequestID - if !id.IsZero() { - t.Fatal("zero RequestID is not reported as zero") - } - if !errors.Is(id.Validate(), ErrInvalidRequestID) { - t.Fatalf("zero RequestID validation error = %v, want ErrInvalidRequestID", id.Validate()) - } -} diff --git a/internal/sandbox/doc.go b/internal/sandbox/doc.go deleted file mode 100644 index 0735a07..0000000 --- a/internal/sandbox/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package sandbox owns sandbox identity, lifecycle state, and host-side -// lifecycle orchestration. Concrete VMM, network, storage, and persistence -// adapters remain outside this package. -package sandbox diff --git a/internal/sandbox/identity.go b/internal/sandbox/identity.go deleted file mode 100644 index f9dcb13..0000000 --- a/internal/sandbox/identity.go +++ /dev/null @@ -1,139 +0,0 @@ -package sandbox - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "strings" -) - -const ( - idPrefix = "kb_" - idRandomBytes = 16 - idEncodedLength = idRandomBytes * 2 - maxNameLength = 63 -) - -var ( - // ErrInvalidID identifies a malformed or zero sandbox ID. - ErrInvalidID = errors.New("invalid sandbox ID") - // ErrInvalidName identifies a malformed or zero sandbox name. - ErrInvalidName = errors.New("invalid sandbox name") -) - -// ID is the immutable identity of a sandbox. Its zero value is invalid. -type ID struct { - value string -} - -// NewID creates a sandbox ID using cryptographically secure randomness. -func NewID() (ID, error) { - random := make([]byte, idRandomBytes) - if _, err := rand.Read(random); err != nil { - return ID{}, fmt.Errorf("generate sandbox ID: %w", err) - } - return ID{value: idPrefix + hex.EncodeToString(random)}, nil -} - -// ParseID parses a canonical sandbox ID. -func ParseID(value string) (ID, error) { - if err := validateID(value); err != nil { - return ID{}, err - } - return ID{value: value}, nil -} - -// String returns the canonical sandbox ID. It returns an empty string for the -// zero value. -func (id ID) String() string { - return id.value -} - -// IsZero reports whether id is the zero value. -func (id ID) IsZero() bool { - return id.value == "" -} - -// Validate checks that id contains a canonical, non-zero sandbox ID. -func (id ID) Validate() error { - return validateID(id.value) -} - -func validateID(value string) error { - validLength := len(value) == len(idPrefix)+idEncodedLength - if !strings.HasPrefix(value, idPrefix) || !validLength { - return fmt.Errorf( - "%w: must use %q followed by %d lowercase hexadecimal characters", - ErrInvalidID, - idPrefix, - idEncodedLength, - ) - } - - encoded := value[len(idPrefix):] - if encoded != strings.ToLower(encoded) { - return fmt.Errorf("%w: hexadecimal encoding must be lowercase", ErrInvalidID) - } - if _, err := hex.DecodeString(encoded); err != nil { - return fmt.Errorf("%w: decode identity: %v", ErrInvalidID, err) - } - return nil -} - -// Name is a human-selected sandbox name. It follows grammar: -// an ASCII letter or digit followed by at most 62 letters, digits, dots, -// underscores, or hyphens. Its zero value is invalid. -type Name struct { - value string -} - -// ParseName parses a sandbox name. -func ParseName(value string) (Name, error) { - if err := validateName(value); err != nil { - return Name{}, err - } - return Name{value: value}, nil -} - -// String returns the name. It returns an empty string for the zero value. -func (name Name) String() string { - return name.value -} - -// IsZero reports whether name is the zero value. -func (name Name) IsZero() bool { - return name.value == "" -} - -// Validate checks that name is non-zero and follows the sandbox name grammar. -func (name Name) Validate() error { - return validateName(name.value) -} - -func validateName(value string) error { - if len(value) == 0 || len(value) > maxNameLength { - return fmt.Errorf( - "%w: length must be between 1 and %d bytes", - ErrInvalidName, - maxNameLength, - ) - } - if !isASCIIAlphaNumeric(value[0]) { - return fmt.Errorf("%w: %q must start with an ASCII letter or digit", ErrInvalidName, value) - } - for _, character := range []byte(value[1:]) { - if isASCIIAlphaNumeric(character) || character == '.' || character == '_' || character == '-' { - continue - } - return fmt.Errorf("%w: %q contains an unsupported character", ErrInvalidName, value) - } - return nil -} - -func isASCIIAlphaNumeric(character byte) bool { - isLower := character >= 'a' && character <= 'z' - isUpper := character >= 'A' && character <= 'Z' - isDigit := character >= '0' && character <= '9' - return isLower || isUpper || isDigit -} diff --git a/internal/sandbox/identity_test.go b/internal/sandbox/identity_test.go deleted file mode 100644 index 8dc0a36..0000000 --- a/internal/sandbox/identity_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package sandbox - -import ( - "errors" - "strings" - "testing" -) - -func TestNewID(t *testing.T) { - t.Parallel() - - id, err := NewID() - if err != nil { - t.Fatalf("NewID() error = %v", err) - } - if err := id.Validate(); err != nil { - t.Fatalf("generated ID is invalid: %v", err) - } - if got, want := len(id.String()), len(idPrefix)+idEncodedLength; got != want { - t.Fatalf("len(NewID()) = %d, want %d", got, want) - } -} - -func TestParseID(t *testing.T) { - t.Parallel() - - valid := idPrefix + strings.Repeat("01", idRandomBytes) - tests := []struct { - name string - value string - wantErr bool - }{ - {name: "canonical", value: valid}, - {name: "zero", value: "", wantErr: true}, - {name: "wrong prefix", value: "vm_" + strings.Repeat("0", idEncodedLength), wantErr: true}, - {name: "short", value: idPrefix + strings.Repeat("0", idEncodedLength-1), wantErr: true}, - {name: "uppercase", value: idPrefix + strings.Repeat("A", idEncodedLength), wantErr: true}, - {name: "not hexadecimal", value: idPrefix + strings.Repeat("z", idEncodedLength), wantErr: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - id, err := ParseID(test.value) - if test.wantErr { - if !errors.Is(err, ErrInvalidID) { - t.Fatalf("ParseID(%q) error = %v, want ErrInvalidID", test.value, err) - } - return - } - if err != nil { - t.Fatalf("ParseID(%q) error = %v", test.value, err) - } - if got := id.String(); got != test.value { - t.Fatalf("ParseID(%q).String() = %q", test.value, got) - } - }) - } -} - -func TestZeroID(t *testing.T) { - t.Parallel() - - var id ID - if !id.IsZero() { - t.Fatal("zero ID is not reported as zero") - } - if !errors.Is(id.Validate(), ErrInvalidID) { - t.Fatalf("zero ID validation error = %v, want ErrInvalidID", id.Validate()) - } -} - -func TestParseName(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - value string - wantErr bool - }{ - {name: "simple", value: "agent"}, - {name: "mixed case", value: "Agent-001"}, - {name: "dot and underscore", value: "agent.pool_1"}, - {name: "trailing separator", value: "agent-"}, - {name: "single character", value: "a"}, - {name: "zero", value: "", wantErr: true}, - {name: "leading hyphen", value: "-agent", wantErr: true}, - {name: "leading dot", value: ".agent", wantErr: true}, - {name: "slash", value: "agent/1", wantErr: true}, - {name: "space", value: "agent 1", wantErr: true}, - {name: "non ASCII", value: "智能体", wantErr: true}, - {name: "too long", value: strings.Repeat("a", maxNameLength+1), wantErr: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - name, err := ParseName(test.value) - if test.wantErr { - if !errors.Is(err, ErrInvalidName) { - t.Fatalf("ParseName(%q) error = %v, want ErrInvalidName", test.value, err) - } - return - } - if err != nil { - t.Fatalf("ParseName(%q) error = %v", test.value, err) - } - if got := name.String(); got != test.value { - t.Fatalf("ParseName(%q).String() = %q", test.value, got) - } - }) - } -} - -func TestZeroName(t *testing.T) { - t.Parallel() - - var name Name - if !name.IsZero() { - t.Fatal("zero Name is not reported as zero") - } - if !errors.Is(name.Validate(), ErrInvalidName) { - t.Fatalf("zero Name validation error = %v, want ErrInvalidName", name.Validate()) - } -} diff --git a/internal/sandbox/state.go b/internal/sandbox/state.go deleted file mode 100644 index ef75ae5..0000000 --- a/internal/sandbox/state.go +++ /dev/null @@ -1,125 +0,0 @@ -package sandbox - -import ( - "errors" - "fmt" -) - -// State is the durable lifecycle state of a sandbox. Its zero value is -// StateUnknown. -type State string - -const ( - StateUnknown State = "" - StateCreating State = "creating" - StateStopped State = "stopped" - StateStarting State = "starting" - StateRunning State = "running" - StatePaused State = "paused" - StateStopping State = "stopping" - StateError State = "error" - StateDeleting State = "deleting" -) - -var ( - // ErrInvalidState identifies an unknown textual sandbox state. - ErrInvalidState = errors.New("invalid sandbox state") - // ErrInvalidTransition identifies a transition not present in the sandbox - // lifecycle state machine. - ErrInvalidTransition = errors.New("invalid sandbox state transition") -) - -// ParseState parses and validates a state received at a module boundary. -func ParseState(value string) (State, error) { - state := State(value) - if err := state.Validate(); err != nil { - return StateUnknown, err - } - return state, nil -} - -// IsUnknown reports whether state is the zero-value state. -func (state State) IsUnknown() bool { - return state == StateUnknown -} - -// Validate checks that state is a defined lifecycle state. StateUnknown is a -// defined value; operation-specific validation decides where it is permitted. -func (state State) Validate() error { - switch state { - case StateUnknown, - StateCreating, - StateStopped, - StateStarting, - StateRunning, - StatePaused, - StateStopping, - StateError, - StateDeleting: - return nil - default: - return fmt.Errorf("%w: %q", ErrInvalidState, state) - } -} - -// CanTransition reports whether the lifecycle state machine permits a direct -// transition from source to target. Successful deletion removes the durable -// record, so it is not represented by an additional state. -func CanTransition(source, target State) bool { - if source.Validate() != nil || target.Validate() != nil { - return false - } - - switch source { - case StateUnknown: - return target == StateCreating - case StateCreating: - return target == StateStopped || target == StateStarting || target == StateError || target == StateDeleting - case StateStopped: - return target == StateStarting || target == StateDeleting || target == StateError - case StateStarting: - return target == StateRunning || target == StateError || target == StateStopping - case StateRunning: - return target == StatePaused || target == StateStopping || target == StateError - case StatePaused: - return target == StateRunning || target == StateStopping || target == StateError - case StateStopping: - return target == StateStopped || target == StateError - case StateError: - return target == StateStarting || target == StateStopping || target == StateDeleting - case StateDeleting: - return target == StateError - default: - return false - } -} - -// ValidateTransition returns a TransitionError when a direct state transition -// is not permitted. -func ValidateTransition(source, target State) error { - if err := source.Validate(); err != nil { - return fmt.Errorf("validate source state: %w", err) - } - if err := target.Validate(); err != nil { - return fmt.Errorf("validate target state: %w", err) - } - if !CanTransition(source, target) { - return &TransitionError{From: source, To: target} - } - return nil -} - -// TransitionError describes a rejected direct lifecycle transition. -type TransitionError struct { - From State - To State -} - -func (err *TransitionError) Error() string { - return fmt.Sprintf("%s: %q -> %q", ErrInvalidTransition, err.From, err.To) -} - -// Unwrap supports errors.Is with ErrInvalidTransition. -func (err *TransitionError) Unwrap() error { - return ErrInvalidTransition -} diff --git a/internal/sandbox/state_test.go b/internal/sandbox/state_test.go deleted file mode 100644 index 2d4d731..0000000 --- a/internal/sandbox/state_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package sandbox - -import ( - "errors" - "testing" -) - -func TestStateRoundTrip(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - state State - }{ - {name: "unknown", state: StateUnknown}, - {name: "creating", state: StateCreating}, - {name: "stopped", state: StateStopped}, - {name: "starting", state: StateStarting}, - {name: "running", state: StateRunning}, - {name: "paused", state: StatePaused}, - {name: "stopping", state: StateStopping}, - {name: "error", state: StateError}, - {name: "deleting", state: StateDeleting}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - if err := test.state.Validate(); err != nil { - t.Fatalf("State(%q).Validate() error = %v", test.state, err) - } - parsed, err := ParseState(string(test.state)) - if err != nil { - t.Fatalf("ParseState(%q) error = %v", test.state, err) - } - if parsed != test.state { - t.Fatalf("ParseState(%q) = %q, want %q", test.state, parsed, test.state) - } - }) - } -} - -func TestStateUnknownIsZeroValue(t *testing.T) { - t.Parallel() - - var state State - if state != StateUnknown || !state.IsUnknown() { - t.Fatalf("zero State = %q, want StateUnknown", state) - } -} - -func TestInvalidState(t *testing.T) { - t.Parallel() - - state := State("booting") - if !errors.Is(state.Validate(), ErrInvalidState) { - t.Fatalf("State(booting).Validate() error = %v, want ErrInvalidState", state.Validate()) - } - if _, err := ParseState("RUNNING"); !errors.Is(err, ErrInvalidState) { - t.Fatalf("ParseState(RUNNING) error = %v, want ErrInvalidState", err) - } - if CanTransition(state, StateError) { - t.Fatal("invalid source state can transition") - } - if err := ValidateTransition(StateRunning, state); !errors.Is(err, ErrInvalidState) { - t.Fatalf("ValidateTransition to invalid state error = %v, want ErrInvalidState", err) - } -} - -func TestStateTransitionMatrix(t *testing.T) { - t.Parallel() - - states := []State{ - StateUnknown, - StateCreating, - StateStopped, - StateStarting, - StateRunning, - StatePaused, - StateStopping, - StateError, - StateDeleting, - } - allowed := map[State]map[State]bool{ - StateUnknown: {StateCreating: true}, - StateCreating: {StateStopped: true, StateStarting: true, StateError: true, StateDeleting: true}, - StateStopped: {StateStarting: true, StateDeleting: true, StateError: true}, - StateStarting: {StateRunning: true, StateError: true, StateStopping: true}, - StateRunning: {StatePaused: true, StateStopping: true, StateError: true}, - StatePaused: {StateRunning: true, StateStopping: true, StateError: true}, - StateStopping: {StateStopped: true, StateError: true}, - StateError: {StateStarting: true, StateStopping: true, StateDeleting: true}, - StateDeleting: {StateError: true}, - } - - for _, source := range states { - for _, target := range states { - want := allowed[source][target] - if got := CanTransition(source, target); got != want { - t.Errorf("CanTransition(%q, %q) = %t, want %t", source, target, got, want) - } - - err := ValidateTransition(source, target) - if want && err != nil { - t.Errorf("ValidateTransition(%q, %q) error = %v", source, target, err) - } - if !want && !errors.Is(err, ErrInvalidTransition) { - t.Errorf( - "ValidateTransition(%q, %q) error = %v, want ErrInvalidTransition", - source, - target, - err, - ) - } - } - } -} diff --git a/internal/tenant/doc.go b/internal/tenant/doc.go deleted file mode 100644 index 74dabe1..0000000 --- a/internal/tenant/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package tenant owns tenant identity and authorization-scope primitives. -// Resource admission and quota policy belong to the resource module. -package tenant diff --git a/internal/tenant/id.go b/internal/tenant/id.go deleted file mode 100644 index 2029dc9..0000000 --- a/internal/tenant/id.go +++ /dev/null @@ -1,79 +0,0 @@ -package tenant - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "strings" -) - -const ( - idPrefix = "tenant_" - generatedBytes = 16 - maxIDSuffixLen = 63 -) - -// ErrInvalidID identifies a malformed or zero tenant ID. -var ErrInvalidID = errors.New("invalid tenant ID") - -// ID is the stable identity of a tenant and its authorization scope. -// Its zero value is invalid. -type ID struct { - value string -} - -// NewID creates a tenant ID using cryptographically secure randomness. -func NewID() (ID, error) { - random := make([]byte, generatedBytes) - if _, err := rand.Read(random); err != nil { - return ID{}, fmt.Errorf("generate tenant ID: %w", err) - } - return ID{value: idPrefix + hex.EncodeToString(random)}, nil -} - -// ParseID parses a canonical tenant ID. -func ParseID(value string) (ID, error) { - if err := validateID(value); err != nil { - return ID{}, err - } - return ID{value: value}, nil -} - -// String returns the canonical tenant ID. It returns an empty string for the -// zero value. -func (id ID) String() string { - return id.value -} - -// IsZero reports whether id is the zero value. -func (id ID) IsZero() bool { - return id.value == "" -} - -// Validate checks that id contains a canonical, non-zero tenant ID. -func (id ID) Validate() error { - return validateID(id.value) -} - -func validateID(value string) error { - if !strings.HasPrefix(value, idPrefix) || len(value) <= len(idPrefix) || len(value) > len(idPrefix)+maxIDSuffixLen { - return fmt.Errorf("%w: must use %q followed by 1 to %d characters", ErrInvalidID, idPrefix, maxIDSuffixLen) - } - - suffix := value[len(idPrefix):] - for index, character := range []byte(suffix) { - if isLowerAlphaNumeric(character) { - continue - } - if character == '-' && index > 0 && index < len(suffix)-1 { - continue - } - return fmt.Errorf("%w: %q is not canonical", ErrInvalidID, value) - } - return nil -} - -func isLowerAlphaNumeric(character byte) bool { - return character >= 'a' && character <= 'z' || character >= '0' && character <= '9' -} diff --git a/internal/tenant/id_test.go b/internal/tenant/id_test.go deleted file mode 100644 index 9e2825f..0000000 --- a/internal/tenant/id_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package tenant - -import ( - "errors" - "strings" - "testing" -) - -func TestNewID(t *testing.T) { - t.Parallel() - - id, err := NewID() - if err != nil { - t.Fatalf("NewID() error = %v", err) - } - if err := id.Validate(); err != nil { - t.Fatalf("generated ID is invalid: %v", err) - } - if got, want := len(id.String()), len(idPrefix)+generatedBytes*2; got != want { - t.Fatalf("len(NewID()) = %d, want %d", got, want) - } -} - -func TestParseID(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - value string - wantErr bool - }{ - {name: "generated shape", value: "tenant_0123456789abcdef0123456789abcdef"}, - {name: "external name", value: "tenant_control-plane-1"}, - {name: "zero", value: "", wantErr: true}, - {name: "missing prefix", value: "control-plane", wantErr: true}, - {name: "empty suffix", value: "tenant_", wantErr: true}, - {name: "uppercase", value: "tenant_Control", wantErr: true}, - {name: "leading hyphen", value: "tenant_-control", wantErr: true}, - {name: "trailing hyphen", value: "tenant_control-", wantErr: true}, - {name: "separator", value: "tenant_control/plane", wantErr: true}, - {name: "too long", value: "tenant_" + strings.Repeat("a", maxIDSuffixLen+1), wantErr: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - id, err := ParseID(test.value) - if test.wantErr { - if !errors.Is(err, ErrInvalidID) { - t.Fatalf("ParseID(%q) error = %v, want ErrInvalidID", test.value, err) - } - return - } - if err != nil { - t.Fatalf("ParseID(%q) error = %v", test.value, err) - } - if got := id.String(); got != test.value { - t.Fatalf("ParseID(%q).String() = %q", test.value, got) - } - if err := id.Validate(); err != nil { - t.Fatalf("parsed ID is invalid: %v", err) - } - }) - } -} - -func TestZeroID(t *testing.T) { - t.Parallel() - - var id ID - if !id.IsZero() { - t.Fatal("zero ID is not reported as zero") - } - if !errors.Is(id.Validate(), ErrInvalidID) { - t.Fatalf("zero ID validation error = %v, want ErrInvalidID", id.Validate()) - } -} From 04686b4aaa6ff12cfbb89db61293718a36a6ff45 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Thu, 10 Sep 2026 09:56:54 +0800 Subject: [PATCH 08/45] feat(doctor): report whether this machine can run KumaBox Cocoon-shaped layout: main.go at the root, cmd/ with a cobra command tree (cmd/doctor), and flat capability packages at the top level (config/, layout/, host/, version/) instead of an internal/ tree of abstract packages. doctor judges each requirement against the phase being worked on, so KVM, Cloud Hypervisor and CNI show as not-required on a laptop while the root and free-space checks decide the exit code. --fix only creates KumaBox-owned directories; installing packages stays in the install script (DEC-014). Tests run on macOS with no root and no KVM. --- .github/workflows/ci.yml | 7 + AGENTS.md | 36 ++-- Makefile | 16 +- README.md | 58 ++++-- cmd/commands.go | 71 +++++++ cmd/commands_test.go | 104 +++++++++++ cmd/doctor/commands.go | 42 +++++ cmd/doctor/handler.go | 96 ++++++++++ cmd/doctor/handler_test.go | 149 +++++++++++++++ cmd/errors.go | 67 +++++++ config/config.go | 45 +++++ config/config_test.go | 73 ++++++++ go.mod | 7 + go.sum | 10 + host/collect.go | 135 ++++++++++++++ host/collect_darwin.go | 14 ++ host/collect_linux.go | 17 ++ host/collect_other.go | 7 + host/collect_test.go | 106 +++++++++++ host/facts.go | 372 +++++++++++++++++++++++++++++++++++++ host/facts_test.go | 261 ++++++++++++++++++++++++++ layout/layout.go | 173 +++++++++++++++++ layout/layout_test.go | 179 ++++++++++++++++++ layout/statfs_bsd.go | 14 ++ layout/statfs_linux.go | 14 ++ main.go | 17 ++ version/version.go | 25 +++ 27 files changed, 2078 insertions(+), 37 deletions(-) create mode 100644 cmd/commands.go create mode 100644 cmd/commands_test.go create mode 100644 cmd/doctor/commands.go create mode 100644 cmd/doctor/handler.go create mode 100644 cmd/doctor/handler_test.go create mode 100644 cmd/errors.go create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 go.sum create mode 100644 host/collect.go create mode 100644 host/collect_darwin.go create mode 100644 host/collect_linux.go create mode 100644 host/collect_other.go create mode 100644 host/collect_test.go create mode 100644 host/facts.go create mode 100644 host/facts_test.go create mode 100644 layout/layout.go create mode 100644 layout/layout_test.go create mode 100644 layout/statfs_bsd.go create mode 100644 layout/statfs_linux.go create mode 100644 main.go create mode 100644 version/version.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3adc0a5..8e863e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,10 @@ concurrency: cancel-in-progress: true jobs: + # 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 per docs/ROADMAP.md; see that file for the runbooks. verify: runs-on: ubuntu-latest steps: @@ -28,6 +32,9 @@ jobs: - name: Verify run: make verify + - name: Race detector + run: make race + - name: Check module files run: | go mod tidy diff --git a/AGENTS.md b/AGENTS.md index 484205d..2961fa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,31 @@ -# KumaBox P12 rewrite rules +# KumaBox working rules ## Project and baseline -- This remains the KumaBox project. P12 rewrites KumaBox's core logic; it does not create a new product or fork Cocoon. -- Preserve `pre-p12-rewrite-20260909`, which points to `08bc8d7545492da96b5c0fc984afc549b4497af1`. Do not move or delete the tag. -- Use Cocoon at `../cocoon@27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` to align lifecycle design, ordering, commit boundaries, and failure recovery. Do not copy its package structure or treat it as KumaBox source code. -- Read `docs/implementation/14-large-scale-agent-cross-node.md` and `docs/implementation/15-p12-core-rewrite-plan-and-progress.md` before planning P12 work. +- This remains the KumaBox project. The core logic is being rewritten; it does not create a second product and does not fork Cocoon. +- Preserve the tag `pre-p12-rewrite-20260909` (`08bc8d7545492da96b5c0fc984afc549b4497af1`). Never move or delete it. The pre-rewrite tree is a **read-only reference**: read it to confirm behaviour, ordering and failure semantics; never copy its code, types, schema or tests. +- Cocoon at `../cocoon@27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` is the capability floor and behavioural reference. Match its lifecycle ordering, commit boundaries and failure recovery; do not copy its package structure, god packages, hook-bag control flow or dual metadata backends. +- Read these before doing any work, in this order: `docs/PRODUCT.md`, `docs/ARCHITECTURE.md`, `docs/BEHAVIOR.md`, `docs/HOST.md`, `docs/PERFORMANCE.md`, `docs/ROADMAP.md`, `docs/DECISIONS.md`. +- The design documents previously numbered 14–19 under `docs/implementation/` were written by ChatGPT/Codex, are deleted, and must not be recreated or referenced (DEC-011). `docs/implementation/00`–`13` are history only, never a specification. -## Mandatory feature approval gate +## Hard rules -Before starting any major feature, present all four items to the project owner: +1. **Zero code reuse.** Every implementation is written from the specifications. The old tree is reference material only (DEC-003). +2. **Layer direction is enforced by tooling, not by a bespoke test suite.** `ARCHITECTURE.md` §12.1 maps each rule to a standard linter (`depguard` for the layer matrix, `gochecknoinits`, `forbidigo`, `revive`) configured in `.golangci.yml`, which lands with the first real packages in S1. Do not add a hand-written architecture test package: rules live in the spec, enforcement lives in linter config. +3. **Naming and style follow `ARCHITECTURE.md` §13.** No `utils`/`helpers`/`common`/`Manager`/`Service`/`Impl` naming, no `init()`, no package-level mutable state, no `_ =` error discard. +4. **Tests must pass locally before a phase is called done.** `make verify` and `make race` must be green on macOS with no root and no KVM. Anything needing a real Cloud Hypervisor, CNI or KVM host is verified manually by the project owner, so every phase must ship a copy-pasteable runbook with expected output (DEC-009). +5. **Performance claims need the protocol.** Use `PERFORMANCE.md` §2: same machine, same parameters, N ≥ 30, P50/P95/P99, raw data archived, differences judged with `benchstat`, and every claimed win traceable to one code mechanism (DEC-008). +6. **Do not weaken safety for speed.** Never trade away digests, path boundaries, references, leases, dirty markers or durability. -1. Logic: inputs, outputs, preconditions, state transitions, operation order, failures, cancellation, retry, crash recovery, idempotency, and Cocoon parity. -2. Design: domain boundaries, data ownership, transactions, commit points, locks/leases, external side effects, reconciliation, alternatives, and tradeoffs. -3. Code abstraction: core types and invariants, consumer-owned interfaces, concrete implementations, errors, configuration, and test seams. -4. Project layout: directories and files to add/remove, package responsibilities, import direction, forbidden dependencies, and final legacy deletion scope. +## Approval gate -Do not create or modify Go, proto, SQL, test, script, generated, or other implementation code until the project owner explicitly approves that feature's four-part proposal. Reading, analysis, diagrams, design documents, and progress-ledger updates are allowed before approval. +A **phase** from `ROADMAP.md` (S1 image vertical, S2 sandbox vertical, S3 network, S4 snapshot/restore, S5 clone, S6 convergence, S7 cross-node, S8 production gates) is the unit of approval. The current phase proposal lives in `docs/proposals/`. Before writing implementation code for a phase, present all four items to the project owner and get an explicit answer: -Each P12 Epic, each create/start/snapshot/clone/restore/delete/reconcile vertical path, and every change to the domain model, schema, operation state machine, public API, package boundary, or concurrency model is a major feature. Overall plan approval does not approve individual features. Material deviation from an approved proposal invalidates the approval; stop coding and request approval again. +1. **Logic** — what the phase makes work end to end; inputs, outputs, preconditions, state transitions, step order, cancellation, retry, crash recovery, idempotency, and the Cocoon behaviour it matches or deliberately improves. +2. **Design** — which module owns which durable fact, transaction and commit boundaries, locks and leases, external side effects, reconciliation, alternatives considered and why they were rejected. +3. **Code abstraction** — core types and invariants, the consumer-owned ports (1–3 methods, each with an in-package fake), concrete implementations, errors, configuration, and test seams. +4. **Layout** — directories and files added or removed, package responsibilities, import direction, forbidden dependencies, and what gets deleted at the end of the phase. + +Reading, analysis, diagrams, design documents and roadmap updates are allowed before approval. Do not create or modify Go, proto, SQL, test, script or generated code until the phase is approved. + +Scope changes invalidate the approval: if implementation needs a different domain model, schema, operation state machine, public API, package boundary or concurrency model than the approved proposal, stop and ask again. diff --git a/Makefile b/Makefile index 59cd3cb..5c79c3d 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,25 @@ GO ?= go -.PHONY: build fmt-check test vet verify +# Packages in this module. Empty until the first phase lands code. +PKGS := $(shell $(GO) list ./... 2>/dev/null) + +.PHONY: build fmt-check test race vet verify build: - $(GO) build ./... + @if [ -n "$(PKGS)" ]; then $(GO) build ./...; else echo "no packages yet"; fi fmt-check: @test -z "$$(gofmt -l .)" || (gofmt -l . && exit 1) test: - $(GO) test ./... + @if [ -n "$(PKGS)" ]; then $(GO) test ./...; else echo "no packages yet"; fi + +# race is required for any change touching concurrency, workers, streams or +# reconciliation (docs/ARCHITECTURE.md §9). +race: + @if [ -n "$(PKGS)" ]; then $(GO) test -race ./...; else echo "no packages yet"; fi vet: - $(GO) vet ./... + @if [ -n "$(PKGS)" ]; then $(GO) vet ./...; else echo "no packages yet"; fi verify: fmt-check vet test build diff --git a/README.md b/README.md index 6eb659e..91e97ed 100644 --- a/README.md +++ b/README.md @@ -4,36 +4,54 @@ # KumaBox -KumaBox is being rebuilt as a modular infrastructure project for high-density -agent sandboxes. +A microVM sandbox runtime for AI agents. One node runs one daemon +(`kumaboxd`) that owns all state, plus a thin client (`kumabox`) with a +Docker-like command line; sandboxes are Cloud Hypervisor microVMs booted from +OCI images, with CNI networking, cgroups, snapshots and clone. -The rewrite follows Cocoon's core lifecycle semantics—such as create, snapshot, -clone, and restore—while keeping KumaBox's architecture and implementation -independent. The current branch is not a usable release until those capabilities -are reintroduced through the approved P12 milestones. +The core logic is being rewritten from scratch. **The current branch has no +product code yet** — it contains the specifications and the architecture gate +only. That is deliberate: each phase of `docs/ROADMAP.md` starts with a +four-part proposal, and no implementation code is written before it is +approved. -## Rewrite rules +## Where the design lives -- Infrastructure capability modules are the primary architectural boundary. -- Lifecycle behavior is specified and compared with Cocoon before implementation. -- Each major capability requires an approved logic, design, abstraction, and - directory proposal before code is written. -- Tests and architecture checks are delivered with each capability. +Read these in order. They are the only specifications; anything else under +`docs/` is history. -Local design and progress records live under `docs/` and are intentionally not -tracked by Git. +| Document | Answers | +|---|---| +| [docs/PRODUCT.md](docs/PRODUCT.md) | What this is, who uses it, what v1 must do, what it will not do, how it relates to Cocoon, shared vocabulary | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Layering and the import matrix, fact ownership, the execution model, transactions and locks, cross-cutting contracts, testing tiers, naming and code style | +| [docs/BEHAVIOR.md](docs/BEHAVIOR.md) | What happens on the machine when a command runs, and what is left behind when it fails | +| [docs/PERFORMANCE.md](docs/PERFORMANCE.md) | How performance is measured, how it is compared against Cocoon, and which scenarios must match or beat it | +| [docs/ROADMAP.md](docs/ROADMAP.md) | What each phase does, why, how, and the evidence that closes it | +| [docs/DECISIONS.md](docs/DECISIONS.md) | Decisions taken, why the previous design was discarded, what is still open | -## Development +Documentation is intentionally not tracked by Git (see `.gitignore`), so these +files live only in the working tree — keep local backups. + +## Working on it ```bash -make verify +make verify # formatting, vet, tests, build +make race # race detector, required for concurrency changes ``` -## Recovery +`make verify` must stay green on macOS with no root and no KVM. Real microVM +behaviour (Cloud Hypervisor, CNI, KVM) is verified manually on a Linux host +using the runbook attached to each phase. + +## Reference material -The pre-rewrite source is recoverable from the protected Git tag -`pre-p12-rewrite-20260909`. A verified local source archive is also stored under -`.rewrite-backup/` in the rewrite workspace. +- Cocoon at `../cocoon@27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` is the + capability floor: match its lifecycle ordering and failure recovery, never + copy its package structure or its dual metadata backends. +- The pre-rewrite KumaBox source is available read-only from the protected tag + `pre-p12-rewrite-20260909` (and as an archive under `.rewrite-backup/`). It is + reference material for behaviour only; no code, types, schema or tests are + reused from it. ## License diff --git a/cmd/commands.go b/cmd/commands.go new file mode 100644 index 0000000..c03790a --- /dev/null +++ b/cmd/commands.go @@ -0,0 +1,71 @@ +// Package cmd defines the kumabox command tree. +// +// This package owns flags, help and exit codes only: every command converts its +// flags into a typed request, calls a library, and renders the result. No +// lifecycle step order lives here (docs/ARCHITECTURE.md §1). +package cmd + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/cmd/doctor" + "github.com/kumabox/kumabox/version" +) + +// Execute runs one invocation and returns the process exit code. +func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int { + root := newRootCommand() + root.SetArgs(args) + root.SetOut(stdout) + root.SetErr(stderr) + + if err := root.ExecuteContext(ctx); err != nil { + return fail(stderr, err) + } + return exitOK +} + +func newRootCommand() *cobra.Command { + root := &cobra.Command{ + Use: "kumabox", + Short: "microVM sandboxes for AI agents", + Long: "kumabox runs microVM sandboxes on this machine.\n\n" + + "Every command opens the node root, does one job and exits; there is no\n" + + "daemon in this version. Use --root to point at a different root, for\n" + + "example while developing.", + SilenceUsage: true, + SilenceErrors: true, + } + root.PersistentFlags().String("root", "", + "node root directory (default $KUMABOX_ROOT, then /var/lib/kumabox)") + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return fmt.Errorf("%w: %w", ErrUsage, err) + }) + + root.AddCommand(doctor.NewCommand()) + root.AddCommand(newVersionCommand()) + return root +} + +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 writeJSON(command.OutOrStdout(), version.Info()) + } + fmt.Fprintln(command.OutOrStdout(), version.String()) + return nil + }, + } + command.Flags().BoolVar(&asJSON, "json", false, "print the version as JSON") + return command +} diff --git a/cmd/commands_test.go b/cmd/commands_test.go new file mode 100644 index 0000000..6d52d2b --- /dev/null +++ b/cmd/commands_test.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +func execute(t *testing.T, args ...string) (int, string, string) { + t.Helper() + + var stdout, stderr bytes.Buffer + code := Execute(context.Background(), args, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +func TestDoctorFailsUntilTheRootExists(t *testing.T) { + t.Parallel() + + root := filepath.Join(t.TempDir(), "kb") + code, _, stderr := execute(t, "doctor", "--root", root) + if code != 6 { + t.Errorf("exit code = %d, want 6\nstderr: %s", code, stderr) + } + if !strings.Contains(stderr, "HOST_NOT_READY") { + t.Errorf("stderr must carry the stable code:\n%s", stderr) + } +} + +func TestDoctorFixThenJSON(t *testing.T) { + t.Parallel() + + root := filepath.Join(t.TempDir(), "kb") + if code, _, stderr := execute(t, "doctor", "--fix", "--root", root); code != 0 { + t.Fatalf("doctor --fix exit code = %d, want 0\nstderr: %s", code, stderr) + } + + code, stdout, stderr := execute(t, "doctor", "--json", "--root", root) + if code != 0 { + t.Fatalf("doctor --json exit code = %d, want 0\nstderr: %s", code, stderr) + } + var report map[string]any + if err := json.Unmarshal([]byte(stdout), &report); err != nil { + t.Fatalf("--json did not produce valid JSON: %v\n%s", err, stdout) + } + if report["phase"] != "S1" { + t.Errorf("phase = %v, want S1", report["phase"]) + } +} + +func TestInvalidRootIsAnInvalidArgument(t *testing.T) { + t.Parallel() + + code, _, stderr := execute(t, "doctor", "--root", "relative/path") + if code != 5 { + t.Errorf("exit code = %d, want 5\nstderr: %s", code, stderr) + } + if !strings.Contains(stderr, "ROOT_NOT_ABSOLUTE") { + t.Errorf("stderr must carry the stable code:\n%s", stderr) + } +} + +func TestUnknownCommandIsAUsageError(t *testing.T) { + t.Parallel() + + code, _, stderr := execute(t, "frobnicate") + if code != 5 { + t.Errorf("exit code = %d, want 5", code) + } + if !strings.Contains(stderr, "code: USAGE") { + t.Errorf("stderr must classify the failure as usage:\n%s", stderr) + } +} + +func TestUnknownFlagIsAUsageError(t *testing.T) { + t.Parallel() + + code, _, stderr := execute(t, "doctor", "--nope") + if code != 5 { + t.Errorf("exit code = %d, want 5", code) + } + if !strings.Contains(stderr, "code: USAGE") { + t.Errorf("stderr must classify the failure as usage:\n%s", stderr) + } +} + +func TestVersion(t *testing.T) { + t.Parallel() + + code, stdout, stderr := execute(t, "version", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr) + } + var payload map[string]string + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("version --json is not valid JSON: %v\n%s", err, stdout) + } + if payload["version"] == "" { + t.Error("version payload is empty") + } +} diff --git a/cmd/doctor/commands.go b/cmd/doctor/commands.go new file mode 100644 index 0000000..9069fa6 --- /dev/null +++ b/cmd/doctor/commands.go @@ -0,0 +1,42 @@ +// Package doctor implements "kumabox doctor". +package doctor + +import ( + "github.com/spf13/cobra" +) + +// NewCommand builds the doctor command. +func NewCommand() *cobra.Command { + var options Options + + command := &cobra.Command{ + Use: "doctor", + Short: "check whether this machine can run KumaBox", + Long: "doctor inspects this machine and reports, check by check, whether it is\n" + + "ready for the phase being worked on.\n\n" + + "A check that belongs to a later phase is reported as not-required and never\n" + + "fails the command. --fix only creates or repairs KumaBox-owned directories;\n" + + "installing packages, sysctl and firewall rules belong to hack/host-install.sh.", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + rootFlag, err := command.Flags().GetString("root") + if err != nil { + return err + } + options.Root = rootFlag + + handler := Handler{} + report, err := handler.Doctor(command.Context(), options) + if err != nil { + return err + } + if err := handler.Render(command.OutOrStdout(), report, options.JSON); err != nil { + return err + } + return handler.NotReady(report) + }, + } + command.Flags().BoolVar(&options.JSON, "json", false, "print the report as JSON") + command.Flags().BoolVar(&options.Fix, "fix", false, "create or repair KumaBox-owned directories") + return command +} diff --git a/cmd/doctor/handler.go b/cmd/doctor/handler.go new file mode 100644 index 0000000..a3658be --- /dev/null +++ b/cmd/doctor/handler.go @@ -0,0 +1,96 @@ +package doctor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "text/tabwriter" + + "github.com/kumabox/kumabox/config" + "github.com/kumabox/kumabox/host" + "github.com/kumabox/kumabox/layout" +) + +// phase is the phase doctor judges against. It moves forward as phases land; it +// is not a flag, because "is this machine ready for what we are building" is a +// property of the build, not of the caller. +const phase = host.PhaseImage + +// Options carries the doctor flags. +type Options struct { + Root string + JSON bool + Fix bool +} + +// Handler runs the doctor command. +type Handler struct{} + +// Doctor inspects the machine and returns the report. +func (Handler) Doctor(ctx context.Context, options Options) (host.Report, error) { + cfg, err := config.Load(options.Root) + if err != nil { + return host.Report{}, err + } + root, err := layout.New(cfg.Root) + if err != nil { + return host.Report{}, err + } + + // --fix is the only thing doctor may change, and it stays inside paths + // KumaBox owns. + if options.Fix { + if err := root.Prepare(); err != nil { + return host.Report{}, err + } + } + + facts, err := host.Collector{}.Collect(ctx, root) + if err != nil { + return host.Report{}, err + } + return host.Evaluate(facts, phase), nil +} + +// NotReady returns an error when the report contains an unmet requirement of +// the current phase. +func (Handler) NotReady(report host.Report) error { + if !report.Failed() { + return nil + } + return fmt.Errorf("%w: not ready for phase %s", host.ErrNotReady, report.Phase) +} + +// Render writes the report to out. +func (Handler) Render(out io.Writer, report host.Report, asJSON bool) error { + if asJSON { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + return encoder.Encode(report) + } + + fmt.Fprintf(out, "kumabox doctor - machine readiness for phase %s\n\n", report.Phase) + fmt.Fprintf(out, "host %s/%s", report.OS, report.Arch) + if report.Kernel != "" { + fmt.Fprintf(out, " kernel %s", report.Kernel) + } + fmt.Fprintf(out, "\nroot %s\n\n", report.Root) + + table := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(table, "CHECK\tSTATE\tSINCE\tDETAIL") + for _, check := range report.Checks { + fmt.Fprintf(table, "%s\t%s\t%s\t%s\n", check.Name, check.State, check.Since, check.Detail) + } + if err := table.Flush(); err != nil { + return err + } + + for _, check := range report.Checks { + switch check.State { + case host.StateMissing, host.StateUnsupported: + fmt.Fprintf(out, "\n%s: %s\n fix: %s\n", check.Name, check.Detail, check.Fix) + } + } + return nil +} diff --git a/cmd/doctor/handler_test.go b/cmd/doctor/handler_test.go new file mode 100644 index 0000000..bfc4aaf --- /dev/null +++ b/cmd/doctor/handler_test.go @@ -0,0 +1,149 @@ +package doctor + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kumabox/kumabox/host" +) + +func TestDoctorReportsAMissingRoot(t *testing.T) { + t.Parallel() + + root := filepath.Join(t.TempDir(), "kb") + report, err := Handler{}.Doctor(context.Background(), Options{Root: root}) + if err != nil { + t.Fatalf("Doctor: %v", err) + } + if !report.Failed() { + t.Fatal("a missing root must fail the phase being checked") + } + // The root is canonicalised, so compare against the resolved path rather + // than the platform's spelling of the temp directory. + resolved, err := filepath.EvalSymlinks(filepath.Dir(root)) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + if want := filepath.Join(resolved, "kb"); report.Root != want { + t.Errorf("report root = %q, want %q", report.Root, want) + } + if state := stateOf(t, report, "root-directory"); state != host.StateMissing { + t.Errorf("root-directory state = %s, want missing", state) + } + if err := (Handler{}).NotReady(report); err == nil { + t.Error("NotReady must report an error for a failed report") + } +} + +func TestDoctorFixCreatesTheRootAndThenPasses(t *testing.T) { + t.Parallel() + + root := filepath.Join(t.TempDir(), "kb") + handler := Handler{} + + if _, err := handler.Doctor(context.Background(), Options{Root: root, Fix: true}); err != nil { + t.Fatalf("Doctor --fix: %v", err) + } + for _, dir := range []string{"metadata", "blobs", "tmp"} { + if _, err := os.Stat(filepath.Join(root, dir)); err != nil { + t.Errorf("--fix did not create %s: %v", dir, err) + } + } + + report, err := handler.Doctor(context.Background(), Options{Root: root}) + if err != nil { + t.Fatalf("Doctor: %v", err) + } + if report.Failed() { + t.Errorf("a repaired root must pass phase %s: %+v", report.Phase, report.Checks) + } + if err := handler.NotReady(report); err != nil { + t.Errorf("NotReady = %v, want nil", err) + } +} + +func TestDoctorRejectsARelativeRoot(t *testing.T) { + t.Parallel() + + if _, err := (Handler{}).Doctor(context.Background(), Options{Root: "relative/path"}); err == nil { + t.Fatal("Doctor must reject a relative root") + } +} + +func TestRenderJSONIsStableAndReadable(t *testing.T) { + t.Parallel() + + root := filepath.Join(t.TempDir(), "kb") + report, err := Handler{}.Doctor(context.Background(), Options{Root: root, Fix: true}) + if err != nil { + t.Fatalf("Doctor: %v", err) + } + + var buffer bytes.Buffer + if err := (Handler{}).Render(&buffer, report, true); err != nil { + t.Fatalf("Render: %v", err) + } + + var decoded struct { + Phase string `json:"phase"` + OS string `json:"os"` + Root string `json:"root"` + Checks []struct { + Name string `json:"name"` + State string `json:"state"` + Since string `json:"since"` + } `json:"checks"` + } + if err := json.Unmarshal(buffer.Bytes(), &decoded); err != nil { + t.Fatalf("Render --json produced invalid JSON: %v\n%s", err, buffer.String()) + } + if decoded.Phase != "S1" { + t.Errorf("phase = %q, want S1 as text", decoded.Phase) + } + if decoded.Root != report.Root { + t.Errorf("root = %q, want %q", decoded.Root, report.Root) + } + if len(decoded.Checks) == 0 { + t.Fatal("the report must contain checks") + } + if decoded.Checks[0].State == "" || decoded.Checks[0].Since == "" { + t.Errorf("check states and phases must be text, got %+v", decoded.Checks[0]) + } +} + +func TestRenderTextListsFixes(t *testing.T) { + t.Parallel() + + root := filepath.Join(t.TempDir(), "kb") + report, err := Handler{}.Doctor(context.Background(), Options{Root: root}) + if err != nil { + t.Fatalf("Doctor: %v", err) + } + + var buffer bytes.Buffer + if err := (Handler{}).Render(&buffer, report, false); err != nil { + t.Fatalf("Render: %v", err) + } + text := buffer.String() + for _, want := range []string{"CHECK", "root-directory", "fix:", "--fix"} { + if !strings.Contains(text, want) { + t.Errorf("text report does not mention %q:\n%s", want, text) + } + } +} + +func stateOf(t *testing.T, report host.Report, name string) host.State { + t.Helper() + for _, check := range report.Checks { + if check.Name == name { + return check.State + } + } + t.Fatalf("report has no check %q", name) + return host.StateOK +} diff --git a/cmd/errors.go b/cmd/errors.go new file mode 100644 index 0000000..50f23e1 --- /dev/null +++ b/cmd/errors.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/kumabox/kumabox/config" + "github.com/kumabox/kumabox/host" + "github.com/kumabox/kumabox/layout" +) + +// Exit codes are documented in docs/BEHAVIOR.md §17. +const ( + exitOK = 0 + exitInternal = 1 + exitInvalid = 5 + exitUnavail = 6 +) + +// ErrUsage marks a failure of the command line itself: an unknown command, an +// unknown flag, or a bad argument. +var ErrUsage = errors.New("usage") + +// fail prints a failure the way the CLI contract requires and returns the code +// the process should exit with. +func fail(stderr io.Writer, err error) int { + fmt.Fprintf(stderr, "kumabox: %v\n", err) + code, exit := classify(err) + fmt.Fprintf(stderr, "code: %s\n", code) + return exit +} + +// classify maps a library error to its stable code and exit code. +// +// Library code returns sentinel errors wrapped with context; the mapping lives +// here, at the edge, so no library has to know about exit codes. Anything that +// reaches here without a known sentinel came from the command line itself, +// because every handler wraps its failures. +func classify(err error) (code string, exit int) { + switch { + case errors.Is(err, ErrUsage): + return "USAGE", exitInvalid + case errors.Is(err, config.ErrRootNotAbsolute), errors.Is(err, layout.ErrRootNotAbsolute): + return "ROOT_NOT_ABSOLUTE", exitInvalid + case errors.Is(err, layout.ErrRootEmpty): + return "ROOT_EMPTY", exitInvalid + case errors.Is(err, layout.ErrRootNotDirectory): + return "ROOT_NOT_DIRECTORY", exitInvalid + case errors.Is(err, layout.ErrRootNotWritable): + return "ROOT_NOT_WRITABLE", exitUnavail + case errors.Is(err, layout.ErrStatFailed): + return "ROOT_STAT_FAILED", exitInternal + case errors.Is(err, host.ErrNotReady): + return "HOST_NOT_READY", exitUnavail + default: + return "USAGE", exitInvalid + } +} + +// writeJSON renders a command result as JSON on out. +func writeJSON(out io.Writer, value any) error { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..96b68fc --- /dev/null +++ b/config/config.go @@ -0,0 +1,45 @@ +// Package config resolves KumaBox settings. +// +// It is the only package that reads the environment (docs/ARCHITECTURE.md +// §12.1). Precedence is flag, then environment, then default. A configuration +// file joins this package once there is more than one setting worth putting in +// it. +package config + +import ( + "errors" + "os" + "path/filepath" +) + +const ( + // EnvRoot overrides the node root directory. + EnvRoot = "KUMABOX_ROOT" + // DefaultRoot is the system-wide node root used by a package install. + DefaultRoot = "/var/lib/kumabox" +) + +// ErrRootNotAbsolute reports a root that is not an absolute path. +var ErrRootNotAbsolute = errors.New("root must be an absolute path") + +// Config holds the resolved settings for one invocation. +type Config struct { + // Root is the absolute node root directory. + Root string +} + +// Load resolves the configuration. flagRoot is the value of --root, or an +// empty string when the flag was not given. +func Load(flagRoot string) (Config, error) { + root := flagRoot + if root == "" { + root = os.Getenv(EnvRoot) + } + if root == "" { + root = DefaultRoot + } + if !filepath.IsAbs(root) { + return Config{}, ErrRootNotAbsolute + } + return Config{Root: filepath.Clean(root)}, nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..6a9ff61 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,73 @@ +package config + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestLoadPrecedence(t *testing.T) { + temp := t.TempDir() + fromEnv := filepath.Join(temp, "from-env") + fromFlag := filepath.Join(temp, "from-flag") + + t.Run("flag wins over environment and default", func(t *testing.T) { + t.Setenv(EnvRoot, fromEnv) + cfg, err := Load(fromFlag) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Root != fromFlag { + t.Errorf("Root = %q, want the flag value %q", cfg.Root, fromFlag) + } + }) + + t.Run("environment wins over default", func(t *testing.T) { + t.Setenv(EnvRoot, fromEnv) + cfg, err := Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Root != fromEnv { + t.Errorf("Root = %q, want the environment value %q", cfg.Root, fromEnv) + } + }) + + t.Run("default is used when nothing is set", func(t *testing.T) { + t.Setenv(EnvRoot, "") + cfg, err := Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Root != DefaultRoot { + t.Errorf("Root = %q, want the default %q", cfg.Root, DefaultRoot) + } + }) +} + +func TestLoadRejectsRelativeRoots(t *testing.T) { + t.Run("from the flag", func(t *testing.T) { + t.Setenv(EnvRoot, "") + if _, err := Load("kb"); !errors.Is(err, ErrRootNotAbsolute) { + t.Fatalf("err = %v, want ErrRootNotAbsolute", err) + } + }) + + t.Run("from the environment", func(t *testing.T) { + t.Setenv(EnvRoot, "relative/root") + if _, err := Load(""); !errors.Is(err, ErrRootNotAbsolute) { + t.Fatalf("err = %v, want ErrRootNotAbsolute", err) + } + }) +} + +func TestLoadCleansThePath(t *testing.T) { + t.Setenv(EnvRoot, "") + cfg, err := Load("/var/lib/kumabox/") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Root != "/var/lib/kumabox" { + t.Errorf("Root = %q, want the cleaned path", cfg.Root) + } +} diff --git a/go.mod b/go.mod index 76b7179..810f8ae 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module github.com/kumabox/kumabox go 1.24.4 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/host/collect.go b/host/collect.go new file mode 100644 index 0000000..552332a --- /dev/null +++ b/host/collect.go @@ -0,0 +1,135 @@ +package host + +import ( + "context" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/kumabox/kumabox/layout" +) + +const ( + // KVMPath is the device a microVM needs. + KVMPath = "/dev/kvm" + // DefaultCNIPluginDir is where CNI plugins are installed. + DefaultCNIPluginDir = "/opt/cni/bin" + // DefaultCNIConfigDir is where CNI network configuration lives. + DefaultCNIConfigDir = "/etc/cni/net.d" + // cgroupV2Marker exists only when the unified cgroup hierarchy is mounted. + cgroupV2Marker = "/sys/fs/cgroup/cgroup.controllers" + + // binaryTimeout bounds the version probe so doctor can never hang. + binaryTimeout = 5 * time.Second +) + +// Collector gathers facts about the machine. +// +// It only ever reads: no directory is created, no privileged operation is +// performed. Everything that needs root belongs to hack/host-install.sh +// (docs/HOST.md §4). +type Collector struct { + // CNIPluginDir and CNIConfigDir are overridable for tests. + CNIPluginDir string + CNIConfigDir string +} + +// Collect reports what this machine looks like right now. +func (c Collector) Collect(ctx context.Context, root layout.Root) (Facts, error) { + facts := Facts{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Kernel: kernelVersion(), + KVM: KVM{Device: KVMPath}, + VMM: Binary{Name: "cloud-hypervisor"}, + CNI: CNI{PluginBinDir: c.pluginDir(), ConfigDir: c.configDir()}, + CgroupV2: exists(cgroupV2Marker), + } + + state, err := root.Inspect() + if err != nil { + return Facts{}, err + } + facts.Root = Root{ + Path: root.Dir(), + Exists: state.Exists, + Writable: state.Writable, + TotalBytes: state.TotalBytes, + FreeBytes: state.FreeBytes, + } + + facts.KVM.Present, facts.KVM.Readable = kvmState() + facts.VMM = binaryFacts(ctx, facts.VMM.Name) + facts.CNI.PluginsFound = hasEntries(facts.CNI.PluginBinDir) + facts.CNI.ConfigFound = hasEntries(facts.CNI.ConfigDir) + return facts, nil +} + +func (c Collector) pluginDir() string { + if c.CNIPluginDir != "" { + return c.CNIPluginDir + } + return DefaultCNIPluginDir +} + +func (c Collector) configDir() string { + if c.CNIConfigDir != "" { + return c.CNIConfigDir + } + return DefaultCNIConfigDir +} + +// kvmState reports whether the device exists and can be opened for read-write, +// which is what the runtime will need. +func kvmState() (present, readable bool) { + info, err := os.Stat(KVMPath) + if err != nil || info.IsDir() { + return false, false + } + file, err := os.OpenFile(KVMPath, os.O_RDWR, 0) + if err != nil { + return true, false + } + _ = file.Close() + return true, true +} + +// binaryFacts looks up a tool and asks it for its version. +func binaryFacts(ctx context.Context, name string) Binary { + binary := Binary{Name: name} + path, err := exec.LookPath(name) + if err != nil { + return binary + } + binary.Path = path + binary.Found = true + + probeCtx, cancel := context.WithTimeout(ctx, binaryTimeout) + defer cancel() + output, err := exec.CommandContext(probeCtx, path, "--version").Output() + if err != nil { + return binary + } + binary.Version = firstLine(string(output)) + return binary +} + +func firstLine(text string) string { + if index := strings.IndexByte(text, '\n'); index >= 0 { + text = text[:index] + } + return strings.TrimSpace(text) +} + +// hasEntries reports whether dir exists and contains at least one entry. +func hasEntries(dir string) bool { + entries, err := os.ReadDir(dir) + return err == nil && len(entries) > 0 +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/host/collect_darwin.go b/host/collect_darwin.go new file mode 100644 index 0000000..488215b --- /dev/null +++ b/host/collect_darwin.go @@ -0,0 +1,14 @@ +//go:build darwin + +package host + +import "syscall" + +// kernelVersion reports the running XNU version. +func kernelVersion() string { + version, err := syscall.Sysctl("kern.osrelease") + if err != nil { + return "" + } + return version +} diff --git a/host/collect_linux.go b/host/collect_linux.go new file mode 100644 index 0000000..04b4043 --- /dev/null +++ b/host/collect_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package host + +import ( + "os" + "strings" +) + +// kernelVersion reads the running kernel release from /proc. +func kernelVersion() string { + data, err := os.ReadFile("/proc/sys/kernel/osrelease") + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} diff --git a/host/collect_other.go b/host/collect_other.go new file mode 100644 index 0000000..7e01ca7 --- /dev/null +++ b/host/collect_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package host + +// kernelVersion is unavailable on this platform. Every Linux-only requirement +// is already reported as unsupported here. +func kernelVersion() string { return "" } diff --git a/host/collect_test.go b/host/collect_test.go new file mode 100644 index 0000000..3cc6647 --- /dev/null +++ b/host/collect_test.go @@ -0,0 +1,106 @@ +package host + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/kumabox/kumabox/layout" +) + +func TestCollectReportsHostAndRoot(t *testing.T) { + t.Parallel() + + root := tempRoot(t) + cniPlugins := t.TempDir() + cniConfig := t.TempDir() + if err := os.WriteFile(filepath.Join(cniPlugins, "bridge"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("seed cni plugin: %v", err) + } + if err := os.WriteFile(filepath.Join(cniConfig, "10-kumabox.conflist"), []byte("{}\n"), 0o644); err != nil { + t.Fatalf("seed cni config: %v", err) + } + + collector := Collector{CNIPluginDir: cniPlugins, CNIConfigDir: cniConfig} + facts, err := collector.Collect(context.Background(), root) + if err != nil { + t.Fatalf("Collect: %v", err) + } + + if facts.OS == "" || facts.Arch == "" { + t.Errorf("OS/Arch must always be reported, got %q/%q", facts.OS, facts.Arch) + } + if facts.Root.Path != root.Dir() { + t.Errorf("root path = %q, want %q", facts.Root.Path, root.Dir()) + } + if !facts.Root.Exists || !facts.Root.Writable { + t.Errorf("a prepared root must be reported as writable: %+v", facts.Root) + } + if facts.Root.TotalBytes == 0 || facts.Root.FreeBytes == 0 { + t.Errorf("free and total space must be measured: %+v", facts.Root) + } + if !facts.CNI.PluginsFound || !facts.CNI.ConfigFound { + t.Errorf("seeded CNI directories must be detected: %+v", facts.CNI) + } + if facts.VMM.Name != "cloud-hypervisor" { + t.Errorf("VMM name = %q", facts.VMM.Name) + } +} + +func TestCollectDoesNotCreateTheRoot(t *testing.T) { + t.Parallel() + + missing := filepath.Join(t.TempDir(), "not-created-yet", "root") + root, err := layout.New(missing) + if err != nil { + t.Fatalf("layout.New: %v", err) + } + + collector := Collector{CNIPluginDir: t.TempDir(), CNIConfigDir: t.TempDir()} + facts, err := collector.Collect(context.Background(), root) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if facts.Root.Exists { + t.Errorf("Collect must never create the root: %+v", facts.Root) + } + if _, err := os.Stat(missing); !os.IsNotExist(err) { + t.Errorf("a read-only check created the root: %v", err) + } + if facts.Root.FreeBytes == 0 { + t.Errorf("free space of the nearest existing ancestor must be measured: %+v", facts.Root) + } +} + +func TestCollectReportsEmptyCNIDirectoriesAsMissing(t *testing.T) { + t.Parallel() + + collector := Collector{ + CNIPluginDir: t.TempDir(), + CNIConfigDir: filepath.Join(t.TempDir(), "absent"), + } + facts, err := collector.Collect(context.Background(), tempRoot(t)) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if facts.CNI.PluginsFound { + t.Error("an empty plugin directory must not count as found") + } + if facts.CNI.ConfigFound { + t.Error("an absent config directory must not count as found") + } +} + +func tempRoot(t *testing.T) layout.Root { + t.Helper() + + root, err := layout.New(t.TempDir()) + if err != nil { + t.Fatalf("layout.New: %v", err) + } + if err := root.Prepare(); err != nil { + t.Fatalf("root.Prepare: %v", err) + } + return root +} diff --git a/host/facts.go b/host/facts.go new file mode 100644 index 0000000..da045b7 --- /dev/null +++ b/host/facts.go @@ -0,0 +1,372 @@ +// Package host describes what this machine can do and whether it is ready for +// the phase being worked on (docs/HOST.md). +// +// Collecting facts reads the machine; evaluating them is a pure function, so +// every judgement can be tested on any platform, including a development laptop +// with no KVM. +package host + +import ( + "errors" + "fmt" + "strings" +) + +// ErrNotReady reports that this machine cannot run the phase being checked. +var ErrNotReady = errors.New("machine is not ready") + +// Phase is a delivery phase from docs/ROADMAP.md. A requirement records the +// phase from which it becomes mandatory, so doctor can tell "not ready" apart +// from "not needed yet". +type Phase uint8 + +// Delivery phases, in order. +const ( + PhaseImage Phase = iota + PhaseSandbox + PhaseNetwork + PhaseSnapshot + PhaseClone + PhaseConvergence + PhaseCrossNode + PhaseProduction +) + +func (p Phase) String() string { + if p > PhaseProduction { + return "unknown" + } + return fmt.Sprintf("S%d", int(p)+1) +} + +// MarshalText renders the phase as "S1".."S8" so a JSON report is readable by a +// control plane instead of a bare integer. +func (p Phase) MarshalText() ([]byte, error) { return []byte(p.String()), nil } + +// State is the outcome of one check, per docs/HOST.md §3. +type State uint8 + +// Check outcomes. +const ( + StateOK State = iota + StateMissing + StateUnsupported + StateNotRequired +) + +func (s State) String() string { + switch s { + case StateOK: + return "ok" + case StateMissing: + return "missing" + case StateUnsupported: + return "unsupported" + case StateNotRequired: + return "not-required" + default: + return "unknown" + } +} + +// MarshalText renders the state as text, never as an integer. +func (s State) MarshalText() ([]byte, error) { return []byte(s.String()), nil } + +// Facts is a snapshot of what this machine looks like right now. +type Facts struct { + OS string `json:"os"` + Arch string `json:"arch"` + Kernel string `json:"kernel"` + Root Root `json:"root"` + KVM KVM `json:"kvm"` + VMM Binary `json:"cloud_hypervisor"` + CNI CNI `json:"cni"` + CgroupV2 bool `json:"cgroup_v2"` +} + +// Root describes the node root directory. +type Root struct { + Path string `json:"path"` + Exists bool `json:"exists"` + Writable bool `json:"writable"` + TotalBytes uint64 `json:"total_bytes"` + FreeBytes uint64 `json:"free_bytes"` +} + +// KVM describes the virtualization device. +type KVM struct { + Device string `json:"device"` + Present bool `json:"present"` + Readable bool `json:"readable"` +} + +// Binary describes an external executable the runtime depends on. +type Binary struct { + Name string `json:"name"` + Path string `json:"path"` + Version string `json:"version"` + Found bool `json:"found"` +} + +// CNI describes the CNI plugin and configuration directories. +type CNI struct { + PluginBinDir string `json:"plugin_bin_dir"` + ConfigDir string `json:"config_dir"` + PluginsFound bool `json:"plugins_found"` + ConfigFound bool `json:"config_found"` +} + +// Check is the evaluation of one requirement. +type Check struct { + Name string `json:"name"` + State State `json:"state"` + Since Phase `json:"since"` + Detail string `json:"detail"` + Fix string `json:"fix,omitempty"` +} + +// Report is the full doctor result. +type Report struct { + Phase Phase `json:"phase"` + OS string `json:"os"` + Arch string `json:"arch"` + Kernel string `json:"kernel"` + Root string `json:"root"` + Checks []Check `json:"checks"` +} + +// Failed reports whether a requirement of the current phase is unmet. Checks +// that belong to a later phase never make a run fail. +func (r Report) Failed() bool { + for _, check := range r.Checks { + if check.State == StateMissing || check.State == StateUnsupported { + return true + } + } + return false +} + +// minCHMajor is the oldest Cloud Hypervisor KumaBox supports. It is a +// placeholder until S2 pins the version against a real deployment. +const minCHMajor = 43 + +// minFreeBytes is the free space a node root must have before it counts as +// usable. It is deliberately generous until S2 measures real image sizes. +const minFreeBytes = uint64(10) << 30 + +// Evaluate judges facts against the requirements of the given phase. +func Evaluate(facts Facts, phase Phase) Report { + return Report{ + Phase: phase, + OS: facts.OS, + Arch: facts.Arch, + Kernel: facts.Kernel, + Root: facts.Root.Path, + Checks: []Check{ + rootWritable(facts, phase), + rootSpace(facts, phase), + linuxKernel(facts, phase), + kvmAvailable(facts, phase), + cloudHypervisor(facts, phase), + cniAvailable(facts, phase), + cgroupV2(facts, phase), + }, + } +} + +func rootWritable(facts Facts, phase Phase) Check { + check := Check{ + Name: "root-directory", + Since: PhaseImage, + Fix: fmt.Sprintf("kumabox doctor --fix --root %s", facts.Root.Path), + } + switch { + case facts.Root.Path == "": + check.State = StateUnsupported + check.Detail = "no root directory was resolved" + case !facts.Root.Exists: + check.State = StateMissing + check.Detail = fmt.Sprintf("%s does not exist yet", facts.Root.Path) + case !facts.Root.Writable: + check.State = StateMissing + check.Detail = fmt.Sprintf("%s exists but is not writable", facts.Root.Path) + default: + check.State = StateOK + check.Detail = fmt.Sprintf("%s is writable", facts.Root.Path) + check.Fix = "" + } + return withPhase(check, phase) +} + +func rootSpace(facts Facts, phase Phase) Check { + check := Check{ + Name: "root-space", + Since: PhaseImage, + Fix: "free space on the volume that holds the node root", + } + switch { + case facts.Root.FreeBytes == 0: + check.State = StateMissing + check.Detail = "free space could not be determined" + case facts.Root.FreeBytes < minFreeBytes: + check.State = StateMissing + check.Detail = fmt.Sprintf("only %s free, want at least %s", + HumanBytes(facts.Root.FreeBytes), HumanBytes(minFreeBytes)) + default: + check.State = StateOK + check.Detail = fmt.Sprintf("%s free of %s", + HumanBytes(facts.Root.FreeBytes), HumanBytes(facts.Root.TotalBytes)) + check.Fix = "" + } + return withPhase(check, phase) +} + +func linuxKernel(facts Facts, phase Phase) Check { + check := Check{ + Name: "linux-kernel", + Since: PhaseSandbox, + Fix: "run KumaBox on Linux; microVMs need KVM and cgroup v2", + } + if facts.OS == "linux" { + check.State = StateOK + check.Detail = fmt.Sprintf("linux %s %s", facts.Kernel, facts.Arch) + check.Fix = "" + } else { + check.State = StateUnsupported + check.Detail = fmt.Sprintf("%s/%s cannot boot microVMs", facts.OS, facts.Arch) + } + return withPhase(check, phase) +} + +func kvmAvailable(facts Facts, phase Phase) Check { + check := Check{ + Name: "kvm", + Since: PhaseSandbox, + Fix: "load the kvm module and make " + facts.KVM.Device + " readable by the KumaBox user", + } + switch { + case facts.KVM.Present && facts.KVM.Readable: + check.State = StateOK + check.Detail = facts.KVM.Device + " is readable" + check.Fix = "" + case facts.KVM.Present: + check.State = StateMissing + check.Detail = facts.KVM.Device + " exists but is not readable" + case facts.OS != "linux": + check.State = StateUnsupported + check.Detail = "KVM requires Linux" + default: + check.State = StateMissing + check.Detail = facts.KVM.Device + " does not exist" + } + return withPhase(check, phase) +} + +func cloudHypervisor(facts Facts, phase Phase) Check { + required := fmt.Sprintf("v%d.0", minCHMajor) + check := Check{ + Name: "cloud-hypervisor", + Since: PhaseSandbox, + Fix: "install Cloud Hypervisor " + required + " or newer (see hack/host-install.sh)", + } + switch { + case !facts.VMM.Found: + check.State = StateMissing + check.Detail = fmt.Sprintf("%s not found in PATH", facts.VMM.Name) + case MajorOf(facts.VMM.Version) < minCHMajor: + check.State = StateMissing + check.Detail = fmt.Sprintf("%s is older than %s", facts.VMM.Version, required) + default: + check.State = StateOK + check.Detail = fmt.Sprintf("%s at %s", facts.VMM.Version, facts.VMM.Path) + check.Fix = "" + } + return withPhase(check, phase) +} + +func cniAvailable(facts Facts, phase Phase) Check { + check := Check{ + Name: "cni", + Since: PhaseNetwork, + Fix: "install CNI plugins into " + facts.CNI.PluginBinDir + + " and add a conflist to " + facts.CNI.ConfigDir, + } + switch { + case facts.CNI.PluginsFound && facts.CNI.ConfigFound: + check.State = StateOK + check.Detail = fmt.Sprintf("plugins in %s, config in %s", + facts.CNI.PluginBinDir, facts.CNI.ConfigDir) + check.Fix = "" + case !facts.CNI.PluginsFound: + check.State = StateMissing + check.Detail = facts.CNI.PluginBinDir + " has no plugins" + default: + check.State = StateMissing + check.Detail = facts.CNI.ConfigDir + " has no network configuration" + } + return withPhase(check, phase) +} + +func cgroupV2(facts Facts, phase Phase) Check { + check := Check{ + Name: "cgroup-v2", + Since: PhaseConvergence, + Fix: "boot with the unified cgroup hierarchy (systemd.unified_cgroup_hierarchy=1)", + } + switch { + case facts.CgroupV2: + check.State = StateOK + check.Detail = "unified hierarchy is mounted" + check.Fix = "" + case facts.OS != "linux": + check.State = StateUnsupported + check.Detail = "cgroup v2 requires Linux" + default: + check.State = StateMissing + check.Detail = "/sys/fs/cgroup/cgroup.controllers is missing" + } + return withPhase(check, phase) +} + +// withPhase downgrades a failure to StateNotRequired when the requirement +// belongs to a later phase than the one being checked. +func withPhase(check Check, phase Phase) Check { + if check.Since > phase && (check.State == StateMissing || check.State == StateUnsupported) { + check.State = StateNotRequired + check.Detail = fmt.Sprintf("%s (needed from %s)", check.Detail, check.Since) + check.Fix = "" + } + return check +} + +// MajorOf extracts the major version from strings such as "Cloud Hypervisor +// v43.0" or "43.0". It reports 0 when no version can be read. +func MajorOf(version string) int { + for i := 0; i < len(version); i++ { + if version[i] < '0' || version[i] > '9' { + continue + } + major := 0 + for ; i < len(version) && version[i] >= '0' && version[i] <= '9'; i++ { + major = major*10 + int(version[i]-'0') + } + return major + } + return 0 +} + +// HumanBytes renders a byte count the way an operator reads it. +func HumanBytes(n uint64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + value := float64(n) + for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB", "PiB"} { + value /= unit + if value < unit { + return strings.TrimSuffix(fmt.Sprintf("%.1f", value), ".0") + " " + suffix + } + } + return fmt.Sprintf("%.1f EiB", value/unit) +} diff --git a/host/facts_test.go b/host/facts_test.go new file mode 100644 index 0000000..b6c084c --- /dev/null +++ b/host/facts_test.go @@ -0,0 +1,261 @@ +package host + +import ( + "encoding/json" + "strings" + "testing" +) + +// linuxFacts is a machine that satisfies every phase up to S2. +func linuxFacts() Facts { + return Facts{ + OS: "linux", + Arch: "amd64", + Kernel: "6.8.0-45-generic", + Root: Root{ + Path: "/var/lib/kumabox", + Exists: true, + Writable: true, + TotalBytes: 200 << 30, + FreeBytes: 120 << 30, + }, + KVM: KVM{Device: KVMPath, Present: true, Readable: true}, + VMM: Binary{ + Name: "cloud-hypervisor", + Path: "/usr/local/bin/cloud-hypervisor", + Version: "Cloud Hypervisor v53.0", + Found: true, + }, + CNI: CNI{ + PluginBinDir: DefaultCNIPluginDir, + ConfigDir: DefaultCNIConfigDir, + PluginsFound: true, + ConfigFound: true, + }, + CgroupV2: true, + } +} + +// laptopFacts is a development laptop: no KVM, no Cloud Hypervisor. +func laptopFacts() Facts { + facts := linuxFacts() + facts.OS = "darwin" + facts.Arch = "arm64" + facts.KVM = KVM{Device: KVMPath} + facts.VMM = Binary{Name: "cloud-hypervisor"} + facts.CNI = CNI{PluginBinDir: DefaultCNIPluginDir, ConfigDir: DefaultCNIConfigDir} + facts.CgroupV2 = false + return facts +} + +func checkNamed(t *testing.T, report Report, name string) Check { + t.Helper() + for _, check := range report.Checks { + if check.Name == name { + return check + } + } + t.Fatalf("report has no check %q", name) + return Check{} +} + +func TestEvaluate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + facts Facts + phase Phase + check string + want State + wantFailed bool + }{ + { + name: "a ready linux node passes the image phase", + facts: linuxFacts(), + phase: PhaseImage, + check: "root-directory", + want: StateOK, + }, + { + name: "a missing root fails the image phase", + facts: withRoot(linuxFacts(), Root{Path: "/var/lib/kumabox", TotalBytes: 200 << 30, FreeBytes: 120 << 30}), + phase: PhaseImage, + check: "root-directory", + want: StateMissing, + wantFailed: true, + }, + { + name: "a read-only root fails the image phase", + facts: withRoot(linuxFacts(), Root{Path: "/var/lib/kumabox", Exists: true, TotalBytes: 200 << 30, FreeBytes: 120 << 30}), + phase: PhaseImage, + check: "root-directory", + want: StateMissing, + wantFailed: true, + }, + { + name: "too little free space fails the image phase", + facts: withRoot(linuxFacts(), Root{Path: "/var/lib/kumabox", Exists: true, Writable: true, TotalBytes: 200 << 30, FreeBytes: 1 << 30}), + phase: PhaseImage, + check: "root-space", + want: StateMissing, + wantFailed: true, + }, + { + name: "kvm is not required while the image phase is the target", + facts: laptopFacts(), + phase: PhaseImage, + check: "kvm", + want: StateNotRequired, + }, + { + name: "kvm is required in the sandbox phase", + facts: laptopFacts(), + phase: PhaseSandbox, + check: "kvm", + want: StateUnsupported, + wantFailed: true, + }, + { + name: "an old cloud hypervisor is rejected", + facts: withVMMVersion(linuxFacts(), "Cloud Hypervisor v42.0"), + phase: PhaseSandbox, + check: "cloud-hypervisor", + want: StateMissing, + wantFailed: true, + }, + { + name: "cni is satisfied on a full node", + facts: linuxFacts(), + phase: PhaseNetwork, + check: "cni", + want: StateOK, + }, + { + name: "cni failure blocks the network phase", + facts: withCNI(linuxFacts(), false, false), + phase: PhaseNetwork, + check: "cni", + want: StateMissing, + wantFailed: true, + }, + { + name: "cgroup v2 is not required before the convergence phase", + facts: withCgroupV2(linuxFacts(), false), + phase: PhaseNetwork, + check: "cgroup-v2", + want: StateNotRequired, + wantFailed: false, + }, + { + name: "cgroup v2 failure blocks the convergence phase", + facts: withCgroupV2(linuxFacts(), false), + phase: PhaseConvergence, + check: "cgroup-v2", + want: StateMissing, + wantFailed: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + report := Evaluate(test.facts, test.phase) + if got := checkNamed(t, report, test.check).State; got != test.want { + t.Errorf("check %s state = %s, want %s", test.check, got, test.want) + } + if got := report.Failed(); got != test.wantFailed { + t.Errorf("report.Failed() = %v, want %v (%+v)", got, test.wantFailed, report.Checks) + } + }) + } +} + +func TestEvaluateNeverFailsOnLaterPhases(t *testing.T) { + t.Parallel() + + // A laptop that satisfies nothing beyond the root must still pass the image + // phase: requirements are judged against the phase being worked on. + facts := withRoot(laptopFacts(), Root{ + Path: "/home/dev/.kumabox", Exists: true, Writable: true, + TotalBytes: 500 << 30, FreeBytes: 200 << 30, + }) + if report := Evaluate(facts, PhaseImage); report.Failed() { + t.Fatalf("the image phase must not fail on later-phase requirements: %+v", report.Checks) + } +} + +func TestStatesAndPhasesRenderAsTextInJSON(t *testing.T) { + t.Parallel() + + encoded, err := json.Marshal(Evaluate(linuxFacts(), PhaseImage)) + if err != nil { + t.Fatalf("marshal report: %v", err) + } + text := string(encoded) + for _, want := range []string{`"phase":"S1"`, `"state":"ok"`, `"since":"S1"`} { + if !strings.Contains(text, want) { + t.Errorf("JSON report missing %s:\n%s", want, text) + } + } + if strings.Contains(text, `"state":0`) { + t.Errorf("states must never be encoded as integers:\n%s", text) + } +} + +func TestMajorOf(t *testing.T) { + t.Parallel() + + tests := map[string]int{ + "Cloud Hypervisor v43.0": 43, + "cloud-hypervisor 53.1": 53, + "43.0": 43, + "v100": 100, + "": 0, + "no digits here": 0, + } + for input, want := range tests { + if got := MajorOf(input); got != want { + t.Errorf("MajorOf(%q) = %d, want %d", input, got, want) + } + } +} + +func TestHumanBytes(t *testing.T) { + t.Parallel() + + tests := map[uint64]string{ + 512: "512 B", + 1024: "1 KiB", + 1536: "1.5 KiB", + 10 << 30: "10 GiB", + } + for input, want := range tests { + if got := HumanBytes(input); got != want { + t.Errorf("HumanBytes(%d) = %q, want %q", input, got, want) + } + } +} + +func withRoot(facts Facts, root Root) Facts { + facts.Root = root + return facts +} + +func withVMMVersion(facts Facts, version string) Facts { + facts.VMM.Version = version + facts.VMM.Found = true + facts.VMM.Path = "/usr/local/bin/cloud-hypervisor" + return facts +} + +func withCNI(facts Facts, plugins, config bool) Facts { + facts.CNI.PluginsFound = plugins + facts.CNI.ConfigFound = config + return facts +} + +func withCgroupV2(facts Facts, mounted bool) Facts { + facts.CgroupV2 = mounted + return facts +} diff --git a/layout/layout.go b/layout/layout.go new file mode 100644 index 0000000..6204392 --- /dev/null +++ b/layout/layout.go @@ -0,0 +1,173 @@ +// Package layout owns the on-disk namespace under a node root. +// +// Every managed path is derived here, so no other package has to join paths +// under the root by hand and no shell script has to keep its own copy of the +// directory list (docs/ARCHITECTURE.md §5, docs/HOST.md §1). +package layout + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// Errors reported by this package. +var ( + ErrRootEmpty = errors.New("root path is empty") + ErrRootNotAbsolute = errors.New("root path must be absolute") + ErrRootNotDirectory = errors.New("root path exists and is not a directory") + ErrRootNotWritable = errors.New("root directory is not writable") + ErrStatFailed = errors.New("cannot inspect the root directory") +) + +// dirPerm is the mode for every KumaBox-owned directory. +const dirPerm os.FileMode = 0o700 + +// Root is a resolved node root directory. +type Root struct { + dir string +} + +// New resolves dir into a Root. +// +// The path is made absolute and canonical so that containment checks compare +// real paths. dir may be a symlink: it is resolved rather than rejected, because +// operators legitimately point the root at another volume. +func New(dir string) (Root, error) { + if dir == "" { + return Root{}, ErrRootEmpty + } + if !filepath.IsAbs(dir) { + return Root{}, fmt.Errorf("%w: %q", ErrRootNotAbsolute, dir) + } + canonical, err := canonicalize(filepath.Clean(dir)) + if err != nil { + return Root{}, err + } + return Root{dir: canonical}, nil +} + +// Dir returns the root directory. +func (r Root) Dir() string { return r.dir } + +// MetadataDir holds the fact database. +func (r Root) MetadataDir() string { return filepath.Join(r.dir, "metadata") } + +// DatabaseFile is the SQLite fact database. +func (r Root) DatabaseFile() string { return filepath.Join(r.MetadataDir(), "kumabox.db") } + +// BlobsDir holds content-addressed objects: one file per digest. +func (r Root) BlobsDir() string { return filepath.Join(r.dir, "blobs") } + +// TempDir is where a command stages work before publishing it atomically. +func (r Root) TempDir() string { return filepath.Join(r.dir, "tmp") } + +// State describes what a root looks like right now. +type State struct { + Exists bool `json:"exists"` + Writable bool `json:"writable"` + TotalBytes uint64 `json:"total_bytes"` + FreeBytes uint64 `json:"free_bytes"` +} + +// Inspect reports the state of the root without changing it. When the root does +// not exist yet, free space is measured on the nearest existing ancestor, +// because that is the volume Prepare would write to. +func (r Root) Inspect() (State, error) { + info, err := os.Stat(r.dir) + switch { + case err == nil: + if !info.IsDir() { + return State{}, fmt.Errorf("%w: %q", ErrRootNotDirectory, r.dir) + } + total, free, err := statfs(r.dir) + if err != nil { + return State{}, fmt.Errorf("%w: %q: %w", ErrStatFailed, r.dir, err) + } + return State{ + Exists: true, + Writable: writable(r.dir), + TotalBytes: total, + FreeBytes: free, + }, nil + case errors.Is(err, os.ErrNotExist): + ancestor := nearestExisting(r.dir) + total, free, err := statfs(ancestor) + if err != nil { + return State{}, fmt.Errorf("%w: %q: %w", ErrStatFailed, ancestor, err) + } + return State{Writable: writable(ancestor), TotalBytes: total, FreeBytes: free}, nil + default: + return State{}, fmt.Errorf("%w: %q: %w", ErrStatFailed, r.dir, err) + } +} + +// Prepare creates the managed directories and checks that they are writable. It +// is the only function here that modifies the filesystem, and it only ever +// touches KumaBox-owned paths. +func (r Root) Prepare() error { + for _, dir := range []string{r.dir, r.MetadataDir(), r.BlobsDir(), r.TempDir()} { + if err := os.MkdirAll(dir, dirPerm); err != nil { + return fmt.Errorf("%w: %q: %w", ErrRootNotWritable, dir, err) + } + } + if !writable(r.dir) { + return fmt.Errorf("%w: %q", ErrRootNotWritable, r.dir) + } + return nil +} + +// canonicalize resolves symlinks along the longest existing prefix of path and +// re-appends the missing suffix, so the result is canonical without requiring +// the path to exist. +func canonicalize(path string) (string, error) { + existing, suffix := path, "" + for { + resolved, err := filepath.EvalSymlinks(existing) + if err == nil { + if suffix == "" { + return resolved, nil + } + return filepath.Join(resolved, suffix), nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("%w: %q: %w", ErrStatFailed, existing, err) + } + parent := filepath.Dir(existing) + if parent == existing { + return path, nil + } + suffix = filepath.Join(filepath.Base(existing), suffix) + existing = parent + } +} + +// nearestExisting returns the deepest existing ancestor of path. +func nearestExisting(path string) string { + current := path + for { + parent := filepath.Dir(current) + if parent == current { + return current + } + if _, err := os.Stat(parent); err == nil { + return parent + } + current = parent + } +} + +// writable reports whether a new file can be created in dir. Creating and +// removing a probe file is the only reliable check across the permission models +// KumaBox runs under. +func writable(dir string) bool { + probe, err := os.CreateTemp(dir, ".kumabox-write-probe-*") + if err != nil { + return false + } + name := probe.Name() + _ = probe.Close() + _ = os.Remove(name) + return true +} diff --git a/layout/layout_test.go b/layout/layout_test.go new file mode 100644 index 0000000..924059a --- /dev/null +++ b/layout/layout_test.go @@ -0,0 +1,179 @@ +package layout + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewResolvesManagedPaths(t *testing.T) { + t.Parallel() + + root, err := New("/var/lib/kumabox") + if err != nil { + t.Fatalf("New: %v", err) + } + // The path is canonical, so on macOS /var becomes /private/var. Assert the + // shape rather than one platform's spelling. + if !filepath.IsAbs(root.Dir()) { + t.Fatalf("Dir() = %q, want an absolute path", root.Dir()) + } + if !strings.HasSuffix(root.Dir(), filepath.Join("var", "lib", "kumabox")) { + t.Errorf("Dir() = %q, want it to end in var/lib/kumabox", root.Dir()) + } + if got, want := root.DatabaseFile(), filepath.Join(root.Dir(), "metadata", "kumabox.db"); got != want { + t.Errorf("DatabaseFile() = %q, want %q", got, want) + } + for _, path := range []string{root.MetadataDir(), root.BlobsDir(), root.TempDir()} { + if !strings.HasPrefix(path, root.Dir()+string(filepath.Separator)) { + t.Errorf("%q is not under the root %q", path, root.Dir()) + } + } +} + +func TestNewRejectsEmptyAndRelativePaths(t *testing.T) { + t.Parallel() + + if _, err := New(""); !errors.Is(err, ErrRootEmpty) { + t.Errorf("New(\"\") err = %v, want ErrRootEmpty", err) + } + for _, input := range []string{"var/lib/kumabox", "./kb", "../kb"} { + if _, err := New(input); !errors.Is(err, ErrRootNotAbsolute) { + t.Errorf("New(%q) err = %v, want ErrRootNotAbsolute", input, err) + } + } +} + +func TestNewResolvesSymlinksSoContainmentChecksCompareRealPaths(t *testing.T) { + t.Parallel() + + target := t.TempDir() + link := filepath.Join(t.TempDir(), "link-to-root") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + root, err := New(link) + if err != nil { + t.Fatalf("New: %v", err) + } + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + if root.Dir() != resolved { + t.Errorf("Dir() = %q, want the resolved target %q", root.Dir(), resolved) + } +} + +func TestNewResolvesSymlinksInAMissingRoot(t *testing.T) { + t.Parallel() + + // This is what --root /tmp/kb looks like on macOS, where /tmp is a symlink. + real := t.TempDir() + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(real, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + root, err := New(filepath.Join(link, "kb", "nested")) + if err != nil { + t.Fatalf("New: %v", err) + } + resolvedParent, err := filepath.EvalSymlinks(real) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + if want := filepath.Join(resolvedParent, "kb", "nested"); root.Dir() != want { + t.Errorf("Dir() = %q, want %q", root.Dir(), want) + } +} + +func TestInspectReportsMissingRootWithoutCreatingIt(t *testing.T) { + t.Parallel() + + missing := filepath.Join(t.TempDir(), "kb") + root, err := New(missing) + if err != nil { + t.Fatalf("New: %v", err) + } + + state, err := root.Inspect() + if err != nil { + t.Fatalf("Inspect: %v", err) + } + if state.Exists { + t.Error("Inspect must report the root as missing") + } + if state.FreeBytes == 0 || state.TotalBytes == 0 { + t.Errorf("free space must be measured on the nearest existing ancestor: %+v", state) + } + if _, err := os.Stat(missing); !os.IsNotExist(err) { + t.Errorf("Inspect created the root: %v", err) + } +} + +func TestInspectRejectsAFileAsRoot(t *testing.T) { + t.Parallel() + + file := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatalf("seed file: %v", err) + } + root, err := New(file) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := root.Inspect(); !errors.Is(err, ErrRootNotDirectory) { + t.Fatalf("err = %v, want ErrRootNotDirectory", err) + } +} + +func TestPrepareCreatesManagedDirectories(t *testing.T) { + t.Parallel() + + root, err := New(filepath.Join(t.TempDir(), "kb")) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := root.Prepare(); err != nil { + t.Fatalf("Prepare: %v", err) + } + + for _, dir := range []string{root.Dir(), root.MetadataDir(), root.BlobsDir(), root.TempDir()} { + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat %s: %v", dir, err) + } + if !info.IsDir() { + t.Errorf("%s is not a directory", dir) + } + if got := info.Mode().Perm(); got != dirPerm { + t.Errorf("%s mode = %o, want %o", dir, got, dirPerm) + } + } + + state, err := root.Inspect() + if err != nil { + t.Fatalf("Inspect: %v", err) + } + if !state.Exists || !state.Writable { + t.Errorf("prepared root must be an existing writable directory: %+v", state) + } +} + +func TestPrepareIsIdempotent(t *testing.T) { + t.Parallel() + + root, err := New(filepath.Join(t.TempDir(), "kb")) + if err != nil { + t.Fatalf("New: %v", err) + } + for attempt := 0; attempt < 2; attempt++ { + if err := root.Prepare(); err != nil { + t.Fatalf("Prepare attempt %d: %v", attempt+1, err) + } + } +} diff --git a/layout/statfs_bsd.go b/layout/statfs_bsd.go new file mode 100644 index 0000000..0f6fa69 --- /dev/null +++ b/layout/statfs_bsd.go @@ -0,0 +1,14 @@ +//go:build darwin || freebsd || netbsd || openbsd + +package layout + +import "syscall" + +func statfs(path string) (total, free uint64, err error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0, 0, err + } + blockSize := uint64(stat.Bsize) + return stat.Blocks * blockSize, stat.Bavail * blockSize, nil +} diff --git a/layout/statfs_linux.go b/layout/statfs_linux.go new file mode 100644 index 0000000..b83f7a4 --- /dev/null +++ b/layout/statfs_linux.go @@ -0,0 +1,14 @@ +//go:build linux + +package layout + +import "syscall" + +func statfs(path string) (total, free uint64, err error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0, 0, err + } + blockSize := uint64(stat.Bsize) + return stat.Blocks * blockSize, stat.Bavail * blockSize, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0e32e4a --- /dev/null +++ b/main.go @@ -0,0 +1,17 @@ +// Command kumabox is the KumaBox command line. +// +// v1 has no daemon: every invocation opens the node root, takes the locks it +// needs, does one job, and exits (docs/DECISIONS.md DEC-018). This file does +// nothing but hand control to the command layer. +package main + +import ( + "context" + "os" + + "github.com/kumabox/kumabox/cmd" +) + +func main() { + os.Exit(cmd.Execute(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..2e90309 --- /dev/null +++ b/version/version.go @@ -0,0 +1,25 @@ +// Package version reports what build is running. +package version + +import "fmt" + +// Build information. Release builds override these through -ldflags. +var ( + Version = "0.0.0-dev" + Commit = "unknown" + 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) +} + +// Info returns the version as structured data for --json. +func Info() map[string]string { + return map[string]string{ + "version": Version, + "commit": Commit, + "build_time": BuildTime, + } +} From b05da9631d90c584d66b8dc1db373ccb485eeb08 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Thu, 10 Sep 2026 10:46:18 +0800 Subject: [PATCH 09/45] feat(doctor): port Cocoon's pre-flight checker doctor/check.sh is Cocoon's doctor/check.sh with Cocoon's parts replaced: KumaBox root, run and log directories, kumabox0 bridge and 10-kumabox.conflist, the KumaBox database and staging directories, and the KumaBox version floors (cloud-hypervisor >= v43, erofs-utils >= 1.8). Sections that do not apply to KumaBox are gone: firecracker, ch-remote, zstd, the JSON metadata backend and the JSON snapshot inspection. Store health now reports rows stuck mid-flight and stray staging entries, which is what 'kumabox gc' reclaims. doctor/check_test.sh tests the checker against a throwaway root and a PATH of fake tools, so it needs no root, no KVM and no Cloud Hypervisor. Two knobs, KUMABOX_KVM_DEVICE and KUMABOX_NETNS_DIR, exist so the checks can run off-host; arm64 is accepted alongside aarch64. The Go side keeps only the CLI skeleton in Cocoon's shape: main.go, cmd/root.go and version/. The Go doctor command is removed -- doctor is the script, as in Cocoon. --- AGENTS.md | 31 --- Makefile | 24 +- cmd/commands.go | 71 ----- cmd/commands_test.go | 104 -------- cmd/doctor/commands.go | 42 --- cmd/doctor/handler.go | 96 ------- cmd/doctor/handler_test.go | 149 ----------- cmd/errors.go | 67 ----- cmd/root.go | 110 ++++++++ cmd/root_test.go | 68 +++++ config/config.go | 45 ---- config/config_test.go | 73 ------ doctor/check.sh | 520 +++++++++++++++++++++++++++++++++++++ doctor/check_test.sh | 199 ++++++++++++++ host/collect.go | 135 ---------- host/collect_darwin.go | 14 - host/collect_linux.go | 17 -- host/collect_other.go | 7 - host/collect_test.go | 106 -------- host/facts.go | 372 -------------------------- host/facts_test.go | 261 ------------------- layout/layout.go | 173 ------------ layout/layout_test.go | 179 ------------- layout/statfs_bsd.go | 14 - layout/statfs_linux.go | 14 - main.go | 13 +- version/version.go | 16 +- 27 files changed, 936 insertions(+), 1984 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 cmd/commands.go delete mode 100644 cmd/commands_test.go delete mode 100644 cmd/doctor/commands.go delete mode 100644 cmd/doctor/handler.go delete mode 100644 cmd/doctor/handler_test.go delete mode 100644 cmd/errors.go create mode 100644 cmd/root.go create mode 100644 cmd/root_test.go delete mode 100644 config/config.go delete mode 100644 config/config_test.go create mode 100755 doctor/check.sh create mode 100755 doctor/check_test.sh delete mode 100644 host/collect.go delete mode 100644 host/collect_darwin.go delete mode 100644 host/collect_linux.go delete mode 100644 host/collect_other.go delete mode 100644 host/collect_test.go delete mode 100644 host/facts.go delete mode 100644 host/facts_test.go delete mode 100644 layout/layout.go delete mode 100644 layout/layout_test.go delete mode 100644 layout/statfs_bsd.go delete mode 100644 layout/statfs_linux.go diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 2961fa5..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,31 +0,0 @@ -# KumaBox working rules - -## Project and baseline - -- This remains the KumaBox project. The core logic is being rewritten; it does not create a second product and does not fork Cocoon. -- Preserve the tag `pre-p12-rewrite-20260909` (`08bc8d7545492da96b5c0fc984afc549b4497af1`). Never move or delete it. The pre-rewrite tree is a **read-only reference**: read it to confirm behaviour, ordering and failure semantics; never copy its code, types, schema or tests. -- Cocoon at `../cocoon@27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` is the capability floor and behavioural reference. Match its lifecycle ordering, commit boundaries and failure recovery; do not copy its package structure, god packages, hook-bag control flow or dual metadata backends. -- Read these before doing any work, in this order: `docs/PRODUCT.md`, `docs/ARCHITECTURE.md`, `docs/BEHAVIOR.md`, `docs/HOST.md`, `docs/PERFORMANCE.md`, `docs/ROADMAP.md`, `docs/DECISIONS.md`. -- The design documents previously numbered 14–19 under `docs/implementation/` were written by ChatGPT/Codex, are deleted, and must not be recreated or referenced (DEC-011). `docs/implementation/00`–`13` are history only, never a specification. - -## Hard rules - -1. **Zero code reuse.** Every implementation is written from the specifications. The old tree is reference material only (DEC-003). -2. **Layer direction is enforced by tooling, not by a bespoke test suite.** `ARCHITECTURE.md` §12.1 maps each rule to a standard linter (`depguard` for the layer matrix, `gochecknoinits`, `forbidigo`, `revive`) configured in `.golangci.yml`, which lands with the first real packages in S1. Do not add a hand-written architecture test package: rules live in the spec, enforcement lives in linter config. -3. **Naming and style follow `ARCHITECTURE.md` §13.** No `utils`/`helpers`/`common`/`Manager`/`Service`/`Impl` naming, no `init()`, no package-level mutable state, no `_ =` error discard. -4. **Tests must pass locally before a phase is called done.** `make verify` and `make race` must be green on macOS with no root and no KVM. Anything needing a real Cloud Hypervisor, CNI or KVM host is verified manually by the project owner, so every phase must ship a copy-pasteable runbook with expected output (DEC-009). -5. **Performance claims need the protocol.** Use `PERFORMANCE.md` §2: same machine, same parameters, N ≥ 30, P50/P95/P99, raw data archived, differences judged with `benchstat`, and every claimed win traceable to one code mechanism (DEC-008). -6. **Do not weaken safety for speed.** Never trade away digests, path boundaries, references, leases, dirty markers or durability. - -## Approval gate - -A **phase** from `ROADMAP.md` (S1 image vertical, S2 sandbox vertical, S3 network, S4 snapshot/restore, S5 clone, S6 convergence, S7 cross-node, S8 production gates) is the unit of approval. The current phase proposal lives in `docs/proposals/`. Before writing implementation code for a phase, present all four items to the project owner and get an explicit answer: - -1. **Logic** — what the phase makes work end to end; inputs, outputs, preconditions, state transitions, step order, cancellation, retry, crash recovery, idempotency, and the Cocoon behaviour it matches or deliberately improves. -2. **Design** — which module owns which durable fact, transaction and commit boundaries, locks and leases, external side effects, reconciliation, alternatives considered and why they were rejected. -3. **Code abstraction** — core types and invariants, the consumer-owned ports (1–3 methods, each with an in-package fake), concrete implementations, errors, configuration, and test seams. -4. **Layout** — directories and files added or removed, package responsibilities, import direction, forbidden dependencies, and what gets deleted at the end of the phase. - -Reading, analysis, diagrams, design documents and roadmap updates are allowed before approval. Do not create or modify Go, proto, SQL, test, script or generated code until the phase is approved. - -Scope changes invalidate the approval: if implementation needs a different domain model, schema, operation state machine, public API, package boundary or concurrency model than the approved proposal, stop and ask again. diff --git a/Makefile b/Makefile index 5c79c3d..b69d87c 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,39 @@ GO ?= go +BIN_DIR ?= bin +PREFIX ?= /usr/local # Packages in this module. Empty until the first phase lands code. PKGS := $(shell $(GO) list ./... 2>/dev/null) -.PHONY: build fmt-check test race vet verify +.PHONY: build install fmt-check test shell-test race vet verify build: - @if [ -n "$(PKGS)" ]; then $(GO) build ./...; else echo "no packages yet"; fi + @mkdir -p "$(BIN_DIR)" + $(GO) build -o "$(BIN_DIR)/kumabox" . + @cp doctor/check.sh "$(BIN_DIR)/kumabox-check" + @chmod 0755 "$(BIN_DIR)/kumabox-check" + +install: build + @install -d "$(DESTDIR)$(PREFIX)/bin" + @install -m 0755 "$(BIN_DIR)/kumabox" "$(DESTDIR)$(PREFIX)/bin/kumabox" + @install -m 0755 "$(BIN_DIR)/kumabox-check" "$(DESTDIR)$(PREFIX)/bin/kumabox-check" fmt-check: @test -z "$$(gofmt -l .)" || (gofmt -l . && exit 1) test: - @if [ -n "$(PKGS)" ]; then $(GO) test ./...; else echo "no packages yet"; fi + $(GO) test ./... + $(MAKE) shell-test + +shell-test: + bash doctor/check_test.sh # race is required for any change touching concurrency, workers, streams or # reconciliation (docs/ARCHITECTURE.md §9). race: - @if [ -n "$(PKGS)" ]; then $(GO) test -race ./...; else echo "no packages yet"; fi + $(GO) test -race ./... vet: - @if [ -n "$(PKGS)" ]; then $(GO) vet ./...; else echo "no packages yet"; fi + $(GO) vet ./... verify: fmt-check vet test build diff --git a/cmd/commands.go b/cmd/commands.go deleted file mode 100644 index c03790a..0000000 --- a/cmd/commands.go +++ /dev/null @@ -1,71 +0,0 @@ -// Package cmd defines the kumabox command tree. -// -// This package owns flags, help and exit codes only: every command converts its -// flags into a typed request, calls a library, and renders the result. No -// lifecycle step order lives here (docs/ARCHITECTURE.md §1). -package cmd - -import ( - "context" - "fmt" - "io" - - "github.com/spf13/cobra" - - "github.com/kumabox/kumabox/cmd/doctor" - "github.com/kumabox/kumabox/version" -) - -// Execute runs one invocation and returns the process exit code. -func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int { - root := newRootCommand() - root.SetArgs(args) - root.SetOut(stdout) - root.SetErr(stderr) - - if err := root.ExecuteContext(ctx); err != nil { - return fail(stderr, err) - } - return exitOK -} - -func newRootCommand() *cobra.Command { - root := &cobra.Command{ - Use: "kumabox", - Short: "microVM sandboxes for AI agents", - Long: "kumabox runs microVM sandboxes on this machine.\n\n" + - "Every command opens the node root, does one job and exits; there is no\n" + - "daemon in this version. Use --root to point at a different root, for\n" + - "example while developing.", - SilenceUsage: true, - SilenceErrors: true, - } - root.PersistentFlags().String("root", "", - "node root directory (default $KUMABOX_ROOT, then /var/lib/kumabox)") - root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { - return fmt.Errorf("%w: %w", ErrUsage, err) - }) - - root.AddCommand(doctor.NewCommand()) - root.AddCommand(newVersionCommand()) - return root -} - -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 writeJSON(command.OutOrStdout(), version.Info()) - } - fmt.Fprintln(command.OutOrStdout(), version.String()) - return nil - }, - } - command.Flags().BoolVar(&asJSON, "json", false, "print the version as JSON") - return command -} diff --git a/cmd/commands_test.go b/cmd/commands_test.go deleted file mode 100644 index 6d52d2b..0000000 --- a/cmd/commands_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "encoding/json" - "path/filepath" - "strings" - "testing" -) - -func execute(t *testing.T, args ...string) (int, string, string) { - t.Helper() - - var stdout, stderr bytes.Buffer - code := Execute(context.Background(), args, &stdout, &stderr) - return code, stdout.String(), stderr.String() -} - -func TestDoctorFailsUntilTheRootExists(t *testing.T) { - t.Parallel() - - root := filepath.Join(t.TempDir(), "kb") - code, _, stderr := execute(t, "doctor", "--root", root) - if code != 6 { - t.Errorf("exit code = %d, want 6\nstderr: %s", code, stderr) - } - if !strings.Contains(stderr, "HOST_NOT_READY") { - t.Errorf("stderr must carry the stable code:\n%s", stderr) - } -} - -func TestDoctorFixThenJSON(t *testing.T) { - t.Parallel() - - root := filepath.Join(t.TempDir(), "kb") - if code, _, stderr := execute(t, "doctor", "--fix", "--root", root); code != 0 { - t.Fatalf("doctor --fix exit code = %d, want 0\nstderr: %s", code, stderr) - } - - code, stdout, stderr := execute(t, "doctor", "--json", "--root", root) - if code != 0 { - t.Fatalf("doctor --json exit code = %d, want 0\nstderr: %s", code, stderr) - } - var report map[string]any - if err := json.Unmarshal([]byte(stdout), &report); err != nil { - t.Fatalf("--json did not produce valid JSON: %v\n%s", err, stdout) - } - if report["phase"] != "S1" { - t.Errorf("phase = %v, want S1", report["phase"]) - } -} - -func TestInvalidRootIsAnInvalidArgument(t *testing.T) { - t.Parallel() - - code, _, stderr := execute(t, "doctor", "--root", "relative/path") - if code != 5 { - t.Errorf("exit code = %d, want 5\nstderr: %s", code, stderr) - } - if !strings.Contains(stderr, "ROOT_NOT_ABSOLUTE") { - t.Errorf("stderr must carry the stable code:\n%s", stderr) - } -} - -func TestUnknownCommandIsAUsageError(t *testing.T) { - t.Parallel() - - code, _, stderr := execute(t, "frobnicate") - if code != 5 { - t.Errorf("exit code = %d, want 5", code) - } - if !strings.Contains(stderr, "code: USAGE") { - t.Errorf("stderr must classify the failure as usage:\n%s", stderr) - } -} - -func TestUnknownFlagIsAUsageError(t *testing.T) { - t.Parallel() - - code, _, stderr := execute(t, "doctor", "--nope") - if code != 5 { - t.Errorf("exit code = %d, want 5", code) - } - if !strings.Contains(stderr, "code: USAGE") { - t.Errorf("stderr must classify the failure as usage:\n%s", stderr) - } -} - -func TestVersion(t *testing.T) { - t.Parallel() - - code, stdout, stderr := execute(t, "version", "--json") - if code != 0 { - t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr) - } - var payload map[string]string - if err := json.Unmarshal([]byte(stdout), &payload); err != nil { - t.Fatalf("version --json is not valid JSON: %v\n%s", err, stdout) - } - if payload["version"] == "" { - t.Error("version payload is empty") - } -} diff --git a/cmd/doctor/commands.go b/cmd/doctor/commands.go deleted file mode 100644 index 9069fa6..0000000 --- a/cmd/doctor/commands.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package doctor implements "kumabox doctor". -package doctor - -import ( - "github.com/spf13/cobra" -) - -// NewCommand builds the doctor command. -func NewCommand() *cobra.Command { - var options Options - - command := &cobra.Command{ - Use: "doctor", - Short: "check whether this machine can run KumaBox", - Long: "doctor inspects this machine and reports, check by check, whether it is\n" + - "ready for the phase being worked on.\n\n" + - "A check that belongs to a later phase is reported as not-required and never\n" + - "fails the command. --fix only creates or repairs KumaBox-owned directories;\n" + - "installing packages, sysctl and firewall rules belong to hack/host-install.sh.", - Args: cobra.NoArgs, - RunE: func(command *cobra.Command, _ []string) error { - rootFlag, err := command.Flags().GetString("root") - if err != nil { - return err - } - options.Root = rootFlag - - handler := Handler{} - report, err := handler.Doctor(command.Context(), options) - if err != nil { - return err - } - if err := handler.Render(command.OutOrStdout(), report, options.JSON); err != nil { - return err - } - return handler.NotReady(report) - }, - } - command.Flags().BoolVar(&options.JSON, "json", false, "print the report as JSON") - command.Flags().BoolVar(&options.Fix, "fix", false, "create or repair KumaBox-owned directories") - return command -} diff --git a/cmd/doctor/handler.go b/cmd/doctor/handler.go deleted file mode 100644 index a3658be..0000000 --- a/cmd/doctor/handler.go +++ /dev/null @@ -1,96 +0,0 @@ -package doctor - -import ( - "context" - "encoding/json" - "fmt" - "io" - "text/tabwriter" - - "github.com/kumabox/kumabox/config" - "github.com/kumabox/kumabox/host" - "github.com/kumabox/kumabox/layout" -) - -// phase is the phase doctor judges against. It moves forward as phases land; it -// is not a flag, because "is this machine ready for what we are building" is a -// property of the build, not of the caller. -const phase = host.PhaseImage - -// Options carries the doctor flags. -type Options struct { - Root string - JSON bool - Fix bool -} - -// Handler runs the doctor command. -type Handler struct{} - -// Doctor inspects the machine and returns the report. -func (Handler) Doctor(ctx context.Context, options Options) (host.Report, error) { - cfg, err := config.Load(options.Root) - if err != nil { - return host.Report{}, err - } - root, err := layout.New(cfg.Root) - if err != nil { - return host.Report{}, err - } - - // --fix is the only thing doctor may change, and it stays inside paths - // KumaBox owns. - if options.Fix { - if err := root.Prepare(); err != nil { - return host.Report{}, err - } - } - - facts, err := host.Collector{}.Collect(ctx, root) - if err != nil { - return host.Report{}, err - } - return host.Evaluate(facts, phase), nil -} - -// NotReady returns an error when the report contains an unmet requirement of -// the current phase. -func (Handler) NotReady(report host.Report) error { - if !report.Failed() { - return nil - } - return fmt.Errorf("%w: not ready for phase %s", host.ErrNotReady, report.Phase) -} - -// Render writes the report to out. -func (Handler) Render(out io.Writer, report host.Report, asJSON bool) error { - if asJSON { - encoder := json.NewEncoder(out) - encoder.SetIndent("", " ") - return encoder.Encode(report) - } - - fmt.Fprintf(out, "kumabox doctor - machine readiness for phase %s\n\n", report.Phase) - fmt.Fprintf(out, "host %s/%s", report.OS, report.Arch) - if report.Kernel != "" { - fmt.Fprintf(out, " kernel %s", report.Kernel) - } - fmt.Fprintf(out, "\nroot %s\n\n", report.Root) - - table := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) - fmt.Fprintln(table, "CHECK\tSTATE\tSINCE\tDETAIL") - for _, check := range report.Checks { - fmt.Fprintf(table, "%s\t%s\t%s\t%s\n", check.Name, check.State, check.Since, check.Detail) - } - if err := table.Flush(); err != nil { - return err - } - - for _, check := range report.Checks { - switch check.State { - case host.StateMissing, host.StateUnsupported: - fmt.Fprintf(out, "\n%s: %s\n fix: %s\n", check.Name, check.Detail, check.Fix) - } - } - return nil -} diff --git a/cmd/doctor/handler_test.go b/cmd/doctor/handler_test.go deleted file mode 100644 index bfc4aaf..0000000 --- a/cmd/doctor/handler_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package doctor - -import ( - "bytes" - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/kumabox/kumabox/host" -) - -func TestDoctorReportsAMissingRoot(t *testing.T) { - t.Parallel() - - root := filepath.Join(t.TempDir(), "kb") - report, err := Handler{}.Doctor(context.Background(), Options{Root: root}) - if err != nil { - t.Fatalf("Doctor: %v", err) - } - if !report.Failed() { - t.Fatal("a missing root must fail the phase being checked") - } - // The root is canonicalised, so compare against the resolved path rather - // than the platform's spelling of the temp directory. - resolved, err := filepath.EvalSymlinks(filepath.Dir(root)) - if err != nil { - t.Fatalf("EvalSymlinks: %v", err) - } - if want := filepath.Join(resolved, "kb"); report.Root != want { - t.Errorf("report root = %q, want %q", report.Root, want) - } - if state := stateOf(t, report, "root-directory"); state != host.StateMissing { - t.Errorf("root-directory state = %s, want missing", state) - } - if err := (Handler{}).NotReady(report); err == nil { - t.Error("NotReady must report an error for a failed report") - } -} - -func TestDoctorFixCreatesTheRootAndThenPasses(t *testing.T) { - t.Parallel() - - root := filepath.Join(t.TempDir(), "kb") - handler := Handler{} - - if _, err := handler.Doctor(context.Background(), Options{Root: root, Fix: true}); err != nil { - t.Fatalf("Doctor --fix: %v", err) - } - for _, dir := range []string{"metadata", "blobs", "tmp"} { - if _, err := os.Stat(filepath.Join(root, dir)); err != nil { - t.Errorf("--fix did not create %s: %v", dir, err) - } - } - - report, err := handler.Doctor(context.Background(), Options{Root: root}) - if err != nil { - t.Fatalf("Doctor: %v", err) - } - if report.Failed() { - t.Errorf("a repaired root must pass phase %s: %+v", report.Phase, report.Checks) - } - if err := handler.NotReady(report); err != nil { - t.Errorf("NotReady = %v, want nil", err) - } -} - -func TestDoctorRejectsARelativeRoot(t *testing.T) { - t.Parallel() - - if _, err := (Handler{}).Doctor(context.Background(), Options{Root: "relative/path"}); err == nil { - t.Fatal("Doctor must reject a relative root") - } -} - -func TestRenderJSONIsStableAndReadable(t *testing.T) { - t.Parallel() - - root := filepath.Join(t.TempDir(), "kb") - report, err := Handler{}.Doctor(context.Background(), Options{Root: root, Fix: true}) - if err != nil { - t.Fatalf("Doctor: %v", err) - } - - var buffer bytes.Buffer - if err := (Handler{}).Render(&buffer, report, true); err != nil { - t.Fatalf("Render: %v", err) - } - - var decoded struct { - Phase string `json:"phase"` - OS string `json:"os"` - Root string `json:"root"` - Checks []struct { - Name string `json:"name"` - State string `json:"state"` - Since string `json:"since"` - } `json:"checks"` - } - if err := json.Unmarshal(buffer.Bytes(), &decoded); err != nil { - t.Fatalf("Render --json produced invalid JSON: %v\n%s", err, buffer.String()) - } - if decoded.Phase != "S1" { - t.Errorf("phase = %q, want S1 as text", decoded.Phase) - } - if decoded.Root != report.Root { - t.Errorf("root = %q, want %q", decoded.Root, report.Root) - } - if len(decoded.Checks) == 0 { - t.Fatal("the report must contain checks") - } - if decoded.Checks[0].State == "" || decoded.Checks[0].Since == "" { - t.Errorf("check states and phases must be text, got %+v", decoded.Checks[0]) - } -} - -func TestRenderTextListsFixes(t *testing.T) { - t.Parallel() - - root := filepath.Join(t.TempDir(), "kb") - report, err := Handler{}.Doctor(context.Background(), Options{Root: root}) - if err != nil { - t.Fatalf("Doctor: %v", err) - } - - var buffer bytes.Buffer - if err := (Handler{}).Render(&buffer, report, false); err != nil { - t.Fatalf("Render: %v", err) - } - text := buffer.String() - for _, want := range []string{"CHECK", "root-directory", "fix:", "--fix"} { - if !strings.Contains(text, want) { - t.Errorf("text report does not mention %q:\n%s", want, text) - } - } -} - -func stateOf(t *testing.T, report host.Report, name string) host.State { - t.Helper() - for _, check := range report.Checks { - if check.Name == name { - return check.State - } - } - t.Fatalf("report has no check %q", name) - return host.StateOK -} diff --git a/cmd/errors.go b/cmd/errors.go deleted file mode 100644 index 50f23e1..0000000 --- a/cmd/errors.go +++ /dev/null @@ -1,67 +0,0 @@ -package cmd - -import ( - "encoding/json" - "errors" - "fmt" - "io" - - "github.com/kumabox/kumabox/config" - "github.com/kumabox/kumabox/host" - "github.com/kumabox/kumabox/layout" -) - -// Exit codes are documented in docs/BEHAVIOR.md §17. -const ( - exitOK = 0 - exitInternal = 1 - exitInvalid = 5 - exitUnavail = 6 -) - -// ErrUsage marks a failure of the command line itself: an unknown command, an -// unknown flag, or a bad argument. -var ErrUsage = errors.New("usage") - -// fail prints a failure the way the CLI contract requires and returns the code -// the process should exit with. -func fail(stderr io.Writer, err error) int { - fmt.Fprintf(stderr, "kumabox: %v\n", err) - code, exit := classify(err) - fmt.Fprintf(stderr, "code: %s\n", code) - return exit -} - -// classify maps a library error to its stable code and exit code. -// -// Library code returns sentinel errors wrapped with context; the mapping lives -// here, at the edge, so no library has to know about exit codes. Anything that -// reaches here without a known sentinel came from the command line itself, -// because every handler wraps its failures. -func classify(err error) (code string, exit int) { - switch { - case errors.Is(err, ErrUsage): - return "USAGE", exitInvalid - case errors.Is(err, config.ErrRootNotAbsolute), errors.Is(err, layout.ErrRootNotAbsolute): - return "ROOT_NOT_ABSOLUTE", exitInvalid - case errors.Is(err, layout.ErrRootEmpty): - return "ROOT_EMPTY", exitInvalid - case errors.Is(err, layout.ErrRootNotDirectory): - return "ROOT_NOT_DIRECTORY", exitInvalid - case errors.Is(err, layout.ErrRootNotWritable): - return "ROOT_NOT_WRITABLE", exitUnavail - case errors.Is(err, layout.ErrStatFailed): - return "ROOT_STAT_FAILED", exitInternal - case errors.Is(err, host.ErrNotReady): - return "HOST_NOT_READY", exitUnavail - default: - return "USAGE", exitInvalid - } -} - -// writeJSON renders a command result as JSON on out. -func writeJSON(out io.Writer, value any) error { - encoder := json.NewEncoder(out) - encoder.SetIndent("", " ") - return encoder.Encode(value) -} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..8af305d --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,110 @@ +// Package cmd builds the kumabox command tree. +// +// It owns flags, help and exit codes only. Each command lives in its own +// package and exposes a single NewCommand constructor; no command implements +// its logic here (docs/ARCHITECTURE.md §1). +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/version" +) + +// exitCoder is implemented by errors that know which exit code they deserve. +// Commands classify their own failures this way, so no shared error package is +// needed and a library never has to know about exit codes. +type exitCoder interface{ ExitCode() int } + +// codedError attaches an exit code to an error. +type codedError struct { + err error + code int +} + +func (e *codedError) Error() string { return e.err.Error() } +func (e *codedError) Unwrap() error { return e.err } +func (e *codedError) ExitCode() int { return e.code } + +// exitUsage is the exit code for a failure of the command line itself. +const exitUsage = 5 + +// Execute runs one invocation and returns the error it failed with, or nil. +// +// Errors raised while resolving or parsing the command line are reported as +// usage failures; everything else is expected to carry its own exit code. +func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) error { + root := newRootCommand() + root.SetArgs(args) + root.SetOut(stdout) + root.SetErr(stderr) + + err := root.ExecuteContext(ctx) + if err == nil { + return nil + } + var coded exitCoder + if errors.As(err, &coded) { + return err + } + return &codedError{err: err, code: exitUsage} +} + +// ExitCode maps the result of Execute to the process exit code documented in +// docs/BEHAVIOR.md §17. +func ExitCode(err error) int { + if err == nil { + return 0 + } + var coded exitCoder + if errors.As(err, &coded) { + return coded.ExitCode() + } + return 1 +} + +func newRootCommand() *cobra.Command { + root := &cobra.Command{ + Use: "kumabox", + Short: "microVM sandboxes for AI agents", + Long: "kumabox runs microVM sandboxes on this machine.\n\n" + + "Every command opens the node root, does one job and exits; there is no\n" + + "daemon in this version. Use --root to point at another root, for example\n" + + "while developing.\n\n" + + "Run doctor/check.sh first to see whether this machine is ready.", + SilenceUsage: true, + SilenceErrors: true, + } + root.PersistentFlags().String("root", "", + "node root directory (default $KUMABOX_ROOT, then /var/lib/kumabox)") + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return &codedError{err: err, code: exitUsage} + }) + + root.AddCommand(newVersionCommand()) + return root +} + +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/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..0b4f032 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" +) + +func execute(t *testing.T, args ...string) (error, string, string) { + t.Helper() + + var stdout, stderr bytes.Buffer + err := Execute(context.Background(), args, &stdout, &stderr) + return err, stdout.String(), stderr.String() +} + +func TestVersion(t *testing.T) { + t.Parallel() + + err, stdout, stderr := execute(t, "version", "--json") + if err != nil { + t.Fatalf("version --json: %v\nstderr: %s", err, stderr) + } + var payload map[string]string + if jsonErr := json.Unmarshal([]byte(stdout), &payload); jsonErr != nil { + t.Fatalf("version --json is not valid JSON: %v\n%s", jsonErr, stdout) + } + if payload["version"] == "" { + t.Error("version payload is empty") + } +} + +func TestUnknownCommandIsAUsageError(t *testing.T) { + t.Parallel() + + err, _, _ := execute(t, "frobnicate") + if err == nil { + t.Fatal("an unknown command must fail") + } + if code := ExitCode(err); code != 5 { + t.Errorf("exit code = %d, want 5", code) + } + if !strings.Contains(err.Error(), "unknown command") { + t.Errorf("error = %v, want it to name the unknown command", err) + } +} + +func TestUnknownFlagIsAUsageError(t *testing.T) { + t.Parallel() + + err, _, _ := execute(t, "version", "--nope") + if err == nil { + t.Fatal("an unknown flag must fail") + } + if code := ExitCode(err); code != 5 { + t.Errorf("exit code = %d, want 5", code) + } +} + +func TestSuccessExitsZero(t *testing.T) { + t.Parallel() + + if code := ExitCode(nil); code != 0 { + t.Errorf("ExitCode(nil) = %d, want 0", code) + } +} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 96b68fc..0000000 --- a/config/config.go +++ /dev/null @@ -1,45 +0,0 @@ -// Package config resolves KumaBox settings. -// -// It is the only package that reads the environment (docs/ARCHITECTURE.md -// §12.1). Precedence is flag, then environment, then default. A configuration -// file joins this package once there is more than one setting worth putting in -// it. -package config - -import ( - "errors" - "os" - "path/filepath" -) - -const ( - // EnvRoot overrides the node root directory. - EnvRoot = "KUMABOX_ROOT" - // DefaultRoot is the system-wide node root used by a package install. - DefaultRoot = "/var/lib/kumabox" -) - -// ErrRootNotAbsolute reports a root that is not an absolute path. -var ErrRootNotAbsolute = errors.New("root must be an absolute path") - -// Config holds the resolved settings for one invocation. -type Config struct { - // Root is the absolute node root directory. - Root string -} - -// Load resolves the configuration. flagRoot is the value of --root, or an -// empty string when the flag was not given. -func Load(flagRoot string) (Config, error) { - root := flagRoot - if root == "" { - root = os.Getenv(EnvRoot) - } - if root == "" { - root = DefaultRoot - } - if !filepath.IsAbs(root) { - return Config{}, ErrRootNotAbsolute - } - return Config{Root: filepath.Clean(root)}, nil -} diff --git a/config/config_test.go b/config/config_test.go deleted file mode 100644 index 6a9ff61..0000000 --- a/config/config_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package config - -import ( - "errors" - "path/filepath" - "testing" -) - -func TestLoadPrecedence(t *testing.T) { - temp := t.TempDir() - fromEnv := filepath.Join(temp, "from-env") - fromFlag := filepath.Join(temp, "from-flag") - - t.Run("flag wins over environment and default", func(t *testing.T) { - t.Setenv(EnvRoot, fromEnv) - cfg, err := Load(fromFlag) - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.Root != fromFlag { - t.Errorf("Root = %q, want the flag value %q", cfg.Root, fromFlag) - } - }) - - t.Run("environment wins over default", func(t *testing.T) { - t.Setenv(EnvRoot, fromEnv) - cfg, err := Load("") - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.Root != fromEnv { - t.Errorf("Root = %q, want the environment value %q", cfg.Root, fromEnv) - } - }) - - t.Run("default is used when nothing is set", func(t *testing.T) { - t.Setenv(EnvRoot, "") - cfg, err := Load("") - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.Root != DefaultRoot { - t.Errorf("Root = %q, want the default %q", cfg.Root, DefaultRoot) - } - }) -} - -func TestLoadRejectsRelativeRoots(t *testing.T) { - t.Run("from the flag", func(t *testing.T) { - t.Setenv(EnvRoot, "") - if _, err := Load("kb"); !errors.Is(err, ErrRootNotAbsolute) { - t.Fatalf("err = %v, want ErrRootNotAbsolute", err) - } - }) - - t.Run("from the environment", func(t *testing.T) { - t.Setenv(EnvRoot, "relative/root") - if _, err := Load(""); !errors.Is(err, ErrRootNotAbsolute) { - t.Fatalf("err = %v, want ErrRootNotAbsolute", err) - } - }) -} - -func TestLoadCleansThePath(t *testing.T) { - t.Setenv(EnvRoot, "") - cfg, err := Load("/var/lib/kumabox/") - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.Root != "/var/lib/kumabox" { - t.Errorf("Root = %q, want the cleaned path", cfg.Root) - } -} diff --git a/doctor/check.sh b/doctor/check.sh new file mode 100755 index 0000000..7a5cb81 --- /dev/null +++ b/doctor/check.sh @@ -0,0 +1,520 @@ +#!/usr/bin/env bash +# doctor/check.sh — Pre-flight check and repair tool for KumaBox. +# +# Usage: +# ./doctor/check.sh # Check only +# ./doctor/check.sh --fix # Check and fix issues +# ./doctor/check.sh --upgrade # Check, fix, and upgrade dependencies + +set -uo pipefail + +# --------------------------------------------------------------------------- +# Configuration (override via environment) +# --------------------------------------------------------------------------- +KUMABOX_ROOT_DIR="${KUMABOX_ROOT_DIR:-/var/lib/kumabox}" +KUMABOX_META_DIR="${KUMABOX_META_DIR:-${KUMABOX_ROOT_DIR}/metadata}" +KUMABOX_BLOBS_DIR="${KUMABOX_BLOBS_DIR:-${KUMABOX_ROOT_DIR}/blobs}" +KUMABOX_TMP_DIR="${KUMABOX_TMP_DIR:-${KUMABOX_ROOT_DIR}/tmp}" +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}" +# Overridable so the checks can be exercised without touching the real host. +KUMABOX_KVM_DEVICE="${KUMABOX_KVM_DEVICE:-/dev/kvm}" +KUMABOX_NETNS_DIR="${KUMABOX_NETNS_DIR:-/var/run/netns}" + +# Dependency versions +CH_VERSION="${CH_VERSION:-v53.0}" +CH_MIN_MAJOR="${CH_MIN_MAJOR:-43}" +FW_VERSION="${FW_VERSION:-0.5.0}" +CNI_VERSION="${CNI_VERSION:-v1.9.1}" + +# Architecture detection +ARCH=$(uname -m) +case "$ARCH" in + x86_64|amd64) GO_ARCH="amd64"; CH_SUFFIX=""; FW_SUFFIX="" ;; + aarch64|arm64) GO_ARCH="arm64"; CH_SUFFIX="-aarch64"; FW_SUFFIX="-aarch64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +FIRMWARE_DIR="${KUMABOX_ROOT_DIR}/firmware" +FIRMWARE_PATH="${FIRMWARE_DIR}/CLOUDHV.fd" + +# --------------------------------------------------------------------------- +# 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 < %s\033[0m\n" "$1"; } + +# --------------------------------------------------------------------------- +# CNI conflist generator +# --------------------------------------------------------------------------- +generate_cni_conflist() { + local subnet="${SUBNET:-10.88.0.0/16}" + + # Extract gateway: replace last octet of network address with .1 + local network_part + network_part=$(echo "$subnet" | cut -d/ -f1) + local prefix_len + prefix_len=$(echo "$subnet" | cut -d/ -f2) + # Simple gateway: network address with last octet = 1 + local gateway + gateway=$(echo "$network_part" | awk -F. '{printf "%s.%s.%s.1", $1, $2, $3}') + + # Match the host egress MTU (GCP is 1460, not 1500) so the bridge and TAPs do not + # blackhole large packets on the way out. + local host_iface host_mtu + host_iface=$(ip route show default 2>/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 ;; + qemu-img) 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" ] && ! ch_version_ok "$ver"; then + fail "$name (${ver:-unknown}) is older than v${CH_MIN_MAJOR} — kumabox refuses to launch on it" + 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 mkfs.erofs +check_binary mkfs.ext4 +# qemu-img is optional: only the qcow2 write path needs it. +if command -v qemu-img &>/dev/null; then + check_binary qemu-img +else + warn "qemu-img not found (optional, needed by the qcow2 write path)" +fi + +# --------------------------------------------------------------------------- +# 2. Firmware +# --------------------------------------------------------------------------- +header "Firmware" + +# Only the UEFI boot shape needs firmware; direct kernel boot does not. +if [ -f "$FIRMWARE_PATH" ]; then + local_size=$(stat -c%s "$FIRMWARE_PATH" 2>/dev/null || stat -f%z "$FIRMWARE_PATH" 2>/dev/null || echo 0) + pass "CLOUDHV.fd (${local_size} bytes) at $FIRMWARE_PATH" +else + warn "CLOUDHV.fd not found at $FIRMWARE_PATH (optional, needed by the uefi boot shape)" +fi + +# --------------------------------------------------------------------------- +# 3. KVM access +# --------------------------------------------------------------------------- +header "KVM" + +if [ -e "$KUMABOX_KVM_DEVICE" ]; then + if [ -r "$KUMABOX_KVM_DEVICE" ] && [ -w "$KUMABOX_KVM_DEVICE" ]; then + pass "$KUMABOX_KVM_DEVICE accessible" + else + fail "$KUMABOX_KVM_DEVICE exists but not readable/writable by $(whoami)" + if $FIX; then + chmod 666 "$KUMABOX_KVM_DEVICE" 2>/dev/null && fixed "chmod 666 $KUMABOX_KVM_DEVICE" || warn "failed to fix (need root?)" + fi + fi +else + fail "$KUMABOX_KVM_DEVICE not found (nested virtualization or bare-metal required)" +fi + +# --------------------------------------------------------------------------- +# 4. Runtime 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" +check_dir "$KUMABOX_META_DIR" +check_dir "$KUMABOX_BLOBS_DIR" +check_dir "$KUMABOX_TMP_DIR" +check_dir "$KUMABOX_RUN_DIR" +check_dir "$KUMABOX_LOG_DIR" +check_dir "$FIRMWARE_DIR" +check_dir "$KUMABOX_NETNS_DIR" + +# SQLite WAL needs coherent shared memory; report the same filesystem refusal +# enforced on open (docs/ARCHITECTURE.md §7). +meta_fstype=$(stat -f -c %T "$KUMABOX_ROOT_DIR" 2>/dev/null | head -1 | tr -d '[:space:]') +meta_fstype=${meta_fstype:-unknown} +case "$meta_fstype" in + nfs*|cifs|smb*|fuse*) + fail "root on $meta_fstype: sqlite WAL needs coherent shared memory; kumabox refuses this filesystem" + ;; + *) + pass "root filesystem ($meta_fstype) supports WAL" + ;; +esac + +# --------------------------------------------------------------------------- +# 5. 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 + +# --------------------------------------------------------------------------- +# 6. iptables FORWARD rules for the CNI 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 + +# --------------------------------------------------------------------------- +# 7. CNI configuration +# --------------------------------------------------------------------------- +header "CNI configuration" + +CNI_CONFLIST="${KUMABOX_CNI_CONF_DIR}/10-kumabox.conflist" + +if [ -d "$KUMABOX_CNI_CONF_DIR" ]; then + conflist_count=$(find "$KUMABOX_CNI_CONF_DIR" -maxdepth 1 -name '*.conflist' 2>/dev/null | wc -l) + if [ "$conflist_count" -gt 0 ]; then + first=$(find "$KUMABOX_CNI_CONF_DIR" -maxdepth 1 -name '*.conflist' 2>/dev/null | sort | head -1) + pass "conflist: $(basename "$first")" + else + fail "no .conflist files in $KUMABOX_CNI_CONF_DIR" + if $FIX; then + generate_cni_conflist + fi + fi +else + fail "$KUMABOX_CNI_CONF_DIR does not exist" + if $FIX; then + mkdir -p "$KUMABOX_CNI_CONF_DIR" && fixed "created $KUMABOX_CNI_CONF_DIR" || warn "failed" + generate_cni_conflist + fi +fi + +# --------------------------------------------------------------------------- +# 8. 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 + +# --------------------------------------------------------------------------- +# 9. Store health +# --------------------------------------------------------------------------- +header "Store health" + +DB_PATH="${KUMABOX_META_DIR}/kumabox.db" + +if [ -f "$DB_PATH" ]; then + pass "fact database present ($(stat -c%s "$DB_PATH" 2>/dev/null || echo '?') bytes)" + if command -v sqlite3 &>/dev/null; then + # Anything stuck mid-flight is what doctor is meant to surface; the + # sweeper in 'kumabox gc' finishes or fails it (docs/BEHAVIOR.md §15). + STUCK=$(sqlite3 "$DB_PATH" "select count(*) from images where state='importing'" 2>/dev/null || echo "?") + if [ "$STUCK" = "?" ]; then + warn "cannot read the images table (schema older than this build?)" + elif [ "$STUCK" != "0" ]; then + warn "$STUCK image import(s) left mid-flight; run 'kumabox gc' to finish or fail them" + else + pass "no image import left mid-flight" + fi + else + warn "sqlite3 not found — skipping store inspection" + fi +else + info "no fact database yet (first command creates it)" +fi + +# Staging leftovers are always worth reporting: they are disk usage nobody owns. +if [ -d "$KUMABOX_TMP_DIR" ]; then + staging=$(find "$KUMABOX_TMP_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l) + if [ "$staging" -gt 0 ]; then + warn "$staging staging entr(ies) in $KUMABOX_TMP_DIR; run 'kumabox gc' to reclaim them" + else + pass "no staging leftovers" + fi +fi + +# --------------------------------------------------------------------------- +# 10. 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 + + # -- firmware ----------------------------------------------------------- + header "Install hypervisor-fw ${FW_VERSION}" + + fw_url="https://github.com/cloud-hypervisor/rust-hypervisor-firmware/releases/download/${FW_VERSION}/hypervisor-fw${FW_SUFFIX}" + mkdir -p "$FIRMWARE_DIR" + info "downloading ${fw_url}" + if curl -fsSL -o "${FIRMWARE_PATH}" "$fw_url"; then + fixed "hypervisor-fw ${FW_VERSION} -> ${FIRMWARE_PATH}" + else + fail "failed to download firmware from ${fw_url}" + fi + + # -- mkfs helpers ------------------------------------------------------- + for pkg_bin in "erofs-utils:mkfs.erofs" "e2fsprogs:mkfs.ext4"; do + pkg="${pkg_bin%%:*}"; bin="${pkg_bin##*:}" + if ! command -v "$bin" &>/dev/null; then + header "Install ${pkg}" + if command -v apt-get &>/dev/null; then + apt-get install -y -qq "$pkg" &>/dev/null && fixed "${pkg} installed via apt-get" || warn "failed to install ${pkg}" + elif command -v yum &>/dev/null; then + yum install -y -q "$pkg" &>/dev/null && fixed "${pkg} installed via yum" || warn "failed to install ${pkg}" + else + warn "${pkg} not installed (install ${bin} manually)" + fi + fi + done + + # -- 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 +# --------------------------------------------------------------------------- +printf "\n\033[1m--- Summary ---\033[0m\n" +printf " Pass: %d Warn: %d Fail: %d\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, firmware, and CNI plugins" +fi + +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/doctor/check_test.sh b/doctor/check_test.sh new file mode 100755 index 0000000..a48b083 --- /dev/null +++ b/doctor/check_test.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# doctor/check_test.sh — tests for doctor/check.sh. +# +# Runs the checker against a throwaway root and a PATH full of fake tools, so it +# needs no root, no KVM and no Cloud Hypervisor. Every external command the +# checker calls is replaced, which also means the checks themselves are asserted +# rather than merely exercised. + +set -uo pipefail + +repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +checker="${repo_dir}/doctor/check.sh" + +ESC=$(printf '\033') +strip_ansi() { sed "s/${ESC}\[[0-9;]*m//g"; } + +PASSED=0 +FAILED=0 + +ok() { PASSED=$((PASSED + 1)); printf " \033[32mok\033[0m %s\n" "$1"; } +not_ok() { FAILED=$((FAILED + 1)); printf " \033[31mFAIL\033[0m %s\n" "$1"; } + +assert_contains() { + local haystack="$1" needle="$2" what="$3" + if grep -qF -- "$needle" <<<"$haystack"; then + ok "$what" + else + not_ok "$what (missing: $needle)" + printf '%s\n' "$haystack" | sed 's/^/ | /' + fi +} + +assert_not_contains() { + local haystack="$1" needle="$2" what="$3" + if grep -qF -- "$needle" <<<"$haystack"; then + not_ok "$what (unexpected: $needle)" + printf '%s\n' "$haystack" | sed 's/^/ | /' + else + ok "$what" + fi +} + +assert_dir() { + if [ -d "$1" ]; then ok "created $1"; else not_ok "$1 was not created"; fi +} + +assert_missing() { + if [ -e "$1" ]; then not_ok "$1 should not exist"; else ok "$1 not created"; fi +} + +sandbox=$(mktemp -d) +trap 'rm -rf "$sandbox"' EXIT + +root="${sandbox}/root" +fake_bin="${sandbox}/bin" +cni_conf="${sandbox}/cni-conf" +cni_bin="${sandbox}/cni-bin" +netns_dir="${sandbox}/netns" +kvm_device="${sandbox}/kvm" +mkdir -p "$fake_bin" "$cni_bin" "$netns_dir" + +# --- fake tools ------------------------------------------------------------- +cat > "${fake_bin}/cloud-hypervisor" <<'EOF' +#!/usr/bin/env bash +echo "Cloud Hypervisor v${FAKE_CH_VERSION:-53.0}" +EOF + +cat > "${fake_bin}/mkfs.erofs" <<'EOF' +#!/usr/bin/env bash +echo "mkfs.erofs ${FAKE_EROFS_VERSION:-1.8.1}" +EOF + +cat > "${fake_bin}/mkfs.ext4" <<'EOF' +#!/usr/bin/env bash +[ "${1:-}" = "-V" ] && echo "mke2fs 1.47.0" +EOF + +# The checker only asks whether a rule exists (or adds it); both succeed here. +cat > "${fake_bin}/iptables" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + +# Report the expected values for the two keys the checker inspects. +cat > "${fake_bin}/sysctl" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + -n) case "${2:-}" in + net.ipv4.ip_forward|net.bridge.bridge-nf-call-iptables) echo 1 ;; + *) echo "" ;; + esac ;; + -w) exit 0 ;; +esac +exit 0 +EOF + +cat > "${fake_bin}/ip" <<'EOF' +#!/usr/bin/env bash +case "$*" in + "route show default") echo "default via 10.0.0.1 dev eth0" ;; + "link show eth0") echo "2: eth0: mtu 1460 state UP" ;; +esac +exit 0 +EOF + +chmod 0755 "${fake_bin}"/* + +for plugin in bridge host-local loopback; do + printf '#!/usr/bin/env bash\n' > "${cni_bin}/${plugin}" + chmod 0755 "${cni_bin}/${plugin}" +done +printf '{}\n' > "${cni_conf}/10-kumabox.conflist" +printf 'device\n' > "$kvm_device" +chmod 0666 "$kvm_device" + +# run_checker keeps both the output and the status of one invocation in +# CHECK_OUTPUT and CHECK_STATUS. +CHECK_OUTPUT="" +CHECK_STATUS=0 +run_checker() { + CHECK_OUTPUT=$(env -i \ + PATH="${fake_bin}:/usr/bin:/bin" \ + HOME="$sandbox" \ + KUMABOX_ROOT_DIR="$root" \ + KUMABOX_RUN_DIR="${sandbox}/run" \ + KUMABOX_LOG_DIR="${sandbox}/log" \ + KUMABOX_META_DIR="${root}/metadata" \ + KUMABOX_BLOBS_DIR="${root}/blobs" \ + KUMABOX_TMP_DIR="${root}/tmp" \ + KUMABOX_CNI_CONF_DIR="$cni_conf" \ + KUMABOX_CNI_BIN_DIR="$cni_bin" \ + KUMABOX_NETNS_DIR="$netns_dir" \ + KUMABOX_KVM_DEVICE="$kvm_device" \ + FAKE_CH_VERSION="${FAKE_CH_VERSION:-53.0}" \ + FAKE_EROFS_VERSION="${FAKE_EROFS_VERSION:-1.8.1}" \ + bash "$checker" "$@" 2>&1) + CHECK_STATUS=$? + CHECK_OUTPUT=$(printf '%s' "$CHECK_OUTPUT" | strip_ansi) +} + +printf '\n\033[1mdoctor/check.sh\033[0m\n' + +# --- 1. help ---------------------------------------------------------------- +run_checker --help +if [ "$CHECK_STATUS" -eq 0 ]; then ok "help exits 0"; else not_ok "help exit code = $CHECK_STATUS"; fi +assert_contains "$CHECK_OUTPUT" "Usage:" "help prints usage" +assert_contains "$CHECK_OUTPUT" "KUMABOX_KVM_DEVICE" "help documents the KVM override" + +# --- 2. a fresh root fails and changes nothing ------------------------------ +run_checker +if [ "$CHECK_STATUS" -eq 1 ]; then ok "a fresh root fails with exit 1"; else not_ok "exit code = $CHECK_STATUS, want 1"; fi +assert_contains "$CHECK_OUTPUT" "[FAIL] ${root} does not exist" "reports the missing root" +assert_missing "$root" + +# --- 3. --fix creates the managed directories ------------------------------- +# --fix repairs what it finds, but the failures it found still count: the +# summary reports them and the exit code stays 1 until a clean re-run. +run_checker --fix +if [ "$CHECK_STATUS" -eq 1 ]; then ok "--fix reports the failures it repaired"; else + not_ok "--fix exit code = $CHECK_STATUS, want 1 (failures were found)" + printf '%s\n' "$CHECK_OUTPUT" | sed 's/^/ | /' +fi +assert_contains "$CHECK_OUTPUT" "[FIXED] created ${root}/blobs" "reports what it created" +for dir in "$root" "${root}/metadata" "${root}/blobs" "${root}/tmp" \ + "${root}/firmware" "${sandbox}/run" "${sandbox}/log" "$netns_dir"; do + assert_dir "$dir" +done + +# --- 4. the second run passes the checks it just fixed ---------------------- +run_checker +if [ "$CHECK_STATUS" -eq 0 ]; then ok "a repaired root passes"; else + not_ok "exit code = $CHECK_STATUS after --fix" + printf '%s\n' "$CHECK_OUTPUT" | sed 's/^/ | /' +fi +assert_contains "$CHECK_OUTPUT" "[PASS] ${root}/blobs" "root directories are reported as present" +assert_contains "$CHECK_OUTPUT" "[PASS] cloud-hypervisor (Cloud Hypervisor v53.0)" "reads the Cloud Hypervisor version" +assert_contains "$CHECK_OUTPUT" "[PASS] mkfs.erofs (mkfs.erofs 1.8.1)" "reads the erofs-utils version" +assert_contains "$CHECK_OUTPUT" "[PASS] ${kvm_device} accessible" "reports the KVM device as usable" +assert_contains "$CHECK_OUTPUT" "[PASS] bridge" "finds the CNI plugins" +assert_contains "$CHECK_OUTPUT" "Pass:" "prints a summary" + +# --- 5. the erofs floor is enforced ---------------------------------------- +FAKE_EROFS_VERSION=1.7.4 run_checker +if [ "$CHECK_STATUS" -eq 1 ]; then ok "an old mkfs.erofs fails the check"; else not_ok "exit code = $CHECK_STATUS, want 1"; fi +assert_contains "$CHECK_OUTPUT" "[FAIL] mkfs.erofs (mkfs.erofs 1.7.4) is older than 1.8" "explains the erofs floor" + +# --- 6. the Cloud Hypervisor floor is enforced ------------------------------ +FAKE_CH_VERSION=42.0 run_checker +if [ "$CHECK_STATUS" -eq 1 ]; then ok "an old cloud-hypervisor fails the check"; else not_ok "exit code = $CHECK_STATUS, want 1"; fi +assert_contains "$CHECK_OUTPUT" "is older than v43" "explains the Cloud Hypervisor floor" + +# --- 7. stray staging directories are reported ------------------------------ +mkdir -p "${root}/tmp/import-1234" +run_checker +assert_contains "$CHECK_OUTPUT" "staging entr" "warns about staging leftovers" +assert_contains "$CHECK_OUTPUT" "kumabox gc" "points at the command that reclaims them" + +printf "\n %d passed, %d failed\n\n" "$PASSED" "$FAILED" +[ "$FAILED" -eq 0 ] diff --git a/host/collect.go b/host/collect.go deleted file mode 100644 index 552332a..0000000 --- a/host/collect.go +++ /dev/null @@ -1,135 +0,0 @@ -package host - -import ( - "context" - "os" - "os/exec" - "runtime" - "strings" - "time" - - "github.com/kumabox/kumabox/layout" -) - -const ( - // KVMPath is the device a microVM needs. - KVMPath = "/dev/kvm" - // DefaultCNIPluginDir is where CNI plugins are installed. - DefaultCNIPluginDir = "/opt/cni/bin" - // DefaultCNIConfigDir is where CNI network configuration lives. - DefaultCNIConfigDir = "/etc/cni/net.d" - // cgroupV2Marker exists only when the unified cgroup hierarchy is mounted. - cgroupV2Marker = "/sys/fs/cgroup/cgroup.controllers" - - // binaryTimeout bounds the version probe so doctor can never hang. - binaryTimeout = 5 * time.Second -) - -// Collector gathers facts about the machine. -// -// It only ever reads: no directory is created, no privileged operation is -// performed. Everything that needs root belongs to hack/host-install.sh -// (docs/HOST.md §4). -type Collector struct { - // CNIPluginDir and CNIConfigDir are overridable for tests. - CNIPluginDir string - CNIConfigDir string -} - -// Collect reports what this machine looks like right now. -func (c Collector) Collect(ctx context.Context, root layout.Root) (Facts, error) { - facts := Facts{ - OS: runtime.GOOS, - Arch: runtime.GOARCH, - Kernel: kernelVersion(), - KVM: KVM{Device: KVMPath}, - VMM: Binary{Name: "cloud-hypervisor"}, - CNI: CNI{PluginBinDir: c.pluginDir(), ConfigDir: c.configDir()}, - CgroupV2: exists(cgroupV2Marker), - } - - state, err := root.Inspect() - if err != nil { - return Facts{}, err - } - facts.Root = Root{ - Path: root.Dir(), - Exists: state.Exists, - Writable: state.Writable, - TotalBytes: state.TotalBytes, - FreeBytes: state.FreeBytes, - } - - facts.KVM.Present, facts.KVM.Readable = kvmState() - facts.VMM = binaryFacts(ctx, facts.VMM.Name) - facts.CNI.PluginsFound = hasEntries(facts.CNI.PluginBinDir) - facts.CNI.ConfigFound = hasEntries(facts.CNI.ConfigDir) - return facts, nil -} - -func (c Collector) pluginDir() string { - if c.CNIPluginDir != "" { - return c.CNIPluginDir - } - return DefaultCNIPluginDir -} - -func (c Collector) configDir() string { - if c.CNIConfigDir != "" { - return c.CNIConfigDir - } - return DefaultCNIConfigDir -} - -// kvmState reports whether the device exists and can be opened for read-write, -// which is what the runtime will need. -func kvmState() (present, readable bool) { - info, err := os.Stat(KVMPath) - if err != nil || info.IsDir() { - return false, false - } - file, err := os.OpenFile(KVMPath, os.O_RDWR, 0) - if err != nil { - return true, false - } - _ = file.Close() - return true, true -} - -// binaryFacts looks up a tool and asks it for its version. -func binaryFacts(ctx context.Context, name string) Binary { - binary := Binary{Name: name} - path, err := exec.LookPath(name) - if err != nil { - return binary - } - binary.Path = path - binary.Found = true - - probeCtx, cancel := context.WithTimeout(ctx, binaryTimeout) - defer cancel() - output, err := exec.CommandContext(probeCtx, path, "--version").Output() - if err != nil { - return binary - } - binary.Version = firstLine(string(output)) - return binary -} - -func firstLine(text string) string { - if index := strings.IndexByte(text, '\n'); index >= 0 { - text = text[:index] - } - return strings.TrimSpace(text) -} - -// hasEntries reports whether dir exists and contains at least one entry. -func hasEntries(dir string) bool { - entries, err := os.ReadDir(dir) - return err == nil && len(entries) > 0 -} - -func exists(path string) bool { - _, err := os.Stat(path) - return err == nil -} diff --git a/host/collect_darwin.go b/host/collect_darwin.go deleted file mode 100644 index 488215b..0000000 --- a/host/collect_darwin.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build darwin - -package host - -import "syscall" - -// kernelVersion reports the running XNU version. -func kernelVersion() string { - version, err := syscall.Sysctl("kern.osrelease") - if err != nil { - return "" - } - return version -} diff --git a/host/collect_linux.go b/host/collect_linux.go deleted file mode 100644 index 04b4043..0000000 --- a/host/collect_linux.go +++ /dev/null @@ -1,17 +0,0 @@ -//go:build linux - -package host - -import ( - "os" - "strings" -) - -// kernelVersion reads the running kernel release from /proc. -func kernelVersion() string { - data, err := os.ReadFile("/proc/sys/kernel/osrelease") - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} diff --git a/host/collect_other.go b/host/collect_other.go deleted file mode 100644 index 7e01ca7..0000000 --- a/host/collect_other.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !linux && !darwin - -package host - -// kernelVersion is unavailable on this platform. Every Linux-only requirement -// is already reported as unsupported here. -func kernelVersion() string { return "" } diff --git a/host/collect_test.go b/host/collect_test.go deleted file mode 100644 index 3cc6647..0000000 --- a/host/collect_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package host - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/kumabox/kumabox/layout" -) - -func TestCollectReportsHostAndRoot(t *testing.T) { - t.Parallel() - - root := tempRoot(t) - cniPlugins := t.TempDir() - cniConfig := t.TempDir() - if err := os.WriteFile(filepath.Join(cniPlugins, "bridge"), []byte("#!/bin/sh\n"), 0o755); err != nil { - t.Fatalf("seed cni plugin: %v", err) - } - if err := os.WriteFile(filepath.Join(cniConfig, "10-kumabox.conflist"), []byte("{}\n"), 0o644); err != nil { - t.Fatalf("seed cni config: %v", err) - } - - collector := Collector{CNIPluginDir: cniPlugins, CNIConfigDir: cniConfig} - facts, err := collector.Collect(context.Background(), root) - if err != nil { - t.Fatalf("Collect: %v", err) - } - - if facts.OS == "" || facts.Arch == "" { - t.Errorf("OS/Arch must always be reported, got %q/%q", facts.OS, facts.Arch) - } - if facts.Root.Path != root.Dir() { - t.Errorf("root path = %q, want %q", facts.Root.Path, root.Dir()) - } - if !facts.Root.Exists || !facts.Root.Writable { - t.Errorf("a prepared root must be reported as writable: %+v", facts.Root) - } - if facts.Root.TotalBytes == 0 || facts.Root.FreeBytes == 0 { - t.Errorf("free and total space must be measured: %+v", facts.Root) - } - if !facts.CNI.PluginsFound || !facts.CNI.ConfigFound { - t.Errorf("seeded CNI directories must be detected: %+v", facts.CNI) - } - if facts.VMM.Name != "cloud-hypervisor" { - t.Errorf("VMM name = %q", facts.VMM.Name) - } -} - -func TestCollectDoesNotCreateTheRoot(t *testing.T) { - t.Parallel() - - missing := filepath.Join(t.TempDir(), "not-created-yet", "root") - root, err := layout.New(missing) - if err != nil { - t.Fatalf("layout.New: %v", err) - } - - collector := Collector{CNIPluginDir: t.TempDir(), CNIConfigDir: t.TempDir()} - facts, err := collector.Collect(context.Background(), root) - if err != nil { - t.Fatalf("Collect: %v", err) - } - if facts.Root.Exists { - t.Errorf("Collect must never create the root: %+v", facts.Root) - } - if _, err := os.Stat(missing); !os.IsNotExist(err) { - t.Errorf("a read-only check created the root: %v", err) - } - if facts.Root.FreeBytes == 0 { - t.Errorf("free space of the nearest existing ancestor must be measured: %+v", facts.Root) - } -} - -func TestCollectReportsEmptyCNIDirectoriesAsMissing(t *testing.T) { - t.Parallel() - - collector := Collector{ - CNIPluginDir: t.TempDir(), - CNIConfigDir: filepath.Join(t.TempDir(), "absent"), - } - facts, err := collector.Collect(context.Background(), tempRoot(t)) - if err != nil { - t.Fatalf("Collect: %v", err) - } - if facts.CNI.PluginsFound { - t.Error("an empty plugin directory must not count as found") - } - if facts.CNI.ConfigFound { - t.Error("an absent config directory must not count as found") - } -} - -func tempRoot(t *testing.T) layout.Root { - t.Helper() - - root, err := layout.New(t.TempDir()) - if err != nil { - t.Fatalf("layout.New: %v", err) - } - if err := root.Prepare(); err != nil { - t.Fatalf("root.Prepare: %v", err) - } - return root -} diff --git a/host/facts.go b/host/facts.go deleted file mode 100644 index da045b7..0000000 --- a/host/facts.go +++ /dev/null @@ -1,372 +0,0 @@ -// Package host describes what this machine can do and whether it is ready for -// the phase being worked on (docs/HOST.md). -// -// Collecting facts reads the machine; evaluating them is a pure function, so -// every judgement can be tested on any platform, including a development laptop -// with no KVM. -package host - -import ( - "errors" - "fmt" - "strings" -) - -// ErrNotReady reports that this machine cannot run the phase being checked. -var ErrNotReady = errors.New("machine is not ready") - -// Phase is a delivery phase from docs/ROADMAP.md. A requirement records the -// phase from which it becomes mandatory, so doctor can tell "not ready" apart -// from "not needed yet". -type Phase uint8 - -// Delivery phases, in order. -const ( - PhaseImage Phase = iota - PhaseSandbox - PhaseNetwork - PhaseSnapshot - PhaseClone - PhaseConvergence - PhaseCrossNode - PhaseProduction -) - -func (p Phase) String() string { - if p > PhaseProduction { - return "unknown" - } - return fmt.Sprintf("S%d", int(p)+1) -} - -// MarshalText renders the phase as "S1".."S8" so a JSON report is readable by a -// control plane instead of a bare integer. -func (p Phase) MarshalText() ([]byte, error) { return []byte(p.String()), nil } - -// State is the outcome of one check, per docs/HOST.md §3. -type State uint8 - -// Check outcomes. -const ( - StateOK State = iota - StateMissing - StateUnsupported - StateNotRequired -) - -func (s State) String() string { - switch s { - case StateOK: - return "ok" - case StateMissing: - return "missing" - case StateUnsupported: - return "unsupported" - case StateNotRequired: - return "not-required" - default: - return "unknown" - } -} - -// MarshalText renders the state as text, never as an integer. -func (s State) MarshalText() ([]byte, error) { return []byte(s.String()), nil } - -// Facts is a snapshot of what this machine looks like right now. -type Facts struct { - OS string `json:"os"` - Arch string `json:"arch"` - Kernel string `json:"kernel"` - Root Root `json:"root"` - KVM KVM `json:"kvm"` - VMM Binary `json:"cloud_hypervisor"` - CNI CNI `json:"cni"` - CgroupV2 bool `json:"cgroup_v2"` -} - -// Root describes the node root directory. -type Root struct { - Path string `json:"path"` - Exists bool `json:"exists"` - Writable bool `json:"writable"` - TotalBytes uint64 `json:"total_bytes"` - FreeBytes uint64 `json:"free_bytes"` -} - -// KVM describes the virtualization device. -type KVM struct { - Device string `json:"device"` - Present bool `json:"present"` - Readable bool `json:"readable"` -} - -// Binary describes an external executable the runtime depends on. -type Binary struct { - Name string `json:"name"` - Path string `json:"path"` - Version string `json:"version"` - Found bool `json:"found"` -} - -// CNI describes the CNI plugin and configuration directories. -type CNI struct { - PluginBinDir string `json:"plugin_bin_dir"` - ConfigDir string `json:"config_dir"` - PluginsFound bool `json:"plugins_found"` - ConfigFound bool `json:"config_found"` -} - -// Check is the evaluation of one requirement. -type Check struct { - Name string `json:"name"` - State State `json:"state"` - Since Phase `json:"since"` - Detail string `json:"detail"` - Fix string `json:"fix,omitempty"` -} - -// Report is the full doctor result. -type Report struct { - Phase Phase `json:"phase"` - OS string `json:"os"` - Arch string `json:"arch"` - Kernel string `json:"kernel"` - Root string `json:"root"` - Checks []Check `json:"checks"` -} - -// Failed reports whether a requirement of the current phase is unmet. Checks -// that belong to a later phase never make a run fail. -func (r Report) Failed() bool { - for _, check := range r.Checks { - if check.State == StateMissing || check.State == StateUnsupported { - return true - } - } - return false -} - -// minCHMajor is the oldest Cloud Hypervisor KumaBox supports. It is a -// placeholder until S2 pins the version against a real deployment. -const minCHMajor = 43 - -// minFreeBytes is the free space a node root must have before it counts as -// usable. It is deliberately generous until S2 measures real image sizes. -const minFreeBytes = uint64(10) << 30 - -// Evaluate judges facts against the requirements of the given phase. -func Evaluate(facts Facts, phase Phase) Report { - return Report{ - Phase: phase, - OS: facts.OS, - Arch: facts.Arch, - Kernel: facts.Kernel, - Root: facts.Root.Path, - Checks: []Check{ - rootWritable(facts, phase), - rootSpace(facts, phase), - linuxKernel(facts, phase), - kvmAvailable(facts, phase), - cloudHypervisor(facts, phase), - cniAvailable(facts, phase), - cgroupV2(facts, phase), - }, - } -} - -func rootWritable(facts Facts, phase Phase) Check { - check := Check{ - Name: "root-directory", - Since: PhaseImage, - Fix: fmt.Sprintf("kumabox doctor --fix --root %s", facts.Root.Path), - } - switch { - case facts.Root.Path == "": - check.State = StateUnsupported - check.Detail = "no root directory was resolved" - case !facts.Root.Exists: - check.State = StateMissing - check.Detail = fmt.Sprintf("%s does not exist yet", facts.Root.Path) - case !facts.Root.Writable: - check.State = StateMissing - check.Detail = fmt.Sprintf("%s exists but is not writable", facts.Root.Path) - default: - check.State = StateOK - check.Detail = fmt.Sprintf("%s is writable", facts.Root.Path) - check.Fix = "" - } - return withPhase(check, phase) -} - -func rootSpace(facts Facts, phase Phase) Check { - check := Check{ - Name: "root-space", - Since: PhaseImage, - Fix: "free space on the volume that holds the node root", - } - switch { - case facts.Root.FreeBytes == 0: - check.State = StateMissing - check.Detail = "free space could not be determined" - case facts.Root.FreeBytes < minFreeBytes: - check.State = StateMissing - check.Detail = fmt.Sprintf("only %s free, want at least %s", - HumanBytes(facts.Root.FreeBytes), HumanBytes(minFreeBytes)) - default: - check.State = StateOK - check.Detail = fmt.Sprintf("%s free of %s", - HumanBytes(facts.Root.FreeBytes), HumanBytes(facts.Root.TotalBytes)) - check.Fix = "" - } - return withPhase(check, phase) -} - -func linuxKernel(facts Facts, phase Phase) Check { - check := Check{ - Name: "linux-kernel", - Since: PhaseSandbox, - Fix: "run KumaBox on Linux; microVMs need KVM and cgroup v2", - } - if facts.OS == "linux" { - check.State = StateOK - check.Detail = fmt.Sprintf("linux %s %s", facts.Kernel, facts.Arch) - check.Fix = "" - } else { - check.State = StateUnsupported - check.Detail = fmt.Sprintf("%s/%s cannot boot microVMs", facts.OS, facts.Arch) - } - return withPhase(check, phase) -} - -func kvmAvailable(facts Facts, phase Phase) Check { - check := Check{ - Name: "kvm", - Since: PhaseSandbox, - Fix: "load the kvm module and make " + facts.KVM.Device + " readable by the KumaBox user", - } - switch { - case facts.KVM.Present && facts.KVM.Readable: - check.State = StateOK - check.Detail = facts.KVM.Device + " is readable" - check.Fix = "" - case facts.KVM.Present: - check.State = StateMissing - check.Detail = facts.KVM.Device + " exists but is not readable" - case facts.OS != "linux": - check.State = StateUnsupported - check.Detail = "KVM requires Linux" - default: - check.State = StateMissing - check.Detail = facts.KVM.Device + " does not exist" - } - return withPhase(check, phase) -} - -func cloudHypervisor(facts Facts, phase Phase) Check { - required := fmt.Sprintf("v%d.0", minCHMajor) - check := Check{ - Name: "cloud-hypervisor", - Since: PhaseSandbox, - Fix: "install Cloud Hypervisor " + required + " or newer (see hack/host-install.sh)", - } - switch { - case !facts.VMM.Found: - check.State = StateMissing - check.Detail = fmt.Sprintf("%s not found in PATH", facts.VMM.Name) - case MajorOf(facts.VMM.Version) < minCHMajor: - check.State = StateMissing - check.Detail = fmt.Sprintf("%s is older than %s", facts.VMM.Version, required) - default: - check.State = StateOK - check.Detail = fmt.Sprintf("%s at %s", facts.VMM.Version, facts.VMM.Path) - check.Fix = "" - } - return withPhase(check, phase) -} - -func cniAvailable(facts Facts, phase Phase) Check { - check := Check{ - Name: "cni", - Since: PhaseNetwork, - Fix: "install CNI plugins into " + facts.CNI.PluginBinDir + - " and add a conflist to " + facts.CNI.ConfigDir, - } - switch { - case facts.CNI.PluginsFound && facts.CNI.ConfigFound: - check.State = StateOK - check.Detail = fmt.Sprintf("plugins in %s, config in %s", - facts.CNI.PluginBinDir, facts.CNI.ConfigDir) - check.Fix = "" - case !facts.CNI.PluginsFound: - check.State = StateMissing - check.Detail = facts.CNI.PluginBinDir + " has no plugins" - default: - check.State = StateMissing - check.Detail = facts.CNI.ConfigDir + " has no network configuration" - } - return withPhase(check, phase) -} - -func cgroupV2(facts Facts, phase Phase) Check { - check := Check{ - Name: "cgroup-v2", - Since: PhaseConvergence, - Fix: "boot with the unified cgroup hierarchy (systemd.unified_cgroup_hierarchy=1)", - } - switch { - case facts.CgroupV2: - check.State = StateOK - check.Detail = "unified hierarchy is mounted" - check.Fix = "" - case facts.OS != "linux": - check.State = StateUnsupported - check.Detail = "cgroup v2 requires Linux" - default: - check.State = StateMissing - check.Detail = "/sys/fs/cgroup/cgroup.controllers is missing" - } - return withPhase(check, phase) -} - -// withPhase downgrades a failure to StateNotRequired when the requirement -// belongs to a later phase than the one being checked. -func withPhase(check Check, phase Phase) Check { - if check.Since > phase && (check.State == StateMissing || check.State == StateUnsupported) { - check.State = StateNotRequired - check.Detail = fmt.Sprintf("%s (needed from %s)", check.Detail, check.Since) - check.Fix = "" - } - return check -} - -// MajorOf extracts the major version from strings such as "Cloud Hypervisor -// v43.0" or "43.0". It reports 0 when no version can be read. -func MajorOf(version string) int { - for i := 0; i < len(version); i++ { - if version[i] < '0' || version[i] > '9' { - continue - } - major := 0 - for ; i < len(version) && version[i] >= '0' && version[i] <= '9'; i++ { - major = major*10 + int(version[i]-'0') - } - return major - } - return 0 -} - -// HumanBytes renders a byte count the way an operator reads it. -func HumanBytes(n uint64) string { - const unit = 1024 - if n < unit { - return fmt.Sprintf("%d B", n) - } - value := float64(n) - for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB", "PiB"} { - value /= unit - if value < unit { - return strings.TrimSuffix(fmt.Sprintf("%.1f", value), ".0") + " " + suffix - } - } - return fmt.Sprintf("%.1f EiB", value/unit) -} diff --git a/host/facts_test.go b/host/facts_test.go deleted file mode 100644 index b6c084c..0000000 --- a/host/facts_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package host - -import ( - "encoding/json" - "strings" - "testing" -) - -// linuxFacts is a machine that satisfies every phase up to S2. -func linuxFacts() Facts { - return Facts{ - OS: "linux", - Arch: "amd64", - Kernel: "6.8.0-45-generic", - Root: Root{ - Path: "/var/lib/kumabox", - Exists: true, - Writable: true, - TotalBytes: 200 << 30, - FreeBytes: 120 << 30, - }, - KVM: KVM{Device: KVMPath, Present: true, Readable: true}, - VMM: Binary{ - Name: "cloud-hypervisor", - Path: "/usr/local/bin/cloud-hypervisor", - Version: "Cloud Hypervisor v53.0", - Found: true, - }, - CNI: CNI{ - PluginBinDir: DefaultCNIPluginDir, - ConfigDir: DefaultCNIConfigDir, - PluginsFound: true, - ConfigFound: true, - }, - CgroupV2: true, - } -} - -// laptopFacts is a development laptop: no KVM, no Cloud Hypervisor. -func laptopFacts() Facts { - facts := linuxFacts() - facts.OS = "darwin" - facts.Arch = "arm64" - facts.KVM = KVM{Device: KVMPath} - facts.VMM = Binary{Name: "cloud-hypervisor"} - facts.CNI = CNI{PluginBinDir: DefaultCNIPluginDir, ConfigDir: DefaultCNIConfigDir} - facts.CgroupV2 = false - return facts -} - -func checkNamed(t *testing.T, report Report, name string) Check { - t.Helper() - for _, check := range report.Checks { - if check.Name == name { - return check - } - } - t.Fatalf("report has no check %q", name) - return Check{} -} - -func TestEvaluate(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - facts Facts - phase Phase - check string - want State - wantFailed bool - }{ - { - name: "a ready linux node passes the image phase", - facts: linuxFacts(), - phase: PhaseImage, - check: "root-directory", - want: StateOK, - }, - { - name: "a missing root fails the image phase", - facts: withRoot(linuxFacts(), Root{Path: "/var/lib/kumabox", TotalBytes: 200 << 30, FreeBytes: 120 << 30}), - phase: PhaseImage, - check: "root-directory", - want: StateMissing, - wantFailed: true, - }, - { - name: "a read-only root fails the image phase", - facts: withRoot(linuxFacts(), Root{Path: "/var/lib/kumabox", Exists: true, TotalBytes: 200 << 30, FreeBytes: 120 << 30}), - phase: PhaseImage, - check: "root-directory", - want: StateMissing, - wantFailed: true, - }, - { - name: "too little free space fails the image phase", - facts: withRoot(linuxFacts(), Root{Path: "/var/lib/kumabox", Exists: true, Writable: true, TotalBytes: 200 << 30, FreeBytes: 1 << 30}), - phase: PhaseImage, - check: "root-space", - want: StateMissing, - wantFailed: true, - }, - { - name: "kvm is not required while the image phase is the target", - facts: laptopFacts(), - phase: PhaseImage, - check: "kvm", - want: StateNotRequired, - }, - { - name: "kvm is required in the sandbox phase", - facts: laptopFacts(), - phase: PhaseSandbox, - check: "kvm", - want: StateUnsupported, - wantFailed: true, - }, - { - name: "an old cloud hypervisor is rejected", - facts: withVMMVersion(linuxFacts(), "Cloud Hypervisor v42.0"), - phase: PhaseSandbox, - check: "cloud-hypervisor", - want: StateMissing, - wantFailed: true, - }, - { - name: "cni is satisfied on a full node", - facts: linuxFacts(), - phase: PhaseNetwork, - check: "cni", - want: StateOK, - }, - { - name: "cni failure blocks the network phase", - facts: withCNI(linuxFacts(), false, false), - phase: PhaseNetwork, - check: "cni", - want: StateMissing, - wantFailed: true, - }, - { - name: "cgroup v2 is not required before the convergence phase", - facts: withCgroupV2(linuxFacts(), false), - phase: PhaseNetwork, - check: "cgroup-v2", - want: StateNotRequired, - wantFailed: false, - }, - { - name: "cgroup v2 failure blocks the convergence phase", - facts: withCgroupV2(linuxFacts(), false), - phase: PhaseConvergence, - check: "cgroup-v2", - want: StateMissing, - wantFailed: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - report := Evaluate(test.facts, test.phase) - if got := checkNamed(t, report, test.check).State; got != test.want { - t.Errorf("check %s state = %s, want %s", test.check, got, test.want) - } - if got := report.Failed(); got != test.wantFailed { - t.Errorf("report.Failed() = %v, want %v (%+v)", got, test.wantFailed, report.Checks) - } - }) - } -} - -func TestEvaluateNeverFailsOnLaterPhases(t *testing.T) { - t.Parallel() - - // A laptop that satisfies nothing beyond the root must still pass the image - // phase: requirements are judged against the phase being worked on. - facts := withRoot(laptopFacts(), Root{ - Path: "/home/dev/.kumabox", Exists: true, Writable: true, - TotalBytes: 500 << 30, FreeBytes: 200 << 30, - }) - if report := Evaluate(facts, PhaseImage); report.Failed() { - t.Fatalf("the image phase must not fail on later-phase requirements: %+v", report.Checks) - } -} - -func TestStatesAndPhasesRenderAsTextInJSON(t *testing.T) { - t.Parallel() - - encoded, err := json.Marshal(Evaluate(linuxFacts(), PhaseImage)) - if err != nil { - t.Fatalf("marshal report: %v", err) - } - text := string(encoded) - for _, want := range []string{`"phase":"S1"`, `"state":"ok"`, `"since":"S1"`} { - if !strings.Contains(text, want) { - t.Errorf("JSON report missing %s:\n%s", want, text) - } - } - if strings.Contains(text, `"state":0`) { - t.Errorf("states must never be encoded as integers:\n%s", text) - } -} - -func TestMajorOf(t *testing.T) { - t.Parallel() - - tests := map[string]int{ - "Cloud Hypervisor v43.0": 43, - "cloud-hypervisor 53.1": 53, - "43.0": 43, - "v100": 100, - "": 0, - "no digits here": 0, - } - for input, want := range tests { - if got := MajorOf(input); got != want { - t.Errorf("MajorOf(%q) = %d, want %d", input, got, want) - } - } -} - -func TestHumanBytes(t *testing.T) { - t.Parallel() - - tests := map[uint64]string{ - 512: "512 B", - 1024: "1 KiB", - 1536: "1.5 KiB", - 10 << 30: "10 GiB", - } - for input, want := range tests { - if got := HumanBytes(input); got != want { - t.Errorf("HumanBytes(%d) = %q, want %q", input, got, want) - } - } -} - -func withRoot(facts Facts, root Root) Facts { - facts.Root = root - return facts -} - -func withVMMVersion(facts Facts, version string) Facts { - facts.VMM.Version = version - facts.VMM.Found = true - facts.VMM.Path = "/usr/local/bin/cloud-hypervisor" - return facts -} - -func withCNI(facts Facts, plugins, config bool) Facts { - facts.CNI.PluginsFound = plugins - facts.CNI.ConfigFound = config - return facts -} - -func withCgroupV2(facts Facts, mounted bool) Facts { - facts.CgroupV2 = mounted - return facts -} diff --git a/layout/layout.go b/layout/layout.go deleted file mode 100644 index 6204392..0000000 --- a/layout/layout.go +++ /dev/null @@ -1,173 +0,0 @@ -// Package layout owns the on-disk namespace under a node root. -// -// Every managed path is derived here, so no other package has to join paths -// under the root by hand and no shell script has to keep its own copy of the -// directory list (docs/ARCHITECTURE.md §5, docs/HOST.md §1). -package layout - -import ( - "errors" - "fmt" - "os" - "path/filepath" -) - -// Errors reported by this package. -var ( - ErrRootEmpty = errors.New("root path is empty") - ErrRootNotAbsolute = errors.New("root path must be absolute") - ErrRootNotDirectory = errors.New("root path exists and is not a directory") - ErrRootNotWritable = errors.New("root directory is not writable") - ErrStatFailed = errors.New("cannot inspect the root directory") -) - -// dirPerm is the mode for every KumaBox-owned directory. -const dirPerm os.FileMode = 0o700 - -// Root is a resolved node root directory. -type Root struct { - dir string -} - -// New resolves dir into a Root. -// -// The path is made absolute and canonical so that containment checks compare -// real paths. dir may be a symlink: it is resolved rather than rejected, because -// operators legitimately point the root at another volume. -func New(dir string) (Root, error) { - if dir == "" { - return Root{}, ErrRootEmpty - } - if !filepath.IsAbs(dir) { - return Root{}, fmt.Errorf("%w: %q", ErrRootNotAbsolute, dir) - } - canonical, err := canonicalize(filepath.Clean(dir)) - if err != nil { - return Root{}, err - } - return Root{dir: canonical}, nil -} - -// Dir returns the root directory. -func (r Root) Dir() string { return r.dir } - -// MetadataDir holds the fact database. -func (r Root) MetadataDir() string { return filepath.Join(r.dir, "metadata") } - -// DatabaseFile is the SQLite fact database. -func (r Root) DatabaseFile() string { return filepath.Join(r.MetadataDir(), "kumabox.db") } - -// BlobsDir holds content-addressed objects: one file per digest. -func (r Root) BlobsDir() string { return filepath.Join(r.dir, "blobs") } - -// TempDir is where a command stages work before publishing it atomically. -func (r Root) TempDir() string { return filepath.Join(r.dir, "tmp") } - -// State describes what a root looks like right now. -type State struct { - Exists bool `json:"exists"` - Writable bool `json:"writable"` - TotalBytes uint64 `json:"total_bytes"` - FreeBytes uint64 `json:"free_bytes"` -} - -// Inspect reports the state of the root without changing it. When the root does -// not exist yet, free space is measured on the nearest existing ancestor, -// because that is the volume Prepare would write to. -func (r Root) Inspect() (State, error) { - info, err := os.Stat(r.dir) - switch { - case err == nil: - if !info.IsDir() { - return State{}, fmt.Errorf("%w: %q", ErrRootNotDirectory, r.dir) - } - total, free, err := statfs(r.dir) - if err != nil { - return State{}, fmt.Errorf("%w: %q: %w", ErrStatFailed, r.dir, err) - } - return State{ - Exists: true, - Writable: writable(r.dir), - TotalBytes: total, - FreeBytes: free, - }, nil - case errors.Is(err, os.ErrNotExist): - ancestor := nearestExisting(r.dir) - total, free, err := statfs(ancestor) - if err != nil { - return State{}, fmt.Errorf("%w: %q: %w", ErrStatFailed, ancestor, err) - } - return State{Writable: writable(ancestor), TotalBytes: total, FreeBytes: free}, nil - default: - return State{}, fmt.Errorf("%w: %q: %w", ErrStatFailed, r.dir, err) - } -} - -// Prepare creates the managed directories and checks that they are writable. It -// is the only function here that modifies the filesystem, and it only ever -// touches KumaBox-owned paths. -func (r Root) Prepare() error { - for _, dir := range []string{r.dir, r.MetadataDir(), r.BlobsDir(), r.TempDir()} { - if err := os.MkdirAll(dir, dirPerm); err != nil { - return fmt.Errorf("%w: %q: %w", ErrRootNotWritable, dir, err) - } - } - if !writable(r.dir) { - return fmt.Errorf("%w: %q", ErrRootNotWritable, r.dir) - } - return nil -} - -// canonicalize resolves symlinks along the longest existing prefix of path and -// re-appends the missing suffix, so the result is canonical without requiring -// the path to exist. -func canonicalize(path string) (string, error) { - existing, suffix := path, "" - for { - resolved, err := filepath.EvalSymlinks(existing) - if err == nil { - if suffix == "" { - return resolved, nil - } - return filepath.Join(resolved, suffix), nil - } - if !errors.Is(err, os.ErrNotExist) { - return "", fmt.Errorf("%w: %q: %w", ErrStatFailed, existing, err) - } - parent := filepath.Dir(existing) - if parent == existing { - return path, nil - } - suffix = filepath.Join(filepath.Base(existing), suffix) - existing = parent - } -} - -// nearestExisting returns the deepest existing ancestor of path. -func nearestExisting(path string) string { - current := path - for { - parent := filepath.Dir(current) - if parent == current { - return current - } - if _, err := os.Stat(parent); err == nil { - return parent - } - current = parent - } -} - -// writable reports whether a new file can be created in dir. Creating and -// removing a probe file is the only reliable check across the permission models -// KumaBox runs under. -func writable(dir string) bool { - probe, err := os.CreateTemp(dir, ".kumabox-write-probe-*") - if err != nil { - return false - } - name := probe.Name() - _ = probe.Close() - _ = os.Remove(name) - return true -} diff --git a/layout/layout_test.go b/layout/layout_test.go deleted file mode 100644 index 924059a..0000000 --- a/layout/layout_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package layout - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestNewResolvesManagedPaths(t *testing.T) { - t.Parallel() - - root, err := New("/var/lib/kumabox") - if err != nil { - t.Fatalf("New: %v", err) - } - // The path is canonical, so on macOS /var becomes /private/var. Assert the - // shape rather than one platform's spelling. - if !filepath.IsAbs(root.Dir()) { - t.Fatalf("Dir() = %q, want an absolute path", root.Dir()) - } - if !strings.HasSuffix(root.Dir(), filepath.Join("var", "lib", "kumabox")) { - t.Errorf("Dir() = %q, want it to end in var/lib/kumabox", root.Dir()) - } - if got, want := root.DatabaseFile(), filepath.Join(root.Dir(), "metadata", "kumabox.db"); got != want { - t.Errorf("DatabaseFile() = %q, want %q", got, want) - } - for _, path := range []string{root.MetadataDir(), root.BlobsDir(), root.TempDir()} { - if !strings.HasPrefix(path, root.Dir()+string(filepath.Separator)) { - t.Errorf("%q is not under the root %q", path, root.Dir()) - } - } -} - -func TestNewRejectsEmptyAndRelativePaths(t *testing.T) { - t.Parallel() - - if _, err := New(""); !errors.Is(err, ErrRootEmpty) { - t.Errorf("New(\"\") err = %v, want ErrRootEmpty", err) - } - for _, input := range []string{"var/lib/kumabox", "./kb", "../kb"} { - if _, err := New(input); !errors.Is(err, ErrRootNotAbsolute) { - t.Errorf("New(%q) err = %v, want ErrRootNotAbsolute", input, err) - } - } -} - -func TestNewResolvesSymlinksSoContainmentChecksCompareRealPaths(t *testing.T) { - t.Parallel() - - target := t.TempDir() - link := filepath.Join(t.TempDir(), "link-to-root") - if err := os.Symlink(target, link); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - - root, err := New(link) - if err != nil { - t.Fatalf("New: %v", err) - } - resolved, err := filepath.EvalSymlinks(target) - if err != nil { - t.Fatalf("EvalSymlinks: %v", err) - } - if root.Dir() != resolved { - t.Errorf("Dir() = %q, want the resolved target %q", root.Dir(), resolved) - } -} - -func TestNewResolvesSymlinksInAMissingRoot(t *testing.T) { - t.Parallel() - - // This is what --root /tmp/kb looks like on macOS, where /tmp is a symlink. - real := t.TempDir() - link := filepath.Join(t.TempDir(), "link") - if err := os.Symlink(real, link); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - - root, err := New(filepath.Join(link, "kb", "nested")) - if err != nil { - t.Fatalf("New: %v", err) - } - resolvedParent, err := filepath.EvalSymlinks(real) - if err != nil { - t.Fatalf("EvalSymlinks: %v", err) - } - if want := filepath.Join(resolvedParent, "kb", "nested"); root.Dir() != want { - t.Errorf("Dir() = %q, want %q", root.Dir(), want) - } -} - -func TestInspectReportsMissingRootWithoutCreatingIt(t *testing.T) { - t.Parallel() - - missing := filepath.Join(t.TempDir(), "kb") - root, err := New(missing) - if err != nil { - t.Fatalf("New: %v", err) - } - - state, err := root.Inspect() - if err != nil { - t.Fatalf("Inspect: %v", err) - } - if state.Exists { - t.Error("Inspect must report the root as missing") - } - if state.FreeBytes == 0 || state.TotalBytes == 0 { - t.Errorf("free space must be measured on the nearest existing ancestor: %+v", state) - } - if _, err := os.Stat(missing); !os.IsNotExist(err) { - t.Errorf("Inspect created the root: %v", err) - } -} - -func TestInspectRejectsAFileAsRoot(t *testing.T) { - t.Parallel() - - file := filepath.Join(t.TempDir(), "not-a-directory") - if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { - t.Fatalf("seed file: %v", err) - } - root, err := New(file) - if err != nil { - t.Fatalf("New: %v", err) - } - if _, err := root.Inspect(); !errors.Is(err, ErrRootNotDirectory) { - t.Fatalf("err = %v, want ErrRootNotDirectory", err) - } -} - -func TestPrepareCreatesManagedDirectories(t *testing.T) { - t.Parallel() - - root, err := New(filepath.Join(t.TempDir(), "kb")) - if err != nil { - t.Fatalf("New: %v", err) - } - if err := root.Prepare(); err != nil { - t.Fatalf("Prepare: %v", err) - } - - for _, dir := range []string{root.Dir(), root.MetadataDir(), root.BlobsDir(), root.TempDir()} { - info, err := os.Stat(dir) - if err != nil { - t.Fatalf("stat %s: %v", dir, err) - } - if !info.IsDir() { - t.Errorf("%s is not a directory", dir) - } - if got := info.Mode().Perm(); got != dirPerm { - t.Errorf("%s mode = %o, want %o", dir, got, dirPerm) - } - } - - state, err := root.Inspect() - if err != nil { - t.Fatalf("Inspect: %v", err) - } - if !state.Exists || !state.Writable { - t.Errorf("prepared root must be an existing writable directory: %+v", state) - } -} - -func TestPrepareIsIdempotent(t *testing.T) { - t.Parallel() - - root, err := New(filepath.Join(t.TempDir(), "kb")) - if err != nil { - t.Fatalf("New: %v", err) - } - for attempt := 0; attempt < 2; attempt++ { - if err := root.Prepare(); err != nil { - t.Fatalf("Prepare attempt %d: %v", attempt+1, err) - } - } -} diff --git a/layout/statfs_bsd.go b/layout/statfs_bsd.go deleted file mode 100644 index 0f6fa69..0000000 --- a/layout/statfs_bsd.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build darwin || freebsd || netbsd || openbsd - -package layout - -import "syscall" - -func statfs(path string) (total, free uint64, err error) { - var stat syscall.Statfs_t - if err := syscall.Statfs(path, &stat); err != nil { - return 0, 0, err - } - blockSize := uint64(stat.Bsize) - return stat.Blocks * blockSize, stat.Bavail * blockSize, nil -} diff --git a/layout/statfs_linux.go b/layout/statfs_linux.go deleted file mode 100644 index b83f7a4..0000000 --- a/layout/statfs_linux.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build linux - -package layout - -import "syscall" - -func statfs(path string) (total, free uint64, err error) { - var stat syscall.Statfs_t - if err := syscall.Statfs(path, &stat); err != nil { - return 0, 0, err - } - blockSize := uint64(stat.Bsize) - return stat.Blocks * blockSize, stat.Bavail * blockSize, nil -} diff --git a/main.go b/main.go index 0e32e4a..f67c3ed 100644 --- a/main.go +++ b/main.go @@ -1,17 +1,22 @@ // Command kumabox is the KumaBox command line. // -// v1 has no daemon: every invocation opens the node root, takes the locks it -// needs, does one job, and exits (docs/DECISIONS.md DEC-018). This file does -// nothing but hand control to the command layer. +// 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 ( "context" + "fmt" "os" "github.com/kumabox/kumabox/cmd" ) func main() { - os.Exit(cmd.Execute(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) + err := cmd.Execute(context.Background(), os.Args[1:], os.Stdout, os.Stderr) + if err != nil { + fmt.Fprintf(os.Stderr, "kumabox: %v\n", err) + } + os.Exit(cmd.ExitCode(err)) } diff --git a/version/version.go b/version/version.go index 2e90309..3d1f72c 100644 --- a/version/version.go +++ b/version/version.go @@ -1,7 +1,11 @@ // Package version reports what build is running. package version -import "fmt" +import ( + "encoding/json" + "fmt" + "io" +) // Build information. Release builds override these through -ldflags. var ( @@ -15,11 +19,13 @@ func String() string { return fmt.Sprintf("kumabox %s (commit %s, built %s)", Version, Commit, BuildTime) } -// Info returns the version as structured data for --json. -func Info() map[string]string { - return map[string]string{ +// 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, - } + }) } From ff7072d1c1479cfa81c1746dfa488eb7b3f17183 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Thu, 10 Sep 2026 10:55:34 +0800 Subject: [PATCH 10/45] revert: doctor is the script, shipped as kumabox-check Restores Makefile and doctor/ to the state that matches Cocoon's README: the checker is doctor/check.sh, built into bin/kumabox-check, installed next to the kumabox binary, and covered by 'make test' through doctor/check_test.sh. The Go rewrite of doctor (cmd/doctor) is dropped: it duplicated the script, changed the command surface, and clobbered the Makefile targets that build and install the checker. --- cmd/root.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 8af305d..731d51b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,8 +1,7 @@ // Package cmd builds the kumabox command tree. // // It owns flags, help and exit codes only. Each command lives in its own -// package and exposes a single NewCommand constructor; no command implements -// its logic here (docs/ARCHITECTURE.md §1). +// package and exposes a single NewCommand constructor package cmd import ( @@ -76,7 +75,7 @@ func newRootCommand() *cobra.Command { "Every command opens the node root, does one job and exits; there is no\n" + "daemon in this version. Use --root to point at another root, for example\n" + "while developing.\n\n" + - "Run doctor/check.sh first to see whether this machine is ready.", + "Run kumabox-check first to see whether this machine is ready.", SilenceUsage: true, SilenceErrors: true, } From abdb06edac606a8c74b5ded5856bc89dceb7f403 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Thu, 10 Sep 2026 14:14:43 +0800 Subject: [PATCH 11/45] feat: add host doctor command --- Makefile | 148 +++++++++++++++++----- cmd/doctor/doctor_commands.go | 57 +++++++++ cmd/doctor/doctor_test.go | 37 ++++++ cmd/root.go | 46 +++---- cmd/root_test.go | 78 +++++------- doctor/check.sh | 224 +++++++++++----------------------- doctor/check_test.sh | 199 ------------------------------ main.go | 2 +- 8 files changed, 333 insertions(+), 458 deletions(-) create mode 100644 cmd/doctor/doctor_commands.go create mode 100644 cmd/doctor/doctor_test.go delete mode 100755 doctor/check_test.sh diff --git a/Makefile b/Makefile index b69d87c..f0137d7 100644 --- a/Makefile +++ b/Makefile @@ -1,39 +1,131 @@ -GO ?= go -BIN_DIR ?= bin +.PHONY: all build install test doctor-check race verify lint vet fmt fmt-check deps clean coverage cloc help + +REPO_PATH := github.com/kumabox/kumabox + +## 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) + +ifneq ($(KEEP_SYMBOL), 1) + GO_LDFLAGS += -s +endif + +## Location to install dependencies and build outputs +LOCALBIN ?= $(shell pwd)/bin PREFIX ?= /usr/local +$(LOCALBIN): + mkdir -p $(LOCALBIN) + +## Tool versions +GOLANGCILINT_VERSION ?= v2.13.2 +GOLANGCILINT_ROOT := $(LOCALBIN)/golangci-lint-$(GOLANGCILINT_VERSION) +GOLANGCILINT := $(GOLANGCILINT_ROOT)/golangci-lint + +GOFUMPT_VERSION ?= v0.11.0 +GOIMPORTS_VERSION ?= v0.49.0 +GOFMT := $(LOCALBIN)/gofumpt-$(GOFUMPT_VERSION) +GOIMPORTS := $(LOCALBIN)/goimports-$(GOIMPORTS_VERSION) + +## 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) + +.PHONY: gofumpt +gofumpt: $(GOFMT) +$(GOFMT): | $(LOCALBIN) + GOBIN=$(LOCALBIN) go install mvdan.cc/gofumpt@$(GOFUMPT_VERSION) + mv $(LOCALBIN)/gofumpt $(GOFMT) + +.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 . + cp doctor/check.sh $(LOCALBIN)/kumabox-check + chmod 0755 $(LOCALBIN)/kumabox-check + +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 the doctor script syntax + bash -n doctor/check.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" -# Packages in this module. Empty until the first phase lands code. -PKGS := $(shell $(GO) list ./... 2>/dev/null) +# --- Code quality --- -.PHONY: build install fmt-check test shell-test race vet verify +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 -build: - @mkdir -p "$(BIN_DIR)" - $(GO) build -o "$(BIN_DIR)/kumabox" . - @cp doctor/check.sh "$(BIN_DIR)/kumabox-check" - @chmod 0755 "$(BIN_DIR)/kumabox-check" +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 -install: build - @install -d "$(DESTDIR)$(PREFIX)/bin" - @install -m 0755 "$(BIN_DIR)/kumabox" "$(DESTDIR)$(PREFIX)/bin/kumabox" - @install -m 0755 "$(BIN_DIR)/kumabox-check" "$(DESTDIR)$(PREFIX)/bin/kumabox-check" +fmt: gofumpt goimports ## Format code with gofumpt and goimports + $(GOFMT) -extra -l -w . + $(GOIMPORTS) -l -w --local 'github.com/kumabox/kumabox' . -fmt-check: - @test -z "$$(gofmt -l .)" || (gofmt -l . && exit 1) +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; } -test: - $(GO) test ./... - $(MAKE) shell-test +# --- Maintenance --- -shell-test: - bash doctor/check_test.sh +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 -# race is required for any change touching concurrency, workers, streams or -# reconciliation (docs/ARCHITECTURE.md §9). -race: - $(GO) test -race ./... +cloc: ## Count lines of code excluding tests (requires cloc) + cloc --exclude-dir=vendor,dist --exclude-ext=json --not-match-f='_test\.go$$' . -vet: - $(GO) vet ./... +# --- Help --- -verify: fmt-check vet test build +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/cmd/doctor/doctor_commands.go b/cmd/doctor/doctor_commands.go new file mode 100644 index 0000000..f7b50c4 --- /dev/null +++ b/cmd/doctor/doctor_commands.go @@ -0,0 +1,57 @@ +// Package doctor exposes the Cocoon-compatible host checker through kumabox. +package doctor + +import ( + "errors" + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" +) + +const checkerName = "kumabox-check" + +type processError struct { + err error + code int +} + +func (e *processError) Error() string { return e.err.Error() } +func (e *processError) Unwrap() error { return e.err } +func (e *processError) ExitCode() int { return e.code } +func (e *processError) Silent() bool { return true } + +// NewCommand returns the doctor command. Flag parsing belongs to check.sh, 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...) + 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/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go new file mode 100644 index 0000000..48c8c63 --- /dev/null +++ b/cmd/doctor/doctor_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/cmd/root.go b/cmd/root.go index 731d51b..32172b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,7 +1,4 @@ // Package cmd builds the kumabox command tree. -// -// It owns flags, help and exit codes only. Each command lives in its own -// package and exposes a single NewCommand constructor package cmd import ( @@ -12,15 +9,18 @@ import ( "github.com/spf13/cobra" + doctorcmd "github.com/kumabox/kumabox/cmd/doctor" "github.com/kumabox/kumabox/version" ) -// exitCoder is implemented by errors that know which exit code they deserve. -// Commands classify their own failures this way, so no shared error package is -// needed and a library never has to know about exit codes. -type exitCoder interface{ ExitCode() int } +type exitCoder interface { + ExitCode() int +} + +type silentError interface { + Silent() bool +} -// codedError attaches an exit code to an error. type codedError struct { err error code int @@ -30,13 +30,9 @@ func (e *codedError) Error() string { return e.err.Error() } func (e *codedError) Unwrap() error { return e.err } func (e *codedError) ExitCode() int { return e.code } -// exitUsage is the exit code for a failure of the command line itself. -const exitUsage = 5 +const exitUsage = 2 -// Execute runs one invocation and returns the error it failed with, or nil. -// -// Errors raised while resolving or parsing the command line are reported as -// usage failures; everything else is expected to carry its own exit code. +// Execute runs one CLI invocation. func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) error { root := newRootCommand() root.SetArgs(args) @@ -54,8 +50,7 @@ func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) error return &codedError{err: err, code: exitUsage} } -// ExitCode maps the result of Execute to the process exit code documented in -// docs/BEHAVIOR.md §17. +// ExitCode returns the process exit status represented by err. func ExitCode(err error) int { if err == nil { return 0 @@ -67,31 +62,30 @@ func ExitCode(err error) int { 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() +} + func newRootCommand() *cobra.Command { root := &cobra.Command{ - Use: "kumabox", - Short: "microVM sandboxes for AI agents", - Long: "kumabox runs microVM sandboxes on this machine.\n\n" + - "Every command opens the node root, does one job and exits; there is no\n" + - "daemon in this version. Use --root to point at another root, for example\n" + - "while developing.\n\n" + - "Run kumabox-check first to see whether this machine is ready.", + Use: "kumabox", + Short: "microVM sandboxes for AI agents", SilenceUsage: true, SilenceErrors: true, } - root.PersistentFlags().String("root", "", - "node root directory (default $KUMABOX_ROOT, then /var/lib/kumabox)") root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return &codedError{err: err, code: exitUsage} }) + root.AddCommand(doctorcmd.NewCommand()) root.AddCommand(newVersionCommand()) return root } func newVersionCommand() *cobra.Command { var asJSON bool - command := &cobra.Command{ Use: "version", Short: "print the version", diff --git a/cmd/root_test.go b/cmd/root_test.go index 0b4f032..f6b5b08 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -3,66 +3,48 @@ package cmd import ( "bytes" "context" - "encoding/json" + "os" + "path/filepath" + "runtime" "strings" "testing" ) -func execute(t *testing.T, args ...string) (error, string, string) { - t.Helper() - - var stdout, stderr bytes.Buffer - err := Execute(context.Background(), args, &stdout, &stderr) - return err, stdout.String(), stderr.String() -} - -func TestVersion(t *testing.T) { - t.Parallel() - - err, stdout, stderr := execute(t, "version", "--json") - if err != nil { - t.Fatalf("version --json: %v\nstderr: %s", err, stderr) - } - var payload map[string]string - if jsonErr := json.Unmarshal([]byte(stdout), &payload); jsonErr != nil { - t.Fatalf("version --json is not valid JSON: %v\n%s", jsonErr, stdout) +func TestDoctorForwardsArgumentsAndExitCode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper is a shell script") } - if payload["version"] == "" { - t.Error("version payload is empty") - } -} -func TestUnknownCommandIsAUsageError(t *testing.T) { - t.Parallel() + 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")) - err, _, _ := execute(t, "frobnicate") - if err == nil { - t.Fatal("an unknown command must fail") + 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 code := ExitCode(err); code != 5 { - t.Errorf("exit code = %d, want 5", code) + if !Silent(err) { + t.Fatal("doctor process error must be silent") } - if !strings.Contains(err.Error(), "unknown command") { - t.Errorf("error = %v, want it to name the unknown command", err) + 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 TestUnknownFlagIsAUsageError(t *testing.T) { - t.Parallel() - - err, _, _ := execute(t, "version", "--nope") - if err == nil { - t.Fatal("an unknown flag must fail") - } - if code := ExitCode(err); code != 5 { - t.Errorf("exit code = %d, want 5", code) +func TestVersion(t *testing.T) { + var stdout bytes.Buffer + err := Execute(context.Background(), []string{"version"}, &stdout, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) } -} - -func TestSuccessExitsZero(t *testing.T) { - t.Parallel() - - if code := ExitCode(nil); code != 0 { - t.Errorf("ExitCode(nil) = %d, want 0", code) + if !strings.HasPrefix(stdout.String(), "kumabox ") { + t.Fatalf("version output = %q", stdout.String()) } } diff --git a/doctor/check.sh b/doctor/check.sh index 7a5cb81..051e7fb 100755 --- a/doctor/check.sh +++ b/doctor/check.sh @@ -12,34 +12,23 @@ set -uo pipefail # Configuration (override via environment) # --------------------------------------------------------------------------- KUMABOX_ROOT_DIR="${KUMABOX_ROOT_DIR:-/var/lib/kumabox}" -KUMABOX_META_DIR="${KUMABOX_META_DIR:-${KUMABOX_ROOT_DIR}/metadata}" -KUMABOX_BLOBS_DIR="${KUMABOX_BLOBS_DIR:-${KUMABOX_ROOT_DIR}/blobs}" -KUMABOX_TMP_DIR="${KUMABOX_TMP_DIR:-${KUMABOX_ROOT_DIR}/tmp}" 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}" -# Overridable so the checks can be exercised without touching the real host. -KUMABOX_KVM_DEVICE="${KUMABOX_KVM_DEVICE:-/dev/kvm}" -KUMABOX_NETNS_DIR="${KUMABOX_NETNS_DIR:-/var/run/netns}" # Dependency versions CH_VERSION="${CH_VERSION:-v53.0}" -CH_MIN_MAJOR="${CH_MIN_MAJOR:-43}" -FW_VERSION="${FW_VERSION:-0.5.0}" CNI_VERSION="${CNI_VERSION:-v1.9.1}" # Architecture detection ARCH=$(uname -m) case "$ARCH" in - x86_64|amd64) GO_ARCH="amd64"; CH_SUFFIX=""; FW_SUFFIX="" ;; - aarch64|arm64) GO_ARCH="arm64"; CH_SUFFIX="-aarch64"; FW_SUFFIX="-aarch64" ;; + x86_64) GO_ARCH="amd64"; CH_SUFFIX="" ;; + aarch64) GO_ARCH="arm64"; CH_SUFFIX="-aarch64" ;; *) echo "Unsupported architecture: $ARCH"; exit 1 ;; esac -FIRMWARE_DIR="${KUMABOX_ROOT_DIR}/firmware" -FIRMWARE_PATH="${FIRMWARE_DIR}/CLOUDHV.fd" - # --------------------------------------------------------------------------- # Flags # --------------------------------------------------------------------------- @@ -59,19 +48,14 @@ Options: --fix Attempt to fix detected issues (dirs, sysctl, iptables, CNI config) --upgrade Fix issues and install/upgrade dependencies: cloud-hypervisor ${CH_VERSION} - hypervisor-fw ${FW_VERSION} CNI plugins ${CNI_VERSION} --subnet=CIDR Subnet for generated CNI bridge config (default: 10.88.0.0/16) Environment variables: - CH_VERSION Cloud Hypervisor version (default: ${CH_VERSION}) - CH_MIN_MAJOR oldest accepted major (default: ${CH_MIN_MAJOR}) - FW_VERSION Firmware version (default: ${FW_VERSION}) - CNI_VERSION CNI plugins version (default: ${CNI_VERSION}) + CH_VERSION Cloud Hypervisor version (default: ${CH_VERSION}) + CNI_VERSION CNI plugins version (default: ${CNI_VERSION}) KUMABOX_ROOT_DIR / KUMABOX_RUN_DIR / KUMABOX_LOG_DIR KUMABOX_CNI_CONF_DIR / KUMABOX_CNI_BIN_DIR - KUMABOX_KVM_DEVICE KVM device to check (default: /dev/kvm) - KUMABOX_NETNS_DIR network namespace dir (default: /var/run/netns) EOF exit 0 ;; @@ -83,12 +67,12 @@ done # --------------------------------------------------------------------------- PASS=0; WARN=0; FAIL=0 -pass() { PASS=$((PASS + 1)); printf " \033[32m[PASS]\033[0m %s\n" "$1"; } -warn() { WARN=$((WARN + 1)); printf " \033[33m[WARN]\033[0m %s\n" "$1"; } -fail() { FAIL=$((FAIL + 1)); printf " \033[31m[FAIL]\033[0m %s\n" "$1"; } -info() { printf " \033[36m[INFO]\033[0m %s\n" "$1"; } -fixed() { printf " \033[32m[FIXED]\033[0m %s\n" "$1"; } -header() { printf "\n\033[1m==> %s\033[0m\n" "$1"; } +pass() { PASS=$((PASS + 1)); printf " \033[38;5;42m[PASS]\033[0m %s\n" "$1"; } +warn() { WARN=$((WARN + 1)); printf " \033[38;5;214m[WARN]\033[0m %s\n" "$1"; } +fail() { FAIL=$((FAIL + 1)); printf " \033[38;5;203m[FAIL]\033[0m %s\n" "$1"; } +info() { printf " \033[38;5;75m[INFO]\033[0m %s\n" "$1"; } +fixed() { printf " \033[38;5;81m[FIXED]\033[0m %s\n" "$1"; } +header() { printf "\n\033[1;38;5;111m%s\033[0m\n" "$1"; } # --------------------------------------------------------------------------- # CNI conflist generator @@ -121,7 +105,7 @@ generate_cni_conflist() { "plugins": [ { "type": "bridge", - "bridge": "kumabox0", + "bridge": "cni0", "mtu": ${host_mtu}, "isGateway": true, "ipMasq": true, @@ -152,20 +136,11 @@ bin_to_pkg() { case "$1" in mkfs.erofs) echo "erofs-utils" ;; mkfs.ext4) echo "e2fsprogs" ;; - qemu-img) echo "qemu-utils" ;; *) echo "" ;; esac } -# KumaBox refuses to boot below this major version. -ch_version_ok() { - local major - major=$(echo "$1" | grep -oE 'v?[0-9]+' | head -1 | tr -d 'v') - [ -n "$major" ] || return 1 - [ "$major" -ge "$CH_MIN_MAJOR" ] -} - -# Layers are converted to EROFS at import time. erofs-utils < 1.8 tar mode +# Mirror the runtime floor in images/oci/erofs.go: erofs-utils < 1.8 tar mode # silently corrupts layers, so doctor must not PASS a host kumabox will refuse. erofs_version_ok() { local xy major minor @@ -181,14 +156,10 @@ check_binary() { local ver="" case "$name" in cloud-hypervisor) ver=$("$name" --version 2>/dev/null | head -1) || true ;; - qemu-img) 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" ] && ! ch_version_ok "$ver"; then - fail "$name (${ver:-unknown}) is older than v${CH_MIN_MAJOR} — kumabox refuses to launch on it" - 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 @@ -211,48 +182,30 @@ check_binary() { } check_binary cloud-hypervisor -check_binary mkfs.erofs +check_binary ch-remote check_binary mkfs.ext4 -# qemu-img is optional: only the qcow2 write path needs it. -if command -v qemu-img &>/dev/null; then - check_binary qemu-img -else - warn "qemu-img not found (optional, needed by the qcow2 write path)" -fi - -# --------------------------------------------------------------------------- -# 2. Firmware -# --------------------------------------------------------------------------- -header "Firmware" - -# Only the UEFI boot shape needs firmware; direct kernel boot does not. -if [ -f "$FIRMWARE_PATH" ]; then - local_size=$(stat -c%s "$FIRMWARE_PATH" 2>/dev/null || stat -f%z "$FIRMWARE_PATH" 2>/dev/null || echo 0) - pass "CLOUDHV.fd (${local_size} bytes) at $FIRMWARE_PATH" -else - warn "CLOUDHV.fd not found at $FIRMWARE_PATH (optional, needed by the uefi boot shape)" -fi +check_binary mkfs.erofs # --------------------------------------------------------------------------- -# 3. KVM access +# 2. KVM access # --------------------------------------------------------------------------- header "KVM" -if [ -e "$KUMABOX_KVM_DEVICE" ]; then - if [ -r "$KUMABOX_KVM_DEVICE" ] && [ -w "$KUMABOX_KVM_DEVICE" ]; then - pass "$KUMABOX_KVM_DEVICE accessible" +if [ -e /dev/kvm ]; then + if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then + pass "/dev/kvm accessible" else - fail "$KUMABOX_KVM_DEVICE exists but not readable/writable by $(whoami)" + fail "/dev/kvm exists but not readable/writable by $(whoami)" if $FIX; then - chmod 666 "$KUMABOX_KVM_DEVICE" 2>/dev/null && fixed "chmod 666 $KUMABOX_KVM_DEVICE" || warn "failed to fix (need root?)" + chmod 666 /dev/kvm 2>/dev/null && fixed "chmod 666 /dev/kvm" || warn "failed to fix (need root?)" fi fi else - fail "$KUMABOX_KVM_DEVICE not found (nested virtualization or bare-metal required)" + fail "/dev/kvm not found (nested virtualization or bare-metal required)" fi # --------------------------------------------------------------------------- -# 4. Runtime directories +# 3. Managed directories # --------------------------------------------------------------------------- header "Directories" @@ -269,29 +222,38 @@ check_dir() { } check_dir "$KUMABOX_ROOT_DIR" -check_dir "$KUMABOX_META_DIR" -check_dir "$KUMABOX_BLOBS_DIR" -check_dir "$KUMABOX_TMP_DIR" -check_dir "$KUMABOX_RUN_DIR" -check_dir "$KUMABOX_LOG_DIR" -check_dir "$FIRMWARE_DIR" -check_dir "$KUMABOX_NETNS_DIR" -# SQLite WAL needs coherent shared memory; report the same filesystem refusal -# enforced on open (docs/ARCHITECTURE.md §7). -meta_fstype=$(stat -f -c %T "$KUMABOX_ROOT_DIR" 2>/dev/null | head -1 | tr -d '[:space:]') -meta_fstype=${meta_fstype:-unknown} +# SQLite WAL needs coherent shared memory. KumaBox has one metadata engine and +# does not probe or preserve Cocoon's per-backend JSON stores. +meta_fstype=$(stat -f -c %T "$KUMABOX_ROOT_DIR" 2>/dev/null || echo unknown) case "$meta_fstype" in nfs*|cifs|smb*|fuse*) - fail "root on $meta_fstype: sqlite WAL needs coherent shared memory; kumabox refuses this filesystem" + fail "meta root on $meta_fstype: sqlite WAL needs coherent shared memory; kumabox refuses this filesystem" ;; *) - pass "root filesystem ($meta_fstype) supports WAL" + 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/blobs" +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/sandboxes" +check_dir "${KUMABOX_RUN_DIR}/sandboxes" +check_dir "${KUMABOX_LOG_DIR}/sandboxes" +check_dir /run/netns + # --------------------------------------------------------------------------- -# 5. Sysctl +# 4. Sysctl # --------------------------------------------------------------------------- header "Sysctl" @@ -321,9 +283,9 @@ fi check_sysctl net.bridge.bridge-nf-call-iptables 1 # --------------------------------------------------------------------------- -# 6. iptables FORWARD rules for the CNI bridge +# 5. iptables FORWARD rules for CNI bridge # --------------------------------------------------------------------------- -header "iptables FORWARD (kumabox0)" +header "iptables FORWARD (cni0)" check_iptables_rule() { local desc="$1" @@ -338,10 +300,10 @@ check_iptables_rule() { 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 +check_iptables_rule "FORWARD -i cni0 -j ACCEPT" \ + FORWARD -i cni0 -j ACCEPT +check_iptables_rule "FORWARD -o cni0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" \ + FORWARD -o cni0 -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. @@ -357,7 +319,7 @@ else fi # --------------------------------------------------------------------------- -# 7. CNI configuration +# 6. CNI configuration # --------------------------------------------------------------------------- header "CNI configuration" @@ -383,7 +345,7 @@ else fi # --------------------------------------------------------------------------- -# 8. CNI plugins +# 7. CNI plugins # --------------------------------------------------------------------------- header "CNI plugins (${KUMABOX_CNI_BIN_DIR})" @@ -402,44 +364,7 @@ else fi # --------------------------------------------------------------------------- -# 9. Store health -# --------------------------------------------------------------------------- -header "Store health" - -DB_PATH="${KUMABOX_META_DIR}/kumabox.db" - -if [ -f "$DB_PATH" ]; then - pass "fact database present ($(stat -c%s "$DB_PATH" 2>/dev/null || echo '?') bytes)" - if command -v sqlite3 &>/dev/null; then - # Anything stuck mid-flight is what doctor is meant to surface; the - # sweeper in 'kumabox gc' finishes or fails it (docs/BEHAVIOR.md §15). - STUCK=$(sqlite3 "$DB_PATH" "select count(*) from images where state='importing'" 2>/dev/null || echo "?") - if [ "$STUCK" = "?" ]; then - warn "cannot read the images table (schema older than this build?)" - elif [ "$STUCK" != "0" ]; then - warn "$STUCK image import(s) left mid-flight; run 'kumabox gc' to finish or fail them" - else - pass "no image import left mid-flight" - fi - else - warn "sqlite3 not found — skipping store inspection" - fi -else - info "no fact database yet (first command creates it)" -fi - -# Staging leftovers are always worth reporting: they are disk usage nobody owns. -if [ -d "$KUMABOX_TMP_DIR" ]; then - staging=$(find "$KUMABOX_TMP_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l) - if [ "$staging" -gt 0 ]; then - warn "$staging staging entr(ies) in $KUMABOX_TMP_DIR; run 'kumabox gc' to reclaim them" - else - pass "no staging leftovers" - fi -fi - -# --------------------------------------------------------------------------- -# 10. Upgrade / Install +# 8. Upgrade / Install # --------------------------------------------------------------------------- if $UPGRADE; then tmpdir=$(mktemp -d) @@ -460,33 +385,19 @@ if $UPGRADE; then fail "failed to download cloud-hypervisor from ${ch_url}" fi - # -- firmware ----------------------------------------------------------- - header "Install hypervisor-fw ${FW_VERSION}" + # -- ch-remote ---------------------------------------------------------- + header "Install ch-remote ${CH_VERSION}" - fw_url="https://github.com/cloud-hypervisor/rust-hypervisor-firmware/releases/download/${FW_VERSION}/hypervisor-fw${FW_SUFFIX}" - mkdir -p "$FIRMWARE_DIR" - info "downloading ${fw_url}" - if curl -fsSL -o "${FIRMWARE_PATH}" "$fw_url"; then - fixed "hypervisor-fw ${FW_VERSION} -> ${FIRMWARE_PATH}" + 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 firmware from ${fw_url}" + fail "failed to download ch-remote from ${chr_url}" fi - # -- mkfs helpers ------------------------------------------------------- - for pkg_bin in "erofs-utils:mkfs.erofs" "e2fsprogs:mkfs.ext4"; do - pkg="${pkg_bin%%:*}"; bin="${pkg_bin##*:}" - if ! command -v "$bin" &>/dev/null; then - header "Install ${pkg}" - if command -v apt-get &>/dev/null; then - apt-get install -y -qq "$pkg" &>/dev/null && fixed "${pkg} installed via apt-get" || warn "failed to install ${pkg}" - elif command -v yum &>/dev/null; then - yum install -y -q "$pkg" &>/dev/null && fixed "${pkg} installed via yum" || warn "failed to install ${pkg}" - else - warn "${pkg} not installed (install ${bin} manually)" - fi - fi - done - # -- CNI plugins -------------------------------------------------------- header "Install CNI plugins ${CNI_VERSION}" @@ -509,12 +420,13 @@ fi # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- -printf "\n\033[1m--- Summary ---\033[0m\n" -printf " Pass: %d Warn: %d Fail: %d\n\n" "$PASS" "$WARN" "$FAIL" +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, firmware, and CNI plugins" + info "Run '$0 --upgrade' to install/upgrade cloud-hypervisor and CNI plugins" fi [ "$FAIL" -eq 0 ] || exit 1 diff --git a/doctor/check_test.sh b/doctor/check_test.sh deleted file mode 100755 index a48b083..0000000 --- a/doctor/check_test.sh +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env bash -# doctor/check_test.sh — tests for doctor/check.sh. -# -# Runs the checker against a throwaway root and a PATH full of fake tools, so it -# needs no root, no KVM and no Cloud Hypervisor. Every external command the -# checker calls is replaced, which also means the checks themselves are asserted -# rather than merely exercised. - -set -uo pipefail - -repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -checker="${repo_dir}/doctor/check.sh" - -ESC=$(printf '\033') -strip_ansi() { sed "s/${ESC}\[[0-9;]*m//g"; } - -PASSED=0 -FAILED=0 - -ok() { PASSED=$((PASSED + 1)); printf " \033[32mok\033[0m %s\n" "$1"; } -not_ok() { FAILED=$((FAILED + 1)); printf " \033[31mFAIL\033[0m %s\n" "$1"; } - -assert_contains() { - local haystack="$1" needle="$2" what="$3" - if grep -qF -- "$needle" <<<"$haystack"; then - ok "$what" - else - not_ok "$what (missing: $needle)" - printf '%s\n' "$haystack" | sed 's/^/ | /' - fi -} - -assert_not_contains() { - local haystack="$1" needle="$2" what="$3" - if grep -qF -- "$needle" <<<"$haystack"; then - not_ok "$what (unexpected: $needle)" - printf '%s\n' "$haystack" | sed 's/^/ | /' - else - ok "$what" - fi -} - -assert_dir() { - if [ -d "$1" ]; then ok "created $1"; else not_ok "$1 was not created"; fi -} - -assert_missing() { - if [ -e "$1" ]; then not_ok "$1 should not exist"; else ok "$1 not created"; fi -} - -sandbox=$(mktemp -d) -trap 'rm -rf "$sandbox"' EXIT - -root="${sandbox}/root" -fake_bin="${sandbox}/bin" -cni_conf="${sandbox}/cni-conf" -cni_bin="${sandbox}/cni-bin" -netns_dir="${sandbox}/netns" -kvm_device="${sandbox}/kvm" -mkdir -p "$fake_bin" "$cni_bin" "$netns_dir" - -# --- fake tools ------------------------------------------------------------- -cat > "${fake_bin}/cloud-hypervisor" <<'EOF' -#!/usr/bin/env bash -echo "Cloud Hypervisor v${FAKE_CH_VERSION:-53.0}" -EOF - -cat > "${fake_bin}/mkfs.erofs" <<'EOF' -#!/usr/bin/env bash -echo "mkfs.erofs ${FAKE_EROFS_VERSION:-1.8.1}" -EOF - -cat > "${fake_bin}/mkfs.ext4" <<'EOF' -#!/usr/bin/env bash -[ "${1:-}" = "-V" ] && echo "mke2fs 1.47.0" -EOF - -# The checker only asks whether a rule exists (or adds it); both succeed here. -cat > "${fake_bin}/iptables" <<'EOF' -#!/usr/bin/env bash -exit 0 -EOF - -# Report the expected values for the two keys the checker inspects. -cat > "${fake_bin}/sysctl" <<'EOF' -#!/usr/bin/env bash -case "${1:-}" in - -n) case "${2:-}" in - net.ipv4.ip_forward|net.bridge.bridge-nf-call-iptables) echo 1 ;; - *) echo "" ;; - esac ;; - -w) exit 0 ;; -esac -exit 0 -EOF - -cat > "${fake_bin}/ip" <<'EOF' -#!/usr/bin/env bash -case "$*" in - "route show default") echo "default via 10.0.0.1 dev eth0" ;; - "link show eth0") echo "2: eth0: mtu 1460 state UP" ;; -esac -exit 0 -EOF - -chmod 0755 "${fake_bin}"/* - -for plugin in bridge host-local loopback; do - printf '#!/usr/bin/env bash\n' > "${cni_bin}/${plugin}" - chmod 0755 "${cni_bin}/${plugin}" -done -printf '{}\n' > "${cni_conf}/10-kumabox.conflist" -printf 'device\n' > "$kvm_device" -chmod 0666 "$kvm_device" - -# run_checker keeps both the output and the status of one invocation in -# CHECK_OUTPUT and CHECK_STATUS. -CHECK_OUTPUT="" -CHECK_STATUS=0 -run_checker() { - CHECK_OUTPUT=$(env -i \ - PATH="${fake_bin}:/usr/bin:/bin" \ - HOME="$sandbox" \ - KUMABOX_ROOT_DIR="$root" \ - KUMABOX_RUN_DIR="${sandbox}/run" \ - KUMABOX_LOG_DIR="${sandbox}/log" \ - KUMABOX_META_DIR="${root}/metadata" \ - KUMABOX_BLOBS_DIR="${root}/blobs" \ - KUMABOX_TMP_DIR="${root}/tmp" \ - KUMABOX_CNI_CONF_DIR="$cni_conf" \ - KUMABOX_CNI_BIN_DIR="$cni_bin" \ - KUMABOX_NETNS_DIR="$netns_dir" \ - KUMABOX_KVM_DEVICE="$kvm_device" \ - FAKE_CH_VERSION="${FAKE_CH_VERSION:-53.0}" \ - FAKE_EROFS_VERSION="${FAKE_EROFS_VERSION:-1.8.1}" \ - bash "$checker" "$@" 2>&1) - CHECK_STATUS=$? - CHECK_OUTPUT=$(printf '%s' "$CHECK_OUTPUT" | strip_ansi) -} - -printf '\n\033[1mdoctor/check.sh\033[0m\n' - -# --- 1. help ---------------------------------------------------------------- -run_checker --help -if [ "$CHECK_STATUS" -eq 0 ]; then ok "help exits 0"; else not_ok "help exit code = $CHECK_STATUS"; fi -assert_contains "$CHECK_OUTPUT" "Usage:" "help prints usage" -assert_contains "$CHECK_OUTPUT" "KUMABOX_KVM_DEVICE" "help documents the KVM override" - -# --- 2. a fresh root fails and changes nothing ------------------------------ -run_checker -if [ "$CHECK_STATUS" -eq 1 ]; then ok "a fresh root fails with exit 1"; else not_ok "exit code = $CHECK_STATUS, want 1"; fi -assert_contains "$CHECK_OUTPUT" "[FAIL] ${root} does not exist" "reports the missing root" -assert_missing "$root" - -# --- 3. --fix creates the managed directories ------------------------------- -# --fix repairs what it finds, but the failures it found still count: the -# summary reports them and the exit code stays 1 until a clean re-run. -run_checker --fix -if [ "$CHECK_STATUS" -eq 1 ]; then ok "--fix reports the failures it repaired"; else - not_ok "--fix exit code = $CHECK_STATUS, want 1 (failures were found)" - printf '%s\n' "$CHECK_OUTPUT" | sed 's/^/ | /' -fi -assert_contains "$CHECK_OUTPUT" "[FIXED] created ${root}/blobs" "reports what it created" -for dir in "$root" "${root}/metadata" "${root}/blobs" "${root}/tmp" \ - "${root}/firmware" "${sandbox}/run" "${sandbox}/log" "$netns_dir"; do - assert_dir "$dir" -done - -# --- 4. the second run passes the checks it just fixed ---------------------- -run_checker -if [ "$CHECK_STATUS" -eq 0 ]; then ok "a repaired root passes"; else - not_ok "exit code = $CHECK_STATUS after --fix" - printf '%s\n' "$CHECK_OUTPUT" | sed 's/^/ | /' -fi -assert_contains "$CHECK_OUTPUT" "[PASS] ${root}/blobs" "root directories are reported as present" -assert_contains "$CHECK_OUTPUT" "[PASS] cloud-hypervisor (Cloud Hypervisor v53.0)" "reads the Cloud Hypervisor version" -assert_contains "$CHECK_OUTPUT" "[PASS] mkfs.erofs (mkfs.erofs 1.8.1)" "reads the erofs-utils version" -assert_contains "$CHECK_OUTPUT" "[PASS] ${kvm_device} accessible" "reports the KVM device as usable" -assert_contains "$CHECK_OUTPUT" "[PASS] bridge" "finds the CNI plugins" -assert_contains "$CHECK_OUTPUT" "Pass:" "prints a summary" - -# --- 5. the erofs floor is enforced ---------------------------------------- -FAKE_EROFS_VERSION=1.7.4 run_checker -if [ "$CHECK_STATUS" -eq 1 ]; then ok "an old mkfs.erofs fails the check"; else not_ok "exit code = $CHECK_STATUS, want 1"; fi -assert_contains "$CHECK_OUTPUT" "[FAIL] mkfs.erofs (mkfs.erofs 1.7.4) is older than 1.8" "explains the erofs floor" - -# --- 6. the Cloud Hypervisor floor is enforced ------------------------------ -FAKE_CH_VERSION=42.0 run_checker -if [ "$CHECK_STATUS" -eq 1 ]; then ok "an old cloud-hypervisor fails the check"; else not_ok "exit code = $CHECK_STATUS, want 1"; fi -assert_contains "$CHECK_OUTPUT" "is older than v43" "explains the Cloud Hypervisor floor" - -# --- 7. stray staging directories are reported ------------------------------ -mkdir -p "${root}/tmp/import-1234" -run_checker -assert_contains "$CHECK_OUTPUT" "staging entr" "warns about staging leftovers" -assert_contains "$CHECK_OUTPUT" "kumabox gc" "points at the command that reclaims them" - -printf "\n %d passed, %d failed\n\n" "$PASSED" "$FAILED" -[ "$FAILED" -eq 0 ] diff --git a/main.go b/main.go index f67c3ed..4383e70 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,7 @@ import ( func main() { err := cmd.Execute(context.Background(), os.Args[1:], os.Stdout, os.Stderr) - if err != nil { + if err != nil && !cmd.Silent(err) { fmt.Fprintf(os.Stderr, "kumabox: %v\n", err) } os.Exit(cmd.ExitCode(err)) From 957676cb7f6fe6846b1605b65a3233fe6b9e4417 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Thu, 10 Sep 2026 14:25:50 +0800 Subject: [PATCH 12/45] fix: harden doctor host checks --- doctor/check.sh | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/doctor/check.sh b/doctor/check.sh index 051e7fb..8238144 100755 --- a/doctor/check.sh +++ b/doctor/check.sh @@ -8,6 +8,11 @@ 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) # --------------------------------------------------------------------------- @@ -105,7 +110,7 @@ generate_cni_conflist() { "plugins": [ { "type": "bridge", - "bridge": "cni0", + "bridge": "kumabox0", "mtu": ${host_mtu}, "isGateway": true, "ipMasq": true, @@ -150,6 +155,10 @@ erofs_version_ok() { [ "$major" -gt 1 ] || { [ "$major" -eq 1 ] && [ "$minor" -ge 8 ]; } } +binary_version() { + echo "$1" | grep -oE 'v?[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1 | tr -d 'v' +} + check_binary() { local name="$1" if command -v "$name" &>/dev/null; then @@ -160,6 +169,11 @@ check_binary() { 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 @@ -283,9 +297,9 @@ fi check_sysctl net.bridge.bridge-nf-call-iptables 1 # --------------------------------------------------------------------------- -# 5. iptables FORWARD rules for CNI bridge +# 5. iptables FORWARD rules for KumaBox bridge # --------------------------------------------------------------------------- -header "iptables FORWARD (cni0)" +header "iptables FORWARD (kumabox0)" check_iptables_rule() { local desc="$1" @@ -300,10 +314,10 @@ check_iptables_rule() { fi } -check_iptables_rule "FORWARD -i cni0 -j ACCEPT" \ - FORWARD -i cni0 -j ACCEPT -check_iptables_rule "FORWARD -o cni0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT" \ - FORWARD -o cni0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT +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. @@ -325,21 +339,11 @@ header "CNI configuration" CNI_CONFLIST="${KUMABOX_CNI_CONF_DIR}/10-kumabox.conflist" -if [ -d "$KUMABOX_CNI_CONF_DIR" ]; then - conflist_count=$(find "$KUMABOX_CNI_CONF_DIR" -maxdepth 1 -name '*.conflist' 2>/dev/null | wc -l) - if [ "$conflist_count" -gt 0 ]; then - first=$(find "$KUMABOX_CNI_CONF_DIR" -maxdepth 1 -name '*.conflist' 2>/dev/null | sort | head -1) - pass "conflist: $(basename "$first")" - else - fail "no .conflist files in $KUMABOX_CNI_CONF_DIR" - if $FIX; then - generate_cni_conflist - fi - fi +if [ -f "$CNI_CONFLIST" ]; then + pass "conflist: $(basename "$CNI_CONFLIST")" else - fail "$KUMABOX_CNI_CONF_DIR does not exist" + fail "$CNI_CONFLIST does not exist" if $FIX; then - mkdir -p "$KUMABOX_CNI_CONF_DIR" && fixed "created $KUMABOX_CNI_CONF_DIR" || warn "failed" generate_cni_conflist fi fi From 8084ae8764aac9ae09b4a807a92cfa35f9ddc225 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Thu, 10 Sep 2026 14:52:12 +0800 Subject: [PATCH 13/45] refactor: keep OCI blobs in staging only --- doctor/check.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/doctor/check.sh b/doctor/check.sh index 8238144..c56f2ad 100755 --- a/doctor/check.sh +++ b/doctor/check.sh @@ -252,7 +252,6 @@ esac check_dir "$KUMABOX_RUN_DIR" check_dir "$KUMABOX_LOG_DIR" check_dir "${KUMABOX_ROOT_DIR}/meta" -check_dir "${KUMABOX_ROOT_DIR}/images/blobs" check_dir "${KUMABOX_ROOT_DIR}/images/layers" check_dir "${KUMABOX_ROOT_DIR}/images/boot" check_dir "${KUMABOX_ROOT_DIR}/sandboxes" From 6c9fa7fa2bdd26c78817c7d0522f0c1a0b2df0fa Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Mon, 14 Sep 2026 10:05:29 +0800 Subject: [PATCH 14/45] feat(images): complete S2 OCI import and integrity checks --- .gitignore | 7 + .golangci.yml | 118 +++++ README.md | 42 +- cmd/doctor/doctor_commands.go | 2 +- cmd/image/output.go | 57 +++ cmd/image/root.go | 279 +++++++++++ cmd/image/root_test.go | 112 +++++ cmd/root.go | 54 ++- cmd/root_test.go | 26 + doctor/check.sh | 1 + errdefs/code.go | 30 ++ errdefs/error.go | 96 ++++ go.mod | 29 +- go.sum | 92 ++++ images/catalog.go | 19 + images/files.go | 55 +++ images/image.go | 105 ++++ images/import.go | 335 +++++++++++++ images/import_test.go | 438 +++++++++++++++++ images/metadata_catalog.go | 458 ++++++++++++++++++ images/oci/archive.go | 152 ++++++ images/oci/archive_test.go | 63 +++ images/oci/erofs.go | 238 +++++++++ images/oci/erofs_test.go | 77 +++ images/oci/layout.go | 159 ++++++ images/oci/local.go | 186 +++++++ images/oci/registry.go | 100 ++++ images/oci/source.go | 271 +++++++++++ images/oci/source_test.go | 299 ++++++++++++ images/paths.go | 76 +++ images/remove.go | 46 ++ images/source.go | 56 +++ images/verify.go | 82 ++++ lock/flock/lock.go | 128 +++++ lock/flock/lock_test.go | 75 +++ lock/flock/set.go | 33 ++ main.go | 6 +- metadata/collection.go | 19 + metadata/memory.go | 151 ++++++ metadata/memory_test.go | 23 + metadata/metadatatest/contract.go | 138 ++++++ metadata/sqlite/store.go | 280 +++++++++++ metadata/sqlite/store_test.go | 196 ++++++++ metadata/sqlite/transaction.go | 82 ++++ metadata/store.go | 23 + storage/publish.go | 51 ++ storage/roots.go | 159 ++++++ storage/roots_test.go | 39 ++ testdata/oci-layout/README.md | 1 + ...62e87d8460221bbbf72cf1489cecd2b6f521621a94 | 1 + ...98c82b8e7300d0b0a80d6b75121d0593cd3cbe8c7a | 1 + ...0576401129636cfdd922865438977769077f926f15 | Bin 0 -> 152 bytes testdata/oci-layout/index.json | 1 + testdata/oci-layout/oci-layout | 1 + 54 files changed, 5559 insertions(+), 9 deletions(-) create mode 100644 .golangci.yml create mode 100644 cmd/image/output.go create mode 100644 cmd/image/root.go create mode 100644 cmd/image/root_test.go create mode 100644 errdefs/code.go create mode 100644 errdefs/error.go create mode 100644 images/catalog.go create mode 100644 images/files.go create mode 100644 images/image.go create mode 100644 images/import.go create mode 100644 images/import_test.go create mode 100644 images/metadata_catalog.go create mode 100644 images/oci/archive.go create mode 100644 images/oci/archive_test.go create mode 100644 images/oci/erofs.go create mode 100644 images/oci/erofs_test.go create mode 100644 images/oci/layout.go create mode 100644 images/oci/local.go create mode 100644 images/oci/registry.go create mode 100644 images/oci/source.go create mode 100644 images/oci/source_test.go create mode 100644 images/paths.go create mode 100644 images/remove.go create mode 100644 images/source.go create mode 100644 images/verify.go create mode 100644 lock/flock/lock.go create mode 100644 lock/flock/lock_test.go create mode 100644 lock/flock/set.go create mode 100644 metadata/collection.go create mode 100644 metadata/memory.go create mode 100644 metadata/memory_test.go create mode 100644 metadata/metadatatest/contract.go create mode 100644 metadata/sqlite/store.go create mode 100644 metadata/sqlite/store_test.go create mode 100644 metadata/sqlite/transaction.go create mode 100644 metadata/store.go create mode 100644 storage/publish.go create mode 100644 storage/roots.go create mode 100644 storage/roots_test.go create mode 100644 testdata/oci-layout/README.md create mode 100644 testdata/oci-layout/blobs/sha256/32faa29cb5ac59d05ca95d62e87d8460221bbbf72cf1489cecd2b6f521621a94 create mode 100644 testdata/oci-layout/blobs/sha256/7015d0740b402b509b498898c82b8e7300d0b0a80d6b75121d0593cd3cbe8c7a create mode 100644 testdata/oci-layout/blobs/sha256/796bfaa94f0cc4aa292d1d0576401129636cfdd922865438977769077f926f15 create mode 100644 testdata/oci-layout/index.json create mode 100644 testdata/oci-layout/oci-layout diff --git a/.gitignore b/.gitignore index 7c8b012..c53f276 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,10 @@ coverage.out 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..89adb98 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,118 @@ +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" + - "!**/cmd/**" + - "!**/main.go" + deny: + - pkg: github.com/kumabox/kumabox/cmd + desc: library packages must not import command packages + sqlite-is-an-edge-adapter: + files: + - "$all" + - "!$test" + - "!**/cmd/image/**" + - "!**/metadata/sqlite/**" + deny: + - pkg: github.com/kumabox/kumabox/metadata/sqlite + desc: only cmd/image may assemble the SQLite adapter + oci-is-an-edge-adapter: + files: + - "$all" + - "!$test" + - "!**/cmd/image/**" + - "!**/images/oci/**" + deny: + - pkg: github.com/kumabox/kumabox/images/oci + desc: only cmd/image may assemble the OCI adapter + 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/README.md b/README.md index 91e97ed..3b945ce 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,10 @@ A microVM sandbox runtime for AI agents. One node runs one daemon Docker-like command line; sandboxes are Cloud Hypervisor microVMs booted from OCI images, with CNI networking, cgroups, snapshots and clone. -The core logic is being rewritten from scratch. **The current branch has no -product code yet** — it contains the specifications and the architecture gate -only. That is deliberate: each phase of `docs/ROADMAP.md` starts with a -four-part proposal, and no implementation code is written before it is -approved. +The rewrite currently provides the `kumabox` CLI, the host doctor, and OCI +image management: registry pull, layout/archive import, list, inspect, verify, +and remove. Each command opens its metadata store and exits. VM lifecycle and +a daemon are later phases of [docs/ROADMAP.md](docs/ROADMAP.md). ## Where the design lives @@ -43,6 +42,39 @@ make race # race detector, required for concurrency changes behaviour (Cloud Hypervisor, CNI, KVM) is verified manually on a Linux host using the runbook attached to each phase. +## OCI images + +Pull or import a Linux image containing regular `/boot/vmlinuz*` and +`/boot/initrd.img*` files. Image conversion requires `mkfs.erofs` 1.8 or newer; +unit and integration tests use a stand-in and run on macOS without root/KVM. +The [synthetic fixture](testdata/oci-layout/README.md) cannot boot a VM. + +```bash +kumabox image pull REGISTRY/IMAGE:TAG --platform linux/amd64 +kumabox image import tiny ./testdata/oci-layout --platform linux/amd64 +kumabox image ls --json +kumabox image inspect tiny +kumabox image verify tiny +kumabox image rm tiny +``` + +For a separate data store, pass all three roots: + +```bash +kumabox --root-dir /tmp/kb/data --run-dir /tmp/kb/run --log-dir /tmp/kb/log image ls --json +``` + +Import validates OCI manifest/config/layer digests and layer diffIDs, streams +layers to EROFS, and extracts boot candidates with layer overwrite/whiteout +semantics. Metadata is committed after durable publication and final digest +checks. Verification detects EROFS and boot-file corruption. Source OCI blobs +are never stored persistently; repeated imports reuse verified, registered +artifacts. Removing a name retains artifacts until the last image reference +is removed. + +The [Linux acceptance runbook](docs/runbooks/s2-oci.md) covers real conversion, +registry pull, cancellation, concurrency, and crash/retry behavior. + ## Reference material - Cocoon at `../cocoon@27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` is the diff --git a/cmd/doctor/doctor_commands.go b/cmd/doctor/doctor_commands.go index f7b50c4..aa03f89 100644 --- a/cmd/doctor/doctor_commands.go +++ b/cmd/doctor/doctor_commands.go @@ -37,7 +37,7 @@ func NewCommand() *cobra.Command { return fmt.Errorf("find %s: %w", checkerName, err) } - check := exec.CommandContext(command.Context(), path, args...) + 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() diff --git a/cmd/image/output.go b/cmd/image/output.go new file mode 100644 index 0000000..aed59c8 --- /dev/null +++ b/cmd/image/output.go @@ -0,0 +1,57 @@ +package image + +import ( + "time" + + "github.com/kumabox/kumabox/images" +) + +// CLI serialization belongs to the command adapter, not image domain types. +type imageOutput struct { + Names []string `json:"names"` + ManifestDigest string `json:"manifest_digest"` + Platform platformOutput `json:"platform"` + Layers []layerOutput `json:"layers"` + Boot bootOutput `json:"boot"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"created_at"` +} + +type ( + platformOutput struct { + OS string `json:"os"` + Architecture string `json:"architecture"` + } + layerOutput struct { + SourceDigest string `json:"source_digest"` + EROFSDigest string `json:"erofs_digest"` + Size int64 `json:"size"` + BootFiles []bootFileOutput `json:"boot_files"` + Whiteouts []string `json:"whiteouts,omitempty"` + BootOpaque bool `json:"boot_opaque,omitempty"` + } + + bootFileOutput struct { + Name string `json:"name"` + Digest string `json:"digest"` + Size int64 `json:"size"` + } + bootOutput struct { + KernelLayer string `json:"kernel_layer"` + KernelFile string `json:"kernel_file"` + InitrdLayer string `json:"initrd_layer"` + InitrdFile string `json:"initrd_file"` + } +) + +func imageResult(image images.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{KernelLayer: image.Boot.KernelLayer.String(), KernelFile: image.Boot.KernelFile, InitrdLayer: image.Boot.InitrdLayer.String(), InitrdFile: image.Boot.InitrdFile}, Size: image.Size, CreatedAt: image.CreatedAt} +} diff --git a/cmd/image/root.go b/cmd/image/root.go new file mode 100644 index 0000000..9f234f9 --- /dev/null +++ b/cmd/image/root.go @@ -0,0 +1,279 @@ +package image + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "runtime" + "strings" + + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/images/oci" + "github.com/kumabox/kumabox/metadata" + metadatasqlite "github.com/kumabox/kumabox/metadata/sqlite" + "github.com/kumabox/kumabox/storage" +) + +type rootsProvider func() storage.Roots + +func NewCommand(roots rootsProvider) *cobra.Command { + command := &cobra.Command{Use: "image", Short: "manage OCI images", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { return command.Help() }} + command.AddCommand( + newPullCommand(roots), + newImportCommand(roots), + newListCommand(roots), + newInspectCommand(roots), + newVerifyCommand(roots), + newRemoveCommand(roots), + ) + return command +} + +type opened struct { + store metadata.Store + paths images.Paths + catalog *images.MetadataCatalog +} + +func openStore(ctx context.Context, roots storage.Roots) (*opened, error) { + paths, err := images.NewPaths(roots) + if err != nil { + return nil, err + } + if err := paths.Ensure(); err != nil { + return nil, err + } + store, err := metadatasqlite.Open(ctx, paths.MetadataDB(), images.Collections(), metadatasqlite.DefaultOptions()) + if err != nil { + return nil, err + } + return &opened{store: store, paths: paths, catalog: images.NewMetadataCatalog(store)}, nil +} + +func newImporter(ctx context.Context, state *opened, stderr io.Writer, platform images.Platform) (*images.Importer, error) { + options := images.DefaultOptions() + converter, err := oci.NewEROFSConverterWithLimits(ctx, platform.Architecture, options.Limits) + if err != nil { + return nil, err + } + return images.NewImporter(state.paths, state.catalog, converter, textReporter{writer: stderr}, options) +} + +func newPullCommand(roots rootsProvider) *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 + } + source, name, err := oci.NewRegistry(args[0]) + if err != nil { + return err + } + state, err := openStore(command.Context(), roots()) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, state.store.Close()) }() + importer, err := newImporter(command.Context(), state, command.ErrOrStderr(), parsedPlatform) + if err != nil { + return err + } + image, err := importer.Import(command.Context(), name, parsedPlatform, source) + if err != nil { + return err + } + return writeImage(command.OutOrStdout(), image) + }, + } + command.Flags().StringVar(&platform, "platform", platform, "target platform (linux/amd64 or linux/arm64)") + return command +} + +func newImportCommand(roots rootsProvider) *cobra.Command { + platform := defaultPlatform() + command := &cobra.Command{ + Use: "import NAME PATH", + Short: "import an OCI image layout or OCI archive", + Args: cobra.ExactArgs(2), + RunE: func(command *cobra.Command, args []string) (returnErr error) { + parsedPlatform, err := parsePlatform(platform) + if err != nil { + return err + } + state, err := openStore(command.Context(), roots()) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, state.store.Close()) }() + source, cleanup, err := localSource(command.Context(), args[1], state.paths) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, cleanup()) }() + importer, err := newImporter(command.Context(), state, command.ErrOrStderr(), parsedPlatform) + if err != nil { + return err + } + image, err := importer.Import(command.Context(), args[0], parsedPlatform, source) + if err != nil { + return err + } + return writeImage(command.OutOrStdout(), image) + }, + } + command.Flags().StringVar(&platform, "platform", platform, "target platform (linux/amd64 or linux/arm64)") + return command +} + +func newListCommand(roots rootsProvider) *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 := openStore(command.Context(), roots()) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, state.store.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 json.NewEncoder(command.OutOrStdout()).Encode(results) + } + for _, item := range items { + if _, err := fmt.Fprintf(command.OutOrStdout(), "%s\t%s\t%d\n", strings.Join(item.Names, ","), item.ManifestDigest, item.Size); err != nil { + return err + } + } + return nil + }, + } + command.Flags().BoolVar(&asJSON, "json", false, "write JSON") + return command +} + +func newInspectCommand(roots rootsProvider) *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 := openStore(command.Context(), roots()) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, state.store.Close()) }() + image, err := state.catalog.Resolve(command.Context(), args[0]) + if err != nil { + return err + } + return json.NewEncoder(command.OutOrStdout()).Encode(imageResult(image)) + }, + } +} + +func newVerifyCommand(roots rootsProvider) *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) { + state, err := openStore(command.Context(), roots()) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, state.store.Close()) }() + image, err := images.Verify(command.Context(), state.paths, state.catalog, args[0]) + if err != nil { + return err + } + _, err = fmt.Fprintf(command.OutOrStdout(), "verified %s\n", image.ManifestDigest) + return err + }, + } +} + +func newRemoveCommand(roots rootsProvider) *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) { + state, err := openStore(command.Context(), roots()) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, state.store.Close()) }() + for _, reference := range args { + removed, err := images.Remove(command.Context(), state.paths, state.catalog, reference) + if err != nil { + return err + } + if _, err := fmt.Fprintf(command.OutOrStdout(), "removed %s\n", strings.Join(removed.Names, ",")); err != nil { + return errdefs.Context(err, "remove image", reference, "report", "image removed", true) + } + } + return nil + }, + } +} + +func localSource(ctx context.Context, path string, paths images.Paths) (images.Source, func() error, error) { + info, err := os.Stat(path) + if err != nil { + return nil, nil, fmt.Errorf("inspect OCI source: %w", err) + } + if info.IsDir() { + source, err := oci.NewLayout(path) + return source, func() error { return nil }, err + } + source, cleanup, err := oci.NewArchiveContext(ctx, path, paths.StagingDir(), oci.DefaultLimits()) + return source, cleanup, err +} + +func parsePlatform(value string) (images.Platform, error) { + parts := strings.Split(value, "/") + if len(parts) != 2 || parts[0] != "linux" || (parts[1] != "amd64" && parts[1] != "arm64") { + return images.Platform{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("unsupported platform %q", value)) + } + return images.Platform{OS: parts[0], Architecture: parts[1]}, nil +} + +func defaultPlatform() string { return "linux/" + runtime.GOARCH } + +func writeImage(writer io.Writer, image images.Image) error { + _, err := fmt.Fprintf(writer, "%s\t%s\n", strings.Join(image.Names, ","), image.ManifestDigest) + return err +} + +type textReporter struct { + writer io.Writer +} + +func (r textReporter) Layer(position, total int, digest images.Digest) error { + _, err := fmt.Fprintf(r.writer, "layer %d/%d %s\n", position+1, total, digest) + return err +} + +func (r textReporter) Committed(images.Image) error { return nil } diff --git a/cmd/image/root_test.go b/cmd/image/root_test.go new file mode 100644 index 0000000..16fbc77 --- /dev/null +++ b/cmd/image/root_test.go @@ -0,0 +1,112 @@ +package image + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/storage" +) + +func TestImageCommandsFromLayoutAndArchive(t *testing.T) { + 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() storage.Roots { return roots }) + var out, stderr bytes.Buffer + command.SetOut(&out) + command.SetErr(&stderr) + command.SetArgs(args) + err := command.ExecuteContext(t.Context()) + return out.String(), err + } + 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 _, 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) + } +} diff --git a/cmd/root.go b/cmd/root.go index 32172b3..27de560 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,9 @@ import ( "github.com/spf13/cobra" doctorcmd "github.com/kumabox/kumabox/cmd/doctor" + imagecmd "github.com/kumabox/kumabox/cmd/image" + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/version" ) @@ -39,6 +42,10 @@ func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) error 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 @@ -47,7 +54,7 @@ func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) error if errors.As(err, &coded) { return err } - return &codedError{err: err, code: exitUsage} + return &codedError{err: err, code: errorExitCode(err)} } // ExitCode returns the process exit status represented by err. @@ -69,8 +76,11 @@ func Silent(err error) bool { } func newRootCommand() *cobra.Command { + roots := storage.DefaultRoots() 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, @@ -78,12 +88,54 @@ func newRootCommand() *cobra.Command { root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return &codedError{err: err, code: exitUsage} }) + root.PersistentFlags().StringVar(&roots.Data, "root-dir", roots.Data, "persistent data directory") + root.PersistentFlags().StringVar(&roots.Run, "run-dir", roots.Run, "runtime state directory") + root.PersistentFlags().StringVar(&roots.Log, "log-dir", roots.Log, "log directory") root.AddCommand(doctorcmd.NewCommand()) + root.AddCommand(imagecmd.NewCommand(func() storage.Roots { return roots })) root.AddCommand(newVersionCommand()) + classifyArguments(root) return root } +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 + } +} + +func classifyArguments(command *cobra.Command) { + if command.Args != nil { + command.Args = usageArgs(command.Args) + } + for _, child := range command.Commands() { + classifyArguments(child) + } +} + +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.CodeReferenced: + return 4 + case errdefs.CodeInvalidArgument, errdefs.CodeHostIncompatible, errdefs.CodeDigestMismatch, errdefs.CodeArtifactCorrupt: + return 5 + case errdefs.CodeArtifactUnavailable, errdefs.CodeStoreBusy: + return 6 + default: + return 1 + } +} + func newVersionCommand() *cobra.Command { var asJSON bool command := &cobra.Command{ diff --git a/cmd/root_test.go b/cmd/root_test.go index f6b5b08..b008e35 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -48,3 +48,29 @@ func TestVersion(t *testing.T) { t.Fatalf("version output = %q", stdout.String()) } } + +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}, + {"unknown flag", []string{"image", "ls", "--wrong"}, 2}, + {"unsupported platform", []string{"image", "pull", "example.com/image", "--platform", "windows/amd64"}, 5}, + {"missing image", []string{"image", "inspect", "missing"}, 3}, + {"empty list", []string{"image", "ls", "--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) + } + }) + } +} diff --git a/doctor/check.sh b/doctor/check.sh index c56f2ad..f2d1d5f 100755 --- a/doctor/check.sh +++ b/doctor/check.sh @@ -260,6 +260,7 @@ 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" diff --git a/errdefs/code.go b/errdefs/code.go new file mode 100644 index 0000000..070589e --- /dev/null +++ b/errdefs/code.go @@ -0,0 +1,30 @@ +package errdefs + +// Code is a stable machine-readable failure code. +type Code string + +const ( + CodeNotFound Code = "NOT_FOUND" + CodeNameTaken Code = "NAME_TAKEN" + CodeInvalidArgument Code = "INVALID_ARGUMENT" + CodeHostIncompatible Code = "HOST_INCOMPATIBLE" + CodeDigestMismatch Code = "IMAGE_DIGEST_MISMATCH" + CodeArtifactCorrupt Code = "ARTIFACT_CORRUPT" + CodeArtifactUnavailable Code = "ARTIFACT_UNAVAILABLE" + CodeReferenced Code = "REFERENCED" + CodeStoreBusy Code = "STORE_BUSY" + CodeInternal Code = "INTERNAL" +) + +// Class groups codes that share handling policy. +type Class uint8 + +const ( + ClassUnknown Class = iota + ClassNotFound + ClassInvalid + ClassConflict + ClassUnavailable + ClassCorrupt + ClassInternal +) diff --git a/errdefs/error.go b/errdefs/error.go new file mode 100644 index 0000000..9f9c148 --- /dev/null +++ b/errdefs/error.go @@ -0,0 +1,96 @@ +package errdefs + +import ( + "errors" + "fmt" +) + +// Error carries stable classification and diagnostic context across layers. +type Error struct { + Class Class + Code Code + Operation string + Entity string + Phase string + Committed bool + Retry bool + Action string + Cause error +} + +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 +} + +func (e *Error) Unwrap() error { + if e == nil { + return nil + } + 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 adds operation context without changing an existing classification. +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 = fmt.Errorf("%w", err) + return © + } + return &Error{ + Class: ClassInternal, Code: CodeInternal, Operation: operation, + Entity: entity, Phase: phase, Committed: committed, Action: action, + Cause: 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 +} + +func first(value, fallback string) string { + if value != "" { + return value + } + return fallback +} diff --git a/go.mod b/go.mod index 810f8ae..67e0e82 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,36 @@ module github.com/kumabox/kumabox go 1.24.4 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/gofrs/flock v0.13.0 + github.com/google/go-containerregistry v0.20.6 + github.com/klauspost/compress v1.18.0 + github.com/spf13/cobra v1.10.2 + golang.org/x/sync v0.15.0 + modernc.org/sqlite v1.38.2 +) require ( + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // 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.9.3 // indirect + github.com/dustin/go-humanize v1.0.1 // 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/mitchellh/go-homedir v1.1.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.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.37.0 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..6c6a3c7 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,102 @@ +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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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 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.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/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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +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/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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= +github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= 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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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.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.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.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= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/images/catalog.go b/images/catalog.go new file mode 100644 index 0000000..54be206 --- /dev/null +++ b/images/catalog.go @@ -0,0 +1,19 @@ +package images + +import "context" + +type CatalogReader interface { + Resolve(context.Context, string) (Image, error) + List(context.Context) ([]Image, error) + FindLayers(context.Context, []Digest) (map[Digest]Layer, error) +} + +type CatalogWriter interface { + CommitImport(context.Context, ImportCommit) error + Remove(context.Context, string, Digest) (Removal, error) +} + +type Catalog interface { + CatalogReader + CatalogWriter +} diff --git a/images/files.go b/images/files.go new file mode 100644 index 0000000..5ad8822 --- /dev/null +++ b/images/files.go @@ -0,0 +1,55 @@ +package images + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/storage" +) + +func digestFileContext(ctx context.Context, path string) (Digest, int64, error) { + if err := storage.CheckPath(path); err != nil { + return Digest{}, 0, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err) + } + info, err := os.Lstat(path) + if err != nil { + return Digest{}, 0, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err) + } + if !info.Mode().IsRegular() { + return 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 Digest{}, 0, err + } + file, err := root.Open(filepath.Base(path)) + if err != nil { + return 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 Digest{}, 0, fmt.Errorf("hash %s: %w", path, err) + } + digest, err := ParseDigest(fmt.Sprintf("sha256:%x", hash.Sum(nil))) + return digest, size, err +} + +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/images/image.go b/images/image.go new file mode 100644 index 0000000..a4589c6 --- /dev/null +++ b/images/image.go @@ -0,0 +1,105 @@ +package images + +import ( + "encoding/hex" + "fmt" + "strings" + "time" +) + +// Digest is a validated SHA-256 content identity. +type Digest struct { + value [32]byte +} + +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 +} + +func (d Digest) String() string { return "sha256:" + hex.EncodeToString(d.value[:]) } +func (d Digest) Hex() string { return hex.EncodeToString(d.value[:]) } +func (d Digest) IsZero() bool { return d == Digest{} } + +func (d Digest) MarshalText() ([]byte, error) { return []byte(d.String()), nil } + +func (d *Digest) UnmarshalText(value []byte) error { + parsed, err := ParseDigest(string(value)) + if err != nil { + return err + } + *d = parsed + return nil +} + +type Platform struct { + OS string + Architecture string +} + +type Layer struct { + SourceDigest Digest + EROFSDigest Digest + Size int64 + BootFiles []BootFile + Whiteouts []string + BootOpaque bool +} + +// BootFile is a regular boot candidate extracted from a source layer. +type BootFile struct { + Name string + Digest Digest + Size int64 +} + +type Boot struct { + KernelFile string + InitrdFile string + KernelLayer Digest + InitrdLayer Digest +} + +type Image struct { + Names []string + ManifestDigest Digest + Platform Platform + Layers []Layer + Boot Boot + Size int64 + CreatedAt time.Time +} + +type Manifest struct { + Digest Digest + Platform Platform + Layers []Descriptor +} + +type Descriptor struct { + Digest Digest + Size int64 +} + +type ImportCommit struct { + Name string + Manifest Manifest + Layers []Layer + Boot Boot + Size int64 + Created time.Time +} + +type Removal struct { + Names []string + Layers []Digest +} diff --git a/images/import.go b/images/import.go new file mode 100644 index 0000000..6502372 --- /dev/null +++ b/images/import.go @@ -0,0 +1,335 @@ +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" +) + +type Options struct { + Limits Limits + Parallelism int + Now func() time.Time +} + +func DefaultOptions() Options { + return Options{Limits: DefaultLimits(), Parallelism: min(4, max(1, runtime.NumCPU())), Now: time.Now} +} + +type Importer struct { + paths Paths + catalog Catalog + converter Converter + reporter Reporter + reportMu sync.Mutex + options Options +} + +func NewImporter(paths Paths, catalog Catalog, 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 +} + +func (i *Importer) Import(ctx context.Context, name string, platform Platform, source Source) (result Image, returnErr error) { + if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\r\n\t") || source == nil || !validPlatform(platform) { + return Image{}, invalidImage("image name, supported platform and source are required") + } + if err := ctx.Err(); err != nil { + return Image{}, err + } + if err := i.paths.Ensure(); err != nil { + return Image{}, errdefs.Context(err, "import image", name, "prepare", "check managed directory permissions", false) + } + manifest, err := source.Resolve(ctx, platform) + if err != nil { + return Image{}, errdefs.Context(err, "import image", name, "resolve", "check the OCI source and platform", false) + } + if manifest.Digest.IsZero() || manifest.Platform != platform || len(manifest.Layers) == 0 { + return Image{}, invalidImage("invalid OCI manifest or platform") + } + digests := make([]Digest, len(manifest.Layers)) + for position, descriptor := range manifest.Layers { + if descriptor.Digest.IsZero() || descriptor.Size < 0 || descriptor.Size > i.options.Limits.LayerSize { + return Image{}, invalidImage("invalid OCI layer descriptor") + } + digests[position] = descriptor.Digest + } + known, err := i.catalog.FindLayers(ctx, digests) + if err != nil { + return Image{}, err + } + staging, err := i.paths.NewStaging("image-*") + if err != nil { + return 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 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 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 Image{}, err + } + layers := make([]Layer, len(converted)) + for pos, artifact := range converted { + if err := ctx.Err(); err != nil { + return 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 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 Image{}, errdefs.Context(err, "import image", name, "publish", "retry the import", false) + } + if old, exists := current[layer.SourceDigest]; exists && !sameLayer(old, layer) { + return 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 Image{}, err + } + 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 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 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 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) +} + +func (i *Importer) convert(ctx context.Context, source Source, descriptor 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 +} + +func cachedArtifact(paths Paths, layer 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 +} + +func (i *Importer) publishLayer(ctx context.Context, artifact ConvertedLayer, staging string) (Layer, error) { + layer := 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 Layer{}, err + } + if actual != artifact.EROFSDigest || size != artifact.Size || size <= 0 { + return 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 Layer{}, invalidImage("invalid boot file name") + } + digest, size, err := stagedDigest(ctx, staging, file.Path) + if err != nil { + return Layer{}, err + } + if size == 0 { + return Layer{}, invalidImage("empty boot file %s", file.Name) + } + layer.BootFiles = append(layer.BootFiles, BootFile{Name: file.Name, Digest: digest, Size: size}) + } + // Check against every existing committed mapping BEFORE replacing shared files. + known, err := i.catalog.FindLayers(ctx, []Digest{layer.SourceDigest}) + if err != nil { + return Layer{}, err + } + if old, exists := known[layer.SourceDigest]; exists && !sameLayer(old, layer) { + return 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 Layer{}, err + } + for _, file := range artifact.BootFiles { + final, err := i.paths.BootFile(artifact.SourceDigest, file.Name) + if err != nil { + return Layer{}, err + } + if err := storage.Publish(file.Path, final); err != nil { + return Layer{}, err + } + } + return layer, nil +} + +func stagedDigest(ctx context.Context, staging, path string) (Digest, int64, error) { + rel, err := filepath.Rel(staging, path) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return Digest{}, 0, invalidImage("converter artifact escapes staging") + } + return digestFileContext(ctx, path) +} + +func selectBoot(layers []Layer) (Boot, error) { + type candidate struct { + layer Digest + file 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 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 Boot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("image is missing a regular /boot/vmlinuz* kernel")) + } + if boot.InitrdLayer.IsZero() { + return Boot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("image is missing a regular /boot/initrd.img* initrd")) + } + return boot, nil +} + +func validPlatform(p Platform) bool { + return p.OS == "linux" && (p.Architecture == "amd64" || p.Architecture == "arm64") +} + +func invalidImage(format string, args ...any) error { + return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf(format, args...)) +} +func validFile(path string) bool { return storage.CheckPath(path) == nil && regularNonempty(path) } +func regularNonempty(path string) bool { + info, err := os.Lstat(path) + return err == nil && info.Mode().IsRegular() && info.Size() > 0 +} + +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..b101182 --- /dev/null +++ b/images/import_test.go @@ -0,0 +1,438 @@ +package images + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/kumabox/kumabox/metadata" + metadatasqlite "github.com/kumabox/kumabox/metadata/sqlite" + "github.com/kumabox/kumabox/storage" +) + +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 := 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(), 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: Manifest{ + Digest: manifestDigest, Platform: Platform{OS: "linux", Architecture: "amd64"}, + Layers: []Descriptor{{Digest: layerDigest, Size: 3}}, + }} + converter := &fakeConverter{} + catalog := NewMetadataCatalog(store) + importer, err := NewImporter(paths, catalog, converter, nil, 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 := 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 := Remove(t.Context(), paths, catalog, "first"); err != nil { + t.Fatalf("remove first alias: %v", err) + } + if !validFile(paths.EROFS(layerDigest)) { + t.Fatal("shared layer removed with remaining alias") + } + if _, err := 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 Manifest +} + +func (f fakeSource) Resolve(context.Context, Platform) (Manifest, error) { return f.manifest, nil } + +func (f fakeSource) OpenLayer(context.Context, 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 Descriptor, source io.Reader, workDir string) (ConvertedLayer, error) { + if _, err := io.Copy(io.Discard, source); err != nil { + return 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 ConvertedLayer{}, err + } + } + product, size, err := digestFileContext(ctx, erofs) + if err != nil { + return ConvertedLayer{}, err + } + return ConvertedLayer{ + SourceDigest: descriptor.Digest, EROFSPath: erofs, EROFSDigest: product, + Size: size, BootFiles: []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) Digest { + t.Helper() + digest, err := 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) (Paths, *MetadataCatalog) { + 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.Ensure(); err != nil { + t.Fatal(err) + } + if store == nil { + memory, err := metadata.NewMemory(Collections()) + if err != nil { + t.Fatal(err) + } + store = memory + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Error(err) + } + }) + return paths, NewMetadataCatalog(store) +} + +func testManifest(t *testing.T, manifestDigit string) Manifest { + t.Helper() + return Manifest{Digest: testDigest(t, manifestDigit), Platform: Platform{OS: "linux", Architecture: "amd64"}, Layers: []Descriptor{{Digest: testDigest(t, "1"), Size: 3}}} +} + +func testImporter(t *testing.T, paths Paths, catalog Catalog, converter Converter) *Importer { + t.Helper() + importer, err := NewImporter(paths, catalog, converter, nil, 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.options.Now = func() time.Time { return time.Unix(20, 0) } + // 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 := 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 := Verify(t.Context(), paths, catalog, "alias"); err != nil { + t.Fatal(err) + } + if _, err := 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 Descriptor, reader io.Reader, workDir string) (ConvertedLayer, error) { + select { + case f.started <- struct{}{}: + case <-ctx.Done(): + return ConvertedLayer{}, ctx.Err() + } + select { + case <-f.release: + case <-ctx.Done(): + return 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 := Remove(t.Context(), paths, catalog, "2"); err != nil { + t.Fatal(err) + } + if _, err := Verify(t.Context(), paths, catalog, "3"); err != nil { + t.Fatalf("shared layer deleted: %v", err) + } + if _, err := Remove(t.Context(), paths, catalog, "3"); err != nil { + t.Fatal(err) + } + if validFile(paths.EROFS(testDigest(t, "1"))) { + 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(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 := 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 Descriptor, source io.Reader, workDir string) (ConvertedLayer, error) { + if f.fail != nil { + return 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 := []Layer{ + {SourceDigest: first, BootFiles: []BootFile{{Name: "vmlinuz-1"}, {Name: "vmlinuz-2"}, {Name: "initrd.img"}}}, + {SourceDigest: second, Whiteouts: []string{"vmlinuz-2"}, BootFiles: []BootFile{{Name: "initrd.img"}}}, + } + boot, err := 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 := 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/metadata_catalog.go b/images/metadata_catalog.go new file mode 100644 index 0000000..de12221 --- /dev/null +++ b/images/metadata_catalog.go @@ -0,0 +1,458 @@ +package images + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/metadata" +) + +const ( + CollectionImages metadata.Collection = "images" + CollectionNames metadata.Collection = "image_names" + CollectionLayers metadata.Collection = "image_layers" + minimumDigestPrefix = 12 +) + +func Collections() []metadata.Collection { + return []metadata.Collection{CollectionImages, CollectionNames, CollectionLayers} +} + +type MetadataCatalog struct { + store metadata.Store +} + +func NewMetadataCatalog(store metadata.Store) *MetadataCatalog { + return &MetadataCatalog{store: store} +} + +type imageRecord struct { + ManifestDigest string `json:"manifest_digest"` + OS string `json:"os"` + Architecture string `json:"architecture"` + KernelLayer string `json:"kernel_layer"` + KernelFile string `json:"kernel_file"` + InitrdFile string `json:"initrd_file"` + InitrdLayer string `json:"initrd_layer"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"created_at"` +} + +type nameRecord struct { + ManifestDigest string `json:"manifest_digest"` +} + +type layerRecord struct { + ManifestDigest string `json:"manifest_digest"` + Position int `json:"position"` + SourceDigest string `json:"source_digest"` + EROFSDigest string `json:"erofs_digest"` + Size int64 `json:"size"` + BootFiles []bootFileRecord `json:"boot_files"` + Whiteouts []string `json:"whiteouts"` + BootOpaque bool `json:"boot_opaque"` +} + +type bootFileRecord struct { + Name string `json:"name"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +func encodeBootFiles(files []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 +} + +func (c *MetadataCatalog) Resolve(ctx context.Context, reference string) (Image, error) { + var result Image + err := c.store.View(ctx, func(reader metadata.Reader) error { + image, err := resolveRecord(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) +} + +func (c *MetadataCatalog) List(ctx context.Context) ([]Image, error) { + result := make([]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 Image) int { + return strings.Compare(left.ManifestDigest.String(), right.ManifestDigest.String()) + }) + return result, errdefs.Context(err, "list images", "", "metadata", "inspect the metadata store", false) +} + +func (c *MetadataCatalog) FindLayers(ctx context.Context, digests []Digest) (map[Digest]Layer, error) { + wanted := make(map[Digest]struct{}, len(digests)) + for _, digest := range digests { + wanted[digest] = struct{}{} + } + result := make(map[Digest]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 && !sameLayer(previous, layer) { + return corruptRecord("shared layer", errors.New("conflicting artifact metadata")) + } + result[layer.SourceDigest] = layer + } + return nil + }) + }) + return result, err +} + +func (c *MetadataCatalog) CommitImport(ctx context.Context, commit ImportCommit) error { + if err := validateCommit(commit); 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 !sameLayer(layer, 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, + 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) +} + +func (c *MetadataCatalog) Remove(ctx context.Context, reference string, expected Digest) (Removal, error) { + var result Removal + err := c.store.Update(ctx, func(writer metadata.Writer) error { + result = 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 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) +} + +func resolveRecord(ctx context.Context, reader metadata.Reader, reference string) (Image, error) { + digestID := "" + if raw, ok, err := reader.Get(ctx, CollectionNames, reference); err != nil { + return Image{}, err + } else if ok { + var record nameRecord + if err := json.Unmarshal(raw, &record); err != nil { + return Image{}, corruptRecord("name", err) + } + digestID = record.ManifestDigest + } else { + prefix := strings.TrimPrefix(reference, "sha256:") + if len(prefix) < minimumDigestPrefix { + return 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 Image{}, err + } + } + if digestID == "" { + return Image{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("image %q not found", reference)) + } + return loadImage(ctx, reader, digestID) +} + +func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (Image, error) { + raw, ok, err := reader.Get(ctx, CollectionImages, digestID) + if err != nil { + return Image{}, err + } + if !ok { + return 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 Image{}, corruptRecord("image", err) + } + manifest, err := ParseDigest(record.ManifestDigest) + if err != nil { + return Image{}, corruptRecord("image digest", err) + } + kernel, err := ParseDigest(record.KernelLayer) + if err != nil { + return Image{}, corruptRecord("kernel digest", err) + } + initrd, err := ParseDigest(record.InitrdLayer) + if err != nil { + return Image{}, corruptRecord("initrd digest", err) + } + image := Image{ + ManifestDigest: manifest, Platform: Platform{OS: record.OS, Architecture: record.Architecture}, + Boot: Boot{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 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 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 Image{}, corruptRecord("layer order", errors.New("noncontiguous layer positions")) + } + layer, err := decodeLayer(item) + if err != nil { + return Image{}, err + } + image.Layers = append(image.Layers, layer) + } + if manifest.String() != digestID { + return Image{}, corruptRecord("image identity", errors.New("record key differs from manifest digest")) + } + descriptors := make([]Descriptor, len(image.Layers)) + for pos, layer := range image.Layers { + descriptors[pos] = Descriptor{Digest: layer.SourceDigest} + } + if err := validateCommit(ImportCommit{Name: "stored", Manifest: Manifest{Digest: manifest, Platform: image.Platform, Layers: descriptors}, Layers: image.Layers, Boot: image.Boot, Size: image.Size, Created: image.CreatedAt}); err != nil { + return Image{}, corruptRecord("image facts", err) + } + slices.Sort(image.Names) + return image, nil +} + +func decodeLayer(record layerRecord) (Layer, error) { + source, err := ParseDigest(record.SourceDigest) + if err != nil { + return Layer{}, corruptRecord("source layer digest", err) + } + erofs, err := ParseDigest(record.EROFSDigest) + if err != nil { + return Layer{}, corruptRecord("erofs digest", err) + } + layer := Layer{SourceDigest: source, EROFSDigest: erofs, Size: record.Size, Whiteouts: record.Whiteouts, BootOpaque: record.BootOpaque} + for _, file := range record.BootFiles { + digest, err := ParseDigest(file.Digest) + if err != nil || !IsBootName(file.Name) || file.Size <= 0 { + return Layer{}, corruptRecord("boot file", errors.New("invalid name, digest or size")) + } + layer.BootFiles = append(layer.BootFiles, BootFile{Name: file.Name, Digest: digest, Size: file.Size}) + } + if layer.SourceDigest.IsZero() || layer.EROFSDigest.IsZero() || layer.Size <= 0 { + return Layer{}, corruptRecord("layer", errors.New("invalid digest or size")) + } + for _, name := range layer.Whiteouts { + if !IsBootName(name) { + return Layer{}, corruptRecord("whiteout", errors.New("invalid boot whiteout")) + } + } + return layer, nil +} + +func validateCommit(commit ImportCommit) error { + if commit.Name == "" || commit.Manifest.Digest.IsZero() || !validPlatform(commit.Manifest.Platform) || 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) + if err != nil || boot != commit.Boot || size != commit.Size { + return errors.New("inconsistent boot selection or total image size") + } + return 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)) +} + +func layerKey(manifest Digest, position int) string { + return fmt.Sprintf("%s/%08d", manifest.String(), position) +} + +func layerReferenced(ctx context.Context, reader metadata.Reader, digest 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/oci/archive.go b/images/oci/archive.go new file mode 100644 index 0000000..bfdf837 --- /dev/null +++ b/images/oci/archive.go @@ -0,0 +1,152 @@ +package oci + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/storage" +) + +const maxArchiveEntries = 1 << 20 + +func NewArchive(path, stagingRoot string) (images.Source, func() error, error) { + return NewArchiveContext(context.TODO(), path, stagingRoot, DefaultLimits()) +} + +func NewArchiveContext(ctx context.Context, path, stagingRoot string, limits Limits) (images.Source, func() error, error) { + if !limits.Valid() { + return nil, nil, invalidSource("OCI size limits must be positive and bounded") + } + if err := ctx.Err(); err != nil { + return nil, nil, err + } + if err := storage.EnsureDir(stagingRoot); err != nil { + return nil, nil, err + } + dir, err := os.MkdirTemp(stagingRoot, "oci-archive-*") + if err != nil { + return nil, nil, fmt.Errorf("create OCI archive staging: %w", err) + } + cleanup := func() error { return os.RemoveAll(dir) } + if err := extractArchiveContext(ctx, path, dir, limits.ArchiveSize); err != nil { + return nil, nil, errors.Join(err, cleanup()) + } + source, err := NewLayoutWithLimits(dir, limits) + if err != nil { + return nil, nil, errors.Join(err, cleanup()) + } + return source, cleanup, nil +} + +func extractArchive(path, destination string) error { + return extractArchiveContext(context.TODO(), path, destination, DefaultLimits().ArchiveSize) +} + +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 OCI 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("OCI 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 OCI archive exceeds %d bytes", limit) + } + return nil + } + if err != nil { + return invalidSource("read OCI 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 OCI 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("OCI 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 OCI archive file: %w", err) + } + default: + return invalidSource("unsupported OCI archive entry %q type %d", header.Name, header.Typeflag) + } + } +} + +type contextInput struct { + ctx context.Context + source io.Reader +} + +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/oci/archive_test.go b/images/oci/archive_test.go new file mode 100644 index 0000000..1c2c414 --- /dev/null +++ b/images/oci/archive_test.go @@ -0,0 +1,63 @@ +package oci + +import ( + "archive/tar" + "fmt" + "os" + "path/filepath" + "testing" +) + +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 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") + } +} + +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, 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) + } + }) + } +} diff --git a/images/oci/erofs.go b/images/oci/erofs.go new file mode 100644 index 0000000..c5caf5f --- /dev/null +++ b/images/oci/erofs.go @@ -0,0 +1,238 @@ +package oci + +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" +) + +const ( + erofsBlockSize = 4096 +) + +type EROFSConverter struct { + architecture string + limits Limits +} + +func NewEROFSConverter(ctx context.Context, architecture string) (*EROFSConverter, error) { + return NewEROFSConverterWithLimits(ctx, architecture, DefaultLimits()) +} + +func NewEROFSConverterWithLimits(ctx context.Context, architecture string, limits Limits) (*EROFSConverter, error) { + if !limits.Valid() || (architecture != "amd64" && architecture != "arm64") { + return nil, invalidSource("invalid converter architecture or size limits") + } + output, err := exec.CommandContext(ctx, "mkfs.erofs", "--version").CombinedOutput() + 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 &EROFSConverter{architecture: architecture, limits: limits}, nil +} + +func (c *EROFSConverter) Convert(ctx context.Context, descriptor images.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, + "mkfs.erofs", + "--tar=f", + "-zlz4hc", + fmt.Sprintf("-C%d", erofsBlockSize), + "-T0", + "-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 OCI layer: %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{}, sourceError(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 +} + +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, invalidSource("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, invalidSource("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}) + } +} + +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 invalidSource("boot artifact size %d is outside limit %d", written, limit) + } + return nil +} + +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)) +} + +func deterministicUUID(digest images.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]) +} + +func digestPath(ctx context.Context, path string) (images.Digest, int64, error) { + file, err := os.Open(path) //nolint:gosec // path is a managed staging path + if err != nil { + return images.Digest{}, 0, fmt.Errorf("open generated EROFS: %w", err) + } + hash := sha256.New() + size, copyErr := io.Copy(hash, &contextInput{ctx: ctx, source: file}) + closeErr := file.Close() + if err := errors.Join(copyErr, closeErr); err != nil { + return images.Digest{}, 0, fmt.Errorf("hash generated EROFS: %w", err) + } + digest, err := images.ParseDigest(fmt.Sprintf("sha256:%x", hash.Sum(nil))) + return digest, size, err +} diff --git a/images/oci/erofs_test.go b/images/oci/erofs_test.go new file mode 100644 index 0000000..4b6b929 --- /dev/null +++ b/images/oci/erofs_test.go @@ -0,0 +1,77 @@ +package oci + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +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") + } +} diff --git a/images/oci/layout.go b/images/oci/layout.go new file mode 100644 index 0000000..21e130c --- /dev/null +++ b/images/oci/layout.go @@ -0,0 +1,159 @@ +package oci + +import ( + "bytes" + "context" + "encoding/json" + "io/fs" + "os" + "path/filepath" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/kumabox/kumabox/images" +) + +func NewLayout(path string) (images.Source, error) { + return NewLayoutWithLimits(path, DefaultLimits()) +} + +func NewLayoutWithLimits(path string, limits 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 images.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 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 +} + +func imageForPlatform(index v1.ImageIndex, platform images.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 +} + +func platformCandidates(index v1.ImageIndex, platform images.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 types.OCIImageIndex, types.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 types.OCIManifestSchema1, types.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 +} diff --git a/images/oci/local.go b/images/oci/local.go new file mode 100644 index 0000000..a28ec27 --- /dev/null +++ b/images/oci/local.go @@ -0,0 +1,186 @@ +package oci + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/kumabox/kumabox/images" +) + +// Local metadata is bounded before allocation; os.Root also contains concurrent path changes. +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("OCI metadata exceeds %d bytes", limit) + } + return raw, nil +} + +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("OCI blob 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 +} + +type localReader struct { + io.Reader + file *os.File + root *os.Root +} + +func (r *localReader) Close() error { return errors.Join(r.file.Close(), r.root.Close()) } + +func blobName(hash v1.Hash) (string, error) { + digest, err := images.ParseDigest(hash.String()) + if err != nil { + return "", invalidSource("invalid OCI blob digest: %v", err) + } + return "blobs/sha256/" + digest.Hex(), nil +} + +type localIndex struct { + path string + raw []byte + ctx context.Context +} + +func (i *localIndex) MediaType() (types.MediaType, error) { return types.OCIImageIndex, nil } +func (i *localIndex) Digest() (v1.Hash, error) { return partial.Digest(i) } +func (i *localIndex) Size() (int64, error) { return int64(len(i.raw)), nil } +func (i *localIndex) RawManifest() ([]byte, error) { return bytes.Clone(i.raw), nil } +func (i *localIndex) IndexManifest() (*v1.IndexManifest, error) { + return v1.ParseIndexManifest(bytes.NewReader(i.raw)) +} + +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) +} + +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}) +} + +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 +} + +type localImage struct { + path string + raw []byte + descriptor v1.Descriptor + ctx context.Context +} + +func (i *localImage) MediaType() (types.MediaType, error) { return i.descriptor.MediaType, nil } +func (i *localImage) RawManifest() ([]byte, error) { return bytes.Clone(i.raw), nil } +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) +} + +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 { + return &localLayer{path: i.path, descriptor: descriptor, ctx: i.ctx}, nil + } + } + return nil, fmt.Errorf("OCI layer %s not found", hash) +} + +type localLayer struct { + path string + descriptor v1.Descriptor + ctx context.Context +} + +func (l *localLayer) Digest() (v1.Hash, error) { return l.descriptor.Digest, nil } +func (l *localLayer) Size() (int64, error) { return l.descriptor.Size, nil } +func (l *localLayer) MediaType() (types.MediaType, error) { return l.descriptor.MediaType, nil } +func (l *localLayer) Compressed() (io.ReadCloser, error) { + name, err := blobName(l.descriptor.Digest) + if err != nil { + return nil, err + } + return openLocal(l.ctx, l.path, name) +} diff --git a/images/oci/registry.go b/images/oci/registry.go new file mode 100644 index 0000000..912b680 --- /dev/null +++ b/images/oci/registry.go @@ -0,0 +1,100 @@ +package oci + +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" +) + +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: DefaultLimits()} + source.resolve = func(ctx context.Context, platform images.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 +} + +type safeRegistryError struct { + cause error + message string +} + +func (e *safeRegistryError) Error() string { return e.message } +func (e *safeRegistryError) Unwrap() error { return e.cause } +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"}) +} + +// Wrap lazy operations too: registry I/O continues after remote.Image returns. +type registryImage struct{ v1.Image } + +func (i *registryImage) RawManifest() ([]byte, error) { + b, e := i.Image.RawManifest() + return b, registryError(e) +} + +func (i *registryImage) RawConfigFile() ([]byte, error) { + b, e := i.Image.RawConfigFile() + return b, registryError(e) +} + +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 +} + +type registryLayer struct{ v1.Layer } + +func (l *registryLayer) Compressed() (io.ReadCloser, error) { + reader, err := l.Layer.Compressed() + if err != nil { + return nil, registryError(err) + } + return ®istryReader{ReadCloser: reader}, nil +} + +type registryReader struct{ io.ReadCloser } + +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) +} +func (r *registryReader) Close() error { return registryError(r.ReadCloser.Close()) } diff --git a/images/oci/source.go b/images/oci/source.go new file mode 100644 index 0000000..deede4b --- /dev/null +++ b/images/oci/source.go @@ -0,0 +1,271 @@ +package oci + +import ( + "bufio" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "errors" + "fmt" + "hash" + "io" + "sync" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/klauspost/compress/zstd" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" +) + +const maxMetadataSize = 16 << 20 + +type Limits = images.Limits + +func DefaultLimits() Limits { return images.DefaultLimits() } + +type resolvedLayer struct { + layer v1.Layer + diffID images.Digest + mediaType types.MediaType +} + +type resolvedSource struct { + resolve func(context.Context, images.Platform) (v1.Image, error) + mu sync.RWMutex + layers map[images.Digest]resolvedLayer + limits Limits +} + +func (s *resolvedSource) Resolve(ctx context.Context, platform images.Platform) (images.Manifest, error) { + if err := ctx.Err(); err != nil { + return images.Manifest{}, err + } + image, err := s.resolve(ctx, platform) + if err != nil { + return images.Manifest{}, sourceError(err) + } + raw, err := image.RawManifest() + if err != nil { + return images.Manifest{}, sourceError(err) + } + if len(raw) > maxMetadataSize { + return images.Manifest{}, invalidSource("manifest exceeds metadata limit") + } + manifest, err := v1.ParseManifest(bytes.NewReader(raw)) + if err != nil { + return images.Manifest{}, invalidSource("invalid OCI manifest: %v", err) + } + manifestHash, err := image.Digest() + if err != nil { + return images.Manifest{}, sourceError(err) + } + if err := checkBytes(raw, manifestHash, int64(len(raw))); err != nil { + return images.Manifest{}, err + } + digest, err := images.ParseDigest(manifestHash.String()) + if err != nil { + return images.Manifest{}, invalidSource("invalid manifest digest: %v", err) + } + if manifest.SchemaVersion != 2 { + return images.Manifest{}, invalidSource("unsupported OCI manifest schema version") + } + if manifest.Config.MediaType != types.OCIConfigJSON && manifest.Config.MediaType != types.DockerConfigJSON { + return images.Manifest{}, invalidSource("unsupported OCI config media type") + } + if err := validateDescriptor(manifest.Config, maxMetadataSize); err != nil { + return images.Manifest{}, err + } + configRaw, err := image.RawConfigFile() + if err != nil { + return images.Manifest{}, sourceError(err) + } + if err := checkBytes(configRaw, manifest.Config.Digest, manifest.Config.Size); err != nil { + return images.Manifest{}, err + } + config, err := v1.ParseConfigFile(bytes.NewReader(configRaw)) + if err != nil { + return images.Manifest{}, invalidSource("invalid OCI config: %v", err) + } + if config.OS != platform.OS || config.Architecture != platform.Architecture { + return images.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 images.Manifest{}, invalidSource("config rootfs does not match manifest layers") + } + layers := make(map[images.Digest]resolvedLayer) + descriptors := make([]images.Descriptor, len(manifest.Layers)) + for position, desc := range manifest.Layers { + if err := validateDescriptor(desc, s.limits.LayerSize); err != nil { + return images.Manifest{}, err + } + switch desc.MediaType { + case types.OCILayer, types.OCIUncompressedLayer, types.OCILayerZStd, types.DockerLayer, types.DockerUncompressedLayer: + default: + return images.Manifest{}, invalidSource("unsupported layer media type %s", desc.MediaType) + } + digest, err := images.ParseDigest(desc.Digest.String()) + if err != nil { + return images.Manifest{}, invalidSource("invalid layer digest: %v", err) + } + diffID, err := images.ParseDigest(config.RootFS.DiffIDs[position].String()) + if err != nil { + return images.Manifest{}, invalidSource("invalid layer diffID: %v", err) + } + layer, err := image.LayerByDigest(desc.Digest) + if err != nil { + return images.Manifest{}, sourceError(err) + } + if existing, ok := layers[digest]; ok && existing.diffID != diffID { + return images.Manifest{}, invalidSource("repeated layer has inconsistent diffID") + } + layers[digest] = resolvedLayer{layer: layer, diffID: diffID, mediaType: desc.MediaType} + descriptors[position] = images.Descriptor{Digest: digest, Size: desc.Size} + } + s.mu.Lock() + s.layers = layers + s.mu.Unlock() + return images.Manifest{Digest: digest, Platform: platform, Layers: descriptors}, nil +} + +func (s *resolvedSource) OpenLayer(ctx context.Context, descriptor images.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 types.OCILayer, types.DockerLayer: + decoder, err := gzip.NewReader(buffered) + if err != nil { + return nil, errors.Join(sourceError(err), raw.Close()) + } + input, closeDecoder = decoder, decoder.Close + case types.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 +} + +type checkedReader struct { + ctx context.Context + reader io.Reader + hash hash.Hash + expected images.Digest + limit int64 + size int64 + read int64 + lastErr error +} + +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 +} + +type layerReader struct { + unpacked *checkedReader + buffered *bufio.Reader + raw io.ReadCloser + closeDecoder func() error +} + +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 +} +func (r *layerReader) Close() error { return errors.Join(r.closeDecoder(), r.raw.Close()) } + +func validateDescriptor(desc v1.Descriptor, limit int64) error { + if _, err := images.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 +} + +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 +} + +func invalidSource(format string, args ...any) error { + return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf(format, args...)) +} + +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/oci/source_test.go b/images/oci/source_test.go new file mode 100644 index 0000000..979934e --- /dev/null +++ b/images/oci/source_test.go @@ -0,0 +1,299 @@ +package oci + +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" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/klauspost/compress/zstd" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" +) + +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 := images.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 := DefaultLimits() + limits.UnpackedSize = 64 + source, err := NewLayoutWithLimits(copyFixture(t), limits) + if err != nil { + t.Fatal(err) + } + manifest, err := source.Resolve(t.Context(), images.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 types.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 types.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))}}}}), types.OCIConfigJSON) + manifest := put(encode(v1.Manifest{SchemaVersion: 2, MediaType: types.OCIManifestSchema1, Config: config, Layers: []v1.Descriptor{layer}}), types.OCIManifestSchema1) + if err := os.WriteFile(filepath.Join(root, "index.json"), encode(v1.IndexManifest{SchemaVersion: 2, MediaType: types.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 []types.MediaType{types.OCIUncompressedLayer, types.OCILayer, types.OCILayerZStd} { + t.Run(string(media), func(t *testing.T) { + compressed := unpacked + switch media { + case types.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 types.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(), images.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) + } + 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, images.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(), images.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/paths.go b/images/paths.go new file mode 100644 index 0000000..68b00ad --- /dev/null +++ b/images/paths.go @@ -0,0 +1,76 @@ +package images + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kumabox/kumabox/errdefs" + + "github.com/kumabox/kumabox/storage" +) + +type Paths struct { + roots storage.Roots +} + +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 +} + +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 +} + +func (p Paths) LayersDir() string { return filepath.Join(p.roots.Data, "images", "layers", "sha256") } + +func (p Paths) BootBaseDir() string { return filepath.Join(p.roots.Data, "images", "boot", "sha256") } +func (p Paths) StagingDir() string { return filepath.Join(p.roots.Data, "staging", "imports") } +func (p Paths) LocksDir() string { return filepath.Join(p.roots.Run, "locks", "images") } +func (p Paths) MetadataDB() string { return filepath.Join(p.roots.Data, "meta", "meta.db") } + +func (p Paths) EROFS(digest Digest) string { + return filepath.Join(p.LayersDir(), digest.Hex()+".erofs") +} + +func (p Paths) BootDir(digest Digest) string { + return filepath.Join(p.BootBaseDir(), digest.Hex()) +} + +func (p Paths) BootFile(digest Digest, name string) (string, error) { + if !IsBootName(name) { + return "", fmt.Errorf("invalid boot artifact name %q", name) + } + return storage.Join(p.BootDir(digest), name) +} + +func IsBootName(name string) bool { + return filepath.Base(name) == name && !strings.HasSuffix(name, ".old") && + (strings.HasPrefix(name, "vmlinuz") || strings.HasPrefix(name, "initrd.img")) +} + +func (p Paths) Kernel(digest Digest) string { return filepath.Join(p.BootDir(digest), "vmlinuz") } +func (p Paths) Initrd(digest Digest) string { return filepath.Join(p.BootDir(digest), "initrd.img") } + +func (p Paths) Lock(digest Digest) string { return filepath.Join(p.LocksDir(), digest.Hex()+".lock") } + +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 +} diff --git a/images/remove.go b/images/remove.go new file mode 100644 index 0000000..025c69c --- /dev/null +++ b/images/remove.go @@ -0,0 +1,46 @@ +package images + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/storage" + + filelock "github.com/kumabox/kumabox/lock/flock" +) + +func Remove(ctx context.Context, paths Paths, catalog Catalog, 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.go b/images/source.go new file mode 100644 index 0000000..e4b6ec6 --- /dev/null +++ b/images/source.go @@ -0,0 +1,56 @@ +package images + +import ( + "context" + "io" +) + +type Source interface { + Resolve(context.Context, Platform) (Manifest, error) + OpenLayer(context.Context, Descriptor) (io.ReadCloser, error) +} + +type Converter interface { + Convert(context.Context, Descriptor, io.Reader, string) (ConvertedLayer, error) +} + +type ConvertedLayer struct { + SourceDigest Digest + EROFSPath string + EROFSDigest Digest + Size int64 + BootFiles []StagedBootFile + Whiteouts []string + BootOpaque bool +} + +type StagedBootFile struct { + Name string + Path string +} + +type Reporter interface { + Layer(int, int, Digest) error + Committed(Image) error +} + +type DiscardReporter struct{} + +func (DiscardReporter) Layer(int, int, Digest) error { return nil } +func (DiscardReporter) Committed(Image) error { return nil } + +// Limits bound compressed input and decompressed source and boot artifacts. +type Limits struct { + LayerSize int64 + UnpackedSize int64 + BootSize int64 + ArchiveSize int64 +} + +func DefaultLimits() Limits { + return Limits{LayerSize: 8 << 30, UnpackedSize: 16 << 30, BootSize: 512 << 20, ArchiveSize: 32 << 30} +} + +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 +} diff --git a/images/verify.go b/images/verify.go new file mode 100644 index 0000000..04cb74c --- /dev/null +++ b/images/verify.go @@ -0,0 +1,82 @@ +package images + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/kumabox/kumabox/errdefs" + filelock "github.com/kumabox/kumabox/lock/flock" +) + +func Verify(ctx context.Context, paths Paths, catalog CatalogReader, reference string) (result Image, returnErr error) { + image, err := catalog.Resolve(ctx, reference) + if err != nil { + return Image{}, err + } + lockPaths := make([]string, len(image.Layers)) + for pos, layer := range image.Layers { + lockPaths[pos] = paths.Lock(layer.SourceDigest) + } + var locks filelock.Set + if err := locks.Lock(ctx, lockPaths...); err != nil { + return Image{}, err + } + defer func() { returnErr = errors.Join(returnErr, locks.Unlock(context.WithoutCancel(ctx))) }() + image, err = catalog.Resolve(ctx, reference) + if err != nil { + return Image{}, err + } + var total int64 + for _, layer := range image.Layers { + if err := verifyLayer(ctx, paths, layer); err != nil { + return Image{}, errdefs.Context(err, "verify image", reference, "artifacts", "re-import the image", false) + } + total += layer.Size + } + boot, err := selectBoot(image.Layers) + if err != nil || boot != image.Boot || total != image.Size { + return Image{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("image layer mapping or boot selection is inconsistent")) + } + return image, nil +} + +func verifyLayer(ctx context.Context, paths Paths, layer 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 +} + +func verifyFile(ctx context.Context, path string, expected 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 +} + +func sameLayer(a, 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/lock/flock/lock.go b/lock/flock/lock.go new file mode 100644 index 0000000..6f881a7 --- /dev/null +++ b/lock/flock/lock.go @@ -0,0 +1,128 @@ +package flock + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "time" + + goflock "github.com/gofrs/flock" +) + +const retryInterval = 2 * time.Millisecond + +// Lock combines in-process serialization with an advisory cross-process lock. +type Lock struct { + path string + token chan struct{} + held *goflock.Flock + transient bool +} + +func New(path string) *Lock { + return &Lock{path: path, token: make(chan struct{}, 1)} +} + +func NewTransient(path string) *Lock { + return &Lock{path: path, token: make(chan struct{}, 1), transient: true} +} + +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 + } +} + +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 +} + +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 +} + +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..b2cdcf9 --- /dev/null +++ b/lock/flock/set.go @@ -0,0 +1,33 @@ +package flock + +import ( + "context" + "errors" + "slices" +) + +// Set owns an ordered group of persistent file locks. +type Set struct { + held []*Lock +} + +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 +} + +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/main.go b/main.go index 4383e70..20e5e1e 100644 --- a/main.go +++ b/main.go @@ -9,14 +9,18 @@ import ( "context" "fmt" "os" + "os/signal" + "syscall" "github.com/kumabox/kumabox/cmd" ) func main() { - err := cmd.Execute(context.Background(), os.Args[1:], os.Stdout, os.Stderr) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + err := cmd.Execute(ctx, os.Args[1:], os.Stdout, os.Stderr) if err != nil && !cmd.Silent(err) { fmt.Fprintf(os.Stderr, "kumabox: %v\n", err) } + stop() os.Exit(cmd.ExitCode(err)) } diff --git a/metadata/collection.go b/metadata/collection.go new file mode 100644 index 0000000..777bd91 --- /dev/null +++ b/metadata/collection.go @@ -0,0 +1,19 @@ +package metadata + +import "fmt" + +// Collection identifies one fixed module-owned record set. +type Collection string + +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 +} +func (c Collection) String() string { return string(c) } diff --git a/metadata/memory.go b/metadata/memory.go new file mode 100644 index 0000000..42cb2b2 --- /dev/null +++ b/metadata/memory.go @@ -0,0 +1,151 @@ +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 sync.RWMutex + writeToken chan struct{} + records map[Collection]map[string][]byte + closed bool +} + +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 +} + +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 +} + +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() +} + +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 +} +func (s *Memory) Close() error { s.mu.Lock(); defer s.mu.Unlock(); s.closed = true; return nil } + +type memoryTransaction struct { + records map[Collection]map[string][]byte +} + +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 +} + +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 +} + +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 +} + +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 +} + +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/memory_test.go b/metadata/memory_test.go new file mode 100644 index 0000000..04056b7 --- /dev/null +++ b/metadata/memory_test.go @@ -0,0 +1,23 @@ +package metadata_test + +import ( + "testing" + + "github.com/kumabox/kumabox/metadata" + "github.com/kumabox/kumabox/metadata/metadatatest" +) + +func TestMemoryContract(t *testing.T) { + metadatatest.Run(t, func(t *testing.T, collections []metadata.Collection) metadata.Store { + store, err := metadata.NewMemory(collections) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Error(err) + } + }) + return store + }) +} diff --git a/metadata/metadatatest/contract.go b/metadata/metadatatest/contract.go new file mode 100644 index 0000000..195c12b --- /dev/null +++ b/metadata/metadatatest/contract.go @@ -0,0 +1,138 @@ +// Package metadatatest checks the transaction contract shared by metadata engines. +package metadatatest + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + + "github.com/kumabox/kumabox/metadata" +) + +type Factory func(*testing.T, []metadata.Collection) metadata.Store + +func Run(t *testing.T, open Factory) { + 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) + } + }) +} diff --git a/metadata/sqlite/store.go b/metadata/sqlite/store.go new file mode 100644 index 0000000..7b88f03 --- /dev/null +++ b/metadata/sqlite/store.go @@ -0,0 +1,280 @@ +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 = 0x4B554D41 + schemaVersion = 1 + initLockName = "init.lock" +) + +type Options struct { + BusyTimeout time.Duration + RetryLimit time.Duration +} + +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 *sql.DB + writer *sql.DB + collections map[metadata.Collection]struct{} + retryLimit time.Duration +} + +var _ metadata.Store = (*Store)(nil) + +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 +} + +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 mapError(tx.Commit()) +} + +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 mapError(tx.Commit()) + } + 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): + } + } +} + +func (s *Store) Close() error { return errors.Join(s.readers.Close(), s.writer.Close()) } + +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 +} + +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 || version != schemaVersion { + return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("populated database has identity %#x/version %d", appID, version)) + } + return nil + } + 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 mapError(tx.Commit()) +} + +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() +} + +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 + } +} + +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 +} + +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..4766b6b --- /dev/null +++ b/metadata/sqlite/store_test.go @@ -0,0 +1,196 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/metadata" + "github.com/kumabox/kumabox/metadata/metadatatest" +) + +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 TestStoreContract(t *testing.T) { + metadatatest.Run(t, func(t *testing.T, collections []metadata.Collection) metadata.Store { + store, err := Open(t.Context(), filepath.Join(t.TempDir(), "meta.db"), collections, DefaultOptions()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Error(err) + } + }) + return store + }) +} + +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 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") + } + }) + } +} diff --git a/metadata/sqlite/transaction.go b/metadata/sqlite/transaction.go new file mode 100644 index 0000000..e51dd9a --- /dev/null +++ b/metadata/sqlite/transaction.go @@ -0,0 +1,82 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/kumabox/kumabox/metadata" +) + +type transaction struct { + tx *sql.Tx + allowed map[metadata.Collection]struct{} + writable bool +} + +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 +} + +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()) +} + +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) +} + +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) +} + +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..04bb7f6 --- /dev/null +++ b/metadata/store.go @@ -0,0 +1,23 @@ +package metadata + +import "context" + +// Store is the engine-neutral metadata transaction boundary. +type Store interface { + View(context.Context, func(Reader) error) error + Update(context.Context, func(Writer) error) error + Close() error +} + +// Reader reads detached records from a consistent snapshot. +type Reader interface { + Get(context.Context, Collection, string) ([]byte, bool, error) + Scan(context.Context, Collection, func(string, []byte) error) error +} + +// Writer mutates records in one atomic transaction. +type Writer interface { + Reader + Put(context.Context, Collection, string, []byte) error + Delete(context.Context, Collection, string) error +} diff --git a/storage/publish.go b/storage/publish.go new file mode 100644 index 0000000..1e8a9d7 --- /dev/null +++ b/storage/publish.go @@ -0,0 +1,51 @@ +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. +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))) +} + +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..10650c9 --- /dev/null +++ b/storage/roots.go @@ -0,0 +1,159 @@ +package storage + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + DefaultDataRoot = "/var/lib/kumabox" + DefaultRunRoot = "/run/kumabox" + DefaultLogRoot = "/var/log/kumabox" +) + +// Roots are the three host roots shared by KumaBox modules. +type Roots struct { + Data string + Run string + Log string +} + +func DefaultRoots() Roots { + return Roots{Data: DefaultDataRoot, Run: DefaultRunRoot, Log: DefaultLogRoot} +} + +// Validate normalizes roots and rejects ambiguous ownership boundaries. +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 refuses symlinks in every existing component, including the file. +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 contained child 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 +} + +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/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 0000000000000000000000000000000000000000..b496e2b235086527e239a2c74e03f8eec9e2d8d8 GIT binary patch literal 152 zcmb2|=3oE;CgHc2ZTSuv@Hix%pQEJS;NWe+-^!tME5s{C#llvT$K%bPmG7f-Qcpg* z{$;P;xmf8(1yjv8wyu8tTPJS$R_*V1j(=aecGXr1ZFz%~yURZ9^$FhRVq3*)z1VKG zNA?{40|^^5->SG`~lwjDwoxW9uj(Bn%Ng9ZZw00_uH AV*mgE literal 0 HcmV?d00001 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 From 44c22ed2eb4a3cf0b37db3648c361654382edea2 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Mon, 14 Sep 2026 14:37:34 +0800 Subject: [PATCH 15/45] feat: support Docker imports and modularize image management Move dependency assembly to core and organize CLI commands and image adapters by responsibility. Keep metadata contract tests in their owning package and preserve SQLite cancellation errors after automatic rollback. Validated with make verify, Linux/macOS make lint, and 30 repeated cancellation regression runs. --- .golangci.yml | 61 +++- Makefile | 6 +- README.md | 65 +++- .../doctor/command.go | 0 .../doctor/command_test.go | 0 cli/image/command.go | 38 ++ cli/image/command_test.go | 243 +++++++++++++ cli/image/import.go | 93 +++++ {cmd => cli}/image/output.go | 19 + cli/image/query.go | 90 +++++ cli/image/remove.go | 39 +++ {cmd => cli}/root.go | 8 +- {cmd => cli}/root_test.go | 2 +- cmd/image/root.go | 279 --------------- cmd/image/root_test.go | 112 ------ main.go => cmd/kumabox/main.go | 8 +- core/images.go | 76 ++++ errdefs/code.go | 30 -- errdefs/error.go | 29 ++ images/{paths.go => artifacts.go} | 52 ++- images/boot.go | 63 ++++ images/catalog.go | 51 ++- .../{metadata_catalog.go => catalog/store.go} | 160 ++++----- images/{oci/erofs.go => erofs/convert.go} | 59 +++- .../erofs_test.go => erofs/convert_test.go} | 11 +- images/files.go | 55 --- images/image.go | 17 +- images/import.go | 132 +++---- images/import_test.go | 112 +++--- images/oci/local.go | 186 ---------- images/remove.go | 7 +- images/source.go | 56 --- images/{oci => source}/archive.go | 54 +-- images/{oci => source}/archive_test.go | 36 +- images/source/docker.go | 211 +++++++++++ images/source/docker_test.go | 329 ++++++++++++++++++ images/source/local.go | 183 ++++++++++ images/source/local_test.go | 203 +++++++++++ images/{oci/layout.go => source/oci.go} | 122 ++++++- images/{oci => source}/registry.go | 4 +- images/{oci => source}/source.go | 10 +- images/{oci => source}/source_test.go | 4 +- images/verify.go | 13 +- metadata/collection.go | 19 - metadata/memory_test.go | 23 -- metadata/sqlite/store.go | 21 +- metadata/sqlite/store_test.go | 65 +++- metadata/store.go | 21 +- .../contract.go => store_test.go} | 44 ++- doctor/check.sh => scripts/kumabox-check.sh | 8 +- 50 files changed, 2417 insertions(+), 1112 deletions(-) rename cmd/doctor/doctor_commands.go => cli/doctor/command.go (100%) rename cmd/doctor/doctor_test.go => cli/doctor/command_test.go (100%) create mode 100644 cli/image/command.go create mode 100644 cli/image/command_test.go create mode 100644 cli/image/import.go rename {cmd => cli}/image/output.go (82%) create mode 100644 cli/image/query.go create mode 100644 cli/image/remove.go rename {cmd => cli}/root.go (95%) rename {cmd => cli}/root_test.go (99%) delete mode 100644 cmd/image/root.go delete mode 100644 cmd/image/root_test.go rename main.go => cmd/kumabox/main.go (74%) create mode 100644 core/images.go delete mode 100644 errdefs/code.go rename images/{paths.go => artifacts.go} (60%) create mode 100644 images/boot.go rename images/{metadata_catalog.go => catalog/store.go} (69%) rename images/{oci/erofs.go => erofs/convert.go} (80%) rename images/{oci/erofs_test.go => erofs/convert_test.go} (89%) delete mode 100644 images/files.go delete mode 100644 images/oci/local.go delete mode 100644 images/source.go rename images/{oci => source}/archive.go (72%) rename images/{oci => source}/archive_test.go (72%) create mode 100644 images/source/docker.go create mode 100644 images/source/docker_test.go create mode 100644 images/source/local.go create mode 100644 images/source/local_test.go rename images/{oci/layout.go => source/oci.go} (56%) rename images/{oci => source}/registry.go (97%) rename images/{oci => source}/source.go (98%) rename images/{oci => source}/source_test.go (99%) delete mode 100644 metadata/collection.go delete mode 100644 metadata/memory_test.go rename metadata/{metadatatest/contract.go => store_test.go} (78%) rename doctor/check.sh => scripts/kumabox-check.sh (98%) diff --git a/.golangci.yml b/.golangci.yml index 89adb98..50e1c4b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -28,29 +28,74 @@ linters: files: - "$all" - "!$test" + - "!**/cli/**" - "!**/cmd/**" - "!**/main.go" deny: - pkg: github.com/kumabox/kumabox/cmd - desc: library packages must not import command packages + 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 + 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" - - "!**/cmd/image/**" + - "!**/core/**" - "!**/metadata/sqlite/**" deny: - pkg: github.com/kumabox/kumabox/metadata/sqlite - desc: only cmd/image may assemble the SQLite adapter - oci-is-an-edge-adapter: + 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" - - "!**/cmd/image/**" - - "!**/images/oci/**" + - "!**/core/**" + - "!**/images/erofs/**" deny: - - pkg: github.com/kumabox/kumabox/images/oci - desc: only cmd/image may assemble the OCI adapter + - pkg: github.com/kumabox/kumabox/images/erofs + desc: only core may assemble the EROFS converter sql-only-in-sqlite: files: - "$all" diff --git a/Makefile b/Makefile index f0137d7..02dfcf2 100644 --- a/Makefile +++ b/Makefile @@ -61,8 +61,8 @@ deps: ## Tidy Go modules # --- Build --- build: | $(LOCALBIN) ## Build kumabox and kumabox-check - CGO_ENABLED=0 go build -ldflags "$(GO_LDFLAGS)" -o $(LOCALBIN)/kumabox . - cp doctor/check.sh $(LOCALBIN)/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 install: build ## Install kumabox and kumabox-check @@ -76,7 +76,7 @@ test: vet ## Run tests with race detection and coverage go test -race -timeout 120s -count=1 -cover -coverprofile=coverage.out ./... doctor-check: ## Check the doctor script syntax - bash -n doctor/check.sh + bash -n scripts/kumabox-check.sh race: ## Run all Go tests with race detection go test -race ./... diff --git a/README.md b/README.md index 3b945ce..1104946 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ A microVM sandbox runtime for AI agents. One node runs one daemon Docker-like command line; sandboxes are Cloud Hypervisor microVMs booted from OCI images, with CNI networking, cgroups, snapshots and clone. -The rewrite currently provides the `kumabox` CLI, the host doctor, and OCI -image management: registry pull, layout/archive import, list, inspect, verify, +The rewrite currently provides the `kumabox` CLI, the host doctor, and container +image management: registry pull, Docker/OCI import, list, inspect, verify, and remove. Each command opens its metadata store and exits. VM lifecycle and a daemon are later phases of [docs/ROADMAP.md](docs/ROADMAP.md). @@ -42,7 +42,42 @@ make race # race detector, required for concurrency changes behaviour (Cloud Hypervisor, CNI, KVM) is verified manually on a Linux host using the runbook attached to each phase. -## OCI images +Code is organized as importable modules by responsibility, without `internal` +or a generic `pkg` container: + +| Package | Responsibility | +|---|---| +| `cmd/kumabox` | Process entry point, signals and exit status | +| `cli`, `cli/image`, `cli/doctor` | Command trees, argument parsing and presentation | +| `core` | Assemble concrete adapters and own their resources | +| `images` | Managed image model, import, verification and removal rules | +| `images/catalog` | Persist image identities, name bindings and layer references | +| `images/source` | Read Docker archives, OCI layouts/archives and registries | +| `images/erofs` | Convert source layers and extract boot candidates | +| `metadata`, `metadata/sqlite` | Engine-neutral transactions and the SQLite implementation | +| `storage`, `lock/flock` | Managed filesystem operations and file locks | +| `errdefs`, `version` | Error classification and build information | + +`core` connects modules through constructors; it is not a second implementation +of their business operations. CLI handlers use the assembled modules. Only +`core` selects concrete image and metadata adapters. The image core does not +import adapters or metadata engines, and modules do not import `core` or `cli`. +These dependency directions are enforced by depguard in `.golangci.yml`. + +The image command groups complete responsibilities into `import.go` (pull and +local import), `query.go` (list, inspect and verify), and `remove.go`. Related +types, interfaces and methods stay together; files are not split by declaration +kind. Interfaces describe the operations needed by their consumers. + +Tests live in their owning directories as `*_test.go`. The shared memory/SQLite +transaction contract is exercised in `metadata/store_test.go`; there is no +production package for test helpers. Image workflow integration tests use the +public module APIs and cover the assembled catalog with both metadata engines. + +Build with `make build`, or run the entry point with `go run ./cmd/kumabox`. +The host checker source is `scripts/kumabox-check.sh`. + +## Container images Pull or import a Linux image containing regular `/boot/vmlinuz*` and `/boot/initrd.img*` files. Image conversion requires `mkfs.erofs` 1.8 or newer; @@ -52,12 +87,36 @@ The [synthetic fixture](testdata/oci-layout/README.md) cannot boot a VM. ```bash kumabox image pull REGISTRY/IMAGE:TAG --platform linux/amd64 kumabox image import tiny ./testdata/oci-layout --platform linux/amd64 +kumabox image import demo ./docker-save.tar --format docker --platform linux/amd64 kumabox image ls --json kumabox image inspect tiny kumabox image verify tiny kumabox image rm tiny ``` +`image import NAME PATH` detects the format from source contents by default. +It accepts OCI layout directories, OCI archives, and `docker save` archives; +archives can be plain tar or gzip, regardless of their filename extension. +Use `--format oci` or `--format docker` to select a format explicitly. +For Docker archives containing multiple images for the target platform, use +`--source-tag REPOSITORY:TAG` to select the source image; `NAME` is its local +KumaBox name. Archives containing both OCI and Docker metadata use OCI by +default; pass `--format docker --source-tag REPOSITORY:TAG` for Docker tag +selection. `docker export` filesystem archives are not supported. + +To import an image already present in Docker: + +```bash +docker save -o demo.tar your-image:tag +kumabox image import demo ./demo.tar --platform linux/amd64 +kumabox image verify demo +``` + +Docker archives are normalized to a deterministic OCI manifest. Its digest +identifies the imported config and ordered layers and may differ from the +original registry manifest digest. Repacking or changing source tags preserves +the imported digest. Docker images must meet the same kernel/initrd requirements. + For a separate data store, pass all three roots: ```bash diff --git a/cmd/doctor/doctor_commands.go b/cli/doctor/command.go similarity index 100% rename from cmd/doctor/doctor_commands.go rename to cli/doctor/command.go diff --git a/cmd/doctor/doctor_test.go b/cli/doctor/command_test.go similarity index 100% rename from cmd/doctor/doctor_test.go rename to cli/doctor/command_test.go diff --git a/cli/image/command.go b/cli/image/command.go new file mode 100644 index 0000000..80b644b --- /dev/null +++ b/cli/image/command.go @@ -0,0 +1,38 @@ +package image + +import ( + "fmt" + "runtime" + "strings" + + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/storage" +) + +type rootsProvider func() storage.Roots + +func NewCommand(roots rootsProvider) *cobra.Command { + command := &cobra.Command{Use: "image", Short: "manage container images", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { return command.Help() }} + command.AddCommand( + newPullCommand(roots), + newImportCommand(roots), + newListCommand(roots), + newInspectCommand(roots), + newVerifyCommand(roots), + newRemoveCommand(roots), + ) + return command +} + +func parsePlatform(value string) (images.Platform, error) { + parts := strings.Split(value, "/") + if len(parts) != 2 || parts[0] != "linux" || (parts[1] != "amd64" && parts[1] != "arm64") { + return images.Platform{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("unsupported platform %q", value)) + } + return images.Platform{OS: parts[0], Architecture: parts[1]}, nil +} + +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..5621e95 --- /dev/null +++ b/cli/image/command_test.go @@ -0,0 +1,243 @@ +package image + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "os" + "path/filepath" + "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/errdefs" + "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/storage" +) + +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() storage.Roots { return 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 +} + +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 _, 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) + } + 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.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() storage.Roots { return 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..8204a89 --- /dev/null +++ b/cli/image/import.go @@ -0,0 +1,93 @@ +package image + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/core" +) + +func newPullCommand(roots rootsProvider) *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 + } + state, err := core.OpenImages(command.Context(), roots()) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, state.Close()) }() + importer, err := core.NewImageImporter(command.Context(), state, textReporter{writer: command.ErrOrStderr()}, parsedPlatform) + if err != nil { + return err + } + image, err := importer.Import(command.Context(), name, parsedPlatform, input) + if err != nil { + return err + } + return writeImage(command.OutOrStdout(), image) + }, + } + command.Flags().StringVar(&platform, "platform", platform, "target platform (linux/amd64 or linux/arm64)") + return command +} + +func newImportCommand(roots rootsProvider) *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 + } + state, err := core.OpenImages(command.Context(), roots()) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, state.Close()) }() + 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, textReporter{writer: command.ErrOrStderr()}, parsedPlatform) + if err != nil { + return err + } + image, err := importer.Import(command.Context(), args[0], parsedPlatform, input) + if err != nil { + return err + } + return writeImage(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/cmd/image/output.go b/cli/image/output.go similarity index 82% rename from cmd/image/output.go rename to cli/image/output.go index aed59c8..a993300 100644 --- a/cmd/image/output.go +++ b/cli/image/output.go @@ -1,6 +1,9 @@ package image import ( + "fmt" + "io" + "strings" "time" "github.com/kumabox/kumabox/images" @@ -55,3 +58,19 @@ func imageResult(image images.Image) imageOutput { } return imageOutput{Names: image.Names, ManifestDigest: image.ManifestDigest.String(), Platform: platformOutput{OS: image.Platform.OS, Architecture: image.Platform.Architecture}, Layers: layers, Boot: bootOutput{KernelLayer: image.Boot.KernelLayer.String(), KernelFile: image.Boot.KernelFile, InitrdLayer: image.Boot.InitrdLayer.String(), InitrdFile: image.Boot.InitrdFile}, Size: image.Size, CreatedAt: image.CreatedAt} } + +func writeImage(writer io.Writer, image images.Image) error { + _, err := fmt.Fprintf(writer, "%s\t%s\n", strings.Join(image.Names, ","), image.ManifestDigest) + return err +} + +type textReporter struct { + writer io.Writer +} + +func (r textReporter) Layer(position, total int, digest images.Digest) error { + _, err := fmt.Fprintf(r.writer, "layer %d/%d %s\n", position+1, total, digest) + return err +} + +func (r textReporter) Committed(images.Image) error { return nil } diff --git a/cli/image/query.go b/cli/image/query.go new file mode 100644 index 0000000..a71a8e0 --- /dev/null +++ b/cli/image/query.go @@ -0,0 +1,90 @@ +package image + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/core" + "github.com/kumabox/kumabox/images" +) + +func newListCommand(roots rootsProvider) *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(), roots()) + 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 json.NewEncoder(command.OutOrStdout()).Encode(results) + } + for _, item := range items { + if _, err := fmt.Fprintf(command.OutOrStdout(), "%s\t%s\t%d\n", strings.Join(item.Names, ","), item.ManifestDigest, item.Size); err != nil { + return err + } + } + return nil + }, + } + command.Flags().BoolVar(&asJSON, "json", false, "write JSON") + return command +} + +func newInspectCommand(roots rootsProvider) *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(), roots()) + 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 json.NewEncoder(command.OutOrStdout()).Encode(imageResult(image)) + }, + } +} + +func newVerifyCommand(roots rootsProvider) *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) { + state, err := core.OpenImages(command.Context(), roots()) + 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(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..1acb028 --- /dev/null +++ b/cli/image/remove.go @@ -0,0 +1,39 @@ +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" +) + +func newRemoveCommand(roots rootsProvider) *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) { + state, err := core.OpenImages(command.Context(), roots()) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, state.Close()) }() + for _, reference := range args { + removed, err := images.Remove(command.Context(), state.Paths, state.Catalog, reference) + if err != nil { + return err + } + if _, err := fmt.Fprintf(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/cmd/root.go b/cli/root.go similarity index 95% rename from cmd/root.go rename to cli/root.go index 27de560..fd3720c 100644 --- a/cmd/root.go +++ b/cli/root.go @@ -1,5 +1,5 @@ -// Package cmd builds the kumabox command tree. -package cmd +// Package cli builds the kumabox command tree. +package cli import ( "context" @@ -9,8 +9,8 @@ import ( "github.com/spf13/cobra" - doctorcmd "github.com/kumabox/kumabox/cmd/doctor" - imagecmd "github.com/kumabox/kumabox/cmd/image" + doctorcmd "github.com/kumabox/kumabox/cli/doctor" + imagecmd "github.com/kumabox/kumabox/cli/image" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/version" diff --git a/cmd/root_test.go b/cli/root_test.go similarity index 99% rename from cmd/root_test.go rename to cli/root_test.go index b008e35..2debe9e 100644 --- a/cmd/root_test.go +++ b/cli/root_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "bytes" diff --git a/cmd/image/root.go b/cmd/image/root.go deleted file mode 100644 index 9f234f9..0000000 --- a/cmd/image/root.go +++ /dev/null @@ -1,279 +0,0 @@ -package image - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "runtime" - "strings" - - "github.com/spf13/cobra" - - "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/images" - "github.com/kumabox/kumabox/images/oci" - "github.com/kumabox/kumabox/metadata" - metadatasqlite "github.com/kumabox/kumabox/metadata/sqlite" - "github.com/kumabox/kumabox/storage" -) - -type rootsProvider func() storage.Roots - -func NewCommand(roots rootsProvider) *cobra.Command { - command := &cobra.Command{Use: "image", Short: "manage OCI images", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { return command.Help() }} - command.AddCommand( - newPullCommand(roots), - newImportCommand(roots), - newListCommand(roots), - newInspectCommand(roots), - newVerifyCommand(roots), - newRemoveCommand(roots), - ) - return command -} - -type opened struct { - store metadata.Store - paths images.Paths - catalog *images.MetadataCatalog -} - -func openStore(ctx context.Context, roots storage.Roots) (*opened, error) { - paths, err := images.NewPaths(roots) - if err != nil { - return nil, err - } - if err := paths.Ensure(); err != nil { - return nil, err - } - store, err := metadatasqlite.Open(ctx, paths.MetadataDB(), images.Collections(), metadatasqlite.DefaultOptions()) - if err != nil { - return nil, err - } - return &opened{store: store, paths: paths, catalog: images.NewMetadataCatalog(store)}, nil -} - -func newImporter(ctx context.Context, state *opened, stderr io.Writer, platform images.Platform) (*images.Importer, error) { - options := images.DefaultOptions() - converter, err := oci.NewEROFSConverterWithLimits(ctx, platform.Architecture, options.Limits) - if err != nil { - return nil, err - } - return images.NewImporter(state.paths, state.catalog, converter, textReporter{writer: stderr}, options) -} - -func newPullCommand(roots rootsProvider) *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 - } - source, name, err := oci.NewRegistry(args[0]) - if err != nil { - return err - } - state, err := openStore(command.Context(), roots()) - if err != nil { - return err - } - defer func() { returnErr = errors.Join(returnErr, state.store.Close()) }() - importer, err := newImporter(command.Context(), state, command.ErrOrStderr(), parsedPlatform) - if err != nil { - return err - } - image, err := importer.Import(command.Context(), name, parsedPlatform, source) - if err != nil { - return err - } - return writeImage(command.OutOrStdout(), image) - }, - } - command.Flags().StringVar(&platform, "platform", platform, "target platform (linux/amd64 or linux/arm64)") - return command -} - -func newImportCommand(roots rootsProvider) *cobra.Command { - platform := defaultPlatform() - command := &cobra.Command{ - Use: "import NAME PATH", - Short: "import an OCI image layout or OCI archive", - Args: cobra.ExactArgs(2), - RunE: func(command *cobra.Command, args []string) (returnErr error) { - parsedPlatform, err := parsePlatform(platform) - if err != nil { - return err - } - state, err := openStore(command.Context(), roots()) - if err != nil { - return err - } - defer func() { returnErr = errors.Join(returnErr, state.store.Close()) }() - source, cleanup, err := localSource(command.Context(), args[1], state.paths) - if err != nil { - return err - } - defer func() { returnErr = errors.Join(returnErr, cleanup()) }() - importer, err := newImporter(command.Context(), state, command.ErrOrStderr(), parsedPlatform) - if err != nil { - return err - } - image, err := importer.Import(command.Context(), args[0], parsedPlatform, source) - if err != nil { - return err - } - return writeImage(command.OutOrStdout(), image) - }, - } - command.Flags().StringVar(&platform, "platform", platform, "target platform (linux/amd64 or linux/arm64)") - return command -} - -func newListCommand(roots rootsProvider) *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 := openStore(command.Context(), roots()) - if err != nil { - return err - } - defer func() { returnErr = errors.Join(returnErr, state.store.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 json.NewEncoder(command.OutOrStdout()).Encode(results) - } - for _, item := range items { - if _, err := fmt.Fprintf(command.OutOrStdout(), "%s\t%s\t%d\n", strings.Join(item.Names, ","), item.ManifestDigest, item.Size); err != nil { - return err - } - } - return nil - }, - } - command.Flags().BoolVar(&asJSON, "json", false, "write JSON") - return command -} - -func newInspectCommand(roots rootsProvider) *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 := openStore(command.Context(), roots()) - if err != nil { - return err - } - defer func() { returnErr = errors.Join(returnErr, state.store.Close()) }() - image, err := state.catalog.Resolve(command.Context(), args[0]) - if err != nil { - return err - } - return json.NewEncoder(command.OutOrStdout()).Encode(imageResult(image)) - }, - } -} - -func newVerifyCommand(roots rootsProvider) *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) { - state, err := openStore(command.Context(), roots()) - if err != nil { - return err - } - defer func() { returnErr = errors.Join(returnErr, state.store.Close()) }() - image, err := images.Verify(command.Context(), state.paths, state.catalog, args[0]) - if err != nil { - return err - } - _, err = fmt.Fprintf(command.OutOrStdout(), "verified %s\n", image.ManifestDigest) - return err - }, - } -} - -func newRemoveCommand(roots rootsProvider) *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) { - state, err := openStore(command.Context(), roots()) - if err != nil { - return err - } - defer func() { returnErr = errors.Join(returnErr, state.store.Close()) }() - for _, reference := range args { - removed, err := images.Remove(command.Context(), state.paths, state.catalog, reference) - if err != nil { - return err - } - if _, err := fmt.Fprintf(command.OutOrStdout(), "removed %s\n", strings.Join(removed.Names, ",")); err != nil { - return errdefs.Context(err, "remove image", reference, "report", "image removed", true) - } - } - return nil - }, - } -} - -func localSource(ctx context.Context, path string, paths images.Paths) (images.Source, func() error, error) { - info, err := os.Stat(path) - if err != nil { - return nil, nil, fmt.Errorf("inspect OCI source: %w", err) - } - if info.IsDir() { - source, err := oci.NewLayout(path) - return source, func() error { return nil }, err - } - source, cleanup, err := oci.NewArchiveContext(ctx, path, paths.StagingDir(), oci.DefaultLimits()) - return source, cleanup, err -} - -func parsePlatform(value string) (images.Platform, error) { - parts := strings.Split(value, "/") - if len(parts) != 2 || parts[0] != "linux" || (parts[1] != "amd64" && parts[1] != "arm64") { - return images.Platform{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("unsupported platform %q", value)) - } - return images.Platform{OS: parts[0], Architecture: parts[1]}, nil -} - -func defaultPlatform() string { return "linux/" + runtime.GOARCH } - -func writeImage(writer io.Writer, image images.Image) error { - _, err := fmt.Fprintf(writer, "%s\t%s\n", strings.Join(image.Names, ","), image.ManifestDigest) - return err -} - -type textReporter struct { - writer io.Writer -} - -func (r textReporter) Layer(position, total int, digest images.Digest) error { - _, err := fmt.Fprintf(r.writer, "layer %d/%d %s\n", position+1, total, digest) - return err -} - -func (r textReporter) Committed(images.Image) error { return nil } diff --git a/cmd/image/root_test.go b/cmd/image/root_test.go deleted file mode 100644 index 16fbc77..0000000 --- a/cmd/image/root_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package image - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/kumabox/kumabox/images" - "github.com/kumabox/kumabox/storage" -) - -func TestImageCommandsFromLayoutAndArchive(t *testing.T) { - 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() storage.Roots { return roots }) - var out, stderr bytes.Buffer - command.SetOut(&out) - command.SetErr(&stderr) - command.SetArgs(args) - err := command.ExecuteContext(t.Context()) - return out.String(), err - } - 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 _, 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) - } -} diff --git a/main.go b/cmd/kumabox/main.go similarity index 74% rename from main.go rename to cmd/kumabox/main.go index 20e5e1e..b5aeb0d 100644 --- a/main.go +++ b/cmd/kumabox/main.go @@ -12,15 +12,15 @@ import ( "os/signal" "syscall" - "github.com/kumabox/kumabox/cmd" + "github.com/kumabox/kumabox/cli" ) func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - err := cmd.Execute(ctx, os.Args[1:], os.Stdout, os.Stderr) - if err != nil && !cmd.Silent(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(cmd.ExitCode(err)) + os.Exit(cli.ExitCode(err)) } diff --git a/core/images.go b/core/images.go new file mode 100644 index 0000000..ded86ef --- /dev/null +++ b/core/images.go @@ -0,0 +1,76 @@ +// Package core assembles application modules and their concrete dependencies. +package core + +import ( + "context" + + "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" + "github.com/kumabox/kumabox/storage" +) + +// ImageStore owns the resources assembled for one image command. +// Call Close after using its catalog and managed artifact paths. +type ImageStore struct { + Paths images.Paths + Catalog images.Catalog + store metadata.Store +} + +func OpenImages(ctx context.Context, roots storage.Roots) (*ImageStore, error) { + paths, err := images.NewPaths(roots) + if err != nil { + return nil, err + } + if err := paths.Ensure(); err != nil { + return nil, err + } + store, err := sqlite.Open(ctx, paths.MetadataDB(), catalog.Collections(), sqlite.DefaultOptions()) + if err != nil { + return nil, err + } + return &ImageStore{Paths: paths, Catalog: catalog.New(store), store: store}, nil +} + +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 images.Platform) (*images.Importer, error) { + options := images.DefaultOptions() + converter, err := erofs.New(ctx, platform.Architecture, 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 string + SourceTag string +} + +func (o LocalImageOptions) Validate() error { + _, err := source.ParseFormat(o.Format) + return err +} + +// OpenLocalSource returns a source and the cleanup required for staged archives. +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: images.DefaultLimits(), + }) +} + +// NewRegistrySource selects the registry adapter and returns the normalized local name. +func NewRegistrySource(reference string) (images.Source, string, error) { + return source.NewRegistry(reference) +} diff --git a/errdefs/code.go b/errdefs/code.go deleted file mode 100644 index 070589e..0000000 --- a/errdefs/code.go +++ /dev/null @@ -1,30 +0,0 @@ -package errdefs - -// Code is a stable machine-readable failure code. -type Code string - -const ( - CodeNotFound Code = "NOT_FOUND" - CodeNameTaken Code = "NAME_TAKEN" - CodeInvalidArgument Code = "INVALID_ARGUMENT" - CodeHostIncompatible Code = "HOST_INCOMPATIBLE" - CodeDigestMismatch Code = "IMAGE_DIGEST_MISMATCH" - CodeArtifactCorrupt Code = "ARTIFACT_CORRUPT" - CodeArtifactUnavailable Code = "ARTIFACT_UNAVAILABLE" - CodeReferenced Code = "REFERENCED" - CodeStoreBusy Code = "STORE_BUSY" - CodeInternal Code = "INTERNAL" -) - -// Class groups codes that share handling policy. -type Class uint8 - -const ( - ClassUnknown Class = iota - ClassNotFound - ClassInvalid - ClassConflict - ClassUnavailable - ClassCorrupt - ClassInternal -) diff --git a/errdefs/error.go b/errdefs/error.go index 9f9c148..621a9d9 100644 --- a/errdefs/error.go +++ b/errdefs/error.go @@ -5,6 +5,35 @@ import ( "fmt" ) +// Code is a stable machine-readable failure code. +type Code string + +const ( + CodeNotFound Code = "NOT_FOUND" + CodeNameTaken Code = "NAME_TAKEN" + CodeInvalidArgument Code = "INVALID_ARGUMENT" + CodeHostIncompatible Code = "HOST_INCOMPATIBLE" + CodeDigestMismatch Code = "IMAGE_DIGEST_MISMATCH" + CodeArtifactCorrupt Code = "ARTIFACT_CORRUPT" + CodeArtifactUnavailable Code = "ARTIFACT_UNAVAILABLE" + CodeReferenced Code = "REFERENCED" + CodeStoreBusy Code = "STORE_BUSY" + CodeInternal Code = "INTERNAL" +) + +// Class groups codes that share handling policy. +type Class uint8 + +const ( + ClassUnknown Class = iota + ClassNotFound + ClassInvalid + ClassConflict + ClassUnavailable + ClassCorrupt + ClassInternal +) + // Error carries stable classification and diagnostic context across layers. type Error struct { Class Class diff --git a/images/paths.go b/images/artifacts.go similarity index 60% rename from images/paths.go rename to images/artifacts.go index 68b00ad..2837498 100644 --- a/images/paths.go +++ b/images/artifacts.go @@ -1,13 +1,15 @@ package images import ( + "context" + "crypto/sha256" + "errors" "fmt" + "io" "os" "path/filepath" - "strings" "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/storage" ) @@ -54,11 +56,6 @@ func (p Paths) BootFile(digest Digest, name string) (string, error) { return storage.Join(p.BootDir(digest), name) } -func IsBootName(name string) bool { - return filepath.Base(name) == name && !strings.HasSuffix(name, ".old") && - (strings.HasPrefix(name, "vmlinuz") || strings.HasPrefix(name, "initrd.img")) -} - func (p Paths) Kernel(digest Digest) string { return filepath.Join(p.BootDir(digest), "vmlinuz") } func (p Paths) Initrd(digest Digest) string { return filepath.Join(p.BootDir(digest), "initrd.img") } @@ -74,3 +71,44 @@ func (p Paths) NewStaging(pattern string) (string, error) { } return dir, nil } + +func digestFileContext(ctx context.Context, path string) (Digest, int64, error) { + if err := storage.CheckPath(path); err != nil { + return Digest{}, 0, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err) + } + info, err := os.Lstat(path) + if err != nil { + return Digest{}, 0, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err) + } + if !info.Mode().IsRegular() { + return 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 Digest{}, 0, err + } + file, err := root.Open(filepath.Base(path)) + if err != nil { + return 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 Digest{}, 0, fmt.Errorf("hash %s: %w", path, err) + } + digest, err := ParseDigest(fmt.Sprintf("sha256:%x", hash.Sum(nil))) + return digest, size, err +} + +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/images/boot.go b/images/boot.go new file mode 100644 index 0000000..176c96d --- /dev/null +++ b/images/boot.go @@ -0,0 +1,63 @@ +package images + +import ( + "errors" + "path/filepath" + "strings" + + "github.com/kumabox/kumabox/errdefs" +) + +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. +func SelectBoot(layers []Layer) (Boot, error) { + type candidate struct { + layer Digest + file 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 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 Boot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("image is missing a regular /boot/vmlinuz* kernel")) + } + if boot.InitrdLayer.IsZero() { + return 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 index 54be206..9c7bcb1 100644 --- a/images/catalog.go +++ b/images/catalog.go @@ -1,6 +1,24 @@ package images -import "context" +import ( + "context" + "errors" + "time" +) + +type ImportCommit struct { + Name string + Manifest Manifest + Layers []Layer + Boot Boot + Size int64 + Created time.Time +} + +type Removal struct { + Names []string + Layers []Digest +} type CatalogReader interface { Resolve(context.Context, string) (Image, error) @@ -17,3 +35,34 @@ 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) + 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/metadata_catalog.go b/images/catalog/store.go similarity index 69% rename from images/metadata_catalog.go rename to images/catalog/store.go index de12221..e8c04e9 100644 --- a/images/metadata_catalog.go +++ b/images/catalog/store.go @@ -1,4 +1,5 @@ -package images +// Package catalog persists image identities, names and layer references. +package catalog import ( "context" @@ -10,9 +11,12 @@ import ( "time" "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" "github.com/kumabox/kumabox/metadata" ) +var _ images.Catalog = (*Store)(nil) + const ( CollectionImages metadata.Collection = "images" CollectionNames metadata.Collection = "image_names" @@ -24,12 +28,12 @@ func Collections() []metadata.Collection { return []metadata.Collection{CollectionImages, CollectionNames, CollectionLayers} } -type MetadataCatalog struct { +type Store struct { store metadata.Store } -func NewMetadataCatalog(store metadata.Store) *MetadataCatalog { - return &MetadataCatalog{store: store} +func New(store metadata.Store) *Store { + return &Store{store: store} } type imageRecord struct { @@ -65,7 +69,7 @@ type bootFileRecord struct { Size int64 `json:"size"` } -func encodeBootFiles(files []BootFile) []bootFileRecord { +func encodeBootFiles(files []images.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}) @@ -73,8 +77,8 @@ func encodeBootFiles(files []BootFile) []bootFileRecord { return result } -func (c *MetadataCatalog) Resolve(ctx context.Context, reference string) (Image, error) { - var result Image +func (c *Store) Resolve(ctx context.Context, reference string) (images.Image, error) { + var result images.Image err := c.store.View(ctx, func(reader metadata.Reader) error { image, err := resolveRecord(ctx, reader, reference) if err != nil { @@ -86,8 +90,8 @@ func (c *MetadataCatalog) Resolve(ctx context.Context, reference string) (Image, return result, errdefs.Context(err, "resolve image", reference, "metadata", "check the image name or digest", false) } -func (c *MetadataCatalog) List(ctx context.Context) ([]Image, error) { - result := make([]Image, 0) +func (c *Store) List(ctx context.Context) ([]images.Image, error) { + result := make([]images.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) @@ -98,18 +102,18 @@ func (c *MetadataCatalog) List(ctx context.Context) ([]Image, error) { return nil }) }) - slices.SortFunc(result, func(left, right Image) int { + slices.SortFunc(result, func(left, right images.Image) int { return strings.Compare(left.ManifestDigest.String(), right.ManifestDigest.String()) }) return result, errdefs.Context(err, "list images", "", "metadata", "inspect the metadata store", false) } -func (c *MetadataCatalog) FindLayers(ctx context.Context, digests []Digest) (map[Digest]Layer, error) { - wanted := make(map[Digest]struct{}, len(digests)) +func (c *Store) FindLayers(ctx context.Context, digests []images.Digest) (map[images.Digest]images.Layer, error) { + wanted := make(map[images.Digest]struct{}, len(digests)) for _, digest := range digests { wanted[digest] = struct{}{} } - result := make(map[Digest]Layer) + result := make(map[images.Digest]images.Layer) err := c.store.View(ctx, func(reader metadata.Reader) error { return reader.Scan(ctx, CollectionLayers, func(_ string, raw []byte) error { var record layerRecord @@ -121,7 +125,7 @@ func (c *MetadataCatalog) FindLayers(ctx context.Context, digests []Digest) (map return err } if _, ok := wanted[layer.SourceDigest]; ok { - if previous, ok := result[layer.SourceDigest]; ok && !sameLayer(previous, layer) { + if previous, ok := result[layer.SourceDigest]; ok && !previous.Equal(layer) { return corruptRecord("shared layer", errors.New("conflicting artifact metadata")) } result[layer.SourceDigest] = layer @@ -132,8 +136,8 @@ func (c *MetadataCatalog) FindLayers(ctx context.Context, digests []Digest) (map return result, err } -func (c *MetadataCatalog) CommitImport(ctx context.Context, commit ImportCommit) error { - if err := validateCommit(commit); err != nil { +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 { @@ -161,7 +165,7 @@ func (c *MetadataCatalog) CommitImport(ctx context.Context, commit ImportCommit) return corruptRecord("image", errors.New("manifest facts changed")) } for pos, layer := range existing.Layers { - if !sameLayer(layer, commit.Layers[pos]) { + if !layer.Equal(commit.Layers[pos]) { return corruptRecord("image", errors.New("manifest layers changed")) } } @@ -191,10 +195,10 @@ func (c *MetadataCatalog) CommitImport(ctx context.Context, commit ImportCommit) return errdefs.Context(err, "commit image import", commit.Name, "metadata", "retry the import", false) } -func (c *MetadataCatalog) Remove(ctx context.Context, reference string, expected Digest) (Removal, error) { - var result Removal +func (c *Store) Remove(ctx context.Context, reference string, expected images.Digest) (images.Removal, error) { + var result images.Removal err := c.store.Update(ctx, func(writer metadata.Writer) error { - result = Removal{} + result = images.Removal{} image, err := resolveRecord(ctx, writer, reference) if err != nil { return err @@ -253,20 +257,20 @@ func (c *MetadataCatalog) Remove(ctx context.Context, reference string, expected return result, errdefs.Context(err, "remove image", reference, "metadata", "inspect image references", false) } -func resolveRecord(ctx context.Context, reader metadata.Reader, reference string) (Image, error) { +func resolveRecord(ctx context.Context, reader metadata.Reader, reference string) (images.Image, error) { digestID := "" if raw, ok, err := reader.Get(ctx, CollectionNames, reference); err != nil { - return Image{}, err + return images.Image{}, err } else if ok { var record nameRecord if err := json.Unmarshal(raw, &record); err != nil { - return Image{}, corruptRecord("name", err) + return images.Image{}, corruptRecord("name", err) } digestID = record.ManifestDigest } else { prefix := strings.TrimPrefix(reference, "sha256:") if len(prefix) < minimumDigestPrefix { - return Image{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("image %q not found", reference)) + return images.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) { @@ -277,42 +281,42 @@ func resolveRecord(ctx context.Context, reader metadata.Reader, reference string } return nil }); err != nil { - return Image{}, err + return images.Image{}, err } } if digestID == "" { - return Image{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("image %q not found", reference)) + return images.Image{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("image %q not found", reference)) } return loadImage(ctx, reader, digestID) } -func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (Image, error) { +func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (images.Image, error) { raw, ok, err := reader.Get(ctx, CollectionImages, digestID) if err != nil { - return Image{}, err + return images.Image{}, err } if !ok { - return Image{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("image record %s is missing", digestID)) + return images.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 Image{}, corruptRecord("image", err) + return images.Image{}, corruptRecord("image", err) } - manifest, err := ParseDigest(record.ManifestDigest) + manifest, err := images.ParseDigest(record.ManifestDigest) if err != nil { - return Image{}, corruptRecord("image digest", err) + return images.Image{}, corruptRecord("image digest", err) } - kernel, err := ParseDigest(record.KernelLayer) + kernel, err := images.ParseDigest(record.KernelLayer) if err != nil { - return Image{}, corruptRecord("kernel digest", err) + return images.Image{}, corruptRecord("kernel digest", err) } - initrd, err := ParseDigest(record.InitrdLayer) + initrd, err := images.ParseDigest(record.InitrdLayer) if err != nil { - return Image{}, corruptRecord("initrd digest", err) + return images.Image{}, corruptRecord("initrd digest", err) } - image := Image{ - ManifestDigest: manifest, Platform: Platform{OS: record.OS, Architecture: record.Architecture}, - Boot: Boot{KernelLayer: kernel, InitrdLayer: initrd, KernelFile: record.KernelFile, InitrdFile: record.InitrdFile}, Size: record.Size, CreatedAt: record.CreatedAt, + image := images.Image{ + ManifestDigest: manifest, Platform: images.Platform{OS: record.OS, Architecture: record.Architecture}, + Boot: images.Boot{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 @@ -324,7 +328,7 @@ func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (Im } return nil }); err != nil { - return Image{}, err + return images.Image{}, err } var layerRecords []layerRecord if err := reader.Scan(ctx, CollectionLayers, func(key string, raw []byte) error { @@ -341,91 +345,61 @@ func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (Im layerRecords = append(layerRecords, item) return nil }); err != nil { - return Image{}, err + return images.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 Image{}, corruptRecord("layer order", errors.New("noncontiguous layer positions")) + return images.Image{}, corruptRecord("layer order", errors.New("noncontiguous layer positions")) } layer, err := decodeLayer(item) if err != nil { - return Image{}, err + return images.Image{}, err } image.Layers = append(image.Layers, layer) } if manifest.String() != digestID { - return Image{}, corruptRecord("image identity", errors.New("record key differs from manifest digest")) + return images.Image{}, corruptRecord("image identity", errors.New("record key differs from manifest digest")) } - descriptors := make([]Descriptor, len(image.Layers)) + descriptors := make([]images.Descriptor, len(image.Layers)) for pos, layer := range image.Layers { - descriptors[pos] = Descriptor{Digest: layer.SourceDigest} + descriptors[pos] = images.Descriptor{Digest: layer.SourceDigest} } - if err := validateCommit(ImportCommit{Name: "stored", Manifest: Manifest{Digest: manifest, Platform: image.Platform, Layers: descriptors}, Layers: image.Layers, Boot: image.Boot, Size: image.Size, Created: image.CreatedAt}); err != nil { - return Image{}, corruptRecord("image facts", err) + if err := (images.ImportCommit{Name: "stored", Manifest: images.Manifest{Digest: manifest, Platform: image.Platform, Layers: descriptors}, Layers: image.Layers, Boot: image.Boot, Size: image.Size, Created: image.CreatedAt}).Validate(); err != nil { + return images.Image{}, corruptRecord("image facts", err) } slices.Sort(image.Names) return image, nil } -func decodeLayer(record layerRecord) (Layer, error) { - source, err := ParseDigest(record.SourceDigest) +func decodeLayer(record layerRecord) (images.Layer, error) { + source, err := images.ParseDigest(record.SourceDigest) if err != nil { - return Layer{}, corruptRecord("source layer digest", err) + return images.Layer{}, corruptRecord("source layer digest", err) } - erofs, err := ParseDigest(record.EROFSDigest) + erofs, err := images.ParseDigest(record.EROFSDigest) if err != nil { - return Layer{}, corruptRecord("erofs digest", err) + return images.Layer{}, corruptRecord("erofs digest", err) } - layer := Layer{SourceDigest: source, EROFSDigest: erofs, Size: record.Size, Whiteouts: record.Whiteouts, BootOpaque: record.BootOpaque} + layer := images.Layer{SourceDigest: source, EROFSDigest: erofs, Size: record.Size, Whiteouts: record.Whiteouts, BootOpaque: record.BootOpaque} for _, file := range record.BootFiles { - digest, err := ParseDigest(file.Digest) - if err != nil || !IsBootName(file.Name) || file.Size <= 0 { - return Layer{}, corruptRecord("boot file", errors.New("invalid name, digest or size")) + digest, err := images.ParseDigest(file.Digest) + if err != nil || !images.IsBootName(file.Name) || file.Size <= 0 { + return images.Layer{}, corruptRecord("boot file", errors.New("invalid name, digest or size")) } - layer.BootFiles = append(layer.BootFiles, BootFile{Name: file.Name, Digest: digest, Size: file.Size}) + layer.BootFiles = append(layer.BootFiles, images.BootFile{Name: file.Name, Digest: digest, Size: file.Size}) } if layer.SourceDigest.IsZero() || layer.EROFSDigest.IsZero() || layer.Size <= 0 { - return Layer{}, corruptRecord("layer", errors.New("invalid digest or size")) + return images.Layer{}, corruptRecord("layer", errors.New("invalid digest or size")) } for _, name := range layer.Whiteouts { - if !IsBootName(name) { - return Layer{}, corruptRecord("whiteout", errors.New("invalid boot whiteout")) + if !images.IsBootName(name) { + return images.Layer{}, corruptRecord("whiteout", errors.New("invalid boot whiteout")) } } return layer, nil } -func validateCommit(commit ImportCommit) error { - if commit.Name == "" || commit.Manifest.Digest.IsZero() || !validPlatform(commit.Manifest.Platform) || 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) - if err != nil || boot != commit.Boot || size != commit.Size { - return errors.New("inconsistent boot selection or total image size") - } - return 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 { @@ -438,11 +412,11 @@ func corruptRecord(kind string, cause error) error { return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("decode %s metadata: %w", kind, cause)) } -func layerKey(manifest Digest, position int) string { +func layerKey(manifest images.Digest, position int) string { return fmt.Sprintf("%s/%08d", manifest.String(), position) } -func layerReferenced(ctx context.Context, reader metadata.Reader, digest Digest) (bool, error) { +func layerReferenced(ctx context.Context, reader metadata.Reader, digest images.Digest) (bool, error) { referenced := false err := reader.Scan(ctx, CollectionLayers, func(_ string, raw []byte) error { var record layerRecord diff --git a/images/oci/erofs.go b/images/erofs/convert.go similarity index 80% rename from images/oci/erofs.go rename to images/erofs/convert.go index c5caf5f..d45208e 100644 --- a/images/oci/erofs.go +++ b/images/erofs/convert.go @@ -1,4 +1,4 @@ -package oci +package erofs import ( "archive/tar" @@ -25,18 +25,16 @@ const ( erofsBlockSize = 4096 ) -type EROFSConverter struct { +type Converter struct { architecture string - limits Limits + limits images.Limits } -func NewEROFSConverter(ctx context.Context, architecture string) (*EROFSConverter, error) { - return NewEROFSConverterWithLimits(ctx, architecture, DefaultLimits()) -} +var _ images.Converter = (*Converter)(nil) -func NewEROFSConverterWithLimits(ctx context.Context, architecture string, limits Limits) (*EROFSConverter, error) { +func New(ctx context.Context, architecture string, limits images.Limits) (*Converter, error) { if !limits.Valid() || (architecture != "amd64" && architecture != "arm64") { - return nil, invalidSource("invalid converter architecture or size limits") + return nil, invalidLayer("invalid converter architecture or size limits") } output, err := exec.CommandContext(ctx, "mkfs.erofs", "--version").CombinedOutput() if err != nil { @@ -45,10 +43,10 @@ func NewEROFSConverterWithLimits(ctx context.Context, architecture string, limit if err := requireEROFSVersion(string(output)); err != nil { return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, err) } - return &EROFSConverter{architecture: architecture, limits: limits}, nil + return &Converter{architecture: architecture, limits: limits}, nil } -func (c *EROFSConverter) Convert(ctx context.Context, descriptor images.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) { +func (c *Converter) Convert(ctx context.Context, descriptor images.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) { ctx, cancel := context.WithCancel(ctx) defer cancel() outputPath := filepath.Join(workDir, descriptor.Digest.Hex()+".erofs") @@ -77,7 +75,7 @@ func (c *EROFSConverter) Convert(ctx context.Context, descriptor images.Descript 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 OCI layer: %w", err) + scanErr = fmt.Errorf("drain layer tar: %w", err) } } if scanErr != nil { @@ -89,7 +87,7 @@ func (c *EROFSConverter) Convert(ctx context.Context, descriptor images.Descript 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{}, sourceError(err) + return images.ConvertedLayer{}, conversionError(err) } digest, size, err := digestPath(ctx, outputPath) if err != nil { @@ -112,11 +110,11 @@ func scanBoot(source io.Reader, workDir, architecture string, limit int64) ([]im return files, whiteouts, opaque, nil } if err != nil { - return nil, nil, false, invalidSource("read layer tar: %v", err) + 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, invalidSource("unsafe layer path %q", header.Name) + return nil, nil, false, invalidLayer("unsafe layer path %q", header.Name) } if clean == ".wh.boot" || (clean == "boot" && header.Typeflag != tar.TypeDir) { files = nil @@ -187,7 +185,7 @@ func writeBootFile(source io.Reader, destination string, decompressKernel bool, return fmt.Errorf("write boot artifact: %w", err) } if written == 0 || written > limit { - return invalidSource("boot artifact size %d is outside limit %d", written, limit) + return invalidLayer("boot artifact size %d is outside limit %d", written, limit) } return nil } @@ -228,7 +226,7 @@ func digestPath(ctx context.Context, path string) (images.Digest, int64, error) return images.Digest{}, 0, fmt.Errorf("open generated EROFS: %w", err) } hash := sha256.New() - size, copyErr := io.Copy(hash, &contextInput{ctx: ctx, source: file}) + size, copyErr := io.Copy(hash, contextReader{ctx: ctx, reader: file}) closeErr := file.Close() if err := errors.Join(copyErr, closeErr); err != nil { return images.Digest{}, 0, fmt.Errorf("hash generated EROFS: %w", err) @@ -236,3 +234,32 @@ func digestPath(ctx context.Context, path string) (images.Digest, int64, error) digest, err := images.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...)) +} + +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) +} + +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/images/oci/erofs_test.go b/images/erofs/convert_test.go similarity index 89% rename from images/oci/erofs_test.go rename to images/erofs/convert_test.go index 4b6b929..ad5a3ff 100644 --- a/images/oci/erofs_test.go +++ b/images/erofs/convert_test.go @@ -1,4 +1,4 @@ -package oci +package erofs import ( "archive/tar" @@ -75,3 +75,12 @@ func TestWriteBootFileBoundsARM64Decompression(t *testing.T) { 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/files.go b/images/files.go deleted file mode 100644 index 5ad8822..0000000 --- a/images/files.go +++ /dev/null @@ -1,55 +0,0 @@ -package images - -import ( - "context" - "crypto/sha256" - "errors" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/storage" -) - -func digestFileContext(ctx context.Context, path string) (Digest, int64, error) { - if err := storage.CheckPath(path); err != nil { - return Digest{}, 0, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err) - } - info, err := os.Lstat(path) - if err != nil { - return Digest{}, 0, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err) - } - if !info.Mode().IsRegular() { - return 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 Digest{}, 0, err - } - file, err := root.Open(filepath.Base(path)) - if err != nil { - return 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 Digest{}, 0, fmt.Errorf("hash %s: %w", path, err) - } - digest, err := ParseDigest(fmt.Sprintf("sha256:%x", hash.Sum(nil))) - return digest, size, err -} - -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/images/image.go b/images/image.go index a4589c6..95b7cf6 100644 --- a/images/image.go +++ b/images/image.go @@ -3,6 +3,7 @@ package images import ( "encoding/hex" "fmt" + "slices" "strings" "time" ) @@ -90,16 +91,12 @@ type Descriptor struct { Size int64 } -type ImportCommit struct { - Name string - Manifest Manifest - Layers []Layer - Boot Boot - Size int64 - Created time.Time +// Valid reports whether the platform is supported by KumaBox. +func (p Platform) Valid() bool { + return p.OS == "linux" && (p.Architecture == "amd64" || p.Architecture == "arm64") } -type Removal struct { - Names []string - Layers []Digest +// 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/images/import.go b/images/import.go index 6502372..af0e8ec 100644 --- a/images/import.go +++ b/images/import.go @@ -19,6 +19,63 @@ import ( "github.com/kumabox/kumabox/storage" ) +type Source interface { + Resolve(context.Context, Platform) (Manifest, error) + OpenLayer(context.Context, Descriptor) (io.ReadCloser, error) +} + +// ImportCatalog provides the metadata operations needed by an import. +type ImportCatalog interface { + Resolve(context.Context, string) (Image, error) + FindLayers(context.Context, []Digest) (map[Digest]Layer, error) + CommitImport(context.Context, ImportCommit) error +} + +type Converter interface { + Convert(context.Context, Descriptor, io.Reader, string) (ConvertedLayer, error) +} + +type ConvertedLayer struct { + SourceDigest Digest + EROFSPath string + EROFSDigest Digest + Size int64 + BootFiles []StagedBootFile + Whiteouts []string + BootOpaque bool +} + +type StagedBootFile struct { + Name string + Path string +} + +type Reporter interface { + Layer(int, int, Digest) error + Committed(Image) error +} + +type DiscardReporter struct{} + +func (DiscardReporter) Layer(int, int, Digest) error { return nil } +func (DiscardReporter) Committed(Image) error { return nil } + +// Limits bound compressed input and decompressed source and boot artifacts. +type Limits struct { + LayerSize int64 + UnpackedSize int64 + BootSize int64 + ArchiveSize int64 +} + +func DefaultLimits() Limits { + return Limits{LayerSize: 8 << 30, UnpackedSize: 16 << 30, BootSize: 512 << 20, ArchiveSize: 32 << 30} +} + +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 +} + type Options struct { Limits Limits Parallelism int @@ -31,14 +88,14 @@ func DefaultOptions() Options { type Importer struct { paths Paths - catalog Catalog + catalog ImportCatalog converter Converter reporter Reporter reportMu sync.Mutex options Options } -func NewImporter(paths Paths, catalog Catalog, converter Converter, reporter Reporter, options Options) (*Importer, error) { +func NewImporter(paths Paths, catalog ImportCatalog, converter Converter, reporter Reporter, options Options) (*Importer, error) { if options.Limits == (Limits{}) { options.Limits = DefaultLimits() } @@ -55,7 +112,7 @@ func NewImporter(paths Paths, catalog Catalog, converter Converter, reporter Rep } func (i *Importer) Import(ctx context.Context, name string, platform Platform, source Source) (result Image, returnErr error) { - if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\r\n\t") || source == nil || !validPlatform(platform) { + if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\r\n\t") || source == nil || !platform.Valid() { return Image{}, invalidImage("image name, supported platform and source are required") } if err := ctx.Err(); err != nil { @@ -66,15 +123,15 @@ func (i *Importer) Import(ctx context.Context, name string, platform Platform, s } manifest, err := source.Resolve(ctx, platform) if err != nil { - return Image{}, errdefs.Context(err, "import image", name, "resolve", "check the OCI source and platform", false) + return 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 Image{}, invalidImage("invalid OCI manifest or platform") + return Image{}, invalidImage("invalid image manifest or platform") } digests := make([]Digest, len(manifest.Layers)) for position, descriptor := range manifest.Layers { if descriptor.Digest.IsZero() || descriptor.Size < 0 || descriptor.Size > i.options.Limits.LayerSize { - return Image{}, invalidImage("invalid OCI layer descriptor") + return Image{}, invalidImage("invalid layer descriptor") } digests[position] = descriptor.Digest } @@ -151,13 +208,13 @@ func (i *Importer) Import(ctx context.Context, name string, platform Platform, s if err != nil { return Image{}, errdefs.Context(err, "import image", name, "publish", "retry the import", false) } - if old, exists := current[layer.SourceDigest]; exists && !sameLayer(old, layer) { + if old, exists := current[layer.SourceDigest]; exists && !old.Equal(layer) { return 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) + boot, err := SelectBoot(layers) if err != nil { return Image{}, err } @@ -240,7 +297,7 @@ func (i *Importer) publishLayer(ctx context.Context, artifact ConvertedLayer, st if err != nil { return Layer{}, err } - if old, exists := known[layer.SourceDigest]; exists && !sameLayer(old, layer) { + if old, exists := known[layer.SourceDigest]; exists && !old.Equal(layer) { return 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 { @@ -266,66 +323,9 @@ func stagedDigest(ctx context.Context, staging, path string) (Digest, int64, err return digestFileContext(ctx, path) } -func selectBoot(layers []Layer) (Boot, error) { - type candidate struct { - layer Digest - file 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 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 Boot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("image is missing a regular /boot/vmlinuz* kernel")) - } - if boot.InitrdLayer.IsZero() { - return Boot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("image is missing a regular /boot/initrd.img* initrd")) - } - return boot, nil -} - -func validPlatform(p Platform) bool { - return p.OS == "linux" && (p.Architecture == "amd64" || p.Architecture == "arm64") -} - func invalidImage(format string, args ...any) error { return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf(format, args...)) } -func validFile(path string) bool { return storage.CheckPath(path) == nil && regularNonempty(path) } -func regularNonempty(path string) bool { - info, err := os.Lstat(path) - return err == nil && info.Mode().IsRegular() && info.Size() > 0 -} func removeStaging(path string) error { if err := os.RemoveAll(path); err != nil { diff --git a/images/import_test.go b/images/import_test.go index b101182..75eaa9c 100644 --- a/images/import_test.go +++ b/images/import_test.go @@ -1,8 +1,9 @@ -package images +package images_test import ( "bytes" "context" + "crypto/sha256" "errors" "fmt" "io" @@ -12,6 +13,8 @@ import ( "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" @@ -24,14 +27,14 @@ func TestImporterReusesLayerAndRemovesAliases(t *testing.T) { Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"), } - paths, err := NewPaths(roots) + 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(), Collections(), metadatasqlite.DefaultOptions()) + store, err := metadatasqlite.Open(t.Context(), paths.MetadataDB(), imagecatalog.Collections(), metadatasqlite.DefaultOptions()) if err != nil { t.Fatalf("Open metadata: %v", err) } @@ -42,13 +45,13 @@ func TestImporterReusesLayerAndRemovesAliases(t *testing.T) { }() layerDigest := testDigest(t, "1") manifestDigest := testDigest(t, "2") - source := fakeSource{manifest: Manifest{ - Digest: manifestDigest, Platform: Platform{OS: "linux", Architecture: "amd64"}, - Layers: []Descriptor{{Digest: layerDigest, Size: 3}}, + source := fakeSource{manifest: images.Manifest{ + Digest: manifestDigest, Platform: images.Platform{OS: "linux", Architecture: "amd64"}, + Layers: []images.Descriptor{{Digest: layerDigest, Size: 3}}, }} converter := &fakeConverter{} - catalog := NewMetadataCatalog(store) - importer, err := NewImporter(paths, catalog, converter, nil, Options{Parallelism: 1, Now: func() time.Time { return time.Unix(1, 0) }}) + 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) } @@ -61,20 +64,20 @@ func TestImporterReusesLayerAndRemovesAliases(t *testing.T) { if converter.Calls() != 1 { t.Fatalf("converter calls = %d, want 1", converter.Calls()) } - image, err := Verify(t.Context(), paths, catalog, "second") + 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 := Remove(t.Context(), paths, catalog, "first"); err != nil { + if _, err := images.Remove(t.Context(), paths, catalog, "first"); err != nil { t.Fatalf("remove first alias: %v", err) } - if !validFile(paths.EROFS(layerDigest)) { + if _, err := images.Verify(t.Context(), paths, catalog, "second"); err != nil { t.Fatal("shared layer removed with remaining alias") } - if _, err := Remove(t.Context(), paths, catalog, "second"); err != nil { + 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) { @@ -83,12 +86,14 @@ func TestImporterReusesLayerAndRemovesAliases(t *testing.T) { } type fakeSource struct { - manifest Manifest + manifest images.Manifest } -func (f fakeSource) Resolve(context.Context, Platform) (Manifest, error) { return f.manifest, nil } +func (f fakeSource) Resolve(context.Context, images.Platform) (images.Manifest, error) { + return f.manifest, nil +} -func (f fakeSource) OpenLayer(context.Context, Descriptor) (io.ReadCloser, error) { +func (f fakeSource) OpenLayer(context.Context, images.Descriptor) (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader([]byte("tar"))), nil } @@ -97,9 +102,9 @@ type fakeConverter struct { calls int } -func (f *fakeConverter) Convert(ctx context.Context, descriptor Descriptor, source io.Reader, workDir string) (ConvertedLayer, error) { +func (f *fakeConverter) Convert(ctx context.Context, descriptor images.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) { if _, err := io.Copy(io.Discard, source); err != nil { - return ConvertedLayer{}, err + return images.ConvertedLayer{}, err } f.mu.Lock() f.calls++ @@ -109,16 +114,16 @@ func (f *fakeConverter) Convert(ctx context.Context, descriptor Descriptor, sour 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 ConvertedLayer{}, err + return images.ConvertedLayer{}, err } } - product, size, err := digestFileContext(ctx, erofs) + product, err := images.ParseDigest(fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("erofs")))) if err != nil { - return ConvertedLayer{}, err + return images.ConvertedLayer{}, err } - return ConvertedLayer{ + return images.ConvertedLayer{ SourceDigest: descriptor.Digest, EROFSPath: erofs, EROFSDigest: product, - Size: size, BootFiles: []StagedBootFile{{Name: "vmlinuz", Path: kernel}, {Name: "initrd.img", Path: initrd}}, + Size: 5, BootFiles: []images.StagedBootFile{{Name: "vmlinuz", Path: kernel}, {Name: "initrd.img", Path: initrd}}, }, nil } @@ -128,19 +133,19 @@ func (f *fakeConverter) Calls() int { return f.calls } -func testDigest(t *testing.T, digit string) Digest { +func testDigest(t *testing.T, digit string) images.Digest { t.Helper() - digest, err := ParseDigest(fmt.Sprintf("sha256:%s", bytes.Repeat([]byte(digit), 64))) + digest, err := images.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) (Paths, *MetadataCatalog) { +func testImportState(t *testing.T, store metadata.Store) (images.Paths, *imagecatalog.Store) { 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")}) + 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) } @@ -148,7 +153,7 @@ func testImportState(t *testing.T, store metadata.Store) (Paths, *MetadataCatalo t.Fatal(err) } if store == nil { - memory, err := metadata.NewMemory(Collections()) + memory, err := metadata.NewMemory(imagecatalog.Collections()) if err != nil { t.Fatal(err) } @@ -159,17 +164,17 @@ func testImportState(t *testing.T, store metadata.Store) (Paths, *MetadataCatalo t.Error(err) } }) - return paths, NewMetadataCatalog(store) + return paths, imagecatalog.New(store) } -func testManifest(t *testing.T, manifestDigit string) Manifest { +func testManifest(t *testing.T, manifestDigit string) images.Manifest { t.Helper() - return Manifest{Digest: testDigest(t, manifestDigit), Platform: Platform{OS: "linux", Architecture: "amd64"}, Layers: []Descriptor{{Digest: testDigest(t, "1"), Size: 3}}} + return images.Manifest{Digest: testDigest(t, manifestDigit), Platform: images.Platform{OS: "linux", Architecture: "amd64"}, Layers: []images.Descriptor{{Digest: testDigest(t, "1"), Size: 3}}} } -func testImporter(t *testing.T, paths Paths, catalog Catalog, converter Converter) *Importer { +func testImporter(t *testing.T, paths images.Paths, catalog images.Catalog, converter images.Converter) *images.Importer { t.Helper() - importer, err := NewImporter(paths, catalog, converter, nil, Options{Parallelism: 2, Now: func() time.Time { return time.Unix(10, 0) }}) + 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) } @@ -185,12 +190,15 @@ func TestImporterRepairsCorruptionAndPreservesCreationTime(t *testing.T) { if err != nil { t.Fatal(err) } - importer.options.Now = func() time.Time { return time.Unix(20, 0) } + 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 := Verify(t.Context(), paths, catalog, "aaaaaaaaaaaa"); err == nil { + 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}) @@ -200,10 +208,10 @@ func TestImporterRepairsCorruptionAndPreservesCreationTime(t *testing.T) { if !second.CreatedAt.Equal(first.CreatedAt) || converter.Calls() != 2 { t.Fatalf("repeat import = created %s, conversions %d", second.CreatedAt, converter.Calls()) } - if _, err := Verify(t.Context(), paths, catalog, "alias"); err != nil { + if _, err := images.Verify(t.Context(), paths, catalog, "alias"); err != nil { t.Fatal(err) } - if _, err := Remove(t.Context(), paths, catalog, "aaaaaaaaaaaa"); err != nil { + if _, err := images.Remove(t.Context(), paths, catalog, "aaaaaaaaaaaa"); err != nil { t.Fatal(err) } remaining, err := catalog.Resolve(t.Context(), "alias") @@ -218,16 +226,16 @@ type gatedConverter struct { release chan struct{} } -func (f *gatedConverter) Convert(ctx context.Context, descriptor Descriptor, reader io.Reader, workDir string) (ConvertedLayer, error) { +func (f *gatedConverter) Convert(ctx context.Context, descriptor images.Descriptor, reader io.Reader, workDir string) (images.ConvertedLayer, error) { select { case f.started <- struct{}{}: case <-ctx.Done(): - return ConvertedLayer{}, ctx.Err() + return images.ConvertedLayer{}, ctx.Err() } select { case <-f.release: case <-ctx.Done(): - return ConvertedLayer{}, ctx.Err() + return images.ConvertedLayer{}, ctx.Err() } return f.fakeConverter.Convert(ctx, descriptor, reader, workDir) } @@ -261,16 +269,16 @@ func TestImporterConcurrentSharedLayerAndLastReferenceRemoval(t *testing.T) { if err != nil || len(items) != 2 { t.Fatalf("images = %d, %v", len(items), err) } - if _, err := Remove(t.Context(), paths, catalog, "2"); err != nil { + if _, err := images.Remove(t.Context(), paths, catalog, "2"); err != nil { t.Fatal(err) } - if _, err := Verify(t.Context(), paths, catalog, "3"); err != nil { + if _, err := images.Verify(t.Context(), paths, catalog, "3"); err != nil { t.Fatalf("shared layer deleted: %v", err) } - if _, err := Remove(t.Context(), paths, catalog, "3"); err != nil { + if _, err := images.Remove(t.Context(), paths, catalog, "3"); err != nil { t.Fatal(err) } - if validFile(paths.EROFS(testDigest(t, "1"))) { + if _, err := os.Stat(paths.EROFS(testDigest(t, "1"))); !os.IsNotExist(err) { t.Fatal("unreferenced layer retained") } entries, err := os.ReadDir(paths.StagingDir()) @@ -293,7 +301,7 @@ func (s *failingStore) Update(ctx context.Context, fn func(metadata.Writer) erro } func TestImporterCommitFailureLeavesInvisibleOrphansAndRetryRebuilds(t *testing.T) { - memory, err := metadata.NewMemory(Collections()) + memory, err := metadata.NewMemory(imagecatalog.Collections()) if err != nil { t.Fatal(err) } @@ -321,7 +329,7 @@ func TestImporterCommitFailureLeavesInvisibleOrphansAndRetryRebuilds(t *testing. if converter.Calls() != 2 { t.Fatalf("retry trusted orphan; conversions = %d", converter.Calls()) } - if _, err := Verify(t.Context(), paths, catalog, "tiny"); err != nil { + if _, err := images.Verify(t.Context(), paths, catalog, "tiny"); err != nil { t.Fatal(err) } } @@ -333,9 +341,9 @@ type badConverter struct { escape string } -func (f *badConverter) Convert(ctx context.Context, descriptor Descriptor, source io.Reader, workDir string) (ConvertedLayer, error) { +func (f *badConverter) Convert(ctx context.Context, descriptor images.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) { if f.fail != nil { - return ConvertedLayer{}, f.fail + return images.ConvertedLayer{}, f.fail } artifact, err := f.fakeConverter.Convert(ctx, descriptor, source, workDir) if f.omitBoot { @@ -388,16 +396,16 @@ func TestImporterFailureAndCancellationDoNotCommit(t *testing.T) { func TestSelectBootAppliesOverwritesAndWhiteouts(t *testing.T) { first, second := testDigest(t, "1"), testDigest(t, "2") - layers := []Layer{ - {SourceDigest: first, BootFiles: []BootFile{{Name: "vmlinuz-1"}, {Name: "vmlinuz-2"}, {Name: "initrd.img"}}}, - {SourceDigest: second, Whiteouts: []string{"vmlinuz-2"}, BootFiles: []BootFile{{Name: "initrd.img"}}}, + layers := []images.Layer{ + {SourceDigest: first, BootFiles: []images.BootFile{{Name: "vmlinuz-1"}, {Name: "vmlinuz-2"}, {Name: "initrd.img"}}}, + {SourceDigest: second, Whiteouts: []string{"vmlinuz-2"}, BootFiles: []images.BootFile{{Name: "initrd.img"}}}, } - boot, err := selectBoot(layers) + 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 := selectBoot(layers); err == nil { + if _, err := images.SelectBoot(layers); err == nil { t.Fatal("opaque layer retained older kernel") } } diff --git a/images/oci/local.go b/images/oci/local.go deleted file mode 100644 index a28ec27..0000000 --- a/images/oci/local.go +++ /dev/null @@ -1,186 +0,0 @@ -package oci - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "os" - - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/partial" - "github.com/google/go-containerregistry/pkg/v1/types" - - "github.com/kumabox/kumabox/images" -) - -// Local metadata is bounded before allocation; os.Root also contains concurrent path changes. -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("OCI metadata exceeds %d bytes", limit) - } - return raw, nil -} - -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("OCI blob 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 -} - -type localReader struct { - io.Reader - file *os.File - root *os.Root -} - -func (r *localReader) Close() error { return errors.Join(r.file.Close(), r.root.Close()) } - -func blobName(hash v1.Hash) (string, error) { - digest, err := images.ParseDigest(hash.String()) - if err != nil { - return "", invalidSource("invalid OCI blob digest: %v", err) - } - return "blobs/sha256/" + digest.Hex(), nil -} - -type localIndex struct { - path string - raw []byte - ctx context.Context -} - -func (i *localIndex) MediaType() (types.MediaType, error) { return types.OCIImageIndex, nil } -func (i *localIndex) Digest() (v1.Hash, error) { return partial.Digest(i) } -func (i *localIndex) Size() (int64, error) { return int64(len(i.raw)), nil } -func (i *localIndex) RawManifest() ([]byte, error) { return bytes.Clone(i.raw), nil } -func (i *localIndex) IndexManifest() (*v1.IndexManifest, error) { - return v1.ParseIndexManifest(bytes.NewReader(i.raw)) -} - -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) -} - -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}) -} - -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 -} - -type localImage struct { - path string - raw []byte - descriptor v1.Descriptor - ctx context.Context -} - -func (i *localImage) MediaType() (types.MediaType, error) { return i.descriptor.MediaType, nil } -func (i *localImage) RawManifest() ([]byte, error) { return bytes.Clone(i.raw), nil } -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) -} - -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 { - return &localLayer{path: i.path, descriptor: descriptor, ctx: i.ctx}, nil - } - } - return nil, fmt.Errorf("OCI layer %s not found", hash) -} - -type localLayer struct { - path string - descriptor v1.Descriptor - ctx context.Context -} - -func (l *localLayer) Digest() (v1.Hash, error) { return l.descriptor.Digest, nil } -func (l *localLayer) Size() (int64, error) { return l.descriptor.Size, nil } -func (l *localLayer) MediaType() (types.MediaType, error) { return l.descriptor.MediaType, nil } -func (l *localLayer) Compressed() (io.ReadCloser, error) { - name, err := blobName(l.descriptor.Digest) - if err != nil { - return nil, err - } - return openLocal(l.ctx, l.path, name) -} diff --git a/images/remove.go b/images/remove.go index 025c69c..9f15634 100644 --- a/images/remove.go +++ b/images/remove.go @@ -12,7 +12,12 @@ import ( filelock "github.com/kumabox/kumabox/lock/flock" ) -func Remove(ctx context.Context, paths Paths, catalog Catalog, reference string) (result Removal, returnErr error) { +type RemovalCatalog interface { + ImageResolver + Remove(context.Context, string, Digest) (Removal, error) +} + +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 diff --git a/images/source.go b/images/source.go deleted file mode 100644 index e4b6ec6..0000000 --- a/images/source.go +++ /dev/null @@ -1,56 +0,0 @@ -package images - -import ( - "context" - "io" -) - -type Source interface { - Resolve(context.Context, Platform) (Manifest, error) - OpenLayer(context.Context, Descriptor) (io.ReadCloser, error) -} - -type Converter interface { - Convert(context.Context, Descriptor, io.Reader, string) (ConvertedLayer, error) -} - -type ConvertedLayer struct { - SourceDigest Digest - EROFSPath string - EROFSDigest Digest - Size int64 - BootFiles []StagedBootFile - Whiteouts []string - BootOpaque bool -} - -type StagedBootFile struct { - Name string - Path string -} - -type Reporter interface { - Layer(int, int, Digest) error - Committed(Image) error -} - -type DiscardReporter struct{} - -func (DiscardReporter) Layer(int, int, Digest) error { return nil } -func (DiscardReporter) Committed(Image) error { return nil } - -// Limits bound compressed input and decompressed source and boot artifacts. -type Limits struct { - LayerSize int64 - UnpackedSize int64 - BootSize int64 - ArchiveSize int64 -} - -func DefaultLimits() Limits { - return Limits{LayerSize: 8 << 30, UnpackedSize: 16 << 30, BootSize: 512 << 20, ArchiveSize: 32 << 30} -} - -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 -} diff --git a/images/oci/archive.go b/images/source/archive.go similarity index 72% rename from images/oci/archive.go rename to images/source/archive.go index bfdf837..4fff28e 100644 --- a/images/oci/archive.go +++ b/images/source/archive.go @@ -1,4 +1,4 @@ -package oci +package source import ( "archive/tar" @@ -19,36 +19,44 @@ import ( const maxArchiveEntries = 1 << 20 func NewArchive(path, stagingRoot string) (images.Source, func() error, error) { - return NewArchiveContext(context.TODO(), path, stagingRoot, DefaultLimits()) + return NewArchiveContext(context.TODO(), path, stagingRoot, images.DefaultLimits()) } -func NewArchiveContext(ctx context.Context, path, stagingRoot string, limits Limits) (images.Source, func() error, error) { +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 +} + +func stageArchive(ctx context.Context, path, stagingRoot string, limits images.Limits) (string, func() error, error) { if !limits.Valid() { - return nil, nil, invalidSource("OCI size limits must be positive and bounded") + return "", nil, invalidSource("archive size limits must be positive and bounded") } if err := ctx.Err(); err != nil { - return nil, nil, err + return "", nil, err } if err := storage.EnsureDir(stagingRoot); err != nil { - return nil, nil, err + return "", nil, err } - dir, err := os.MkdirTemp(stagingRoot, "oci-archive-*") + dir, err := os.MkdirTemp(stagingRoot, "image-archive-*") if err != nil { - return nil, nil, fmt.Errorf("create OCI archive staging: %w", err) + 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, nil, errors.Join(err, cleanup()) + return "", nil, errors.Join(err, cleanup()) } - source, err := NewLayoutWithLimits(dir, limits) - if err != nil { - return nil, nil, errors.Join(err, cleanup()) - } - return source, cleanup, nil + return dir, cleanup, nil } func extractArchive(path, destination string) error { - return extractArchiveContext(context.TODO(), path, destination, DefaultLimits().ArchiveSize) + return extractArchiveContext(context.TODO(), path, destination, images.DefaultLimits().ArchiveSize) } func extractArchiveContext(ctx context.Context, path, destination string, limit int64) (returnErr error) { @@ -69,7 +77,7 @@ func extractArchiveContext(ctx context.Context, path, destination string, limit if len(magic) == 2 && magic[0] == 0x1f && magic[1] == 0x8b { decoder, err := gzip.NewReader(buffered) if err != nil { - return invalidSource("open compressed OCI archive: %v", err) + return invalidSource("open compressed image archive: %v", err) } defer func() { returnErr = errors.Join(returnErr, decoder.Close()) }() source = decoder @@ -82,7 +90,7 @@ func extractArchiveContext(ctx context.Context, path, destination string, limit return err } if count >= maxArchiveEntries { - return invalidSource("OCI archive entry count exceeds limit") + return invalidSource("image archive entry count exceeds limit") } header, err := reader.Next() if errors.Is(err, io.EOF) { @@ -90,26 +98,26 @@ func extractArchiveContext(ctx context.Context, path, destination string, limit return err } if bounded.N == 0 { - return invalidSource("unpacked OCI archive exceeds %d bytes", limit) + return invalidSource("unpacked image archive exceeds %d bytes", limit) } return nil } if err != nil { - return invalidSource("read OCI archive: %v", err) + 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 OCI archive path %q", header.Name) + 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("OCI archive exceeds size limit") + return invalidSource("image archive exceeds size limit") } total += header.Size switch header.Typeflag { @@ -131,10 +139,10 @@ func extractArchiveContext(ctx context.Context, path, destination string, limit } _, copyErr := io.CopyN(output, reader, header.Size) if err := errors.Join(copyErr, output.Close(), root.Close()); err != nil { - return fmt.Errorf("extract OCI archive file: %w", err) + return fmt.Errorf("extract image archive file: %w", err) } default: - return invalidSource("unsupported OCI archive entry %q type %d", header.Name, header.Typeflag) + return invalidSource("unsupported image archive entry %q type %d", header.Name, header.Typeflag) } } } diff --git a/images/oci/archive_test.go b/images/source/archive_test.go similarity index 72% rename from images/oci/archive_test.go rename to images/source/archive_test.go index 1c2c414..c9b3c35 100644 --- a/images/oci/archive_test.go +++ b/images/source/archive_test.go @@ -1,11 +1,14 @@ -package oci +package source import ( "archive/tar" + "bytes" "fmt" "os" "path/filepath" "testing" + + "github.com/kumabox/kumabox/images" ) func TestExtractArchiveRejectsTraversal(t *testing.T) { @@ -32,15 +35,6 @@ func TestExtractArchiveRejectsTraversal(t *testing.T) { } } -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") - } -} - func TestArchiveRejectsLinksAndCleansFailedExtraction(t *testing.T) { for _, flag := range []byte{tar.TypeSymlink, tar.TypeLink} { t.Run(fmt.Sprint(flag), func(t *testing.T) { @@ -51,7 +45,7 @@ func TestArchiveRejectsLinksAndCleansFailedExtraction(t *testing.T) { t.Fatal(err) } staging := filepath.Join(root, "staging") - if _, _, err := NewArchiveContext(t.Context(), path, staging, DefaultLimits()); err == nil { + if _, _, err := NewArchiveContext(t.Context(), path, staging, images.DefaultLimits()); err == nil { t.Fatal("accepted archive link") } entries, err := os.ReadDir(staging) @@ -61,3 +55,23 @@ func TestArchiveRejectsLinksAndCleansFailedExtraction(t *testing.T) { }) } } + +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..1ec5680 --- /dev/null +++ b/images/source/docker.go @@ -0,0 +1,211 @@ +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" + "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" +) + +type dockerEntry struct { + Config string `json:"Config"` + RepoTags []string `json:"RepoTags"` + Layers []string `json:"Layers"` +} + +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 images.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 +} + +func selectDockerEntry(ctx context.Context, path string, platform images.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 +} + +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 +} + +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 +} + +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 := images.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 +} + +// 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. +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: types.OCIManifestSchema1, + Config: v1.Descriptor{MediaType: types.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}) +} + +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 := types.OCIUncompressedLayer + if len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b { + media = types.OCILayer + } else if len(magic) == 4 && bytes.Equal(magic, []byte{0x28, 0xb5, 0x2f, 0xfd}) { + media = types.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 +} + +type dockerImage struct { + config []byte + manifest []byte + layers map[v1.Hash]*fileLayer +} + +var _ partial.CompressedImageCore = (*dockerImage)(nil) + +func (i *dockerImage) MediaType() (types.MediaType, error) { return types.OCIManifestSchema1, nil } +func (i *dockerImage) RawConfigFile() ([]byte, error) { return bytes.Clone(i.config), nil } +func (i *dockerImage) RawManifest() ([]byte, error) { return bytes.Clone(i.manifest), nil } +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..c78e140 --- /dev/null +++ b/images/source/docker_test.go @@ -0,0 +1,329 @@ +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" +) + +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}}} + 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 images.Platform) (images.Manifest, [][]byte, error) { + manifest, err := source.Resolve(ctx, platform) + if err != nil { + return images.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 := images.Platform{OS: "linux", Architecture: "amd64"} + manifest, layers, err := readSourceLayers(t.Context(), source, platform) + if err != nil { + t.Fatal(err) + } + 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, images.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, images.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, images.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..c3ba3e1 --- /dev/null +++ b/images/source/local.go @@ -0,0 +1,183 @@ +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" + "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/kumabox/kumabox/images" +) + +type Format string + +const ( + FormatAuto Format = "auto" + FormatOCI Format = "oci" + FormatDocker Format = "docker" +) + +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) + } +} + +type LocalOptions struct { + Format Format + SourceTag string + 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. +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 +} + +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) + } +} + +// 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 Format + 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") +} + +// Local metadata is bounded before allocation; os.Root also contains concurrent path changes. +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 +} + +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 +} + +type localReader struct { + io.Reader + file *os.File + root *os.Root +} + +func (r *localReader) Close() error { return errors.Join(r.file.Close(), r.root.Close()) } + +type fileLayer struct { + path string + object string + descriptor v1.Descriptor + ctx context.Context +} + +var _ partial.CompressedLayer = (*fileLayer)(nil) + +func (l *fileLayer) Digest() (v1.Hash, error) { return l.descriptor.Digest, nil } +func (l *fileLayer) Size() (int64, error) { return l.descriptor.Size, nil } +func (l *fileLayer) MediaType() (types.MediaType, error) { return l.descriptor.MediaType, nil } +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..b123491 --- /dev/null +++ b/images/source/local_test.go @@ -0,0 +1,203 @@ +package source + +import ( + "context" + "errors" + "io/fs" + "maps" + "os" + "path/filepath" + "testing" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" +) + +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, images.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 := images.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, images.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/oci/layout.go b/images/source/oci.go similarity index 56% rename from images/oci/layout.go rename to images/source/oci.go index 21e130c..8270a74 100644 --- a/images/oci/layout.go +++ b/images/source/oci.go @@ -1,24 +1,26 @@ -package oci +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" "github.com/google/go-containerregistry/pkg/v1/types" "github.com/kumabox/kumabox/images" ) func NewLayout(path string) (images.Source, error) { - return NewLayoutWithLimits(path, DefaultLimits()) + return NewLayoutWithLimits(path, images.DefaultLimits()) } -func NewLayoutWithLimits(path string, limits Limits) (images.Source, error) { +func NewLayoutWithLimits(path string, limits images.Limits) (images.Source, error) { if !limits.Valid() { return nil, invalidSource("OCI size limits must be positive and bounded") } @@ -157,3 +159,117 @@ func platformCandidates(index v1.ImageIndex, platform images.Platform, depth int } return candidates, nil } + +func blobName(hash v1.Hash) (string, error) { + digest, err := images.ParseDigest(hash.String()) + if err != nil { + return "", invalidSource("invalid OCI blob digest: %v", err) + } + return "blobs/sha256/" + digest.Hex(), nil +} + +type localIndex struct { + path string + raw []byte + ctx context.Context +} + +func (i *localIndex) MediaType() (types.MediaType, error) { return types.OCIImageIndex, nil } +func (i *localIndex) Digest() (v1.Hash, error) { return partial.Digest(i) } +func (i *localIndex) Size() (int64, error) { return int64(len(i.raw)), nil } +func (i *localIndex) RawManifest() ([]byte, error) { return bytes.Clone(i.raw), nil } +func (i *localIndex) IndexManifest() (*v1.IndexManifest, error) { + return v1.ParseIndexManifest(bytes.NewReader(i.raw)) +} + +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) +} + +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}) +} + +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 +} + +type localImage struct { + path string + raw []byte + descriptor v1.Descriptor + ctx context.Context +} + +func (i *localImage) MediaType() (types.MediaType, error) { return i.descriptor.MediaType, nil } +func (i *localImage) RawManifest() ([]byte, error) { return bytes.Clone(i.raw), nil } +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) +} + +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/oci/registry.go b/images/source/registry.go similarity index 97% rename from images/oci/registry.go rename to images/source/registry.go index 912b680..99494ae 100644 --- a/images/oci/registry.go +++ b/images/source/registry.go @@ -1,4 +1,4 @@ -package oci +package source import ( "context" @@ -25,7 +25,7 @@ func NewRegistry(reference string) (images.Source, string, error) { if err != nil { return nil, "", errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, &safeRegistryError{cause: err, message: "invalid OCI registry reference"}) } - source := &resolvedSource{limits: DefaultLimits()} + source := &resolvedSource{limits: images.DefaultLimits()} source.resolve = func(ctx context.Context, platform images.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 { diff --git a/images/oci/source.go b/images/source/source.go similarity index 98% rename from images/oci/source.go rename to images/source/source.go index deede4b..a2f9e88 100644 --- a/images/oci/source.go +++ b/images/source/source.go @@ -1,4 +1,4 @@ -package oci +package source import ( "bufio" @@ -22,10 +22,6 @@ import ( const maxMetadataSize = 16 << 20 -type Limits = images.Limits - -func DefaultLimits() Limits { return images.DefaultLimits() } - type resolvedLayer struct { layer v1.Layer diffID images.Digest @@ -36,9 +32,11 @@ type resolvedSource struct { resolve func(context.Context, images.Platform) (v1.Image, error) mu sync.RWMutex layers map[images.Digest]resolvedLayer - limits Limits + limits images.Limits } +var _ images.Source = (*resolvedSource)(nil) + func (s *resolvedSource) Resolve(ctx context.Context, platform images.Platform) (images.Manifest, error) { if err := ctx.Err(); err != nil { return images.Manifest{}, err diff --git a/images/oci/source_test.go b/images/source/source_test.go similarity index 99% rename from images/oci/source_test.go rename to images/source/source_test.go index 979934e..5d905fe 100644 --- a/images/oci/source_test.go +++ b/images/source/source_test.go @@ -1,4 +1,4 @@ -package oci +package source import ( "archive/tar" @@ -117,7 +117,7 @@ func TestSourceChecksOCIObjects(t *testing.T) { } func TestSourceBoundsDecompressionAndCancellation(t *testing.T) { - limits := DefaultLimits() + limits := images.DefaultLimits() limits.UnpackedSize = 64 source, err := NewLayoutWithLimits(copyFixture(t), limits) if err != nil { diff --git a/images/verify.go b/images/verify.go index 04cb74c..7b759fb 100644 --- a/images/verify.go +++ b/images/verify.go @@ -4,13 +4,16 @@ import ( "context" "errors" "fmt" - "slices" "github.com/kumabox/kumabox/errdefs" filelock "github.com/kumabox/kumabox/lock/flock" ) -func Verify(ctx context.Context, paths Paths, catalog CatalogReader, reference string) (result Image, returnErr error) { +type ImageResolver interface { + Resolve(context.Context, string) (Image, error) +} + +func Verify(ctx context.Context, paths Paths, catalog ImageResolver, reference string) (result Image, returnErr error) { image, err := catalog.Resolve(ctx, reference) if err != nil { return Image{}, err @@ -35,7 +38,7 @@ func Verify(ctx context.Context, paths Paths, catalog CatalogReader, reference s } total += layer.Size } - boot, err := selectBoot(image.Layers) + boot, err := SelectBoot(image.Layers) if err != nil || boot != image.Boot || total != image.Size { return Image{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("image layer mapping or boot selection is inconsistent")) } @@ -76,7 +79,3 @@ func verifyFile(ctx context.Context, path string, expected Digest, expectedSize } return nil } - -func sameLayer(a, 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/metadata/collection.go b/metadata/collection.go deleted file mode 100644 index 777bd91..0000000 --- a/metadata/collection.go +++ /dev/null @@ -1,19 +0,0 @@ -package metadata - -import "fmt" - -// Collection identifies one fixed module-owned record set. -type Collection string - -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 -} -func (c Collection) String() string { return string(c) } diff --git a/metadata/memory_test.go b/metadata/memory_test.go deleted file mode 100644 index 04056b7..0000000 --- a/metadata/memory_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package metadata_test - -import ( - "testing" - - "github.com/kumabox/kumabox/metadata" - "github.com/kumabox/kumabox/metadata/metadatatest" -) - -func TestMemoryContract(t *testing.T) { - metadatatest.Run(t, func(t *testing.T, collections []metadata.Collection) metadata.Store { - store, err := metadata.NewMemory(collections) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := store.Close(); err != nil { - t.Error(err) - } - }) - return store - }) -} diff --git a/metadata/sqlite/store.go b/metadata/sqlite/store.go index 7b88f03..0464bf0 100644 --- a/metadata/sqlite/store.go +++ b/metadata/sqlite/store.go @@ -105,7 +105,7 @@ func (s *Store) View(ctx context.Context, fn func(metadata.Reader) error) error if err := fn(handle); err != nil { return errors.Join(err, rollback(tx)) } - return mapError(tx.Commit()) + return commit(ctx, tx) } func (s *Store) Update(ctx context.Context, fn func(metadata.Writer) error) error { @@ -118,7 +118,7 @@ func (s *Store) Update(ctx context.Context, fn func(metadata.Writer) error) erro if err := fn(handle); err != nil { return errors.Join(err, rollback(tx)) } - return mapError(tx.Commit()) + return commit(writeCtx, tx) } if ctx.Err() != nil { return ctx.Err() @@ -222,7 +222,7 @@ func initialize(ctx context.Context, path string, collections []metadata.Collect return mapError(err) } } - return mapError(tx.Commit()) + return commit(ctx, tx) } func dsn(path string, options Options, immediate bool) string { @@ -271,6 +271,21 @@ func busy(err error) bool { return code == modernclib.SQLITE_BUSY || code == modernclib.SQLITE_LOCKED } +// Cancellation can roll 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) +} + func rollback(tx *sql.Tx) error { err := tx.Rollback() if errors.Is(err, sql.ErrTxDone) { diff --git a/metadata/sqlite/store_test.go b/metadata/sqlite/store_test.go index 4766b6b..eac86c6 100644 --- a/metadata/sqlite/store_test.go +++ b/metadata/sqlite/store_test.go @@ -12,7 +12,6 @@ import ( "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/metadata" - "github.com/kumabox/kumabox/metadata/metadatatest" ) func TestStoreCommitRollbackAndDetachedReads(t *testing.T) { @@ -96,21 +95,6 @@ func TestStoreSerializesConcurrentWriters(t *testing.T) { } } -func TestStoreContract(t *testing.T) { - metadatatest.Run(t, func(t *testing.T, collections []metadata.Collection) metadata.Store { - store, err := Open(t.Context(), filepath.Join(t.TempDir(), "meta.db"), collections, DefaultOptions()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := store.Close(); err != nil { - t.Error(err) - } - }) - return store - }) -} - func TestStoreRejectsForeignDatabaseWithoutChangingJournal(t *testing.T) { path := filepath.Join(t.TempDir(), "meta.db") db, err := sql.Open("sqlite", path) @@ -194,3 +178,52 @@ func TestStoreBusyIsBoundedAcrossProcessesAndWithinPool(t *testing.T) { }) } } + +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/store.go b/metadata/store.go index 04bb7f6..f163b7f 100644 --- a/metadata/store.go +++ b/metadata/store.go @@ -1,6 +1,9 @@ package metadata -import "context" +import ( + "context" + "fmt" +) // Store is the engine-neutral metadata transaction boundary. type Store interface { @@ -21,3 +24,19 @@ type Writer interface { Put(context.Context, Collection, string, []byte) error Delete(context.Context, Collection, string) error } + +// Collection identifies one fixed module-owned record set. +type Collection string + +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 +} +func (c Collection) String() string { return string(c) } diff --git a/metadata/metadatatest/contract.go b/metadata/store_test.go similarity index 78% rename from metadata/metadatatest/contract.go rename to metadata/store_test.go index 195c12b..f430c7a 100644 --- a/metadata/metadatatest/contract.go +++ b/metadata/store_test.go @@ -1,19 +1,20 @@ -// Package metadatatest checks the transaction contract shared by metadata engines. -package metadatatest +package metadata_test import ( "context" "errors" "fmt" + "path/filepath" "sync" "testing" "github.com/kumabox/kumabox/metadata" + "github.com/kumabox/kumabox/metadata/sqlite" ) -type Factory func(*testing.T, []metadata.Collection) metadata.Store +type storeFactory func(*testing.T, []metadata.Collection) metadata.Store -func Run(t *testing.T, open Factory) { +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) { @@ -136,3 +137,38 @@ func Run(t *testing.T, open Factory) { } }) } + +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/doctor/check.sh b/scripts/kumabox-check.sh similarity index 98% rename from doctor/check.sh rename to scripts/kumabox-check.sh index f2d1d5f..5c2de4a 100755 --- a/doctor/check.sh +++ b/scripts/kumabox-check.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# doctor/check.sh — Pre-flight check and repair tool for KumaBox. +# scripts/kumabox-check.sh — Pre-flight check and repair tool for KumaBox. # # Usage: -# ./doctor/check.sh # Check only -# ./doctor/check.sh --fix # Check and fix issues -# ./doctor/check.sh --upgrade # Check, fix, and upgrade dependencies +# ./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 From 72953a1c7b0f65acf41194e3e6c6a67db4f3a9ab Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Mon, 14 Sep 2026 15:13:00 +0800 Subject: [PATCH 16/45] feat: improve image CLI output and progress reporting Add aligned image tables, indented JSON, and terminal-aware progress for import, pull, verification, and removal. Document module responsibilities, API contracts, resource ownership, and workflow boundaries with ASCII diagrams. --- README.md | 14 ++ cli/doctor/command.go | 17 ++- cli/image/command.go | 6 + cli/image/command_test.go | 23 +++ cli/image/import.go | 35 ++++- cli/image/output.go | 114 ++++++++++---- cli/image/output_test.go | 84 +++++++++++ cli/image/progress.go | 262 +++++++++++++++++++++++++++++++++ cli/image/progress_test.go | 216 +++++++++++++++++++++++++++ cli/image/query.go | 27 ++-- cli/image/remove.go | 16 +- cli/root.go | 28 +++- cmd/kumabox/main.go | 2 + core/images.go | 28 +++- errdefs/error.go | 69 ++++++--- go.mod | 2 +- images/artifacts.go | 37 ++++- images/boot.go | 14 +- images/catalog.go | 32 +++- images/catalog/store.go | 116 ++++++++++++--- images/erofs/convert.go | 44 +++++- images/image.go | 89 ++++++++--- images/import.go | 111 +++++++++++--- images/remove.go | 9 ++ images/source/archive.go | 28 +++- images/source/docker.go | 46 +++++- images/source/local.go | 63 ++++++-- images/source/oci.go | 68 +++++++-- images/source/registry.go | 42 +++++- images/source/source.go | 79 ++++++++-- images/verify.go | 8 + lock/flock/lock.go | 33 ++++- lock/flock/set.go | 9 +- metadata/memory.go | 30 +++- metadata/sqlite/store.go | 53 ++++++- metadata/sqlite/transaction.go | 14 +- metadata/store.go | 20 ++- storage/publish.go | 8 + storage/roots.go | 29 +++- version/version.go | 7 +- 40 files changed, 1714 insertions(+), 218 deletions(-) create mode 100644 cli/image/output_test.go create mode 100644 cli/image/progress.go create mode 100644 cli/image/progress_test.go diff --git a/README.md b/README.md index 1104946..3fa0bd9 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ local import), `query.go` (list, inspect and verify), and `remove.go`. Related types, interfaces and methods stay together; files are not split by declaration kind. Interfaces describe the operations needed by their consumers. +Document each package's responsibility in an existing source file. Exported APIs, +key types and fields, and complex private methods need comments explaining their +contracts, units, ownership, and failure boundaries. Keep comments in English and +use indented ASCII diagrams near workflows where ordering, locking, or commit +boundaries matter. Update these comments whenever the behavior changes. + Tests live in their owning directories as `*_test.go`. The shared memory/SQLite transaction contract is exercised in `metadata/store_test.go`; there is no production package for test helpers. Image workflow integration tests use the @@ -94,6 +100,14 @@ kumabox image verify tiny kumabox image rm tiny ``` +`image ls` prints a table with names, 12-character image IDs, platforms, +human-readable sizes, and creation timestamps in UTC. `image inspect` and +`image ls --json` print indented JSON with full digests and numeric sizes. +Import and pull show a live spinner and completed layer counts on a terminal. +Verification and removal also show waiting status; removal reports completed +image counts. Redirected progress uses plain lines on stderr. Results are +written to stdout. + `image import NAME PATH` detects the format from source contents by default. It accepts OCI layout directories, OCI archives, and `docker save` archives; archives can be plain tar or gzip, regardless of their filename extension. diff --git a/cli/doctor/command.go b/cli/doctor/command.go index aa03f89..2716193 100644 --- a/cli/doctor/command.go +++ b/cli/doctor/command.go @@ -10,19 +10,30 @@ import ( "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 error + // 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 } -func (e *processError) Silent() bool { return true } -// NewCommand returns the doctor command. Flag parsing belongs to check.sh, so +// 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{ diff --git a/cli/image/command.go b/cli/image/command.go index 80b644b..3fa8f1e 100644 --- a/cli/image/command.go +++ b/cli/image/command.go @@ -1,3 +1,5 @@ +// 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 ( @@ -12,8 +14,10 @@ import ( "github.com/kumabox/kumabox/storage" ) +// rootsProvider defers reading storage roots until command flags have been parsed. type rootsProvider func() storage.Roots +// NewCommand registers the image command tree using invocation-local storage roots. func NewCommand(roots rootsProvider) *cobra.Command { command := &cobra.Command{Use: "image", Short: "manage container images", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { return command.Help() }} command.AddCommand( @@ -27,6 +31,7 @@ func NewCommand(roots rootsProvider) *cobra.Command { return command } +// parsePlatform rejects targets unsupported by the Linux image conversion pipeline. func parsePlatform(value string) (images.Platform, error) { parts := strings.Split(value, "/") if len(parts) != 2 || parts[0] != "linux" || (parts[1] != "amd64" && parts[1] != "arm64") { @@ -35,4 +40,5 @@ func parsePlatform(value string) (images.Platform, error) { return images.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 index 5621e95..be9732f 100644 --- a/cli/image/command_test.go +++ b/cli/image/command_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/google/go-containerregistry/pkg/name" @@ -106,6 +107,28 @@ func TestImageCommandsFromLayoutAndArchive(t *testing.T) { 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) } diff --git a/cli/image/import.go b/cli/image/import.go index 8204a89..3c15606 100644 --- a/cli/image/import.go +++ b/cli/image/import.go @@ -8,6 +8,8 @@ import ( "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(roots rootsProvider) *cobra.Command { platform := defaultPlatform() command := &cobra.Command{ @@ -23,26 +25,40 @@ func newPullCommand(roots rootsProvider) *cobra.Command { 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(), roots()) if err != nil { return err } defer func() { returnErr = errors.Join(returnErr, state.Close()) }() - importer, err := core.NewImageImporter(command.Context(), state, textReporter{writer: command.ErrOrStderr()}, parsedPlatform) + 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(command.OutOrStdout(), image) + 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(roots rootsProvider) *cobra.Command { platform := defaultPlatform() format := "auto" @@ -65,25 +81,36 @@ func newImportCommand(roots rootsProvider) *cobra.Command { 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(), roots()) 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, textReporter{writer: command.ErrOrStderr()}, parsedPlatform) + 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(command.OutOrStdout(), image) + return writeImage(progress.Output(command.OutOrStdout()), image) }, } command.Flags().StringVar(&platform, "platform", platform, "target platform (linux/amd64 or linux/arm64)") diff --git a/cli/image/output.go b/cli/image/output.go index a993300..4ca0145 100644 --- a/cli/image/output.go +++ b/cli/image/output.go @@ -1,52 +1,82 @@ package image import ( + "encoding/json" "fmt" "io" "strings" + "text/tabwriter" "time" "github.com/kumabox/kumabox/images" ) -// CLI serialization belongs to the command adapter, not image domain 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 []string `json:"names"` - ManifestDigest string `json:"manifest_digest"` - Platform platformOutput `json:"platform"` - Layers []layerOutput `json:"layers"` - Boot bootOutput `json:"boot"` - Size int64 `json:"size"` - CreatedAt time.Time `json:"created_at"` + // 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 string `json:"os"` + // 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 string `json:"source_digest"` - EROFSDigest string `json:"erofs_digest"` - Size int64 `json:"size"` - BootFiles []bootFileOutput `json:"boot_files"` - Whiteouts []string `json:"whiteouts,omitempty"` - BootOpaque bool `json:"boot_opaque,omitempty"` + // 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 string `json:"name"` + // Name is the boot candidate filename within the layer. + Name string `json:"name"` + // Digest identifies the extracted file contents. Digest string `json:"digest"` - Size int64 `json:"size"` + // 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 { + // KernelLayer is the source digest of the layer providing the selected kernel. KernelLayer string `json:"kernel_layer"` - KernelFile string `json:"kernel_file"` + // 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 string `json:"initrd_file"` + // 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 images.Image) imageOutput { layers := make([]layerOutput, 0, len(image.Layers)) for _, layer := range image.Layers { @@ -59,18 +89,52 @@ func imageResult(image images.Image) imageOutput { return imageOutput{Names: image.Names, ManifestDigest: image.ManifestDigest.String(), Platform: platformOutput{OS: image.Platform.OS, Architecture: image.Platform.Architecture}, Layers: layers, Boot: bootOutput{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 images.Image) error { _, err := fmt.Fprintf(writer, "%s\t%s\n", strings.Join(image.Names, ","), image.ManifestDigest) return err } -type textReporter struct { - writer io.Writer +// 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) } -func (r textReporter) Layer(position, total int, digest images.Digest) error { - _, err := fmt.Fprintf(r.writer, "layer %d/%d %s\n", position+1, total, digest) - return err +// 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 []images.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() } -func (r textReporter) Committed(images.Image) error { return nil } +// 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..af840bf --- /dev/null +++ b/cli/image/output_test.go @@ -0,0 +1,84 @@ +package image + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" + + "github.com/kumabox/kumabox/images" +) + +func TestImagesTableHeadersAndAlignedRows(t *testing.T) { + first, err := images.ParseDigest("sha256:" + strings.Repeat("a", 64)) + if err != nil { + t.Fatal(err) + } + second, err := images.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 := []images.Image{ + { + Names: []string{"demo", "demo-alias-with-a-long-name"}, ManifestDigest: first, + Platform: images.Platform{OS: "linux", Architecture: "amd64"}, Size: 127600000, CreatedAt: created, + }, + {ManifestDigest: second, Platform: images.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..78b09ac --- /dev/null +++ b/cli/image/progress.go @@ -0,0 +1,262 @@ +package image + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "sync" + "time" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" +) + +// imageProgress serializes terminal presentation and implements images.Reporter. +// Counts reflect completed work; the animation indicates activity, not a percentage. +// The owning command calls Finish after source and store cleanup. +// +// start --> status/layer callbacks --> commit --> cleanup --> Finish +// | | | | +// +--> ticker --+--> serialized stderr frames <-----+ | +// | | +// +<-- cancellation or stop <-------------------------+ +// closes done; Finish joins before final output +type imageProgress struct { + // mu protects mutable state and serializes ticker, callbacks, and result writes. + mu sync.Mutex + // writer receives progress on stderr, independently of command results on stdout. + writer io.Writer + // animated enables terminal redraws; redirected streams receive plain lines. + animated bool + // label identifies the operation and quoted image reference. + label string + // status describes the current stage, including waits without measurable progress. + status string + // completed counts successful callbacks, including out-of-order layer completions. + completed int + // total is the known layer or image count; zero means it is not yet available. + total int + // unit labels the count as layers or images. + unit string + // committed records that persistent state changed even if reporting later fails. + committed bool + // frame selects the next activity glyph without implying a completion percentage. + frame int + // err retains the first rendering failure so later callbacks cannot hide it. + err error + // stopOnce makes shutdown safe when command cleanup and test cleanup both join. + stopOnce sync.Once + // stop requests ticker shutdown; stopAnimation owns closing it. + stop chan struct{} + // done signals goroutine exit, or is closed immediately when animation is disabled. + done chan struct{} +} + +var _ images.Reporter = (*imageProgress)(nil) + +// startImageProgress animates only actual terminal stderr, keeping redirected logs plain. +func startImageProgress(command *cobra.Command, operation, reference string) (*imageProgress, error) { + writer := command.ErrOrStderr() + file, ok := writer.(*os.File) + animated := ok && isatty.IsTerminal(file.Fd()) + return newImageProgress(command.Context(), writer, fmt.Sprintf("%s %q", operation, reference), animated) +} + +// newImageProgress writes the initial stage before starting any animation goroutine. +// If initialization fails, no goroutine or shutdown responsibility escapes to the caller. +func newImageProgress(ctx context.Context, writer io.Writer, label string, animated bool) (*imageProgress, error) { + p := &imageProgress{ + writer: writer, animated: animated, label: label, + status: "preparing image", unit: "layers", stop: make(chan struct{}), done: make(chan struct{}), + } + if err := p.render(); err != nil { + return nil, err + } + if animated { + go p.animate(ctx) + } else { + close(p.done) + } + return p, nil +} + +// animate owns the ticker and closes done on cancellation, shutdown, or write failure. +// Each frame shares the same lock as worker callbacks and command result writes. +func (p *imageProgress) animate(ctx context.Context) { + defer close(p.done) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-p.stop: + return + case <-ticker.C: + p.mu.Lock() + if p.err != nil { + p.mu.Unlock() + return + } + p.err = p.render() + failed := p.err != nil + p.mu.Unlock() + if failed { + return + } + } + } +} + +// Status updates the visible stage and returns any retained rendering failure. +func (p *imageProgress) Status(status string) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return p.err + } + p.status = status + p.err = p.render() + return p.err +} + +// Layer records a completed conversion; position is the zero-based source order. +// Completion count is independent of position because workers may finish out of order. +func (p *imageProgress) Layer(position, total int, digest images.Digest) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return p.err + } + p.completed++ + p.total = total + if p.completed == p.total { + p.status = "publishing image" + } + if p.animated { + p.err = p.render() + } else { + _, p.err = fmt.Fprintf(p.writer, "Layer %d/%d %s complete\n", position+1, total, digest.Hex()[:12]) + } + return p.err +} + +// Committed records durable import completion while keeping animation alive for cleanup. +// It preserves reporting failures so the command can distinguish committed-with-error state. +func (p *imageProgress) Committed(images.Image) error { + p.mu.Lock() + defer p.mu.Unlock() + p.committed = true + p.status = "finishing" + return p.err +} + +// Removed records one successful deletion and switches the completion unit to images. +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.err != nil { + return p.err + } + if p.animated { + p.err = p.render() + } + return p.err +} + +// Output clears the current frame around result writes, then resumes the spinner. +func (p *imageProgress) Output(writer io.Writer) io.Writer { + return progressOutput{progress: p, writer: writer} +} + +// progressOutput coordinates result writes with an active stderr animation. +type progressOutput struct { + // progress owns the shared rendering lock and current animation state. + progress *imageProgress + // writer receives result bytes unchanged, normally on stdout. + writer io.Writer +} + +// Write clears and restores the animation around one result write under the shared lock. +// It propagates both result-stream and redraw errors without changing result bytes. +func (w progressOutput) Write(data []byte) (int, error) { + p := w.progress + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return 0, p.err + } + if p.animated { + if _, err := fmt.Fprint(p.writer, "\r\x1b[2K"); err != nil { + p.err = err + return 0, err + } + } + n, err := w.writer.Write(data) + if p.animated { + p.err = p.render() + } + return n, errors.Join(err, p.err) +} + +// stopAnimation requests shutdown and joins without holding mu, avoiding ticker deadlock. +func (p *imageProgress) stopAnimation() { + p.stopOnce.Do(func() { close(p.stop); <-p.done }) +} + +// Finish stops and joins the animation before writing the final line. +// The command calls it once, after its source and store cleanup have finished. +func (p *imageProgress) Finish(operationErr error) error { + p.stopAnimation() + p.mu.Lock() + defer p.mu.Unlock() + var classified *errdefs.Error + if errors.As(operationErr, &classified) && classified.Committed { + p.committed = true + } + result := "complete" + symbol := "✓" + if operationErr != nil || p.err != nil { + result, symbol = "failed", "✗" + if p.committed { + result = "committed with errors" + } else if errors.Is(operationErr, context.Canceled) { + result = "canceled" + } + } + message := fmt.Sprintf("%s %s", p.label, result) + if p.total > 0 { + message += fmt.Sprintf(" (%d/%d %s)", p.completed, p.total, p.unit) + } + if p.animated { + message = "\r\x1b[2K" + symbol + " " + message + } + _, err := fmt.Fprintln(p.writer, message) + return errdefs.Context(errors.Join(p.err, err), "image operation", p.label, "report", "check image state with image inspect", p.committed) +} + +// render emits one terminal frame or plain stage line. +// The caller holds mu once the animation has started. +func (p *imageProgress) render() error { + message := p.label + " · " + p.status + if p.total > 0 { + message += fmt.Sprintf(" (%d/%d %s)", p.completed, p.total, p.unit) + } + if p.animated { + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + _, err := fmt.Fprintf(p.writer, "\r\x1b[2K%s %s", frames[p.frame%len(frames)], message) + p.frame++ + return err + } + _, err := fmt.Fprintln(p.writer, message) + return err +} diff --git a/cli/image/progress_test.go b/cli/image/progress_test.go new file mode 100644 index 0000000..9038c54 --- /dev/null +++ b/cli/image/progress_test.go @@ -0,0 +1,216 @@ +package image + +import ( + "bytes" + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" +) + +func TestProgressLogsHaveNoAnimationControls(t *testing.T) { + var out bytes.Buffer + p, err := newImageProgress(t.Context(), &out, `Import "demo"`, false) + if err != nil { + t.Fatal(err) + } + if err := p.Status("converting layers"); err != nil { + t.Fatal(err) + } + if err := p.Layer(1, 2, images.Digest{}); err != nil { + t.Fatal(err) + } + if err := p.Layer(0, 2, images.Digest{}); err != nil { + t.Fatal(err) + } + if err := p.Committed(images.Image{}); err != nil { + t.Fatal(err) + } + if err := p.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()) + } +} + +// Observe real ticker writes without sleeping or reading a buffer concurrently. +type observedProgressWriter struct { + mu sync.Mutex + out bytes.Buffer + writes int + changed chan struct{} + failAt int + failure error +} + +func (w *observedProgressWriter) 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.out.Write(data) +} + +func (w *observedProgressWriter) snapshot() (string, int) { + w.mu.Lock() + defer w.mu.Unlock() + return w.out.String(), w.writes +} + +func waitProgressDone(t *testing.T, p *imageProgress) { + t.Helper() + select { + case <-p.done: + case <-time.After(2 * time.Second): + t.Fatal("animation did not stop") + } +} + +func TestProgressAnimatesAndCountsConcurrentCompletedLayers(t *testing.T) { + writer := &observedProgressWriter{changed: make(chan struct{}, 1)} + p, err := newImageProgress(t.Context(), writer, `Import "demo"`, true) + if err != nil { + t.Fatal(err) + } + t.Cleanup(p.stopAnimation) + timeout := time.NewTimer(2 * time.Second) + defer timeout.Stop() + for { + if _, writes := writer.snapshot(); writes >= 2 { + break + } + select { + case <-writer.changed: + case <-timeout.C: + t.Fatal("spinner did not advance while waiting") + } + } + if err := p.Status("converting layers"); 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 := p.Layer(position, 3, images.Digest{}); err != nil { + t.Error(err) + } + }() + } + wait.Wait() + if err := p.Committed(images.Image{}); err != nil { + t.Fatal(err) + } + if err := p.Finish(nil); err != nil { + t.Fatal(err) + } + out, _ := writer.snapshot() + if !strings.Contains(out, "⠋") || !strings.Contains(out, "⠙") || !strings.Contains(out, "publishing image (3/3 layers)") { + t.Fatalf("animation or completion counts missing: %q", out) + } + if !strings.HasSuffix(out, "✓ Import \"demo\" complete (3/3 layers)\n") { + t.Fatalf("final status = %q", out) + } +} + +func TestProgressStopsOnCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + writer := &observedProgressWriter{changed: make(chan struct{}, 1)} + p, err := newImageProgress(ctx, writer, `Pull "demo"`, true) + if err != nil { + t.Fatal(err) + } + t.Cleanup(p.stopAnimation) + cancel() + waitProgressDone(t, p) + if err := p.Finish(ctx.Err()); err != nil { + t.Fatal(err) + } + out, _ := writer.snapshot() + if !strings.HasSuffix(out, "✗ Pull \"demo\" canceled\n") { + t.Fatalf("canceled status = %q", out) + } +} + +func TestProgressRetainsAnimationFailureAfterCommit(t *testing.T) { + failure := errors.New("terminal write failed") + writer := &observedProgressWriter{changed: make(chan struct{}, 1), failAt: 2, failure: failure} + p, err := newImageProgress(t.Context(), writer, `Import "demo"`, true) + if err != nil { + t.Fatal(err) + } + t.Cleanup(p.stopAnimation) + waitProgressDone(t, p) + if err := p.Status("converting layers"); !errors.Is(err, failure) { + t.Fatalf("status error = %v", err) + } + if err := p.Committed(images.Image{}); !errors.Is(err, failure) { + t.Fatalf("commit report error = %v", err) + } + err = p.Finish(failure) + var classified *errdefs.Error + if !errors.Is(err, failure) || !errors.As(err, &classified) || !classified.Committed { + t.Fatalf("final report error = %v", err) + } + out, _ := writer.snapshot() + if !strings.Contains(out, "committed with errors") { + t.Fatalf("committed error status = %q", out) + } +} + +func TestProgressKeepsResultsSeparateFromLiveFrames(t *testing.T) { + writer := &observedProgressWriter{changed: make(chan struct{}, 1)} + p, err := newImageProgress(t.Context(), writer, `Verify "demo"`, true) + if err != nil { + t.Fatal(err) + } + t.Cleanup(p.stopAnimation) + if _, err := p.Output(writer).Write([]byte("verified sha256:example\n")); err != nil { + t.Fatal(err) + } + if err := p.Finish(nil); err != nil { + t.Fatal(err) + } + out, _ := writer.snapshot() + if !strings.Contains(out, "\r\x1b[2Kverified sha256:example\n\r\x1b[2K") { + t.Fatalf("result was not separated from the live spinner: %q", out) + } + waitProgressDone(t, p) +} + +func TestRemovalProgressCountsCompletedImages(t *testing.T) { + var out bytes.Buffer + p, err := newImageProgress(t.Context(), &out, `Remove "demo, alias"`, false) + if err != nil { + t.Fatal(err) + } + if err := p.Removed(2); err != nil { + t.Fatal(err) + } + if err := p.Removed(2); err != nil { + t.Fatal(err) + } + if err := p.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 index a71a8e0..388e696 100644 --- a/cli/image/query.go +++ b/cli/image/query.go @@ -1,10 +1,8 @@ package image import ( - "encoding/json" "errors" "fmt" - "strings" "github.com/spf13/cobra" @@ -12,6 +10,7 @@ import ( "github.com/kumabox/kumabox/images" ) +// newListCommand renders catalog entries as an aligned table or detailed JSON. func newListCommand(roots rootsProvider) *cobra.Command { asJSON := false command := &cobra.Command{ @@ -34,20 +33,16 @@ func newListCommand(roots rootsProvider) *cobra.Command { for _, item := range items { results = append(results, imageResult(item)) } - return json.NewEncoder(command.OutOrStdout()).Encode(results) + return writeJSON(command.OutOrStdout(), results) } - for _, item := range items { - if _, err := fmt.Fprintf(command.OutOrStdout(), "%s\t%s\t%d\n", strings.Join(item.Names, ","), item.ManifestDigest, item.Size); err != nil { - return err - } - } - return nil + 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(roots rootsProvider) *cobra.Command { return &cobra.Command{ Use: "inspect IMAGE", @@ -63,17 +58,27 @@ func newInspectCommand(roots rootsProvider) *cobra.Command { if err != nil { return err } - return json.NewEncoder(command.OutOrStdout()).Encode(imageResult(image)) + 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(roots rootsProvider) *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(), roots()) if err != nil { return err @@ -83,7 +88,7 @@ func newVerifyCommand(roots rootsProvider) *cobra.Command { if err != nil { return err } - _, err = fmt.Fprintf(command.OutOrStdout(), "verified %s\n", image.ManifestDigest) + _, 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 index 1acb028..e4da951 100644 --- a/cli/image/remove.go +++ b/cli/image/remove.go @@ -12,6 +12,9 @@ import ( "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(roots rootsProvider) *cobra.Command { return &cobra.Command{ Use: "remove IMAGE...", @@ -19,17 +22,28 @@ func newRemoveCommand(roots rootsProvider) *cobra.Command { 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(), roots()) 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 := fmt.Fprintf(command.OutOrStdout(), "removed %s\n", strings.Join(removed.Names, ",")); err != nil { + 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) } } diff --git a/cli/root.go b/cli/root.go index fd3720c..2e62088 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1,4 +1,5 @@ -// Package cli builds the kumabox command tree. +// Package cli builds the kumabox command tree and maps command failures to exit statuses. +// Commands receive explicit streams and storage roots for independent invocations. package cli import ( @@ -16,26 +17,41 @@ import ( "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 error + // 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. +// 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 := newRootCommand() root.SetArgs(args) @@ -75,6 +91,8 @@ func Silent(err error) bool { return errors.As(err, &silent) && silent.Silent() } +// newRootCommand creates invocation-local flags and registers the command modules. +// The roots callback observes values after Cobra has parsed persistent flags. func newRootCommand() *cobra.Command { roots := storage.DefaultRoots() root := &cobra.Command{ @@ -99,6 +117,7 @@ func newRootCommand() *cobra.Command { return root } +// 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 { @@ -108,6 +127,7 @@ func usageArgs(validate cobra.PositionalArgs) cobra.PositionalArgs { } } +// classifyArguments applies usage classification throughout the registered command tree. func classifyArguments(command *cobra.Command) { if command.Args != nil { command.Args = usageArgs(command.Args) @@ -117,6 +137,7 @@ func classifyArguments(command *cobra.Command) { } } +// errorExitCode groups domain error codes into the CLI's documented failure statuses. func errorExitCode(err error) int { code, ok := errdefs.CodeOf(err) if !ok { @@ -136,6 +157,7 @@ func errorExitCode(err error) int { } } +// newVersionCommand exposes build metadata as human-readable text or JSON. func newVersionCommand() *cobra.Command { var asJSON bool command := &cobra.Command{ diff --git a/cmd/kumabox/main.go b/cmd/kumabox/main.go index b5aeb0d..3398842 100644 --- a/cmd/kumabox/main.go +++ b/cmd/kumabox/main.go @@ -15,6 +15,8 @@ import ( "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) err := cli.Execute(ctx, os.Args[1:], os.Stdout, os.Stderr) diff --git a/core/images.go b/core/images.go index ded86ef..e7c5a92 100644 --- a/core/images.go +++ b/core/images.go @@ -1,4 +1,13 @@ // Package core assembles application modules and their concrete dependencies. +// It owns resource construction; image policies and workflows remain in images. +// +// Image command assembly: +// +// storage.Roots --> images.Paths -----------+ +// | | +// +--> SQLite --> catalog --> ImageStore +// | +// source + EROFS + reporter +--> images.Importer package core import ( @@ -16,11 +25,16 @@ import ( // ImageStore owns the resources assembled for one image command. // Call Close after using its catalog and managed artifact paths. type ImageStore struct { - Paths images.Paths + // 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 - store metadata.Store + // 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, roots storage.Roots) (*ImageStore, error) { paths, err := images.NewPaths(roots) if err != nil { @@ -36,6 +50,7 @@ func OpenImages(ctx context.Context, roots storage.Roots) (*ImageStore, error) { return &ImageStore{Paths: paths, Catalog: catalog.New(store), 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. @@ -50,16 +65,21 @@ func NewImageImporter(ctx context.Context, store *ImageStore, reporter images.Re // LocalImageOptions selects a local source without exposing adapter types to callers. type LocalImageOptions struct { - Format string + // 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 returns a source and the cleanup required for staged archives. +// 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 { diff --git a/errdefs/error.go b/errdefs/error.go index 621a9d9..ad7aeda 100644 --- a/errdefs/error.go +++ b/errdefs/error.go @@ -1,3 +1,5 @@ +// Package errdefs carries stable failure codes, handling classes, and operation +// context across module boundaries while preserving the original error chain. package errdefs import ( @@ -9,44 +11,73 @@ import ( type Code string const ( - CodeNotFound Code = "NOT_FOUND" - CodeNameTaken Code = "NAME_TAKEN" - CodeInvalidArgument Code = "INVALID_ARGUMENT" - CodeHostIncompatible Code = "HOST_INCOMPATIBLE" - CodeDigestMismatch Code = "IMAGE_DIGEST_MISMATCH" - CodeArtifactCorrupt Code = "ARTIFACT_CORRUPT" + // 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" + // 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" + // 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 Code = "REFERENCED" - CodeStoreBusy Code = "STORE_BUSY" - CodeInternal Code = "INTERNAL" + // 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 Class - Code Code + // 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 string - Phase 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 - Retry bool - Action string - Cause error + // Retry is an optional producer hint that another attempt may succeed. + Retry bool + // Action suggests a recovery step for the caller. + Action string + // Cause preserves underlying failures for errors.Is and errors.As. + Cause 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 "" @@ -70,6 +101,7 @@ func (e *Error) Error() string { return message } +// Unwrap exposes the cause to standard error-chain inspection, including nil receivers. func (e *Error) Unwrap() error { if e == nil { return nil @@ -85,7 +117,9 @@ func New(class Class, code Code, cause error) *Error { return &Error{Class: class, Code: code, Cause: cause} } -// Context adds operation context without changing an existing classification. +// 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 @@ -117,6 +151,7 @@ func CodeOf(err error) (Code, bool) { 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 diff --git a/go.mod b/go.mod index 67e0e82..64af5b5 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( 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/spf13/cobra v1.10.2 golang.org/x/sync v0.15.0 modernc.org/sqlite v1.38.2 @@ -19,7 +20,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // 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/mitchellh/go-homedir v1.1.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect diff --git a/images/artifacts.go b/images/artifacts.go index 2837498..ab25382 100644 --- a/images/artifacts.go +++ b/images/artifacts.go @@ -13,10 +13,15 @@ import ( "github.com/kumabox/kumabox/storage" ) +// 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 { @@ -25,6 +30,7 @@ func NewPaths(roots storage.Roots) (Paths, error) { 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 { @@ -34,21 +40,32 @@ func (p Paths) Ensure() error { 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") } -func (p Paths) StagingDir() string { return filepath.Join(p.roots.Data, "staging", "imports") } -func (p Paths) LocksDir() string { return filepath.Join(p.roots.Run, "locks", "images") } -func (p Paths) MetadataDB() string { return filepath.Join(p.roots.Data, "meta", "meta.db") } +// 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 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 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 Digest, name string) (string, error) { if !IsBootName(name) { return "", fmt.Errorf("invalid boot artifact name %q", name) @@ -56,11 +73,16 @@ func (p Paths) BootFile(digest Digest, name string) (string, error) { 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 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 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 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 @@ -72,6 +94,8 @@ func (p Paths) NewStaging(pattern string) (string, error) { 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) (Digest, int64, error) { if err := storage.CheckPath(path); err != nil { return Digest{}, 0, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err) @@ -101,11 +125,16 @@ func digestFileContext(ctx context.Context, path string) (Digest, int64, error) 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 context.Context + // 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 diff --git a/images/boot.go b/images/boot.go index 176c96d..ffea198 100644 --- a/images/boot.go +++ b/images/boot.go @@ -8,16 +8,28 @@ import ( "github.com/kumabox/kumabox/errdefs" ) +// 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 []Layer) (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 Digest - file BootFile + // file supplies the basename and integrity facts selected for boot. + file BootFile } var candidates []candidate for _, layer := range layers { diff --git a/images/catalog.go b/images/catalog.go index 9c7bcb1..0c0a8a3 100644 --- a/images/catalog.go +++ b/images/catalog.go @@ -6,31 +6,51 @@ import ( "time" ) +// 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 string + // 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 Manifest - Layers []Layer - Boot Boot - Size int64 - Created time.Time + // Layers contains converted metadata in the same order as Manifest.Layers. + Layers []Layer + // Boot must equal the overlay-aware selection derived from Layers. + Boot 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 []string + // Names contains aliases deleted by the catalog transaction. + Names []string + // Layers contains source digests no longer referenced by any registered image. Layers []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) (Image, error) + // List returns committed images with their aliases and ordered layers. List(context.Context) ([]Image, error) + // FindLayers returns committed mappings and rejects conflicting shared artifacts. FindLayers(context.Context, []Digest) (map[Digest]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, Digest) (Removal, error) } +// Catalog combines the metadata contracts used by image management commands. type Catalog interface { CatalogReader CatalogWriter diff --git a/images/catalog/store.go b/images/catalog/store.go index e8c04e9..fa56e9f 100644 --- a/images/catalog/store.go +++ b/images/catalog/store.go @@ -1,4 +1,6 @@ -// Package catalog persists image identities, names and layer references. +// 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 ( @@ -18,55 +20,91 @@ import ( var _ images.Catalog = (*Store)(nil) const ( - CollectionImages metadata.Collection = "images" - CollectionNames metadata.Collection = "image_names" - CollectionLayers metadata.Collection = "image_layers" - minimumDigestPrefix = 12 + // 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 } +// New creates an adapter for a non-nil metadata store whose schema includes +// Collections. The caller retains ownership of the store's lifetime. func New(store metadata.Store) *Store { return &Store{store: store} } +// imageRecord stores manifest-wide facts separately from aliases and layer order. type imageRecord struct { - ManifestDigest string `json:"manifest_digest"` - OS string `json:"os"` - Architecture string `json:"architecture"` - KernelLayer string `json:"kernel_layer"` - KernelFile string `json:"kernel_file"` - InitrdFile string `json:"initrd_file"` - InitrdLayer string `json:"initrd_layer"` - Size int64 `json:"size"` - CreatedAt time.Time `json:"created_at"` + // 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"` + // 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 string `json:"manifest_digest"` - Position int `json:"position"` - SourceDigest string `json:"source_digest"` - EROFSDigest string `json:"erofs_digest"` - Size int64 `json:"size"` - BootFiles []bootFileRecord `json:"boot_files"` - Whiteouts []string `json:"whiteouts"` - BootOpaque bool `json:"boot_opaque"` + // 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 string `json:"name"` + // 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 int64 `json:"size"` + // Size is the extracted file size in bytes. + Size int64 `json:"size"` } func encodeBootFiles(files []images.BootFile) []bootFileRecord { @@ -77,6 +115,8 @@ func encodeBootFiles(files []images.BootFile) []bootFileRecord { 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) (images.Image, error) { var result images.Image err := c.store.View(ctx, func(reader metadata.Reader) error { @@ -90,6 +130,8 @@ func (c *Store) Resolve(ctx context.Context, reference string) (images.Image, er 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) ([]images.Image, error) { result := make([]images.Image, 0) err := c.store.View(ctx, func(reader metadata.Reader) error { @@ -108,6 +150,8 @@ func (c *Store) List(ctx context.Context) ([]images.Image, error) { 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 []images.Digest) (map[images.Digest]images.Layer, error) { wanted := make(map[images.Digest]struct{}, len(digests)) for _, digest := range digests { @@ -136,6 +180,9 @@ func (c *Store) FindLayers(ctx context.Context, digests []images.Digest) (map[im 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) @@ -195,8 +242,19 @@ func (c *Store) CommitImport(ctx context.Context, commit images.ImportCommit) er 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 images.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) @@ -257,6 +315,8 @@ func (c *Store) Remove(ctx context.Context, reference string, expected images.Di 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) (images.Image, error) { digestID := "" if raw, ok, err := reader.Get(ctx, CollectionNames, reference); err != nil { @@ -290,6 +350,9 @@ func resolveRecord(ctx context.Context, reader metadata.Reader, reference string 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) (images.Image, error) { raw, ok, err := reader.Get(ctx, CollectionImages, digestID) if err != nil { @@ -372,6 +435,8 @@ func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (im 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) (images.Layer, error) { source, err := images.ParseDigest(record.SourceDigest) if err != nil { @@ -412,10 +477,13 @@ 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 images.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 images.Digest) (bool, error) { referenced := false err := reader.Scan(ctx, CollectionLayers, func(_ string, raw []byte) error { diff --git a/images/erofs/convert.go b/images/erofs/convert.go index d45208e..5f0bc77 100644 --- a/images/erofs/convert.go +++ b/images/erofs/convert.go @@ -1,3 +1,6 @@ +// 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 ( @@ -22,16 +25,23 @@ import ( ) 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 { + // architecture selects whether an extracted arm64 gzip kernel is decompressed. architecture string - limits images.Limits + // limits bounds extracted boot files; source adapters bound the layer streams. + limits images.Limits } var _ images.Converter = (*Converter)(nil) +// 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, limits images.Limits) (*Converter, error) { if !limits.Valid() || (architecture != "amd64" && architecture != "arm64") { return nil, invalidLayer("invalid converter architecture or size limits") @@ -46,6 +56,18 @@ func New(ctx context.Context, architecture string, limits images.Limits) (*Conve return &Converter{architecture: architecture, limits: 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 images.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) { ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -56,7 +78,7 @@ func (c *Converter) Convert(ctx context.Context, descriptor images.Descriptor, s "--tar=f", "-zlz4hc", fmt.Sprintf("-C%d", erofsBlockSize), - "-T0", + "-T0", // Stable filesystem timestamps are part of the converted artifact identity. "-U", deterministicUUID(descriptor.Digest), outputPath, ) @@ -99,6 +121,9 @@ func (c *Converter) Convert(ctx context.Context, descriptor images.Descriptor, s }, 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 @@ -153,6 +178,8 @@ func scanBoot(source io.Reader, workDir, architecture string, limit int64) ([]im } } +// 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 { @@ -190,6 +217,8 @@ func writeBootFile(source io.Reader, destination string, decompressKernel bool, 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 != '.' @@ -212,6 +241,8 @@ func requireEROFSVersion(output string) error { 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 images.Digest) string { sum := sha256.Sum256([]byte(digest.String())) sum[6] = (sum[6] & 0x0f) | 0x50 @@ -220,6 +251,7 @@ func deterministicUUID(digest images.Digest) string { 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) (images.Digest, int64, error) { file, err := os.Open(path) //nolint:gosec // path is a managed staging path if err != nil { @@ -239,6 +271,8 @@ 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 @@ -252,11 +286,15 @@ func conversionError(err error) error { return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err) } +// contextReader observes cancellation between reads of a generated local artifact. type contextReader struct { - ctx context.Context + // 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 diff --git a/images/image.go b/images/image.go index 95b7cf6..56b3f91 100644 --- a/images/image.go +++ b/images/image.go @@ -1,3 +1,7 @@ +// Package images defines image identities and manages imports, artifact verification, +// boot selection and removal. Source, converter and catalog adapters implement the +// contracts in this package without coupling the workflows to an archive format +// or metadata engine. package images import ( @@ -10,9 +14,11 @@ import ( // 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) @@ -27,12 +33,19 @@ func ParseDigest(value string) (Digest, error) { return digest, nil } +// String returns the canonical algorithm-prefixed identity. func (d Digest) String() string { return "sha256:" + hex.EncodeToString(d.value[:]) } -func (d Digest) Hex() string { return hex.EncodeToString(d.value[:]) } -func (d Digest) IsZero() bool { return d == Digest{} } +// 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 { @@ -42,53 +55,87 @@ func (d *Digest) UnmarshalText(value []byte) error { return nil } +// Platform selects the operating system and instruction set of an image. type Platform struct { - OS string + // 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 Digest - Size int64 - BootFiles []BootFile - Whiteouts []string - BootOpaque bool + // 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 string + // Name is an accepted /boot basename, without parent directories. + Name string + // Digest verifies the extracted artifact, including any kernel decompression. Digest Digest - Size int64 + // 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 { - KernelFile string - InitrdFile string + // 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 []string + // 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 Platform - Layers []Layer - Boot Boot - Size int64 - CreatedAt time.Time + // 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 Digest + // Digest identifies this manifest; archive adapters may synthesize it. + Digest Digest + // Platform must match the platform requested from Source.Resolve. Platform Platform - Layers []Descriptor + // 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 int64 + // 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. diff --git a/images/import.go b/images/import.go index af0e8ec..cb3b3d7 100644 --- a/images/import.go +++ b/images/import.go @@ -19,82 +19,139 @@ import ( "github.com/kumabox/kumabox/storage" ) +// 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, Platform) (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, 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) (Image, error) + // FindLayers returns committed artifact mappings for the requested source blobs. FindLayers(context.Context, []Digest) (map[Digest]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, 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 Digest - EROFSPath string - EROFSDigest Digest - Size int64 - BootFiles []StagedBootFile - Whiteouts []string - BootOpaque bool + // EROFSPath is the staged EROFS file, or a verified managed file on cache reuse. + EROFSPath string + // EROFSDigest is the expected hash of EROFSPath. + EROFSDigest 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, Digest) error + // Committed runs after the atomic catalog commit and readback succeed. Committed(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, Digest) error { return nil } -func (DiscardReporter) Committed(Image) error { return nil } + +// Committed accepts a successful catalog commit without retaining it. +func (DiscardReporter) Committed(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 int64 + // LayerSize caps each original layer blob. + LayerSize int64 + // UnpackedSize caps each decompressed layer tar stream. UnpackedSize int64 - BootSize int64 - ArchiveSize 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 Limits + // 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 func() time.Time + // 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 Paths - catalog ImportCatalog + // 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 Reporter - reportMu sync.Mutex - options Options + // 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() @@ -111,6 +168,19 @@ func NewImporter(paths Paths, catalog ImportCatalog, converter Converter, report 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 Platform, source Source) (result Image, returnErr error) { if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\r\n\t") || source == nil || !platform.Valid() { return Image{}, invalidImage("image name, supported platform and source are required") @@ -242,6 +312,8 @@ func (i *Importer) Import(ctx context.Context, name string, platform Platform, s 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 Descriptor, workDir string) (ConvertedLayer, error) { reader, err := source.OpenLayer(ctx, descriptor) if err != nil { @@ -261,6 +333,8 @@ func (i *Importer) convert(ctx context.Context, source Source, descriptor Descri 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 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 { @@ -269,6 +343,8 @@ func cachedArtifact(paths Paths, layer Layer) ConvertedLayer { 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) (Layer, error) { layer := 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. @@ -315,6 +391,7 @@ func (i *Importer) publishLayer(ctx context.Context, artifact ConvertedLayer, st return layer, nil } +// stagedDigest rejects converter paths outside this import before hashing files. func stagedDigest(ctx context.Context, staging, path string) (Digest, int64, error) { rel, err := filepath.Rel(staging, path) if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { diff --git a/images/remove.go b/images/remove.go index 9f15634..e1f1a5e 100644 --- a/images/remove.go +++ b/images/remove.go @@ -12,11 +12,20 @@ import ( 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, 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 { diff --git a/images/source/archive.go b/images/source/archive.go index 4fff28e..2ea27a8 100644 --- a/images/source/archive.go +++ b/images/source/archive.go @@ -16,12 +16,18 @@ import ( "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 { @@ -34,6 +40,8 @@ func NewArchiveContext(ctx context.Context, path, stagingRoot string, limits ima 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") @@ -55,10 +63,23 @@ func stageArchive(ctx context.Context, path, stagingRoot string, limits images.L 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") @@ -147,11 +168,16 @@ func extractArchiveContext(ctx context.Context, path, destination string, limit } } +// contextInput stops subsequent reads after cancellation; it cannot interrupt an +// underlying Read already in progress, which must support cancellation itself. type contextInput struct { - ctx context.Context + // 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 diff --git a/images/source/docker.go b/images/source/docker.go index 1ec5680..6f7d7e1 100644 --- a/images/source/docker.go +++ b/images/source/docker.go @@ -21,12 +21,17 @@ import ( "github.com/kumabox/kumabox/images" ) +// dockerEntry is one image record in Docker save's manifest.json. type dockerEntry struct { - Config string `json:"Config"` + // Config names a config object by its content hash. + Config string `json:"Config"` + // RepoTags supplies optional source-tag selectors. RepoTags []string `json:"RepoTags"` - Layers []string `json:"Layers"` + // 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) @@ -46,6 +51,8 @@ func newDockerSource(path string, options LocalOptions) (images.Source, error) { 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 images.Platform, sourceTag string) (dockerEntry, []byte, error) { raw, err := readLocal(ctx, path, "manifest.json", maxMetadataSize) if err != nil { @@ -91,6 +98,7 @@ func selectDockerEntry(ctx context.Context, path string, platform images.Platfor 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) @@ -101,6 +109,7 @@ func dockerTagMatches(tags []string, wanted string) bool { 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)) { @@ -109,12 +118,13 @@ func archiveObjectName(value string) (string, error) { 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, } - // 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 := images.ParseDigest("sha256:" + hex) @@ -131,9 +141,17 @@ func readDockerConfig(ctx context.Context, path, object string) ([]byte, error) 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{ @@ -162,6 +180,9 @@ func dockerImageFromEntry(ctx context.Context, path string, entry dockerEntry, c 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 { @@ -191,17 +212,28 @@ func describeArchiveLayer(ctx context.Context, path, object string, limit int64) return v1.Descriptor{MediaType: media, Digest: digest, Size: size}, nil } +// dockerImage supplies the normalized metadata and lazy file layer adapters. type dockerImage struct { - config []byte + // config retains the verified Docker config unchanged. + config []byte + // manifest is the deterministic serialized OCI manifest. manifest []byte - layers map[v1.Hash]*fileLayer + // 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() (types.MediaType, error) { return types.OCIManifestSchema1, nil } -func (i *dockerImage) RawConfigFile() ([]byte, error) { return bytes.Clone(i.config), nil } -func (i *dockerImage) RawManifest() ([]byte, error) { return bytes.Clone(i.manifest), 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 { diff --git a/images/source/local.go b/images/source/local.go index c3ba3e1..3a4961e 100644 --- a/images/source/local.go +++ b/images/source/local.go @@ -15,14 +15,19 @@ import ( "github.com/kumabox/kumabox/images" ) +// Format identifies local image metadata conventions, independently of tar compression. type Format string const ( - FormatAuto Format = "auto" - FormatOCI Format = "oci" + // 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 { @@ -35,14 +40,26 @@ func ParseFormat(value string) (Format, error) { } } +// LocalOptions controls local format selection and source resource budgets. type LocalOptions struct { - Format Format + // 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 images.Limits + // 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 { @@ -76,6 +93,7 @@ func OpenLocal(ctx context.Context, path, stagingRoot string, options LocalOptio 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 { @@ -98,11 +116,14 @@ func openLocalFormat(ctx context.Context, path string, options LocalOptions) (im } } +// 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 { @@ -123,7 +144,9 @@ func detectLocalFormat(ctx context.Context, path string) (Format, error) { return "", invalidSource("unrecognized image format; expected an OCI layout/archive or a docker save archive") } -// Local metadata is bounded before allocation; os.Root also contains concurrent path changes. +// 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 { @@ -139,6 +162,8 @@ func readLocal(ctx context.Context, path, name string, limit int64) ([]byte, err 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 { @@ -158,26 +183,44 @@ func openLocal(ctx context.Context, path, name string) (io.ReadCloser, error) { 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 string - object string + // 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 context.Context + // ctx belongs to the resolution that selected this layer. + ctx context.Context } var _ partial.CompressedLayer = (*fileLayer)(nil) -func (l *fileLayer) Digest() (v1.Hash, error) { return l.descriptor.Digest, nil } -func (l *fileLayer) Size() (int64, error) { return l.descriptor.Size, 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() (types.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/oci.go b/images/source/oci.go index 8270a74..c568a23 100644 --- a/images/source/oci.go +++ b/images/source/oci.go @@ -16,10 +16,16 @@ import ( "github.com/kumabox/kumabox/images" ) +// 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") @@ -57,6 +63,7 @@ func NewLayoutWithLimits(path string, limits images.Limits) (images.Source, erro 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" { @@ -72,6 +79,7 @@ func NewLayoutWithLimits(path string, limits images.Limits) (images.Source, erro return source, nil } +// imageForPlatform rejects absent or ambiguous matches rather than choosing by order. func imageForPlatform(index v1.ImageIndex, platform images.Platform) (v1.Image, error) { candidates, err := platformCandidates(index, platform, 0) if err != nil { @@ -83,6 +91,20 @@ func imageForPlatform(index v1.ImageIndex, platform images.Platform) (v1.Image, 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 images.Platform, depth int) ([]v1.Image, error) { if depth > 16 { return nil, invalidSource("OCI index nesting exceeds limit") @@ -160,6 +182,7 @@ func platformCandidates(index v1.ImageIndex, platform images.Platform, depth int return candidates, nil } +// blobName converts a supported digest into the confined OCI blob path. func blobName(hash v1.Hash) (string, error) { digest, err := images.ParseDigest(hash.String()) if err != nil { @@ -168,20 +191,34 @@ func blobName(hash v1.Hash) (string, error) { 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 []byte - ctx context.Context + // 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() (types.MediaType, error) { return types.OCIImageIndex, nil } -func (i *localIndex) Digest() (v1.Hash, error) { return partial.Digest(i) } -func (i *localIndex) Size() (int64, error) { return int64(len(i.raw)), nil } -func (i *localIndex) RawManifest() ([]byte, error) { return bytes.Clone(i.raw), 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 { @@ -195,6 +232,7 @@ func (i *localIndex) descriptor(hash v1.Hash) (v1.Descriptor, error) { 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 { @@ -214,6 +252,7 @@ func (i *localIndex) Image(hash v1.Hash) (v1.Image, error) { 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 { @@ -233,15 +272,25 @@ func (i *localIndex) ImageIndex(hash v1.Hash) (v1.ImageIndex, error) { 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 string - raw []byte + // 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 context.Context + // 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() (types.MediaType, error) { return i.descriptor.MediaType, nil } -func (i *localImage) RawManifest() ([]byte, error) { return bytes.Clone(i.raw), 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 { @@ -257,6 +306,7 @@ func (i *localImage) RawConfigFile() ([]byte, error) { 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 { diff --git a/images/source/registry.go b/images/source/registry.go index 99494ae..b446456 100644 --- a/images/source/registry.go +++ b/images/source/registry.go @@ -16,6 +16,10 @@ import ( "github.com/kumabox/kumabox/images" ) +// 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, "://") { @@ -36,13 +40,22 @@ func NewRegistry(reference string) (images.Source, string, error) { return source, parsed.String(), nil } +// safeRegistryError separates a safe display message from diagnostic error identity. type safeRegistryError struct { - cause error + // 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 @@ -57,19 +70,26 @@ func registryError(err error) error { return errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, &safeRegistryError{cause: err, message: "registry request failed; check connectivity and credentials"}) } -// Wrap lazy operations too: registry I/O continues after remote.Image returns. -type registryImage struct{ v1.Image } +// 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 { @@ -78,8 +98,13 @@ func (i *registryImage) LayerByDigest(h v1.Hash) (v1.Layer, error) { return ®istryLayer{Layer: layer}, nil } -type registryLayer struct{ v1.Layer } +// 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 { @@ -88,8 +113,13 @@ func (l *registryLayer) Compressed() (io.ReadCloser, error) { return ®istryReader{ReadCloser: reader}, nil } -type registryReader struct{ io.ReadCloser } +// 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) { @@ -97,4 +127,6 @@ func (r *registryReader) Read(p []byte) (int, error) { } 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 index a2f9e88..f7ce9e3 100644 --- a/images/source/source.go +++ b/images/source/source.go @@ -1,3 +1,7 @@ +// 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 ( @@ -20,23 +24,37 @@ import ( "github.com/kumabox/kumabox/images" ) +// 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 v1.Layer - diffID images.Digest + // layer supplies the encoded bytes through the source adapter. + layer v1.Layer + // diffID is the config hash of the decoded tar stream. + diffID images.Digest + // mediaType selects gzip, zstd, or raw decoding. mediaType types.MediaType } +// resolvedSource shares metadata and streaming checks across all source formats. type resolvedSource struct { + // resolve selects the format-specific image. resolve func(context.Context, images.Platform) (v1.Image, error) - mu sync.RWMutex - layers map[images.Digest]resolvedLayer - limits images.Limits + // mu protects replacement and lookup of the resolved layer map. + mu sync.RWMutex + // layers is populated only after successful metadata validation. + layers map[images.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 images.Platform) (images.Manifest, error) { if err := ctx.Err(); err != nil { return images.Manifest{}, err @@ -128,6 +146,13 @@ func (s *resolvedSource) Resolve(ctx context.Context, platform images.Platform) return images.Manifest{Digest: digest, Platform: platform, 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 images.Descriptor) (io.ReadCloser, error) { if err := ctx.Err(); err != nil { return nil, err @@ -165,17 +190,28 @@ func (s *resolvedSource) OpenLayer(ctx context.Context, descriptor images.Descri 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 context.Context - reader io.Reader - hash hash.Hash + // 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 images.Digest - limit int64 - size int64 - read int64 - lastErr error + // 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 @@ -210,13 +246,19 @@ func (r *checkedReader) Read(p []byte) (n int, returnErr error) { return n, err } +// layerReader couples decoder ownership with encoded and unpacked verification. type layerReader struct { - unpacked *checkedReader - buffered *bufio.Reader - raw io.ReadCloser + // 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) { @@ -226,8 +268,11 @@ func (r *layerReader) Read(p []byte) (int, error) { } 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 := images.ParseDigest(desc.Digest.String()); err != nil { return invalidSource("invalid descriptor: %v", err) @@ -241,6 +286,7 @@ func validateDescriptor(desc v1.Descriptor, limit int64) error { 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)) @@ -248,10 +294,13 @@ func checkBytes(raw []byte, expected v1.Hash, size int64) error { 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 diff --git a/images/verify.go b/images/verify.go index 7b759fb..5bf6eba 100644 --- a/images/verify.go +++ b/images/verify.go @@ -9,10 +9,15 @@ import ( filelock "github.com/kumabox/kumabox/lock/flock" ) +// 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) (Image, error) } +// 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 Image, returnErr error) { image, err := catalog.Resolve(ctx, reference) if err != nil { @@ -45,6 +50,8 @@ func Verify(ctx context.Context, paths Paths, catalog ImageResolver, reference s return image, 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 Layer) error { if layer.SourceDigest.IsZero() || layer.EROFSDigest.IsZero() || layer.Size <= 0 { return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("invalid layer metadata")) @@ -69,6 +76,7 @@ func verifyLayer(ctx context.Context, paths Paths, layer Layer) error { return nil } +// verifyFile requires both identity and byte size to match committed metadata. func verifyFile(ctx context.Context, path string, expected Digest, expectedSize int64) error { digest, size, err := digestFileContext(ctx, path) if err != nil { diff --git a/lock/flock/lock.go b/lock/flock/lock.go index 6f881a7..d47b84a 100644 --- a/lock/flock/lock.go +++ b/lock/flock/lock.go @@ -1,3 +1,5 @@ +// 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 ( @@ -11,24 +13,41 @@ import ( goflock "github.com/gofrs/flock" ) +// retryInterval bounds polling delay between advisory lock acquisition attempts. const retryInterval = 2 * time.Millisecond -// Lock combines in-process serialization with an advisory cross-process lock. +// 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 string - token chan struct{} - held *goflock.Flock + // 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 @@ -67,6 +86,8 @@ func (l *Lock) Lock(ctx context.Context) error { } } +// 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 @@ -98,6 +119,9 @@ func (l *Lock) TryLock(ctx context.Context) (bool, error) { 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 @@ -118,6 +142,7 @@ func (l *Lock) Unlock(context.Context) error { 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 { diff --git a/lock/flock/set.go b/lock/flock/set.go index b2cdcf9..35ae016 100644 --- a/lock/flock/set.go +++ b/lock/flock/set.go @@ -6,11 +6,16 @@ import ( "slices" ) -// Set owns an ordered group of persistent file locks. +// 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 { @@ -23,6 +28,8 @@ func (s *Set) Lock(ctx context.Context, paths ...string) error { 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) { diff --git a/metadata/memory.go b/metadata/memory.go index 42cb2b2..adf5607 100644 --- a/metadata/memory.go +++ b/metadata/memory.go @@ -10,12 +10,18 @@ import ( // Memory is a snapshotting in-memory Store for business tests and engine contracts. // It is not a durable metadata engine. type Memory struct { - mu sync.RWMutex + // mu protects the published snapshot and the closed flag. + mu sync.RWMutex + // writeToken serializes writers with context-aware acquisition. writeToken chan struct{} - records map[Collection]map[string][]byte - closed bool + // 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 { @@ -30,6 +36,8 @@ func NewMemory(collections []Collection) (*Memory, error) { 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 @@ -49,6 +57,7 @@ func (s *Memory) snapshot(ctx context.Context) (*memoryTransaction, error) { 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 { @@ -60,6 +69,12 @@ func (s *Memory) View(ctx context.Context, fn func(Reader) error) error { 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{}{}: @@ -85,12 +100,17 @@ func (s *Memory) Update(ctx context.Context, fn func(Writer) error) error { 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 @@ -102,6 +122,7 @@ func (t *memoryTransaction) collection(ctx context.Context, collection Collectio 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 { @@ -111,6 +132,7 @@ func (t *memoryTransaction) Get(ctx context.Context, collection Collection, 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 { @@ -132,6 +154,7 @@ func (t *memoryTransaction) Scan(ctx context.Context, collection Collection, fn 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 { @@ -141,6 +164,7 @@ func (t *memoryTransaction) Put(ctx context.Context, collection Collection, key 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 { diff --git a/metadata/sqlite/store.go b/metadata/sqlite/store.go index 0464bf0..f748bf6 100644 --- a/metadata/sqlite/store.go +++ b/metadata/sqlite/store.go @@ -1,3 +1,6 @@ +// 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 ( @@ -22,30 +25,46 @@ import ( ) const ( + // applicationID distinguishes KumaBox metadata from unrelated SQLite files. applicationID = 0x4B554D41 + // schemaVersion identifies the collection/record schema accepted by this engine. schemaVersion = 1 - initLockName = "init.lock" + // 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 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 *sql.DB - writer *sql.DB + // 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 time.Duration + // retryLimit becomes each Update call's context deadline. + retryLimit time.Duration } var _ metadata.Store = (*Store)(nil) +// Open validates paths and declarations, initializes an empty database under a +// transient file lock, and verifies database identity and existing collections. +// It rejects incompatible populated databases rather than rewriting their schema. +// 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")) @@ -96,6 +115,7 @@ func Open(ctx context.Context, path string, collections []metadata.Collection, o 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 { @@ -108,6 +128,16 @@ func (s *Store) View(ctx context.Context, fn func(metadata.Reader) error) error 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() @@ -141,8 +171,10 @@ func (s *Store) Update(ctx context.Context, fn func(metadata.Writer) error) erro } } +// 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 { @@ -167,6 +199,8 @@ func (s *Store) verify(ctx context.Context) error { return nil } +// initialize creates schema only for an unidentified empty database. 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())) @@ -225,6 +259,8 @@ func initialize(ctx context.Context, path string, collections []metadata.Collect 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)") @@ -237,6 +273,8 @@ func dsn(path string, options Options, immediate bool) string { 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 @@ -262,6 +300,7 @@ func mapError(err error) error { } } +// busy recognizes both database contention and table/schema lock contention. func busy(err error) bool { var sqliteErr *moderncsqlite.Error if !errors.As(err, &sqliteErr) { @@ -271,7 +310,8 @@ func busy(err error) bool { return code == modernclib.SQLITE_BUSY || code == modernclib.SQLITE_LOCKED } -// Cancellation can roll a transaction back before Commit observes its context. +// 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 { @@ -286,6 +326,7 @@ func commit(ctx context.Context, tx *sql.Tx) error { 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) { diff --git a/metadata/sqlite/transaction.go b/metadata/sqlite/transaction.go index e51dd9a..ecc45f4 100644 --- a/metadata/sqlite/transaction.go +++ b/metadata/sqlite/transaction.go @@ -9,12 +9,18 @@ import ( "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 *sql.Tx - allowed map[metadata.Collection]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 @@ -30,6 +36,7 @@ func (t *transaction) Get(ctx context.Context, collection metadata.Collection, i 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 @@ -52,6 +59,7 @@ func (t *transaction) Scan(ctx context.Context, collection metadata.Collection, 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") @@ -63,6 +71,7 @@ func (t *transaction) Put(ctx context.Context, collection metadata.Collection, i 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") @@ -74,6 +83,7 @@ func (t *transaction) Delete(ctx context.Context, collection metadata.Collection 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) diff --git a/metadata/store.go b/metadata/store.go index f163b7f..5b00d14 100644 --- a/metadata/store.go +++ b/metadata/store.go @@ -1,3 +1,5 @@ +// Package metadata defines collection-scoped transactions without prescribing +// a storage engine or interpreting module-owned record payloads. package metadata import ( @@ -5,29 +7,43 @@ import ( "fmt" ) -// Store is the engine-neutral metadata transaction boundary. +// 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) @@ -39,4 +55,6 @@ func NewCollection(name string) (Collection, error) { } return Collection(name), nil } + +// String returns the collection name used by engine adapters. func (c Collection) String() string { return string(c) } diff --git a/storage/publish.go b/storage/publish.go index 1e8a9d7..0214b4d 100644 --- a/storage/publish.go +++ b/storage/publish.go @@ -9,6 +9,12 @@ import ( // 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 @@ -35,6 +41,8 @@ func Publish(staged, final string) error { 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 { diff --git a/storage/roots.go b/storage/roots.go index 10650c9..47824d7 100644 --- a/storage/roots.go +++ b/storage/roots.go @@ -1,3 +1,5 @@ +// Package storage manages host root boundaries and durable filesystem publication. +// It supplies path and sync mechanisms without interpreting module artifacts. package storage import ( @@ -8,23 +10,32 @@ import ( ) const ( + // DefaultDataRoot holds persistent module data. DefaultDataRoot = "/var/lib/kumabox" - DefaultRunRoot = "/run/kumabox" - DefaultLogRoot = "/var/log/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 string - Log 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 normalizes roots and rejects ambiguous ownership boundaries. +// 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 { @@ -66,7 +77,9 @@ func (r Roots) Validate() (Roots, error) { return r, nil } -// CheckPath refuses symlinks in every existing component, including the file. +// 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 { @@ -136,7 +149,8 @@ func EnsureDir(path string) error { return syncPath(parent) } -// Join returns a contained child path. +// 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)) { @@ -153,6 +167,7 @@ func Join(root string, elements ...string) (string, error) { 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/version/version.go b/version/version.go index 3d1f72c..23f613d 100644 --- a/version/version.go +++ b/version/version.go @@ -9,8 +9,11 @@ import ( // Build information. Release builds override these through -ldflags. var ( - Version = "0.0.0-dev" - Commit = "unknown" + // 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" ) From 5e8cf516206d8275670d5b473553a69cec22a8a4 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Tue, 15 Sep 2026 15:53:46 +0800 Subject: [PATCH 17/45] feat: add sandbox create workflow --- .golangci.yml | 17 ++ README.md | 51 ++++-- cli/image/command.go | 8 +- cli/image/output.go | 8 +- cli/image/output_test.go | 12 +- cli/image/progress.go | 5 +- cli/image/progress_test.go | 14 +- cli/root.go | 4 +- cli/root_test.go | 1 + cli/sandbox/create.go | 320 ++++++++++++++++++++++++++++++++++ cli/sandbox/create_test.go | 212 ++++++++++++++++++++++ core/images.go | 21 ++- core/sandbox.go | 227 ++++++++++++++++++++++++ core/sandbox_test.go | 184 +++++++++++++++++++ disk/ext4.go | 142 +++++++++++++++ disk/ext4_test.go | 58 ++++++ errdefs/error.go | 2 + images/artifacts.go | 33 ++-- images/boot.go | 13 +- images/catalog.go | 18 +- images/catalog/store.go | 150 ++++++++++------ images/erofs/convert.go | 13 +- images/guard_test.go | 58 ++++++ images/import.go | 106 +++++------ images/import_test.go | 35 ++-- images/remove.go | 3 +- images/source/docker.go | 23 +-- images/source/docker_test.go | 13 +- images/source/local.go | 4 +- images/source/local_test.go | 7 +- images/source/oci.go | 19 +- images/source/registry.go | 3 +- images/source/source.go | 85 ++++----- images/source/source_test.go | 29 +-- images/verify.go | 122 +++++++++++-- metadata/sqlite/store.go | 4 +- sandbox/catalog/store.go | 298 +++++++++++++++++++++++++++++++ sandbox/catalog/store_test.go | 94 ++++++++++ sandbox/paths.go | 67 +++++++ sandbox/paths_test.go | 33 ++++ {images => types}/image.go | 6 +- types/sandbox.go | 180 +++++++++++++++++++ types/sandbox_test.go | 41 +++++ 43 files changed, 2432 insertions(+), 311 deletions(-) create mode 100644 cli/sandbox/create.go create mode 100644 cli/sandbox/create_test.go create mode 100644 core/sandbox.go create mode 100644 core/sandbox_test.go create mode 100644 disk/ext4.go create mode 100644 disk/ext4_test.go create mode 100644 images/guard_test.go create mode 100644 sandbox/catalog/store.go create mode 100644 sandbox/catalog/store_test.go create mode 100644 sandbox/paths.go create mode 100644 sandbox/paths_test.go rename {images => types}/image.go (94%) create mode 100644 types/sandbox.go create mode 100644 types/sandbox_test.go diff --git a/.golangci.yml b/.golangci.yml index 50e1c4b..8a6c35e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -46,6 +46,23 @@ linters: 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" diff --git a/README.md b/README.md index 3fa0bd9..0139284 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ A microVM sandbox runtime for AI agents. One node runs one daemon Docker-like command line; sandboxes are Cloud Hypervisor microVMs booted from OCI images, with CNI networking, cgroups, snapshots and clone. -The rewrite currently provides the `kumabox` CLI, the host doctor, and container -image management: registry pull, Docker/OCI import, list, inspect, verify, -and remove. Each command opens its metadata store and exits. VM lifecycle and -a daemon are later phases of [docs/ROADMAP.md](docs/ROADMAP.md). +The rewrite currently provides the `kumabox` CLI, the host doctor, container +image management, and persistent sandbox creation. Each command opens its +metadata store, performs one operation, and exits. Starting a VMM and the rest +of the sandbox lifecycle are later phases of [docs/ROADMAP.md](docs/ROADMAP.md). ## Where the design lives @@ -48,21 +48,24 @@ or a generic `pkg` container: | Package | Responsibility | |---|---| | `cmd/kumabox` | Process entry point, signals and exit status | -| `cli`, `cli/image`, `cli/doctor` | Command trees, argument parsing and presentation | -| `core` | Assemble concrete adapters and own their resources | -| `images` | Managed image model, import, verification and removal rules | +| `cli`, `cli/image`, `cli/sandbox`, `cli/doctor` | Command trees, argument parsing and presentation | +| `core` | Application services, operation ordering and concrete adapter assembly | +| `types` | Shared image and sandbox resource models and value objects | +| `images` | Image import, verification, boot selection and removal rules | | `images/catalog` | Persist image identities, name bindings and layer references | | `images/source` | Read Docker archives, OCI layouts/archives and registries | | `images/erofs` | Convert source layers and extract boot candidates | +| `sandbox`, `sandbox/catalog` | Sandbox filesystem ownership and metadata persistence | +| `disk` | Prepare and remove sandbox-owned sparse ext4 COW disks | | `metadata`, `metadata/sqlite` | Engine-neutral transactions and the SQLite implementation | | `storage`, `lock/flock` | Managed filesystem operations and file locks | | `errdefs`, `version` | Error classification and build information | -`core` connects modules through constructors; it is not a second implementation -of their business operations. CLI handlers use the assembled modules. Only -`core` selects concrete image and metadata adapters. The image core does not -import adapters or metadata engines, and modules do not import `core` or `cli`. -These dependency directions are enforced by depguard in `.golangci.yml`. +`core` owns application workflows that cross module boundaries and connects +their concrete adapters. CLI handlers use those services. Shared resource data +belongs to `types`; capability interfaces stay beside their consumers and are +not collected in `types`. Modules do not import `core` or `cli`. These dependency +directions are enforced by depguard in `.golangci.yml`. The image command groups complete responsibilities into `import.go` (pull and local import), `query.go` (list, inspect and verify), and `remove.go`. Related @@ -143,11 +146,33 @@ semantics. Metadata is committed after durable publication and final digest checks. Verification detects EROFS and boot-file corruption. Source OCI blobs are never stored persistently; repeated imports reuse verified, registered artifacts. Removing a name retains artifacts until the last image reference -is removed. +is removed. A final manifest cannot be removed while a sandbox pins it. The [Linux acceptance runbook](docs/runbooks/s2-oci.md) covers real conversion, registry pull, cancellation, concurrency, and crash/retry behavior. +## Create a sandbox + +`create` resolves an existing local image, reserves the sandbox name and exact +manifest digest, then creates a private sparse ext4 COW directly at +`Data/sandboxes//cow.raw`. It does not start a VMM. + +```bash +kumabox create IMAGE --name NAME \ + --cpus 2 --memory 1GiB --storage 10GiB +``` + +Successful text output is the full sandbox UUID. `--json` returns an indented +object containing the ID, name, manifest digest, `created` state, resource +shape, and creation time. Progress is written to stderr. `mkfs.ext4` from +e2fsprogs must be available on the host. + +`Created` means the disk and metadata exist but the sandbox has never started; +`Stopped` is reserved for a sandbox whose VMM has exited after a start. The +metadata schema is version 2. Pre-S3 development roots use version 1 and are +rejected rather than modified automatically; create a fresh isolated root and +re-import the image. + ## Reference material - Cocoon at `../cocoon@27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` is the diff --git a/cli/image/command.go b/cli/image/command.go index 3fa8f1e..b0ba5a4 100644 --- a/cli/image/command.go +++ b/cli/image/command.go @@ -10,8 +10,8 @@ import ( "github.com/spf13/cobra" "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/images" "github.com/kumabox/kumabox/storage" + "github.com/kumabox/kumabox/types" ) // rootsProvider defers reading storage roots until command flags have been parsed. @@ -32,12 +32,12 @@ func NewCommand(roots rootsProvider) *cobra.Command { } // parsePlatform rejects targets unsupported by the Linux image conversion pipeline. -func parsePlatform(value string) (images.Platform, error) { +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 images.Platform{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("unsupported platform %q", value)) + return types.Platform{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("unsupported platform %q", value)) } - return images.Platform{OS: parts[0], Architecture: parts[1]}, nil + return types.Platform{OS: parts[0], Architecture: parts[1]}, nil } // defaultPlatform selects the host architecture while keeping the guest OS Linux. diff --git a/cli/image/output.go b/cli/image/output.go index 4ca0145..5a76e41 100644 --- a/cli/image/output.go +++ b/cli/image/output.go @@ -8,7 +8,7 @@ import ( "text/tabwriter" "time" - "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/types" ) // imageOutput is the CLI JSON schema, keeping serialization separate from domain types. @@ -77,7 +77,7 @@ type ( ) // imageResult projects domain metadata into the CLI schema without truncating content identities. -func imageResult(image images.Image) imageOutput { +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)) @@ -90,7 +90,7 @@ func imageResult(image images.Image) imageOutput { } // writeImage reports the aliases and full manifest digest after a successful import. -func writeImage(writer io.Writer, image images.Image) error { +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 } @@ -104,7 +104,7 @@ func writeJSON(writer io.Writer, value any) error { // 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 []images.Image) error { +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 diff --git a/cli/image/output_test.go b/cli/image/output_test.go index af840bf..614f82e 100644 --- a/cli/image/output_test.go +++ b/cli/image/output_test.go @@ -7,25 +7,25 @@ import ( "testing" "time" - "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/types" ) func TestImagesTableHeadersAndAlignedRows(t *testing.T) { - first, err := images.ParseDigest("sha256:" + strings.Repeat("a", 64)) + first, err := types.ParseDigest("sha256:" + strings.Repeat("a", 64)) if err != nil { t.Fatal(err) } - second, err := images.ParseDigest("sha256:" + strings.Repeat("b", 64)) + 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 := []images.Image{ + items := []types.Image{ { Names: []string{"demo", "demo-alias-with-a-long-name"}, ManifestDigest: first, - Platform: images.Platform{OS: "linux", Architecture: "amd64"}, Size: 127600000, CreatedAt: created, + Platform: types.Platform{OS: "linux", Architecture: "amd64"}, Size: 127600000, CreatedAt: created, }, - {ManifestDigest: second, Platform: images.Platform{OS: "linux", Architecture: "arm64"}, Size: 1024, 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 { diff --git a/cli/image/progress.go b/cli/image/progress.go index 78b09ac..93baf8d 100644 --- a/cli/image/progress.go +++ b/cli/image/progress.go @@ -14,6 +14,7 @@ import ( "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/types" ) // imageProgress serializes terminal presentation and implements images.Reporter. @@ -127,7 +128,7 @@ func (p *imageProgress) Status(status string) error { // Layer records a completed conversion; position is the zero-based source order. // Completion count is independent of position because workers may finish out of order. -func (p *imageProgress) Layer(position, total int, digest images.Digest) error { +func (p *imageProgress) Layer(position, total int, digest types.Digest) error { p.mu.Lock() defer p.mu.Unlock() if p.err != nil { @@ -148,7 +149,7 @@ func (p *imageProgress) Layer(position, total int, digest images.Digest) error { // Committed records durable import completion while keeping animation alive for cleanup. // It preserves reporting failures so the command can distinguish committed-with-error state. -func (p *imageProgress) Committed(images.Image) error { +func (p *imageProgress) Committed(types.Image) error { p.mu.Lock() defer p.mu.Unlock() p.committed = true diff --git a/cli/image/progress_test.go b/cli/image/progress_test.go index 9038c54..140c672 100644 --- a/cli/image/progress_test.go +++ b/cli/image/progress_test.go @@ -10,7 +10,7 @@ import ( "time" "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/types" ) func TestProgressLogsHaveNoAnimationControls(t *testing.T) { @@ -22,13 +22,13 @@ func TestProgressLogsHaveNoAnimationControls(t *testing.T) { if err := p.Status("converting layers"); err != nil { t.Fatal(err) } - if err := p.Layer(1, 2, images.Digest{}); err != nil { + if err := p.Layer(1, 2, types.Digest{}); err != nil { t.Fatal(err) } - if err := p.Layer(0, 2, images.Digest{}); err != nil { + if err := p.Layer(0, 2, types.Digest{}); err != nil { t.Fatal(err) } - if err := p.Committed(images.Image{}); err != nil { + if err := p.Committed(types.Image{}); err != nil { t.Fatal(err) } if err := p.Finish(nil); err != nil { @@ -108,13 +108,13 @@ func TestProgressAnimatesAndCountsConcurrentCompletedLayers(t *testing.T) { wait.Add(1) go func() { defer wait.Done() - if err := p.Layer(position, 3, images.Digest{}); err != nil { + if err := p.Layer(position, 3, types.Digest{}); err != nil { t.Error(err) } }() } wait.Wait() - if err := p.Committed(images.Image{}); err != nil { + if err := p.Committed(types.Image{}); err != nil { t.Fatal(err) } if err := p.Finish(nil); err != nil { @@ -161,7 +161,7 @@ func TestProgressRetainsAnimationFailureAfterCommit(t *testing.T) { if err := p.Status("converting layers"); !errors.Is(err, failure) { t.Fatalf("status error = %v", err) } - if err := p.Committed(images.Image{}); !errors.Is(err, failure) { + if err := p.Committed(types.Image{}); !errors.Is(err, failure) { t.Fatalf("commit report error = %v", err) } err = p.Finish(failure) diff --git a/cli/root.go b/cli/root.go index 2e62088..1876784 100644 --- a/cli/root.go +++ b/cli/root.go @@ -12,6 +12,7 @@ import ( doctorcmd "github.com/kumabox/kumabox/cli/doctor" imagecmd "github.com/kumabox/kumabox/cli/image" + sandboxcmd "github.com/kumabox/kumabox/cli/sandbox" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/version" @@ -112,6 +113,7 @@ func newRootCommand() *cobra.Command { root.AddCommand(doctorcmd.NewCommand()) root.AddCommand(imagecmd.NewCommand(func() storage.Roots { return roots })) + root.AddCommand(sandboxcmd.NewCreateCommand(func() storage.Roots { return roots })) root.AddCommand(newVersionCommand()) classifyArguments(root) return root @@ -146,7 +148,7 @@ func errorExitCode(err error) int { switch code { case errdefs.CodeNotFound: return 3 - case errdefs.CodeNameTaken, errdefs.CodeReferenced: + case errdefs.CodeNameTaken, errdefs.CodeStateConflict, errdefs.CodeReferenced: return 4 case errdefs.CodeInvalidArgument, errdefs.CodeHostIncompatible, errdefs.CodeDigestMismatch, errdefs.CodeArtifactCorrupt: return 5 diff --git a/cli/root_test.go b/cli/root_test.go index 2debe9e..659d100 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -60,6 +60,7 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"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}, {"unknown flag", []string{"image", "ls", "--wrong"}, 2}, {"unsupported platform", []string{"image", "pull", "example.com/image", "--platform", "windows/amd64"}, 5}, {"missing image", []string{"image", "inspect", "missing"}, 3}, diff --git a/cli/sandbox/create.go b/cli/sandbox/create.go new file mode 100644 index 0000000..adaf7c1 --- /dev/null +++ b/cli/sandbox/create.go @@ -0,0 +1,320 @@ +// 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 ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "strconv" + "sync" + "time" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/core" + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/storage" + "github.com/kumabox/kumabox/types" +) + +// rootsProvider reads persistent flags only after Cobra has parsed them. +type rootsProvider func() storage.Roots + +// NewCreateCommand builds the top-level create command. +func NewCreateCommand(roots rootsProvider) *cobra.Command { + name := "" + cpus := types.DefaultSandboxCPUs + memory := "1GiB" + storageSize := "10GiB" + 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) { + memoryBytes, err := parseBytes(memory) + if err != nil { + return invalidFlag("memory", err) + } + storageBytes, err := parseBytes(storageSize) + if err != nil { + return invalidFlag("storage", err) + } + config := types.SandboxConfig{Name: name, CPUs: cpus, Memory: memoryBytes, Storage: storageBytes} + if err := config.Validate(); err != nil { + return err + } + progress, err := startCreateProgress(command, name) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }() + service, err := core.OpenSandbox(command.Context(), roots(), progress) + if err != nil { + return err + } + committed := false + defer func() { + closeErr := service.Close() + returnErr = errors.Join(returnErr, errdefs.Context(closeErr, "create sandbox", name, "close metadata", "inspect the sandbox before retrying", committed)) + }() + record, err := service.Create(command.Context(), core.CreateSandboxRequest{ImageReference: args[0], Config: config}) + if err != nil { + return err + } + committed = true + if err := writeResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil { + return errdefs.Context(err, "create sandbox", name, "output", "sandbox was created; inspect it before retrying", true) + } + return nil + }, + } + command.Flags().StringVar(&name, "name", name, "required sandbox name") + command.Flags().Uint32Var(&cpus, "cpus", cpus, "number of virtual CPUs") + command.Flags().StringVar(&memory, "memory", memory, "guest memory (for example 1GiB)") + command.Flags().StringVar(&storageSize, "storage", storageSize, "logical sparse COW size (minimum 10GiB)") + command.Flags().BoolVar(&asJSON, "json", false, "print the created sandbox as indented JSON") + return command +} + +// result is the stable JSON projection returned by create --json. +type result struct { + // ID is the complete immutable sandbox UUID. + ID string `json:"id"` + // Name is the human-readable lookup key supplied by the user. + Name string `json:"name"` + // ImageDigest is the exact pinned manifest identity. + ImageDigest string `json:"image_digest"` + // State is created after persistent resources are ready. + 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"` + // Generation fences stale lifecycle transitions. + Generation uint64 `json:"generation"` + // CreatedAt is the identity reservation time. + CreatedAt time.Time `json:"created_at"` + // UpdatedAt is the Created transition time. + UpdatedAt time.Time `json:"updated_at"` +} + +// writeResult keeps the default output script-friendly and JSON complete. +func writeResult(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(result{ + ID: sandbox.ID.String(), Name: sandbox.Config.Name, ImageDigest: sandbox.ImageDigest.String(), + State: string(sandbox.State), CPUs: sandbox.Config.CPUs, Memory: sandbox.Config.Memory, + Storage: sandbox.Config.Storage, Generation: sandbox.Generation, + CreatedAt: sandbox.CreatedAt, UpdatedAt: sandbox.UpdatedAt, + }) +} + +// 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)) +} + +// createProgress serializes a small activity animation with lifecycle callbacks. +// Redirected stderr receives plain stage lines and stdout remains command data only. +type createProgress struct { + // mu serializes ticker, callback, and result output writes. + mu sync.Mutex + // writer receives progress independently of stdout command results. + writer io.Writer + // label identifies the create operation and quoted sandbox name. + label string + // status is the current lifecycle stage. + status string + // animated selects terminal redraws instead of plain log lines. + animated bool + // committed records that the Created transition is durable. + committed bool + // frame indexes the next spinner glyph. + frame int + // err retains the first rendering failure. + err error + // stopOnce makes Finish safe if cleanup calls it more than once. + stopOnce sync.Once + // stop requests ticker shutdown. + stop chan struct{} + // done is closed after the ticker goroutine exits. + done chan struct{} +} + +var _ core.CreateReporter = (*createProgress)(nil) + +// startCreateProgress writes an initial stage before starting its ticker. +func startCreateProgress(command *cobra.Command, name string) (*createProgress, error) { + writer := command.ErrOrStderr() + file, isFile := writer.(*os.File) + progress := &createProgress{ + writer: writer, label: fmt.Sprintf("Create %q", name), status: "preparing sandbox", + animated: isFile && isatty.IsTerminal(file.Fd()), stop: make(chan struct{}), done: make(chan struct{}), + } + if err := progress.render(); err != nil { + return nil, err + } + if progress.animated { + go progress.animate(command.Context()) + } else { + close(progress.done) + } + return progress, nil +} + +// animate redraws until command cleanup finishes, cancellation occurs, or output fails. +func (p *createProgress) animate(ctx context.Context) { + defer close(p.done) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-p.stop: + return + case <-ticker.C: + p.mu.Lock() + if p.err == nil { + p.err = p.render() + } + failed := p.err != nil + p.mu.Unlock() + if failed { + return + } + } + } +} + +// Status updates the current lifecycle stage. +func (p *createProgress) Status(status string) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return p.err + } + p.status = status + p.err = p.render() + return p.err +} + +// Committed records that Created is durable before output and cleanup finish. +func (p *createProgress) Committed(types.Sandbox) error { + p.mu.Lock() + defer p.mu.Unlock() + p.committed = true + p.status = "finishing" + return p.err +} + +// Output coordinates stdout writes with terminal redraws. +func (p *createProgress) Output(writer io.Writer) io.Writer { + return progressWriter{progress: p, writer: writer} +} + +// progressWriter prevents a live animation from visually mixing with command output. +type progressWriter struct { + // progress owns output serialization and animation state. + progress *createProgress + // writer receives the unchanged command result. + writer io.Writer +} + +func (w progressWriter) Write(data []byte) (int, error) { + p := w.progress + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return 0, p.err + } + if p.animated { + if _, err := fmt.Fprint(p.writer, "\r\x1b[2K"); err != nil { + p.err = err + return 0, err + } + } + n, writeErr := w.writer.Write(data) + if p.animated { + p.err = p.render() + } + return n, errors.Join(writeErr, p.err) +} + +// Finish joins the ticker and emits one unambiguous final status line. +func (p *createProgress) Finish(operationErr error) error { + p.stopOnce.Do(func() { close(p.stop); <-p.done }) + p.mu.Lock() + defer p.mu.Unlock() + var classified *errdefs.Error + if errors.As(operationErr, &classified) && classified.Committed { + p.committed = true + } + resultText, symbol := "complete", "✓" + if operationErr != nil || p.err != nil { + resultText, symbol = "failed", "✗" + if p.committed { + resultText = "committed with errors" + } else if errors.Is(operationErr, context.Canceled) { + resultText = "canceled" + } + } + message := fmt.Sprintf("%s %s", p.label, resultText) + if p.animated { + message = "\r\x1b[2K" + symbol + " " + message + } + _, err := fmt.Fprintln(p.writer, message) + return errdefs.Context(errors.Join(p.err, err), "create sandbox", p.label, "report", "inspect the sandbox state", p.committed) +} + +// render writes one spinner frame or one plain stage line. The caller holds mu +// after animation starts. +func (p *createProgress) render() error { + message := p.label + " · " + p.status + if p.animated { + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + _, err := fmt.Fprintf(p.writer, "\r\x1b[2K%s %s", frames[p.frame%len(frames)], message) + p.frame++ + return err + } + _, err := fmt.Fprintln(p.writer, message) + return err +} diff --git a/cli/sandbox/create_test.go b/cli/sandbox/create_test.go new file mode 100644 index 0000000..5c31018 --- /dev/null +++ b/cli/sandbox/create_test.go @@ -0,0 +1,212 @@ +package sandbox + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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" +) + +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 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}, + ImageDigest: digest, 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), + } + var text bytes.Buffer + if err := writeResult(&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 := writeResult(&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()) + } +} + +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) + 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")) + command := NewCreateCommand(func() storage.Roots { return roots }) + command.SetArgs([]string{"demo", "--name", "box", "--cpus", "1", "--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 result + 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(), 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) + } +} + +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 newTestProgress(writer *bytes.Buffer) (*createProgress, error) { + progress := &createProgress{ + writer: writer, label: `Create "box"`, status: "preparing sandbox", + stop: make(chan struct{}), done: make(chan struct{}), + } + if err := progress.render(); err != nil { + return nil, err + } + close(progress.done) + return progress, nil +} diff --git a/core/images.go b/core/images.go index e7c5a92..37afdac 100644 --- a/core/images.go +++ b/core/images.go @@ -1,5 +1,5 @@ -// Package core assembles application modules and their concrete dependencies. -// It owns resource construction; image policies and workflows remain in images. +// Package core owns application workflows that cross module boundaries and +// assembles their concrete adapters. Module-local policies remain with modules. // // Image command assembly: // @@ -19,7 +19,9 @@ import ( "github.com/kumabox/kumabox/images/source" "github.com/kumabox/kumabox/metadata" "github.com/kumabox/kumabox/metadata/sqlite" + sandboxcatalog "github.com/kumabox/kumabox/sandbox/catalog" "github.com/kumabox/kumabox/storage" + "github.com/kumabox/kumabox/types" ) // ImageStore owns the resources assembled for one image command. @@ -43,18 +45,19 @@ func OpenImages(ctx context.Context, roots storage.Roots) (*ImageStore, error) { if err := paths.Ensure(); err != nil { return nil, err } - store, err := sqlite.Open(ctx, paths.MetadataDB(), catalog.Collections(), sqlite.DefaultOptions()) + store, err := sqlite.Open(ctx, paths.MetadataDB(), metadataCollections(), sqlite.DefaultOptions()) if err != nil { return nil, err } - return &ImageStore{Paths: paths, Catalog: catalog.New(store), store: store}, nil + imageCatalog := catalog.New(store, catalog.WithImageUsage(sandboxcatalog.Usage{})) + return &ImageStore{Paths: paths, Catalog: imageCatalog, 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 images.Platform) (*images.Importer, error) { +func NewImageImporter(ctx context.Context, store *ImageStore, reporter images.Reporter, platform types.Platform) (*images.Importer, error) { options := images.DefaultOptions() converter, err := erofs.New(ctx, platform.Architecture, options.Limits) if err != nil { @@ -94,3 +97,11 @@ func (s *ImageStore) OpenLocalSource(ctx context.Context, path string, options L 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() + return append(result, sandboxcatalog.Collections()...) +} diff --git a/core/sandbox.go b/core/sandbox.go new file mode 100644 index 0000000..f5befef --- /dev/null +++ b/core/sandbox.go @@ -0,0 +1,227 @@ +package core + +import ( + "context" + "errors" + "fmt" + "runtime" + "time" + + "github.com/kumabox/kumabox/disk" + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" + imagecatalog "github.com/kumabox/kumabox/images/catalog" + filelock "github.com/kumabox/kumabox/lock/flock" + "github.com/kumabox/kumabox/metadata" + "github.com/kumabox/kumabox/metadata/sqlite" + "github.com/kumabox/kumabox/sandbox" + sandboxcatalog "github.com/kumabox/kumabox/sandbox/catalog" + "github.com/kumabox/kumabox/storage" + "github.com/kumabox/kumabox/types" +) + +const cleanupTimeout = 10 * time.Second + +// 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 +} + +// 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 metadata capability consumed by sandbox creation. +type sandboxCatalog interface { + Reserve(context.Context, string, types.Digest, types.Sandbox) error + MarkCreated(context.Context, types.SandboxID, uint64, 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 +} + +// cowStore is the private writable-disk capability consumed by sandbox creation. +type cowStore interface { + Prepare(context.Context, types.SandboxID, int64) error + Remove(context.Context, types.SandboxID) error +} + +// CreateReporter receives user-visible stages without controlling the workflow. +type CreateReporter interface { + Status(string) error + Committed(types.Sandbox) error +} + +// SandboxService owns application ordering and resources for sandbox commands. +type SandboxService 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 commits identity, references, and state transitions. + catalog sandboxCatalog + // cows prepares and cleans the sandbox-owned writable disk. + cows cowStore + // reporter emits progress independently of command results. + reporter CreateReporter + // 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 +} + +// newSandboxService connects the explicit capabilities needed by sandbox commands. +func newSandboxService(paths sandbox.Paths, images imageGuard, catalog sandboxCatalog, cows cowStore, reporter CreateReporter) *SandboxService { + if reporter == nil { + reporter = discardReporter{} + } + return &SandboxService{paths: paths, images: images, catalog: catalog, cows: cows, reporter: reporter, newID: types.NewSandboxID, now: time.Now} +} + +// 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, roots storage.Roots, reporter CreateReporter) (*SandboxService, error) { + imagePaths, err := images.NewPaths(roots) + if err != nil { + return nil, err + } + sandboxPaths, err := sandbox.NewPaths(roots) + 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.DefaultOptions()) + if err != nil { + return nil, err + } + imageCatalog := imagecatalog.New(store, imagecatalog.WithImageUsage(sandboxcatalog.Usage{})) + sandboxCatalog := sandboxcatalog.New(store, imagecatalog.Reader{}) + service := newSandboxService(sandboxPaths, images.NewGuard(imagePaths, imageCatalog), sandboxCatalog, disk.NewExt4(sandboxPaths), reporter) + service.store = store + return service, nil +} + +// Close releases the shared metadata engine owned by the service. +func (s *SandboxService) Close() error { + if s == nil || s.store == nil { + return nil + } + return s.store.Close() +} + +// Create reserves identity and image usage before preparing the private disk. +// Only the final generation-fenced transition makes the disk startable. +// +// validate -> ID lock -> image locks + reservation -> sparse ext4 COW -> Created +// | | +// +---- failure cleanup <---+ +func (s *SandboxService) Create(ctx context.Context, request CreateSandboxRequest) (result types.Sandbox, returnErr error) { + if s == nil || s.images == nil || s.catalog == nil || s.cows == nil || s.reporter == nil || s.newID == nil || s.now == nil { + 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 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.reporter.Status("resolving and checking image"); err != nil { + return types.Sandbox{}, err + } + id, err := s.newID() + if err != nil { + return types.Sandbox{}, err + } + lockPath, err := s.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.now().UTC() + record := types.Sandbox{} + reserved := false + _, err = s.images.WithAvailable(ctx, request.ImageReference, func(image types.Image) error { + record = types.Sandbox{ + ID: id, Config: request.Config, ImageDigest: image.ManifestDigest, + State: types.SandboxStateCreating, Generation: 1, + CreatedAt: createdAt, UpdatedAt: createdAt, + } + if err := s.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) + } + if err := s.reporter.Status("creating sparse ext4 disk"); err != nil { + return types.Sandbox{}, s.compensate(ctx, record, "report", err) + } + if err := s.cows.Prepare(ctx, id, request.Config.Storage); err != nil { + return types.Sandbox{}, s.compensate(ctx, record, "disk", err) + } + if err := s.reporter.Status("committing created state"); err != nil { + return types.Sandbox{}, s.compensate(ctx, record, "report", err) + } + created, err := s.catalog.MarkCreated(ctx, id, record.Generation, s.now().UTC()) + if err != nil { + return types.Sandbox{}, s.compensate(ctx, record, "commit", err) + } + result = created + if err := s.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 +} + +// compensate removes the owned disk 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), cleanupTimeout) + defer cancel() + removeErr := s.cows.Remove(cleanupCtx, record.ID) + if removeErr == nil { + forgetErr := s.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) + } + removeErr = forgetErr + } + failure := types.SandboxFailure{Phase: phase, Message: errors.Join(cause, removeErr).Error()} + _, markErr := s.catalog.MarkError(cleanupCtx, record.ID, record.Generation, failure, s.now().UTC()) + return errdefs.Context(errors.Join(cause, removeErr, markErr), "create sandbox", record.Config.Name, phase, "inspect or remove the retained error sandbox", false) +} + +// 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_test.go b/core/sandbox_test.go new file mode 100644 index 0000000..e82dd63 --- /dev/null +++ b/core/sandbox_test.go @@ -0,0 +1,184 @@ +package core + +import ( + "context" + "errors" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/kumabox/kumabox/sandbox" + "github.com/kumabox/kumabox/storage" + "github.com/kumabox/kumabox/types" +) + +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 +} + +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, 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.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) Forget(context.Context, types.SandboxID, uint64) error { + *f.steps = append(*f.steps, "forget") + return nil +} + +type fakeDisk struct { + steps *[]string + prepare error + remove error +} + +func (f fakeDisk) Prepare(context.Context, types.SandboxID, int64) error { + *f.steps = append(*f.steps, "disk") + return f.prepare +} + +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 +} + +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() + 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) + } + steps := []string{} + catalog := &fakeCatalog{steps: &steps} + service := newSandboxService(paths, fakeGuard{image: types.Image{ManifestDigest: digest}, steps: &steps}, catalog, fakeDisk{steps: &steps, prepare: diskError}, fakeReporter{steps: &steps}) + service.newID = func() (types.SandboxID, error) { return fixedID, nil } + service.now = func() time.Time { return time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) } + return service, &steps +} + +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) + } + 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 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.images.(fakeGuard) + guard.afterUse = failure + service.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.cows.(fakeDisk) + disks.remove = removeFailure + service.cows = 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.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) + } +} diff --git a/disk/ext4.go b/disk/ext4.go new file mode 100644 index 0000000..6fcb56b --- /dev/null +++ b/disk/ext4.go @@ -0,0 +1,142 @@ +// 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 +) + +// 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 +} + +// NewExt4 creates the production disk preparer using mkfs.ext4 from PATH. +func NewExt4(paths sandbox.Paths) *Ext4 { + return &Ext4{paths: paths, mkfs: "mkfs.ext4"} +} + +// 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 +} + +// 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/ext4_test.go b/disk/ext4_test.go new file mode 100644 index 0000000..b03c44a --- /dev/null +++ b/disk/ext4_test.go @@ -0,0 +1,58 @@ +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 := &Ext4{paths: paths, 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) + } +} diff --git a/errdefs/error.go b/errdefs/error.go index ad7aeda..f4c6053 100644 --- a/errdefs/error.go +++ b/errdefs/error.go @@ -15,6 +15,8 @@ const ( 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. diff --git a/images/artifacts.go b/images/artifacts.go index ab25382..d6ce454 100644 --- a/images/artifacts.go +++ b/images/artifacts.go @@ -11,6 +11,7 @@ import ( "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. @@ -56,17 +57,17 @@ func (p Paths) LocksDir() string { return filepath.Join(p.roots.Run, "locks", "i 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 Digest) string { +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 Digest) string { +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 Digest, name string) (string, error) { +func (p Paths) BootFile(digest types.Digest, name string) (string, error) { if !IsBootName(name) { return "", fmt.Errorf("invalid boot artifact name %q", name) } @@ -74,13 +75,17 @@ func (p Paths) BootFile(digest Digest, name string) (string, error) { } // Kernel returns the conventional vmlinuz path; use BootFile for a selected versioned name. -func (p Paths) Kernel(digest Digest) string { return filepath.Join(p.BootDir(digest), "vmlinuz") } +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 Digest) string { return filepath.Join(p.BootDir(digest), "initrd.img") } +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 Digest) string { return filepath.Join(p.LocksDir(), digest.Hex()+".lock") } +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) { @@ -96,32 +101,32 @@ func (p Paths) NewStaging(pattern string) (string, error) { // 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) (Digest, int64, error) { +func digestFileContext(ctx context.Context, path string) (types.Digest, int64, error) { if err := storage.CheckPath(path); err != nil { - return Digest{}, 0, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err) + return types.Digest{}, 0, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err) } info, err := os.Lstat(path) if err != nil { - return Digest{}, 0, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err) + return types.Digest{}, 0, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, err) } if !info.Mode().IsRegular() { - return Digest{}, 0, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("artifact %s is not a regular file", path)) + 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 Digest{}, 0, err + return types.Digest{}, 0, err } file, err := root.Open(filepath.Base(path)) if err != nil { - return Digest{}, 0, errors.Join(fmt.Errorf("open %s: %w", path, err), root.Close()) + 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 Digest{}, 0, fmt.Errorf("hash %s: %w", path, err) + return types.Digest{}, 0, fmt.Errorf("hash %s: %w", path, err) } - digest, err := ParseDigest(fmt.Sprintf("sha256:%x", hash.Sum(nil))) + digest, err := types.ParseDigest(fmt.Sprintf("sha256:%x", hash.Sum(nil))) return digest, size, err } diff --git a/images/boot.go b/images/boot.go index ffea198..fe15e39 100644 --- a/images/boot.go +++ b/images/boot.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/types" ) // IsBootName accepts kernel or initrd basenames and excludes .old backups. @@ -23,13 +24,13 @@ func IsBootName(name string) bool { // (repeat for each layer) | // v // last kernel + last initrd -func SelectBoot(layers []Layer) (Boot, error) { +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 Digest + layer types.Digest // file supplies the basename and integrity facts selected for boot. - file BootFile + file types.BootFile } var candidates []candidate for _, layer := range layers { @@ -56,7 +57,7 @@ func SelectBoot(layers []Layer) (Boot, error) { candidates = kept } } - var boot Boot + var boot types.Boot for _, c := range candidates { if strings.HasPrefix(c.file.Name, "vmlinuz") { boot.KernelLayer, boot.KernelFile = c.layer, c.file.Name @@ -66,10 +67,10 @@ func SelectBoot(layers []Layer) (Boot, error) { } } if boot.KernelLayer.IsZero() { - return Boot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("image is missing a regular /boot/vmlinuz* kernel")) + return types.Boot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("image is missing a regular /boot/vmlinuz* kernel")) } if boot.InitrdLayer.IsZero() { - return Boot{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeHostIncompatible, errors.New("image is missing a regular /boot/initrd.img* initrd")) + 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 index 0c0a8a3..fd7d4a4 100644 --- a/images/catalog.go +++ b/images/catalog.go @@ -4,6 +4,8 @@ import ( "context" "errors" "time" + + "github.com/kumabox/kumabox/types" ) // ImportCommit contains image facts that a catalog must persist atomically. @@ -12,11 +14,11 @@ 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 Manifest + Manifest types.Manifest // Layers contains converted metadata in the same order as Manifest.Layers. - Layers []Layer + Layers []types.Layer // Boot must equal the overlay-aware selection derived from Layers. - Boot Boot + 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. @@ -28,17 +30,17 @@ 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 []Digest + 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) (Image, error) + Resolve(context.Context, string) (types.Image, error) // List returns committed images with their aliases and ordered layers. - List(context.Context) ([]Image, error) + List(context.Context) ([]types.Image, error) // FindLayers returns committed mappings and rejects conflicting shared artifacts. - FindLayers(context.Context, []Digest) (map[Digest]Layer, error) + FindLayers(context.Context, []types.Digest) (map[types.Digest]types.Layer, error) } // CatalogWriter changes aliases, image facts and layer references atomically. @@ -47,7 +49,7 @@ type CatalogWriter interface { 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, Digest) (Removal, error) + Remove(context.Context, string, types.Digest) (Removal, error) } // Catalog combines the metadata contracts used by image management commands. diff --git a/images/catalog/store.go b/images/catalog/store.go index fa56e9f..2b3791e 100644 --- a/images/catalog/store.go +++ b/images/catalog/store.go @@ -15,6 +15,7 @@ import ( "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) @@ -40,12 +41,42 @@ func Collections() []metadata.Collection { 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. -func New(store metadata.Store) *Store { - return &Store{store: store} +// 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. @@ -107,7 +138,7 @@ type bootFileRecord struct { Size int64 `json:"size"` } -func encodeBootFiles(files []images.BootFile) []bootFileRecord { +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}) @@ -117,10 +148,10 @@ func encodeBootFiles(files []images.BootFile) []bootFileRecord { // 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) (images.Image, error) { - var result images.Image +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 := resolveRecord(ctx, reader, reference) + image, err := (Reader{}).Resolve(ctx, reader, reference) if err != nil { return err } @@ -132,8 +163,8 @@ func (c *Store) Resolve(ctx context.Context, reference string) (images.Image, er // 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) ([]images.Image, error) { - result := make([]images.Image, 0) +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) @@ -144,7 +175,7 @@ func (c *Store) List(ctx context.Context) ([]images.Image, error) { return nil }) }) - slices.SortFunc(result, func(left, right images.Image) int { + 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) @@ -152,12 +183,12 @@ func (c *Store) List(ctx context.Context) ([]images.Image, error) { // 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 []images.Digest) (map[images.Digest]images.Layer, error) { - wanted := make(map[images.Digest]struct{}, len(digests)) +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[images.Digest]images.Layer) + 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 @@ -246,7 +277,7 @@ func (c *Store) CommitImport(ctx context.Context, commit images.ImportCommit) er // 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 images.Digest) (images.Removal, error) { +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: // @@ -295,6 +326,15 @@ func (c *Store) Remove(ctx context.Context, reference string, expected images.Di 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 } @@ -317,20 +357,20 @@ func (c *Store) Remove(ctx context.Context, reference string, expected images.Di // 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) (images.Image, error) { +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 images.Image{}, err + return types.Image{}, err } else if ok { var record nameRecord if err := json.Unmarshal(raw, &record); err != nil { - return images.Image{}, corruptRecord("name", err) + return types.Image{}, corruptRecord("name", err) } digestID = record.ManifestDigest } else { prefix := strings.TrimPrefix(reference, "sha256:") if len(prefix) < minimumDigestPrefix { - return images.Image{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("image %q not found", reference)) + 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) { @@ -341,11 +381,11 @@ func resolveRecord(ctx context.Context, reader metadata.Reader, reference string } return nil }); err != nil { - return images.Image{}, err + return types.Image{}, err } } if digestID == "" { - return images.Image{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("image %q not found", reference)) + return types.Image{}, errdefs.New(errdefs.ClassNotFound, errdefs.CodeNotFound, fmt.Errorf("image %q not found", reference)) } return loadImage(ctx, reader, digestID) } @@ -353,33 +393,33 @@ func resolveRecord(ctx context.Context, reader metadata.Reader, reference string // 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) (images.Image, error) { +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 images.Image{}, err + return types.Image{}, err } if !ok { - return images.Image{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("image record %s is missing", digestID)) + 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 images.Image{}, corruptRecord("image", err) + return types.Image{}, corruptRecord("image", err) } - manifest, err := images.ParseDigest(record.ManifestDigest) + manifest, err := types.ParseDigest(record.ManifestDigest) if err != nil { - return images.Image{}, corruptRecord("image digest", err) + return types.Image{}, corruptRecord("image digest", err) } - kernel, err := images.ParseDigest(record.KernelLayer) + kernel, err := types.ParseDigest(record.KernelLayer) if err != nil { - return images.Image{}, corruptRecord("kernel digest", err) + return types.Image{}, corruptRecord("kernel digest", err) } - initrd, err := images.ParseDigest(record.InitrdLayer) + initrd, err := types.ParseDigest(record.InitrdLayer) if err != nil { - return images.Image{}, corruptRecord("initrd digest", err) + return types.Image{}, corruptRecord("initrd digest", err) } - image := images.Image{ - ManifestDigest: manifest, Platform: images.Platform{OS: record.OS, Architecture: record.Architecture}, - Boot: images.Boot{KernelLayer: kernel, InitrdLayer: initrd, KernelFile: record.KernelFile, InitrdFile: record.InitrdFile}, Size: record.Size, CreatedAt: record.CreatedAt, + image := types.Image{ + ManifestDigest: manifest, Platform: types.Platform{OS: record.OS, Architecture: record.Architecture}, + Boot: types.Boot{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 @@ -391,7 +431,7 @@ func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (im } return nil }); err != nil { - return images.Image{}, err + return types.Image{}, err } var layerRecords []layerRecord if err := reader.Scan(ctx, CollectionLayers, func(key string, raw []byte) error { @@ -408,28 +448,28 @@ func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (im layerRecords = append(layerRecords, item) return nil }); err != nil { - return images.Image{}, err + 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 images.Image{}, corruptRecord("layer order", errors.New("noncontiguous layer positions")) + return types.Image{}, corruptRecord("layer order", errors.New("noncontiguous layer positions")) } layer, err := decodeLayer(item) if err != nil { - return images.Image{}, err + return types.Image{}, err } image.Layers = append(image.Layers, layer) } if manifest.String() != digestID { - return images.Image{}, corruptRecord("image identity", errors.New("record key differs from manifest digest")) + return types.Image{}, corruptRecord("image identity", errors.New("record key differs from manifest digest")) } - descriptors := make([]images.Descriptor, len(image.Layers)) + descriptors := make([]types.Descriptor, len(image.Layers)) for pos, layer := range image.Layers { - descriptors[pos] = images.Descriptor{Digest: layer.SourceDigest} + descriptors[pos] = types.Descriptor{Digest: layer.SourceDigest} } - if err := (images.ImportCommit{Name: "stored", Manifest: images.Manifest{Digest: manifest, Platform: image.Platform, Layers: descriptors}, Layers: image.Layers, Boot: image.Boot, Size: image.Size, Created: image.CreatedAt}).Validate(); err != nil { - return images.Image{}, corruptRecord("image facts", err) + if err := (images.ImportCommit{Name: "stored", Manifest: types.Manifest{Digest: manifest, Platform: image.Platform, 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 @@ -437,29 +477,29 @@ func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (im // decodeLayer validates serialized artifact identities and boot overlay facts. // It verifies metadata shape only; images.Verify checks the actual files. -func decodeLayer(record layerRecord) (images.Layer, error) { - source, err := images.ParseDigest(record.SourceDigest) +func decodeLayer(record layerRecord) (types.Layer, error) { + source, err := types.ParseDigest(record.SourceDigest) if err != nil { - return images.Layer{}, corruptRecord("source layer digest", err) + return types.Layer{}, corruptRecord("source layer digest", err) } - erofs, err := images.ParseDigest(record.EROFSDigest) + erofs, err := types.ParseDigest(record.EROFSDigest) if err != nil { - return images.Layer{}, corruptRecord("erofs digest", err) + return types.Layer{}, corruptRecord("erofs digest", err) } - layer := images.Layer{SourceDigest: source, EROFSDigest: erofs, Size: record.Size, Whiteouts: record.Whiteouts, BootOpaque: record.BootOpaque} + layer := types.Layer{SourceDigest: source, EROFSDigest: erofs, Size: record.Size, Whiteouts: record.Whiteouts, BootOpaque: record.BootOpaque} for _, file := range record.BootFiles { - digest, err := images.ParseDigest(file.Digest) + digest, err := types.ParseDigest(file.Digest) if err != nil || !images.IsBootName(file.Name) || file.Size <= 0 { - return images.Layer{}, corruptRecord("boot file", errors.New("invalid name, digest or size")) + return types.Layer{}, corruptRecord("boot file", errors.New("invalid name, digest or size")) } - layer.BootFiles = append(layer.BootFiles, images.BootFile{Name: file.Name, Digest: digest, Size: file.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 images.Layer{}, corruptRecord("layer", errors.New("invalid digest or size")) + return types.Layer{}, corruptRecord("layer", errors.New("invalid digest or size")) } for _, name := range layer.Whiteouts { if !images.IsBootName(name) { - return images.Layer{}, corruptRecord("whiteout", errors.New("invalid boot whiteout")) + return types.Layer{}, corruptRecord("whiteout", errors.New("invalid boot whiteout")) } } return layer, nil @@ -478,13 +518,13 @@ func corruptRecord(kind string, cause error) error { } // layerKey preserves distinct repeated source layers by using manifest position. -func layerKey(manifest images.Digest, position int) string { +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 images.Digest) (bool, error) { +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 diff --git a/images/erofs/convert.go b/images/erofs/convert.go index 5f0bc77..403d221 100644 --- a/images/erofs/convert.go +++ b/images/erofs/convert.go @@ -22,6 +22,7 @@ import ( "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/types" ) const ( @@ -68,7 +69,7 @@ func New(ctx context.Context, architecture string, limits images.Limits) (*Conve // // 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 images.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) { +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") @@ -243,7 +244,7 @@ func requireEROFSVersion(output string) error { // 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 images.Digest) string { +func deterministicUUID(digest types.Digest) string { sum := sha256.Sum256([]byte(digest.String())) sum[6] = (sum[6] & 0x0f) | 0x50 sum[8] = (sum[8] & 0x3f) | 0x80 @@ -252,18 +253,18 @@ func deterministicUUID(digest images.Digest) string { } // digestPath hashes the generated staged filesystem before it is published. -func digestPath(ctx context.Context, path string) (images.Digest, int64, error) { +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 images.Digest{}, 0, fmt.Errorf("open generated EROFS: %w", err) + 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 images.Digest{}, 0, fmt.Errorf("hash generated EROFS: %w", err) + return types.Digest{}, 0, fmt.Errorf("hash generated EROFS: %w", err) } - digest, err := images.ParseDigest(fmt.Sprintf("sha256:%x", hash.Sum(nil))) + digest, err := types.ParseDigest(fmt.Sprintf("sha256:%x", hash.Sum(nil))) return digest, size, err } 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 index cb3b3d7..4efe201 100644 --- a/images/import.go +++ b/images/import.go @@ -1,3 +1,6 @@ +// 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 ( @@ -17,6 +20,7 @@ import ( "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. @@ -24,19 +28,19 @@ import ( // must keep the source alive until Import returns. type Source interface { // Resolve selects exactly one manifest matching the requested platform. - Resolve(context.Context, Platform) (Manifest, error) + 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, Descriptor) (io.ReadCloser, error) + 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) (Image, error) + Resolve(context.Context, string) (types.Image, error) // FindLayers returns committed artifact mappings for the requested source blobs. - FindLayers(context.Context, []Digest) (map[Digest]Layer, error) + 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 } @@ -46,17 +50,17 @@ type ImportCatalog interface { 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, Descriptor, io.Reader, string) (ConvertedLayer, error) + 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 Digest + 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 Digest + EROFSDigest types.Digest // Size is the expected EROFS size in bytes. Size int64 // BootFiles contains staged regular boot candidates. @@ -80,19 +84,19 @@ type StagedBootFile struct { // 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, Digest) error + Layer(int, int, types.Digest) error // Committed runs after the atomic catalog commit and readback succeed. - Committed(Image) error + 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, Digest) error { return nil } +func (DiscardReporter) Layer(int, int, types.Digest) error { return nil } // Committed accepts a successful catalog commit without retaining it. -func (DiscardReporter) Committed(Image) error { return nil } +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. @@ -181,37 +185,37 @@ func NewImporter(paths Paths, catalog ImportCatalog, converter Converter, report // | // v // report -> unlock -> staging cleanup -func (i *Importer) Import(ctx context.Context, name string, platform Platform, source Source) (result Image, returnErr error) { +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 Image{}, invalidImage("image name, supported platform and source are required") + return types.Image{}, invalidImage("image name, supported platform and source are required") } if err := ctx.Err(); err != nil { - return Image{}, err + return types.Image{}, err } if err := i.paths.Ensure(); err != nil { - return Image{}, errdefs.Context(err, "import image", name, "prepare", "check managed directory permissions", false) + 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 Image{}, errdefs.Context(err, "import image", name, "resolve", "check the image source and platform", false) + 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 Image{}, invalidImage("invalid image manifest or platform") + return types.Image{}, invalidImage("invalid image manifest or platform") } - digests := make([]Digest, len(manifest.Layers)) + 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 Image{}, invalidImage("invalid layer descriptor") + return types.Image{}, invalidImage("invalid layer descriptor") } digests[position] = descriptor.Digest } known, err := i.catalog.FindLayers(ctx, digests) if err != nil { - return Image{}, err + return types.Image{}, err } staging, err := i.paths.NewStaging("image-*") if err != nil { - return Image{}, err + return types.Image{}, err } committed := false defer func() { @@ -243,7 +247,7 @@ func (i *Importer) Import(ctx context.Context, name string, platform Platform, s }) } if err := group.Wait(); err != nil { - return Image{}, errdefs.Context(err, "import image", name, "convert", "fix source or converter and retry", false) + 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 { @@ -251,7 +255,7 @@ func (i *Importer) Import(ctx context.Context, name string, platform Platform, s } var locks filelock.Set if err := locks.Lock(ctx, lockPaths...); err != nil { - return Image{}, err + 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)) @@ -259,12 +263,12 @@ func (i *Importer) Import(ctx context.Context, name string, platform Platform, s // Conversion is slow; metadata and files may have changed while we were staging. current, err := i.catalog.FindLayers(ctx, digests) if err != nil { - return Image{}, err + return types.Image{}, err } - layers := make([]Layer, len(converted)) + layers := make([]types.Layer, len(converted)) for pos, artifact := range converted { if err := ctx.Err(); err != nil { - return Image{}, err + return types.Image{}, err } if layer, ok := current[artifact.SourceDigest]; ok && verifyLayer(ctx, i.paths, layer) == nil { layers[pos] = layer @@ -272,21 +276,21 @@ func (i *Importer) Import(ctx context.Context, name string, platform Platform, s } // A cache hit may have been removed meanwhile. Retry without downloading under a lock. if artifact.EROFSPath == i.paths.EROFS(artifact.SourceDigest) { - return Image{}, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("cached layer changed during import; retry")) + 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 Image{}, errdefs.Context(err, "import image", name, "publish", "retry the import", false) + 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 Image{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("rebuilt layer differs from committed metadata")) + 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 Image{}, err + return types.Image{}, err } var total int64 for _, layer := range layers { @@ -295,17 +299,17 @@ func (i *Importer) Import(ctx context.Context, name string, platform Platform, s // 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 Image{}, err + 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 Image{}, errdefs.Context(err, "import image", name, "catalog commit", "retry; unregistered artifacts will be rebuilt", false) + 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 Image{}, errdefs.Context(err, "import image", name, "read committed image", "run image verify", true) + return types.Image{}, errdefs.Context(err, "import image", name, "read committed image", "run image verify", true) } i.reportMu.Lock() defer i.reportMu.Unlock() @@ -314,7 +318,7 @@ func (i *Importer) Import(ctx context.Context, name string, platform Platform, s // 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 Descriptor, workDir string) (ConvertedLayer, error) { +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 @@ -335,7 +339,7 @@ func (i *Importer) convert(ctx context.Context, source Source, descriptor Descri // 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 Layer) ConvertedLayer { +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)}) @@ -345,57 +349,57 @@ func cachedArtifact(paths Paths, layer Layer) ConvertedLayer { // 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) (Layer, error) { - layer := Layer{SourceDigest: artifact.SourceDigest, EROFSDigest: artifact.EROFSDigest, Size: artifact.Size, Whiteouts: artifact.Whiteouts, BootOpaque: artifact.BootOpaque} +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 Layer{}, err + return types.Layer{}, err } if actual != artifact.EROFSDigest || size != artifact.Size || size <= 0 { - return Layer{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("staged EROFS does not match converter result")) + 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 Layer{}, invalidImage("invalid boot file name") + return types.Layer{}, invalidImage("invalid boot file name") } digest, size, err := stagedDigest(ctx, staging, file.Path) if err != nil { - return Layer{}, err + return types.Layer{}, err } if size == 0 { - return Layer{}, invalidImage("empty boot file %s", file.Name) + return types.Layer{}, invalidImage("empty boot file %s", file.Name) } - layer.BootFiles = append(layer.BootFiles, BootFile{Name: file.Name, Digest: digest, Size: size}) + 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, []Digest{layer.SourceDigest}) + known, err := i.catalog.FindLayers(ctx, []types.Digest{layer.SourceDigest}) if err != nil { - return Layer{}, err + return types.Layer{}, err } if old, exists := known[layer.SourceDigest]; exists && !old.Equal(layer) { - return Layer{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("rebuilt layer differs from committed metadata")) + 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 Layer{}, err + return types.Layer{}, err } for _, file := range artifact.BootFiles { final, err := i.paths.BootFile(artifact.SourceDigest, file.Name) if err != nil { - return Layer{}, err + return types.Layer{}, err } if err := storage.Publish(file.Path, final); err != nil { - return Layer{}, err + 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) (Digest, int64, error) { +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 Digest{}, 0, invalidImage("converter artifact escapes staging") + return types.Digest{}, 0, invalidImage("converter artifact escapes staging") } return digestFileContext(ctx, path) } diff --git a/images/import_test.go b/images/import_test.go index 75eaa9c..b3e8cdd 100644 --- a/images/import_test.go +++ b/images/import_test.go @@ -18,6 +18,7 @@ import ( "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) { @@ -45,9 +46,9 @@ func TestImporterReusesLayerAndRemovesAliases(t *testing.T) { }() layerDigest := testDigest(t, "1") manifestDigest := testDigest(t, "2") - source := fakeSource{manifest: images.Manifest{ - Digest: manifestDigest, Platform: images.Platform{OS: "linux", Architecture: "amd64"}, - Layers: []images.Descriptor{{Digest: layerDigest, Size: 3}}, + 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) @@ -86,14 +87,14 @@ func TestImporterReusesLayerAndRemovesAliases(t *testing.T) { } type fakeSource struct { - manifest images.Manifest + manifest types.Manifest } -func (f fakeSource) Resolve(context.Context, images.Platform) (images.Manifest, error) { +func (f fakeSource) Resolve(context.Context, types.Platform) (types.Manifest, error) { return f.manifest, nil } -func (f fakeSource) OpenLayer(context.Context, images.Descriptor) (io.ReadCloser, error) { +func (f fakeSource) OpenLayer(context.Context, types.Descriptor) (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader([]byte("tar"))), nil } @@ -102,7 +103,7 @@ type fakeConverter struct { calls int } -func (f *fakeConverter) Convert(ctx context.Context, descriptor images.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) { +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 } @@ -117,7 +118,7 @@ func (f *fakeConverter) Convert(ctx context.Context, descriptor images.Descripto return images.ConvertedLayer{}, err } } - product, err := images.ParseDigest(fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("erofs")))) + product, err := types.ParseDigest(fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("erofs")))) if err != nil { return images.ConvertedLayer{}, err } @@ -133,9 +134,9 @@ func (f *fakeConverter) Calls() int { return f.calls } -func testDigest(t *testing.T, digit string) images.Digest { +func testDigest(t *testing.T, digit string) types.Digest { t.Helper() - digest, err := images.ParseDigest(fmt.Sprintf("sha256:%s", bytes.Repeat([]byte(digit), 64))) + digest, err := types.ParseDigest(fmt.Sprintf("sha256:%s", bytes.Repeat([]byte(digit), 64))) if err != nil { t.Fatalf("ParseDigest: %v", err) } @@ -167,9 +168,9 @@ func testImportState(t *testing.T, store metadata.Store) (images.Paths, *imageca return paths, imagecatalog.New(store) } -func testManifest(t *testing.T, manifestDigit string) images.Manifest { +func testManifest(t *testing.T, manifestDigit string) types.Manifest { t.Helper() - return images.Manifest{Digest: testDigest(t, manifestDigit), Platform: images.Platform{OS: "linux", Architecture: "amd64"}, Layers: []images.Descriptor{{Digest: testDigest(t, "1"), Size: 3}}} + 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 { @@ -226,7 +227,7 @@ type gatedConverter struct { release chan struct{} } -func (f *gatedConverter) Convert(ctx context.Context, descriptor images.Descriptor, reader io.Reader, workDir string) (images.ConvertedLayer, error) { +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(): @@ -341,7 +342,7 @@ type badConverter struct { escape string } -func (f *badConverter) Convert(ctx context.Context, descriptor images.Descriptor, source io.Reader, workDir string) (images.ConvertedLayer, error) { +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 } @@ -396,9 +397,9 @@ func TestImporterFailureAndCancellationDoNotCommit(t *testing.T) { func TestSelectBootAppliesOverwritesAndWhiteouts(t *testing.T) { first, second := testDigest(t, "1"), testDigest(t, "2") - layers := []images.Layer{ - {SourceDigest: first, BootFiles: []images.BootFile{{Name: "vmlinuz-1"}, {Name: "vmlinuz-2"}, {Name: "initrd.img"}}}, - {SourceDigest: second, Whiteouts: []string{"vmlinuz-2"}, BootFiles: []images.BootFile{{Name: "initrd.img"}}}, + 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 { diff --git a/images/remove.go b/images/remove.go index e1f1a5e..0e3719c 100644 --- a/images/remove.go +++ b/images/remove.go @@ -8,6 +8,7 @@ import ( "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/storage" + "github.com/kumabox/kumabox/types" filelock "github.com/kumabox/kumabox/lock/flock" ) @@ -16,7 +17,7 @@ import ( type RemovalCatalog interface { ImageResolver // Remove atomically deletes references only if the manifest binding is unchanged. - Remove(context.Context, string, Digest) (Removal, error) + Remove(context.Context, string, types.Digest) (Removal, error) } // Remove drops an alias or a manifest and deletes only layers no longer referenced diff --git a/images/source/docker.go b/images/source/docker.go index 6f7d7e1..8697be3 100644 --- a/images/source/docker.go +++ b/images/source/docker.go @@ -15,10 +15,11 @@ import ( "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/partial" - "github.com/google/go-containerregistry/pkg/v1/types" + 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. @@ -41,7 +42,7 @@ func newDockerSource(path string, options LocalOptions) (images.Source, error) { options.SourceTag = tag.Name() } source := &resolvedSource{limits: options.Limits} - source.resolve = func(ctx context.Context, platform images.Platform) (v1.Image, error) { + 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 @@ -53,7 +54,7 @@ func newDockerSource(path string, options LocalOptions) (images.Source, error) { // 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 images.Platform, sourceTag string) (dockerEntry, []byte, error) { +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 @@ -127,7 +128,7 @@ func readDockerConfig(ctx context.Context, path, object string) ([]byte, error) } // sha256:hex names, or modern blobs/sha256/hex paths). hex := strings.TrimPrefix(strings.TrimSuffix(filepath.Base(object), ".json"), "sha256:") - digest, err := images.ParseDigest("sha256:" + hex) + digest, err := types.ParseDigest("sha256:" + hex) if err != nil { return nil, invalidSource("Docker config filename must contain its sha256 digest") } @@ -156,8 +157,8 @@ func dockerImageFromEntry(ctx context.Context, path string, entry dockerEntry, c configHash := v1.Hash{Algorithm: "sha256", Hex: fmt.Sprintf("%x", sha256.Sum256(config))} manifest := v1.Manifest{ SchemaVersion: 2, - MediaType: types.OCIManifestSchema1, - Config: v1.Descriptor{MediaType: types.OCIConfigJSON, Digest: configHash, Size: int64(len(config))}, + 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)) @@ -190,11 +191,11 @@ func describeArchiveLayer(ctx context.Context, path, object string, limit int64) } buffered := bufio.NewReader(reader) magic, peekErr := buffered.Peek(4) - media := types.OCIUncompressedLayer + media := mediatypes.OCIUncompressedLayer if len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b { - media = types.OCILayer + media = mediatypes.OCILayer } else if len(magic) == 4 && bytes.Equal(magic, []byte{0x28, 0xb5, 0x2f, 0xfd}) { - media = types.OCILayerZStd + media = mediatypes.OCILayerZStd } if peekErr != nil && !errors.Is(peekErr, io.EOF) { return v1.Descriptor{}, errors.Join(peekErr, reader.Close()) @@ -225,7 +226,9 @@ type dockerImage struct { var _ partial.CompressedImageCore = (*dockerImage)(nil) // MediaType reports the synthetic OCI manifest format. -func (i *dockerImage) MediaType() (types.MediaType, error) { return types.OCIManifestSchema1, nil } +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 } diff --git a/images/source/docker_test.go b/images/source/docker_test.go index c78e140..b148792 100644 --- a/images/source/docker_test.go +++ b/images/source/docker_test.go @@ -21,6 +21,7 @@ import ( "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/types" ) func encodeJSON(t *testing.T, value any) []byte { @@ -120,10 +121,10 @@ func writeImageArchive(t *testing.T, objects map[string][]byte, compressed bool) return path } -func readSourceLayers(ctx context.Context, source images.Source, platform images.Platform) (images.Manifest, [][]byte, error) { +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 images.Manifest{}, nil, err + return types.Manifest{}, nil, err } var layers [][]byte for _, descriptor := range manifest.Layers { @@ -156,7 +157,7 @@ func TestDockerSourcePreservesLayersAndIdentity(t *testing.T) { t.Error(err) } }) - platform := images.Platform{OS: "linux", Architecture: "amd64"} + platform := types.Platform{OS: "linux", Architecture: "amd64"} manifest, layers, err := readSourceLayers(t.Context(), source, platform) if err != nil { t.Fatal(err) @@ -216,7 +217,7 @@ func TestDockerSourceSelectsTagAndPlatform(t *testing.T) { if err != nil { t.Fatal(err) } - _, _, readErr := readSourceLayers(t.Context(), source, images.Platform{OS: "linux", Architecture: test.architecture}) + _, _, readErr := readSourceLayers(t.Context(), source, types.Platform{OS: "linux", Architecture: test.architecture}) if err := cleanup(); err != nil { t.Fatal(err) } @@ -274,7 +275,7 @@ func TestDockerSourceRejectsCorruptionAndUnsafeReferences(t *testing.T) { 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, images.Platform{OS: "linux", Architecture: "amd64"}) + _, _, err = readSourceLayers(t.Context(), source, types.Platform{OS: "linux", Architecture: "amd64"}) if cleanupErr := cleanup(); cleanupErr != nil { t.Fatal(cleanupErr) } @@ -313,7 +314,7 @@ func TestDockerSourceBlobPaths(t *testing.T) { if err != nil { t.Fatal(err) } - _, layers, readErr := readSourceLayers(t.Context(), source, images.Platform{OS: "linux", Architecture: "amd64"}) + _, layers, readErr := readSourceLayers(t.Context(), source, types.Platform{OS: "linux", Architecture: "amd64"}) if err := cleanup(); err != nil { t.Fatal(err) } diff --git a/images/source/local.go b/images/source/local.go index 3a4961e..1555df6 100644 --- a/images/source/local.go +++ b/images/source/local.go @@ -10,7 +10,7 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/partial" - "github.com/google/go-containerregistry/pkg/v1/types" + mediatypes "github.com/google/go-containerregistry/pkg/v1/types" "github.com/kumabox/kumabox/images" ) @@ -218,7 +218,7 @@ func (l *fileLayer) Digest() (v1.Hash, error) { return l.descriptor.Digest, nil func (l *fileLayer) Size() (int64, error) { return l.descriptor.Size, nil } // MediaType identifies the decoder required for the stored object. -func (l *fileLayer) MediaType() (types.MediaType, error) { return l.descriptor.MediaType, nil } +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) { diff --git a/images/source/local_test.go b/images/source/local_test.go index b123491..2aa7ddb 100644 --- a/images/source/local_test.go +++ b/images/source/local_test.go @@ -11,6 +11,7 @@ import ( "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" + "github.com/kumabox/kumabox/types" ) func TestParseFormat(t *testing.T) { @@ -97,7 +98,7 @@ func TestOpenLocalFormats(t *testing.T) { staging := t.TempDir() source, cleanup, err := OpenLocal(t.Context(), path, staging, LocalOptions{Format: test.format}) if err == nil { - _, _, err = readSourceLayers(t.Context(), source, images.Platform{OS: "linux", Architecture: "amd64"}) + _, _, err = readSourceLayers(t.Context(), source, types.Platform{OS: "linux", Architecture: "amd64"}) if cleanupErr := cleanup(); cleanupErr != nil { t.Fatal(cleanupErr) } @@ -122,7 +123,7 @@ func TestOpenLocalPrefersOCIWithoutFallback(t *testing.T) { entry, dockerObjects, _ := fixtureDockerEntry(t, "amd64", "example/demo:one", "raw") maps.Copy(objects, dockerObjects) objects["manifest.json"] = encodeJSON(t, []dockerEntry{entry}) - platform := images.Platform{OS: "linux", Architecture: "amd64"} + platform := types.Platform{OS: "linux", Architecture: "amd64"} fixture, err := NewLayout("../../testdata/oci-layout") if err != nil { t.Fatal(err) @@ -193,7 +194,7 @@ func TestOpenLocalCancellation(t *testing.T) { if err != nil { t.Fatal(err) } - _, resolveErr := source.Resolve(ctx, images.Platform{OS: "linux", Architecture: "amd64"}) + _, resolveErr := source.Resolve(ctx, types.Platform{OS: "linux", Architecture: "amd64"}) if err := cleanup(); err != nil { t.Fatal(err) } diff --git a/images/source/oci.go b/images/source/oci.go index c568a23..0220b6e 100644 --- a/images/source/oci.go +++ b/images/source/oci.go @@ -11,9 +11,10 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/partial" - "github.com/google/go-containerregistry/pkg/v1/types" + 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 @@ -31,7 +32,7 @@ func NewLayoutWithLimits(path string, limits images.Limits) (images.Source, erro return nil, invalidSource("OCI size limits must be positive and bounded") } source := &resolvedSource{limits: limits} - source.resolve = func(ctx context.Context, platform images.Platform) (v1.Image, error) { + 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 @@ -80,7 +81,7 @@ func NewLayoutWithLimits(path string, limits images.Limits) (images.Source, erro } // imageForPlatform rejects absent or ambiguous matches rather than choosing by order. -func imageForPlatform(index v1.ImageIndex, platform images.Platform) (v1.Image, error) { +func imageForPlatform(index v1.ImageIndex, platform types.Platform) (v1.Image, error) { candidates, err := platformCandidates(index, platform, 0) if err != nil { return nil, err @@ -105,7 +106,7 @@ func imageForPlatform(index v1.ImageIndex, platform images.Platform) (v1.Image, // +-----------------+------------------+ // | // exactly one candidate -func platformCandidates(index v1.ImageIndex, platform images.Platform, depth int) ([]v1.Image, error) { +func platformCandidates(index v1.ImageIndex, platform types.Platform, depth int) ([]v1.Image, error) { if depth > 16 { return nil, invalidSource("OCI index nesting exceeds limit") } @@ -125,7 +126,7 @@ func platformCandidates(index v1.ImageIndex, platform images.Platform, depth int continue } switch descriptor.MediaType { - case types.OCIImageIndex, types.DockerManifestList: + case mediatypes.OCIImageIndex, mediatypes.DockerManifestList: nested, err := index.ImageIndex(descriptor.Digest) if err != nil { return nil, err @@ -142,7 +143,7 @@ func platformCandidates(index v1.ImageIndex, platform images.Platform, depth int return nil, err } candidates = append(candidates, images...) - case types.OCIManifestSchema1, types.DockerManifestSchema2: + case mediatypes.OCIManifestSchema1, mediatypes.DockerManifestSchema2: image, err := index.Image(descriptor.Digest) if err != nil { return nil, err @@ -184,7 +185,7 @@ func platformCandidates(index v1.ImageIndex, platform images.Platform, depth int // blobName converts a supported digest into the confined OCI blob path. func blobName(hash v1.Hash) (string, error) { - digest, err := images.ParseDigest(hash.String()) + digest, err := types.ParseDigest(hash.String()) if err != nil { return "", invalidSource("invalid OCI blob digest: %v", err) } @@ -202,7 +203,7 @@ type localIndex struct { } // MediaType identifies this adapter as an OCI image index. -func (i *localIndex) MediaType() (types.MediaType, error) { return types.OCIImageIndex, nil } +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) } @@ -285,7 +286,7 @@ type localImage struct { } // MediaType preserves the manifest media type declared by the parent index. -func (i *localImage) MediaType() (types.MediaType, error) { return i.descriptor.MediaType, nil } +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 } diff --git a/images/source/registry.go b/images/source/registry.go index b446456..e98d088 100644 --- a/images/source/registry.go +++ b/images/source/registry.go @@ -14,6 +14,7 @@ import ( "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. @@ -30,7 +31,7 @@ func NewRegistry(reference string) (images.Source, string, error) { 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 images.Platform) (v1.Image, error) { + 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) diff --git a/images/source/source.go b/images/source/source.go index f7ce9e3..6715e86 100644 --- a/images/source/source.go +++ b/images/source/source.go @@ -17,11 +17,12 @@ import ( "sync" v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/types" + 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. @@ -32,19 +33,19 @@ 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 images.Digest + diffID types.Digest // mediaType selects gzip, zstd, or raw decoding. - mediaType types.MediaType + 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, images.Platform) (v1.Image, error) + 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[images.Digest]resolvedLayer + layers map[types.Digest]resolvedLayer // limits bounds encoded and unpacked layer content. limits images.Limits } @@ -55,95 +56,95 @@ var _ images.Source = (*resolvedSource)(nil) // 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 images.Platform) (images.Manifest, error) { +func (s *resolvedSource) Resolve(ctx context.Context, platform types.Platform) (types.Manifest, error) { if err := ctx.Err(); err != nil { - return images.Manifest{}, err + return types.Manifest{}, err } image, err := s.resolve(ctx, platform) if err != nil { - return images.Manifest{}, sourceError(err) + return types.Manifest{}, sourceError(err) } raw, err := image.RawManifest() if err != nil { - return images.Manifest{}, sourceError(err) + return types.Manifest{}, sourceError(err) } if len(raw) > maxMetadataSize { - return images.Manifest{}, invalidSource("manifest exceeds metadata limit") + return types.Manifest{}, invalidSource("manifest exceeds metadata limit") } manifest, err := v1.ParseManifest(bytes.NewReader(raw)) if err != nil { - return images.Manifest{}, invalidSource("invalid OCI manifest: %v", err) + return types.Manifest{}, invalidSource("invalid OCI manifest: %v", err) } manifestHash, err := image.Digest() if err != nil { - return images.Manifest{}, sourceError(err) + return types.Manifest{}, sourceError(err) } if err := checkBytes(raw, manifestHash, int64(len(raw))); err != nil { - return images.Manifest{}, err + return types.Manifest{}, err } - digest, err := images.ParseDigest(manifestHash.String()) + digest, err := types.ParseDigest(manifestHash.String()) if err != nil { - return images.Manifest{}, invalidSource("invalid manifest digest: %v", err) + return types.Manifest{}, invalidSource("invalid manifest digest: %v", err) } if manifest.SchemaVersion != 2 { - return images.Manifest{}, invalidSource("unsupported OCI manifest schema version") + return types.Manifest{}, invalidSource("unsupported OCI manifest schema version") } - if manifest.Config.MediaType != types.OCIConfigJSON && manifest.Config.MediaType != types.DockerConfigJSON { - return images.Manifest{}, invalidSource("unsupported OCI config media type") + 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 images.Manifest{}, err + return types.Manifest{}, err } configRaw, err := image.RawConfigFile() if err != nil { - return images.Manifest{}, sourceError(err) + return types.Manifest{}, sourceError(err) } if err := checkBytes(configRaw, manifest.Config.Digest, manifest.Config.Size); err != nil { - return images.Manifest{}, err + return types.Manifest{}, err } config, err := v1.ParseConfigFile(bytes.NewReader(configRaw)) if err != nil { - return images.Manifest{}, invalidSource("invalid OCI config: %v", err) + return types.Manifest{}, invalidSource("invalid OCI config: %v", err) } if config.OS != platform.OS || config.Architecture != platform.Architecture { - return images.Manifest{}, invalidSource("image platform %s/%s does not match %s/%s", config.OS, config.Architecture, platform.OS, 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 images.Manifest{}, invalidSource("config rootfs does not match manifest layers") + return types.Manifest{}, invalidSource("config rootfs does not match manifest layers") } - layers := make(map[images.Digest]resolvedLayer) - descriptors := make([]images.Descriptor, len(manifest.Layers)) + 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 images.Manifest{}, err + return types.Manifest{}, err } switch desc.MediaType { - case types.OCILayer, types.OCIUncompressedLayer, types.OCILayerZStd, types.DockerLayer, types.DockerUncompressedLayer: + case mediatypes.OCILayer, mediatypes.OCIUncompressedLayer, mediatypes.OCILayerZStd, mediatypes.DockerLayer, mediatypes.DockerUncompressedLayer: default: - return images.Manifest{}, invalidSource("unsupported layer media type %s", desc.MediaType) + return types.Manifest{}, invalidSource("unsupported layer media type %s", desc.MediaType) } - digest, err := images.ParseDigest(desc.Digest.String()) + digest, err := types.ParseDigest(desc.Digest.String()) if err != nil { - return images.Manifest{}, invalidSource("invalid layer digest: %v", err) + return types.Manifest{}, invalidSource("invalid layer digest: %v", err) } - diffID, err := images.ParseDigest(config.RootFS.DiffIDs[position].String()) + diffID, err := types.ParseDigest(config.RootFS.DiffIDs[position].String()) if err != nil { - return images.Manifest{}, invalidSource("invalid layer diffID: %v", err) + return types.Manifest{}, invalidSource("invalid layer diffID: %v", err) } layer, err := image.LayerByDigest(desc.Digest) if err != nil { - return images.Manifest{}, sourceError(err) + return types.Manifest{}, sourceError(err) } if existing, ok := layers[digest]; ok && existing.diffID != diffID { - return images.Manifest{}, invalidSource("repeated layer has inconsistent diffID") + return types.Manifest{}, invalidSource("repeated layer has inconsistent diffID") } layers[digest] = resolvedLayer{layer: layer, diffID: diffID, mediaType: desc.MediaType} - descriptors[position] = images.Descriptor{Digest: digest, Size: desc.Size} + descriptors[position] = types.Descriptor{Digest: digest, Size: desc.Size} } s.mu.Lock() s.layers = layers s.mu.Unlock() - return images.Manifest{Digest: digest, Platform: platform, Layers: descriptors}, nil + return types.Manifest{Digest: digest, Platform: platform, Layers: descriptors}, nil } // OpenLayer opens a previously resolved layer as a decoded tar stream. The caller @@ -153,7 +154,7 @@ func (s *resolvedSource) Resolve(ctx context.Context, platform images.Platform) // encoded bytes --> size + digest check --> decoder --> limit + diffID check // ^ | // +--- drain encoded remainder <---+ EOF -func (s *resolvedSource) OpenLayer(ctx context.Context, descriptor images.Descriptor) (io.ReadCloser, error) { +func (s *resolvedSource) OpenLayer(ctx context.Context, descriptor types.Descriptor) (io.ReadCloser, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -172,13 +173,13 @@ func (s *resolvedSource) OpenLayer(ctx context.Context, descriptor images.Descri var input io.Reader = buffered closeDecoder := func() error { return nil } switch layer.mediaType { - case types.OCILayer, types.DockerLayer: + 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 types.OCILayerZStd: + 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()) @@ -200,7 +201,7 @@ type checkedReader struct { // hash accumulates every byte returned by reader. hash hash.Hash // expected is the stored digest or unpacked diffID. - expected images.Digest + 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. @@ -274,7 +275,7 @@ func (r *layerReader) Close() error { return errors.Join(r.closeDecoder(), r.raw // validateDescriptor accepts bounded SHA-256 objects and rejects external URLs. func validateDescriptor(desc v1.Descriptor, limit int64) error { - if _, err := images.ParseDigest(desc.Digest.String()); err != nil { + if _, err := types.ParseDigest(desc.Digest.String()); err != nil { return invalidSource("invalid descriptor: %v", err) } if desc.Size < 0 || desc.Size > limit { diff --git a/images/source/source_test.go b/images/source/source_test.go index 5d905fe..b9020b5 100644 --- a/images/source/source_test.go +++ b/images/source/source_test.go @@ -22,11 +22,12 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/v1/types" + 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 { @@ -47,7 +48,7 @@ func TestSourceChecksOCIObjects(t *testing.T) { t.Fatal(err) } source := indexSource.(*resolvedSource) - platform := images.Platform{OS: "linux", Architecture: "amd64"} + platform := types.Platform{OS: "linux", Architecture: "amd64"} resolved, err := source.resolve(t.Context(), platform) if err != nil { t.Fatal(err) @@ -123,7 +124,7 @@ func TestSourceBoundsDecompressionAndCancellation(t *testing.T) { if err != nil { t.Fatal(err) } - manifest, err := source.Resolve(t.Context(), images.Platform{OS: "linux", Architecture: "amd64"}) + manifest, err := source.Resolve(t.Context(), types.Platform{OS: "linux", Architecture: "amd64"}) if err != nil { t.Fatal(err) } @@ -157,13 +158,13 @@ func TestRegistryErrorsDoNotExposeCredentials(t *testing.T) { } } -func writeLayout(t *testing.T, compressed, unpacked []byte, media types.MediaType) string { +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 types.MediaType) v1.Descriptor { + 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) @@ -178,9 +179,9 @@ func writeLayout(t *testing.T, compressed, unpacked []byte, media types.MediaTyp 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))}}}}), types.OCIConfigJSON) - manifest := put(encode(v1.Manifest{SchemaVersion: 2, MediaType: types.OCIManifestSchema1, Config: config, Layers: []v1.Descriptor{layer}}), types.OCIManifestSchema1) - if err := os.WriteFile(filepath.Join(root, "index.json"), encode(v1.IndexManifest{SchemaVersion: 2, MediaType: types.OCIImageIndex, Manifests: []v1.Descriptor{manifest}}), 0o600); err != nil { + 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))}}}}), 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 { @@ -191,11 +192,11 @@ func writeLayout(t *testing.T, compressed, unpacked []byte, media types.MediaTyp func TestSourceSupportsLayerCompressionAndChecksDiffID(t *testing.T) { unpacked := bootTar(t, []*tar.Header{{Name: "boot/vmlinuz", Typeflag: tar.TypeReg, Size: 8}}) - for _, media := range []types.MediaType{types.OCIUncompressedLayer, types.OCILayer, types.OCILayerZStd} { + for _, media := range []mediatypes.MediaType{mediatypes.OCIUncompressedLayer, mediatypes.OCILayer, mediatypes.OCILayerZStd} { t.Run(string(media), func(t *testing.T) { compressed := unpacked switch media { - case types.OCILayer: + case mediatypes.OCILayer: var buffer bytes.Buffer writer := gzip.NewWriter(&buffer) if _, err := writer.Write(unpacked); err != nil { @@ -205,7 +206,7 @@ func TestSourceSupportsLayerCompressionAndChecksDiffID(t *testing.T) { t.Fatal(err) } compressed = buffer.Bytes() - case types.OCILayerZStd: + case mediatypes.OCILayerZStd: writer, err := zstd.NewWriter(nil) if err != nil { t.Fatal(err) @@ -224,7 +225,7 @@ func TestSourceSupportsLayerCompressionAndChecksDiffID(t *testing.T) { if err != nil { t.Fatal(err) } - manifest, err := source.Resolve(t.Context(), images.Platform{OS: "linux", Architecture: "amd64"}) + manifest, err := source.Resolve(t.Context(), types.Platform{OS: "linux", Architecture: "amd64"}) if err != nil { t.Fatal(err) } @@ -261,7 +262,7 @@ func TestRegistrySourcePullsFromHTTPRegistry(t *testing.T) { if err != nil { t.Fatal(err) } - image, err := imageForPlatform(index, images.Platform{OS: "linux", Architecture: "amd64"}) + image, err := imageForPlatform(index, types.Platform{OS: "linux", Architecture: "amd64"}) if err != nil { t.Fatal(err) } @@ -275,7 +276,7 @@ func TestRegistrySourcePullsFromHTTPRegistry(t *testing.T) { if normalized != ref.String() { t.Fatalf("reference = %s", normalized) } - manifest, err := source.Resolve(t.Context(), images.Platform{OS: "linux", Architecture: "amd64"}) + manifest, err := source.Resolve(t.Context(), types.Platform{OS: "linux", Architecture: "amd64"}) if err != nil { t.Fatal(err) } diff --git a/images/verify.go b/images/verify.go index 5bf6eba..959484f 100644 --- a/images/verify.go +++ b/images/verify.go @@ -4,55 +4,143 @@ 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) (Image, error) + Resolve(context.Context, string) (types.Image, error) } -// 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 Image, returnErr error) { - image, err := catalog.Resolve(ctx, reference) +// 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 Image{}, err + return types.Image{}, err } + expected := image.ManifestDigest lockPaths := make([]string, len(image.Layers)) for pos, layer := range image.Layers { - lockPaths[pos] = paths.Lock(layer.SourceDigest) + lockPaths[pos] = g.paths.Lock(layer.SourceDigest) } var locks filelock.Set if err := locks.Lock(ctx, lockPaths...); err != nil { - return Image{}, err + return types.Image{}, fmt.Errorf("lock image layers: %w", err) } defer func() { returnErr = errors.Join(returnErr, locks.Unlock(context.WithoutCancel(ctx))) }() - image, err = catalog.Resolve(ctx, reference) + image, err = g.catalog.Resolve(ctx, reference) if err != nil { - return Image{}, err + return types.Image{}, err } - var total int64 + 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 Image{}, errdefs.Context(err, "verify image", reference, "artifacts", "re-import the image", false) + 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) if err != nil || boot != image.Boot || total != image.Size { - return Image{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("image layer mapping or boot selection is inconsistent")) + return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("image layer mapping or boot selection is inconsistent")) } - return image, nil + 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 Layer) error { +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")) } @@ -77,7 +165,7 @@ func verifyLayer(ctx context.Context, paths Paths, layer Layer) error { } // verifyFile requires both identity and byte size to match committed metadata. -func verifyFile(ctx context.Context, path string, expected Digest, expectedSize int64) error { +func verifyFile(ctx context.Context, path string, expected types.Digest, expectedSize int64) error { digest, size, err := digestFileContext(ctx, path) if err != nil { return err diff --git a/metadata/sqlite/store.go b/metadata/sqlite/store.go index f748bf6..88eeca4 100644 --- a/metadata/sqlite/store.go +++ b/metadata/sqlite/store.go @@ -27,8 +27,8 @@ import ( const ( // applicationID distinguishes KumaBox metadata from unrelated SQLite files. applicationID = 0x4B554D41 - // schemaVersion identifies the collection/record schema accepted by this engine. - schemaVersion = 1 + // schemaVersion identifies the current application collection contract. + schemaVersion = 2 // initLockName serializes schema initialization across processes in this directory. initLockName = "init.lock" ) diff --git a/sandbox/catalog/store.go b/sandbox/catalog/store.go new file mode 100644 index 0000000..1cac4b7 --- /dev/null +++ b/sandbox/catalog/store.go @@ -0,0 +1,298 @@ +// 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" + "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"` + // ImageDigest pins the canonical manifest record. + ImageDigest string `json:"image_digest"` + // 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"` +} + +// 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 performs the create commit only when state and generation still match. +func (c *Store) MarkCreated(ctx context.Context, id types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) { + return c.transition(ctx, id, expected, types.SandboxStateCreating, types.SandboxStateCreated, nil, updated) +} + +// 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) +} + +// 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)) + } + 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 != 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, id.String()) + }) + 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 +} + +// 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, + ImageDigest: record.ImageDigest.String(), State: string(record.State), + Generation: record.Generation, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, + } + 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) + } + record := types.Sandbox{ + ID: id, Config: types.SandboxConfig{Name: data.Name, CPUs: data.CPUs, Memory: data.Memory, Storage: data.Storage}, + ImageDigest: digest, State: types.SandboxState(data.State), Generation: data.Generation, + CreatedAt: data.CreatedAt, UpdatedAt: data.UpdatedAt, + } + 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 +} + +// 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..5fcb4d7 --- /dev/null +++ b/sandbox/catalog/store_test.go @@ -0,0 +1,94 @@ +package catalog + +import ( + "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 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, 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, 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, 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) + } +} + +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/images/image.go b/types/image.go similarity index 94% rename from images/image.go rename to types/image.go index 56b3f91..384c33a 100644 --- a/images/image.go +++ b/types/image.go @@ -1,8 +1,4 @@ -// Package images defines image identities and manages imports, artifact verification, -// boot selection and removal. Source, converter and catalog adapters implement the -// contracts in this package without coupling the workflows to an archive format -// or metadata engine. -package images +package types import ( "encoding/hex" diff --git a/types/sandbox.go b/types/sandbox.go new file mode 100644 index 0000000..c4c92be --- /dev/null +++ b/types/sandbox.go @@ -0,0 +1,180 @@ +// 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" + "time" + + "github.com/kumabox/kumabox/errdefs" +) + +const ( + // DefaultSandboxCPUs matches the initial Cocoon-compatible sandbox shape. + 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 matches the minimum COW capacity accepted by Cocoon. + MinSandboxStorage int64 = 10 << 30 + // MaxSandboxCPUs bounds conversion to host-native integer APIs and unreasonable shapes. + MaxSandboxCPUs uint32 = 1024 +) + +var validSandboxName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$`) + +// 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 +} + +// 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("--cpus 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)) + } + 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 + // 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") + } + 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..f8182e4 --- /dev/null +++ b/types/sandbox_test.go @@ -0,0 +1,41 @@ +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}}, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.config.Validate(); err == nil { + t.Fatal("invalid spec passed validation") + } + }) + } +} From 939e2afc71458e40d388f793e629a6cc8229784d Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Tue, 15 Sep 2026 16:10:07 +0800 Subject: [PATCH 18/45] fix: migrate existing metadata roots --- README.md | 6 +- metadata/sqlite/store.go | 53 +++++++++++++--- metadata/sqlite/store_test.go | 110 ++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0139284..f88dad9 100644 --- a/README.md +++ b/README.md @@ -169,9 +169,9 @@ e2fsprogs must be available on the host. `Created` means the disk and metadata exist but the sandbox has never started; `Stopped` is reserved for a sandbox whose VMM has exited after a start. The -metadata schema is version 2. Pre-S3 development roots use version 1 and are -rejected rather than modified automatically; create a fresh isolated root and -re-import the image. +metadata schema is version 2. Existing version 1 roots are migrated in one +transaction when first opened: image records and artifacts remain in place, +and the new sandbox collections become available without changing CLI roots. ## Reference material diff --git a/metadata/sqlite/store.go b/metadata/sqlite/store.go index 88eeca4..482aa42 100644 --- a/metadata/sqlite/store.go +++ b/metadata/sqlite/store.go @@ -29,6 +29,8 @@ const ( applicationID = 0x4B554D41 // schemaVersion identifies the current application collection contract. schemaVersion = 2 + // 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" ) @@ -61,9 +63,8 @@ type Store struct { var _ metadata.Store = (*Store)(nil) -// Open validates paths and declarations, initializes an empty database under a -// transient file lock, and verifies database identity and existing collections. -// It rejects incompatible populated databases rather than rewriting their schema. +// 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 { @@ -199,8 +200,9 @@ func (s *Store) verify(ctx context.Context) error { return nil } -// initialize creates schema only for an unidentified empty database. The caller -// holds the directory initialization lock for this entire operation. +// 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())) @@ -223,10 +225,17 @@ func initialize(ctx context.Context, path string, collections []metadata.Collect return mapError(err) } if tables > 0 { - if appID != applicationID || version != schemaVersion { - return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, fmt.Errorf("populated database has identity %#x/version %d", appID, version)) + 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 firstSchemaVersion: + return migrateVersionOne(ctx, db, collections) + 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)) } - return nil } if appID != 0 || version != 0 { return errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("empty database has unexpected identity")) @@ -259,6 +268,34 @@ func initialize(ctx context.Context, path string, collections []metadata.Collect return commit(ctx, tx) } +// migrateVersionOne adds the collections introduced with sandbox management +// and publishes version 2 only after every declaration is durable. Version 1 +// already uses the same collections and records tables, so record payloads and +// image artifacts remain unchanged. +// +// BEGIN IMMEDIATE -> register missing collections -> user_version=2 -> COMMIT +// \---------------- any failure: ROLLBACK -----------------/ +func migrateVersionOne(ctx context.Context, db *sql.DB, collections []metadata.Collection) (returnErr error) { + 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 { diff --git a/metadata/sqlite/store_test.go b/metadata/sqlite/store_test.go index eac86c6..f70eb7e 100644 --- a/metadata/sqlite/store_test.go +++ b/metadata/sqlite/store_test.go @@ -126,6 +126,116 @@ func TestStoreRejectsForeignDatabaseWithoutChangingJournal(t *testing.T) { } } +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 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) { From 2cbd2040f4968422736666c20f786ca18e999c06 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 16 Sep 2026 09:50:21 +0800 Subject: [PATCH 19/45] feat: add retryable sandbox removal --- README.md | 14 +++ cli/root.go | 1 + cli/root_test.go | 2 + cli/sandbox/create.go | 171 ----------------------------- cli/sandbox/create_test.go | 31 +++--- cli/sandbox/progress.go | 199 ++++++++++++++++++++++++++++++++++ cli/sandbox/remove.go | 71 ++++++++++++ cli/sandbox/remove_test.go | 103 ++++++++++++++++++ core/sandbox.go | 106 +++++++++++++++--- core/sandbox_test.go | 138 ++++++++++++++++++++++- sandbox/catalog/store.go | 137 ++++++++++++++++++++--- sandbox/catalog/store_test.go | 62 +++++++++++ 12 files changed, 813 insertions(+), 222 deletions(-) create mode 100644 cli/sandbox/progress.go create mode 100644 cli/sandbox/remove.go create mode 100644 cli/sandbox/remove_test.go diff --git a/README.md b/README.md index f88dad9..95fccea 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,20 @@ metadata schema is version 2. Existing version 1 roots are migrated in one transaction when first opened: image records and artifacts remain in place, and the new sandbox collections become available without changing CLI roots. +Remove a non-running sandbox by its exact name or complete UUID: + +```bash +kumabox rm NAME +kumabox rm 123e4567-e89b-42d3-a456-426614174000 --json +``` + +Removal records durable `Deleting` intent before deleting the private disk. +If cleanup is interrupted, running the same command again resumes it. The +sandbox name and image reference are released together only after disk cleanup +succeeds. Text output is the removed sandbox's full UUID; `--json` returns its +ID and released name. Active lifecycle states are rejected until the sandbox +has been stopped; force removal will be added with VMM lifecycle support. + ## Reference material - Cocoon at `../cocoon@27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` is the diff --git a/cli/root.go b/cli/root.go index 1876784..74cea85 100644 --- a/cli/root.go +++ b/cli/root.go @@ -114,6 +114,7 @@ func newRootCommand() *cobra.Command { root.AddCommand(doctorcmd.NewCommand()) root.AddCommand(imagecmd.NewCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewCreateCommand(func() storage.Roots { return roots })) + root.AddCommand(sandboxcmd.NewRemoveCommand(func() storage.Roots { return roots })) root.AddCommand(newVersionCommand()) classifyArguments(root) return root diff --git a/cli/root_test.go b/cli/root_test.go index 659d100..d81d959 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -61,9 +61,11 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"unknown image command", []string{"image", "unknown"}, 2}, {"missing image argument", []string{"image", "inspect"}, 2}, {"missing create image", []string{"create", "--name", "box"}, 2}, + {"missing remove sandbox", []string{"rm"}, 2}, {"unknown flag", []string{"image", "ls", "--wrong"}, 2}, {"unsupported platform", []string{"image", "pull", "example.com/image", "--platform", "windows/amd64"}, 5}, {"missing image", []string{"image", "inspect", "missing"}, 3}, + {"missing sandbox", []string{"rm", "missing"}, 3}, {"empty list", []string{"image", "ls", "--json"}, 0}, } { t.Run(test.name, func(t *testing.T) { diff --git a/cli/sandbox/create.go b/cli/sandbox/create.go index adaf7c1..f193d03 100644 --- a/cli/sandbox/create.go +++ b/cli/sandbox/create.go @@ -4,18 +4,14 @@ package sandbox import ( - "context" "encoding/json" "errors" "fmt" "io" "math" - "os" "strconv" - "sync" "time" - "github.com/mattn/go-isatty" "github.com/spf13/cobra" "github.com/kumabox/kumabox/core" @@ -151,170 +147,3 @@ func parseBytes(value string) (int64, error) { func invalidFlag(name string, cause error) error { return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("--%s: %w", name, cause)) } - -// createProgress serializes a small activity animation with lifecycle callbacks. -// Redirected stderr receives plain stage lines and stdout remains command data only. -type createProgress struct { - // mu serializes ticker, callback, and result output writes. - mu sync.Mutex - // writer receives progress independently of stdout command results. - writer io.Writer - // label identifies the create operation and quoted sandbox name. - label string - // status is the current lifecycle stage. - status string - // animated selects terminal redraws instead of plain log lines. - animated bool - // committed records that the Created transition is durable. - committed bool - // frame indexes the next spinner glyph. - frame int - // err retains the first rendering failure. - err error - // stopOnce makes Finish safe if cleanup calls it more than once. - stopOnce sync.Once - // stop requests ticker shutdown. - stop chan struct{} - // done is closed after the ticker goroutine exits. - done chan struct{} -} - -var _ core.CreateReporter = (*createProgress)(nil) - -// startCreateProgress writes an initial stage before starting its ticker. -func startCreateProgress(command *cobra.Command, name string) (*createProgress, error) { - writer := command.ErrOrStderr() - file, isFile := writer.(*os.File) - progress := &createProgress{ - writer: writer, label: fmt.Sprintf("Create %q", name), status: "preparing sandbox", - animated: isFile && isatty.IsTerminal(file.Fd()), stop: make(chan struct{}), done: make(chan struct{}), - } - if err := progress.render(); err != nil { - return nil, err - } - if progress.animated { - go progress.animate(command.Context()) - } else { - close(progress.done) - } - return progress, nil -} - -// animate redraws until command cleanup finishes, cancellation occurs, or output fails. -func (p *createProgress) animate(ctx context.Context) { - defer close(p.done) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-p.stop: - return - case <-ticker.C: - p.mu.Lock() - if p.err == nil { - p.err = p.render() - } - failed := p.err != nil - p.mu.Unlock() - if failed { - return - } - } - } -} - -// Status updates the current lifecycle stage. -func (p *createProgress) Status(status string) error { - p.mu.Lock() - defer p.mu.Unlock() - if p.err != nil { - return p.err - } - p.status = status - p.err = p.render() - return p.err -} - -// Committed records that Created is durable before output and cleanup finish. -func (p *createProgress) Committed(types.Sandbox) error { - p.mu.Lock() - defer p.mu.Unlock() - p.committed = true - p.status = "finishing" - return p.err -} - -// Output coordinates stdout writes with terminal redraws. -func (p *createProgress) Output(writer io.Writer) io.Writer { - return progressWriter{progress: p, writer: writer} -} - -// progressWriter prevents a live animation from visually mixing with command output. -type progressWriter struct { - // progress owns output serialization and animation state. - progress *createProgress - // writer receives the unchanged command result. - writer io.Writer -} - -func (w progressWriter) Write(data []byte) (int, error) { - p := w.progress - p.mu.Lock() - defer p.mu.Unlock() - if p.err != nil { - return 0, p.err - } - if p.animated { - if _, err := fmt.Fprint(p.writer, "\r\x1b[2K"); err != nil { - p.err = err - return 0, err - } - } - n, writeErr := w.writer.Write(data) - if p.animated { - p.err = p.render() - } - return n, errors.Join(writeErr, p.err) -} - -// Finish joins the ticker and emits one unambiguous final status line. -func (p *createProgress) Finish(operationErr error) error { - p.stopOnce.Do(func() { close(p.stop); <-p.done }) - p.mu.Lock() - defer p.mu.Unlock() - var classified *errdefs.Error - if errors.As(operationErr, &classified) && classified.Committed { - p.committed = true - } - resultText, symbol := "complete", "✓" - if operationErr != nil || p.err != nil { - resultText, symbol = "failed", "✗" - if p.committed { - resultText = "committed with errors" - } else if errors.Is(operationErr, context.Canceled) { - resultText = "canceled" - } - } - message := fmt.Sprintf("%s %s", p.label, resultText) - if p.animated { - message = "\r\x1b[2K" + symbol + " " + message - } - _, err := fmt.Fprintln(p.writer, message) - return errdefs.Context(errors.Join(p.err, err), "create sandbox", p.label, "report", "inspect the sandbox state", p.committed) -} - -// render writes one spinner frame or one plain stage line. The caller holds mu -// after animation starts. -func (p *createProgress) render() error { - message := p.label + " · " + p.status - if p.animated { - frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - _, err := fmt.Fprintf(p.writer, "\r\x1b[2K%s %s", frames[p.frame%len(frames)], message) - p.frame++ - return err - } - _, err := fmt.Fprintln(p.writer, message) - return err -} diff --git a/cli/sandbox/create_test.go b/cli/sandbox/create_test.go index 5c31018..e1bad47 100644 --- a/cli/sandbox/create_test.go +++ b/cli/sandbox/create_test.go @@ -90,16 +90,7 @@ func TestCreateCommandPersistsCreatedSandboxAndFinalCOW(t *testing.T) { Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"), } seedImage(t, roots) - 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")) + installFakeMKFS(t, base) command := NewCreateCommand(func() storage.Roots { return roots }) command.SetArgs([]string{"demo", "--name", "box", "--cpus", "1", "--json"}) var stdout, stderr bytes.Buffer @@ -199,9 +190,23 @@ func digestOf(t *testing.T, data []byte) types.Digest { return digest } -func newTestProgress(writer *bytes.Buffer) (*createProgress, error) { - progress := &createProgress{ - writer: writer, label: `Create "box"`, status: "preparing sandbox", +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) { + progress := &sandboxProgress{ + writer: writer, operation: "create sandbox", label: `Create "box"`, status: "preparing sandbox", recovery: "inspect the sandbox state", stop: make(chan struct{}), done: make(chan struct{}), } if err := progress.render(); err != nil { diff --git a/cli/sandbox/progress.go b/cli/sandbox/progress.go new file mode 100644 index 0000000..ed742be --- /dev/null +++ b/cli/sandbox/progress.go @@ -0,0 +1,199 @@ +package sandbox + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "sync" + "time" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/core" + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/types" +) + +// sandboxProgress serializes terminal animation, stage callbacks, and result output. +// Redirected stderr receives plain stage lines and stdout remains command data only. +type sandboxProgress struct { + // mu serializes ticker, callback, and result output writes. + mu sync.Mutex + // writer receives progress independently of stdout command results. + writer io.Writer + // operation supplies error context such as create sandbox or remove sandbox. + operation string + // label identifies the operation and quoted user-facing reference. + label string + // status is the current application workflow stage. + status string + // recovery tells callers how to handle a progress rendering failure. + recovery string + // animated selects terminal redraws instead of plain log lines. + animated bool + // committed records that durable state changed despite a later failure. + committed bool + // frame indexes the next spinner glyph. + frame int + // err retains the first rendering failure. + err error + // stopOnce makes Finish safe if cleanup calls it more than once. + stopOnce sync.Once + // stop requests ticker shutdown. + stop chan struct{} + // done is closed after the ticker goroutine exits. + done chan struct{} +} + +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") +} + +// 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") +} + +// startProgress writes an initial stage before starting its ticker. +func startProgress(command *cobra.Command, operation, label, status, recovery string) (*sandboxProgress, error) { + writer := command.ErrOrStderr() + file, isFile := writer.(*os.File) + progress := &sandboxProgress{ + writer: writer, operation: operation, label: label, status: status, recovery: recovery, + animated: isFile && isatty.IsTerminal(file.Fd()), stop: make(chan struct{}), done: make(chan struct{}), + } + if err := progress.render(); err != nil { + return nil, err + } + if progress.animated { + go progress.animate(command.Context()) + } else { + close(progress.done) + } + return progress, nil +} + +// animate redraws until command cleanup finishes, cancellation occurs, or output fails. +func (p *sandboxProgress) animate(ctx context.Context) { + defer close(p.done) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-p.stop: + return + case <-ticker.C: + p.mu.Lock() + if p.err == nil { + p.err = p.render() + } + failed := p.err != nil + p.mu.Unlock() + if failed { + return + } + } + } +} + +// Status updates the current workflow stage. +func (p *sandboxProgress) Status(status string) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return p.err + } + p.status = status + p.err = p.render() + return p.err +} + +// Committed records that durable application state changed before reporting finished. +func (p *sandboxProgress) Committed(types.Sandbox) error { + p.mu.Lock() + defer p.mu.Unlock() + p.committed = true + p.status = "finishing" + return p.err +} + +// Output coordinates stdout writes with terminal redraws. +func (p *sandboxProgress) Output(writer io.Writer) io.Writer { + return progressWriter{progress: p, writer: writer} +} + +// progressWriter prevents a live animation from visually mixing with command output. +type progressWriter struct { + // progress owns output serialization and animation state. + progress *sandboxProgress + // writer receives the unchanged command result. + writer io.Writer +} + +func (w progressWriter) Write(data []byte) (int, error) { + p := w.progress + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return 0, p.err + } + if p.animated { + if _, err := fmt.Fprint(p.writer, "\r\x1b[2K"); err != nil { + p.err = err + return 0, err + } + } + n, writeErr := w.writer.Write(data) + if p.animated { + p.err = p.render() + } + return n, errors.Join(writeErr, p.err) +} + +// Finish joins the ticker and emits one unambiguous final status line. +func (p *sandboxProgress) Finish(operationErr error) error { + p.stopOnce.Do(func() { close(p.stop); <-p.done }) + p.mu.Lock() + defer p.mu.Unlock() + var classified *errdefs.Error + if errors.As(operationErr, &classified) && classified.Committed { + p.committed = true + } + resultText, symbol := "complete", "✓" + if operationErr != nil || p.err != nil { + resultText, symbol = "failed", "✗" + if p.committed { + resultText = "committed with errors" + } else if errors.Is(operationErr, context.Canceled) { + resultText = "canceled" + } + } + message := fmt.Sprintf("%s %s", p.label, resultText) + if p.animated { + message = "\r\x1b[2K" + symbol + " " + message + } + _, err := fmt.Fprintln(p.writer, message) + return errdefs.Context(errors.Join(p.err, err), p.operation, p.label, "report", p.recovery, p.committed) +} + +// render writes one spinner frame or one plain stage line. The caller holds mu +// after animation starts. +func (p *sandboxProgress) render() error { + message := p.label + " · " + p.status + if p.animated { + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + _, err := fmt.Fprintf(p.writer, "\r\x1b[2K%s %s", frames[p.frame%len(frames)], message) + p.frame++ + return err + } + _, err := fmt.Fprintln(p.writer, message) + return err +} diff --git a/cli/sandbox/remove.go b/cli/sandbox/remove.go new file mode 100644 index 0000000..19e7264 --- /dev/null +++ b/cli/sandbox/remove.go @@ -0,0 +1,71 @@ +package sandbox + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/core" + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/types" +) + +// 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"` +} + +// NewRemoveCommand builds the top-level sandbox removal command. +func NewRemoveCommand(roots rootsProvider) *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(), roots(), 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 +} + +// 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}) +} diff --git a/cli/sandbox/remove_test.go b/cli/sandbox/remove_test.go new file mode 100644 index 0000000..d71c1a3 --- /dev/null +++ b/cli/sandbox/remove_test.go @@ -0,0 +1,103 @@ +package sandbox + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "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" +) + +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") + remove := NewRemoveCommand(func() storage.Roots { return 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) + } + + secondID := executeCreate(t, roots, "box") + if secondID == firstID { + t.Fatal("recreated sandbox reused immutable ID") + } + remove = NewRemoveCommand(func() storage.Roots { return 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(), 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() storage.Roots { return roots }) + command.SetArgs([]string{"demo", "--name", name, "--cpus", "1"}) + 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/core/sandbox.go b/core/sandbox.go index f5befef..02a92c0 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -35,22 +35,29 @@ type imageGuard interface { WithAvailable(context.Context, string, func(types.Image) error) (types.Image, error) } -// sandboxCatalog is the metadata capability consumed by sandbox creation. -type sandboxCatalog interface { +// sandboxCreator is the metadata capability consumed by sandbox creation. +type sandboxCreator interface { Reserve(context.Context, string, types.Digest, types.Sandbox) error MarkCreated(context.Context, types.SandboxID, uint64, 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 } +// sandboxRemover is the metadata capability consumed by sandbox removal. +type sandboxRemover interface { + Resolve(context.Context, string) (types.Sandbox, error) + BeginDelete(context.Context, types.SandboxID, uint64, time.Time) (types.Sandbox, error) + FinalizeDelete(context.Context, types.SandboxID, uint64) error +} + // cowStore is the private writable-disk capability consumed by sandbox creation. type cowStore interface { Prepare(context.Context, types.SandboxID, int64) error Remove(context.Context, types.SandboxID) error } -// CreateReporter receives user-visible stages without controlling the workflow. -type CreateReporter interface { +// SandboxReporter receives user-visible stages without controlling workflows. +type SandboxReporter interface { Status(string) error Committed(types.Sandbox) error } @@ -61,12 +68,14 @@ type SandboxService struct { paths sandbox.Paths // images closes the verify/pin race with image removal. images imageGuard - // catalog commits identity, references, and state transitions. - catalog sandboxCatalog + // creator commits identity, image references, and create transitions. + creator sandboxCreator + // remover resolves references and commits delete transitions. + remover sandboxRemover // cows prepares and cleans the sandbox-owned writable disk. cows cowStore // reporter emits progress independently of command results. - reporter CreateReporter + reporter SandboxReporter // newID and now are replaceable in same-package tests. newID func() (types.SandboxID, error) now func() time.Time @@ -75,11 +84,11 @@ type SandboxService struct { } // newSandboxService connects the explicit capabilities needed by sandbox commands. -func newSandboxService(paths sandbox.Paths, images imageGuard, catalog sandboxCatalog, cows cowStore, reporter CreateReporter) *SandboxService { +func newSandboxService(paths sandbox.Paths, images imageGuard, creator sandboxCreator, remover sandboxRemover, cows cowStore, reporter SandboxReporter) *SandboxService { if reporter == nil { reporter = discardReporter{} } - return &SandboxService{paths: paths, images: images, catalog: catalog, cows: cows, reporter: reporter, newID: types.NewSandboxID, now: time.Now} + return &SandboxService{paths: paths, images: images, creator: creator, remover: remover, cows: cows, reporter: reporter, newID: types.NewSandboxID, now: time.Now} } // OpenSandbox assembles the image guard, metadata catalog, and ext4 COW adapter @@ -88,7 +97,7 @@ func newSandboxService(paths sandbox.Paths, images imageGuard, catalog sandboxCa // shared SQLite -> image catalog <---- transaction reader ---- sandbox catalog // | ^ | // +---- usage ---+---- image guard + ext4 COW ----------> service -func OpenSandbox(ctx context.Context, roots storage.Roots, reporter CreateReporter) (*SandboxService, error) { +func OpenSandbox(ctx context.Context, roots storage.Roots, reporter SandboxReporter) (*SandboxService, error) { imagePaths, err := images.NewPaths(roots) if err != nil { return nil, err @@ -106,7 +115,7 @@ func OpenSandbox(ctx context.Context, roots storage.Roots, reporter CreateReport } imageCatalog := imagecatalog.New(store, imagecatalog.WithImageUsage(sandboxcatalog.Usage{})) sandboxCatalog := sandboxcatalog.New(store, imagecatalog.Reader{}) - service := newSandboxService(sandboxPaths, images.NewGuard(imagePaths, imageCatalog), sandboxCatalog, disk.NewExt4(sandboxPaths), reporter) + service := newSandboxService(sandboxPaths, images.NewGuard(imagePaths, imageCatalog), sandboxCatalog, sandboxCatalog, disk.NewExt4(sandboxPaths), reporter) service.store = store return service, nil } @@ -126,7 +135,7 @@ func (s *SandboxService) Close() error { // | | // +---- failure cleanup <---+ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxRequest) (result types.Sandbox, returnErr error) { - if s == nil || s.images == nil || s.catalog == nil || s.cows == nil || s.reporter == nil || s.newID == nil || s.now == nil { + if s == nil || s.images == nil || s.creator == nil || s.cows == nil || s.reporter == nil || s.newID == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if request.ImageReference == "" { @@ -170,7 +179,7 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques State: types.SandboxStateCreating, Generation: 1, CreatedAt: createdAt, UpdatedAt: createdAt, } - if err := s.catalog.Reserve(ctx, request.ImageReference, image.ManifestDigest, record); err != nil { + if err := s.creator.Reserve(ctx, request.ImageReference, image.ManifestDigest, record); err != nil { return err } reserved = true @@ -191,7 +200,7 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques if err := s.reporter.Status("committing created state"); err != nil { return types.Sandbox{}, s.compensate(ctx, record, "report", err) } - created, err := s.catalog.MarkCreated(ctx, id, record.Generation, s.now().UTC()) + created, err := s.creator.MarkCreated(ctx, id, record.Generation, s.now().UTC()) if err != nil { return types.Sandbox{}, s.compensate(ctx, record, "commit", err) } @@ -202,6 +211,71 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques return created, nil } +// Remove records cleanup intent before deleting the COW directory and releases +// the name and image reference only after filesystem cleanup succeeds. +// +// resolve -> sandbox lock -> Deleting -> remove files -> forget record + name +// | | +// +---- retry resumes here <---+ +func (s *SandboxService) Remove(ctx context.Context, reference string) (result types.Sandbox, returnErr error) { + if s == nil || s.remover == nil || s.cows == nil || s.reporter == nil || s.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.reporter.Status("resolving sandbox"); err != nil { + return types.Sandbox{}, err + } + record, err := s.remover.Resolve(ctx, reference) + if err != nil { + return types.Sandbox{}, err + } + lockPath, err := s.paths.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 + } + 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) + } + }() + if err := s.reporter.Status("marking sandbox for deletion"); err != nil { + return types.Sandbox{}, err + } + deleting, err := s.remover.BeginDelete(ctx, record.ID, record.Generation, s.now().UTC()) + if err != nil { + return types.Sandbox{}, err + } + committed = true + result = deleting + if err := s.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.cows.Remove(ctx, deleting.ID); err != nil { + return deleting, errdefs.Context(err, "remove sandbox", reference, "disk cleanup", "retry removal to finish cleanup", true) + } + if err := s.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.remover.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.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 the owned disk 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 { @@ -209,14 +283,14 @@ func (s *SandboxService) compensate(ctx context.Context, record types.Sandbox, p defer cancel() removeErr := s.cows.Remove(cleanupCtx, record.ID) if removeErr == nil { - forgetErr := s.catalog.Forget(cleanupCtx, record.ID, record.Generation) + forgetErr := s.creator.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) } removeErr = forgetErr } failure := types.SandboxFailure{Phase: phase, Message: errors.Join(cause, removeErr).Error()} - _, markErr := s.catalog.MarkError(cleanupCtx, record.ID, record.Generation, failure, s.now().UTC()) + _, markErr := s.creator.MarkError(cleanupCtx, record.ID, record.Generation, failure, s.now().UTC()) return errdefs.Context(errors.Join(cause, removeErr, markErr), "create sandbox", record.Config.Name, phase, "inspect or remove the retained error sandbox", false) } diff --git a/core/sandbox_test.go b/core/sandbox_test.go index e82dd63..af20967 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/sandbox" "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/types" @@ -30,8 +31,9 @@ func (f fakeGuard) WithAvailable(ctx context.Context, _ string, use func(types.I } type fakeCatalog struct { - steps *[]string - record types.Sandbox + steps *[]string + record types.Sandbox + deleted bool } func (f *fakeCatalog) Reserve(_ context.Context, _ string, _ types.Digest, record types.Sandbox) error { @@ -61,6 +63,42 @@ func (f *fakeCatalog) Forget(context.Context, types.SandboxID, uint64) error { 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) 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 @@ -107,7 +145,7 @@ func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]s } steps := []string{} catalog := &fakeCatalog{steps: &steps} - service := newSandboxService(paths, fakeGuard{image: types.Image{ManifestDigest: digest}, steps: &steps}, catalog, fakeDisk{steps: &steps, prepare: diskError}, fakeReporter{steps: &steps}) + service := newSandboxService(paths, fakeGuard{image: types.Image{ManifestDigest: digest}, steps: &steps}, catalog, catalog, fakeDisk{steps: &steps, prepare: diskError}, fakeReporter{steps: &steps}) service.newID = func() (types.SandboxID, error) { return fixedID, nil } service.now = func() time.Time { return time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) } return service, &steps @@ -173,7 +211,7 @@ func TestCreateRetainsErrorOwnerWhenDiskCleanupFails(t *testing.T) { }); !errors.Is(err, prepareFailure) || !errors.Is(err, removeFailure) { t.Fatalf("Create error = %v", err) } - catalog := service.catalog.(*fakeCatalog) + catalog := service.creator.(*fakeCatalog) if catalog.record.State != types.SandboxStateError || catalog.record.Failure == nil || catalog.record.Failure.Phase != "disk" { t.Fatalf("retained record = %+v", catalog.record) } @@ -182,3 +220,95 @@ func TestCreateRetainsErrorOwnerWhenDiskCleanupFails(t *testing.T) { 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", + "status:marking sandbox for deletion", "deleting", "status:removing sandbox disk", "remove", + "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.cows.(fakeDisk) + disks.remove = failure + service.cows = 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.remover.(*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.cows = 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", + "status:marking sandbox for deletion", "deleting", "status:removing sandbox disk", "remove", + "status:releasing metadata and image reference", "finalize", "report", + }) { + t.Fatalf("retry steps = %v", got) + } +} + +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.remover.(*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/sandbox/catalog/store.go b/sandbox/catalog/store.go index 1cac4b7..9c31e07 100644 --- a/sandbox/catalog/store.go +++ b/sandbox/catalog/store.go @@ -142,6 +142,74 @@ func (c *Store) MarkError(ctx context.Context, id types.SandboxID, expected uint return c.transition(ctx, id, expected, types.SandboxStateCreating, types.SandboxStateError, &failure, 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) +} + +// 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 @@ -180,24 +248,7 @@ func (c *Store) Forget(ctx context.Context, id types.SandboxID, expected uint64) 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)) } - 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 != 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, id.String()) + return deleteRecord(ctx, writer, record) }) return errdefs.Context(err, "forget sandbox", id.String(), "metadata", "inspect the retained sandbox record", false) } @@ -241,6 +292,56 @@ func load(ctx context.Context, reader metadata.Reader, id types.SandboxID) (type 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{ diff --git a/sandbox/catalog/store_test.go b/sandbox/catalog/store_test.go index 5fcb4d7..049af7f 100644 --- a/sandbox/catalog/store_test.go +++ b/sandbox/catalog/store_test.go @@ -1,6 +1,7 @@ package catalog import ( + "encoding/json" "testing" "time" @@ -11,6 +12,28 @@ import ( "github.com/kumabox/kumabox/types" ) +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 TestReservationPinsImageInsideRemovalTransaction(t *testing.T) { collections := append(imagecatalog.Collections(), Collections()...) store, err := metadata.NewMemory(collections) @@ -77,6 +100,45 @@ func TestReservationPinsImageInsideRemovalTransaction(t *testing.T) { } 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) + } + } + deleting, err := sandboxStore.BeginDelete(t.Context(), id, createdRecord.Generation, created.Add(3*time.Second)) + if err != nil { + t.Fatal(err) + } + if deleting.State != types.SandboxStateDeleting || deleting.Generation != 3 { + t.Fatalf("deleting record = %+v", deleting) + } + resumed, err := sandboxStore.BeginDelete(t.Context(), id, deleting.Generation, created.Add(4*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 { From ce1c3e5ddd7342d52bbbaa6e9e87c3ec574301fa Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 16 Sep 2026 10:11:27 +0800 Subject: [PATCH 20/45] feat: add sandbox listing --- README.md | 15 ++++ cli/root.go | 1 + cli/root_test.go | 3 + cli/sandbox/create.go | 45 +--------- cli/sandbox/create_test.go | 6 +- cli/sandbox/output.go | 131 ++++++++++++++++++++++++++++ cli/sandbox/query.go | 48 ++++++++++ cli/sandbox/query_test.go | 160 ++++++++++++++++++++++++++++++++++ cli/sandbox/remove.go | 23 ----- core/sandbox.go | 42 +++++++-- core/sandbox_test.go | 37 +++++++- sandbox/catalog/store.go | 31 +++++++ sandbox/catalog/store_test.go | 44 ++++++++++ 13 files changed, 509 insertions(+), 77 deletions(-) create mode 100644 cli/sandbox/output.go create mode 100644 cli/sandbox/query.go create mode 100644 cli/sandbox/query_test.go diff --git a/README.md b/README.md index 95fccea..8ae3baf 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,21 @@ metadata schema is version 2. Existing version 1 roots are migrated in one transaction when first opened: image records and artifacts remain in place, and the new sandbox collections become available without changing CLI roots. +List active sandboxes with `ps`, or include created, stopped, failed, and +deleting records with `-a`: + +```bash +kumabox ps +kumabox ps -a +kumabox ps -a --quiet +kumabox ps -a --json +``` + +The table always includes headers and prints complete sandbox UUIDs that can be +passed directly to `rm`. `--quiet` writes only those UUIDs, one per line. JSON +uses the same complete resource facts as `create --json` and returns `[]` for +an empty result. + Remove a non-running sandbox by its exact name or complete UUID: ```bash diff --git a/cli/root.go b/cli/root.go index 74cea85..8492029 100644 --- a/cli/root.go +++ b/cli/root.go @@ -114,6 +114,7 @@ func newRootCommand() *cobra.Command { root.AddCommand(doctorcmd.NewCommand()) root.AddCommand(imagecmd.NewCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewCreateCommand(func() storage.Roots { return roots })) + root.AddCommand(sandboxcmd.NewListCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewRemoveCommand(func() storage.Roots { return roots })) root.AddCommand(newVersionCommand()) classifyArguments(root) diff --git a/cli/root_test.go b/cli/root_test.go index d81d959..d48d94b 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -62,11 +62,14 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"missing image argument", []string{"image", "inspect"}, 2}, {"missing create image", []string{"create", "--name", "box"}, 2}, {"missing remove sandbox", []string{"rm"}, 2}, + {"unexpected ps argument", []string{"ps", "box"}, 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 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...) diff --git a/cli/sandbox/create.go b/cli/sandbox/create.go index f193d03..958f4dc 100644 --- a/cli/sandbox/create.go +++ b/cli/sandbox/create.go @@ -4,13 +4,10 @@ package sandbox import ( - "encoding/json" "errors" "fmt" - "io" "math" "strconv" - "time" "github.com/spf13/cobra" @@ -66,7 +63,7 @@ func NewCreateCommand(roots rootsProvider) *cobra.Command { return err } committed = true - if err := writeResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil { + if err := writeCreateResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil { return errdefs.Context(err, "create sandbox", name, "output", "sandbox was created; inspect it before retrying", true) } return nil @@ -80,46 +77,6 @@ func NewCreateCommand(roots rootsProvider) *cobra.Command { return command } -// result is the stable JSON projection returned by create --json. -type result struct { - // ID is the complete immutable sandbox UUID. - ID string `json:"id"` - // Name is the human-readable lookup key supplied by the user. - Name string `json:"name"` - // ImageDigest is the exact pinned manifest identity. - ImageDigest string `json:"image_digest"` - // State is created after persistent resources are ready. - 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"` - // Generation fences stale lifecycle transitions. - Generation uint64 `json:"generation"` - // CreatedAt is the identity reservation time. - CreatedAt time.Time `json:"created_at"` - // UpdatedAt is the Created transition time. - UpdatedAt time.Time `json:"updated_at"` -} - -// writeResult keeps the default output script-friendly and JSON complete. -func writeResult(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(result{ - ID: sandbox.ID.String(), Name: sandbox.Config.Name, ImageDigest: sandbox.ImageDigest.String(), - State: string(sandbox.State), CPUs: sandbox.Config.CPUs, Memory: sandbox.Config.Memory, - Storage: sandbox.Config.Storage, Generation: sandbox.Generation, - CreatedAt: sandbox.CreatedAt, UpdatedAt: sandbox.UpdatedAt, - }) -} - // parseBytes accepts integer bytes or binary IEC units without floating-point rounding. func parseBytes(value string) (int64, error) { if value == "" { diff --git a/cli/sandbox/create_test.go b/cli/sandbox/create_test.go index e1bad47..8fc9cc4 100644 --- a/cli/sandbox/create_test.go +++ b/cli/sandbox/create_test.go @@ -53,14 +53,14 @@ func TestWriteResultUsesFullIDAndIndentedJSON(t *testing.T) { CreatedAt: time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 15, 10, 0, 1, 0, time.UTC), } var text bytes.Buffer - if err := writeResult(&text, record, false); err != nil { + if err := writeCreateResult(&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 := writeResult(&jsonOut, record, true); err != nil { + if err := writeCreateResult(&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") { @@ -99,7 +99,7 @@ func TestCreateCommandPersistsCreatedSandboxAndFinalCOW(t *testing.T) { if err := command.ExecuteContext(t.Context()); err != nil { t.Fatal(err) } - var output result + var output sandboxOutput if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode output %q: %v", stdout.String(), err) } diff --git a/cli/sandbox/output.go b/cli/sandbox/output.go new file mode 100644 index 0000000..1a72af1 --- /dev/null +++ b/cli/sandbox/output.go @@ -0,0 +1,131 @@ +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"` + // 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"` + // Generation fences stale lifecycle transitions. + Generation uint64 `json:"generation"` + // 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"` +} + +// 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 { + return sandboxOutput{ + ID: sandbox.ID.String(), Name: sandbox.Config.Name, ImageDigest: sandbox.ImageDigest.String(), + State: string(sandbox.State), CPUs: sandbox.Config.CPUs, Memory: sandbox.Config.Memory, + Storage: sandbox.Config.Storage, Generation: sandbox.Generation, + CreatedAt: sandbox.CreatedAt.UTC(), UpdatedAt: sandbox.UpdatedAt.UTC(), + } +} + +// writeCreateResult keeps default output script-friendly and JSON complete. +func writeCreateResult(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(sandboxResult(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\tSTATE\tCPUS\tMEMORY\tSTORAGE\tCREATED"); err != nil { + return err + } + for _, record := range records { + if _, err := fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", + record.ID, record.Config.Name, record.ImageDigest.Hex()[:12], record.State, record.Config.CPUs, + formatIECBytes(record.Config.Memory), formatIECBytes(record.Config.Storage), + 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/query.go b/cli/sandbox/query.go new file mode 100644 index 0000000..bb87824 --- /dev/null +++ b/cli/sandbox/query.go @@ -0,0 +1,48 @@ +package sandbox + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/kumabox/kumabox/core" + "github.com/kumabox/kumabox/errdefs" +) + +// NewListCommand builds the top-level Docker-style sandbox process listing. +func NewListCommand(roots rootsProvider) *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(), roots(), 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..7f8974b --- /dev/null +++ b/cli/sandbox/query_test.go @@ -0,0 +1,160 @@ +package sandbox + +import ( + "bytes" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/storage" + "github.com/kumabox/kumabox/types" +) + +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 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() storage.Roots { return 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() storage.Roots { return 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) + } +} + +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{ + "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 executeList(t *testing.T, roots storage.Roots, args ...string) string { + t.Helper() + command := NewListCommand(func() storage.Roots { return 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, State: types.SandboxStateCreated, Generation: 2, + CreatedAt: created, UpdatedAt: created.Add(time.Second), + } +} diff --git a/cli/sandbox/remove.go b/cli/sandbox/remove.go index 19e7264..469bd05 100644 --- a/cli/sandbox/remove.go +++ b/cli/sandbox/remove.go @@ -1,26 +1,14 @@ package sandbox import ( - "encoding/json" "errors" - "fmt" - "io" "github.com/spf13/cobra" "github.com/kumabox/kumabox/core" "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/types" ) -// 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"` -} - // NewRemoveCommand builds the top-level sandbox removal command. func NewRemoveCommand(roots rootsProvider) *cobra.Command { asJSON := false @@ -58,14 +46,3 @@ func NewRemoveCommand(roots rootsProvider) *cobra.Command { command.Flags().BoolVar(&asJSON, "json", false, "print the removed sandbox as indented JSON") return command } - -// 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}) -} diff --git a/core/sandbox.go b/core/sandbox.go index 02a92c0..5462397 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -43,9 +43,14 @@ type sandboxCreator interface { Forget(context.Context, types.SandboxID, uint64) error } +// sandboxReader is the metadata capability consumed by sandbox queries and lookup. +type sandboxReader interface { + Resolve(context.Context, string) (types.Sandbox, error) + List(context.Context) ([]types.Sandbox, error) +} + // sandboxRemover is the metadata capability consumed by sandbox removal. type sandboxRemover interface { - Resolve(context.Context, string) (types.Sandbox, error) BeginDelete(context.Context, types.SandboxID, uint64, time.Time) (types.Sandbox, error) FinalizeDelete(context.Context, types.SandboxID, uint64) error } @@ -70,7 +75,9 @@ type SandboxService struct { images imageGuard // creator commits identity, image references, and create transitions. creator sandboxCreator - // remover resolves references and commits delete transitions. + // reader supplies consistent sandbox snapshots without changing state. + reader sandboxReader + // remover commits generation-fenced delete transitions. remover sandboxRemover // cows prepares and cleans the sandbox-owned writable disk. cows cowStore @@ -84,11 +91,11 @@ type SandboxService struct { } // newSandboxService connects the explicit capabilities needed by sandbox commands. -func newSandboxService(paths sandbox.Paths, images imageGuard, creator sandboxCreator, remover sandboxRemover, cows cowStore, reporter SandboxReporter) *SandboxService { +func newSandboxService(paths sandbox.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, cows cowStore, reporter SandboxReporter) *SandboxService { if reporter == nil { reporter = discardReporter{} } - return &SandboxService{paths: paths, images: images, creator: creator, remover: remover, cows: cows, reporter: reporter, newID: types.NewSandboxID, now: time.Now} + return &SandboxService{paths: paths, images: images, creator: creator, reader: reader, remover: remover, cows: cows, reporter: reporter, newID: types.NewSandboxID, now: time.Now} } // OpenSandbox assembles the image guard, metadata catalog, and ext4 COW adapter @@ -115,7 +122,7 @@ func OpenSandbox(ctx context.Context, roots storage.Roots, reporter SandboxRepor } imageCatalog := imagecatalog.New(store, imagecatalog.WithImageUsage(sandboxcatalog.Usage{})) sandboxCatalog := sandboxcatalog.New(store, imagecatalog.Reader{}) - service := newSandboxService(sandboxPaths, images.NewGuard(imagePaths, imageCatalog), sandboxCatalog, sandboxCatalog, disk.NewExt4(sandboxPaths), reporter) + service := newSandboxService(sandboxPaths, images.NewGuard(imagePaths, imageCatalog), sandboxCatalog, sandboxCatalog, sandboxCatalog, disk.NewExt4(sandboxPaths), reporter) service.store = store return service, nil } @@ -211,6 +218,29 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques return created, 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.reader == nil { + return nil, errors.New("sandbox service is not configured") + } + records, err := s.reader.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 +} + // Remove records cleanup intent before deleting the COW directory and releases // the name and image reference only after filesystem cleanup succeeds. // @@ -227,7 +257,7 @@ func (s *SandboxService) Remove(ctx context.Context, reference string) (result t if err := s.reporter.Status("resolving sandbox"); err != nil { return types.Sandbox{}, err } - record, err := s.remover.Resolve(ctx, reference) + record, err := s.reader.Resolve(ctx, reference) if err != nil { return types.Sandbox{}, err } diff --git a/core/sandbox_test.go b/core/sandbox_test.go index af20967..3707073 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -71,6 +71,14 @@ func (f *fakeCatalog) Resolve(_ context.Context, _ string) (types.Sandbox, error 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 { @@ -145,7 +153,7 @@ func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]s } steps := []string{} catalog := &fakeCatalog{steps: &steps} - service := newSandboxService(paths, fakeGuard{image: types.Image{ManifestDigest: digest}, steps: &steps}, catalog, catalog, fakeDisk{steps: &steps, prepare: diskError}, fakeReporter{steps: &steps}) + service := newSandboxService(paths, fakeGuard{image: types.Image{ManifestDigest: digest}, steps: &steps}, catalog, catalog, catalog, fakeDisk{steps: &steps, prepare: diskError}, fakeReporter{steps: &steps}) service.newID = func() (types.SandboxID, error) { return fixedID, nil } service.now = func() time.Time { return time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) } return service, &steps @@ -221,6 +229,33 @@ func TestCreateRetainsErrorOwnerWhenDiskCleanupFails(t *testing.T) { } } +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.reader.(*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 TestRemoveMarksDeletingBeforeDiskAndFinalizesAfterCleanup(t *testing.T) { service, steps := newTestSandboxService(t, nil) if _, err := service.Create(t.Context(), CreateSandboxRequest{ diff --git a/sandbox/catalog/store.go b/sandbox/catalog/store.go index 9c31e07..5ca10b6 100644 --- a/sandbox/catalog/store.go +++ b/sandbox/catalog/store.go @@ -8,6 +8,8 @@ import ( "encoding/json" "errors" "fmt" + "slices" + "strings" "time" "github.com/kumabox/kumabox/errdefs" @@ -157,6 +159,35 @@ func (c *Store) Resolve(ctx context.Context, reference string) (types.Sandbox, e 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) { diff --git a/sandbox/catalog/store_test.go b/sandbox/catalog/store_test.go index 049af7f..db10c2c 100644 --- a/sandbox/catalog/store_test.go +++ b/sandbox/catalog/store_test.go @@ -34,6 +34,50 @@ func TestResolveRejectsDanglingNameBinding(t *testing.T) { } } +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, 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) From 01fee5b99a259a2e1dc078106b26f470519d0d76 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 16 Sep 2026 10:20:18 +0800 Subject: [PATCH 21/45] feat: add sandbox inspection --- README.md | 8 +++++ cli/root.go | 1 + cli/root_test.go | 3 ++ cli/sandbox/output.go | 27 ++++++++++++++--- cli/sandbox/query.go | 25 ++++++++++++++++ cli/sandbox/query_test.go | 61 +++++++++++++++++++++++++++++++++++++-- core/sandbox.go | 14 ++++++++- core/sandbox_test.go | 21 ++++++++++++++ 8 files changed, 153 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8ae3baf..7cd1bf4 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,14 @@ passed directly to `rm`. `--quiet` writes only those UUIDs, one per line. JSON uses the same complete resource facts as `create --json` and returns `[]` for an empty result. +Inspect one sandbox by its exact name or complete UUID. The command always +writes indented JSON, including retained failure diagnostics when present: + +```bash +kumabox inspect NAME +kumabox inspect 123e4567-e89b-42d3-a456-426614174000 +``` + Remove a non-running sandbox by its exact name or complete UUID: ```bash diff --git a/cli/root.go b/cli/root.go index 8492029..09d203d 100644 --- a/cli/root.go +++ b/cli/root.go @@ -114,6 +114,7 @@ func newRootCommand() *cobra.Command { root.AddCommand(doctorcmd.NewCommand()) root.AddCommand(imagecmd.NewCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewCreateCommand(func() storage.Roots { return roots })) + root.AddCommand(sandboxcmd.NewInspectCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewListCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewRemoveCommand(func() storage.Roots { return roots })) root.AddCommand(newVersionCommand()) diff --git a/cli/root_test.go b/cli/root_test.go index d48d94b..5a3fc9c 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -61,12 +61,15 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"unknown image command", []string{"image", "unknown"}, 2}, {"missing image argument", []string{"image", "inspect"}, 2}, {"missing create image", []string{"create", "--name", "box"}, 2}, + {"missing inspect sandbox", []string{"inspect"}, 2}, {"missing remove sandbox", []string{"rm"}, 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 sandbox", []string{"rm", "missing"}, 3}, {"empty list", []string{"image", "ls", "--json"}, 0}, {"empty ps", []string{"ps", "--all", "--json"}, 0}, diff --git a/cli/sandbox/output.go b/cli/sandbox/output.go index 1a72af1..66ccba6 100644 --- a/cli/sandbox/output.go +++ b/cli/sandbox/output.go @@ -30,12 +30,22 @@ type sandboxOutput struct { Storage int64 `json:"storage"` // 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"` +} + // removeOutput is the stable JSON result for a completed sandbox removal. type removeOutput struct { // ID is the immutable identity whose resources were deleted. @@ -46,12 +56,23 @@ type removeOutput struct { // sandboxResult projects a validated domain record into the CLI JSON schema. func sandboxResult(sandbox types.Sandbox) sandboxOutput { - return sandboxOutput{ + result := sandboxOutput{ ID: sandbox.ID.String(), Name: sandbox.Config.Name, ImageDigest: sandbox.ImageDigest.String(), State: string(sandbox.State), CPUs: sandbox.Config.CPUs, Memory: sandbox.Config.Memory, Storage: sandbox.Config.Storage, Generation: sandbox.Generation, CreatedAt: sandbox.CreatedAt.UTC(), UpdatedAt: sandbox.UpdatedAt.UTC(), } + if sandbox.Failure != nil { + result.Failure = &sandboxFailureOutput{Phase: sandbox.Failure.Phase, Message: sandbox.Failure.Message} + } + 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)) } // writeCreateResult keeps default output script-friendly and JSON complete. @@ -60,9 +81,7 @@ func writeCreateResult(writer io.Writer, sandbox types.Sandbox, asJSON bool) err _, err := fmt.Fprintln(writer, sandbox.ID) return err } - encoder := json.NewEncoder(writer) - encoder.SetIndent("", " ") - return encoder.Encode(sandboxResult(sandbox)) + return writeSandboxJSON(writer, sandbox) } // writeRemoveResult keeps text output script-friendly and JSON self-describing. diff --git a/cli/sandbox/query.go b/cli/sandbox/query.go index bb87824..cde0f28 100644 --- a/cli/sandbox/query.go +++ b/cli/sandbox/query.go @@ -9,6 +9,31 @@ import ( "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(roots rootsProvider) *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(), roots(), 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 +} + // NewListCommand builds the top-level Docker-style sandbox process listing. func NewListCommand(roots rootsProvider) *cobra.Command { var includeAll, asJSON, quiet bool diff --git a/cli/sandbox/query_test.go b/cli/sandbox/query_test.go index 7f8974b..8ece6ba 100644 --- a/cli/sandbox/query_test.go +++ b/cli/sandbox/query_test.go @@ -57,6 +57,49 @@ func TestEmptySandboxOutputsRemainScriptFriendly(t *testing.T) { } } +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{ @@ -117,8 +160,9 @@ func (writer sandboxFailingOutput) Write([]byte) (int, error) { return 0, writer func TestSandboxListOutputPreservesWriteErrors(t *testing.T) { failure := errors.New("output closed") for name, write := range map[string]func() error{ - "table": func() error { return writeSandboxTable(sandboxFailingOutput{failure}, nil) }, - "json": func() error { return writeSandboxListJSON(sandboxFailingOutput{failure}, nil) }, + "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)}) }, @@ -131,6 +175,19 @@ func TestSandboxListOutputPreservesWriteErrors(t *testing.T) { } } +func executeInspect(t *testing.T, roots storage.Roots, reference string) string { + t.Helper() + command := NewInspectCommand(func() storage.Roots { return 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() storage.Roots { return roots }) diff --git a/core/sandbox.go b/core/sandbox.go index 5462397..f95130e 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -241,6 +241,18 @@ func (s *SandboxService) List(ctx context.Context, includeAll bool) ([]types.San 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.reader == 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.reader.Resolve(ctx, reference) +} + // Remove records cleanup intent before deleting the COW directory and releases // the name and image reference only after filesystem cleanup succeeds. // @@ -248,7 +260,7 @@ func (s *SandboxService) List(ctx context.Context, includeAll bool) ([]types.San // | | // +---- retry resumes here <---+ func (s *SandboxService) Remove(ctx context.Context, reference string) (result types.Sandbox, returnErr error) { - if s == nil || s.remover == nil || s.cows == nil || s.reporter == nil || s.now == nil { + if s == nil || s.reader == nil || s.remover == nil || s.cows == nil || s.reporter == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if reference == "" { diff --git a/core/sandbox_test.go b/core/sandbox_test.go index 3707073..e314502 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -5,6 +5,7 @@ import ( "errors" "path/filepath" "reflect" + "strings" "testing" "time" @@ -256,6 +257,26 @@ func TestListFiltersInactiveSandboxesUnlessAllRequested(t *testing.T) { } } +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) + } +} + func TestRemoveMarksDeletingBeforeDiskAndFinalizesAfterCleanup(t *testing.T) { service, steps := newTestSandboxService(t, nil) if _, err := service.Create(t.Context(), CreateSandboxRequest{ From 4ad06ebb74de9b2347103c00954a2449673af058 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 16 Sep 2026 10:27:03 +0800 Subject: [PATCH 22/45] fix: update kumabox image short description --- cli/image/command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/image/command.go b/cli/image/command.go index b0ba5a4..8237355 100644 --- a/cli/image/command.go +++ b/cli/image/command.go @@ -19,7 +19,7 @@ type rootsProvider func() storage.Roots // NewCommand registers the image command tree using invocation-local storage roots. func NewCommand(roots rootsProvider) *cobra.Command { - command := &cobra.Command{Use: "image", Short: "manage container images", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { return command.Help() }} + 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(roots), newImportCommand(roots), From 6b0a676ee00a81a63eee7157dd90fe01d1ace25f Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 16 Sep 2026 17:28:23 +0800 Subject: [PATCH 23/45] feat: start sandboxes with cloud hypervisor --- Makefile | 3 +- README.md | 34 ++- cgroup/cgroup.go | 44 ++++ cgroup/manager_linux.go | 176 ++++++++++++++ cgroup/manager_other.go | 27 +++ cli/image/command_test.go | 7 +- cli/image/output.go | 4 +- cli/root.go | 3 +- cli/root_test.go | 1 + cli/sandbox/progress.go | 5 + cli/sandbox/start.go | 48 ++++ core/sandbox.go | 305 ++++++++++++++++++++++- core/sandbox_test.go | 214 ++++++++++++++++- disk/ext4.go | 19 ++ errdefs/error.go | 2 + images/catalog.go | 1 + images/catalog/store.go | 11 +- images/import.go | 3 + images/source/docker_test.go | 8 +- images/source/source.go | 3 +- images/source/source_test.go | 9 +- images/verify.go | 3 + oci-images/ubuntu/Dockerfile | 63 +++++ oci-images/ubuntu/README.md | 29 +++ oci-images/ubuntu/overlay.sh | 107 +++++++++ sandbox/catalog/store.go | 58 +++++ sandbox/catalog/store_test.go | 39 ++- types/image.go | 22 ++ vmm/cloudhypervisor/args.go | 50 ++++ vmm/cloudhypervisor/args_test.go | 91 +++++++ vmm/cloudhypervisor/driver.go | 346 +++++++++++++++++++++++++++ vmm/cloudhypervisor/process_linux.go | 195 +++++++++++++++ vmm/cloudhypervisor/process_other.go | 32 +++ vmm/paths.go | 223 +++++++++++++++++ vmm/paths_test.go | 45 ++++ vmm/vmm.go | 148 ++++++++++++ vmm/vmm_test.go | 38 +++ 37 files changed, 2390 insertions(+), 26 deletions(-) create mode 100644 cgroup/cgroup.go create mode 100644 cgroup/manager_linux.go create mode 100644 cgroup/manager_other.go create mode 100644 cli/sandbox/start.go create mode 100644 oci-images/ubuntu/Dockerfile create mode 100644 oci-images/ubuntu/README.md create mode 100755 oci-images/ubuntu/overlay.sh create mode 100644 vmm/cloudhypervisor/args.go create mode 100644 vmm/cloudhypervisor/args_test.go create mode 100644 vmm/cloudhypervisor/driver.go create mode 100644 vmm/cloudhypervisor/process_linux.go create mode 100644 vmm/cloudhypervisor/process_other.go create mode 100644 vmm/paths.go create mode 100644 vmm/paths_test.go create mode 100644 vmm/vmm.go create mode 100644 vmm/vmm_test.go diff --git a/Makefile b/Makefile index 02dfcf2..cc33aba 100644 --- a/Makefile +++ b/Makefile @@ -75,8 +75,9 @@ install: build ## Install kumabox and kumabox-check test: vet ## Run tests with race detection and coverage go test -race -timeout 120s -count=1 -cover -coverprofile=coverage.out ./... -doctor-check: ## Check the doctor script syntax +doctor-check: ## Check host and guest shell script syntax bash -n scripts/kumabox-check.sh + sh -n oci-images/ubuntu/overlay.sh race: ## Run all Go tests with race detection go test -race ./... diff --git a/README.md b/README.md index 7cd1bf4..bb71f74 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ Docker-like command line; sandboxes are Cloud Hypervisor microVMs booted from OCI images, with CNI networking, cgroups, snapshots and clone. The rewrite currently provides the `kumabox` CLI, the host doctor, container -image management, and persistent sandbox creation. Each command opens its -metadata store, performs one operation, and exits. Starting a VMM and the rest -of the sandbox lifecycle are later phases of [docs/ROADMAP.md](docs/ROADMAP.md). +image management, persistent sandbox creation, and recoverable Cloud Hypervisor +startup. Each command opens its metadata store, performs one operation, and +exits. The remaining sandbox lifecycle is tracked in +[docs/ROADMAP.md](docs/ROADMAP.md). ## Where the design lives @@ -57,6 +58,8 @@ or a generic `pkg` container: | `images/erofs` | Convert source layers and extract boot candidates | | `sandbox`, `sandbox/catalog` | Sandbox filesystem ownership and metadata persistence | | `disk` | Prepare and remove sandbox-owned sparse ext4 COW disks | +| `vmm`, `vmm/cloudhypervisor` | VMM launch contracts, process identity, arguments and readiness | +| `cgroup` | Per-sandbox cgroup v2 preparation and reclamation | | `metadata`, `metadata/sqlite` | Engine-neutral transactions and the SQLite implementation | | `storage`, `lock/flock` | Managed filesystem operations and file locks | | `errdefs`, `version` | Error classification and build information | @@ -89,9 +92,12 @@ The host checker source is `scripts/kumabox-check.sh`. ## Container images Pull or import a Linux image containing regular `/boot/vmlinuz*` and -`/boot/initrd.img*` files. Image conversion requires `mkfs.erofs` 1.8 or newer; -unit and integration tests use a stand-in and run on macOS without root/KVM. -The [synthetic fixture](testdata/oci-layout/README.md) cannot boot a VM. +`/boot/initrd.img*` files. A bootable KumaBox image also declares the OCI config +label `io.kumabox.boot.profile=overlay-v1`; older images without the label remain +importable and inspectable but will be rejected by `start`. Image conversion +requires `mkfs.erofs` 1.8 or newer; unit and integration tests use a stand-in and +run on macOS without root/KVM. The +[synthetic fixture](testdata/oci-layout/README.md) cannot boot a VM. ```bash kumabox image pull REGISTRY/IMAGE:TAG --platform linux/amd64 @@ -106,6 +112,9 @@ kumabox image rm tiny `image ls` prints a table with names, 12-character image IDs, platforms, human-readable sizes, and creation timestamps in UTC. `image inspect` and `image ls --json` print indented JSON with full digests and numeric sizes. +The inspect response reports the declaration as `boot.profile`; an empty value +means the source did not declare a boot contract. KumaBox never guesses a profile +from kernel or initrd filenames. Import and pull show a live spinner and completed layer counts on a terminal. Verification and removal also show waiting status; removal reports completed image counts. Redirected progress uses plain lines on stderr. Results are @@ -134,6 +143,11 @@ identifies the imported config and ordered layers and may differ from the original registry manifest digest. Repacking or changing source tags preserves the imported digest. Docker images must meet the same kernel/initrd requirements. +The reference Ubuntu guest image is built from +[`oci-images/ubuntu`](oci-images/ubuntu). Its independently implemented initramfs +script consumes only `kumabox.*` kernel parameters and virtio serials. It does +not expose or depend on another runtime's guest protocol. + For a separate data store, pass all three roots: ```bash @@ -173,6 +187,14 @@ metadata schema is version 2. Existing version 1 roots are migrated in one transaction when first opened: image records and artifacts remain in place, and the new sandbox collections become available without changing CLI roots. +`start SANDBOX` accepts an exact name or complete UUID. It checks KVM, the +Cloud Hypervisor executable, the pinned image, its declared `overlay-v1` boot +profile, and the existing ext4 COW before committing `Starting`. It records a +PID-reuse-safe process identity and commits `Running` only after the private +Cloud Hypervisor API reports readiness. Retrying recovers the same `Starting` +generation; a failed launch is terminated and retained as `Error` with a +diagnostic. `--json` returns the complete indented sandbox object. + List active sandboxes with `ps`, or include created, stopped, failed, and deleting records with `-a`: 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/manager_linux.go b/cgroup/manager_linux.go new file mode 100644 index 0000000..6e7ede3 --- /dev/null +++ b/cgroup/manager_linux.go @@ -0,0 +1,176 @@ +//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) + } + weight := min(int(cpus), 10_000) + if err := writeControl(directory, "cpu.weight", strconv.Itoa(weight)); err != nil { + return nil, err + } + quota := int64(cpus) * cpuPeriodMicros + if err := writeControl(directory, "cpu.max", fmt.Sprintf("%d %d", quota, cpuPeriodMicros)); 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 +} + +// 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_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/cli/image/command_test.go b/cli/image/command_test.go index be9732f..bdda162 100644 --- a/cli/image/command_test.go +++ b/cli/image/command_test.go @@ -19,6 +19,7 @@ import ( "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)) { @@ -167,6 +168,10 @@ func TestImageCommandsFromDockerArchive(t *testing.T) { 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) @@ -194,7 +199,7 @@ func TestImageCommandsFromDockerArchive(t *testing.T) { if err := json.Unmarshal([]byte(out), &first); err != nil { t.Fatal(err) } - if len(first.Names) != 2 || len(first.Layers) != 1 || first.Boot.KernelFile == "" || first.Boot.InitrdFile == "" { + 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 { diff --git a/cli/image/output.go b/cli/image/output.go index 5a76e41..e2ba9b5 100644 --- a/cli/image/output.go +++ b/cli/image/output.go @@ -65,6 +65,8 @@ type ( } // 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. @@ -86,7 +88,7 @@ func imageResult(image types.Image) imageOutput { } 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{KernelLayer: image.Boot.KernelLayer.String(), KernelFile: image.Boot.KernelFile, InitrdLayer: image.Boot.InitrdLayer.String(), InitrdFile: image.Boot.InitrdFile}, Size: image.Size, CreatedAt: image.CreatedAt} + 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. diff --git a/cli/root.go b/cli/root.go index 09d203d..0f77dc3 100644 --- a/cli/root.go +++ b/cli/root.go @@ -117,6 +117,7 @@ func newRootCommand() *cobra.Command { root.AddCommand(sandboxcmd.NewInspectCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewListCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewRemoveCommand(func() storage.Roots { return roots })) + root.AddCommand(sandboxcmd.NewStartCommand(func() storage.Roots { return roots })) root.AddCommand(newVersionCommand()) classifyArguments(root) return root @@ -153,7 +154,7 @@ func errorExitCode(err error) int { return 3 case errdefs.CodeNameTaken, errdefs.CodeStateConflict, errdefs.CodeReferenced: return 4 - case errdefs.CodeInvalidArgument, errdefs.CodeHostIncompatible, errdefs.CodeDigestMismatch, errdefs.CodeArtifactCorrupt: + case errdefs.CodeInvalidArgument, errdefs.CodeHostIncompatible, errdefs.CodeImageIncompatible, errdefs.CodeDigestMismatch, errdefs.CodeArtifactCorrupt: return 5 case errdefs.CodeArtifactUnavailable, errdefs.CodeStoreBusy: return 6 diff --git a/cli/root_test.go b/cli/root_test.go index 5a3fc9c..34ca5f9 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -63,6 +63,7 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"missing create image", []string{"create", "--name", "box"}, 2}, {"missing inspect sandbox", []string{"inspect"}, 2}, {"missing remove sandbox", []string{"rm"}, 2}, + {"missing start sandbox", []string{"start"}, 2}, {"unexpected ps argument", []string{"ps", "box"}, 2}, {"unsupported inspect flag", []string{"inspect", "box", "--json"}, 2}, {"unknown flag", []string{"image", "ls", "--wrong"}, 2}, diff --git a/cli/sandbox/progress.go b/cli/sandbox/progress.go index ed742be..2d41041 100644 --- a/cli/sandbox/progress.go +++ b/cli/sandbox/progress.go @@ -60,6 +60,11 @@ func startRemoveProgress(command *cobra.Command, reference string) (*sandboxProg 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") +} + // startProgress writes an initial stage before starting its ticker. func startProgress(command *cobra.Command, operation, label, status, recovery string) (*sandboxProgress, error) { writer := command.ErrOrStderr() diff --git a/cli/sandbox/start.go b/cli/sandbox/start.go new file mode 100644 index 0000000..330e55f --- /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(roots rootsProvider) *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(), roots(), 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 := writeCreateResult(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/core/sandbox.go b/core/sandbox.go index f95130e..dcdcaf3 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -7,6 +7,7 @@ import ( "runtime" "time" + "github.com/kumabox/kumabox/cgroup" "github.com/kumabox/kumabox/disk" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" @@ -18,6 +19,8 @@ import ( sandboxcatalog "github.com/kumabox/kumabox/sandbox/catalog" "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/types" + "github.com/kumabox/kumabox/vmm" + "github.com/kumabox/kumabox/vmm/cloudhypervisor" ) const cleanupTimeout = 10 * time.Second @@ -55,12 +58,32 @@ type sandboxRemover interface { FinalizeDelete(context.Context, types.SandboxID, uint64) error } +// sandboxStarter is the generation-fenced metadata capability used by start. +type sandboxStarter interface { + 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) + MarkStopped(context.Context, types.SandboxID, uint64, time.Time) (types.Sandbox, error) +} + // cowStore is the private writable-disk capability consumed by sandbox creation. type cowStore interface { Prepare(context.Context, types.SandboxID, int64) error + Check(context.Context, types.SandboxID, int64) error Remove(context.Context, types.SandboxID) error } +// vmmRuntime is the process capability consumed by lifecycle orchestration. +// Its implementation owns process identity and readiness, not durable state. +type vmmRuntime interface { + Preflight() error + Observe(context.Context, types.SandboxID, uint64) (vmm.Observation, error) + WaitReady(context.Context, vmm.Process) error + Launch(context.Context, vmm.LaunchPlan) (vmm.Process, error) + Abort(context.Context, vmm.Process) error + Cleanup(context.Context, types.SandboxID) error +} + // SandboxReporter receives user-visible stages without controlling workflows. type SandboxReporter interface { Status(string) error @@ -79,8 +102,14 @@ type SandboxService struct { reader sandboxReader // remover commits generation-fenced delete transitions. remover sandboxRemover + // starter commits generation-fenced start, running, error, and recovery transitions. + starter sandboxStarter // cows prepares and cleans the sandbox-owned writable disk. cows cowStore + // imagePaths derives immutable artifacts after the image guard verifies them. + imagePaths images.Paths + // runtime owns Cloud Hypervisor process identity, readiness, and cleanup. + runtime vmmRuntime // reporter emits progress independently of command results. reporter SandboxReporter // newID and now are replaceable in same-package tests. @@ -91,11 +120,15 @@ type SandboxService struct { } // newSandboxService connects the explicit capabilities needed by sandbox commands. -func newSandboxService(paths sandbox.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, cows cowStore, reporter SandboxReporter) *SandboxService { +func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, starter sandboxStarter, cows cowStore, runtime vmmRuntime, reporter SandboxReporter) *SandboxService { if reporter == nil { reporter = discardReporter{} } - return &SandboxService{paths: paths, images: images, creator: creator, reader: reader, remover: remover, cows: cows, reporter: reporter, newID: types.NewSandboxID, now: time.Now} + return &SandboxService{ + paths: paths, imagePaths: imagePaths, images: images, creator: creator, reader: reader, + remover: remover, starter: starter, cows: cows, runtime: runtime, reporter: reporter, + newID: types.NewSandboxID, now: time.Now, + } } // OpenSandbox assembles the image guard, metadata catalog, and ext4 COW adapter @@ -122,7 +155,22 @@ func OpenSandbox(ctx context.Context, roots storage.Roots, reporter SandboxRepor } imageCatalog := imagecatalog.New(store, imagecatalog.WithImageUsage(sandboxcatalog.Usage{})) sandboxCatalog := sandboxcatalog.New(store, imagecatalog.Reader{}) - service := newSandboxService(sandboxPaths, images.NewGuard(imagePaths, imageCatalog), sandboxCatalog, sandboxCatalog, sandboxCatalog, disk.NewExt4(sandboxPaths), reporter) + vmmPaths, err := vmm.NewPaths(roots) + if err != nil { + return nil, errors.Join(err, store.Close()) + } + scopes, err := cgroup.New("") + if err != nil { + return nil, errors.Join(err, store.Close()) + } + runtimeDriver, err := cloudhypervisor.New(vmmPaths, scopes, cloudhypervisor.Options{}) + if err != nil { + return nil, errors.Join(err, store.Close()) + } + service := newSandboxService( + sandboxPaths, imagePaths, images.NewGuard(imagePaths, imageCatalog), sandboxCatalog, + sandboxCatalog, sandboxCatalog, sandboxCatalog, disk.NewExt4(sandboxPaths), runtimeDriver, reporter, + ) service.store = store return service, nil } @@ -253,6 +301,257 @@ func (s *SandboxService) Inspect(ctx context.Context, reference string) (types.S return s.reader.Resolve(ctx, reference) } +// 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.reader == nil || s.starter == nil || s.images == nil || s.cows == nil || s.runtime == nil || s.reporter == nil || s.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.reporter.Status("resolving sandbox"); err != nil { + return types.Sandbox{}, err + } + record, err := s.reader.Resolve(ctx, reference) + if err != nil { + return types.Sandbox{}, err + } + lockPath, err := s.paths.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 + } + 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.reader.Resolve(ctx, record.ID.String()) + if err != nil { + return types.Sandbox{}, err + } + beforeRecovery := record + result, done, err := s.recoverStart(ctx, 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.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, record, phase, cause, vmm.Process{}) + } + return errdefs.Context(cause, "start sandbox", reference, phase, "fix the validation failure and retry", committed) + } + + if err := s.reporter.Status("checking host runtime"); err != nil { + return record, failBeforeLaunch("report", err) + } + if err := s.runtime.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.reporter.Status("verifying image and sandbox disk"); err != nil { + return record, failBeforeLaunch("report", err) + } + var plan vmm.LaunchPlan + _, err = s.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.cows.Check(ctx, record.ID, record.Config.Storage) + }) + if err != nil { + return record, failBeforeLaunch("validate artifacts", err) + } + + if err := s.reporter.Status("committing starting state"); err != nil { + return record, failBeforeLaunch("report", err) + } + starting, err := s.starter.BeginStart(ctx, record.ID, record.Generation, s.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, starting, "build launch plan", err, vmm.Process{}) + } + if err := s.reporter.Status("launching Cloud Hypervisor"); err != nil { + return starting, s.failStart(ctx, starting, "report", err, vmm.Process{}) + } + process, err := s.runtime.Launch(ctx, plan) + if err != nil { + return starting, s.failStart(ctx, starting, "launch VMM", err, process) + } + if err := s.reporter.Status("committing running state"); err != nil { + return starting, s.failStart(ctx, starting, "report", err, process) + } + running, err := s.starter.MarkRunning(ctx, starting.ID, starting.Generation, s.now().UTC()) + if err != nil { + return starting, s.failStart(ctx, starting, "commit running", err, process) + } + result = running + if err := s.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, 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.reporter.Status("checking existing runtime"); err != nil { + return record, false, err + } + observation, err := s.runtime.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 := s.runtime.WaitReady(ctx, observation.Process); err != nil { + return record, false, err + } + return record, true, nil + case vmm.ProcessAbsent: + if err := s.runtime.Cleanup(ctx, record.ID); err != nil { + return record, false, err + } + stopped, err := s.starter.MarkStopped(ctx, record.ID, record.Generation, s.now().UTC()) + return stopped, false, err + } + case types.SandboxStateStarting: + switch observation.State { + case vmm.ProcessRunning: + running, err := s.starter.MarkRunning(ctx, record.ID, record.Generation, s.now().UTC()) + return running, err == nil, err + case vmm.ProcessStarting: + if err := s.runtime.WaitReady(ctx, observation.Process); err != nil { + return record, false, s.failStart(ctx, record, "recover VMM", err, observation.Process) + } + running, err := s.starter.MarkRunning(ctx, record.ID, record.Generation, s.now().UTC()) + if err != nil { + return record, false, s.failStart(ctx, record, "commit recovered VMM", err, observation.Process) + } + return running, true, nil + case vmm.ProcessAbsent: + if err := s.runtime.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 := s.runtime.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.imagePaths.BootFile(image.Boot.KernelLayer, image.Boot.KernelFile) + if err != nil { + return vmm.LaunchPlan{}, err + } + initrd, err := s.imagePaths.BootFile(image.Boot.InitrdLayer, image.Boot.InitrdFile) + if err != nil { + return vmm.LaunchPlan{}, err + } + cmdline, err := vmm.OverlayV1Cmdline(len(image.Layers)) + 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.imagePaths.EROFS(layer.SourceDigest), Serial: fmt.Sprintf("%s%d", vmm.LayerSerialPrefix, position), ReadOnly: true}) + } + cow, err := s.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, + }, nil +} + +// 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, starting types.Sandbox, phase string, cause error, process vmm.Process) error { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cleanupTimeout) + defer cancel() + var cleanupErr error + if process.PID > 0 { + cleanupErr = s.runtime.Abort(cleanupCtx, process) + } else { + cleanupErr = s.runtime.Cleanup(cleanupCtx, starting.ID) + } + failureCause := errors.Join(cause, cleanupErr) + failure := types.SandboxFailure{Phase: phase, Message: failureCause.Error()} + _, markErr := s.starter.MarkStartError(cleanupCtx, starting.ID, starting.Generation, failure, s.now().UTC()) + return errdefs.Context(errors.Join(failureCause, markErr), "start sandbox", starting.Config.Name, phase, "inspect the retained error sandbox and VMM log", true) +} + // Remove records cleanup intent before deleting the COW directory and releases // the name and image reference only after filesystem cleanup succeeds. // diff --git a/core/sandbox_test.go b/core/sandbox_test.go index e314502..237ffe2 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -5,14 +5,17 @@ import ( "errors" "path/filepath" "reflect" + "runtime" "strings" "testing" "time" "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" "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") @@ -59,6 +62,47 @@ func (f *fakeCatalog) MarkError(_ context.Context, _ types.SandboxID, _ uint64, 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) MarkStopped(_ context.Context, _ types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) { + *f.steps = append(*f.steps, "stopped") + if f.record.State != types.SandboxStateRunning || f.record.Generation != expected { + return types.Sandbox{}, errors.New("wrong running 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 @@ -119,6 +163,11 @@ func (f fakeDisk) Prepare(context.Context, types.SandboxID, int64) error { 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 @@ -136,6 +185,49 @@ func (f fakeReporter) Committed(types.Sandbox) error { return nil } +type fakeRuntime struct { + steps *[]string + observation vmm.Observation + preflightErr error + launchErr error + plan vmm.LaunchPlan +} + +func (f *fakeRuntime) Preflight() error { + *f.steps = append(*f.steps, "preflight") + return f.preflightErr +} + +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) Cleanup(context.Context, types.SandboxID) error { + *f.steps = append(*f.steps, "cleanup") + return nil +} + func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]string) { t.Helper() digest, err := types.ParseDigest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") @@ -143,9 +235,10 @@ func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]s t.Fatal(err) } base := t.TempDir() - paths, err := sandbox.NewPaths(storage.Roots{ + 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) } @@ -154,7 +247,17 @@ func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]s } steps := []string{} catalog := &fakeCatalog{steps: &steps} - service := newSandboxService(paths, fakeGuard{image: types.Image{ManifestDigest: digest}, steps: &steps}, catalog, catalog, catalog, fakeDisk{steps: &steps, prepare: diskError}, fakeReporter{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}} + service := newSandboxService(paths, imagePaths, fakeGuard{image: image, steps: &steps}, catalog, catalog, catalog, catalog, fakeDisk{steps: &steps, prepare: diskError}, runtimeAdapter, fakeReporter{steps: &steps}) service.newID = func() (types.SandboxID, error) { return fixedID, nil } service.now = func() time.Time { return time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) } return service, &steps @@ -277,6 +380,111 @@ func TestInspectReturnsResolvedPersistentRecord(t *testing.T) { } } +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 := service.runtime.(*fakeRuntime) + 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 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.starter.(*fakeCatalog) + catalog.record.State, catalog.record.Generation = types.SandboxStateStarting, 3 + runtimeAdapter := service.runtime.(*fakeRuntime) + 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") + service.runtime.(*fakeRuntime).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.starter.(*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.starter.(*fakeCatalog) + catalog.record.State, catalog.record.Generation = types.SandboxStateStarting, 3 + failure := errors.New("KVM unavailable") + service.runtime.(*fakeRuntime).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 TestRemoveMarksDeletingBeforeDiskAndFinalizesAfterCleanup(t *testing.T) { service, steps := newTestSandboxService(t, nil) if _, err := service.Create(t.Context(), CreateSandboxRequest{ diff --git a/disk/ext4.go b/disk/ext4.go index 6fcb56b..40eac8b 100644 --- a/disk/ext4.go +++ b/disk/ext4.go @@ -93,6 +93,25 @@ func (d *Ext4) Prepare(ctx context.Context, id types.SandboxID, size int64) erro 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 { diff --git a/errdefs/error.go b/errdefs/error.go index f4c6053..3a79bbe 100644 --- a/errdefs/error.go +++ b/errdefs/error.go @@ -21,6 +21,8 @@ const ( 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. diff --git a/images/catalog.go b/images/catalog.go index fd7d4a4..880d036 100644 --- a/images/catalog.go +++ b/images/catalog.go @@ -83,6 +83,7 @@ func (commit ImportCommit) Validate() error { } } 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") } diff --git a/images/catalog/store.go b/images/catalog/store.go index 2b3791e..a20ff5d 100644 --- a/images/catalog/store.go +++ b/images/catalog/store.go @@ -87,6 +87,9 @@ type imageRecord struct { 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. @@ -251,8 +254,8 @@ func (c *Store) CommitImport(ctx context.Context, commit images.ImportCommit) er } record := imageRecord{ ManifestDigest: commit.Manifest.Digest.String(), OS: commit.Manifest.Platform.OS, - Architecture: commit.Manifest.Platform.Architecture, - KernelLayer: commit.Boot.KernelLayer.String(), InitrdLayer: commit.Boot.InitrdLayer.String(), KernelFile: commit.Boot.KernelFile, InitrdFile: commit.Boot.InitrdFile, + 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 { @@ -419,7 +422,7 @@ func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (ty } image := types.Image{ ManifestDigest: manifest, Platform: types.Platform{OS: record.OS, Architecture: record.Architecture}, - Boot: types.Boot{KernelLayer: kernel, InitrdLayer: initrd, KernelFile: record.KernelFile, InitrdFile: record.InitrdFile}, Size: record.Size, CreatedAt: record.CreatedAt, + 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 @@ -468,7 +471,7 @@ func loadImage(ctx context.Context, reader metadata.Reader, digestID string) (ty 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, Layers: descriptors}, Layers: image.Layers, Boot: image.Boot, Size: image.Size, Created: image.CreatedAt}).Validate(); err != nil { + 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) diff --git a/images/import.go b/images/import.go index 4efe201..2dcafb4 100644 --- a/images/import.go +++ b/images/import.go @@ -292,6 +292,9 @@ func (i *Importer) Import(ctx context.Context, name string, platform types.Platf 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 diff --git a/images/source/docker_test.go b/images/source/docker_test.go index b148792..d6674e3 100644 --- a/images/source/docker_test.go +++ b/images/source/docker_test.go @@ -41,7 +41,10 @@ func fixtureDockerEntry(t *testing.T, architecture, tag, compression string) (do } 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}}} + 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 @@ -162,6 +165,9 @@ func TestDockerSourcePreservesLayersAndIdentity(t *testing.T) { 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)) } diff --git a/images/source/source.go b/images/source/source.go index 6715e86..50dbb8b 100644 --- a/images/source/source.go +++ b/images/source/source.go @@ -112,6 +112,7 @@ func (s *resolvedSource) Resolve(ctx context.Context, platform types.Platform) ( 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 { @@ -144,7 +145,7 @@ func (s *resolvedSource) Resolve(ctx context.Context, platform types.Platform) ( s.mu.Lock() s.layers = layers s.mu.Unlock() - return types.Manifest{Digest: digest, Platform: platform, Layers: descriptors}, nil + 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 diff --git a/images/source/source_test.go b/images/source/source_test.go index b9020b5..ec47ef3 100644 --- a/images/source/source_test.go +++ b/images/source/source_test.go @@ -179,7 +179,11 @@ func writeLayout(t *testing.T, compressed, unpacked []byte, media mediatypes.Med 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))}}}}), mediatypes.OCIConfigJSON) + 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) @@ -229,6 +233,9 @@ func TestSourceSupportsLayerCompressionAndChecksDiffID(t *testing.T) { 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) diff --git a/images/verify.go b/images/verify.go index 959484f..71f7f30 100644 --- a/images/verify.go +++ b/images/verify.go @@ -132,6 +132,9 @@ func validateFacts(image types.Image) error { 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")) } diff --git a/oci-images/ubuntu/Dockerfile b/oci-images/ubuntu/Dockerfile new file mode 100644 index 0000000..c642ef0 --- /dev/null +++ b/oci-images/ubuntu/Dockerfile @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1.7 + +# Pin UBUNTU_IMAGE to a digest in release automation. Keeping it configurable +# also makes local architecture-specific acceptance builds straightforward. +ARG UBUNTU_IMAGE=ubuntu:24.04 +FROM ${UBUNTU_IMAGE} + +LABEL io.kumabox.boot.profile="overlay-v1" + +ENV DEBIAN_FRONTEND=noninteractive + +COPY overlay.sh /usr/local/lib/kumabox/initramfs/kumabox-overlay + +RUN set -eu; \ + apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + initramfs-tools \ + kmod \ + linux-image-virtual \ + systemd \ + systemd-sysv \ + systemd-timesyncd \ + systemd-resolved \ + udev \ + && install -m 0755 /usr/local/lib/kumabox/initramfs/kumabox-overlay \ + /etc/initramfs-tools/scripts/kumabox-overlay \ + && 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"; \ + 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 \ + && 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 \ + && 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..1d8af51 --- /dev/null +++ b/oci-images/ubuntu/README.md @@ -0,0 +1,29 @@ +# 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 \ + -t kumabox/ubuntu:24.04 oci-images/ubuntu +``` + +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: \ + -t ghcr.io/kgpp34/kumabox/ubuntu:24.04 --push oci-images/ubuntu +``` + +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. diff --git a/oci-images/ubuntu/overlay.sh b/oci-images/ubuntu/overlay.sh new file mode 100755 index 0000000..ba65719 --- /dev/null +++ b/oci-images/ubuntu/overlay.sh @@ -0,0 +1,107 @@ +#!/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 + +# 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() { + KUMABOX_LAYERS= + KUMABOX_COW= + 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.timeout=*) KUMABOX_DEVICE_TIMEOUT=${argument#kumabox.timeout=} ;; + 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" + 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 + + 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" + log_success_msg "KumaBox overlay-v1 root is ready" +} diff --git a/sandbox/catalog/store.go b/sandbox/catalog/store.go index 5ca10b6..533c140 100644 --- a/sandbox/catalog/store.go +++ b/sandbox/catalog/store.go @@ -144,6 +144,64 @@ func (c *Store) MarkError(ctx context.Context, id types.SandboxID, expected uint 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) +} + +// MarkStopped converges a stale Running record after the owned process is +// proven absent. A later BeginStart receives the new generation. +func (c *Store) MarkStopped(ctx context.Context, id types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) { + return c.transition(ctx, id, expected, types.SandboxStateRunning, 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) { diff --git a/sandbox/catalog/store_test.go b/sandbox/catalog/store_test.go index db10c2c..f6c53cd 100644 --- a/sandbox/catalog/store_test.go +++ b/sandbox/catalog/store_test.go @@ -153,14 +153,47 @@ func TestReservationPinsImageInsideRemovalTransaction(t *testing.T) { t.Fatalf("Resolve %q = %+v", reference, resolved) } } - deleting, err := sandboxStore.BeginDelete(t.Context(), id, createdRecord.Generation, created.Add(3*time.Second)) + starting, err := sandboxStore.BeginStart(t.Context(), id, createdRecord.Generation, created.Add(3*time.Second)) if err != nil { t.Fatal(err) } - if deleting.State != types.SandboxStateDeleting || deleting.Generation != 3 { + 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) + } + stopped, err := sandboxStore.MarkStopped(t.Context(), id, running.Generation, created.Add(6*time.Second)) + if err != nil { + t.Fatal(err) + } + restarting, err := sandboxStore.BeginStart(t.Context(), id, stopped.Generation, created.Add(7*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(8*time.Second)) + if err != nil { + t.Fatal(err) + } + if failed.State != types.SandboxStateError || failed.Generation != 7 || failed.Failure == nil { + t.Fatalf("failed start record = %+v", failed) + } + deleting, err := sandboxStore.BeginDelete(t.Context(), id, failed.Generation, created.Add(9*time.Second)) + if err != nil { + t.Fatal(err) + } + if deleting.State != types.SandboxStateDeleting || deleting.Generation != 8 { t.Fatalf("deleting record = %+v", deleting) } - resumed, err := sandboxStore.BeginDelete(t.Context(), id, deleting.Generation, created.Add(4*time.Second)) + resumed, err := sandboxStore.BeginDelete(t.Context(), id, deleting.Generation, created.Add(10*time.Second)) if err != nil { t.Fatal(err) } diff --git a/types/image.go b/types/image.go index 384c33a..a032c47 100644 --- a/types/image.go +++ b/types/image.go @@ -8,6 +8,24 @@ import ( "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. @@ -88,6 +106,8 @@ type BootFile struct { // 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. @@ -122,6 +142,8 @@ type Manifest struct { 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 } diff --git a/vmm/cloudhypervisor/args.go b/vmm/cloudhypervisor/args.go new file mode 100644 index 0000000..2066ee6 --- /dev/null +++ b/vmm/cloudhypervisor/args.go @@ -0,0 +1,50 @@ +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, ",")) + } + 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..81e8c8c --- /dev/null +++ b/vmm/cloudhypervisor/args_test.go @@ -0,0 +1,91 @@ +package cloudhypervisor + +import ( + "context" + "net" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "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) + } + server := &http.Server{Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/v1/vm.info" { + http.NotFound(writer, request) + return + } + _, _ = writer.Write([]byte(`{"state":"Running"}`)) + })} + 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) + } + + 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 TestBuildArgsPreservesDiskOrderAndAccessMode(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}, + }, + } + args := buildArgs(plan, "/run/api.sock", "/run/vsock.uds") + diskIndex := slices.Index(args, "--disk") + if diskIndex < 0 || diskIndex+3 >= len(args) { + t.Fatalf("disk arguments missing: %v", args) + } + disks := args[diskIndex+1 : diskIndex+4] + if !strings.Contains(disks[0], "serial=kumabox-layer0") || !strings.Contains(disks[0], "readonly=on") || + !strings.Contains(disks[1], "serial=kumabox-layer1") || !strings.Contains(disks[1], "readonly=on") || + !strings.Contains(disks[2], "serial=kumabox-cow") || !strings.Contains(disks[2], "direct=on") || !strings.Contains(disks[2], "sparse=on") { + t.Fatalf("disk arguments = %v", disks) + } + if slices.Index(args, "--kernel") < diskIndex+4 || slices.Index(args, "--initramfs") < 0 || slices.Index(args, "--vsock") < 0 { + t.Fatalf("boot arguments = %v", args) + } +} diff --git a/vmm/cloudhypervisor/driver.go b/vmm/cloudhypervisor/driver.go new file mode 100644 index 0000000..9d7ff1d --- /dev/null +++ b/vmm/cloudhypervisor/driver.go @@ -0,0 +1,346 @@ +// 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 + abortGrace = 3 * time.Second + 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 +} + +// 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 +} + +// 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.StartupTimeout < probeInterval { + return nil, errors.New("cloud hypervisor startup timeout is too short") + } + return &Driver{paths: paths, scopes: scopes, binary: options.Binary, startupTimeout: options.StartupTimeout}, nil +} + +// 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), 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 := command.Start(); 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 +} + +// Observe verifies process generation, boot ID, executable, unique API argument, +// socket type, and vm.info. A missing process file falls back to the owned cgroup +// to close the exec-before-identity crash window. +func (d *Driver) Observe(ctx context.Context, id types.SandboxID, generation uint64) (vmm.Observation, error) { + if d == nil || d.scopes == nil { + return vmm.Observation{}, 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.Observation{}, err + } + if process.PID == 0 { + return vmm.Observation{State: vmm.ProcessAbsent}, nil + } + alive, err := verifyProcess(process) + if err != nil { + return vmm.Observation{}, err + } + if !alive { + return vmm.Observation{State: vmm.ProcessAbsent}, nil + } + if process.Generation != generation { + return vmm.Observation{}, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("live VMM belongs to Starting generation %d, expected %d", process.Generation, generation)) + } + 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 { + deadline := time.NewTimer(d.startupTimeout) + defer deadline.Stop() + ticker := time.NewTicker(probeInterval) + defer ticker.Stop() + for { + observation, err := d.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, abortGrace); err != nil { + return err + } + return d.Cleanup(ctx, process.SandboxID) +} + +// 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) +} + +// 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 := os.Lstat(socket) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSocket == 0 { + return "", 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) + }} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: probeTimeout} + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost/api/v1/vm.info", nil) + if err != nil { + return "", err + } + response, err := client.Do(request) + if err != nil { + return "", 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 "", fmt.Errorf("cloud hypervisor vm.info returned HTTP %d", response.StatusCode) + } + var payload struct { + State string `json:"state"` + } + decoder := json.NewDecoder(io.LimitReader(response.Body, maxAPIResponse+1)) + if err := decoder.Decode(&payload); err != nil { + return "", fmt.Errorf("decode Cloud Hypervisor vm.info: %w", err) + } + if payload.State == "" { + return "", errors.New("cloud hypervisor vm.info omitted state") + } + return payload.State, 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/process_linux.go b/vmm/cloudhypervisor/process_linux.go new file mode 100644 index 0000000..3d9beec --- /dev/null +++ b/vmm/cloudhypervisor/process_linux.go @@ -0,0 +1,195 @@ +//go:build linux + +package cloudhypervisor + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "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()), + } +} + +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_other.go b/vmm/cloudhypervisor/process_other.go new file mode 100644 index 0000000..f4f9170 --- /dev/null +++ b/vmm/cloudhypervisor/process_other.go @@ -0,0 +1,32 @@ +//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 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/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..7765b57 --- /dev/null +++ b/vmm/vmm.go @@ -0,0 +1,148 @@ +// 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" + "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 +} + +// 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") + } + } + return nil +} + +// 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(layerCount int) (string, error) { + if layerCount <= 0 { + return "", errors.New("overlay-v1 requires at least one image layer") + } + serials := make([]string, 0, layerCount) + for position := layerCount - 1; position >= 0; position-- { + serials = append(serials, fmt.Sprintf("%s%d", LayerSerialPrefix, position)) + } + return "console=hvc0 loglevel=3 boot=kumabox-overlay kumabox.layers=" + strings.Join(serials, ",") + + " kumabox.cow=" + COWSerial + " clocksource=kvm-clock rw", 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 +} diff --git a/vmm/vmm_test.go b/vmm/vmm_test.go new file mode 100644 index 0000000..3349e94 --- /dev/null +++ b/vmm/vmm_test.go @@ -0,0 +1,38 @@ +package vmm + +import ( + "strings" + "testing" + + "github.com/kumabox/kumabox/types" +) + +func TestOverlayV1CmdlineListsLayersTopToBase(t *testing.T) { + cmdline, err := OverlayV1Cmdline(3) + 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") { + t.Fatalf("cmdline = %q", cmdline) + } +} + +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") + } +} From ff383e15af426ed9cf53dd730e490c4df7a6c754 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Thu, 17 Sep 2026 10:28:39 +0800 Subject: [PATCH 24/45] feat: add recoverable sandbox stop --- README.md | 21 +++- cli/root.go | 1 + cli/root_test.go | 1 + cli/sandbox/create.go | 2 +- cli/sandbox/create_test.go | 4 +- cli/sandbox/output.go | 4 +- cli/sandbox/progress.go | 5 + cli/sandbox/start.go | 2 +- cli/sandbox/stop.go | 48 +++++++++ core/sandbox.go | 171 ++++++++++++++++++++++++++++--- core/sandbox_test.go | 170 ++++++++++++++++++++++++++++-- sandbox/catalog/store.go | 48 ++++++++- sandbox/catalog/store_test.go | 28 +++-- vmm/cloudhypervisor/args_test.go | 18 +++- vmm/cloudhypervisor/driver.go | 102 ++++++++++++++---- 15 files changed, 564 insertions(+), 61 deletions(-) create mode 100644 cli/sandbox/stop.go diff --git a/README.md b/README.md index bb71f74..a669eb3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ OCI images, with CNI networking, cgroups, snapshots and clone. The rewrite currently provides the `kumabox` CLI, the host doctor, container image management, persistent sandbox creation, and recoverable Cloud Hypervisor -startup. Each command opens its metadata store, performs one operation, and +start/stop. Each command opens its metadata store, performs one operation, and exits. The remaining sandbox lifecycle is tracked in [docs/ROADMAP.md](docs/ROADMAP.md). @@ -195,6 +195,23 @@ Cloud Hypervisor API reports readiness. Retrying recovers the same `Starting` generation; a failed launch is terminated and retained as `Error` with a diagnostic. `--json` returns the complete indented sandbox object. +`stop SANDBOX` follows the same direct-boot behavior as Cocoon. It first makes +a best-effort request to Cloud Hypervisor's private `vm.shutdown` endpoint, +then terminates the exact identity-checked VMM process with `SIGTERM`, waits up +to five seconds, and uses `SIGKILL` if it is still alive. There is no guest ACPI +shutdown wait and no `--force` or `--timeout` mode. Runtime files and the empty +cgroup are removed before the generation-fenced transition to `Stopped`. + +```bash +kumabox stop NAME +kumabox stop 123e4567-e89b-42d3-a456-426614174000 --json +``` + +An interrupted stop retains `Stopping`; running the same command again resumes +the operation. It also recovers `Starting` records left by an interrupted start. +Stopping an already `Created` or `Stopped` sandbox succeeds without changing +its lifecycle history. + List active sandboxes with `ps`, or include created, stopped, failed, and deleting records with `-a`: @@ -230,7 +247,7 @@ If cleanup is interrupted, running the same command again resumes it. The sandbox name and image reference are released together only after disk cleanup succeeds. Text output is the removed sandbox's full UUID; `--json` returns its ID and released name. Active lifecycle states are rejected until the sandbox -has been stopped; force removal will be added with VMM lifecycle support. +has been stopped with `kumabox stop`. ## Reference material diff --git a/cli/root.go b/cli/root.go index 0f77dc3..67c950c 100644 --- a/cli/root.go +++ b/cli/root.go @@ -118,6 +118,7 @@ func newRootCommand() *cobra.Command { root.AddCommand(sandboxcmd.NewListCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewRemoveCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewStartCommand(func() storage.Roots { return roots })) + root.AddCommand(sandboxcmd.NewStopCommand(func() storage.Roots { return roots })) root.AddCommand(newVersionCommand()) classifyArguments(root) return root diff --git a/cli/root_test.go b/cli/root_test.go index 34ca5f9..38e8ae6 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -64,6 +64,7 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"missing inspect sandbox", []string{"inspect"}, 2}, {"missing remove sandbox", []string{"rm"}, 2}, {"missing start sandbox", []string{"start"}, 2}, + {"missing stop sandbox", []string{"stop"}, 2}, {"unexpected ps argument", []string{"ps", "box"}, 2}, {"unsupported inspect flag", []string{"inspect", "box", "--json"}, 2}, {"unknown flag", []string{"image", "ls", "--wrong"}, 2}, diff --git a/cli/sandbox/create.go b/cli/sandbox/create.go index 958f4dc..ea40c53 100644 --- a/cli/sandbox/create.go +++ b/cli/sandbox/create.go @@ -63,7 +63,7 @@ func NewCreateCommand(roots rootsProvider) *cobra.Command { return err } committed = true - if err := writeCreateResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil { + if err := writeSandboxResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil { return errdefs.Context(err, "create sandbox", name, "output", "sandbox was created; inspect it before retrying", true) } return nil diff --git a/cli/sandbox/create_test.go b/cli/sandbox/create_test.go index 8fc9cc4..dd65dec 100644 --- a/cli/sandbox/create_test.go +++ b/cli/sandbox/create_test.go @@ -53,14 +53,14 @@ func TestWriteResultUsesFullIDAndIndentedJSON(t *testing.T) { CreatedAt: time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 15, 10, 0, 1, 0, time.UTC), } var text bytes.Buffer - if err := writeCreateResult(&text, record, false); err != nil { + 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 := writeCreateResult(&jsonOut, record, true); err != nil { + 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") { diff --git a/cli/sandbox/output.go b/cli/sandbox/output.go index 66ccba6..8d6728d 100644 --- a/cli/sandbox/output.go +++ b/cli/sandbox/output.go @@ -75,8 +75,8 @@ func writeSandboxJSON(writer io.Writer, sandbox types.Sandbox) error { return encoder.Encode(sandboxResult(sandbox)) } -// writeCreateResult keeps default output script-friendly and JSON complete. -func writeCreateResult(writer io.Writer, sandbox types.Sandbox, asJSON bool) error { +// 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 diff --git a/cli/sandbox/progress.go b/cli/sandbox/progress.go index 2d41041..24b3d0e 100644 --- a/cli/sandbox/progress.go +++ b/cli/sandbox/progress.go @@ -65,6 +65,11 @@ func startStartProgress(command *cobra.Command, reference string) (*sandboxProgr 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") +} + // startProgress writes an initial stage before starting its ticker. func startProgress(command *cobra.Command, operation, label, status, recovery string) (*sandboxProgress, error) { writer := command.ErrOrStderr() diff --git a/cli/sandbox/start.go b/cli/sandbox/start.go index 330e55f..ca544b8 100644 --- a/cli/sandbox/start.go +++ b/cli/sandbox/start.go @@ -37,7 +37,7 @@ func NewStartCommand(roots rootsProvider) *cobra.Command { return err } committed = true - if err := writeCreateResult(progress.Output(command.OutOrStdout()), record, asJSON); err != nil { + 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 diff --git a/cli/sandbox/stop.go b/cli/sandbox/stop.go new file mode 100644 index 0000000..83cd580 --- /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(roots rootsProvider) *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(), roots(), 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/core/sandbox.go b/core/sandbox.go index dcdcaf3..45b82e9 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -58,12 +58,13 @@ type sandboxRemover interface { FinalizeDelete(context.Context, types.SandboxID, uint64) error } -// sandboxStarter is the generation-fenced metadata capability used by start. -type sandboxStarter interface { +// sandboxLifecycle is the generation-fenced metadata capability used by start and stop. +type sandboxLifecycle interface { 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) - MarkStopped(context.Context, types.SandboxID, uint64, 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) } // cowStore is the private writable-disk capability consumed by sandbox creation. @@ -77,10 +78,12 @@ type cowStore interface { // Its implementation owns process identity and readiness, not durable state. type vmmRuntime interface { Preflight() error + Locate(context.Context, types.SandboxID, uint64) (vmm.Process, bool, error) Observe(context.Context, types.SandboxID, uint64) (vmm.Observation, error) WaitReady(context.Context, vmm.Process) error Launch(context.Context, vmm.LaunchPlan) (vmm.Process, error) Abort(context.Context, vmm.Process) error + Stop(context.Context, vmm.Process) error Cleanup(context.Context, types.SandboxID) error } @@ -102,8 +105,8 @@ type SandboxService struct { reader sandboxReader // remover commits generation-fenced delete transitions. remover sandboxRemover - // starter commits generation-fenced start, running, error, and recovery transitions. - starter sandboxStarter + // lifecycle commits generation-fenced start, stop, and recovery transitions. + lifecycle sandboxLifecycle // cows prepares and cleans the sandbox-owned writable disk. cows cowStore // imagePaths derives immutable artifacts after the image guard verifies them. @@ -120,13 +123,13 @@ type SandboxService struct { } // newSandboxService connects the explicit capabilities needed by sandbox commands. -func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, starter sandboxStarter, cows cowStore, runtime vmmRuntime, reporter SandboxReporter) *SandboxService { +func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, lifecycle sandboxLifecycle, cows cowStore, runtime vmmRuntime, reporter SandboxReporter) *SandboxService { if reporter == nil { reporter = discardReporter{} } return &SandboxService{ paths: paths, imagePaths: imagePaths, images: images, creator: creator, reader: reader, - remover: remover, starter: starter, cows: cows, runtime: runtime, reporter: reporter, + remover: remover, lifecycle: lifecycle, cows: cows, runtime: runtime, reporter: reporter, newID: types.NewSandboxID, now: time.Now, } } @@ -310,7 +313,7 @@ func (s *SandboxService) Inspect(ctx context.Context, reference string) (types.S // | // abort + retained Error func (s *SandboxService) Start(ctx context.Context, reference string) (result types.Sandbox, returnErr error) { - if s == nil || s.reader == nil || s.starter == nil || s.images == nil || s.cows == nil || s.runtime == nil || s.reporter == nil || s.now == nil { + if s == nil || s.reader == nil || s.lifecycle == nil || s.images == nil || s.cows == nil || s.runtime == nil || s.reporter == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if reference == "" { @@ -395,7 +398,7 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty if err := s.reporter.Status("committing starting state"); err != nil { return record, failBeforeLaunch("report", err) } - starting, err := s.starter.BeginStart(ctx, record.ID, record.Generation, s.now().UTC()) + starting, err := s.lifecycle.BeginStart(ctx, record.ID, record.Generation, s.now().UTC()) if err != nil { return record, errdefs.Context(err, "start sandbox", reference, "mark starting", "inspect the sandbox before retrying", committed) } @@ -415,7 +418,7 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty if err := s.reporter.Status("committing running state"); err != nil { return starting, s.failStart(ctx, starting, "report", err, process) } - running, err := s.starter.MarkRunning(ctx, starting.ID, starting.Generation, s.now().UTC()) + running, err := s.lifecycle.MarkRunning(ctx, starting.ID, starting.Generation, s.now().UTC()) if err != nil { return starting, s.failStart(ctx, starting, "commit running", err, process) } @@ -461,19 +464,19 @@ func (s *SandboxService) recoverStart(ctx context.Context, record types.Sandbox) if err := s.runtime.Cleanup(ctx, record.ID); err != nil { return record, false, err } - stopped, err := s.starter.MarkStopped(ctx, record.ID, record.Generation, s.now().UTC()) + stopped, err := s.lifecycle.MarkStopped(ctx, record.ID, record.Generation, types.SandboxStateRunning, s.now().UTC()) return stopped, false, err } case types.SandboxStateStarting: switch observation.State { case vmm.ProcessRunning: - running, err := s.starter.MarkRunning(ctx, record.ID, record.Generation, s.now().UTC()) + running, err := s.lifecycle.MarkRunning(ctx, record.ID, record.Generation, s.now().UTC()) return running, err == nil, err case vmm.ProcessStarting: if err := s.runtime.WaitReady(ctx, observation.Process); err != nil { return record, false, s.failStart(ctx, record, "recover VMM", err, observation.Process) } - running, err := s.starter.MarkRunning(ctx, record.ID, record.Generation, s.now().UTC()) + running, err := s.lifecycle.MarkRunning(ctx, record.ID, record.Generation, s.now().UTC()) if err != nil { return record, false, s.failStart(ctx, record, "commit recovered VMM", err, observation.Process) } @@ -548,10 +551,150 @@ func (s *SandboxService) failStart(ctx context.Context, starting types.Sandbox, } failureCause := errors.Join(cause, cleanupErr) failure := types.SandboxFailure{Phase: phase, Message: failureCause.Error()} - _, markErr := s.starter.MarkStartError(cleanupCtx, starting.ID, starting.Generation, failure, s.now().UTC()) + _, markErr := s.lifecycle.MarkStartError(cleanupCtx, starting.ID, starting.Generation, failure, s.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 -> 5s -> 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.reader == nil || s.lifecycle == nil || s.runtime == nil || s.reporter == nil || s.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.reporter.Status("resolving sandbox"); err != nil { + return types.Sandbox{}, err + } + record, err := s.reader.Resolve(ctx, reference) + if err != nil { + return types.Sandbox{}, err + } + lockPath, err := s.paths.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 + } + 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.reader.Resolve(ctx, record.ID.String()) + if err != nil { + return types.Sandbox{}, 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.reporter.Status("cleaning stale runtime state"); err != nil { + return record, err + } + if err := s.runtime.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.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.reporter.Status("checking existing runtime"); err != nil { + return record, err + } + process, exists, err := s.runtime.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.reporter.Status("committing stopping state"); err != nil { + return record, err + } + record, err = s.lifecycle.BeginStop(ctx, record.ID, record.Generation, s.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.reporter.Status("stopping Cloud Hypervisor"); err != nil { + return record, errdefs.Context(err, "stop sandbox", reference, "report", "retry the stop to resume Stopping", committed) + } + if err := s.runtime.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.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 := s.runtime.Cleanup(ctx, record.ID); err != nil { + return record, errdefs.Context(err, "stop sandbox", reference, "cleanup runtime", "retry the stop to finish 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.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.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.lifecycle.MarkStopped(ctx, record.ID, record.Generation, record.State, s.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.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 +} + // Remove records cleanup intent before deleting the COW directory and releases // the name and image reference only after filesystem cleanup succeeds. // diff --git a/core/sandbox_test.go b/core/sandbox_test.go index 237ffe2..229eb52 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -94,10 +94,25 @@ func (f *fakeCatalog) MarkStartError(_ context.Context, _ types.SandboxID, expec return f.record, nil } -func (f *fakeCatalog) MarkStopped(_ context.Context, _ types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) { +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 != types.SandboxStateRunning || f.record.Generation != expected { - return types.Sandbox{}, errors.New("wrong running generation") + 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 @@ -190,6 +205,7 @@ type fakeRuntime struct { observation vmm.Observation preflightErr error launchErr error + stopErr error plan vmm.LaunchPlan } @@ -198,6 +214,11 @@ func (f *fakeRuntime) Preflight() error { 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 @@ -223,6 +244,11 @@ func (f *fakeRuntime) Abort(context.Context, vmm.Process) error { return nil } +func (f *fakeRuntime) Stop(context.Context, vmm.Process) error { + *f.steps = append(*f.steps, "stop") + return f.stopErr +} + func (f *fakeRuntime) Cleanup(context.Context, types.SandboxID) error { *f.steps = append(*f.steps, "cleanup") return nil @@ -417,7 +443,7 @@ func TestStartRecoversRunningProcessFromStartingState(t *testing.T) { }); err != nil { t.Fatal(err) } - catalog := service.starter.(*fakeCatalog) + catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateStarting, 3 runtimeAdapter := service.runtime.(*fakeRuntime) runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}} @@ -452,7 +478,7 @@ func TestStartFailureAbortsProcessAndRetainsError(t *testing.T) { t.Fatalf("Start did not report retained state: %v", err) } } - catalog := service.starter.(*fakeCatalog) + catalog := service.lifecycle.(*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) } @@ -469,7 +495,7 @@ func TestStartRetryDoesNotLeaveStartingAfterPreflightFailure(t *testing.T) { }); err != nil { t.Fatal(err) } - catalog := service.starter.(*fakeCatalog) + catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateStarting, 3 failure := errors.New("KVM unavailable") service.runtime.(*fakeRuntime).preflightErr = failure @@ -485,6 +511,138 @@ func TestStartRetryDoesNotLeaveStartingAfterPreflightFailure(t *testing.T) { } } +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.lifecycle.(*fakeCatalog) + catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4 + service.runtime.(*fakeRuntime).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 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.lifecycle.(*fakeCatalog) + catalog.record.State, catalog.record.Generation = test.state, test.generation + service.runtime.(*fakeRuntime).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.lifecycle.(*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.lifecycle.(*fakeCatalog) + catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4 + failure := errors.New("signal failed") + runtimeAdapter := service.runtime.(*fakeRuntime) + 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 TestRemoveMarksDeletingBeforeDiskAndFinalizesAfterCleanup(t *testing.T) { service, steps := newTestSandboxService(t, nil) if _, err := service.Create(t.Context(), CreateSandboxRequest{ diff --git a/sandbox/catalog/store.go b/sandbox/catalog/store.go index 533c140..637ae04 100644 --- a/sandbox/catalog/store.go +++ b/sandbox/catalog/store.go @@ -196,10 +196,50 @@ func (c *Store) MarkStartError(ctx context.Context, id types.SandboxID, expected return c.transition(ctx, id, expected, types.SandboxStateStarting, types.SandboxStateError, &failure, updated) } -// MarkStopped converges a stale Running record after the owned process is -// proven absent. A later BeginStart receives the new generation. -func (c *Store) MarkStopped(ctx context.Context, id types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) { - return c.transition(ctx, id, expected, types.SandboxStateRunning, types.SandboxStateStopped, nil, 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 diff --git a/sandbox/catalog/store_test.go b/sandbox/catalog/store_test.go index f6c53cd..434d6b0 100644 --- a/sandbox/catalog/store_test.go +++ b/sandbox/catalog/store_test.go @@ -171,29 +171,43 @@ func TestReservationPinsImageInsideRemovalTransaction(t *testing.T) { if err != nil { t.Fatal(err) } - stopped, err := sandboxStore.MarkStopped(t.Context(), id, running.Generation, created.Add(6*time.Second)) + stopping, err := sandboxStore.BeginStop(t.Context(), id, running.Generation, created.Add(6*time.Second)) if err != nil { t.Fatal(err) } - restarting, err := sandboxStore.BeginStart(t.Context(), id, stopped.Generation, created.Add(7*time.Second)) + 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(8*time.Second)) + 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 != 7 || failed.Failure == nil { + 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(9*time.Second)) + 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 != 8 { + 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(10*time.Second)) + resumed, err := sandboxStore.BeginDelete(t.Context(), id, deleting.Generation, created.Add(12*time.Second)) if err != nil { t.Fatal(err) } diff --git a/vmm/cloudhypervisor/args_test.go b/vmm/cloudhypervisor/args_test.go index 81e8c8c..2c54fc2 100644 --- a/vmm/cloudhypervisor/args_test.go +++ b/vmm/cloudhypervisor/args_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "slices" "strings" + "sync/atomic" "testing" "github.com/kumabox/kumabox/errdefs" @@ -30,12 +31,17 @@ func TestQueryStateRequiresUnixSocketAndDecodesRunning(t *testing.T) { if err != nil { t.Fatal(err) } + var shutdown atomic.Bool server := &http.Server{Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.URL.Path != "/api/v1/vm.info" { + switch { + case request.Method == http.MethodGet && request.URL.Path == "/api/v1/vm.info": + _, _ = writer.Write([]byte(`{"state":"Running"}`)) + case request.Method == http.MethodPut && request.URL.Path == "/api/v1/vm.shutdown": + shutdown.Store(true) + writer.WriteHeader(http.StatusNoContent) + default: http.NotFound(writer, request) - return } - _, _ = writer.Write([]byte(`{"state":"Running"}`)) })} go func() { _ = server.Serve(listener) }() t.Cleanup(func() { @@ -51,6 +57,12 @@ func TestQueryStateRequiresUnixSocketAndDecodesRunning(t *testing.T) { if state != "Running" { t.Fatalf("state = %q", state) } + 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 { diff --git a/vmm/cloudhypervisor/driver.go b/vmm/cloudhypervisor/driver.go index 9d7ff1d..8a09815 100644 --- a/vmm/cloudhypervisor/driver.go +++ b/vmm/cloudhypervisor/driver.go @@ -31,6 +31,7 @@ const ( probeInterval = 50 * time.Millisecond probeTimeout = 500 * time.Millisecond abortGrace = 3 * time.Second + stopGrace = 5 * time.Second maxAPIResponse = 1 << 20 ) @@ -169,32 +170,45 @@ func (d *Driver) Launch(ctx context.Context, plan vmm.LaunchPlan) (result vmm.Pr return result, nil } -// Observe verifies process generation, boot ID, executable, unique API argument, -// socket type, and vm.info. A missing process file falls back to the owned cgroup -// to close the exec-before-identity crash window. -func (d *Driver) Observe(ctx context.Context, id types.SandboxID, generation uint64) (vmm.Observation, error) { +// 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.Observation{}, errors.New("cloud hypervisor driver is not configured") + 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.Observation{}, err + return vmm.Process{}, false, err } if process.PID == 0 { - return vmm.Observation{State: vmm.ProcessAbsent}, nil + return vmm.Process{}, false, nil } alive, err := verifyProcess(process) if err != nil { - return vmm.Observation{}, err + return vmm.Process{}, false, err } if !alive { - return vmm.Observation{State: vmm.ProcessAbsent}, nil + return vmm.Process{}, false, nil } if process.Generation != generation { - return vmm.Observation{}, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("live VMM belongs to Starting generation %d, expected %d", 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 { @@ -251,6 +265,23 @@ func (d *Driver) Abort(ctx context.Context, process vmm.Process) error { 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, stopGrace) +} + // 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 { @@ -293,18 +324,11 @@ func (d *Driver) recoverProcess(id types.SandboxID, generation uint64) (vmm.Proc // queryState performs one bounded request over the private Unix socket. func (d *Driver) queryState(ctx context.Context, socket string) (string, error) { - info, err := os.Lstat(socket) + client, closeClient, err := unixAPIClient(socket) if err != nil { return "", err } - if info.Mode()&os.ModeSocket == 0 { - return "", 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) - }} - defer transport.CloseIdleConnections() - client := &http.Client{Transport: transport, Timeout: probeTimeout} + defer closeClient() request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost/api/v1/vm.info", nil) if err != nil { return "", err @@ -331,6 +355,46 @@ func (d *Driver) queryState(ctx context.Context, socket string) (string, error) return payload.State, 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) From 46f2e72ad8744a53da89b00788c165f8bc13ad78 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Fri, 18 Sep 2026 09:06:21 +0800 Subject: [PATCH 25/45] feat: add sandbox console and VMM backends --- README.md | 16 ++- cli/root.go | 1 + cli/root_test.go | 1 + cli/sandbox/console.go | 187 +++++++++++++++++++++++++++++++ cli/sandbox/console_test.go | 76 +++++++++++++ cli/sandbox/create_test.go | 2 +- cli/sandbox/output.go | 10 +- cli/sandbox/query_test.go | 2 +- core/sandbox.go | 177 +++++++++++++++++++---------- core/sandbox_test.go | 143 +++++++++++++++++++++-- core/vmm.go | 76 +++++++++++++ go.mod | 2 + go.sum | 7 ++ sandbox/catalog/store.go | 10 +- sandbox/catalog/store_test.go | 30 ++++- types/sandbox.go | 27 +++++ vmm/backend.go | 24 ++++ vmm/cloudhypervisor/args_test.go | 23 +++- vmm/cloudhypervisor/driver.go | 106 ++++++++++++++++-- 19 files changed, 828 insertions(+), 92 deletions(-) create mode 100644 cli/sandbox/console.go create mode 100644 cli/sandbox/console_test.go create mode 100644 core/vmm.go create mode 100644 vmm/backend.go diff --git a/README.md b/README.md index a669eb3..d47b0dc 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ or a generic `pkg` container: | `images/erofs` | Convert source layers and extract boot candidates | | `sandbox`, `sandbox/catalog` | Sandbox filesystem ownership and metadata persistence | | `disk` | Prepare and remove sandbox-owned sparse ext4 COW disks | -| `vmm`, `vmm/cloudhypervisor` | VMM launch contracts, process identity, arguments and readiness | +| `vmm`, `vmm/cloudhypervisor` | VMM backend contract, launch/process facts, and the Cloud Hypervisor adapter | | `cgroup` | Per-sandbox cgroup v2 preparation and reclamation | | `metadata`, `metadata/sqlite` | Engine-neutral transactions and the SQLite implementation | | `storage`, `lock/flock` | Managed filesystem operations and file locks | @@ -212,6 +212,20 @@ the operation. It also recovers `Starting` records left by an interrupted start. Stopping an already `Created` or `Stopped` sandbox succeeds without changing its lifecycle history. +Attach to the direct-boot PTY of a running sandbox with `console`. The command +verifies the current process generation and Cloud Hypervisor API state before +opening the kernel PTY, switches the local terminal to raw mode, and restores it +on every exit path. Press `Ctrl-]` followed by `.` to detach without stopping +the sandbox; use `--escape-char` to select another ASCII escape character. + +```bash +kumabox console NAME +kumabox console 123e4567-e89b-42d3-a456-426614174000 --escape-char '^A' +``` + +Console requires terminal stdin. A concurrent `stop` closes the PTY session; +the console command does not hold the sandbox operation lock while relaying I/O. + List active sandboxes with `ps`, or include created, stopped, failed, and deleting records with `-a`: diff --git a/cli/root.go b/cli/root.go index 67c950c..bd18dcc 100644 --- a/cli/root.go +++ b/cli/root.go @@ -113,6 +113,7 @@ func newRootCommand() *cobra.Command { root.AddCommand(doctorcmd.NewCommand()) root.AddCommand(imagecmd.NewCommand(func() storage.Roots { return roots })) + root.AddCommand(sandboxcmd.NewConsoleCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewCreateCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewInspectCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewListCommand(func() storage.Roots { return roots })) diff --git a/cli/root_test.go b/cli/root_test.go index 38e8ae6..0ad9ffa 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -61,6 +61,7 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"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 inspect sandbox", []string{"inspect"}, 2}, {"missing remove sandbox", []string{"rm"}, 2}, {"missing start sandbox", []string{"start"}, 2}, diff --git a/cli/sandbox/console.go b/cli/sandbox/console.go new file mode 100644 index 0000000..2f58aa8 --- /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(roots rootsProvider) *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(), roots(), 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 Cocoon-compatible 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_test.go b/cli/sandbox/create_test.go index dd65dec..4e51ecd 100644 --- a/cli/sandbox/create_test.go +++ b/cli/sandbox/create_test.go @@ -49,7 +49,7 @@ func TestWriteResultUsesFullIDAndIndentedJSON(t *testing.T) { record := 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, State: types.SandboxStateCreated, Generation: 2, + 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), } var text bytes.Buffer diff --git a/cli/sandbox/output.go b/cli/sandbox/output.go index 8d6728d..87ee411 100644 --- a/cli/sandbox/output.go +++ b/cli/sandbox/output.go @@ -20,6 +20,8 @@ type sandboxOutput struct { 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. @@ -57,7 +59,7 @@ type removeOutput struct { // 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(), + 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, Generation: sandbox.Generation, CreatedAt: sandbox.CreatedAt.UTC(), UpdatedAt: sandbox.UpdatedAt.UTC(), @@ -119,12 +121,12 @@ func writeSandboxIDs(writer io.Writer, records []types.Sandbox) error { // 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\tSTATE\tCPUS\tMEMORY\tSTORAGE\tCREATED"); err != nil { + if _, err := fmt.Fprintln(table, "SANDBOX ID\tNAME\tIMAGE ID\tVMM\tSTATE\tCPUS\tMEMORY\tSTORAGE\tCREATED"); err != nil { return err } for _, record := range records { - if _, err := fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", - record.ID, record.Config.Name, record.ImageDigest.Hex()[:12], record.State, record.Config.CPUs, + if _, err := fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%d\t%s\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.CreatedAt.UTC().Format(time.RFC3339), ); err != nil { diff --git a/cli/sandbox/query_test.go b/cli/sandbox/query_test.go index 8ece6ba..b78ca58 100644 --- a/cli/sandbox/query_test.go +++ b/cli/sandbox/query_test.go @@ -211,7 +211,7 @@ func testSandboxRecord(t *testing.T) types.Sandbox { 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, State: types.SandboxStateCreated, Generation: 2, + ImageDigest: digest, VMM: types.VMMCloudHypervisor, State: types.SandboxStateCreated, Generation: 2, CreatedAt: created, UpdatedAt: created.Add(time.Second), } } diff --git a/core/sandbox.go b/core/sandbox.go index 45b82e9..8f0cac0 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -4,10 +4,10 @@ import ( "context" "errors" "fmt" + "io" "runtime" "time" - "github.com/kumabox/kumabox/cgroup" "github.com/kumabox/kumabox/disk" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" @@ -20,7 +20,6 @@ import ( "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/types" "github.com/kumabox/kumabox/vmm" - "github.com/kumabox/kumabox/vmm/cloudhypervisor" ) const cleanupTimeout = 10 * time.Second @@ -31,6 +30,8 @@ type CreateSandboxRequest struct { ImageReference string // Config contains the immutable name and guest resource shape. Config types.SandboxConfig + // VMM selects the runtime backend; empty uses Cloud Hypervisor. + VMM types.VMMType } // imageGuard is the image capability consumed by sandbox creation. @@ -74,19 +75,6 @@ type cowStore interface { Remove(context.Context, types.SandboxID) error } -// vmmRuntime is the process capability consumed by lifecycle orchestration. -// Its implementation owns process identity and readiness, not durable state. -type vmmRuntime interface { - Preflight() error - Locate(context.Context, types.SandboxID, uint64) (vmm.Process, bool, error) - Observe(context.Context, types.SandboxID, uint64) (vmm.Observation, error) - WaitReady(context.Context, vmm.Process) error - Launch(context.Context, vmm.LaunchPlan) (vmm.Process, error) - Abort(context.Context, vmm.Process) error - Stop(context.Context, vmm.Process) error - Cleanup(context.Context, types.SandboxID) error -} - // SandboxReporter receives user-visible stages without controlling workflows. type SandboxReporter interface { Status(string) error @@ -111,8 +99,8 @@ type SandboxService struct { cows cowStore // imagePaths derives immutable artifacts after the image guard verifies them. imagePaths images.Paths - // runtime owns Cloud Hypervisor process identity, readiness, and cleanup. - runtime vmmRuntime + // runtimes route persisted VMM identities to process adapters. + runtimes vmmBackends // reporter emits progress independently of command results. reporter SandboxReporter // newID and now are replaceable in same-package tests. @@ -123,13 +111,13 @@ type SandboxService struct { } // newSandboxService connects the explicit capabilities needed by sandbox commands. -func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, lifecycle sandboxLifecycle, cows cowStore, runtime vmmRuntime, reporter SandboxReporter) *SandboxService { +func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, lifecycle sandboxLifecycle, cows cowStore, runtimes vmmBackends, reporter SandboxReporter) *SandboxService { if reporter == nil { reporter = discardReporter{} } return &SandboxService{ paths: paths, imagePaths: imagePaths, images: images, creator: creator, reader: reader, - remover: remover, lifecycle: lifecycle, cows: cows, runtime: runtime, reporter: reporter, + remover: remover, lifecycle: lifecycle, cows: cows, runtimes: runtimes, reporter: reporter, newID: types.NewSandboxID, now: time.Now, } } @@ -158,21 +146,13 @@ func OpenSandbox(ctx context.Context, roots storage.Roots, reporter SandboxRepor } imageCatalog := imagecatalog.New(store, imagecatalog.WithImageUsage(sandboxcatalog.Usage{})) sandboxCatalog := sandboxcatalog.New(store, imagecatalog.Reader{}) - vmmPaths, err := vmm.NewPaths(roots) - if err != nil { - return nil, errors.Join(err, store.Close()) - } - scopes, err := cgroup.New("") - if err != nil { - return nil, errors.Join(err, store.Close()) - } - runtimeDriver, err := cloudhypervisor.New(vmmPaths, scopes, cloudhypervisor.Options{}) + runtimes, err := openVMMBackends(roots) if err != nil { return nil, errors.Join(err, store.Close()) } service := newSandboxService( sandboxPaths, imagePaths, images.NewGuard(imagePaths, imageCatalog), sandboxCatalog, - sandboxCatalog, sandboxCatalog, sandboxCatalog, disk.NewExt4(sandboxPaths), runtimeDriver, reporter, + sandboxCatalog, sandboxCatalog, sandboxCatalog, disk.NewExt4(sandboxPaths), runtimes, reporter, ) service.store = store return service, nil @@ -193,7 +173,7 @@ func (s *SandboxService) Close() error { // | | // +---- failure cleanup <---+ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxRequest) (result types.Sandbox, returnErr error) { - if s == nil || s.images == nil || s.creator == nil || s.cows == nil || s.reporter == nil || s.newID == nil || s.now == nil { + if s == nil || s.images == nil || s.creator == nil || s.cows == nil || len(s.runtimes) == 0 || s.reporter == nil || s.newID == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if request.ImageReference == "" { @@ -202,6 +182,12 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques if err := request.Config.Validate(); err != nil { return types.Sandbox{}, err } + if request.VMM == "" { + request.VMM = types.VMMCloudHypervisor + } + if _, err := s.runtimes.backend(request.VMM); err != nil { + return types.Sandbox{}, err + } 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())) } @@ -234,6 +220,7 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques _, err = s.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, } @@ -313,7 +300,7 @@ func (s *SandboxService) Inspect(ctx context.Context, reference string) (types.S // | // abort + retained Error func (s *SandboxService) Start(ctx context.Context, reference string) (result types.Sandbox, returnErr error) { - if s == nil || s.reader == nil || s.lifecycle == nil || s.images == nil || s.cows == nil || s.runtime == nil || s.reporter == nil || s.now == nil { + if s == nil || s.reader == nil || s.lifecycle == nil || s.images == nil || s.cows == nil || len(s.runtimes) == 0 || s.reporter == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if reference == "" { @@ -349,8 +336,12 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty if err != nil { return types.Sandbox{}, err } + backend, err := s.runtimes.backend(record.VMM) + if err != nil { + return record, err + } beforeRecovery := record - result, done, err := s.recoverStart(ctx, 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) } @@ -365,7 +356,7 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty record = result failBeforeLaunch := func(phase string, cause error) error { if record.State == types.SandboxStateStarting { - return s.failStart(ctx, record, phase, cause, vmm.Process{}) + 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) } @@ -373,7 +364,7 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty if err := s.reporter.Status("checking host runtime"); err != nil { return record, failBeforeLaunch("report", err) } - if err := s.runtime.Preflight(); err != nil { + if err := backend.Preflight(); err != nil { return record, failBeforeLaunch("host preflight", err) } if int(record.Config.CPUs) > runtime.NumCPU() { @@ -406,21 +397,21 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty result = starting plan.Generation = starting.Generation if err := plan.Validate(); err != nil { - return starting, s.failStart(ctx, starting, "build launch plan", err, vmm.Process{}) + return starting, s.failStart(ctx, backend, starting, "build launch plan", err, vmm.Process{}) } - if err := s.reporter.Status("launching Cloud Hypervisor"); err != nil { - return starting, s.failStart(ctx, starting, "report", err, vmm.Process{}) + if err := s.reporter.Status("launching " + string(backend.Type())); err != nil { + return starting, s.failStart(ctx, backend, starting, "report", err, vmm.Process{}) } - process, err := s.runtime.Launch(ctx, plan) + process, err := backend.Launch(ctx, plan) if err != nil { - return starting, s.failStart(ctx, starting, "launch VMM", err, process) + return starting, s.failStart(ctx, backend, starting, "launch VMM", err, process) } if err := s.reporter.Status("committing running state"); err != nil { - return starting, s.failStart(ctx, starting, "report", err, process) + return starting, s.failStart(ctx, backend, starting, "report", err, process) } running, err := s.lifecycle.MarkRunning(ctx, starting.ID, starting.Generation, s.now().UTC()) if err != nil { - return starting, s.failStart(ctx, starting, "commit running", err, process) + return starting, s.failStart(ctx, backend, starting, "commit running", err, process) } result = running if err := s.reporter.Committed(running); err != nil { @@ -431,7 +422,7 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty // 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, record types.Sandbox) (types.Sandbox, bool, error) { +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)) @@ -446,7 +437,7 @@ func (s *SandboxService) recoverStart(ctx context.Context, record types.Sandbox) if err := s.reporter.Status("checking existing runtime"); err != nil { return record, false, err } - observation, err := s.runtime.Observe(ctx, record.ID, expected) + observation, err := backend.Observe(ctx, record.ID, expected) if err != nil { return record, false, err } @@ -456,12 +447,12 @@ func (s *SandboxService) recoverStart(ctx context.Context, record types.Sandbox) case vmm.ProcessRunning: return record, true, nil case vmm.ProcessStarting: - if err := s.runtime.WaitReady(ctx, observation.Process); err != nil { + if err := backend.WaitReady(ctx, observation.Process); err != nil { return record, false, err } return record, true, nil case vmm.ProcessAbsent: - if err := s.runtime.Cleanup(ctx, record.ID); err != nil { + if err := backend.Cleanup(ctx, record.ID); err != nil { return record, false, err } stopped, err := s.lifecycle.MarkStopped(ctx, record.ID, record.Generation, types.SandboxStateRunning, s.now().UTC()) @@ -473,16 +464,16 @@ func (s *SandboxService) recoverStart(ctx context.Context, record types.Sandbox) running, err := s.lifecycle.MarkRunning(ctx, record.ID, record.Generation, s.now().UTC()) return running, err == nil, err case vmm.ProcessStarting: - if err := s.runtime.WaitReady(ctx, observation.Process); err != nil { - return record, false, s.failStart(ctx, record, "recover VMM", err, observation.Process) + 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.lifecycle.MarkRunning(ctx, record.ID, record.Generation, s.now().UTC()) if err != nil { - return record, false, s.failStart(ctx, record, "commit recovered VMM", err, observation.Process) + return record, false, s.failStart(ctx, backend, record, "commit recovered VMM", err, observation.Process) } return running, true, nil case vmm.ProcessAbsent: - if err := s.runtime.Cleanup(ctx, record.ID); err != nil { + if err := backend.Cleanup(ctx, record.ID); err != nil { return record, false, err } return record, false, nil @@ -491,7 +482,7 @@ func (s *SandboxService) recoverStart(ctx context.Context, record types.Sandbox) 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 := s.runtime.Cleanup(ctx, record.ID); err != nil { + if err := backend.Cleanup(ctx, record.ID); err != nil { return record, false, err } return record, false, nil @@ -540,14 +531,14 @@ func (s *SandboxService) launchPlan(record types.Sandbox, image types.Image) (vm // 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, starting types.Sandbox, phase string, cause error, process vmm.Process) error { +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), cleanupTimeout) defer cancel() var cleanupErr error if process.PID > 0 { - cleanupErr = s.runtime.Abort(cleanupCtx, process) + cleanupErr = backend.Abort(cleanupCtx, process) } else { - cleanupErr = s.runtime.Cleanup(cleanupCtx, starting.ID) + cleanupErr = backend.Cleanup(cleanupCtx, starting.ID) } failureCause := errors.Join(cause, cleanupErr) failure := types.SandboxFailure{Phase: phase, Message: failureCause.Error()} @@ -562,7 +553,7 @@ func (s *SandboxService) failStart(ctx context.Context, starting types.Sandbox, // 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.reader == nil || s.lifecycle == nil || s.runtime == nil || s.reporter == nil || s.now == nil { + if s == nil || s.reader == nil || s.lifecycle == nil || len(s.runtimes) == 0 || s.reporter == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if reference == "" { @@ -598,6 +589,10 @@ func (s *SandboxService) Stop(ctx context.Context, reference string) (result typ if err != nil { return types.Sandbox{}, err } + backend, err := s.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)) @@ -606,7 +601,7 @@ func (s *SandboxService) Stop(ctx context.Context, reference string) (result typ if err := s.reporter.Status("cleaning stale runtime state"); err != nil { return record, err } - if err := s.runtime.Cleanup(ctx, record.ID); err != nil { + 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.reporter.Committed(record); err != nil { @@ -622,7 +617,7 @@ func (s *SandboxService) Stop(ctx context.Context, reference string) (result typ if err := s.reporter.Status("checking existing runtime"); err != nil { return record, err } - process, exists, err := s.runtime.Locate(ctx, record.ID, processGeneration) + 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) } @@ -639,17 +634,17 @@ func (s *SandboxService) Stop(ctx context.Context, reference string) (result typ } if exists { - if err := s.reporter.Status("stopping Cloud Hypervisor"); err != nil { + if err := s.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 := s.runtime.Stop(ctx, process); err != nil { + 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.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 := s.runtime.Cleanup(ctx, record.ID); err != nil { + 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) } @@ -695,6 +690,70 @@ func stopProcessGeneration(record types.Sandbox) (uint64, error) { 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. The operation lock is +// released before the caller relays bytes so stop remains available. +// +// resolve -> lock -> reread Running -> locate exact process -> open PTY -> unlock +// | +// caller owns console session +func (s *SandboxService) Console(ctx context.Context, reference string) (connection io.ReadWriteCloser, returnErr error) { + if s == nil || s.reader == nil || len(s.runtimes) == 0 { + return nil, errors.New("sandbox service is not configured") + } + if reference == "" { + return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX must not be empty")) + } + record, err := s.reader.Resolve(ctx, reference) + if err != nil { + return nil, err + } + lockPath, err := s.paths.Lock(record.ID) + if err != nil { + return nil, err + } + lock := filelock.New(lockPath) + if err := lock.Lock(ctx); err != nil { + return nil, errdefs.Context(err, "open sandbox console", reference, "lock", "retry the console connection", false) + } + defer func() { + if unlockErr := lock.Unlock(context.WithoutCancel(ctx)); unlockErr != nil { + if connection != nil { + unlockErr = errors.Join(unlockErr, connection.Close()) + connection = nil + } + returnErr = errdefs.Context(errors.Join(returnErr, unlockErr), "open sandbox console", reference, "unlock", "retry the console connection", false) + } + }() + + record, err = s.reader.Resolve(ctx, record.ID.String()) + if err != nil { + return nil, err + } + if record.State != types.SandboxStateRunning { + return nil, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s is %s, not running", record.ID, record.State)) + } + if record.Generation < 2 { + return nil, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("running sandbox has no Starting generation")) + } + backend, err := s.runtimes.backend(record.VMM) + if err != nil { + return nil, err + } + process, exists, err := backend.Locate(ctx, record.ID, record.Generation-1) + if err != nil { + return nil, errdefs.Context(err, "open sandbox console", reference, "locate VMM", "inspect the sandbox runtime", false) + } + if !exists { + return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("sandbox state is running but its VMM process is absent")) + } + 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 +} + // Remove records cleanup intent before deleting the COW directory and releases // the name and image reference only after filesystem cleanup succeeds. // diff --git a/core/sandbox_test.go b/core/sandbox_test.go index 229eb52..cfb01b4 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -3,6 +3,7 @@ package core import ( "context" "errors" + "io" "path/filepath" "reflect" "runtime" @@ -201,12 +202,21 @@ func (f fakeReporter) Committed(types.Sandbox) error { } type fakeRuntime struct { + typ types.VMMType steps *[]string observation vmm.Observation preflightErr error launchErr error stopErr error plan vmm.LaunchPlan + console io.ReadWriteCloser +} + +func (f *fakeRuntime) Type() types.VMMType { + if f.typ == "" { + return types.VMMCloudHypervisor + } + return f.typ } func (f *fakeRuntime) Preflight() error { @@ -249,11 +259,28 @@ func (f *fakeRuntime) Stop(context.Context, vmm.Process) error { 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) Cleanup(context.Context, types.SandboxID) error { *f.steps = append(*f.steps, "cleanup") return nil } +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") @@ -283,7 +310,7 @@ func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]s 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}} - service := newSandboxService(paths, imagePaths, fakeGuard{image: image, steps: &steps}, catalog, catalog, catalog, catalog, fakeDisk{steps: &steps, prepare: diskError}, runtimeAdapter, fakeReporter{steps: &steps}) + service := newSandboxService(paths, imagePaths, fakeGuard{image: image, steps: &steps}, catalog, catalog, catalog, catalog, fakeDisk{steps: &steps, prepare: diskError}, vmmBackends{runtimeAdapter.Type(): runtimeAdapter}, fakeReporter{steps: &steps}) service.newID = func() (types.SandboxID, error) { return fixedID, nil } service.now = func() time.Time { return time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) } return service, &steps @@ -300,12 +327,60 @@ func TestCreateCommitsCreatedAfterDiskPreparation(t *testing.T) { 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 TestSandboxLifecycleRoutesToPersistedVMM(t *testing.T) { + service, steps := newTestSandboxService(t, nil) + firecracker := &fakeRuntime{typ: types.VMMFirecracker, steps: steps, observation: vmm.Observation{State: vmm.ProcessAbsent}} + service.runtimes[types.VMMFirecracker] = firecracker + 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 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) @@ -421,7 +496,7 @@ func TestStartCommitsRunningOnlyAfterLaunchReadiness(t *testing.T) { if record.State != types.SandboxStateRunning || record.Generation != 4 { t.Fatalf("running record = %+v", record) } - runtimeAdapter := service.runtime.(*fakeRuntime) + runtimeAdapter := service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime) 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) } @@ -429,7 +504,7 @@ func TestStartCommitsRunningOnlyAfterLaunchReadiness(t *testing.T) { "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", + "status:launching cloud-hypervisor", "launch", "status:committing running state", "running", "report", } if !reflect.DeepEqual(*steps, want) { t.Fatalf("steps = %v, want %v", *steps, want) @@ -445,7 +520,7 @@ func TestStartRecoversRunningProcessFromStartingState(t *testing.T) { } catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateStarting, 3 - runtimeAdapter := service.runtime.(*fakeRuntime) + runtimeAdapter := service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime) runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}} *steps = nil record, err := service.Start(t.Context(), "box") @@ -468,7 +543,7 @@ func TestStartFailureAbortsProcessAndRetainsError(t *testing.T) { t.Fatal(err) } failure := errors.New("VMM exited") - service.runtime.(*fakeRuntime).launchErr = failure + service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime).launchErr = failure *steps = nil if _, err := service.Start(t.Context(), "box"); !errors.Is(err, failure) { t.Fatalf("Start error = %v", err) @@ -498,7 +573,7 @@ func TestStartRetryDoesNotLeaveStartingAfterPreflightFailure(t *testing.T) { catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateStarting, 3 failure := errors.New("KVM unavailable") - service.runtime.(*fakeRuntime).preflightErr = failure + service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime).preflightErr = failure *steps = nil if _, err := service.Start(t.Context(), "box"); !errors.Is(err, failure) { t.Fatalf("Start error = %v", err) @@ -520,7 +595,7 @@ func TestStopRecordsIntentBeforeTerminatingRunningVMM(t *testing.T) { } catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4 - service.runtime.(*fakeRuntime).observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}} + service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime).observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}} *steps = nil record, err := service.Stop(t.Context(), "box") if err != nil { @@ -532,7 +607,7 @@ func TestStopRecordsIntentBeforeTerminatingRunningVMM(t *testing.T) { 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:stopping cloud-hypervisor", "stop", "status:cleaning runtime state", "cleanup", "status:committing stopped state", "stopped", "report", } if !reflect.DeepEqual(*steps, want) { @@ -559,7 +634,7 @@ func TestStopResumesStoppingAndRecoversStarting(t *testing.T) { } catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = test.state, test.generation - service.runtime.(*fakeRuntime).observation = vmm.Observation{State: vmm.ProcessStarting, Process: vmm.Process{PID: 42}} + service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime).observation = vmm.Observation{State: vmm.ProcessStarting, Process: vmm.Process{PID: 42}} *steps = nil record, err := service.Stop(t.Context(), "box") if err != nil { @@ -568,7 +643,7 @@ func TestStopResumesStoppingAndRecoversStarting(t *testing.T) { 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") { + 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) } }) @@ -607,7 +682,7 @@ func TestStopFailureRetainsRetryableStoppingState(t *testing.T) { catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4 failure := errors.New("signal failed") - runtimeAdapter := service.runtime.(*fakeRuntime) + runtimeAdapter := service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime) runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}} runtimeAdapter.stopErr = failure *steps = nil @@ -643,6 +718,52 @@ func TestStopCreatedIsIdempotentAndPreservesCreated(t *testing.T) { } } +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.lifecycle.(*fakeCatalog) + catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4 + runtimeAdapter := service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime) + 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 TestRemoveMarksDeletingBeforeDiskAndFinalizesAfterCleanup(t *testing.T) { service, steps := newTestSandboxService(t, nil) if _, err := service.Create(t.Context(), CreateSandboxRequest{ diff --git a/core/vmm.go b/core/vmm.go new file mode 100644 index 0000000..2249c2d --- /dev/null +++ b/core/vmm.go @@ -0,0 +1,76 @@ +package core + +import ( + "errors" + "fmt" + + "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" + "github.com/kumabox/kumabox/vmm/cloudhypervisor" +) + +// vmmFactory assembles one concrete backend from application storage roots. +type vmmFactory func(storage.Roots) (vmm.Backend, error) + +// vmmFactories is the single registration point for supported VMM adapters. +// Adding Firecracker means registering its constructor here; sandbox workflows +// continue to route through the persisted types.VMMType. +var vmmFactories = map[types.VMMType]vmmFactory{ + types.VMMCloudHypervisor: newCloudHypervisor, +} + +// vmmBackends routes a persisted backend identity to its process adapter. +type vmmBackends map[types.VMMType]vmm.Backend + +// openVMMBackends constructs every registered adapter so commands can operate +// on sandboxes created by different VMMs in the same metadata catalog. +func openVMMBackends(roots storage.Roots) (vmmBackends, error) { + backends := make(vmmBackends, len(vmmFactories)) + for typ, factory := range vmmFactories { + backend, err := factory(roots) + if err != nil { + return nil, fmt.Errorf("initialize VMM %s: %w", typ, err) + } + if backend == nil || backend.Type() != typ { + return nil, fmt.Errorf("VMM factory %s returned a mismatched backend", typ) + } + backends[typ] = backend + } + return backends, nil +} + +// backend returns the adapter that owns record. Unknown but syntactically +// valid identities are unsupported locally rather than treated as corruption. +func (b vmmBackends) backend(typ types.VMMType) (vmm.Backend, error) { + if err := typ.Validate(); err != nil { + return nil, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, err) + } + backend, exists := b[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 +} + +// newCloudHypervisor owns the Cloud Hypervisor-specific dependency graph. +func newCloudHypervisor(roots storage.Roots) (vmm.Backend, error) { + paths, err := vmm.NewPaths(roots) + if err != nil { + return nil, err + } + scopes, err := cgroup.New("") + if err != nil { + return nil, err + } + backend, err := cloudhypervisor.New(paths, scopes, cloudhypervisor.Options{}) + if err != nil { + return nil, err + } + if backend == nil { + return nil, errors.New("cloud-hypervisor constructor returned nil") + } + return backend, nil +} diff --git a/go.mod b/go.mod index 64af5b5..d84ac11 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,14 @@ require ( 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/moby/term v0.5.2 github.com/spf13/cobra v1.10.2 golang.org/x/sync v0.15.0 modernc.org/sqlite v1.38.2 ) 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 v28.2.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect diff --git a/go.sum b/go.sum index 6c6a3c7..2ef7130 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ +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/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/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= @@ -30,6 +34,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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/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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -62,6 +68,7 @@ golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= diff --git a/sandbox/catalog/store.go b/sandbox/catalog/store.go index 637ae04..d97f979 100644 --- a/sandbox/catalog/store.go +++ b/sandbox/catalog/store.go @@ -63,6 +63,9 @@ type recordData struct { Storage int64 `json:"storage"` // 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. @@ -476,7 +479,7 @@ 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, - ImageDigest: record.ImageDigest.String(), State: string(record.State), + ImageDigest: record.ImageDigest.String(), VMM: string(record.VMM), State: string(record.State), Generation: record.Generation, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, } if record.Failure != nil { @@ -499,9 +502,12 @@ func decode(raw []byte) (types.Sandbox, error) { 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}, - ImageDigest: digest, State: types.SandboxState(data.State), Generation: data.Generation, + ImageDigest: digest, VMM: types.VMMType(data.VMM), State: types.SandboxState(data.State), Generation: data.Generation, CreatedAt: data.CreatedAt, UpdatedAt: data.UpdatedAt, } if data.Failure != nil { diff --git a/sandbox/catalog/store_test.go b/sandbox/catalog/store_test.go index 434d6b0..532493d 100644 --- a/sandbox/catalog/store_test.go +++ b/sandbox/catalog/store_test.go @@ -12,6 +12,32 @@ import ( "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 { @@ -44,7 +70,7 @@ func TestListReturnsValidatedRecordsNewestFirst(t *testing.T) { 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, State: types.SandboxStateCreated, Generation: 2, + ImageDigest: digest, VMM: types.VMMCloudHypervisor, State: types.SandboxStateCreated, Generation: 2, CreatedAt: created, UpdatedAt: created, } newer := older @@ -112,7 +138,7 @@ func TestReservationPinsImageInsideRemovalTransaction(t *testing.T) { 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, State: types.SandboxStateCreating, Generation: 1, CreatedAt: created, UpdatedAt: created, + 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) diff --git a/types/sandbox.go b/types/sandbox.go index c4c92be..d038454 100644 --- a/types/sandbox.go +++ b/types/sandbox.go @@ -31,6 +31,28 @@ const ( var validSandboxName = 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 @@ -144,6 +166,8 @@ type Sandbox struct { 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 // State controls which operations may consume owned resources. State SandboxState // Generation increments on every state transition and fences stale operations. @@ -167,6 +191,9 @@ func (s Sandbox) Validate() error { 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 + } switch s.State { case SandboxStateCreating, SandboxStateCreated, SandboxStateStarting, SandboxStateRunning, SandboxStateStopping, SandboxStateStopped, SandboxStateError, SandboxStateDeleting: diff --git a/vmm/backend.go b/vmm/backend.go new file mode 100644 index 0000000..62153d6 --- /dev/null +++ b/vmm/backend.go @@ -0,0 +1,24 @@ +package vmm + +import ( + "context" + "io" + + "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) + Cleanup(context.Context, types.SandboxID) error +} diff --git a/vmm/cloudhypervisor/args_test.go b/vmm/cloudhypervisor/args_test.go index 2c54fc2..8d00b1d 100644 --- a/vmm/cloudhypervisor/args_test.go +++ b/vmm/cloudhypervisor/args_test.go @@ -35,7 +35,7 @@ func TestQueryStateRequiresUnixSocketAndDecodesRunning(t *testing.T) { 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"}`)) + _, _ = 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) @@ -57,6 +57,13 @@ func TestQueryStateRequiresUnixSocketAndDecodesRunning(t *testing.T) { 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) } @@ -75,6 +82,20 @@ func TestQueryStateRequiresUnixSocketAndDecodesRunning(t *testing.T) { } } +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 TestBuildArgsPreservesDiskOrderAndAccessMode(t *testing.T) { plan := vmm.LaunchPlan{ SandboxID: "123e4567-e89b-42d3-a456-426614174000", Generation: 3, diff --git a/vmm/cloudhypervisor/driver.go b/vmm/cloudhypervisor/driver.go index 8a09815..03462b1 100644 --- a/vmm/cloudhypervisor/driver.go +++ b/vmm/cloudhypervisor/driver.go @@ -62,6 +62,8 @@ type Driver struct { startupTimeout 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 { @@ -79,6 +81,9 @@ func New(paths vmm.Paths, scopes *cgroup.Manager, options Options) (*Driver, err return &Driver{paths: paths, scopes: scopes, binary: options.Binary, startupTimeout: options.StartupTimeout}, 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 == "" { @@ -282,6 +287,43 @@ func (d *Driver) Stop(ctx context.Context, process vmm.Process) error { return terminateProcess(ctx, process, 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 +} + // 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 { @@ -324,35 +366,79 @@ func (d *Driver) recoverProcess(id types.SandboxID, generation uint64) (vmm.Proc // queryState performs one bounded request over the private Unix socket. func (d *Driver) queryState(ctx context.Context, socket string) (string, error) { - client, closeClient, err := unixAPIClient(socket) + 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 "", err + return vmInfo{}, err } response, err := client.Do(request) if err != nil { - return "", err + 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 "", fmt.Errorf("cloud hypervisor vm.info returned HTTP %d", response.StatusCode) - } - var payload struct { - State string `json:"state"` + 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 "", fmt.Errorf("decode Cloud Hypervisor vm.info: %w", err) + return vmInfo{}, fmt.Errorf("decode Cloud Hypervisor vm.info: %w", err) } if payload.State == "" { - return "", errors.New("cloud hypervisor vm.info omitted 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 payload.State, nil + return file, nil } // requestShutdown asks Cloud Hypervisor to stop its VM before process signals From 15aa88ce379c0660ebccffbdb464431c017653c3 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Fri, 18 Sep 2026 10:49:26 +0800 Subject: [PATCH 26/45] feat: add sandbox exec through guest agent --- .dockerignore | 14 ++ .gitignore | 2 - Makefile | 5 +- README.md | 15 ++ agent/agent_test.go | 143 +++++++++++++ agent/client.go | 107 ++++++++++ agent/process_linux.go | 31 +++ agent/process_other.go | 17 ++ agent/protocol.go | 163 ++++++++++++++ agent/server.go | 273 ++++++++++++++++++++++++ agent/vsock_linux.go | 39 ++++ agent/vsock_other.go | 13 ++ cli/doctor/command.go | 2 +- cli/root.go | 1 + cli/root_test.go | 1 + cli/sandbox/console.go | 2 +- cli/sandbox/exec.go | 64 ++++++ cmd/kumabox-agent/main.go | 52 +++++ core/sandbox.go | 121 +++++++---- core/sandbox_test.go | 64 +++++- disk/{ext4.go => disk.go} | 11 + disk/{ext4_test.go => disk_test.go} | 0 go.mod | 3 + go.sum | 6 + oci-images/ubuntu/Dockerfile | 24 ++- oci-images/ubuntu/README.md | 13 +- oci-images/ubuntu/kumabox-agent.service | 20 ++ oci-images/ubuntu/overlay.sh | 7 + scripts/kumabox-check.sh | 2 +- types/exec.go | 55 +++++ types/exec_test.go | 25 +++ types/sandbox.go | 4 +- vmm/backend.go | 1 + vmm/cloudhypervisor/vsock.go | 92 ++++++++ vmm/cloudhypervisor/vsock_test.go | 22 ++ vmm/vmm.go | 22 +- vmm/vmm_test.go | 4 +- 37 files changed, 1370 insertions(+), 70 deletions(-) create mode 100644 .dockerignore create mode 100644 agent/agent_test.go create mode 100644 agent/client.go create mode 100644 agent/process_linux.go create mode 100644 agent/process_other.go create mode 100644 agent/protocol.go create mode 100644 agent/server.go create mode 100644 agent/vsock_linux.go create mode 100644 agent/vsock_other.go create mode 100644 cli/sandbox/exec.go create mode 100644 cmd/kumabox-agent/main.go rename disk/{ext4.go => disk.go} (93%) rename disk/{ext4_test.go => disk_test.go} (100%) create mode 100644 oci-images/ubuntu/kumabox-agent.service create mode 100644 types/exec.go create mode 100644 types/exec_test.go create mode 100644 vmm/cloudhypervisor/vsock.go create mode 100644 vmm/cloudhypervisor/vsock_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a966139 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +** +!go.mod +!go.sum +!agent/ +!agent/** +!version/ +!version/** +!cmd/ +!cmd/kumabox-agent/ +!cmd/kumabox-agent/** +!oci-images/ +!oci-images/ubuntu/ +!oci-images/ubuntu/overlay.sh +!oci-images/ubuntu/kumabox-agent.service diff --git a/.gitignore b/.gitignore index c53f276..098f33d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,6 @@ dist/ 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*/ diff --git a/Makefile b/Makefile index cc33aba..46576c7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build install test doctor-check race verify lint vet fmt fmt-check deps clean coverage cloc help +.PHONY: all build agent install test doctor-check race verify lint vet fmt fmt-check deps clean coverage cloc help REPO_PATH := github.com/kumabox/kumabox @@ -65,6 +65,9 @@ build: | $(LOCALBIN) ## Build kumabox and kumabox-check 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" diff --git a/README.md b/README.md index d47b0dc..bfa3503 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,21 @@ kumabox console 123e4567-e89b-42d3-a456-426614174000 --escape-char '^A' Console requires terminal stdin. A concurrent `stop` closes the PTY session; the console command does not hold the sandbox operation lock while relaying I/O. +Run a command inside a running sandbox with `exec`. Arguments are sent directly +to the guest without an implicit shell. Standard output and standard error are +streamed independently, and the guest command status becomes the local process +status. Standard input is closed by default; use `-i` to attach it. + +```bash +kumabox exec NAME -- uname -a +kumabox exec -e FOO=bar NAME -- sh -c 'echo "$FOO"' +echo hello | kumabox exec -i NAME -- cat +``` + +The official Ubuntu image starts `kumabox-agent` on vsock port 1024. Early +userspace also applies the sandbox name as the guest hostname, so the serial +console and `hostname` report the same identity. + List active sandboxes with `ps`, or include created, stopped, failed, and deleting records with `-a`: diff --git a/agent/agent_test.go b/agent/agent_test.go new file mode 100644 index 0000000..b36c8d0 --- /dev/null +++ b/agent/agent_test.go @@ -0,0 +1,143 @@ +package agent + +import ( + "bytes" + "context" + "errors" + "io" + "log" + "net" + "strings" + "testing" + "time" +) + +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, + []string{"sh", "-c", `printf '%s:' "$KUMABOX_TEST"; cat; printf 'warning' >&2; exit 7`}, + 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 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..cdc22ec --- /dev/null +++ b/agent/client.go @@ -0,0 +1,107 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "io" + "sync/atomic" +) + +var errMissingExit = errors.New("agent connection closed before an exit frame") + +// Run executes argv 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, argv []string, environment map[string]string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + if len(argv) == 0 || argv[0] == "" { + return 0, errors.New("agent command is empty") + } + 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: argv, Env: environment}); 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/cli/doctor/command.go b/cli/doctor/command.go index 2716193..48a74d7 100644 --- a/cli/doctor/command.go +++ b/cli/doctor/command.go @@ -1,4 +1,4 @@ -// Package doctor exposes the Cocoon-compatible host checker through kumabox. +// Package doctor exposes host prerequisite checks through kumabox. package doctor import ( diff --git a/cli/root.go b/cli/root.go index bd18dcc..9e34207 100644 --- a/cli/root.go +++ b/cli/root.go @@ -115,6 +115,7 @@ func newRootCommand() *cobra.Command { root.AddCommand(imagecmd.NewCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewConsoleCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewCreateCommand(func() storage.Roots { return roots })) + root.AddCommand(sandboxcmd.NewExecCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewInspectCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewListCommand(func() storage.Roots { return roots })) root.AddCommand(sandboxcmd.NewRemoveCommand(func() storage.Roots { return roots })) diff --git a/cli/root_test.go b/cli/root_test.go index 0ad9ffa..d08876d 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -62,6 +62,7 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"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 remove sandbox", []string{"rm"}, 2}, {"missing start sandbox", []string{"start"}, 2}, diff --git a/cli/sandbox/console.go b/cli/sandbox/console.go index 2f58aa8..01b7300 100644 --- a/cli/sandbox/console.go +++ b/cli/sandbox/console.go @@ -145,7 +145,7 @@ func relayConsoleResize(localFD, remoteFD uintptr) func() { } } -// parseEscapeChar accepts Cocoon-compatible caret notation or one ASCII byte. +// parseEscapeChar accepts caret notation or one ASCII byte. func parseEscapeChar(value string) (byte, error) { if len(value) == 2 && value[0] == '^' { char := value[1] diff --git a/cli/sandbox/exec.go b/cli/sandbox/exec.go new file mode 100644 index 0000000..4756457 --- /dev/null +++ b/cli/sandbox/exec.go @@ -0,0 +1,64 @@ +package sandbox + +import ( + "errors" + "fmt" + "io" + + "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(roots rootsProvider) *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) { + config := types.ExecConfig{Args: append([]string(nil), args[1:]...), Env: environment, Interactive: interactive} + if err := config.Validate(); err != nil { + return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err) + } + service, err := core.OpenSandbox(command.Context(), roots(), 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], config, 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 +} 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/core/sandbox.go b/core/sandbox.go index 8f0cac0..7997f78 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -8,6 +8,7 @@ import ( "runtime" "time" + "github.com/kumabox/kumabox/agent" "github.com/kumabox/kumabox/disk" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" @@ -68,13 +69,6 @@ type sandboxLifecycle interface { MarkStopped(context.Context, types.SandboxID, uint64, types.SandboxState, time.Time) (types.Sandbox, error) } -// cowStore is the private writable-disk capability consumed by sandbox creation. -type cowStore interface { - Prepare(context.Context, types.SandboxID, int64) error - Check(context.Context, types.SandboxID, int64) error - Remove(context.Context, types.SandboxID) error -} - // SandboxReporter receives user-visible stages without controlling workflows. type SandboxReporter interface { Status(string) error @@ -95,8 +89,8 @@ type SandboxService struct { remover sandboxRemover // lifecycle commits generation-fenced start, stop, and recovery transitions. lifecycle sandboxLifecycle - // cows prepares and cleans the sandbox-owned writable disk. - cows cowStore + // disks prepares and cleans sandbox-owned writable disks. + disks disk.Backend // imagePaths derives immutable artifacts after the image guard verifies them. imagePaths images.Paths // runtimes route persisted VMM identities to process adapters. @@ -111,13 +105,13 @@ type SandboxService struct { } // newSandboxService connects the explicit capabilities needed by sandbox commands. -func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, lifecycle sandboxLifecycle, cows cowStore, runtimes vmmBackends, reporter SandboxReporter) *SandboxService { +func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, lifecycle sandboxLifecycle, disks disk.Backend, runtimes vmmBackends, reporter SandboxReporter) *SandboxService { if reporter == nil { reporter = discardReporter{} } return &SandboxService{ paths: paths, imagePaths: imagePaths, images: images, creator: creator, reader: reader, - remover: remover, lifecycle: lifecycle, cows: cows, runtimes: runtimes, reporter: reporter, + remover: remover, lifecycle: lifecycle, disks: disks, runtimes: runtimes, reporter: reporter, newID: types.NewSandboxID, now: time.Now, } } @@ -173,7 +167,7 @@ func (s *SandboxService) Close() error { // | | // +---- failure cleanup <---+ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxRequest) (result types.Sandbox, returnErr error) { - if s == nil || s.images == nil || s.creator == nil || s.cows == nil || len(s.runtimes) == 0 || s.reporter == nil || s.newID == nil || s.now == nil { + if s == nil || s.images == nil || s.creator == nil || s.disks == nil || len(s.runtimes) == 0 || s.reporter == nil || s.newID == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if request.ImageReference == "" { @@ -239,7 +233,7 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques if err := s.reporter.Status("creating sparse ext4 disk"); err != nil { return types.Sandbox{}, s.compensate(ctx, record, "report", err) } - if err := s.cows.Prepare(ctx, id, request.Config.Storage); err != nil { + if err := s.disks.Prepare(ctx, id, request.Config.Storage); err != nil { return types.Sandbox{}, s.compensate(ctx, record, "disk", err) } if err := s.reporter.Status("committing created state"); err != nil { @@ -300,7 +294,7 @@ func (s *SandboxService) Inspect(ctx context.Context, reference string) (types.S // | // abort + retained Error func (s *SandboxService) Start(ctx context.Context, reference string) (result types.Sandbox, returnErr error) { - if s == nil || s.reader == nil || s.lifecycle == nil || s.images == nil || s.cows == nil || len(s.runtimes) == 0 || s.reporter == nil || s.now == nil { + if s == nil || s.reader == nil || s.lifecycle == nil || s.images == nil || s.disks == nil || len(s.runtimes) == 0 || s.reporter == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if reference == "" { @@ -380,7 +374,7 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty if buildErr != nil { return buildErr } - return s.cows.Check(ctx, record.ID, record.Config.Storage) + return s.disks.Check(ctx, record.ID, record.Config.Storage) }) if err != nil { return record, failBeforeLaunch("validate artifacts", err) @@ -510,7 +504,7 @@ func (s *SandboxService) launchPlan(record types.Sandbox, image types.Image) (vm if err != nil { return vmm.LaunchPlan{}, err } - cmdline, err := vmm.OverlayV1Cmdline(len(image.Layers)) + cmdline, err := vmm.OverlayV1Cmdline(vmm.OverlayV1Config{LayerCount: len(image.Layers), Hostname: record.Config.Name}) if err != nil { return vmm.LaunchPlan{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeImageIncompatible, err) } @@ -691,67 +685,104 @@ func stopProcessGeneration(record types.Sandbox) (uint64, error) { } // Console opens the current direct-boot PTY after proving the sandbox record -// and VMM process refer to the same Running generation. The operation lock is -// released before the caller relays bytes so stop remains available. +// and VMM process refer to the same Running generation. // -// resolve -> lock -> reread Running -> locate exact process -> open PTY -> unlock +// resolve -> lock -> reread Running -> locate exact process -> unlock -> open PTY // | // caller owns console session -func (s *SandboxService) Console(ctx context.Context, reference string) (connection io.ReadWriteCloser, returnErr error) { +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 +} + +// 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, config types.ExecConfig, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + if err := config.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 + if !config.Interactive { + stdin = nil + } + exitCode, err := agent.Run(ctx, connection, config.Args, config.Environment(), 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.reader == nil || len(s.runtimes) == 0 { - return nil, errors.New("sandbox service is not configured") + return nil, vmm.Process{}, errors.New("sandbox service is not configured") } if reference == "" { - return nil, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX must not be empty")) + return nil, vmm.Process{}, errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, errors.New("SANDBOX must not be empty")) } record, err := s.reader.Resolve(ctx, reference) if err != nil { - return nil, err + return nil, vmm.Process{}, err } lockPath, err := s.paths.Lock(record.ID) if err != nil { - return nil, err + return nil, vmm.Process{}, err } lock := filelock.New(lockPath) if err := lock.Lock(ctx); err != nil { - return nil, errdefs.Context(err, "open sandbox console", reference, "lock", "retry the console connection", false) + 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 { - if connection != nil { - unlockErr = errors.Join(unlockErr, connection.Close()) - connection = nil - } - returnErr = errdefs.Context(errors.Join(returnErr, unlockErr), "open sandbox console", reference, "unlock", "retry the console connection", false) + backend = nil + process = vmm.Process{} + returnErr = errdefs.Context(errors.Join(returnErr, unlockErr), operation, reference, "unlock", "retry the operation", false) } }() record, err = s.reader.Resolve(ctx, record.ID.String()) if err != nil { - return nil, err + return nil, vmm.Process{}, err } if record.State != types.SandboxStateRunning { - return nil, errdefs.New(errdefs.ClassConflict, errdefs.CodeStateConflict, fmt.Errorf("sandbox %s is %s, not running", record.ID, record.State)) + 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, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("running sandbox has no Starting generation")) + return nil, vmm.Process{}, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, errors.New("running sandbox has no Starting generation")) } - backend, err := s.runtimes.backend(record.VMM) + backend, err = s.runtimes.backend(record.VMM) if err != nil { - return nil, err + return nil, vmm.Process{}, err } process, exists, err := backend.Locate(ctx, record.ID, record.Generation-1) if err != nil { - return nil, errdefs.Context(err, "open sandbox console", reference, "locate VMM", "inspect the sandbox runtime", false) + return nil, vmm.Process{}, errdefs.Context(err, operation, reference, "locate VMM", "inspect the sandbox runtime", false) } if !exists { - return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("sandbox state is running but its VMM process is absent")) + return nil, vmm.Process{}, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, errors.New("sandbox state is running but its VMM process is absent")) } - 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 + return backend, process, nil } // Remove records cleanup intent before deleting the COW directory and releases @@ -761,7 +792,7 @@ func (s *SandboxService) Console(ctx context.Context, reference string) (connect // | | // +---- retry resumes here <---+ func (s *SandboxService) Remove(ctx context.Context, reference string) (result types.Sandbox, returnErr error) { - if s == nil || s.reader == nil || s.remover == nil || s.cows == nil || s.reporter == nil || s.now == nil { + if s == nil || s.reader == nil || s.remover == nil || s.disks == nil || s.reporter == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if reference == "" { @@ -804,7 +835,7 @@ func (s *SandboxService) Remove(ctx context.Context, reference string) (result t if err := s.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.cows.Remove(ctx, deleting.ID); err != nil { + if err := s.disks.Remove(ctx, deleting.ID); err != nil { return deleting, errdefs.Context(err, "remove sandbox", reference, "disk cleanup", "retry removal to finish cleanup", true) } if err := s.reporter.Status("releasing metadata and image reference"); err != nil { @@ -824,7 +855,7 @@ func (s *SandboxService) Remove(ctx context.Context, reference string) (result t func (s *SandboxService) compensate(ctx context.Context, record types.Sandbox, phase string, cause error) error { cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cleanupTimeout) defer cancel() - removeErr := s.cows.Remove(cleanupCtx, record.ID) + removeErr := s.disks.Remove(cleanupCtx, record.ID) if removeErr == nil { forgetErr := s.creator.Forget(cleanupCtx, record.ID, record.Generation) if forgetErr == nil { diff --git a/core/sandbox_test.go b/core/sandbox_test.go index cfb01b4..0a5bef0 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -1,9 +1,11 @@ package core import ( + "bytes" "context" "errors" "io" + "net" "path/filepath" "reflect" "runtime" @@ -11,6 +13,7 @@ import ( "testing" "time" + "github.com/kumabox/kumabox/agent" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" "github.com/kumabox/kumabox/sandbox" @@ -210,6 +213,7 @@ type fakeRuntime struct { stopErr error plan vmm.LaunchPlan console io.ReadWriteCloser + vsock io.ReadWriteCloser } func (f *fakeRuntime) Type() types.VMMType { @@ -267,6 +271,14 @@ func (f *fakeRuntime) Console(context.Context, vmm.Process) (io.ReadWriteCloser, 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) Cleanup(context.Context, types.SandboxID) error { *f.steps = append(*f.steps, "cleanup") return nil @@ -416,9 +428,9 @@ func TestCreateRetainsErrorOwnerWhenDiskCleanupFails(t *testing.T) { prepareFailure := errors.New("mkfs failed") removeFailure := errors.New("disk cleanup failed") service, steps := newTestSandboxService(t, prepareFailure) - disks := service.cows.(fakeDisk) + disks := service.disks.(fakeDisk) disks.remove = removeFailure - service.cows = disks + service.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) { @@ -764,6 +776,48 @@ func TestConsoleRejectsNonRunningSandboxBeforeRuntimeAccess(t *testing.T) { } } +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.lifecycle.(*fakeCatalog) + catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4 + runtimeAdapter := service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime) + 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.ExecConfig{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) + } +} + func TestRemoveMarksDeletingBeforeDiskAndFinalizesAfterCleanup(t *testing.T) { service, steps := newTestSandboxService(t, nil) if _, err := service.Create(t.Context(), CreateSandboxRequest{ @@ -797,9 +851,9 @@ func TestRemoveFailureRetainsDeletingAndRetryFinishes(t *testing.T) { t.Fatal(err) } failure := errors.New("disk cleanup failed") - disks := service.cows.(fakeDisk) + disks := service.disks.(fakeDisk) disks.remove = failure - service.cows = disks + service.disks = disks *steps = nil if _, err := service.Remove(t.Context(), "box"); !errors.Is(err, failure) { t.Fatalf("Remove error = %v", err) @@ -814,7 +868,7 @@ func TestRemoveFailureRetainsDeletingAndRetryFinishes(t *testing.T) { t.Fatalf("retained delete record = %+v, deleted=%v", catalog.record, catalog.deleted) } disks.remove = nil - service.cows = disks + service.disks = disks *steps = nil if _, err := service.Remove(t.Context(), "box"); err != nil { t.Fatalf("retry Remove: %v", err) diff --git a/disk/ext4.go b/disk/disk.go similarity index 93% rename from disk/ext4.go rename to disk/disk.go index 40eac8b..bc021b8 100644 --- a/disk/ext4.go +++ b/disk/disk.go @@ -26,6 +26,15 @@ const ( 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. @@ -34,6 +43,8 @@ type Ext4 struct { mkfs string } +var _ Backend = (*Ext4)(nil) + // NewExt4 creates the production disk preparer using mkfs.ext4 from PATH. func NewExt4(paths sandbox.Paths) *Ext4 { return &Ext4{paths: paths, mkfs: "mkfs.ext4"} diff --git a/disk/ext4_test.go b/disk/disk_test.go similarity index 100% rename from disk/ext4_test.go rename to disk/disk_test.go diff --git a/go.mod b/go.mod index d84ac11..ba0d635 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( 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 golang.org/x/sync v0.15.0 @@ -22,6 +23,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mdlayher/socket v0.4.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -32,6 +34,7 @@ require ( github.com/spf13/pflag v1.0.9 // indirect github.com/vbatts/tar-split v0.12.1 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.9.0 // indirect golang.org/x/sys v0.37.0 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 2ef7130..b9af8f8 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,10 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= +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/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -66,6 +70,8 @@ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/y golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/oci-images/ubuntu/Dockerfile b/oci-images/ubuntu/Dockerfile index c642ef0..4618aba 100644 --- a/oci-images/ubuntu/Dockerfile +++ b/oci-images/ubuntu/Dockerfile @@ -1,15 +1,31 @@ # 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 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. -ARG UBUNTU_IMAGE=ubuntu:24.04 FROM ${UBUNTU_IMAGE} LABEL io.kumabox.boot.profile="overlay-v1" ENV DEBIAN_FRONTEND=noninteractive -COPY overlay.sh /usr/local/lib/kumabox/initramfs/kumabox-overlay +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/kumabox-agent.service /etc/systemd/system/kumabox-agent.service RUN set -eu; \ apt-get update \ @@ -52,12 +68,14 @@ RUN set -eu; \ 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 \ + && 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 index 1d8af51..1d0b2d5 100644 --- a/oci-images/ubuntu/README.md +++ b/oci-images/ubuntu/README.md @@ -13,17 +13,26 @@ Build a local architecture image with BuildKit: ```sh docker buildx build --load --platform linux/amd64 \ - -t kumabox/ubuntu:24.04 oci-images/ubuntu + -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: \ - -t ghcr.io/kgpp34/kumabox/ubuntu:24.04 --push oci-images/ubuntu + -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/kumabox-agent.service b/oci-images/ubuntu/kumabox-agent.service new file mode 100644 index 0000000..70dbcd9 --- /dev/null +++ b/oci-images/ubuntu/kumabox-agent.service @@ -0,0 +1,20 @@ +[Unit] +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 vmw_vsock_virtio_transport +ExecStart=/usr/local/bin/kumabox-agent serve +Restart=always +RestartSec=2s +StandardOutput=journal +StandardError=journal +SyslogIdentifier=kumabox-agent +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target diff --git a/oci-images/ubuntu/overlay.sh b/oci-images/ubuntu/overlay.sh index ba65719..edf728f 100755 --- a/oci-images/ubuntu/overlay.sh +++ b/oci-images/ubuntu/overlay.sh @@ -45,11 +45,13 @@ kumabox_device() { mountroot() { KUMABOX_LAYERS= KUMABOX_COW= + KUMABOX_HOSTNAME= 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=} ;; esac done @@ -60,12 +62,16 @@ mountroot() { [ "$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 modprobe erofs 2>/dev/null || true modprobe overlay 2>/dev/null || true @@ -103,5 +109,6 @@ mountroot() { 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/scripts/kumabox-check.sh b/scripts/kumabox-check.sh index 5c2de4a..e7c031d 100755 --- a/scripts/kumabox-check.sh +++ b/scripts/kumabox-check.sh @@ -238,7 +238,7 @@ check_dir() { check_dir "$KUMABOX_ROOT_DIR" # SQLite WAL needs coherent shared memory. KumaBox has one metadata engine and -# does not probe or preserve Cocoon's per-backend JSON stores. +# 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*) diff --git a/types/exec.go b/types/exec.go new file mode 100644 index 0000000..35dcb50 --- /dev/null +++ b/types/exec.go @@ -0,0 +1,55 @@ +package types + +import ( + "errors" + "fmt" + "strings" +) + +// ExecConfig describes one command invocation inside a running sandbox. It is +// independent of the guest-agent wire format so core and CLI do not depend on +// protocol frames. +type ExecConfig 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 in KEY=VALUE form. + Env []string + // Interactive connects the caller's input stream to the guest process. + Interactive bool +} + +// Validate rejects malformed commands before a guest-agent connection opens. +func (c ExecConfig) 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 _, pair := range c.Env { + key, _, ok := strings.Cut(pair, "=") + if !ok || key == "" || strings.IndexByte(pair, 0) >= 0 || strings.Contains(key, "=") { + return fmt.Errorf("environment %q must be KEY=VALUE", pair) + } + } + return nil +} + +// Environment converts validated KEY=VALUE entries into the map used by the +// agent protocol. Repeated keys use the last CLI value. +func (c ExecConfig) Environment() map[string]string { + if len(c.Env) == 0 { + return nil + } + environment := make(map[string]string, len(c.Env)) + for _, pair := range c.Env { + key, value, ok := strings.Cut(pair, "=") + if ok && key != "" { + environment[key] = value + } + } + return environment +} diff --git a/types/exec_test.go b/types/exec_test.go new file mode 100644 index 0000000..a182d3c --- /dev/null +++ b/types/exec_test.go @@ -0,0 +1,25 @@ +package types + +import "testing" + +func TestExecConfigValidationAndEnvironment(t *testing.T) { + config := ExecConfig{Args: []string{"sh", "-c", "echo"}, Env: []string{"A=1", "A=2", "EMPTY="}} + if err := config.Validate(); err != nil { + t.Fatal(err) + } + environment := config.Environment() + if environment["A"] != "2" || environment["EMPTY"] != "" { + t.Fatalf("environment = %#v", environment) + } + for _, invalid := range []ExecConfig{ + {}, + {Args: []string{""}}, + {Args: []string{"echo", "bad\x00argument"}}, + {Args: []string{"env"}, Env: []string{"MISSING_VALUE"}}, + {Args: []string{"env"}, Env: []string{"=missing-key"}}, + } { + if err := invalid.Validate(); err == nil { + t.Fatalf("accepted invalid config %#v", invalid) + } + } +} diff --git a/types/sandbox.go b/types/sandbox.go index d038454..c366a07 100644 --- a/types/sandbox.go +++ b/types/sandbox.go @@ -15,7 +15,7 @@ import ( ) const ( - // DefaultSandboxCPUs matches the initial Cocoon-compatible sandbox shape. + // DefaultSandboxCPUs is the default virtual CPU count. DefaultSandboxCPUs uint32 = 2 // DefaultSandboxMemory is one gibibyte. DefaultSandboxMemory int64 = 1 << 30 @@ -23,7 +23,7 @@ const ( DefaultSandboxStorage int64 = 10 << 30 // MinSandboxMemory rejects guests too small for the supported boot path. MinSandboxMemory int64 = 512 << 20 - // MinSandboxStorage matches the minimum COW capacity accepted by Cocoon. + // 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 diff --git a/vmm/backend.go b/vmm/backend.go index 62153d6..55eab3a 100644 --- a/vmm/backend.go +++ b/vmm/backend.go @@ -20,5 +20,6 @@ type Backend interface { 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) Cleanup(context.Context, types.SandboxID) error } diff --git a/vmm/cloudhypervisor/vsock.go b/vmm/cloudhypervisor/vsock.go new file mode 100644 index 0000000..aae9712 --- /dev/null +++ b/vmm/cloudhypervisor/vsock.go @@ -0,0 +1,92 @@ +package cloudhypervisor + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "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 strings.TrimSpace(reply) != fmt.Sprintf("OK %d", port) { + _ = connection.Close() + return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, fmt.Errorf("connect guest vsock port %d: %s", port, strings.TrimSpace(reply))) + } + 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 +} + +// 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..da508cd --- /dev/null +++ b/vmm/cloudhypervisor/vsock_test.go @@ -0,0 +1,22 @@ +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") + } +} diff --git a/vmm/vmm.go b/vmm/vmm.go index 7765b57..c15c2b6 100644 --- a/vmm/vmm.go +++ b/vmm/vmm.go @@ -84,18 +84,30 @@ func (p LaunchPlan) Validate() error { 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 +} + // 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(layerCount int) (string, error) { - if layerCount <= 0 { +func OverlayV1Cmdline(config OverlayV1Config) (string, error) { + if config.LayerCount <= 0 { return "", errors.New("overlay-v1 requires at least one image layer") } - serials := make([]string, 0, layerCount) - for position := layerCount - 1; position >= 0; position-- { + 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)) } return "console=hvc0 loglevel=3 boot=kumabox-overlay kumabox.layers=" + strings.Join(serials, ",") + - " kumabox.cow=" + COWSerial + " clocksource=kvm-clock rw", nil + " kumabox.cow=" + COWSerial + " kumabox.hostname=" + config.Hostname + " clocksource=kvm-clock rw", nil } // Process identifies one Linux process generation independently of PID reuse. diff --git a/vmm/vmm_test.go b/vmm/vmm_test.go index 3349e94..3c8bbb9 100644 --- a/vmm/vmm_test.go +++ b/vmm/vmm_test.go @@ -8,11 +8,11 @@ import ( ) func TestOverlayV1CmdlineListsLayersTopToBase(t *testing.T) { - cmdline, err := OverlayV1Cmdline(3) + 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") { + 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) } } From 3766f758302ccbf5e9dd7f00f8c992554f6bf137 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Fri, 18 Sep 2026 11:09:26 +0800 Subject: [PATCH 27/45] fix: accept hybrid vsock assigned port --- vmm/cloudhypervisor/vsock.go | 19 +++++++++++++++++-- vmm/cloudhypervisor/vsock_test.go | 11 +++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/vmm/cloudhypervisor/vsock.go b/vmm/cloudhypervisor/vsock.go index aae9712..3e550f2 100644 --- a/vmm/cloudhypervisor/vsock.go +++ b/vmm/cloudhypervisor/vsock.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net" + "strconv" "strings" "github.com/kumabox/kumabox/errdefs" @@ -54,9 +55,9 @@ func (d *Driver) DialVsock(ctx context.Context, process vmm.Process, port uint32 } return nil, fmt.Errorf("read hybrid vsock reply: %w", err) } - if strings.TrimSpace(reply) != fmt.Sprintf("OK %d", port) { + if err := validateHybridVsockReply(reply); err != nil { _ = connection.Close() - return nil, errdefs.New(errdefs.ClassUnavailable, errdefs.CodeArtifactUnavailable, fmt.Errorf("connect guest vsock port %d: %s", port, strings.TrimSpace(reply))) + 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 { @@ -69,6 +70,20 @@ func (d *Driver) DialVsock(ctx context.Context, process vmm.Process, port uint32 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) { diff --git a/vmm/cloudhypervisor/vsock_test.go b/vmm/cloudhypervisor/vsock_test.go index da508cd..e88f346 100644 --- a/vmm/cloudhypervisor/vsock_test.go +++ b/vmm/cloudhypervisor/vsock_test.go @@ -20,3 +20,14 @@ func TestReadHybridVsockReplyIsBounded(t *testing.T) { 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) + } + } +} From 19f8456019ec0d6b08e5f390c319daf7b3ed1844 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Fri, 18 Sep 2026 23:36:33 +0800 Subject: [PATCH 28/45] fix: preserve structured error chains --- cli/root_test.go | 34 +++++++++++++++++ errdefs/error.go | 52 +++++++++++++++++++++----- errdefs/error_test.go | 86 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 errdefs/error_test.go diff --git a/cli/root_test.go b/cli/root_test.go index d08876d..199422f 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -3,11 +3,14 @@ package cli import ( "bytes" "context" + "errors" "os" "path/filepath" "runtime" "strings" "testing" + + "github.com/kumabox/kumabox/errdefs" ) func TestDoctorForwardsArgumentsAndExitCode(t *testing.T) { @@ -87,3 +90,34 @@ func TestImageAndUsageExitCodes(t *testing.T) { }) } } + +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/errdefs/error.go b/errdefs/error.go index 3a79bbe..e51b3ec 100644 --- a/errdefs/error.go +++ b/errdefs/error.go @@ -2,10 +2,7 @@ // context across module boundaries while preserving the original error chain. package errdefs -import ( - "errors" - "fmt" -) +import "errors" // Code is a stable machine-readable failure code. type Code string @@ -72,12 +69,14 @@ type Error struct { // Committed records that durable business state changed despite this error; // callers must inspect resulting state before deciding to retry. Committed bool - // Retry is an optional producer hint that another attempt may succeed. - Retry bool // Action suggests a recovery step for the caller. Action string - // Cause preserves underlying failures for errors.Is and errors.As. + // 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. @@ -105,11 +104,16 @@ func (e *Error) Error() string { return message } -// Unwrap exposes the cause to standard error-chain inspection, including nil receivers. +// 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 } @@ -136,7 +140,8 @@ func Context(err error, operation, entity, phase, action string, committed bool) copy.Phase = first(phase, copy.Phase) copy.Action = first(action, copy.Action) copy.Committed = committed || copy.Committed - copy.Cause = fmt.Errorf("%w", err) + copy.Cause = diagnosticCause(err, classified) + copy.wrapped = err return © } return &Error{ @@ -146,6 +151,35 @@ func Context(err error, operation, entity, phase, action string, committed bool) } } +// 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 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) + } +} From bc8a55954e07bc38f15cb48f0373c01ab2648b0c Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Sat, 19 Sep 2026 00:09:44 +0800 Subject: [PATCH 29/45] refactor: centralize runtime configuration --- cli/image/command.go | 22 +-- cli/image/command_test.go | 12 +- cli/image/import.go | 8 +- cli/image/query.go | 12 +- cli/image/remove.go | 4 +- cli/root.go | 65 ++++++--- cli/root_test.go | 60 ++++++++ cli/sandbox/console.go | 4 +- cli/sandbox/create.go | 16 +-- cli/sandbox/create_test.go | 12 +- cli/sandbox/exec.go | 4 +- cli/sandbox/query.go | 8 +- cli/sandbox/query_test.go | 9 +- cli/sandbox/remove.go | 4 +- cli/sandbox/remove_test.go | 9 +- cli/sandbox/start.go | 4 +- cli/sandbox/stop.go | 4 +- config/config.go | 213 +++++++++++++++++++++++++++++ config/config_test.go | 119 ++++++++++++++++ core/images.go | 39 ++++-- core/sandbox.go | 70 ++++++---- core/sandbox_test.go | 60 ++++++-- core/vmm.go | 78 +++-------- disk/disk.go | 9 +- disk/disk_test.go | 14 +- go.mod | 15 +- go.sum | 46 ++++++- images/erofs/convert.go | 20 ++- images/erofs/convert_test.go | 17 +++ vmm/backend.go | 66 +++++++++ vmm/backend_test.go | 77 +++++++++++ vmm/cloudhypervisor/driver.go | 31 +++-- vmm/cloudhypervisor/driver_test.go | 47 +++++++ 33 files changed, 970 insertions(+), 208 deletions(-) create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 vmm/backend_test.go create mode 100644 vmm/cloudhypervisor/driver_test.go diff --git a/cli/image/command.go b/cli/image/command.go index 8237355..902ac54 100644 --- a/cli/image/command.go +++ b/cli/image/command.go @@ -9,24 +9,24 @@ import ( "github.com/spf13/cobra" + "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/types" ) -// rootsProvider defers reading storage roots until command flags have been parsed. -type rootsProvider func() storage.Roots +// configProvider defers reading immutable configuration until flags are parsed. +type configProvider func() config.Config -// NewCommand registers the image command tree using invocation-local storage roots. -func NewCommand(roots rootsProvider) *cobra.Command { +// 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(roots), - newImportCommand(roots), - newListCommand(roots), - newInspectCommand(roots), - newVerifyCommand(roots), - newRemoveCommand(roots), + newPullCommand(configuration), + newImportCommand(configuration), + newListCommand(configuration), + newInspectCommand(configuration), + newVerifyCommand(configuration), + newRemoveCommand(configuration), ) return command } diff --git a/cli/image/command_test.go b/cli/image/command_test.go index bdda162..b08f19d 100644 --- a/cli/image/command_test.go +++ b/cli/image/command_test.go @@ -16,6 +16,7 @@ import ( "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" @@ -34,7 +35,7 @@ func newImageTestExecutor(t *testing.T) (storage.Roots, func(...string) (string, 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() storage.Roots { return roots }) + command := NewCommand(func() config.Config { return imageTestConfig(roots) }) var out, stderr bytes.Buffer command.SetOut(&out) command.SetErr(&stderr) @@ -45,6 +46,13 @@ func newImageTestExecutor(t *testing.T) (storage.Roots, func(...string) (string, 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) @@ -256,7 +264,7 @@ func TestImageCommandsFromDockerArchive(t *testing.T) { 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() storage.Roots { return roots }) + 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"}) diff --git a/cli/image/import.go b/cli/image/import.go index 3c15606..e08b5c5 100644 --- a/cli/image/import.go +++ b/cli/image/import.go @@ -10,7 +10,7 @@ import ( // 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(roots rootsProvider) *cobra.Command { +func newPullCommand(configuration configProvider) *cobra.Command { platform := defaultPlatform() command := &cobra.Command{ Use: "pull REF", @@ -30,7 +30,7 @@ func newPullCommand(roots rootsProvider) *cobra.Command { return err } defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }() - state, err := core.OpenImages(command.Context(), roots()) + state, err := core.OpenImages(command.Context(), configuration()) if err != nil { return err } @@ -59,7 +59,7 @@ func newPullCommand(roots rootsProvider) *cobra.Command { // validate --> open store --> stage source --> import --> write result // | // final progress <-- close store <-- clean source <----+ -func newImportCommand(roots rootsProvider) *cobra.Command { +func newImportCommand(configuration configProvider) *cobra.Command { platform := defaultPlatform() format := "auto" sourceTag := "" @@ -86,7 +86,7 @@ func newImportCommand(roots rootsProvider) *cobra.Command { return err } defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }() - state, err := core.OpenImages(command.Context(), roots()) + state, err := core.OpenImages(command.Context(), configuration()) if err != nil { return err } diff --git a/cli/image/query.go b/cli/image/query.go index 388e696..11c5246 100644 --- a/cli/image/query.go +++ b/cli/image/query.go @@ -11,7 +11,7 @@ import ( ) // newListCommand renders catalog entries as an aligned table or detailed JSON. -func newListCommand(roots rootsProvider) *cobra.Command { +func newListCommand(configuration configProvider) *cobra.Command { asJSON := false command := &cobra.Command{ Use: "list", @@ -19,7 +19,7 @@ func newListCommand(roots rootsProvider) *cobra.Command { Short: "list imported images", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) (returnErr error) { - state, err := core.OpenImages(command.Context(), roots()) + state, err := core.OpenImages(command.Context(), configuration()) if err != nil { return err } @@ -43,13 +43,13 @@ func newListCommand(roots rootsProvider) *cobra.Command { } // newInspectCommand resolves a name or digest and preserves full metadata in JSON. -func newInspectCommand(roots rootsProvider) *cobra.Command { +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(), roots()) + state, err := core.OpenImages(command.Context(), configuration()) if err != nil { return err } @@ -65,7 +65,7 @@ func newInspectCommand(roots rootsProvider) *cobra.Command { // newVerifyCommand checks persisted artifacts and reports waiting on stderr. // Store cleanup completes before the progress reporter emits its final status. -func newVerifyCommand(roots rootsProvider) *cobra.Command { +func newVerifyCommand(configuration configProvider) *cobra.Command { return &cobra.Command{ Use: "verify IMAGE", Short: "verify image artifacts", @@ -79,7 +79,7 @@ func newVerifyCommand(roots rootsProvider) *cobra.Command { if err := progress.Status("checking image artifacts"); err != nil { return err } - state, err := core.OpenImages(command.Context(), roots()) + state, err := core.OpenImages(command.Context(), configuration()) if err != nil { return err } diff --git a/cli/image/remove.go b/cli/image/remove.go index e4da951..659f151 100644 --- a/cli/image/remove.go +++ b/cli/image/remove.go @@ -15,7 +15,7 @@ import ( // 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(roots rootsProvider) *cobra.Command { +func newRemoveCommand(configuration configProvider) *cobra.Command { return &cobra.Command{ Use: "remove IMAGE...", Aliases: []string{"rm"}, @@ -27,7 +27,7 @@ func newRemoveCommand(roots rootsProvider) *cobra.Command { return err } defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }() - state, err := core.OpenImages(command.Context(), roots()) + state, err := core.OpenImages(command.Context(), configuration()) if err != nil { return err } diff --git a/cli/root.go b/cli/root.go index 9e34207..3ca8816 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1,5 +1,5 @@ // Package cli builds the kumabox command tree and maps command failures to exit statuses. -// Commands receive explicit streams and storage roots for independent invocations. +// Commands receive explicit streams and immutable configuration for independent invocations. package cli import ( @@ -13,8 +13,8 @@ import ( doctorcmd "github.com/kumabox/kumabox/cli/doctor" imagecmd "github.com/kumabox/kumabox/cli/image" sandboxcmd "github.com/kumabox/kumabox/cli/sandbox" + "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/version" ) @@ -54,7 +54,10 @@ const exitUsage = 2 // 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 := newRootCommand() + root, err := newRootCommand() + if err != nil { + return err + } root.SetArgs(args) root.SetOut(stdout) root.SetErr(stderr) @@ -63,7 +66,7 @@ func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) error if _, _, err := root.Find(args); err != nil { return &codedError{err: err, code: exitUsage} } - err := root.ExecuteContext(ctx) + err = root.ExecuteContext(ctx) if err == nil { return nil } @@ -92,10 +95,12 @@ func Silent(err error) bool { return errors.As(err, &silent) && silent.Silent() } -// newRootCommand creates invocation-local flags and registers the command modules. -// The roots callback observes values after Cobra has parsed persistent flags. -func newRootCommand() *cobra.Command { - roots := storage.DefaultRoots() +// 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, @@ -103,27 +108,45 @@ func newRootCommand() *cobra.Command { 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} }) - root.PersistentFlags().StringVar(&roots.Data, "root-dir", roots.Data, "persistent data directory") - root.PersistentFlags().StringVar(&roots.Run, "run-dir", roots.Run, "runtime state directory") - root.PersistentFlags().StringVar(&roots.Log, "log-dir", roots.Log, "log directory") + 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") + for key, name := range map[string]string{ + "paths.data": "root-dir", "paths.run": "run-dir", "paths.log": "log-dir", + } { + 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(func() storage.Roots { return roots })) - root.AddCommand(sandboxcmd.NewConsoleCommand(func() storage.Roots { return roots })) - root.AddCommand(sandboxcmd.NewCreateCommand(func() storage.Roots { return roots })) - root.AddCommand(sandboxcmd.NewExecCommand(func() storage.Roots { return roots })) - root.AddCommand(sandboxcmd.NewInspectCommand(func() storage.Roots { return roots })) - root.AddCommand(sandboxcmd.NewListCommand(func() storage.Roots { return roots })) - root.AddCommand(sandboxcmd.NewRemoveCommand(func() storage.Roots { return roots })) - root.AddCommand(sandboxcmd.NewStartCommand(func() storage.Roots { return roots })) - root.AddCommand(sandboxcmd.NewStopCommand(func() storage.Roots { return roots })) + 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.NewRemoveCommand(provideConfig)) + root.AddCommand(sandboxcmd.NewStartCommand(provideConfig)) + root.AddCommand(sandboxcmd.NewStopCommand(provideConfig)) root.AddCommand(newVersionCommand()) classifyArguments(root) - return root + return root, nil } // usageArgs classifies positional validation failures as usage errors. diff --git a/cli/root_test.go b/cli/root_test.go index 199422f..4d54b1f 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -52,6 +52,66 @@ func TestVersion(t *testing.T) { } } +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 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")} diff --git a/cli/sandbox/console.go b/cli/sandbox/console.go index 01b7300..66ca06b 100644 --- a/cli/sandbox/console.go +++ b/cli/sandbox/console.go @@ -20,7 +20,7 @@ import ( const defaultConsoleEscape = "^]" // NewConsoleCommand builds the interactive direct-boot console command. -func NewConsoleCommand(roots rootsProvider) *cobra.Command { +func NewConsoleCommand(configuration configProvider) *cobra.Command { escapeText := defaultConsoleEscape command := &cobra.Command{ Use: "console SANDBOX", @@ -37,7 +37,7 @@ func NewConsoleCommand(roots rootsProvider) *cobra.Command { } reference := args[0] - service, err := core.OpenSandbox(command.Context(), roots(), nil) + service, err := core.OpenSandbox(command.Context(), configuration(), nil) if err != nil { return err } diff --git a/cli/sandbox/create.go b/cli/sandbox/create.go index ea40c53..747f867 100644 --- a/cli/sandbox/create.go +++ b/cli/sandbox/create.go @@ -11,17 +11,17 @@ import ( "github.com/spf13/cobra" + "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/core" "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/types" ) -// rootsProvider reads persistent flags only after Cobra has parsed them. -type rootsProvider func() storage.Roots +// configProvider reads immutable configuration only after Cobra parses flags. +type configProvider func() config.Config // NewCreateCommand builds the top-level create command. -func NewCreateCommand(roots rootsProvider) *cobra.Command { +func NewCreateCommand(configuration configProvider) *cobra.Command { name := "" cpus := types.DefaultSandboxCPUs memory := "1GiB" @@ -40,8 +40,8 @@ func NewCreateCommand(roots rootsProvider) *cobra.Command { if err != nil { return invalidFlag("storage", err) } - config := types.SandboxConfig{Name: name, CPUs: cpus, Memory: memoryBytes, Storage: storageBytes} - if err := config.Validate(); err != nil { + sandboxConfig := types.SandboxConfig{Name: name, CPUs: cpus, Memory: memoryBytes, Storage: storageBytes} + if err := sandboxConfig.Validate(); err != nil { return err } progress, err := startCreateProgress(command, name) @@ -49,7 +49,7 @@ func NewCreateCommand(roots rootsProvider) *cobra.Command { return err } defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }() - service, err := core.OpenSandbox(command.Context(), roots(), progress) + service, err := core.OpenSandbox(command.Context(), configuration(), progress) if err != nil { return err } @@ -58,7 +58,7 @@ func NewCreateCommand(roots rootsProvider) *cobra.Command { closeErr := service.Close() returnErr = errors.Join(returnErr, errdefs.Context(closeErr, "create sandbox", name, "close metadata", "inspect the sandbox before retrying", committed)) }() - record, err := service.Create(command.Context(), core.CreateSandboxRequest{ImageReference: args[0], Config: config}) + record, err := service.Create(command.Context(), core.CreateSandboxRequest{ImageReference: args[0], Config: sandboxConfig}) if err != nil { return err } diff --git a/cli/sandbox/create_test.go b/cli/sandbox/create_test.go index 4e51ecd..949f170 100644 --- a/cli/sandbox/create_test.go +++ b/cli/sandbox/create_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/core" "github.com/kumabox/kumabox/images" sandboxfs "github.com/kumabox/kumabox/sandbox" @@ -91,7 +92,7 @@ func TestCreateCommandPersistsCreatedSandboxAndFinalCOW(t *testing.T) { } seedImage(t, roots) installFakeMKFS(t, base) - command := NewCreateCommand(func() storage.Roots { return roots }) + command := NewCreateCommand(func() config.Config { return sandboxTestConfig(roots) }) command.SetArgs([]string{"demo", "--name", "box", "--cpus", "1", "--json"}) var stdout, stderr bytes.Buffer command.SetOut(&stdout) @@ -131,7 +132,7 @@ func TestCreateCommandPersistsCreatedSandboxAndFinalCOW(t *testing.T) { func seedImage(t *testing.T, roots storage.Roots) { t.Helper() - state, err := core.OpenImages(t.Context(), roots) + state, err := core.OpenImages(t.Context(), sandboxTestConfig(roots)) if err != nil { t.Fatal(err) } @@ -180,6 +181,13 @@ func seedImage(t *testing.T, roots storage.Roots) { } } +// 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) diff --git a/cli/sandbox/exec.go b/cli/sandbox/exec.go index 4756457..6ad72dd 100644 --- a/cli/sandbox/exec.go +++ b/cli/sandbox/exec.go @@ -25,7 +25,7 @@ func (e *commandExitError) ExitCode() int { return e.code } func (e *commandExitError) Silent() bool { return true } // NewExecCommand builds the streaming guest exec command. -func NewExecCommand(roots rootsProvider) *cobra.Command { +func NewExecCommand(configuration configProvider) *cobra.Command { var environment []string var interactive bool command := &cobra.Command{ @@ -37,7 +37,7 @@ func NewExecCommand(roots rootsProvider) *cobra.Command { if err := config.Validate(); err != nil { return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, err) } - service, err := core.OpenSandbox(command.Context(), roots(), nil) + service, err := core.OpenSandbox(command.Context(), configuration(), nil) if err != nil { return err } diff --git a/cli/sandbox/query.go b/cli/sandbox/query.go index cde0f28..43a68c4 100644 --- a/cli/sandbox/query.go +++ b/cli/sandbox/query.go @@ -11,13 +11,13 @@ import ( // NewInspectCommand builds the read-only detailed sandbox query. Inspect always // writes JSON so its complete output remains stable for people and scripts. -func NewInspectCommand(roots rootsProvider) *cobra.Command { +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(), roots(), nil) + service, err := core.OpenSandbox(command.Context(), configuration(), nil) if err != nil { return err } @@ -35,7 +35,7 @@ func NewInspectCommand(roots rootsProvider) *cobra.Command { } // NewListCommand builds the top-level Docker-style sandbox process listing. -func NewListCommand(roots rootsProvider) *cobra.Command { +func NewListCommand(configuration configProvider) *cobra.Command { var includeAll, asJSON, quiet bool command := &cobra.Command{ Use: "ps", @@ -45,7 +45,7 @@ func NewListCommand(roots rootsProvider) *cobra.Command { 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(), roots(), nil) + service, err := core.OpenSandbox(command.Context(), configuration(), nil) if err != nil { return err } diff --git a/cli/sandbox/query_test.go b/cli/sandbox/query_test.go index b78ca58..b94e67f 100644 --- a/cli/sandbox/query_test.go +++ b/cli/sandbox/query_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/types" @@ -127,7 +128,7 @@ func TestListCommandShowsCreatedOnlyWithAllAndTracksRemoval(t *testing.T) { t.Fatalf("ps --all table = %q", output) } - remove := NewRemoveCommand(func() storage.Roots { return roots }) + remove := NewRemoveCommand(func() config.Config { return sandboxTestConfig(roots) }) remove.SetArgs([]string{id.String()}) remove.SetOut(&bytes.Buffer{}) remove.SetErr(&bytes.Buffer{}) @@ -144,7 +145,7 @@ func TestListCommandRejectsJSONWithQuiet(t *testing.T) { roots := storage.Roots{ Data: filepath.Join(base, "data"), Run: filepath.Join(base, "run"), Log: filepath.Join(base, "log"), } - command := NewListCommand(func() storage.Roots { return roots }) + 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") @@ -177,7 +178,7 @@ func TestSandboxListOutputPreservesWriteErrors(t *testing.T) { func executeInspect(t *testing.T, roots storage.Roots, reference string) string { t.Helper() - command := NewInspectCommand(func() storage.Roots { return roots }) + command := NewInspectCommand(func() config.Config { return sandboxTestConfig(roots) }) command.SetArgs([]string{reference}) var output bytes.Buffer command.SetOut(&output) @@ -190,7 +191,7 @@ func executeInspect(t *testing.T, roots storage.Roots, reference string) string func executeList(t *testing.T, roots storage.Roots, args ...string) string { t.Helper() - command := NewListCommand(func() storage.Roots { return roots }) + command := NewListCommand(func() config.Config { return sandboxTestConfig(roots) }) command.SetArgs(args) var output bytes.Buffer command.SetOut(&output) diff --git a/cli/sandbox/remove.go b/cli/sandbox/remove.go index 469bd05..8b2f21b 100644 --- a/cli/sandbox/remove.go +++ b/cli/sandbox/remove.go @@ -10,7 +10,7 @@ import ( ) // NewRemoveCommand builds the top-level sandbox removal command. -func NewRemoveCommand(roots rootsProvider) *cobra.Command { +func NewRemoveCommand(configuration configProvider) *cobra.Command { asJSON := false command := &cobra.Command{ Use: "rm SANDBOX", @@ -23,7 +23,7 @@ func NewRemoveCommand(roots rootsProvider) *cobra.Command { return err } defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }() - service, err := core.OpenSandbox(command.Context(), roots(), progress) + service, err := core.OpenSandbox(command.Context(), configuration(), progress) if err != nil { return err } diff --git a/cli/sandbox/remove_test.go b/cli/sandbox/remove_test.go index d71c1a3..c691261 100644 --- a/cli/sandbox/remove_test.go +++ b/cli/sandbox/remove_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/core" "github.com/kumabox/kumabox/images" sandboxfs "github.com/kumabox/kumabox/sandbox" @@ -24,7 +25,7 @@ func TestRemoveCommandClosesCreateAndImageReferenceLifecycle(t *testing.T) { installFakeMKFS(t, base) firstID := executeCreate(t, roots, "box") - remove := NewRemoveCommand(func() storage.Roots { return roots }) + remove := NewRemoveCommand(func() config.Config { return sandboxTestConfig(roots) }) remove.SetArgs([]string{"box", "--json"}) var stdout, stderr bytes.Buffer remove.SetOut(&stdout) @@ -58,7 +59,7 @@ func TestRemoveCommandClosesCreateAndImageReferenceLifecycle(t *testing.T) { if secondID == firstID { t.Fatal("recreated sandbox reused immutable ID") } - remove = NewRemoveCommand(func() storage.Roots { return roots }) + remove = NewRemoveCommand(func() config.Config { return sandboxTestConfig(roots) }) remove.SetArgs([]string{secondID.String()}) stdout.Reset() stderr.Reset() @@ -71,7 +72,7 @@ func TestRemoveCommandClosesCreateAndImageReferenceLifecycle(t *testing.T) { t.Fatalf("text remove output = %q", stdout.String()) } - state, err := core.OpenImages(t.Context(), roots) + state, err := core.OpenImages(t.Context(), sandboxTestConfig(roots)) if err != nil { t.Fatal(err) } @@ -87,7 +88,7 @@ func TestRemoveCommandClosesCreateAndImageReferenceLifecycle(t *testing.T) { func executeCreate(t *testing.T, roots storage.Roots, name string) types.SandboxID { t.Helper() - command := NewCreateCommand(func() storage.Roots { return roots }) + command := NewCreateCommand(func() config.Config { return sandboxTestConfig(roots) }) command.SetArgs([]string{"demo", "--name", name, "--cpus", "1"}) var stdout, stderr bytes.Buffer command.SetOut(&stdout) diff --git a/cli/sandbox/start.go b/cli/sandbox/start.go index ca544b8..4069cc1 100644 --- a/cli/sandbox/start.go +++ b/cli/sandbox/start.go @@ -10,7 +10,7 @@ import ( ) // NewStartCommand builds the top-level sandbox start command. -func NewStartCommand(roots rootsProvider) *cobra.Command { +func NewStartCommand(configuration configProvider) *cobra.Command { asJSON := false command := &cobra.Command{ Use: "start SANDBOX", @@ -23,7 +23,7 @@ func NewStartCommand(roots rootsProvider) *cobra.Command { return err } defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }() - service, err := core.OpenSandbox(command.Context(), roots(), progress) + service, err := core.OpenSandbox(command.Context(), configuration(), progress) if err != nil { return err } diff --git a/cli/sandbox/stop.go b/cli/sandbox/stop.go index 83cd580..125eb61 100644 --- a/cli/sandbox/stop.go +++ b/cli/sandbox/stop.go @@ -10,7 +10,7 @@ import ( ) // NewStopCommand builds the top-level sandbox stop command. -func NewStopCommand(roots rootsProvider) *cobra.Command { +func NewStopCommand(configuration configProvider) *cobra.Command { asJSON := false command := &cobra.Command{ Use: "stop SANDBOX", @@ -23,7 +23,7 @@ func NewStopCommand(roots rootsProvider) *cobra.Command { return err } defer func() { returnErr = errors.Join(returnErr, progress.Finish(returnErr)) }() - service, err := core.OpenSandbox(command.Context(), roots(), progress) + service, err := core.OpenSandbox(command.Context(), configuration(), progress) if err != nil { return err } diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..f46190f --- /dev/null +++ b/config/config.go @@ -0,0 +1,213 @@ +// 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" + "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"` + // 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"` +} + +// 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}, + 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 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, + "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..5e106e8 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,119 @@ +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") + } +} diff --git a/core/images.go b/core/images.go index 37afdac..971187a 100644 --- a/core/images.go +++ b/core/images.go @@ -3,7 +3,7 @@ // // Image command assembly: // -// storage.Roots --> images.Paths -----------+ +// config.Paths --> images.Paths ------------+ // | | // +--> SQLite --> catalog --> ImageStore // | @@ -12,7 +12,9 @@ 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" @@ -20,7 +22,6 @@ import ( "github.com/kumabox/kumabox/metadata" "github.com/kumabox/kumabox/metadata/sqlite" sandboxcatalog "github.com/kumabox/kumabox/sandbox/catalog" - "github.com/kumabox/kumabox/storage" "github.com/kumabox/kumabox/types" ) @@ -31,26 +32,34 @@ type ImageStore struct { 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, roots storage.Roots) (*ImageStore, error) { - paths, err := images.NewPaths(roots) +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.DefaultOptions()) + 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, store: store}, nil + return &ImageStore{Paths: paths, Catalog: imageCatalog, options: configuration.Images, store: store}, nil } // Close releases the metadata store after all catalog operations have finished. @@ -58,8 +67,11 @@ 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.DefaultOptions() - converter, err := erofs.New(ctx, platform.Architecture, options.Limits) + 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 } @@ -89,10 +101,19 @@ func (s *ImageStore) OpenLocalSource(ctx context.Context, path string, options L return nil, nil, err } return source.OpenLocal(ctx, path, s.Paths.StagingDir(), source.LocalOptions{ - Format: format, SourceTag: options.SourceTag, Limits: images.DefaultLimits(), + 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) diff --git a/core/sandbox.go b/core/sandbox.go index 7997f78..9911808 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -9,6 +9,7 @@ import ( "time" "github.com/kumabox/kumabox/agent" + "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/disk" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" @@ -18,13 +19,10 @@ import ( "github.com/kumabox/kumabox/metadata/sqlite" "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" ) -const cleanupTimeout = 10 * time.Second - // CreateSandboxRequest contains user intent before image aliases are resolved. type CreateSandboxRequest struct { // ImageReference is an existing local image alias or manifest digest. @@ -94,7 +92,11 @@ type SandboxService struct { // imagePaths derives immutable artifacts after the image guard verifies them. imagePaths images.Paths // runtimes route persisted VMM identities to process adapters. - runtimes vmmBackends + 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 // reporter emits progress independently of command results. reporter SandboxReporter // newID and now are replaceable in same-package tests. @@ -105,13 +107,14 @@ type SandboxService struct { } // newSandboxService connects the explicit capabilities needed by sandbox commands. -func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, lifecycle sandboxLifecycle, disks disk.Backend, runtimes vmmBackends, reporter SandboxReporter) *SandboxService { +func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, lifecycle sandboxLifecycle, disks disk.Backend, runtimes *vmm.Registry, defaultVMM types.VMMType, cleanupTimeout time.Duration, reporter SandboxReporter) *SandboxService { if reporter == nil { reporter = discardReporter{} } return &SandboxService{ paths: paths, imagePaths: imagePaths, images: images, creator: creator, reader: reader, - remover: remover, lifecycle: lifecycle, disks: disks, runtimes: runtimes, reporter: reporter, + remover: remover, lifecycle: lifecycle, disks: disks, runtimes: runtimes, + defaultVMM: defaultVMM, cleanupTimeout: cleanupTimeout, reporter: reporter, newID: types.NewSandboxID, now: time.Now, } } @@ -122,31 +125,46 @@ func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imag // shared SQLite -> image catalog <---- transaction reader ---- sandbox catalog // | ^ | // +---- usage ---+---- image guard + ext4 COW ----------> service -func OpenSandbox(ctx context.Context, roots storage.Roots, reporter SandboxReporter) (*SandboxService, error) { - imagePaths, err := images.NewPaths(roots) +func OpenSandbox(ctx context.Context, configuration config.Config, reporter SandboxReporter) (*SandboxService, error) { + if err := configuration.Validate(); 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 } - sandboxPaths, err := sandbox.NewPaths(roots) + 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.DefaultOptions()) + store, err := sqlite.Open(ctx, imagePaths.MetadataDB(), metadataCollections(), sqlite.Options{ + BusyTimeout: configuration.Metadata.BusyTimeout, + RetryLimit: configuration.Metadata.RetryLimit, + }) if err != nil { return nil, err } imageCatalog := imagecatalog.New(store, imagecatalog.WithImageUsage(sandboxcatalog.Usage{})) sandboxCatalog := sandboxcatalog.New(store, imagecatalog.Reader{}) - runtimes, err := openVMMBackends(roots) - if err != nil { - return nil, errors.Join(err, store.Close()) - } service := newSandboxService( sandboxPaths, imagePaths, images.NewGuard(imagePaths, imageCatalog), sandboxCatalog, - sandboxCatalog, sandboxCatalog, sandboxCatalog, disk.NewExt4(sandboxPaths), runtimes, reporter, + sandboxCatalog, sandboxCatalog, sandboxCatalog, disks, runtimes, defaultVMM, + configuration.Sandbox.CleanupTimeout, reporter, ) service.store = store return service, nil @@ -167,7 +185,7 @@ func (s *SandboxService) Close() error { // | | // +---- failure cleanup <---+ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxRequest) (result types.Sandbox, returnErr error) { - if s == nil || s.images == nil || s.creator == nil || s.disks == nil || len(s.runtimes) == 0 || s.reporter == nil || s.newID == nil || s.now == nil { + if s == nil || s.images == nil || s.creator == nil || s.disks == nil || s.runtimes.Len() == 0 || s.reporter == nil || s.newID == nil || s.now == nil || s.cleanupTimeout <= 0 { return types.Sandbox{}, errors.New("sandbox service is not configured") } if request.ImageReference == "" { @@ -177,9 +195,9 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques return types.Sandbox{}, err } if request.VMM == "" { - request.VMM = types.VMMCloudHypervisor + request.VMM = s.defaultVMM } - if _, err := s.runtimes.backend(request.VMM); err != nil { + if _, err := s.runtimes.Backend(request.VMM); err != nil { return types.Sandbox{}, err } if int(request.Config.CPUs) > runtime.NumCPU() { //nolint:gosec // Config validation bounds CPUs to a small positive value @@ -294,7 +312,7 @@ func (s *SandboxService) Inspect(ctx context.Context, reference string) (types.S // | // abort + retained Error func (s *SandboxService) Start(ctx context.Context, reference string) (result types.Sandbox, returnErr error) { - if s == nil || s.reader == nil || s.lifecycle == nil || s.images == nil || s.disks == nil || len(s.runtimes) == 0 || s.reporter == nil || s.now == nil { + if s == nil || s.reader == nil || s.lifecycle == nil || s.images == nil || s.disks == nil || s.runtimes.Len() == 0 || s.reporter == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if reference == "" { @@ -330,7 +348,7 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty if err != nil { return types.Sandbox{}, err } - backend, err := s.runtimes.backend(record.VMM) + backend, err := s.runtimes.Backend(record.VMM) if err != nil { return record, err } @@ -526,7 +544,7 @@ func (s *SandboxService) launchPlan(record types.Sandbox, image types.Image) (vm // 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), cleanupTimeout) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.cleanupTimeout) defer cancel() var cleanupErr error if process.PID > 0 { @@ -547,7 +565,7 @@ func (s *SandboxService) failStart(ctx context.Context, backend vmm.Backend, sta // 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.reader == nil || s.lifecycle == nil || len(s.runtimes) == 0 || s.reporter == nil || s.now == nil { + if s == nil || s.reader == nil || s.lifecycle == nil || s.runtimes.Len() == 0 || s.reporter == nil || s.now == nil { return types.Sandbox{}, errors.New("sandbox service is not configured") } if reference == "" { @@ -583,7 +601,7 @@ func (s *SandboxService) Stop(ctx context.Context, reference string) (result typ if err != nil { return types.Sandbox{}, err } - backend, err := s.runtimes.backend(record.VMM) + backend, err := s.runtimes.Backend(record.VMM) if err != nil { return record, err } @@ -735,7 +753,7 @@ func (s *SandboxService) Exec(ctx context.Context, reference string, config type // 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.reader == nil || len(s.runtimes) == 0 { + if s == nil || s.reader == nil || s.runtimes.Len() == 0 { return nil, vmm.Process{}, errors.New("sandbox service is not configured") } if reference == "" { @@ -771,7 +789,7 @@ func (s *SandboxService) locateRunning(ctx context.Context, reference, operation 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.runtimes.backend(record.VMM) + backend, err = s.runtimes.Backend(record.VMM) if err != nil { return nil, vmm.Process{}, err } @@ -853,7 +871,7 @@ func (s *SandboxService) Remove(ctx context.Context, reference string) (result t // compensate removes the owned disk 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), cleanupTimeout) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.cleanupTimeout) defer cancel() removeErr := s.disks.Remove(cleanupCtx, record.ID) if removeErr == nil { diff --git a/core/sandbox_test.go b/core/sandbox_test.go index 0a5bef0..3b2008d 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/kumabox/kumabox/agent" + "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" "github.com/kumabox/kumabox/sandbox" @@ -322,12 +323,45 @@ func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]s 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}} - service := newSandboxService(paths, imagePaths, fakeGuard{image: image, steps: &steps}, catalog, catalog, catalog, catalog, fakeDisk{steps: &steps, prepare: diskError}, vmmBackends{runtimeAdapter.Type(): runtimeAdapter}, fakeReporter{steps: &steps}) + runtimes, err := vmm.NewRegistry(runtimeAdapter) + if err != nil { + t.Fatal(err) + } + service := newSandboxService( + paths, imagePaths, fakeGuard{image: image, steps: &steps}, catalog, catalog, + catalog, catalog, fakeDisk{steps: &steps, prepare: diskError}, runtimes, + types.VMMCloudHypervisor, 10*time.Second, fakeReporter{steps: &steps}, + ) service.newID = func() (types.SandboxID, error) { return fixedID, nil } service.now = func() time.Time { return time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) } return service, &steps } +func testRuntime(t *testing.T, service *SandboxService) *fakeRuntime { + t.Helper() + backend, err := service.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 TestCreateCommitsCreatedAfterDiskPreparation(t *testing.T) { service, steps := newTestSandboxService(t, nil) record, err := service.Create(t.Context(), CreateSandboxRequest{ @@ -351,7 +385,11 @@ func TestCreateCommitsCreatedAfterDiskPreparation(t *testing.T) { func TestSandboxLifecycleRoutesToPersistedVMM(t *testing.T) { service, steps := newTestSandboxService(t, nil) firecracker := &fakeRuntime{typ: types.VMMFirecracker, steps: steps, observation: vmm.Observation{State: vmm.ProcessAbsent}} - service.runtimes[types.VMMFirecracker] = firecracker + runtimes, err := vmm.NewRegistry(testRuntime(t, service), firecracker) + if err != nil { + t.Fatal(err) + } + service.runtimes = runtimes record, err := service.Create(t.Context(), CreateSandboxRequest{ ImageReference: "demo", Config: types.SandboxConfig{Name: "box", CPUs: 1, Memory: types.DefaultSandboxMemory, Storage: types.DefaultSandboxStorage}, @@ -508,7 +546,7 @@ func TestStartCommitsRunningOnlyAfterLaunchReadiness(t *testing.T) { if record.State != types.SandboxStateRunning || record.Generation != 4 { t.Fatalf("running record = %+v", record) } - runtimeAdapter := service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime) + 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) } @@ -532,7 +570,7 @@ func TestStartRecoversRunningProcessFromStartingState(t *testing.T) { } catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateStarting, 3 - runtimeAdapter := service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime) + 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") @@ -555,7 +593,7 @@ func TestStartFailureAbortsProcessAndRetainsError(t *testing.T) { t.Fatal(err) } failure := errors.New("VMM exited") - service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime).launchErr = failure + testRuntime(t, service).launchErr = failure *steps = nil if _, err := service.Start(t.Context(), "box"); !errors.Is(err, failure) { t.Fatalf("Start error = %v", err) @@ -585,7 +623,7 @@ func TestStartRetryDoesNotLeaveStartingAfterPreflightFailure(t *testing.T) { catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateStarting, 3 failure := errors.New("KVM unavailable") - service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime).preflightErr = failure + testRuntime(t, service).preflightErr = failure *steps = nil if _, err := service.Start(t.Context(), "box"); !errors.Is(err, failure) { t.Fatalf("Start error = %v", err) @@ -607,7 +645,7 @@ func TestStopRecordsIntentBeforeTerminatingRunningVMM(t *testing.T) { } catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4 - service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime).observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}} + 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 { @@ -646,7 +684,7 @@ func TestStopResumesStoppingAndRecoversStarting(t *testing.T) { } catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = test.state, test.generation - service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime).observation = vmm.Observation{State: vmm.ProcessStarting, Process: vmm.Process{PID: 42}} + 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 { @@ -694,7 +732,7 @@ func TestStopFailureRetainsRetryableStoppingState(t *testing.T) { catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4 failure := errors.New("signal failed") - runtimeAdapter := service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime) + runtimeAdapter := testRuntime(t, service) runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42}} runtimeAdapter.stopErr = failure *steps = nil @@ -739,7 +777,7 @@ func TestConsoleOpensExactRunningGenerationWithoutHoldingOperationLock(t *testin } catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4 - runtimeAdapter := service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime) + runtimeAdapter := testRuntime(t, service) runtimeAdapter.observation = vmm.Observation{State: vmm.ProcessRunning, Process: vmm.Process{PID: 42, Generation: 3}} *steps = nil @@ -786,7 +824,7 @@ func TestExecUsesExactRunningGenerationAndStreamsResult(t *testing.T) { } catalog := service.lifecycle.(*fakeCatalog) catalog.record.State, catalog.record.Generation = types.SandboxStateRunning, 4 - runtimeAdapter := service.runtimes[types.VMMCloudHypervisor].(*fakeRuntime) + 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 diff --git a/core/vmm.go b/core/vmm.go index 2249c2d..d89b619 100644 --- a/core/vmm.go +++ b/core/vmm.go @@ -1,76 +1,38 @@ package core import ( - "errors" "fmt" "github.com/kumabox/kumabox/cgroup" - "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/storage" - "github.com/kumabox/kumabox/types" + "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/vmm" "github.com/kumabox/kumabox/vmm/cloudhypervisor" ) -// vmmFactory assembles one concrete backend from application storage roots. -type vmmFactory func(storage.Roots) (vmm.Backend, error) - -// vmmFactories is the single registration point for supported VMM adapters. -// Adding Firecracker means registering its constructor here; sandbox workflows -// continue to route through the persisted types.VMMType. -var vmmFactories = map[types.VMMType]vmmFactory{ - types.VMMCloudHypervisor: newCloudHypervisor, -} - -// vmmBackends routes a persisted backend identity to its process adapter. -type vmmBackends map[types.VMMType]vmm.Backend - -// openVMMBackends constructs every registered adapter so commands can operate -// on sandboxes created by different VMMs in the same metadata catalog. -func openVMMBackends(roots storage.Roots) (vmmBackends, error) { - backends := make(vmmBackends, len(vmmFactories)) - for typ, factory := range vmmFactories { - backend, err := factory(roots) - if err != nil { - return nil, fmt.Errorf("initialize VMM %s: %w", typ, err) - } - if backend == nil || backend.Type() != typ { - return nil, fmt.Errorf("VMM factory %s returned a mismatched backend", typ) - } - backends[typ] = backend - } - return backends, nil -} - -// backend returns the adapter that owns record. Unknown but syntactically -// valid identities are unsupported locally rather than treated as corruption. -func (b vmmBackends) backend(typ types.VMMType) (vmm.Backend, error) { - if err := typ.Validate(); err != nil { - return nil, errdefs.New(errdefs.ClassCorrupt, errdefs.CodeArtifactCorrupt, err) - } - backend, exists := b[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 -} - -// newCloudHypervisor owns the Cloud Hypervisor-specific dependency graph. -func newCloudHypervisor(roots storage.Roots) (vmm.Backend, error) { - paths, err := vmm.NewPaths(roots) +// 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, err + return nil, fmt.Errorf("initialize VMM paths: %w", err) } - scopes, err := cgroup.New("") + scopes, err := cgroup.New(configuration.VMM.CgroupParent) if err != nil { - return nil, err + return nil, fmt.Errorf("initialize VMM cgroups: %w", err) } - backend, err := cloudhypervisor.New(paths, scopes, cloudhypervisor.Options{}) + 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, err + return nil, fmt.Errorf("initialize VMM cloud-hypervisor: %w", err) } - if backend == nil { - return nil, errors.New("cloud-hypervisor constructor returned nil") + registry, err := vmm.NewRegistry(cloudHypervisor) + if err != nil { + return nil, fmt.Errorf("initialize VMM registry: %w", err) } - return backend, nil + return registry, nil } diff --git a/disk/disk.go b/disk/disk.go index bc021b8..337fc19 100644 --- a/disk/disk.go +++ b/disk/disk.go @@ -45,9 +45,12 @@ type Ext4 struct { var _ Backend = (*Ext4)(nil) -// NewExt4 creates the production disk preparer using mkfs.ext4 from PATH. -func NewExt4(paths sandbox.Paths) *Ext4 { - return &Ext4{paths: paths, mkfs: "mkfs.ext4"} +// 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 diff --git a/disk/disk_test.go b/disk/disk_test.go index b03c44a..ad83350 100644 --- a/disk/disk_test.go +++ b/disk/disk_test.go @@ -31,7 +31,13 @@ func TestExt4PreparesFinalSparsePathAndRemovesIt(t *testing.T) { t.Fatal(err) } id := types.SandboxID("123e4567-e89b-42d3-a456-426614174000") - preparer := &Ext4{paths: paths, mkfs: formatter} + 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) } @@ -56,3 +62,9 @@ func TestExt4PreparesFinalSparsePathAndRemovesIt(t *testing.T) { 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/go.mod b/go.mod index ba0d635..0405991 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,9 @@ require ( github.com/mdlayher/vsock v1.2.1 github.com/moby/term v0.5.2 github.com/spf13/cobra v1.10.2 - golang.org/x/sync v0.15.0 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 + golang.org/x/sync v0.16.0 modernc.org/sqlite v1.38.2 ) @@ -21,6 +23,8 @@ require ( github.com/docker/distribution v2.8.3+incompatible // 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/mdlayher/socket v0.4.1 // indirect @@ -28,14 +32,21 @@ require ( 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.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.9 // 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.9.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.11.0 // indirect diff --git a/go.sum b/go.sum index b9af8f8..e4426a6 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,12 @@ github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqI 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/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-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= @@ -30,6 +36,10 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= @@ -46,42 +56,64 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 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/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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 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.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= +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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +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.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +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.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= 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= diff --git a/images/erofs/convert.go b/images/erofs/convert.go index 403d221..4b58a9a 100644 --- a/images/erofs/convert.go +++ b/images/erofs/convert.go @@ -33,6 +33,8 @@ const ( // 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. @@ -41,20 +43,28 @@ type Converter struct { 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, limits images.Limits) (*Converter, error) { - if !limits.Valid() || (architecture != "amd64" && architecture != "arm64") { +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, "mkfs.erofs", "--version").CombinedOutput() + 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{architecture: architecture, limits: limits}, nil + return &Converter{binary: options.Binary, architecture: architecture, limits: options.Limits}, nil } // Convert streams a decompressed tar to mkfs.erofs while extracting boot files @@ -75,7 +85,7 @@ func (c *Converter) Convert(ctx context.Context, descriptor types.Descriptor, so 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, - "mkfs.erofs", + c.binary, "--tar=f", "-zlz4hc", fmt.Sprintf("-C%d", erofsBlockSize), diff --git a/images/erofs/convert_test.go b/images/erofs/convert_test.go index ad5a3ff..6f38090 100644 --- a/images/erofs/convert_test.go +++ b/images/erofs/convert_test.go @@ -7,8 +7,25 @@ import ( "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 diff --git a/vmm/backend.go b/vmm/backend.go index 55eab3a..d67a3a6 100644 --- a/vmm/backend.go +++ b/vmm/backend.go @@ -2,8 +2,12 @@ package vmm import ( "context" + "errors" + "fmt" "io" + "reflect" + "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/types" ) @@ -23,3 +27,65 @@ type Backend interface { DialVsock(context.Context, Process, uint32) (io.ReadWriteCloser, error) Cleanup(context.Context, types.SandboxID) 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..cff7df1 --- /dev/null +++ b/vmm/backend_test.go @@ -0,0 +1,77 @@ +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) Cleanup(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") + } +} + +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/driver.go b/vmm/cloudhypervisor/driver.go index 03462b1..99737a6 100644 --- a/vmm/cloudhypervisor/driver.go +++ b/vmm/cloudhypervisor/driver.go @@ -30,8 +30,6 @@ const ( defaultStartupTimeout = 10 * time.Second probeInterval = 50 * time.Millisecond probeTimeout = 500 * time.Millisecond - abortGrace = 3 * time.Second - stopGrace = 5 * time.Second maxAPIResponse = 1 << 20 ) @@ -48,6 +46,10 @@ type Options struct { 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. @@ -60,6 +62,10 @@ type Driver struct { 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) @@ -75,10 +81,19 @@ func New(paths vmm.Paths, scopes *cgroup.Manager, options Options) (*Driver, err if options.StartupTimeout == 0 { options.StartupTimeout = defaultStartupTimeout } - if options.StartupTimeout < probeInterval { - return nil, errors.New("cloud hypervisor startup timeout is too short") + if options.StopGrace == 0 { + options.StopGrace = 5 * time.Second } - return &Driver{paths: paths, scopes: scopes, binary: options.Binary, startupTimeout: options.StartupTimeout}, nil + 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. @@ -123,7 +138,7 @@ func (d *Driver) Launch(ctx context.Context, plan vmm.LaunchPlan) (result vmm.Pr if returnErr == nil { return } - cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), abortGrace+time.Second) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), d.abortGrace+time.Second) defer cancel() switch { case result.PID > 0: @@ -264,7 +279,7 @@ func (d *Driver) Abort(ctx context.Context, process vmm.Process) error { if err := process.Validate(); err != nil { return err } - if err := terminateProcess(ctx, process, abortGrace); err != nil { + if err := terminateProcess(ctx, process, d.abortGrace); err != nil { return err } return d.Cleanup(ctx, process.SandboxID) @@ -284,7 +299,7 @@ func (d *Driver) Stop(ctx context.Context, process vmm.Process) error { return nil } _ = d.requestShutdown(ctx, process.APISocket) - return terminateProcess(ctx, process, stopGrace) + return terminateProcess(ctx, process, d.stopGrace) } // Console opens the direct-boot PTY reported by the exact live VMM. The caller diff --git a/vmm/cloudhypervisor/driver_test.go b/vmm/cloudhypervisor/driver_test.go new file mode 100644 index 0000000..46de01a --- /dev/null +++ b/vmm/cloudhypervisor/driver_test.go @@ -0,0 +1,47 @@ +package cloudhypervisor + +import ( + "path/filepath" + "testing" + "time" + + "github.com/kumabox/kumabox/cgroup" + "github.com/kumabox/kumabox/storage" + "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") + } +} From a31d5919b0c6a91ade1dca1bb657c6741a0c4d89 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Sat, 19 Sep 2026 00:19:22 +0800 Subject: [PATCH 30/45] refactor: organize sandbox service workflows --- core/sandbox.go | 778 +++-------------------------------- core/sandbox_runtime.go | 515 +++++++++++++++++++++++ core/sandbox_runtime_test.go | 371 +++++++++++++++++ core/sandbox_storage.go | 185 +++++++++ core/sandbox_storage_test.go | 193 +++++++++ core/sandbox_test.go | 586 ++------------------------ 6 files changed, 1361 insertions(+), 1267 deletions(-) create mode 100644 core/sandbox_runtime.go create mode 100644 core/sandbox_runtime_test.go create mode 100644 core/sandbox_storage.go create mode 100644 core/sandbox_storage_test.go diff --git a/core/sandbox.go b/core/sandbox.go index 9911808..4a14608 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -3,18 +3,13 @@ package core import ( "context" "errors" - "fmt" - "io" - "runtime" "time" - "github.com/kumabox/kumabox/agent" "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" - filelock "github.com/kumabox/kumabox/lock/flock" "github.com/kumabox/kumabox/metadata" "github.com/kumabox/kumabox/metadata/sqlite" "github.com/kumabox/kumabox/sandbox" @@ -29,7 +24,7 @@ type CreateSandboxRequest struct { ImageReference string // Config contains the immutable name and guest resource shape. Config types.SandboxConfig - // VMM selects the runtime backend; empty uses Cloud Hypervisor. + // VMM selects the runtime backend; empty uses the configured default. VMM types.VMMType } @@ -38,28 +33,18 @@ type imageGuard interface { WithAvailable(context.Context, string, func(types.Image) error) (types.Image, error) } -// sandboxCreator is the metadata capability consumed by sandbox creation. -type sandboxCreator interface { +// 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, 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 -} - -// sandboxReader is the metadata capability consumed by sandbox queries and lookup. -type sandboxReader interface { Resolve(context.Context, string) (types.Sandbox, error) List(context.Context) ([]types.Sandbox, error) -} - -// sandboxRemover is the metadata capability consumed by sandbox removal. -type sandboxRemover interface { BeginDelete(context.Context, types.SandboxID, uint64, time.Time) (types.Sandbox, error) FinalizeDelete(context.Context, types.SandboxID, uint64) error -} - -// sandboxLifecycle is the generation-fenced metadata capability used by start and stop. -type sandboxLifecycle interface { 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) @@ -67,26 +52,26 @@ type sandboxLifecycle interface { 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 } -// SandboxService owns application ordering and resources for sandbox commands. -type SandboxService struct { +// 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 - // creator commits identity, image references, and create transitions. - creator sandboxCreator - // reader supplies consistent sandbox snapshots without changing state. - reader sandboxReader - // remover commits generation-fenced delete transitions. - remover sandboxRemover - // lifecycle commits generation-fenced start, stop, and recovery transitions. - lifecycle sandboxLifecycle + // catalog owns sandbox records, names, image pins, and state transitions. + catalog sandboxCatalog // disks prepares and cleans sandbox-owned writable disks. disks disk.Backend // imagePaths derives immutable artifacts after the image guard verifies them. @@ -106,17 +91,33 @@ type SandboxService struct { store metadata.Store } -// newSandboxService connects the explicit capabilities needed by sandbox commands. -func newSandboxService(paths sandbox.Paths, imagePaths images.Paths, images imageGuard, creator sandboxCreator, reader sandboxReader, remover sandboxRemover, lifecycle sandboxLifecycle, disks disk.Backend, runtimes *vmm.Registry, defaultVMM types.VMMType, cleanupTimeout time.Duration, reporter SandboxReporter) *SandboxService { - if reporter == nil { - reporter = discardReporter{} +// 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.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 dependencies.reporter == nil { + dependencies.reporter = discardReporter{} } - return &SandboxService{ - paths: paths, imagePaths: imagePaths, images: images, creator: creator, reader: reader, - remover: remover, lifecycle: lifecycle, disks: disks, runtimes: runtimes, - defaultVMM: defaultVMM, cleanupTimeout: cleanupTimeout, reporter: reporter, - newID: types.NewSandboxID, now: time.Now, + 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 @@ -161,120 +162,32 @@ func OpenSandbox(ctx context.Context, configuration config.Config, reporter Sand } imageCatalog := imagecatalog.New(store, imagecatalog.WithImageUsage(sandboxcatalog.Usage{})) sandboxCatalog := sandboxcatalog.New(store, imagecatalog.Reader{}) - service := newSandboxService( - sandboxPaths, imagePaths, images.NewGuard(imagePaths, imageCatalog), sandboxCatalog, - sandboxCatalog, sandboxCatalog, sandboxCatalog, disks, runtimes, defaultVMM, - configuration.Sandbox.CleanupTimeout, reporter, - ) - service.store = store + service, err := newSandboxService(sandboxDependencies{ + paths: sandboxPaths, imagePaths: imagePaths, images: images.NewGuard(imagePaths, imageCatalog), + catalog: sandboxCatalog, disks: disks, runtimes: runtimes, reporter: reporter, + store: store, defaultVMM: defaultVMM, cleanupTimeout: configuration.Sandbox.CleanupTimeout, + }) + 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.store == nil { + if s == nil || s.dependencies.store == nil { return nil } - return s.store.Close() -} - -// Create reserves identity and image usage before preparing the private disk. -// Only the final generation-fenced transition makes the disk startable. -// -// validate -> ID lock -> image locks + reservation -> sparse ext4 COW -> Created -// | | -// +---- failure cleanup <---+ -func (s *SandboxService) Create(ctx context.Context, request CreateSandboxRequest) (result types.Sandbox, returnErr error) { - if s == nil || s.images == nil || s.creator == nil || s.disks == nil || s.runtimes.Len() == 0 || s.reporter == nil || s.newID == nil || s.now == nil || s.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.defaultVMM - } - if _, err := s.runtimes.Backend(request.VMM); err != nil { - return types.Sandbox{}, err - } - 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.reporter.Status("resolving and checking image"); err != nil { - return types.Sandbox{}, err - } - id, err := s.newID() - if err != nil { - return types.Sandbox{}, err - } - lockPath, err := s.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.now().UTC() - record := types.Sandbox{} - reserved := false - _, err = s.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.creator.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) - } - if err := s.reporter.Status("creating sparse ext4 disk"); err != nil { - return types.Sandbox{}, s.compensate(ctx, record, "report", err) - } - if err := s.disks.Prepare(ctx, id, request.Config.Storage); err != nil { - return types.Sandbox{}, s.compensate(ctx, record, "disk", err) - } - if err := s.reporter.Status("committing created state"); err != nil { - return types.Sandbox{}, s.compensate(ctx, record, "report", err) - } - created, err := s.creator.MarkCreated(ctx, id, record.Generation, s.now().UTC()) - if err != nil { - return types.Sandbox{}, s.compensate(ctx, record, "commit", err) - } - result = created - if err := s.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 + return s.dependencies.store.Close() } // 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.reader == nil { + if s == nil || s.dependencies.catalog == nil { return nil, errors.New("sandbox service is not configured") } - records, err := s.reader.List(ctx) + records, err := s.dependencies.catalog.List(ctx) if err != nil { return nil, err } @@ -294,596 +207,13 @@ func (s *SandboxService) List(ctx context.Context, includeAll bool) ([]types.San // 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.reader == 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.reader.Resolve(ctx, reference) -} - -// 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.reader == nil || s.lifecycle == nil || s.images == nil || s.disks == nil || s.runtimes.Len() == 0 || s.reporter == nil || s.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.reporter.Status("resolving sandbox"); err != nil { - return types.Sandbox{}, err - } - record, err := s.reader.Resolve(ctx, reference) - if err != nil { - return types.Sandbox{}, err - } - lockPath, err := s.paths.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 - } - 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.reader.Resolve(ctx, record.ID.String()) - if err != nil { - return types.Sandbox{}, err - } - backend, err := s.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.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.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.reporter.Status("verifying image and sandbox disk"); err != nil { - return record, failBeforeLaunch("report", err) - } - var plan vmm.LaunchPlan - _, err = s.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.disks.Check(ctx, record.ID, record.Config.Storage) - }) - if err != nil { - return record, failBeforeLaunch("validate artifacts", err) - } - - if err := s.reporter.Status("committing starting state"); err != nil { - return record, failBeforeLaunch("report", err) - } - starting, err := s.lifecycle.BeginStart(ctx, record.ID, record.Generation, s.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.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.reporter.Status("committing running state"); err != nil { - return starting, s.failStart(ctx, backend, starting, "report", err, process) - } - running, err := s.lifecycle.MarkRunning(ctx, starting.ID, starting.Generation, s.now().UTC()) - if err != nil { - return starting, s.failStart(ctx, backend, starting, "commit running", err, process) - } - result = running - if err := s.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.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 - } - stopped, err := s.lifecycle.MarkStopped(ctx, record.ID, record.Generation, types.SandboxStateRunning, s.now().UTC()) - return stopped, false, err - } - case types.SandboxStateStarting: - switch observation.State { - case vmm.ProcessRunning: - running, err := s.lifecycle.MarkRunning(ctx, record.ID, record.Generation, s.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.lifecycle.MarkRunning(ctx, record.ID, record.Generation, s.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.imagePaths.BootFile(image.Boot.KernelLayer, image.Boot.KernelFile) - if err != nil { - return vmm.LaunchPlan{}, err - } - initrd, err := s.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}) - 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.imagePaths.EROFS(layer.SourceDigest), Serial: fmt.Sprintf("%s%d", vmm.LayerSerialPrefix, position), ReadOnly: true}) - } - cow, err := s.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, - }, nil -} - -// 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.cleanupTimeout) - defer cancel() - var cleanupErr error - if process.PID > 0 { - cleanupErr = backend.Abort(cleanupCtx, process) - } else { - cleanupErr = backend.Cleanup(cleanupCtx, starting.ID) - } - failureCause := errors.Join(cause, cleanupErr) - failure := types.SandboxFailure{Phase: phase, Message: failureCause.Error()} - _, markErr := s.lifecycle.MarkStartError(cleanupCtx, starting.ID, starting.Generation, failure, s.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 -> 5s -> 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.reader == nil || s.lifecycle == nil || s.runtimes.Len() == 0 || s.reporter == nil || s.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.reporter.Status("resolving sandbox"); err != nil { - return types.Sandbox{}, err - } - record, err := s.reader.Resolve(ctx, reference) - if err != nil { - return types.Sandbox{}, err - } - lockPath, err := s.paths.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 - } - 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.reader.Resolve(ctx, record.ID.String()) - if err != nil { - return types.Sandbox{}, err - } - backend, err := s.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.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.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.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.reporter.Status("committing stopping state"); err != nil { - return record, err - } - record, err = s.lifecycle.BeginStop(ctx, record.ID, record.Generation, s.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.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.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) - } - - // 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.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.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.lifecycle.MarkStopped(ctx, record.ID, record.Generation, record.State, s.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.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 -} - -// 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, config types.ExecConfig, stdin io.Reader, stdout, stderr io.Writer) (int, error) { - if err := config.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 - if !config.Interactive { - stdin = nil - } - exitCode, err := agent.Run(ctx, connection, config.Args, config.Environment(), 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.reader == nil || s.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.reader.Resolve(ctx, reference) - if err != nil { - return nil, vmm.Process{}, err - } - lockPath, err := s.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.reader.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.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 -} - -// Remove records cleanup intent before deleting the COW directory and releases -// the name and image reference only after filesystem cleanup succeeds. -// -// resolve -> sandbox lock -> Deleting -> remove files -> forget record + name -// | | -// +---- retry resumes here <---+ -func (s *SandboxService) Remove(ctx context.Context, reference string) (result types.Sandbox, returnErr error) { - if s == nil || s.reader == nil || s.remover == nil || s.disks == nil || s.reporter == nil || s.now == nil { + 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")) } - if err := s.reporter.Status("resolving sandbox"); err != nil { - return types.Sandbox{}, err - } - record, err := s.reader.Resolve(ctx, reference) - if err != nil { - return types.Sandbox{}, err - } - lockPath, err := s.paths.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 - } - 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) - } - }() - if err := s.reporter.Status("marking sandbox for deletion"); err != nil { - return types.Sandbox{}, err - } - deleting, err := s.remover.BeginDelete(ctx, record.ID, record.Generation, s.now().UTC()) - if err != nil { - return types.Sandbox{}, err - } - committed = true - result = deleting - if err := s.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.disks.Remove(ctx, deleting.ID); err != nil { - return deleting, errdefs.Context(err, "remove sandbox", reference, "disk cleanup", "retry removal to finish cleanup", true) - } - if err := s.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.remover.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.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 the owned disk 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.cleanupTimeout) - defer cancel() - removeErr := s.disks.Remove(cleanupCtx, record.ID) - if removeErr == nil { - forgetErr := s.creator.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) - } - removeErr = forgetErr - } - failure := types.SandboxFailure{Phase: phase, Message: errors.Join(cause, removeErr).Error()} - _, markErr := s.creator.MarkError(cleanupCtx, record.ID, record.Generation, failure, s.now().UTC()) - return errdefs.Context(errors.Join(cause, removeErr, markErr), "create sandbox", record.Config.Name, phase, "inspect or remove the retained error sandbox", false) + return s.dependencies.catalog.Resolve(ctx, reference) } // discardReporter keeps reporting optional for non-CLI consumers. diff --git a/core/sandbox_runtime.go b/core/sandbox_runtime.go new file mode 100644 index 0000000..959eb0b --- /dev/null +++ b/core/sandbox_runtime.go @@ -0,0 +1,515 @@ +package core + +import ( + "context" + "errors" + "fmt" + "io" + "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.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.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 + } + 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}) + 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, + }, nil +} + +// 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) + } + 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.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.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) + } + + // 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 +} + +// 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, config types.ExecConfig, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + if err := config.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 + if !config.Interactive { + stdin = nil + } + exitCode, err := agent.Run(ctx, connection, config.Args, config.Environment(), 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..66285bb --- /dev/null +++ b/core/sandbox_runtime_test.go @@ -0,0 +1,371 @@ +package core + +import ( + "bytes" + "errors" + "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 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 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 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 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.ExecConfig{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..fd6eec6 --- /dev/null +++ b/core/sandbox_storage.go @@ -0,0 +1,185 @@ +package core + +import ( + "context" + "errors" + "fmt" + "runtime" + + "github.com/kumabox/kumabox/errdefs" + filelock "github.com/kumabox/kumabox/lock/flock" + "github.com/kumabox/kumabox/types" +) + +// Create reserves identity and image usage before preparing the private disk. +// Only the final generation-fenced transition makes the disk startable. +// +// validate -> ID lock -> image locks + reservation -> sparse ext4 COW -> Created +// | | +// +---- 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.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 + } + 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) + } + 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, 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 the COW directory and releases +// the name and image reference only after filesystem cleanup succeeds. +// +// resolve -> sandbox lock -> Deleting -> remove files -> forget record + name +// | | +// +---- retry resumes here <---+ +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.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) + } + }() + 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 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 the owned disk 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() + removeErr := s.dependencies.disks.Remove(cleanupCtx, record.ID) + if removeErr == 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) + } + removeErr = forgetErr + } + failure := types.SandboxFailure{Phase: phase, Message: errors.Join(cause, removeErr).Error()} + _, markErr := s.dependencies.catalog.MarkError(cleanupCtx, record.ID, record.Generation, failure, s.dependencies.now().UTC()) + return errdefs.Context(errors.Join(cause, removeErr, 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..2192a93 --- /dev/null +++ b/core/sandbox_storage_test.go @@ -0,0 +1,193 @@ +package core + +import ( + "errors" + "reflect" + "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 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", + "status:marking sandbox for deletion", "deleting", "status:removing sandbox disk", "remove", + "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", + "status:marking sandbox for deletion", "deleting", "status:removing sandbox disk", "remove", + "status:releasing metadata and image reference", "finalize", "report", + }) { + t.Fatalf("retry steps = %v", got) + } +} + +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 index 3b2008d..ded36c8 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -1,19 +1,15 @@ package core import ( - "bytes" "context" "errors" "io" - "net" "path/filepath" - "reflect" "runtime" "strings" "testing" "time" - "github.com/kumabox/kumabox/agent" "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/images" @@ -327,19 +323,23 @@ func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]s if err != nil { t.Fatal(err) } - service := newSandboxService( - paths, imagePaths, fakeGuard{image: image, steps: &steps}, catalog, catalog, - catalog, catalog, fakeDisk{steps: &steps, prepare: diskError}, runtimes, - types.VMMCloudHypervisor, 10*time.Second, fakeReporter{steps: &steps}, - ) - service.newID = func() (types.SandboxID, error) { return fixedID, nil } - service.now = func() time.Time { return time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) } + service, err := newSandboxService(sandboxDependencies{ + paths: paths, imagePaths: imagePaths, images: fakeGuard{image: image, steps: &steps}, + catalog: catalog, disks: fakeDisk{steps: &steps, prepare: diskError}, runtimes: runtimes, + defaultVMM: types.VMMCloudHypervisor, cleanupTimeout: 10 * time.Second, + 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 testRuntime(t *testing.T, service *SandboxService) *fakeRuntime { t.Helper() - backend, err := service.runtimes.Backend(types.VMMCloudHypervisor) + backend, err := service.dependencies.runtimes.Backend(types.VMMCloudHypervisor) if err != nil { t.Fatal(err) } @@ -362,125 +362,42 @@ func TestOpenVMMRegistryUsesConfiguredCgroupParent(t *testing.T) { } } -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 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: "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 }}, + } { + 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 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.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, - }) +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 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 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.images.(fakeGuard) - guard.afterUse = failure - service.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.disks.(fakeDisk) - disks.remove = removeFailure - service.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.creator.(*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) + if configured.dependencies.reporter == nil || configured.dependencies.newID == nil || configured.dependencies.now == nil { + t.Fatal("newSandboxService() left process-local defaults unconfigured") } } @@ -502,7 +419,7 @@ func TestListFiltersInactiveSandboxesUnlessAllRequested(t *testing.T) { } else if len(records) != 1 || records[0].ID != fixedID { t.Fatalf("all records = %+v", records) } - catalog := service.reader.(*fakeCatalog) + catalog := service.dependencies.catalog.(*fakeCatalog) catalog.record.State = types.SandboxStateRunning if records, err := service.List(t.Context(), false); err != nil { t.Fatal(err) @@ -530,420 +447,3 @@ func TestInspectReturnsResolvedPersistentRecord(t *testing.T) { t.Fatalf("steps = %q, want resolve", diff) } } - -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 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.lifecycle.(*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.lifecycle.(*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.lifecycle.(*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.lifecycle.(*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 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.lifecycle.(*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.lifecycle.(*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.lifecycle.(*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.lifecycle.(*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 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.lifecycle.(*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.ExecConfig{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) - } -} - -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", - "status:marking sandbox for deletion", "deleting", "status:removing sandbox disk", "remove", - "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.disks.(fakeDisk) - disks.remove = failure - service.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.remover.(*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.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", - "status:marking sandbox for deletion", "deleting", "status:removing sandbox disk", "remove", - "status:releasing metadata and image reference", "finalize", "report", - }) { - t.Fatalf("retry steps = %v", got) - } -} - -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.remover.(*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) - } - } -} From bcb8f54a73a31d544b53318ee9ba77c9fa391dc3 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Sat, 19 Sep 2026 00:30:29 +0800 Subject: [PATCH 31/45] refactor: separate CLI and command boundaries --- agent/agent_test.go | 9 +++-- agent/client.go | 15 ++++---- cli/sandbox/create.go | 9 +++++ cli/sandbox/create_test.go | 29 +++++++++++++++ cli/sandbox/exec.go | 32 +++++++++++++++-- cli/sandbox/exec_test.go | 70 ++++++++++++++++++++++++++++++++++++ core/sandbox_runtime.go | 9 ++--- core/sandbox_runtime_test.go | 2 +- types/exec.go | 55 ---------------------------- types/exec_test.go | 25 ------------- types/sandbox.go | 41 +++++++++++++++++++-- types/sandbox_test.go | 26 +++++++++++++- 12 files changed, 220 insertions(+), 102 deletions(-) create mode 100644 cli/sandbox/exec_test.go delete mode 100644 types/exec.go delete mode 100644 types/exec_test.go diff --git a/agent/agent_test.go b/agent/agent_test.go index b36c8d0..9d671db 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" "time" + + "github.com/kumabox/kumabox/types" ) func TestProtocolEncodingIsStable(t *testing.T) { @@ -57,8 +59,11 @@ func TestRunStreamsInputOutputAndExitStatus(t *testing.T) { var stdout, stderr bytes.Buffer code, err := Run( t.Context(), client, - []string{"sh", "-c", `printf '%s:' "$KUMABOX_TEST"; cat; printf 'warning' >&2; exit 7`}, - map[string]string{"KUMABOX_TEST": "value"}, strings.NewReader("input\n"), &stdout, &stderr, + 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) diff --git a/agent/client.go b/agent/client.go index cdc22ec..01a8a0b 100644 --- a/agent/client.go +++ b/agent/client.go @@ -6,15 +6,18 @@ import ( "fmt" "io" "sync/atomic" + + "github.com/kumabox/kumabox/types" ) var errMissingExit = errors.New("agent connection closed before an exit frame") -// Run executes argv 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, argv []string, environment map[string]string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { - if len(argv) == 0 || argv[0] == "" { - return 0, errors.New("agent command is empty") +// 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() @@ -22,7 +25,7 @@ func Run(ctx context.Context, connection io.ReadWriteCloser, argv []string, envi encoder := NewEncoder(connection) decoder := NewDecoder(connection) - if err := encoder.Encode(Message{Type: MessageExec, Argv: argv, Env: environment}); err != nil { + if err := encoder.Encode(Message{Type: MessageExec, Argv: command.Args, Env: command.Env}); err != nil { return 0, fmt.Errorf("send exec frame: %w", err) } diff --git a/cli/sandbox/create.go b/cli/sandbox/create.go index 747f867..09fd0e2 100644 --- a/cli/sandbox/create.go +++ b/cli/sandbox/create.go @@ -32,14 +32,23 @@ func NewCreateCommand(configuration configProvider) *cobra.Command { Short: "create a sandbox without starting it", Args: cobra.ExactArgs(1), RunE: func(command *cobra.Command, args []string) (returnErr error) { + if cpus == 0 || cpus > types.MaxSandboxCPUs { + return invalidFlag("cpus", fmt.Errorf("must be between 1 and %d", types.MaxSandboxCPUs)) + } memoryBytes, err := parseBytes(memory) if err != nil { return invalidFlag("memory", err) } + if memoryBytes < types.MinSandboxMemory { + return invalidFlag("memory", fmt.Errorf("must be at least %d bytes", types.MinSandboxMemory)) + } storageBytes, err := parseBytes(storageSize) if err != nil { return invalidFlag("storage", err) } + if storageBytes < types.MinSandboxStorage { + return invalidFlag("storage", fmt.Errorf("must be at least %d bytes", types.MinSandboxStorage)) + } sandboxConfig := types.SandboxConfig{Name: name, CPUs: cpus, Memory: memoryBytes, Storage: storageBytes} if err := sandboxConfig.Validate(); err != nil { return err diff --git a/cli/sandbox/create_test.go b/cli/sandbox/create_test.go index 949f170..f3b2ec3 100644 --- a/cli/sandbox/create_test.go +++ b/cli/sandbox/create_test.go @@ -14,6 +14,7 @@ import ( "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" @@ -42,6 +43,34 @@ func TestParseBytes(t *testing.T) { } } +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"}, + } + 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 { diff --git a/cli/sandbox/exec.go b/cli/sandbox/exec.go index 6ad72dd..11ced39 100644 --- a/cli/sandbox/exec.go +++ b/cli/sandbox/exec.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "strings" "github.com/spf13/cobra" @@ -33,8 +34,12 @@ func NewExecCommand(configuration configProvider) *cobra.Command { Short: "run a command inside a running sandbox", Args: cobra.MinimumNArgs(2), RunE: func(command *cobra.Command, args []string) (returnErr error) { - config := types.ExecConfig{Args: append([]string(nil), args[1:]...), Env: environment, Interactive: interactive} - if err := config.Validate(); err != nil { + 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) @@ -48,7 +53,7 @@ func NewExecCommand(configuration configProvider) *cobra.Command { if interactive { input = command.InOrStdin() } - exitCode, err := service.Exec(command.Context(), args[0], config, input, command.OutOrStdout(), command.ErrOrStderr()) + exitCode, err := service.Exec(command.Context(), args[0], invocation, input, command.OutOrStdout(), command.ErrOrStderr()) if err != nil { return err } @@ -62,3 +67,24 @@ func NewExecCommand(configuration configProvider) *cobra.Command { 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/core/sandbox_runtime.go b/core/sandbox_runtime.go index 959eb0b..0d9308b 100644 --- a/core/sandbox_runtime.go +++ b/core/sandbox_runtime.go @@ -438,8 +438,8 @@ func (s *SandboxService) Console(ctx context.Context, reference string) (io.Read // resolve + lock -> Running generation -> locate process -> unlock // | // vsock -> agent stream -> exit code -func (s *SandboxService) Exec(ctx context.Context, reference string, config types.ExecConfig, stdin io.Reader, stdout, stderr io.Writer) (int, error) { - if err := config.Validate(); err != nil { +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") @@ -451,10 +451,7 @@ func (s *SandboxService) Exec(ctx context.Context, reference string, config type 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 - if !config.Interactive { - stdin = nil - } - exitCode, err := agent.Run(ctx, connection, config.Args, config.Environment(), stdin, stdout, stderr) + 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) } diff --git a/core/sandbox_runtime_test.go b/core/sandbox_runtime_test.go index 66285bb..77af019 100644 --- a/core/sandbox_runtime_test.go +++ b/core/sandbox_runtime_test.go @@ -358,7 +358,7 @@ func TestExecUsesExactRunningGenerationAndStreamsResult(t *testing.T) { }() *steps = nil var stdout, stderr bytes.Buffer - code, err := service.Exec(t.Context(), "box", types.ExecConfig{Args: []string{"demo"}}, nil, &stdout, &stderr) + code, err := service.Exec(t.Context(), "box", types.Command{Args: []string{"demo"}}, nil, &stdout, &stderr) if err != nil { t.Fatal(err) } diff --git a/types/exec.go b/types/exec.go deleted file mode 100644 index 35dcb50..0000000 --- a/types/exec.go +++ /dev/null @@ -1,55 +0,0 @@ -package types - -import ( - "errors" - "fmt" - "strings" -) - -// ExecConfig describes one command invocation inside a running sandbox. It is -// independent of the guest-agent wire format so core and CLI do not depend on -// protocol frames. -type ExecConfig 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 in KEY=VALUE form. - Env []string - // Interactive connects the caller's input stream to the guest process. - Interactive bool -} - -// Validate rejects malformed commands before a guest-agent connection opens. -func (c ExecConfig) 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 _, pair := range c.Env { - key, _, ok := strings.Cut(pair, "=") - if !ok || key == "" || strings.IndexByte(pair, 0) >= 0 || strings.Contains(key, "=") { - return fmt.Errorf("environment %q must be KEY=VALUE", pair) - } - } - return nil -} - -// Environment converts validated KEY=VALUE entries into the map used by the -// agent protocol. Repeated keys use the last CLI value. -func (c ExecConfig) Environment() map[string]string { - if len(c.Env) == 0 { - return nil - } - environment := make(map[string]string, len(c.Env)) - for _, pair := range c.Env { - key, value, ok := strings.Cut(pair, "=") - if ok && key != "" { - environment[key] = value - } - } - return environment -} diff --git a/types/exec_test.go b/types/exec_test.go deleted file mode 100644 index a182d3c..0000000 --- a/types/exec_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package types - -import "testing" - -func TestExecConfigValidationAndEnvironment(t *testing.T) { - config := ExecConfig{Args: []string{"sh", "-c", "echo"}, Env: []string{"A=1", "A=2", "EMPTY="}} - if err := config.Validate(); err != nil { - t.Fatal(err) - } - environment := config.Environment() - if environment["A"] != "2" || environment["EMPTY"] != "" { - t.Fatalf("environment = %#v", environment) - } - for _, invalid := range []ExecConfig{ - {}, - {Args: []string{""}}, - {Args: []string{"echo", "bad\x00argument"}}, - {Args: []string{"env"}, Env: []string{"MISSING_VALUE"}}, - {Args: []string{"env"}, Env: []string{"=missing-key"}}, - } { - if err := invalid.Validate(); err == nil { - t.Fatalf("accepted invalid config %#v", invalid) - } - } -} diff --git a/types/sandbox.go b/types/sandbox.go index c366a07..cdf1d2e 100644 --- a/types/sandbox.go +++ b/types/sandbox.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "regexp" + "strings" "time" "github.com/kumabox/kumabox/errdefs" @@ -139,13 +140,47 @@ func (c SandboxConfig) Validate() error { 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("--cpus must be between 1 and %d", 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)) + 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)) + return errdefs.New(errdefs.ClassInvalid, errdefs.CodeInvalidArgument, fmt.Errorf("storage must be at least %d bytes", MinSandboxStorage)) + } + 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 } diff --git a/types/sandbox_test.go b/types/sandbox_test.go index f8182e4..1fecf82 100644 --- a/types/sandbox_test.go +++ b/types/sandbox_test.go @@ -33,9 +33,33 @@ func TestSandboxConfigValidationMatchesCreateContract(t *testing.T) { {"storage", SandboxConfig{Name: "demo", CPUs: 1, Memory: MinSandboxMemory, Storage: MinSandboxStorage - 1}}, } { t.Run(test.name, func(t *testing.T) { - if err := test.config.Validate(); err == nil { + 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) + } + } +} From 21e0f13f73154b846fec8d088a38d90dfd1a5d7f Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Sun, 20 Sep 2026 11:09:26 +0800 Subject: [PATCH 32/45] refactor: share CLI progress rendering --- cli/image/progress.go | 232 ++++++++---------------------- cli/image/progress_test.go | 176 ++++++++--------------- cli/progress/renderer.go | 263 ++++++++++++++++++++++++++++++++++ cli/progress/renderer_test.go | 200 ++++++++++++++++++++++++++ cli/sandbox/create_test.go | 13 +- cli/sandbox/progress.go | 172 ++++++---------------- 6 files changed, 627 insertions(+), 429 deletions(-) create mode 100644 cli/progress/renderer.go create mode 100644 cli/progress/renderer_test.go diff --git a/cli/image/progress.go b/cli/image/progress.go index 93baf8d..30d7008 100644 --- a/cli/image/progress.go +++ b/cli/image/progress.go @@ -5,159 +5,92 @@ import ( "errors" "fmt" "io" - "os" "sync" - "time" - "github.com/mattn/go-isatty" "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 serializes terminal presentation and implements images.Reporter. -// Counts reflect completed work; the animation indicates activity, not a percentage. -// The owning command calls Finish after source and store cleanup. +// imageProgress adapts image-specific counters and commit events to the shared +// terminal renderer. Counts represent completed work rather than percentage. // -// start --> status/layer callbacks --> commit --> cleanup --> Finish -// | | | | -// +--> ticker --+--> serialized stderr frames <-----+ | -// | | -// +<-- cancellation or stop <-------------------------+ -// closes done; Finish joins before final output +// images.Reporter callbacks -> image message/counters -> progress.Renderer type imageProgress struct { - // mu protects mutable state and serializes ticker, callbacks, and result writes. + // mu protects counters, status, and commit state across worker callbacks. mu sync.Mutex - // writer receives progress on stderr, independently of command results on stdout. - writer io.Writer - // animated enables terminal redraws; redirected streams receive plain lines. - animated bool + // 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 stage, including waits without measurable progress. + // status describes the current image workflow stage. status string - // completed counts successful callbacks, including out-of-order layer completions. + // completed and total count successful layer or image callbacks. completed int - // total is the known layer or image count; zero means it is not yet available. - total int - // unit labels the count as layers or images. + total int + // unit labels the counter as layers or images. unit string - // committed records that persistent state changed even if reporting later fails. + // committed records durable state changed before a later error. committed bool - // frame selects the next activity glyph without implying a completion percentage. - frame int - // err retains the first rendering failure so later callbacks cannot hide it. - err error - // stopOnce makes shutdown safe when command cleanup and test cleanup both join. - stopOnce sync.Once - // stop requests ticker shutdown; stopAnimation owns closing it. - stop chan struct{} - // done signals goroutine exit, or is closed immediately when animation is disabled. - done chan struct{} } var _ images.Reporter = (*imageProgress)(nil) -// startImageProgress animates only actual terminal stderr, keeping redirected logs plain. +// startImageProgress creates the image adapter on the command's stderr stream. func startImageProgress(command *cobra.Command, operation, reference string) (*imageProgress, error) { - writer := command.ErrOrStderr() - file, ok := writer.(*os.File) - animated := ok && isatty.IsTerminal(file.Fd()) - return newImageProgress(command.Context(), writer, fmt.Sprintf("%s %q", operation, reference), animated) + return newImageProgress(command.Context(), command.ErrOrStderr(), fmt.Sprintf("%s %q", operation, reference)) } -// newImageProgress writes the initial stage before starting any animation goroutine. -// If initialization fails, no goroutine or shutdown responsibility escapes to the caller. -func newImageProgress(ctx context.Context, writer io.Writer, label string, animated bool) (*imageProgress, error) { - p := &imageProgress{ - writer: writer, animated: animated, label: label, - status: "preparing image", unit: "layers", stop: make(chan struct{}), done: make(chan struct{}), - } - if err := p.render(); err != nil { +// 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 } - if animated { - go p.animate(ctx) - } else { - close(p.done) - } - return p, nil + return &imageProgress{renderer: renderer, label: label, status: status, unit: "layers"}, nil } -// animate owns the ticker and closes done on cancellation, shutdown, or write failure. -// Each frame shares the same lock as worker callbacks and command result writes. -func (p *imageProgress) animate(ctx context.Context) { - defer close(p.done) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-p.stop: - return - case <-ticker.C: - p.mu.Lock() - if p.err != nil { - p.mu.Unlock() - return - } - p.err = p.render() - failed := p.err != nil - p.mu.Unlock() - if failed { - return - } - } - } -} - -// Status updates the visible stage and returns any retained rendering failure. +// Status updates the visible image workflow stage. func (p *imageProgress) Status(status string) error { p.mu.Lock() defer p.mu.Unlock() - if p.err != nil { - return p.err - } p.status = status - p.err = p.render() - return p.err + return p.renderer.Update(p.messageLocked()) } -// Layer records a completed conversion; position is the zero-based source order. -// Completion count is independent of position because workers may finish out of order. +// 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() - if p.err != nil { - return p.err - } p.completed++ p.total = total if p.completed == p.total { p.status = "publishing image" } - if p.animated { - p.err = p.render() - } else { - _, p.err = fmt.Fprintf(p.writer, "Layer %d/%d %s complete\n", position+1, total, digest.Hex()[:12]) + if p.renderer.Animated() { + return p.renderer.Update(p.messageLocked()) } - return p.err + return p.renderer.Update(fmt.Sprintf("Layer %d/%d %s complete", position+1, total, digest.Hex()[:12])) } -// Committed records durable import completion while keeping animation alive for cleanup. -// It preserves reporting failures so the command can distinguish committed-with-error state. +// 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" - return p.err + if p.renderer.Animated() { + return p.renderer.Update(p.messageLocked()) + } + return p.renderer.Err() } -// Removed records one successful deletion and switches the completion unit to images. +// Removed records one successful image deletion. func (p *imageProgress) Removed(total int) error { p.mu.Lock() defer p.mu.Unlock() @@ -165,99 +98,52 @@ func (p *imageProgress) Removed(total int) error { p.completed++ p.total, p.unit = total, "images" p.status = "removing images" - if p.err != nil { - return p.err + if p.renderer.Animated() { + return p.renderer.Update(p.messageLocked()) } - if p.animated { - p.err = p.render() - } - return p.err + return p.renderer.Err() } -// Output clears the current frame around result writes, then resumes the spinner. +// Output coordinates command results with a live terminal frame. func (p *imageProgress) Output(writer io.Writer) io.Writer { - return progressOutput{progress: p, writer: writer} + return p.renderer.Output(writer) } -// progressOutput coordinates result writes with an active stderr animation. -type progressOutput struct { - // progress owns the shared rendering lock and current animation state. - progress *imageProgress - // writer receives result bytes unchanged, normally on stdout. - writer io.Writer -} - -// Write clears and restores the animation around one result write under the shared lock. -// It propagates both result-stream and redraw errors without changing result bytes. -func (w progressOutput) Write(data []byte) (int, error) { - p := w.progress - p.mu.Lock() - defer p.mu.Unlock() - if p.err != nil { - return 0, p.err - } - if p.animated { - if _, err := fmt.Fprint(p.writer, "\r\x1b[2K"); err != nil { - p.err = err - return 0, err - } - } - n, err := w.writer.Write(data) - if p.animated { - p.err = p.render() - } - return n, errors.Join(err, p.err) -} - -// stopAnimation requests shutdown and joins without holding mu, avoiding ticker deadlock. -func (p *imageProgress) stopAnimation() { - p.stopOnce.Do(func() { close(p.stop); <-p.done }) -} - -// Finish stops and joins the animation before writing the final line. -// The command calls it once, after its source and store cleanup have finished. +// Finish maps image commit and cancellation facts to a generic final outcome. func (p *imageProgress) Finish(operationErr error) error { - p.stopAnimation() p.mu.Lock() - defer p.mu.Unlock() var classified *errdefs.Error if errors.As(operationErr, &classified) && classified.Committed { p.committed = true } - result := "complete" - symbol := "✓" - if operationErr != nil || p.err != nil { - result, symbol = "failed", "✗" - if p.committed { - result = "committed with errors" - } else if errors.Is(operationErr, context.Canceled) { - result = "canceled" + 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 } } - message := fmt.Sprintf("%s %s", p.label, result) + detail := "" if p.total > 0 { - message += fmt.Sprintf(" (%d/%d %s)", p.completed, p.total, p.unit) + detail = fmt.Sprintf(" (%d/%d %s)", p.completed, p.total, p.unit) } - if p.animated { - message = "\r\x1b[2K" + symbol + " " + message - } - _, err := fmt.Fprintln(p.writer, message) - return errdefs.Context(errors.Join(p.err, err), "image operation", p.label, "report", "check image state with image inspect", p.committed) + 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) } -// render emits one terminal frame or plain stage line. -// The caller holds mu once the animation has started. -func (p *imageProgress) render() error { +// 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) } - if p.animated { - frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - _, err := fmt.Fprintf(p.writer, "\r\x1b[2K%s %s", frames[p.frame%len(frames)], message) - p.frame++ - return err - } - _, err := fmt.Fprintln(p.writer, message) - return err + return message } diff --git a/cli/image/progress_test.go b/cli/image/progress_test.go index 140c672..17de77b 100644 --- a/cli/image/progress_test.go +++ b/cli/image/progress_test.go @@ -7,7 +7,6 @@ import ( "strings" "sync" "testing" - "time" "github.com/kumabox/kumabox/errdefs" "github.com/kumabox/kumabox/types" @@ -15,23 +14,23 @@ import ( func TestProgressLogsHaveNoAnimationControls(t *testing.T) { var out bytes.Buffer - p, err := newImageProgress(t.Context(), &out, `Import "demo"`, false) + progress, err := newImageProgress(t.Context(), &out, `Import "demo"`) if err != nil { t.Fatal(err) } - if err := p.Status("converting layers"); err != nil { + if err := progress.Status("converting layers"); err != nil { t.Fatal(err) } - if err := p.Layer(1, 2, types.Digest{}); err != nil { + if err := progress.Layer(1, 2, types.Digest{}); err != nil { t.Fatal(err) } - if err := p.Layer(0, 2, types.Digest{}); err != nil { + if err := progress.Layer(0, 2, types.Digest{}); err != nil { t.Fatal(err) } - if err := p.Committed(types.Image{}); err != nil { + if err := progress.Committed(types.Image{}); err != nil { t.Fatal(err) } - if err := p.Finish(nil); err != nil { + 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") { @@ -42,172 +41,113 @@ func TestProgressLogsHaveNoAnimationControls(t *testing.T) { } } -// Observe real ticker writes without sleeping or reading a buffer concurrently. -type observedProgressWriter struct { - mu sync.Mutex - out bytes.Buffer - writes int - changed chan struct{} - failAt int - failure error -} - -func (w *observedProgressWriter) 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.out.Write(data) -} - -func (w *observedProgressWriter) snapshot() (string, int) { - w.mu.Lock() - defer w.mu.Unlock() - return w.out.String(), w.writes -} - -func waitProgressDone(t *testing.T, p *imageProgress) { - t.Helper() - select { - case <-p.done: - case <-time.After(2 * time.Second): - t.Fatal("animation did not stop") - } -} - -func TestProgressAnimatesAndCountsConcurrentCompletedLayers(t *testing.T) { - writer := &observedProgressWriter{changed: make(chan struct{}, 1)} - p, err := newImageProgress(t.Context(), writer, `Import "demo"`, true) +func TestProgressCountsConcurrentCompletedLayers(t *testing.T) { + var out bytes.Buffer + progress, err := newImageProgress(t.Context(), &out, `Import "demo"`) if err != nil { t.Fatal(err) } - t.Cleanup(p.stopAnimation) - timeout := time.NewTimer(2 * time.Second) - defer timeout.Stop() - for { - if _, writes := writer.snapshot(); writes >= 2 { - break - } - select { - case <-writer.changed: - case <-timeout.C: - t.Fatal("spinner did not advance while waiting") - } - } - if err := p.Status("converting layers"); 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 := p.Layer(position, 3, types.Digest{}); err != nil { + if err := progress.Layer(position, 3, types.Digest{}); err != nil { t.Error(err) } }() } wait.Wait() - if err := p.Committed(types.Image{}); err != nil { - t.Fatal(err) - } - if err := p.Finish(nil); err != nil { + if err := progress.Finish(nil); err != nil { t.Fatal(err) } - out, _ := writer.snapshot() - if !strings.Contains(out, "⠋") || !strings.Contains(out, "⠙") || !strings.Contains(out, "publishing image (3/3 layers)") { - t.Fatalf("animation or completion counts missing: %q", out) - } - if !strings.HasSuffix(out, "✓ Import \"demo\" complete (3/3 layers)\n") { - t.Fatalf("final status = %q", out) + if strings.Count(out.String(), "Layer ") != 3 || !strings.HasSuffix(out.String(), "complete (3/3 layers)\n") { + t.Fatalf("layer progress = %q", out.String()) } } -func TestProgressStopsOnCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - writer := &observedProgressWriter{changed: make(chan struct{}, 1)} - p, err := newImageProgress(ctx, writer, `Pull "demo"`, true) - if err != nil { - t.Fatal(err) - } - t.Cleanup(p.stopAnimation) - cancel() - waitProgressDone(t, p) - if err := p.Finish(ctx.Err()); err != nil { - t.Fatal(err) - } - out, _ := writer.snapshot() - if !strings.HasSuffix(out, "✗ Pull \"demo\" canceled\n") { - t.Fatalf("canceled status = %q", out) +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 TestProgressRetainsAnimationFailureAfterCommit(t *testing.T) { +func TestProgressRetainsRenderingFailureAfterCommit(t *testing.T) { failure := errors.New("terminal write failed") - writer := &observedProgressWriter{changed: make(chan struct{}, 1), failAt: 2, failure: failure} - p, err := newImageProgress(t.Context(), writer, `Import "demo"`, true) + writer := &imageFailWriter{failAt: 2, failure: failure} + progress, err := newImageProgress(t.Context(), writer, `Import "demo"`) if err != nil { t.Fatal(err) } - t.Cleanup(p.stopAnimation) - waitProgressDone(t, p) - if err := p.Status("converting layers"); !errors.Is(err, failure) { + if err := progress.Status("converting layers"); !errors.Is(err, failure) { t.Fatalf("status error = %v", err) } - if err := p.Committed(types.Image{}); !errors.Is(err, failure) { + if err := progress.Committed(types.Image{}); !errors.Is(err, failure) { t.Fatalf("commit report error = %v", err) } - err = p.Finish(failure) + 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) } - out, _ := writer.snapshot() - if !strings.Contains(out, "committed with errors") { - t.Fatalf("committed error status = %q", out) + if !strings.Contains(writer.String(), "committed with errors") { + t.Fatalf("committed error status = %q", writer.String()) } } -func TestProgressKeepsResultsSeparateFromLiveFrames(t *testing.T) { - writer := &observedProgressWriter{changed: make(chan struct{}, 1)} - p, err := newImageProgress(t.Context(), writer, `Verify "demo"`, true) +func TestProgressKeepsResultsOnTheirWriter(t *testing.T) { + var progressOut, resultOut bytes.Buffer + progress, err := newImageProgress(t.Context(), &progressOut, `Verify "demo"`) if err != nil { t.Fatal(err) } - t.Cleanup(p.stopAnimation) - if _, err := p.Output(writer).Write([]byte("verified sha256:example\n")); err != nil { + 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 := p.Finish(nil); err != nil { + if err := progress.Finish(context.Canceled); err != nil { t.Fatal(err) } - out, _ := writer.snapshot() - if !strings.Contains(out, "\r\x1b[2Kverified sha256:example\n\r\x1b[2K") { - t.Fatalf("result was not separated from the live spinner: %q", out) + if !strings.HasSuffix(out.String(), `Pull "demo" canceled`+"\n") { + t.Fatalf("canceled status = %q", out.String()) } - waitProgressDone(t, p) } func TestRemovalProgressCountsCompletedImages(t *testing.T) { var out bytes.Buffer - p, err := newImageProgress(t.Context(), &out, `Remove "demo, alias"`, false) + progress, err := newImageProgress(t.Context(), &out, `Remove "demo, alias"`) if err != nil { t.Fatal(err) } - if err := p.Removed(2); err != nil { + if err := progress.Removed(2); err != nil { t.Fatal(err) } - if err := p.Removed(2); err != nil { + if err := progress.Removed(2); err != nil { t.Fatal(err) } - if err := p.Finish(nil); err != nil { + if err := progress.Finish(nil); err != nil { t.Fatal(err) } if !strings.HasSuffix(out.String(), "complete (2/2 images)\n") { 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/sandbox/create_test.go b/cli/sandbox/create_test.go index f3b2ec3..399ecd2 100644 --- a/cli/sandbox/create_test.go +++ b/cli/sandbox/create_test.go @@ -2,6 +2,7 @@ package sandbox import ( "bytes" + "context" "crypto/sha256" "encoding/json" "errors" @@ -242,13 +243,7 @@ func installFakeMKFS(t *testing.T, base string) { } func newTestProgress(writer *bytes.Buffer) (*sandboxProgress, error) { - progress := &sandboxProgress{ - writer: writer, operation: "create sandbox", label: `Create "box"`, status: "preparing sandbox", recovery: "inspect the sandbox state", - stop: make(chan struct{}), done: make(chan struct{}), - } - if err := progress.render(); err != nil { - return nil, err - } - close(progress.done) - return progress, nil + return newSandboxProgress( + context.Background(), writer, "create sandbox", `Create "box"`, "preparing sandbox", "inspect the sandbox state", + ) } diff --git a/cli/sandbox/progress.go b/cli/sandbox/progress.go index 24b3d0e..72a63cf 100644 --- a/cli/sandbox/progress.go +++ b/cli/sandbox/progress.go @@ -5,47 +5,33 @@ import ( "errors" "fmt" "io" - "os" "sync" - "time" - "github.com/mattn/go-isatty" "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 serializes terminal animation, stage callbacks, and result output. -// Redirected stderr receives plain stage lines and stdout remains command data only. +// sandboxProgress adapts SandboxService stages and commit events to the shared +// renderer while retaining operation-specific recovery context. type sandboxProgress struct { - // mu serializes ticker, callback, and result output writes. + // mu protects stage and commit state across reporter and final callbacks. mu sync.Mutex - // writer receives progress independently of stdout command results. - writer io.Writer - // operation supplies error context such as create sandbox or remove sandbox. + // renderer owns terminal detection, serialization, animation, and shutdown. + renderer *cliprogress.Renderer + // operation supplies structured error context. operation string - // label identifies the operation and quoted user-facing reference. + // label identifies the operation and quoted sandbox reference. label string // status is the current application workflow stage. status string - // recovery tells callers how to handle a progress rendering failure. + // recovery describes how to inspect or retry a reporting failure. recovery string - // animated selects terminal redraws instead of plain log lines. - animated bool - // committed records that durable state changed despite a later failure. + // committed records durable state changed before a later error. committed bool - // frame indexes the next spinner glyph. - frame int - // err retains the first rendering failure. - err error - // stopOnce makes Finish safe if cleanup calls it more than once. - stopOnce sync.Once - // stop requests ticker shutdown. - stop chan struct{} - // done is closed after the ticker goroutine exits. - done chan struct{} } var _ core.SandboxReporter = (*sandboxProgress)(nil) @@ -70,140 +56,68 @@ func startStopProgress(command *cobra.Command, reference string) (*sandboxProgre return startProgress(command, "stop sandbox", fmt.Sprintf("Stop %q", reference), "preparing stop", "retry the stop or inspect the sandbox runtime") } -// startProgress writes an initial stage before starting its ticker. func startProgress(command *cobra.Command, operation, label, status, recovery string) (*sandboxProgress, error) { - writer := command.ErrOrStderr() - file, isFile := writer.(*os.File) - progress := &sandboxProgress{ - writer: writer, operation: operation, label: label, status: status, recovery: recovery, - animated: isFile && isatty.IsTerminal(file.Fd()), stop: make(chan struct{}), done: make(chan struct{}), - } - if err := progress.render(); err != nil { - return nil, err - } - if progress.animated { - go progress.animate(command.Context()) - } else { - close(progress.done) - } - return progress, nil + return newSandboxProgress(command.Context(), command.ErrOrStderr(), operation, label, status, recovery) } -// animate redraws until command cleanup finishes, cancellation occurs, or output fails. -func (p *sandboxProgress) animate(ctx context.Context) { - defer close(p.done) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-p.stop: - return - case <-ticker.C: - p.mu.Lock() - if p.err == nil { - p.err = p.render() - } - failed := p.err != nil - p.mu.Unlock() - if failed { - return - } - } +// 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 workflow stage. +// Status updates the current sandbox workflow stage. func (p *sandboxProgress) Status(status string) error { p.mu.Lock() defer p.mu.Unlock() - if p.err != nil { - return p.err - } p.status = status - p.err = p.render() - return p.err + return p.renderer.Update(p.label + " · " + status) } -// Committed records that durable application state changed before reporting finished. +// 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" - return p.err + if p.renderer.Animated() { + return p.renderer.Update(p.label + " · " + p.status) + } + return p.renderer.Err() } -// Output coordinates stdout writes with terminal redraws. +// Output coordinates command results with a live terminal frame. func (p *sandboxProgress) Output(writer io.Writer) io.Writer { - return progressWriter{progress: p, writer: writer} -} - -// progressWriter prevents a live animation from visually mixing with command output. -type progressWriter struct { - // progress owns output serialization and animation state. - progress *sandboxProgress - // writer receives the unchanged command result. - writer io.Writer -} - -func (w progressWriter) Write(data []byte) (int, error) { - p := w.progress - p.mu.Lock() - defer p.mu.Unlock() - if p.err != nil { - return 0, p.err - } - if p.animated { - if _, err := fmt.Fprint(p.writer, "\r\x1b[2K"); err != nil { - p.err = err - return 0, err - } - } - n, writeErr := w.writer.Write(data) - if p.animated { - p.err = p.render() - } - return n, errors.Join(writeErr, p.err) + return p.renderer.Output(writer) } -// Finish joins the ticker and emits one unambiguous final status line. +// Finish maps sandbox commit and cancellation facts to a generic final outcome. func (p *sandboxProgress) Finish(operationErr error) error { - p.stopOnce.Do(func() { close(p.stop); <-p.done }) p.mu.Lock() - defer p.mu.Unlock() var classified *errdefs.Error if errors.As(operationErr, &classified) && classified.Committed { p.committed = true } - resultText, symbol := "complete", "✓" - if operationErr != nil || p.err != nil { - resultText, symbol = "failed", "✗" - if p.committed { - resultText = "committed with errors" - } else if errors.Is(operationErr, context.Canceled) { - resultText = "canceled" + 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 } } - message := fmt.Sprintf("%s %s", p.label, resultText) - if p.animated { - message = "\r\x1b[2K" + symbol + " " + message - } - _, err := fmt.Fprintln(p.writer, message) - return errdefs.Context(errors.Join(p.err, err), p.operation, p.label, "report", p.recovery, p.committed) -} + committed, operation, label, recovery := p.committed, p.operation, p.label, p.recovery + p.mu.Unlock() -// render writes one spinner frame or one plain stage line. The caller holds mu -// after animation starts. -func (p *sandboxProgress) render() error { - message := p.label + " · " + p.status - if p.animated { - frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - _, err := fmt.Fprintf(p.writer, "\r\x1b[2K%s %s", frames[p.frame%len(frames)], message) - p.frame++ - return err - } - _, err := fmt.Fprintln(p.writer, message) - return err + reportErr := p.renderer.Finish(label, outcome, "") + return errdefs.Context(reportErr, operation, label, "report", recovery, committed) } From ff2d616b5bc722c6001b2069b92910c336559500 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Mon, 21 Sep 2026 22:00:59 +0800 Subject: [PATCH 33/45] test: strengthen runtime lifecycle contracts --- agent/agent_test.go | 138 ++++++++++++++++++++ cgroup/cgroup_test.go | 40 ++++++ cgroup/manager_linux.go | 20 ++- cgroup/manager_linux_test.go | 90 +++++++++++++ cgroup/manager_other_test.go | 26 ++++ cmd/kumabox/main_test.go | 150 ++++++++++++++++++++++ vmm/backend_test.go | 10 ++ vmm/cloudhypervisor/args_test.go | 37 ++++-- vmm/cloudhypervisor/driver.go | 16 ++- vmm/cloudhypervisor/driver_test.go | 148 +++++++++++++++++++++ vmm/cloudhypervisor/process_linux_test.go | 147 +++++++++++++++++++++ vmm/vmm_test.go | 32 +++++ 12 files changed, 832 insertions(+), 22 deletions(-) create mode 100644 cgroup/cgroup_test.go create mode 100644 cgroup/manager_linux_test.go create mode 100644 cgroup/manager_other_test.go create mode 100644 cmd/kumabox/main_test.go create mode 100644 vmm/cloudhypervisor/process_linux_test.go diff --git a/agent/agent_test.go b/agent/agent_test.go index 9d671db..fc0abe9 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -8,6 +8,7 @@ import ( "log" "net" "strings" + "sync" "testing" "time" @@ -136,6 +137,143 @@ func TestServerRejectsUnexpectedInputFrame(t *testing.T) { _ = 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"}, 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 index 6e7ede3..db86f49 100644 --- a/cgroup/manager_linux.go +++ b/cgroup/manager_linux.go @@ -62,12 +62,7 @@ func (m *Manager) Prepare(_ context.Context, id types.SandboxID, cpus uint32) (* if err := os.Mkdir(directory, 0o750); err != nil && !errors.Is(err, fs.ErrExist) { return nil, fmt.Errorf("create cgroup scope: %w", err) } - weight := min(int(cpus), 10_000) - if err := writeControl(directory, "cpu.weight", strconv.Itoa(weight)); err != nil { - return nil, err - } - quota := int64(cpus) * cpuPeriodMicros - if err := writeControl(directory, "cpu.max", fmt.Sprintf("%d %d", quota, cpuPeriodMicros)); err != nil { + 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 @@ -77,6 +72,19 @@ func (m *Manager) Prepare(_ context.Context, id types.SandboxID, cpus uint32) (* 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) 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_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/cmd/kumabox/main_test.go b/cmd/kumabox/main_test.go new file mode 100644 index 0000000..13e41e6 --- /dev/null +++ b/cmd/kumabox/main_test.go @@ -0,0 +1,150 @@ +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: "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() }) + deadline := time.Now().Add(3 * time.Second) + for { + if _, err := os.Stat(marker); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("doctor helper did not become ready") + } + time.Sleep(10 * time.Millisecond) + } + if err := command.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- command.Wait() }() + select { + case err := <-done: + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() == 0 { + t.Fatalf("signal exit error = %v", err) + } + case <-time.After(3 * 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/vmm/backend_test.go b/vmm/backend_test.go index cff7df1..a3166c5 100644 --- a/vmm/backend_test.go +++ b/vmm/backend_test.go @@ -57,6 +57,16 @@ func TestRegistryRoutesAndRejectsInvalidSets(t *testing.T) { 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) { diff --git a/vmm/cloudhypervisor/args_test.go b/vmm/cloudhypervisor/args_test.go index 8d00b1d..9f506db 100644 --- a/vmm/cloudhypervisor/args_test.go +++ b/vmm/cloudhypervisor/args_test.go @@ -2,12 +2,13 @@ package cloudhypervisor import ( "context" + "fmt" "net" "net/http" "os" "path/filepath" + "runtime" "slices" - "strings" "sync/atomic" "testing" @@ -96,7 +97,7 @@ func TestOpenConsolePTYRejectsUnmanagedOrRegularPaths(t *testing.T) { } } -func TestBuildArgsPreservesDiskOrderAndAccessMode(t *testing.T) { +func TestBuildArgsMatchesDirectBootContract(t *testing.T) { plan := vmm.LaunchPlan{ SandboxID: "123e4567-e89b-42d3-a456-426614174000", Generation: 3, CPUs: 2, Memory: 1 << 30, BootProfile: types.BootProfileOverlayV1, @@ -108,17 +109,25 @@ func TestBuildArgsPreservesDiskOrderAndAccessMode(t *testing.T) { }, } args := buildArgs(plan, "/run/api.sock", "/run/vsock.uds") - diskIndex := slices.Index(args, "--disk") - if diskIndex < 0 || diskIndex+3 >= len(args) { - t.Fatalf("disk arguments missing: %v", args) - } - disks := args[diskIndex+1 : diskIndex+4] - if !strings.Contains(disks[0], "serial=kumabox-layer0") || !strings.Contains(disks[0], "readonly=on") || - !strings.Contains(disks[1], "serial=kumabox-layer1") || !strings.Contains(disks[1], "readonly=on") || - !strings.Contains(disks[2], "serial=kumabox-cow") || !strings.Contains(disks[2], "direct=on") || !strings.Contains(disks[2], "sparse=on") { - t.Fatalf("disk arguments = %v", disks) - } - if slices.Index(args, "--kernel") < diskIndex+4 || slices.Index(args, "--initramfs") < 0 || slices.Index(args, "--vsock") < 0 { - t.Fatalf("boot arguments = %v", args) + 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", + "--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 index 99737a6..76555db 100644 --- a/vmm/cloudhypervisor/driver.go +++ b/vmm/cloudhypervisor/driver.go @@ -245,12 +245,24 @@ func (d *Driver) Observe(ctx context.Context, id types.SandboxID, generation uin // WaitReady waits for the exact process identity to expose a Running VM. func (d *Driver) WaitReady(ctx context.Context, process vmm.Process) error { - deadline := time.NewTimer(d.startupTimeout) + 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 := d.Observe(ctx, process.SandboxID, process.Generation) + observation, err := observe(ctx, process.SandboxID, process.Generation) if err != nil { return err } diff --git a/vmm/cloudhypervisor/driver_test.go b/vmm/cloudhypervisor/driver_test.go index 46de01a..e770879 100644 --- a/vmm/cloudhypervisor/driver_test.go +++ b/vmm/cloudhypervisor/driver_test.go @@ -1,12 +1,17 @@ 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" ) @@ -45,3 +50,146 @@ func TestNewRejectsInvalidLifecyclePolicy(t *testing.T) { 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_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/vmm_test.go b/vmm/vmm_test.go index 3c8bbb9..161c3b7 100644 --- a/vmm/vmm_test.go +++ b/vmm/vmm_test.go @@ -36,3 +36,35 @@ func TestLaunchPlanRequiresBaseToTopReadOnlyLayersAndFinalCOW(t *testing.T) { 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) + } + }) + } +} From dfcb53ff2758f924129b258ce43e3a13d83704bc Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Mon, 21 Sep 2026 22:14:46 +0800 Subject: [PATCH 34/45] test: stabilize binary signal synchronization --- cmd/kumabox/main_test.go | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/cmd/kumabox/main_test.go b/cmd/kumabox/main_test.go index 13e41e6..ac6f2eb 100644 --- a/cmd/kumabox/main_test.go +++ b/cmd/kumabox/main_test.go @@ -90,28 +90,38 @@ func TestMainBinaryPropagatesSignalCancellation(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = command.Process.Kill() }) - deadline := time.Now().Add(3 * time.Second) + 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 { - if _, err := os.Stat(marker); err == nil { - break - } - if time.Now().After(deadline) { + 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) + } } - time.Sleep(10 * time.Millisecond) } if err := command.Process.Signal(os.Interrupt); err != nil { t.Fatal(err) } - done := make(chan error, 1) - go func() { done <- command.Wait() }() select { - case err := <-done: + 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(3 * time.Second): + case <-time.After(10 * time.Second): t.Fatal("kumabox did not exit after interrupt") } if stdout.Len() != 0 || strings.ContainsAny(stderr.String(), "\r\x1b") { From b459301087bd1152002fc9aea371944930086c2b Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Mon, 21 Sep 2026 22:16:13 +0800 Subject: [PATCH 35/45] docs: publish project specifications --- .github/workflows/ci.yml | 7 +- .gitignore | 2 - CONTRIBUTING.md | 43 +++ Makefile | 7 +- README.md | 305 ++++--------------- docs/ARCHITECTURE.md | 121 ++++++++ docs/BEHAVIOR.md | 129 ++++++++ docs/COCOON-MAP.md | 40 +++ docs/CONFIGURATION.md | 65 ++++ docs/DECISIONS.md | 55 ++++ docs/HOST.md | 49 +++ docs/PERFORMANCE.md | 44 +++ docs/PRODUCT.md | 61 ++++ docs/README.md | 31 ++ docs/REFACTORING.md | 547 ++++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 100 +++++++ docs/architecture-diagrams.md | 72 +++++ docs/proposals/s3-sandbox.md | 77 +++++ docs/releasing.md | 39 +++ docs/runbooks/s2-oci.md | 82 +++++ docs/runbooks/s3-create.md | 98 ++++++ scripts/check-doc-links.sh | 21 ++ 22 files changed, 1747 insertions(+), 248 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BEHAVIOR.md create mode 100644 docs/COCOON-MAP.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/DECISIONS.md create mode 100644 docs/HOST.md create mode 100644 docs/PERFORMANCE.md create mode 100644 docs/PRODUCT.md create mode 100644 docs/README.md create mode 100644 docs/REFACTORING.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/architecture-diagrams.md create mode 100644 docs/proposals/s3-sandbox.md create mode 100644 docs/releasing.md create mode 100644 docs/runbooks/s2-oci.md create mode 100644 docs/runbooks/s3-create.md create mode 100755 scripts/check-doc-links.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e863e7..e6fda03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,10 +35,13 @@ jobs: - name: Race detector run: make race + - name: Lint + run: make lint + - name: Check module files run: | go mod tidy git diff --exit-code -- go.mod go.sum - - name: Reject tracked local documentation - run: test -z "$(git ls-files docs)" + - name: Check documentation links + run: make docs-check diff --git a/.gitignore b/.gitignore index 098f33d..ece97d6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,6 @@ dist/ .rewrite-backup/ .claude/ coverage.out -# Local design and implementation notes must never be committed. -docs/ # Local Go and tooling caches are generated, never source. .cache*/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1dd0115 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# 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 Linux acceptance runbook for changes involving KVM, Cloud Hypervisor, cgroup v2, CNI, EROFS, ext4, or vsock. Record the host versions and result in the pull request. + +## Compatibility and documentation + +Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` is the feature and behavior reference. A change may intentionally differ when KumaBox has a stronger safety or modularity guarantee, but the difference must be documented in [docs/COCOON-MAP.md](docs/COCOON-MAP.md). + +Update documentation in the same commit when a change affects: + +- commands, flags, output, exit codes, or lifecycle behavior; +- package ownership or dependency direction; +- persisted metadata, managed paths, or recovery rules; +- host requirements, configuration, or guest protocols; +- roadmap status or Linux acceptance steps. + +Use `make docs-check` to validate relative Markdown links. diff --git a/Makefile b/Makefile index 46576c7..20e6273 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build agent install test doctor-check race verify lint vet fmt fmt-check deps clean coverage cloc help +.PHONY: all build agent install test doctor-check docs-check race verify lint vet fmt fmt-check deps clean coverage cloc help REPO_PATH := github.com/kumabox/kumabox @@ -82,10 +82,13 @@ doctor-check: ## Check host and guest shell script syntax bash -n scripts/kumabox-check.sh sh -n oci-images/ubuntu/overlay.sh +docs-check: ## Check relative Markdown links + bash scripts/check-doc-links.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 +verify: fmt-check vet doctor-check docs-check test build ## Verify formatting, docs, tests and build coverage: test ## Generate and display coverage report go tool cover -func=coverage.out diff --git a/README.md b/README.md index bfa3503..2c83b88 100644 --- a/README.md +++ b/README.md @@ -4,289 +4,110 @@ # KumaBox -A microVM sandbox runtime for AI agents. One node runs one daemon -(`kumaboxd`) that owns all state, plus a thin client (`kumabox`) with a -Docker-like command line; sandboxes are Cloud Hypervisor microVMs booted from -OCI images, with CNI networking, cgroups, snapshots and clone. +KumaBox is a daemonless microVM sandbox runtime for AI agents. The current implementation imports OCI and Docker images, creates persistent sandboxes, boots them with Cloud Hypervisor, and provides console and guest command access. -The rewrite currently provides the `kumabox` CLI, the host doctor, container -image management, persistent sandbox creation, and recoverable Cloud Hypervisor -start/stop. Each command opens its metadata store, performs one operation, and -exits. The remaining sandbox lifecycle is tracked in -[docs/ROADMAP.md](docs/ROADMAP.md). +KumaBox uses Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` as its behavior and capability baseline. Compatibility is evaluated feature by feature. KumaBox keeps its own package structure, error model, metadata schema, and `kumabox.*` guest boot protocol. -## Where the design lives +## Current status -Read these in order. They are the only specifications; anything else under -`docs/` is history. +Available commands: -| Document | Answers | -|---|---| -| [docs/PRODUCT.md](docs/PRODUCT.md) | What this is, who uses it, what v1 must do, what it will not do, how it relates to Cocoon, shared vocabulary | -| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Layering and the import matrix, fact ownership, the execution model, transactions and locks, cross-cutting contracts, testing tiers, naming and code style | -| [docs/BEHAVIOR.md](docs/BEHAVIOR.md) | What happens on the machine when a command runs, and what is left behind when it fails | -| [docs/PERFORMANCE.md](docs/PERFORMANCE.md) | How performance is measured, how it is compared against Cocoon, and which scenarios must match or beat it | -| [docs/ROADMAP.md](docs/ROADMAP.md) | What each phase does, why, how, and the evidence that closes it | -| [docs/DECISIONS.md](docs/DECISIONS.md) | Decisions taken, why the previous design was discarded, what is still open | - -Documentation is intentionally not tracked by Git (see `.gitignore`), so these -files live only in the working tree — keep local backups. - -## Working on it - -```bash -make verify # formatting, vet, tests, build -make race # race detector, required for concurrency changes +```text +kumabox doctor +kumabox image pull|import|list|inspect|verify|remove +kumabox create|start|stop|ps|inspect|console|exec|rm +kumabox version ``` -`make verify` must stay green on macOS with no root and no KVM. Real microVM -behaviour (Cloud Hypervisor, CNI, KVM) is verified manually on a Linux host -using the runbook attached to each phase. +Image import and sandbox lifecycle are implemented locally. Real Cloud Hypervisor, cgroup, vsock, ext4, and EROFS behavior requires Linux and is covered by the checked-in runbooks. Networking, `run`, log streaming, snapshots, clone, and Firecracker remain planned work; see [the roadmap](docs/ROADMAP.md). -Code is organized as importable modules by responsibility, without `internal` -or a generic `pkg` container: +## Build and test -| Package | Responsibility | -|---|---| -| `cmd/kumabox` | Process entry point, signals and exit status | -| `cli`, `cli/image`, `cli/sandbox`, `cli/doctor` | Command trees, argument parsing and presentation | -| `core` | Application services, operation ordering and concrete adapter assembly | -| `types` | Shared image and sandbox resource models and value objects | -| `images` | Image import, verification, boot selection and removal rules | -| `images/catalog` | Persist image identities, name bindings and layer references | -| `images/source` | Read Docker archives, OCI layouts/archives and registries | -| `images/erofs` | Convert source layers and extract boot candidates | -| `sandbox`, `sandbox/catalog` | Sandbox filesystem ownership and metadata persistence | -| `disk` | Prepare and remove sandbox-owned sparse ext4 COW disks | -| `vmm`, `vmm/cloudhypervisor` | VMM backend contract, launch/process facts, and the Cloud Hypervisor adapter | -| `cgroup` | Per-sandbox cgroup v2 preparation and reclamation | -| `metadata`, `metadata/sqlite` | Engine-neutral transactions and the SQLite implementation | -| `storage`, `lock/flock` | Managed filesystem operations and file locks | -| `errdefs`, `version` | Error classification and build information | - -`core` owns application workflows that cross module boundaries and connects -their concrete adapters. CLI handlers use those services. Shared resource data -belongs to `types`; capability interfaces stay beside their consumers and are -not collected in `types`. Modules do not import `core` or `cli`. These dependency -directions are enforced by depguard in `.golangci.yml`. - -The image command groups complete responsibilities into `import.go` (pull and -local import), `query.go` (list, inspect and verify), and `remove.go`. Related -types, interfaces and methods stay together; files are not split by declaration -kind. Interfaces describe the operations needed by their consumers. - -Document each package's responsibility in an existing source file. Exported APIs, -key types and fields, and complex private methods need comments explaining their -contracts, units, ownership, and failure boundaries. Keep comments in English and -use indented ASCII diagrams near workflows where ordering, locking, or commit -boundaries matter. Update these comments whenever the behavior changes. - -Tests live in their owning directories as `*_test.go`. The shared memory/SQLite -transaction contract is exercised in `metadata/store_test.go`; there is no -production package for test helpers. Image workflow integration tests use the -public module APIs and cover the assembled catalog with both metadata engines. - -Build with `make build`, or run the entry point with `go run ./cmd/kumabox`. -The host checker source is `scripts/kumabox-check.sh`. - -## Container images - -Pull or import a Linux image containing regular `/boot/vmlinuz*` and -`/boot/initrd.img*` files. A bootable KumaBox image also declares the OCI config -label `io.kumabox.boot.profile=overlay-v1`; older images without the label remain -importable and inspectable but will be rejected by `start`. Image conversion -requires `mkfs.erofs` 1.8 or newer; unit and integration tests use a stand-in and -run on macOS without root/KVM. The -[synthetic fixture](testdata/oci-layout/README.md) cannot boot a VM. +KumaBox requires Go 1.24 or newer. ```bash -kumabox image pull REGISTRY/IMAGE:TAG --platform linux/amd64 -kumabox image import tiny ./testdata/oci-layout --platform linux/amd64 -kumabox image import demo ./docker-save.tar --format docker --platform linux/amd64 -kumabox image ls --json -kumabox image inspect tiny -kumabox image verify tiny -kumabox image rm tiny +make build +make verify +make lint ``` -`image ls` prints a table with names, 12-character image IDs, platforms, -human-readable sizes, and creation timestamps in UTC. `image inspect` and -`image ls --json` print indented JSON with full digests and numeric sizes. -The inspect response reports the declaration as `boot.profile`; an empty value -means the source did not declare a boot contract. KumaBox never guesses a profile -from kernel or initrd filenames. -Import and pull show a live spinner and completed layer counts on a terminal. -Verification and removal also show waiting status; removal reports completed -image counts. Redirected progress uses plain lines on stderr. Results are -written to stdout. - -`image import NAME PATH` detects the format from source contents by default. -It accepts OCI layout directories, OCI archives, and `docker save` archives; -archives can be plain tar or gzip, regardless of their filename extension. -Use `--format oci` or `--format docker` to select a format explicitly. -For Docker archives containing multiple images for the target platform, use -`--source-tag REPOSITORY:TAG` to select the source image; `NAME` is its local -KumaBox name. Archives containing both OCI and Docker metadata use OCI by -default; pass `--format docker --source-tag REPOSITORY:TAG` for Docker tag -selection. `docker export` filesystem archives are not supported. - -To import an image already present in Docker: +The binaries are written to `bin/`. `make verify` checks formatting, documentation links, Linux and Darwin vet, shell syntax, race-enabled tests, and the build. -```bash -docker save -o demo.tar your-image:tag -kumabox image import demo ./demo.tar --platform linux/amd64 -kumabox image verify demo -``` - -Docker archives are normalized to a deterministic OCI manifest. Its digest -identifies the imported config and ordered layers and may differ from the -original registry manifest digest. Repacking or changing source tags preserves -the imported digest. Docker images must meet the same kernel/initrd requirements. - -The reference Ubuntu guest image is built from -[`oci-images/ubuntu`](oci-images/ubuntu). Its independently implemented initramfs -script consumes only `kumabox.*` kernel parameters and virtio serials. It does -not expose or depend on another runtime's guest protocol. - -For a separate data store, pass all three roots: +Install locally with: ```bash -kumabox --root-dir /tmp/kb/data --run-dir /tmp/kb/run --log-dir /tmp/kb/log image ls --json +sudo make install +kumabox doctor ``` -Import validates OCI manifest/config/layer digests and layer diffIDs, streams -layers to EROFS, and extracts boot candidates with layer overwrite/whiteout -semantics. Metadata is committed after durable publication and final digest -checks. Verification detects EROFS and boot-file corruption. Source OCI blobs -are never stored persistently; repeated imports reuse verified, registered -artifacts. Removing a name retains artifacts until the last image reference -is removed. A final manifest cannot be removed while a sandbox pins it. - -The [Linux acceptance runbook](docs/runbooks/s2-oci.md) covers real conversion, -registry pull, cancellation, concurrency, and crash/retry behavior. +The host checker reports Linux, KVM, cgroup v2, Cloud Hypervisor, `mkfs.erofs`, `mkfs.ext4`, and other runtime prerequisites. See [host requirements](docs/HOST.md). -## Create a sandbox +## Image workflow -`create` resolves an existing local image, reserves the sandbox name and exact -manifest digest, then creates a private sparse ext4 COW directly at -`Data/sandboxes//cow.raw`. It does not start a VMM. +A bootable image must contain a kernel and initramfs and declare the OCI label `io.kumabox.boot.profile=overlay-v1`. ```bash -kumabox create IMAGE --name NAME \ - --cpus 2 --memory 1GiB --storage 10GiB +kumabox image pull ghcr.io/example/image:tag --platform linux/amd64 +kumabox image import demo ./docker-save.tar --format docker --platform linux/amd64 +kumabox image import demo ./oci-layout --format oci --platform linux/amd64 +kumabox image list +kumabox image inspect demo +kumabox image verify demo ``` -Successful text output is the full sandbox UUID. `--json` returns an indented -object containing the ID, name, manifest digest, `created` state, resource -shape, and creation time. Progress is written to stderr. `mkfs.ext4` from -e2fsprogs must be available on the host. - -`Created` means the disk and metadata exist but the sandbox has never started; -`Stopped` is reserved for a sandbox whose VMM has exited after a start. The -metadata schema is version 2. Existing version 1 roots are migrated in one -transaction when first opened: image records and artifacts remain in place, -and the new sandbox collections become available without changing CLI roots. - -`start SANDBOX` accepts an exact name or complete UUID. It checks KVM, the -Cloud Hypervisor executable, the pinned image, its declared `overlay-v1` boot -profile, and the existing ext4 COW before committing `Starting`. It records a -PID-reuse-safe process identity and commits `Running` only after the private -Cloud Hypervisor API reports readiness. Retrying recovers the same `Starting` -generation; a failed launch is terminated and retained as `Error` with a -diagnostic. `--json` returns the complete indented sandbox object. - -`stop SANDBOX` follows the same direct-boot behavior as Cocoon. It first makes -a best-effort request to Cloud Hypervisor's private `vm.shutdown` endpoint, -then terminates the exact identity-checked VMM process with `SIGTERM`, waits up -to five seconds, and uses `SIGKILL` if it is still alive. There is no guest ACPI -shutdown wait and no `--force` or `--timeout` mode. Runtime files and the empty -cgroup are removed before the generation-fenced transition to `Stopped`. +Local import auto-detects OCI layouts, OCI archives, and `docker save` archives. `docker export` filesystem archives are unsupported. Source layers are verified, converted to EROFS, and published by digest. Boot candidates follow layer overwrite, whiteout, and opaque-directory semantics. + +## Sandbox workflow ```bash -kumabox stop NAME -kumabox stop 123e4567-e89b-42d3-a456-426614174000 --json +kumabox create demo --name box --cpus 2 --memory 1GiB --storage 10GiB +kumabox start box +kumabox exec box -- uname -a +kumabox console box +kumabox stop box +kumabox rm box ``` -An interrupted stop retains `Stopping`; running the same command again resumes -the operation. It also recovers `Starting` records left by an interrupted start. -Stopping an already `Created` or `Stopped` sandbox succeeds without changing -its lifecycle history. - -Attach to the direct-boot PTY of a running sandbox with `console`. The command -verifies the current process generation and Cloud Hypervisor API state before -opening the kernel PTY, switches the local terminal to raw mode, and restores it -on every exit path. Press `Ctrl-]` followed by `.` to detach without stopping -the sandbox; use `--escape-char` to select another ASCII escape character. +`create` prepares a sparse ext4 COW disk but does not start the VMM. `start` uses direct kernel boot, records a PID-reuse-safe process identity, and commits `running` only after the Cloud Hypervisor API reports readiness. `stop` requests shutdown, then uses an identity-checked TERM-to-KILL fallback. `exec` uses the guest agent over private hybrid-vsock transport. -```bash -kumabox console NAME -kumabox console 123e4567-e89b-42d3-a456-426614174000 --escape-char '^A' -``` +`ps` prints a table with headers. `inspect` and every `--json` mode emit indented JSON. Progress goes to stderr; command results go to stdout. -Console requires terminal stdin. A concurrent `stop` closes the PTY session; -the console command does not hold the sandbox operation lock while relaying I/O. +## Configuration -Run a command inside a running sandbox with `exec`. Arguments are sent directly -to the guest without an implicit shell. Standard output and standard error are -streamed independently, and the guest command status becomes the local process -status. Standard input is closed by default; use `-i` to attach it. +Configuration precedence is: -```bash -kumabox exec NAME -- uname -a -kumabox exec -e FOO=bar NAME -- sh -c 'echo "$FOO"' -echo hello | kumabox exec -i NAME -- cat +```text +explicit flag > environment > explicit --config file > default ``` -The official Ubuntu image starts `kumabox-agent` on vsock port 1024. Early -userspace also applies the sandbox name as the guest hostname, so the serial -console and `hostname` report the same identity. +KumaBox never searches for an implicit configuration file. Root paths can be set with `--root-dir`, `--run-dir`, and `--log-dir`. All settings and environment variable names are documented in [configuration](docs/CONFIGURATION.md). -List active sandboxes with `ps`, or include created, stopped, failed, and -deleting records with `-a`: +## Architecture -```bash -kumabox ps -kumabox ps -a -kumabox ps -a --quiet -kumabox ps -a --json -``` +The repository uses root-level modules instead of `internal` or a generic `pkg` tree: -The table always includes headers and prints complete sandbox UUIDs that can be -passed directly to `rm`. `--quiet` writes only those UUIDs, one per line. JSON -uses the same complete resource facts as `create --json` and returns `[]` for -an empty result. +| Package | Responsibility | +|---|---| +| `cli` | Cobra command tree, argument validation, and presentation | +| `core` | Application services and concrete adapter assembly | +| `types` | Shared image and sandbox values; no capability interfaces | +| `images` | Source resolution, conversion, verification, and removal | +| `sandbox`, `disk` | Sandbox paths, locks, and writable COW disks | +| `vmm` | VMM contracts, launch plans, process identity, and backend registry | +| `vmm/cloudhypervisor` | Cloud Hypervisor process and API adapter | +| `agent` | Guest exec protocol and host/guest transports | +| `metadata` | Transaction contracts and SQLite implementation | +| `cgroup`, `storage`, `lock/flock` | Host resource adapters | -Inspect one sandbox by its exact name or complete UUID. The command always -writes indented JSON, including retained failure diagnostics when present: +The full ownership and dependency rules are in [architecture](docs/ARCHITECTURE.md). User-visible contracts are in [behavior](docs/BEHAVIOR.md). -```bash -kumabox inspect NAME -kumabox inspect 123e4567-e89b-42d3-a456-426614174000 -``` +## Documentation -Remove a non-running sandbox by its exact name or complete UUID: +Start with [docs/README.md](docs/README.md). Normative design documents, accepted decisions, runbooks, and the active roadmap are tracked in Git and reviewed with code. -```bash -kumabox rm NAME -kumabox rm 123e4567-e89b-42d3-a456-426614174000 --json -``` +## Contributing -Removal records durable `Deleting` intent before deleting the private disk. -If cleanup is interrupted, running the same command again resumes it. The -sandbox name and image reference are released together only after disk cleanup -succeeds. Text output is the removed sandbox's full UUID; `--json` returns its -ID and released name. Active lifecycle states are rejected until the sandbox -has been stopped with `kumabox stop`. - -## Reference material - -- Cocoon at `../cocoon@27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` is the - capability floor: match its lifecycle ordering and failure recovery, never - copy its package structure or its dual metadata backends. -- The pre-rewrite KumaBox source is available read-only from the protected tag - `pre-p12-rewrite-20260909` (and as an archive under `.rewrite-backup/`). It is - reference material for behaviour only; no code, types, schema or tests are - reused from it. +See [CONTRIBUTING.md](CONTRIBUTING.md). Changes to command behavior, architecture, persistent data, or guest protocols must update the corresponding normative document in the same commit. ## License diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8142ef3 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,121 @@ +# KumaBox 架构规范 + +> 状态:normative + +## 1. 结构 + +KumaBox 使用按能力划分的根级 Go 包,不使用 `internal`、通用 `pkg`、`utils` 或按“接口/结构体”种类划分的包。 + +```text +cmd/kumabox ──► cli ──► core + │ + ┌───────────┼─────────────┐ + ▼ ▼ ▼ + images sandbox vmm + │ catalog/disk │ + ▼ │ cloudhypervisor + metadata/sqlite ◄─┘ │ + ▲ ▼ + └──────── types ◄──── cgroup/agent +``` + +- `cmd/kumabox`:信号、进程退出码和入口。 +- `cli`:命令树、参数解析、展示和 stdout/stderr 分流。 +- `core`:跨模块应用流程、补偿顺序和具体 adapter 装配。 +- `types`:跨模块资源数据和值对象;不放接口、SQL 编码或 CLI DTO。 +- `images`:镜像来源无关的导入、验证、启动文件选择和删除规则。 +- `sandbox`、`disk`:sandbox 路径、锁和 writable disk。 +- `vmm`:backend 合同、registry、launch plan、process identity。 +- `vmm/cloudhypervisor`:Cloud Hypervisor 参数、进程、API 和 console adapter。 +- `agent`:guest exec 消息和传输。 +- `metadata`:事务合同;`metadata/sqlite` 是当前持久化实现。 + +`cli` 不编排跨模块事务。具体 adapter 不导入 `core` 或 `cli`。接口放在拥有能力的模块,或唯一消费该能力的包中。 + +## 2. 组装入口 + +每条命令加载一次不可变 `config.Config`。`core.OpenImages` 和 `core.OpenSandbox` 创建路径、SQLite store、catalog、disk、cgroup 和 VMM registry。模块收到自己的 Options 后不再读取环境或全局配置。 + +```text +defaults → explicit config file → environment → flags → Validate + ↓ + core assembly +``` + +## 3. 持久化与锁 + +SQLite 是资源事实源。文件系统保存大文件和运行产物,不替代 metadata 状态。 + +- image digest 锁串行化同一内容的发布和删除。 +- sandbox 实体锁串行化 create/start/stop/rm。 +- generation compare-and-swap 拒绝陈旧状态提交。 +- 慢 I/O 不放在 SQLite 写事务中。 +- 发布顺序为“持久意图 → 慢操作 → 验证宿主事实 → 短事务提交”。 +- 取消后的补偿使用独立且有界的 context。 + +## 4. 镜像流程 + +```text +resolve source → verify source digest/diffID → convert staging + → digest locks → recheck/reuse or atomic publish + → final artifact verification → metadata commit +``` + +manifest 层序保持 base-to-top;VMM 磁盘也按该顺序附加。guest overlay lowerdir 按 top-to-base 使用。重复 source digest 可以复用存储,但不能从 manifest 设备序列中删除。 + +## 5. Sandbox 状态机 + +```text +creating → created → starting → running → stopping → stopped + │ │ │ │ + └────── error ◄──────┴──────────┘ │ + start +created/stopped/error ── rm ──► deleting ── cleanup ──► removed +``` + +- `created` 表示资源已准备、从未启动。 +- `stopped` 表示至少成功启动过一次且 VMM 已退出。 +- `error` 保留失败 phase 和资源所有权。 +- `deleting` 是可重试的清理意图,最终事务同时释放 name、record 和 image pin。 + +## 6. VMM 与进程身份 + +`vmm.Backend` 定义所有 backend 必须具备的基础生命周期能力。`vmm.Registry` 是构造后不可变的显式实例。未来 Firecracker 实现同一合同;只有 snapshot 等真实可选能力出现时才增加窄的可选接口。 + +进程身份至少包含 PID、`/proc` starttime、host boot ID、sandbox ID、generation、binary 和 API socket。观察、信号和清理必须验证完整身份。Linux 信号路径使用 pidfd 固定目标。 + +Cloud Hypervisor readiness 需要同时满足:进程身份仍一致、API 可连接、`vm.info` 为 `Running`。socket 文件存在不等于成功。 + +## 7. Guest agent + +host 通过 Cloud Hypervisor hybrid-vsock UDS 连接 guest port 1024。应用协议是有大小上限的 NDJSON:`exec`、`stdin`、`stdin_close`、`started`、`stdout`、`stderr`、`exit`、`error`。guest EOF 没有 terminal frame 时是协议失败,不能当 exit 0。 + +当前协议行为与参考实现的 exec 子集兼容,但 boot profile、kernel 参数和 service 名称属于 KumaBox。 + +## 8. 网络接入原则 + +网络尚未实现。接入时沿用参考实现已经验证的顺序: + +```text +reserve sandbox identity + → prepare netns + → persist per-NIC cleanup intent + → CNI ADD + TAP/TC redirect + → persist resolved MAC/IP/network identity + → launch VMM inside netns +``` + +stop 保留 netns/TAP/IP,并 quiesce host veth;start 先 recover/unquiesce;rm 执行可重试 CNI DEL 和 netns 清理。任何失败都保留足以重试的逐 NIC 记录。网络事实进入共享 `types`,网络能力属于新的根级 `network` 包,编排仍在 `core.SandboxService`。 + +## 9. Cocoon 对齐规则 + +每项功能都检查参考实现的命令、持久事实、锁、进程/网络顺序、失败恢复和测试。默认采用经过验证的语义;若 KumaBox 选择不同方案,必须在对照表中记录原因和兼容影响。不得为了表面同名破坏现有更强的完整性或模块边界。 + +## 10. 测试层级 + +- 单元测试:值对象、解析、状态和纯计划。 +- adapter 测试:真实 SQLite/文件系统、本地 socket、进程和取消。 +- binary 测试:真实 `kumabox` 的输出、退出码和信号。 +- Linux runbook:KVM、Cloud Hypervisor、cgroup v2、EROFS、ext4、vsock,未来包括 CNI。 + +`make verify`、`make race` 和 `make lint` 是提交门禁。Linux 专属行为不能用 mock 结果冒充真实验收。 diff --git a/docs/BEHAVIOR.md b/docs/BEHAVIOR.md new file mode 100644 index 0000000..023accd --- /dev/null +++ b/docs/BEHAVIOR.md @@ -0,0 +1,129 @@ +# KumaBox 行为规范 + +> 状态:normative + +本文件只描述当前已实现行为。规划中的命令见 [ROADMAP.md](ROADMAP.md)。 + +## 1. 通用行为 + +- 资源引用接受完整 sandbox UUID 或精确名称,不接受模糊前缀。 +- 表格输出包含标题;JSON 使用两个空格缩进并以换行结尾。 +- stdout 只输出结果,stderr 输出进度和错误。 +- TTY 上进度使用 spinner;重定向时使用有限的普通文本行,不写控制字符。 +- SIGINT/SIGTERM 取消当前操作。已越过提交点的错误会明确保留资源并要求 inspect。 + +退出码: + +| 状态 | 含义 | +|---:|---| +| 0 | 成功 | +| 1 | 未分类内部失败或 guest command 通用失败 | +| 2 | 命令、flag 或参数数量错误 | +| 3 | 资源不存在 | +| 4 | 名称、状态或引用冲突 | +| 5 | 参数、主机、镜像或内容不兼容/损坏 | +| 6 | 产物暂不可用或 metadata store 超时 | + +`exec` 中 guest 进程的非零退出码直接成为本地退出码。 + +## 2. `doctor` + +`kumabox doctor [--fix] [--upgrade] [--subnet=CIDR]` 将参数和标准流交给 `kumabox-check`。无修复参数时只检查。安装、升级或修改宿主机只在用户显式传入相应参数时发生。 + +## 3. 镜像命令 + +### `image pull REF` + +从 registry 解析指定平台的 OCI manifest,验证 config、layer digest 和 diffID,转换并提交本地镜像。默认平台为当前架构对应的 `linux/amd64` 或 `linux/arm64`。 + +### `image import NAME PATH` + +支持 OCI layout 目录、OCI archive 和 Docker save archive。`--format auto|oci|docker` 控制解析器;auto 按内容检测。Docker archive 多镜像时可用 `--source-tag` 选择。压缩与否不依赖扩展名。 + +导入满足: + +1. source 内容完整校验; +2. layer 流式解包并转换为 EROFS; +3. boot whiteout/opaque/覆盖语义按 OCI 层序计算; +4. staging 产物在 digest 锁内原子发布; +5. 完整验证后才提交 metadata。 + +同一内容可复用已验证的受管产物。失败不会留下可见的成功 image record。 + +### 查询和删除 + +- `image list`,别名 `image ls`:表格;`--json` 输出完整数组。 +- `image inspect IMAGE`:缩进 JSON。 +- `image verify IMAGE`:重新验证 layer、EROFS 和 boot artifacts。 +- `image remove IMAGE...`,别名 `image rm`:删除名称;最后一个引用消失后清理产物。 + +被 sandbox pin 的镜像不能删除。 + +## 4. Sandbox 命令 + +### `create IMAGE --name NAME` + +可选资源 flags:`--cpus`、`--memory`、`--storage`、`--json`。默认 2 vCPU、1 GiB 内存和 10 GiB sparse COW;最小内存 512 MiB,最小存储 10 GiB。 + +流程: + +```text +validate → lock → resolve and pin image → reserve creating record + → create/format cow.raw → verify → CAS created +``` + +`created` 不代表 VMM 已启动。创建失败保留 `error` 记录和诊断,清理完成后可由 `rm` 删除。 + +### `start SANDBOX` + +只接受 `created`、`stopped` 或可恢复的 `starting`。启动前验证 pinned image、`overlay-v1` profile、kernel/initramfs、COW、KVM、cgroup 和 VMM binary。 + +```text +lock → CAS starting → prepare runtime/cgroup → launch + → persist process identity → wait vm.info Running → CAS running +``` + +启动失败会终止本次进程并清理 runtime;无法完整补偿时保留可诊断状态。重试同一 sandbox 不创建新身份或新 COW。 + +### `stop SANDBOX` + +对 `created` 和 `stopped` 幂等成功。`running` 先提交 `stopping`,向 Cloud Hypervisor 请求 `vm.shutdown`,随后对完全匹配的进程执行 SIGTERM,超过配置窗口后 SIGKILL。确认进程退出并清理 runtime/cgroup 后才提交 `stopped`。 + +中断后保持 `stopping`,再次执行同一命令继续收敛。 + +### `ps` + +默认只显示活动状态;`-a/--all` 包含所有持久记录。`--quiet` 只输出完整 UUID,`--json` 输出完整数组;二者互斥。查询不修改状态。 + +### `inspect SANDBOX` + +始终输出缩进 JSON,包括 immutable 资源规格、image digest、VMM、state、generation、时间和可选 failure。generation 是状态提交的单调版本,用于阻止陈旧操作覆盖新状态。 + +### `console SANDBOX` + +仅连接 `running` sandbox。命令验证当前 generation、process identity 和 VMM API 后打开 PTY;不会在 relay 期间持有实体锁。默认按 `Ctrl-]` 后 `.` 断开,可用 `--escape-char` 修改。断开 console 不停止 sandbox。 + +### `exec SANDBOX -- COMMAND` + +参数直接交给 guest,不隐式插入 shell。`-e/--env KEY=VALUE` 可重复;`-i/--interactive` 才连接 stdin,否则立即发送 `stdin_close`。stdout/stderr 独立透传,guest exit code 原样返回。 + +### `rm SANDBOX` + +拒绝删除活动状态;VMM runtime 和 cgroup 必须先由 `stop` 收敛。命令提交 `deleting`,清理 COW,最后在一个事务中删除 record/name 并释放 image pin。任一步失败都保留 `deleting` 供相同命令重试。 + +当前版本不会删除持久 `vmm.log` 目录。这是下一条 `logs` 切片必须修复的资源归属缺口:日志在 stop 后可读,但 rm 成功前必须进入可重试清理,不能永久成为孤儿。 + +## 5. 受管路径 + +默认路径: + +```text +/var/lib/kumabox/metadata.db +/var/lib/kumabox/images/... +/var/lib/kumabox/sandboxes//cow.raw +/run/kumabox/locks/... +/run/kumabox/sandboxes//... +/var/log/kumabox/sandboxes//vmm.log +``` + +data、run、log roots 必须是绝对、互不重叠且不经过非系统 symlink 的路径。 diff --git a/docs/COCOON-MAP.md b/docs/COCOON-MAP.md new file mode 100644 index 0000000..d4cc5db --- /dev/null +++ b/docs/COCOON-MAP.md @@ -0,0 +1,40 @@ +# KumaBox 与 Cocoon 能力对照 + +> 参考基线:Cocoon `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` +> +> 状态:living reference。每个功能切片完成时更新。 + +| 能力 | Cocoon 实现 | KumaBox 状态 | 结论 | +|---|---|---|---| +| CLI 生命周期 | `vm create/run/start/stop/list/inspect/console/exec/logs/rm/status` | 已有 create/start/stop/ps/inspect/console/exec/rm;logs 下一步 | 命令行为逐项对齐;KumaBox 当前不加 `vm` 中间层 | +| OCI image | OCI layer 转换、direct boot artifacts | registry/OCI/Docker save、digest/diffID/EROFS/boot 验证 | 已对齐机制;KumaBox 内容校验更严格 | +| Boot layout | RO EROFS layers + ext4 COW + overlay initramfs | `overlay-v1`,相同设备/层序机制 | 机制对齐,协议名使用 `kumabox.*` | +| 状态 | created/running/stopped/error 与转换 generation | creating/created/starting/running/stopping/stopped/error/deleting | 对齐用户状态;KumaBox 显式持久中间意图 | +| 进程身份 | PID/starttime、受管 socket/dir、收敛器 | 另加 boot ID、binary、generation,Linux pidfd | KumaBox 保留更强身份验证 | +| Cloud Hypervisor stop | OCI path: API shutdown → TERM → 5s → KILL | 相同主路径,可配置 grace | 已对齐 | +| Console | PTY relay、resize、escape detach | PTY relay、resize、`^].` detach | 已对齐当前需要的交互合同 | +| Guest exec | hybrid-vsock + bounded NDJSON frames | 独立实现兼容 exec 子集 | 已对齐子集;clone/reseed 消息未实现 | +| Logs | persistent per-VM log,tail/follow/reopen,delete 清理 log dir | log 已持久化,CLI 与 rm cleanup 未实现 | 下一条命令同时补齐读取和归属清理 | +| Registry/backends | Cloud Hypervisor + Firecracker | 显式 Registry;只有 Cloud Hypervisor | Firecracker 后续实现相同基础合同 | +| Cgroup | per-VM scope、CPU policy、cleanup/GC | per-sandbox scope、基础 CPU limit、cleanup | 基础对齐;完整 policy/GC 后续 | +| CNI network | reserve → netns → NIC intents → ADD/TAP/TC;stop quiesce/start recover/rm DEL | 未实现 | `logs` 后立即进入网络切片 | +| Run | create + start,网络和资源 flags 一次确定 | 未实现 | 网络持久模型完成后实现,避免返工 | +| Status/reconcile | durable + observed state、watch/event、dead process convergence | `ps` 只读 metadata | run 后增加显式 status/recovery | +| Snapshot/clone | capture/restore/hibernate/clone、lease 和 identity reseed | 未实现 | 网络稳定后按相同故障边界分阶段实现 | +| Metadata | JSON/SQLite engines | transaction contract + SQLite | 不复制双后端;保留模块合同测试 | +| Error model | sentinel/wrapped errors | stable code/class/context/committed/action | KumaBox 保留更完整公共错误合同 | +| Package layout | 能力包与 cmd handler 直接编排较多 | `cli → core → modules` | 不复制结构;行为和失败边界对齐 | + +## 网络对齐重点 + +Cocoon 已验证的关键点必须进入 KumaBox 首版网络: + +1. sandbox/VM identity 在创建 netns 和 TAP 前持久 reserve; +2. 每个 NIC 在 CNI ADD 前写 cleanup intent; +3. partial ADD/DEL 保留记录,允许同命令或 GC 重试; +4. VMM 在目标 netns 中启动; +5. stop quiesce host veth,避免 down TAP 引发软中断开销; +6. start recover netns/TAP/IP identity 并 unquiesce; +7. rm 只有在逐 NIC DEL、TAP 和 netns 清理完成后才释放资源记录。 + +KumaBox 第一版先实现 CNI,不同时实现 bridge 与 hot resize。接口在真实第二实现出现时扩展,不预先复制全部能力。 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..e94b7d2 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,65 @@ +# KumaBox 配置 + +> 状态:normative + +每次命令解析一个独立配置快照。优先级从高到低: + +```text +显式 flag > KUMABOX_* 环境变量 > --config 指定文件 > 默认值 +``` + +未传 `--config` 时不会搜索当前目录、用户目录或 `/etc`。配置文件按扩展名支持 YAML、JSON 和 TOML;未知字段或无效值立即失败。 + +## 根 flags + +| Flag | 配置键 | 默认值 | +|---|---|---| +| `--root-dir` | `paths.data` | `/var/lib/kumabox` | +| `--run-dir` | `paths.run` | `/run/kumabox` | +| `--log-dir` | `paths.log` | `/var/log/kumabox` | +| `--config` | — | 不读取文件 | + +## 配置键 + +| 键 | 环境变量 | 默认值 | +|---|---|---| +| `paths.data` | `KUMABOX_PATHS_DATA` | `/var/lib/kumabox` | +| `paths.run` | `KUMABOX_PATHS_RUN` | `/run/kumabox` | +| `paths.log` | `KUMABOX_PATHS_LOG` | `/var/log/kumabox` | +| `images.erofs_binary` | `KUMABOX_IMAGES_EROFS_BINARY` | `mkfs.erofs` | +| `images.parallelism` | `KUMABOX_IMAGES_PARALLELISM` | `min(4, host CPUs)` | +| `images.layer_size` | `KUMABOX_IMAGES_LAYER_SIZE` | 8 GiB | +| `images.unpacked_size` | `KUMABOX_IMAGES_UNPACKED_SIZE` | 16 GiB | +| `images.boot_size` | `KUMABOX_IMAGES_BOOT_SIZE` | 512 MiB | +| `images.archive_size` | `KUMABOX_IMAGES_ARCHIVE_SIZE` | 32 GiB | +| `metadata.busy_timeout` | `KUMABOX_METADATA_BUSY_TIMEOUT` | `50ms` | +| `metadata.retry_limit` | `KUMABOX_METADATA_RETRY_LIMIT` | `5s` | +| `sandbox.ext4_binary` | `KUMABOX_SANDBOX_EXT4_BINARY` | `mkfs.ext4` | +| `sandbox.cleanup_timeout` | `KUMABOX_SANDBOX_CLEANUP_TIMEOUT` | `10s` | +| `vmm.default` | `KUMABOX_VMM_DEFAULT` | `cloud-hypervisor` | +| `vmm.cgroup_parent` | `KUMABOX_VMM_CGROUP_PARENT` | `/sys/fs/cgroup/kumabox.slice` | +| `vmm.cloud_hypervisor.binary` | `KUMABOX_VMM_CLOUD_HYPERVISOR_BINARY` | `cloud-hypervisor` | +| `vmm.cloud_hypervisor.startup_timeout` | `KUMABOX_VMM_CLOUD_HYPERVISOR_STARTUP_TIMEOUT` | `10s` | +| `vmm.cloud_hypervisor.stop_grace` | `KUMABOX_VMM_CLOUD_HYPERVISOR_STOP_GRACE` | `5s` | +| `vmm.cloud_hypervisor.abort_grace` | `KUMABOX_VMM_CLOUD_HYPERVISOR_ABORT_GRACE` | `3s` | + +## YAML 示例 + +```yaml +paths: + data: /srv/kumabox/data + run: /run/kumabox + log: /srv/kumabox/log +images: + parallelism: 4 +metadata: + busy_timeout: 100ms + retry_limit: 5s +vmm: + cgroup_parent: /sys/fs/cgroup/kumabox.slice + cloud_hypervisor: + binary: /usr/local/bin/cloud-hypervisor + startup_timeout: 15s +``` + +模块不能直接读取这些环境变量。新增运行策略必须先决定它是稳定协议常量,还是进入本配置结构的部署策略。 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..3c8af8c --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,55 @@ +# KumaBox 决策记录 + +> 状态:normative。新决策追加;替代旧决策时明确写出关系。 + +## D001 — daemonless CLI 是当前执行模型 + +每条命令独立加载配置、打开 store、获取跨进程锁、完成操作并退出。当前不提供 daemon、gRPC 或本地控制 socket。运行中的 VMM 自身是独立进程。 + +## D002 — 使用根级能力包 + +不使用 `internal`、通用 `pkg`、`utils` 或按声明种类拆包。`core` 是应用服务和 adapter 组装层,不放在 `cmd` 下。 + +## D003 — `types` 只保存共享数据和值对象 + +接口属于拥有能力的模块,或其真实消费方。CLI DTO、SQL encoding、Cobra flags 和 terminal 状态不进入 `types`。 + +## D004 — SQLite 是当前 metadata 实现 + +`metadata` 提供 transaction contract,`metadata/sqlite` 实现它。资源事实以短事务提交,慢文件和进程操作不持有写事务。跨进程互斥依赖实体 flock 和 generation CAS。 + +## D005 — 镜像使用内容寻址和完整校验 + +导入必须验证 source digest、diffID、EROFS 和 boot artifacts。缓存命中不能只检查路径或大小。发布先进入 staging,在 digest 锁内复查并原子替换,最后提交 metadata。 + +## D006 — OCI boot contract 使用 `overlay-v1` + +可启动镜像显式声明 `io.kumabox.boot.profile=overlay-v1`。kernel 参数和 disk serial 使用 `kumabox.*` 命名。未声明 profile 的旧镜像可以导入和 inspect,但不能 start。 + +## D007 — `created` 与 `stopped` 分离 + +`created` 表示资源已准备且从未启动;`stopped` 表示 VMM 曾运行并已退出。中间状态是持久恢复意图,不是短暂展示值。 + +## D008 — VMM 使用基础 Backend + 显式 Registry + +`vmm.Backend` 保留所有 VMM 都需要的进程级生命周期能力。Registry 在构造时拒绝 nil、重复和类型错误,之后不可变。snapshot/pause 等只有真实实现出现时才增加可选接口。 + +## D009 — 进程操作验证完整身份 + +PID 不构成所有权。信号和清理必须核对 starttime、host boot ID、sandbox ID、generation、binary 和受管 endpoints,并在 Linux 使用 pidfd 固定目标。 + +## D010 — Agent exec 对齐参考实现的 NDJSON 子集 + +消息语义兼容 `exec/stdin/stdin_close/started/stdout/stderr/exit/error`,传输使用 private hybrid-vsock。KumaBox 不复用参考产品名称、boot ABI 或源码。 + +## D011 — 网络必须进入 sandbox 生命周期 + +网络不能只是 VMM argv 的附加字段。sandbox identity 先 reserve,随后建立可回收的逐 NIC intent 和 host plumbing;start/stop/rm 分别负责 recover、quiesce 和 cleanup。网络在 `run` 之前实现,避免重复设计持久模型和补偿。 + +## D012 — Cocoon 是能力基线,不是包结构模板 + +每项主线功能核对固定 commit 的命令、流程、状态、失败恢复和测试。默认保持行为对齐;KumaBox 已有更强的内容完整性、错误分类或模块边界时保留,并在对照表记录差异。 + +## D013 — 规范文档进入 Git + +当前规范、决策、活动提案和 Linux runbook 与代码一起审查。过时设计稿不进入仓库。中文是当前设计规范语言;代码注释、公共 API、CLI help 和根 README 使用英文。 diff --git a/docs/HOST.md b/docs/HOST.md new file mode 100644 index 0000000..55bbe09 --- /dev/null +++ b/docs/HOST.md @@ -0,0 +1,49 @@ +# 主机要求与环境自检 + +> 状态:normative + +## 开发与静态测试 + +macOS 和 Linux 都能运行 `make verify`、`make race` 和 `make lint`。不需要 root、KVM 或外部 VMM。测试使用临时目录、SQLite、真实本地 socket 和 fake formatter。 + +## 真实运行 + +Sandbox 启动当前只支持 Linux。需要: + +- `/dev/kvm` 可用且当前用户有权限; +- cgroup v2,并允许在配置的 parent 下创建 scope、写 CPU 控制和迁移进程; +- Cloud Hypervisor 可执行文件; +- `mkfs.erofs` 1.8 或更新版本; +- `mkfs.ext4`; +- 支持 pidfd 和 vsock 的内核; +- 足够的 data、run、log 目录权限。 + +`kumabox doctor` 检查当前主机。普通检查只读;`--fix` 和 `--upgrade` 是显式的宿主修改授权。 + +```bash +sudo kumabox doctor +sudo kumabox doctor --fix +sudo kumabox doctor --upgrade +``` + +## Guest 镜像 + +可启动 OCI 镜像必须: + +- 含 `/boot/vmlinuz*` 与 `/boot/initrd.img*` regular files; +- 声明 `io.kumabox.boot.profile=overlay-v1`; +- initramfs 能识别 `kumabox.layers`、`kumabox.cow` 和相应 virtio disk serial; +- 启动 `kumabox-agent` 并监听 guest vsock port 1024,才能使用 `exec`。 + +参考构建位于 [`oci-images/ubuntu`](../oci-images/ubuntu/README.md)。 + +## 网络阶段的新增要求 + +网络尚未实现。接入 CNI 时将增加: + +- CNI plugin binaries,默认 `/opt/cni/bin`; +- 至少一份 conflist,默认 `/etc/cni/net.d`; +- 创建持久 netns、TAP、veth 和 TC redirect 的权限; +- CNI ADD/DEL 所需的宿主 sysctl 与防火墙配置。 + +这些检查必须先进入 `doctor`,再开放网络 flags,避免命令接受参数后才静默降级。 diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..c6c1a5a --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,44 @@ +# KumaBox 性能规范 + +> 状态:normative + +性能优化必须先测量,再修改。完整性校验、状态提交顺序、资源身份验证和失败恢复不能为了基准数字被削弱。 + +## 对齐方法 + +Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` 是比较基线。比较时固定:host、kernel、Cloud Hypervisor、镜像内容、CPU、内存、存储、网络模式和缓存冷热状态。记录两边的实际参数,不能依赖不同默认值。 + +## 当前基准场景 + +镜像导入: + +- 全新导入; +- 全缓存命中; +- 单 layer 损坏修复; +- 多 image 共享 layer; +- manifest 重复 layer; +- 1、2、4、8 worker。 + +Sandbox 生命周期: + +- create sparse COW 与 ext4 格式化; +- start 到 Cloud Hypervisor API Running; +- start 到首次 agent exec; +- stop 到 process/cgroup/runtime 清理完成; +- 100 和 1000 条 metadata 查询。 + +未来网络阶段增加:CNI ADD、首次出网、stop quiesce、restart recover、CNI DEL 和多 NIC 成本。 + +## 指标 + +至少记录 wall time、CPU time、读取/写入字节、hash/解压/EROFS 时间、锁等待、峰值 RSS、goroutine 和 FD 数。microVM 指标必须区分: + +```text +process launched → VMM API Running → agent exec ready → workload ready +``` + +## 当前状态 + +结构整理期间的镜像 benchmark 因缺少真实 Linux 环境暂缓,尚未提交性能优化。恢复测试环境后先建立基线和 profile;只有可重复收益才修改热路径。 + +允许的候选方向包括单次导入内 source digest 去重、操作内校验结果复用和减少重复全文件摘要,但最终 digest、diffID、EROFS 与 boot artifact 验证必须保留。 diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..b61df07 --- /dev/null +++ b/docs/PRODUCT.md @@ -0,0 +1,61 @@ +# KumaBox 产品范围 + +> 状态:normative + +KumaBox 是面向 AI agent 工作负载的 daemonless microVM sandbox runtime。每条 CLI 命令独立打开持久化数据、取得资源锁、完成操作并退出;运行中的 sandbox 由 VMM 进程承载。 + +## 当前目标 + +KumaBox 当前主线先完成单机 Linux 上的 OCI microVM 闭环: + +1. 从 registry、OCI layout/archive 或 Docker save archive 导入镜像。 +2. 将镜像层转换为只读 EROFS,并提取直接内核启动所需的 kernel/initramfs。 +3. 创建带私有 sparse ext4 COW 的 sandbox。 +4. 使用 Cloud Hypervisor 启动、停止和重新启动 sandbox。 +5. 通过 console 与 guest agent 进入 guest。 +6. 在完整生命周期稳定后接入 CNI 网络,再做 snapshot、clone 和第二 VMM backend。 + +## 对齐基线 + +Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` 是能力与行为基线。每个切片开始前都要核对其真实实现、测试和失败恢复,而不是只比命令名称。 + +默认对齐: + +- Docker 风格的镜像和 VM 命令面; +- OCI layer + ext4 COW + direct boot 机制; +- `created`、`running`、`stopped` 等用户可理解的生命周期; +- PID/starttime/boot ID 进程身份保护; +- guest exec 的 NDJSON 消息语义; +- Cloud Hypervisor shutdown 后 TERM→KILL 的停止路径; +- CNI netns/TAP、持久网络身份、stop quiesce、start recover、delete cleanup; +- snapshot、clone、status/reconcile 等后续能力的失败边界。 + +允许有意不同: + +- KumaBox 使用根级模块和 `core` 应用服务,不复制 Cocoon 的包结构。 +- `types` 只放共享数据和值对象,不收集接口。 +- `errdefs` 保留稳定错误码、提交状态和完整错误链。 +- 镜像缓存必须验证 digest、diffID 和最终产物,不能退化为只检查文件存在。 +- guest boot 参数和 profile 使用 `kumabox.*` 命名,不冒充其他产品协议。 + +所有差异记录在 [COCOON-MAP.md](COCOON-MAP.md)。 + +## 当前不承诺 + +以下能力尚未实现,不能在帮助、README 或输出中描述成可用: + +- guest 网络和多网卡; +- snapshot、restore、hibernate、clone; +- Firecracker; +- cloud image、UEFI 和 Windows; +- 热插磁盘、文件系统或 NIC; +- 跨节点控制面、daemon 或远程 API; +- 生产级全节点 GC、admission 和容量调度。 + +## 用户合同 + +- stdout 只承载命令结果,stderr 承载进度和诊断。 +- JSON 使用稳定字段名和缩进格式。 +- 状态只在相应宿主事实完成后提交。 +- 失败后保留足够的持久意图,使同一命令可以安全重试。 +- 不向未经身份确认的宿主进程发送信号,不删除归属不确定的资源。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4911292 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,31 @@ +# KumaBox 文档 + +本目录随代码进入 Git。行为、架构、配置或验收方式发生变化时,相关文档必须在同一提交中更新。 + +## 当前规范 + +| 文档 | 内容 | +|---|---| +| [PRODUCT.md](PRODUCT.md) | 产品范围、能力基线与明确不做的内容 | +| [ARCHITECTURE.md](ARCHITECTURE.md) | 包边界、依赖方向、事实归属和关键流程 | +| [BEHAVIOR.md](BEHAVIOR.md) | 当前命令、状态、输出、错误与恢复语义 | +| [CONFIGURATION.md](CONFIGURATION.md) | 配置文件、环境变量、flags、默认值与优先级 | +| [HOST.md](HOST.md) | 主机依赖与 `doctor` 检查范围 | +| [PERFORMANCE.md](PERFORMANCE.md) | 性能口径、基准场景和优化约束 | +| [ROADMAP.md](ROADMAP.md) | 已完成能力、下一条命令以及网络等后续阶段 | +| [DECISIONS.md](DECISIONS.md) | 仍然有效的架构和产品决策 | +| [COCOON-MAP.md](COCOON-MAP.md) | 与参考实现的能力对齐状态和有意差异 | + +## 实施资料 + +- [proposals/s3-sandbox.md](proposals/s3-sandbox.md):当前沙箱主线的已实现范围与后续切片。 +- [runbooks/s2-oci.md](runbooks/s2-oci.md):Linux 镜像导入验收。 +- [runbooks/s3-create.md](runbooks/s3-create.md):Linux 沙箱生命周期验收。 +- [architecture-diagrams.md](architecture-diagrams.md):当前代码对应的简图。 +- [releasing.md](releasing.md):发布流程。 + +[REFACTORING.md](REFACTORING.md) 记录 2026-09 的结构整理及验收结果。该轮结束后它不再决定功能开发顺序;后续主线以 `ROADMAP.md`、当前行为规范和逐功能评审为准。 + +## 文档语言 + +设计规范以中文维护,代码注释、公共 Go API 注释、命令帮助和根 README 使用英文。面向国际用户的完整英文文档可在功能面稳定后补充,中文规范仍是当前设计评审的事实来源。 diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md new file mode 100644 index 0000000..104d797 --- /dev/null +++ b/docs/REFACTORING.md @@ -0,0 +1,547 @@ +# KumaBox 架构改造计划 + +> 状态:**archived**(结构整理记录;R8 完成后不再决定主线功能顺序) +> +> 形成日期:2026-09-18 +> +> 代码基线:KumaBox `3766f75` +> +> 对照基线:Cocoon `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d`,guest agent `v0.2.3` +> +> 相关:包边界见 `ARCHITECTURE.md`;已批准决策见 `DECISIONS.md`;功能阶段见 `ROADMAP.md` +> +> 2026-09-21:项目负责人决定 R8 完成后回到功能主线;后续以 `ROADMAP.md` 和逐功能评审为准。R6 性能实测仍等待 Linux 环境,不因归档而视为已完成。 + +## 1. 目的与执行规则 + +这轮改造只处理现有代码的结构、抽象、配置、错误、性能与测试质量,不增加新的用户功能。目标是在保留当前 CLI 行为、持久化数据和 guest 协议的前提下,使代码职责清晰、可复用、可扩展,并避免为了尚不存在的需求提前制造接口和包。 + +后续实施必须遵守以下规则: + +1. 严格按本文第 3 节顺序逐节推进,同一时间只改一节。 +2. 每节写代码前,先向项目负责人说明具体文件、类型、调用流程、兼容性和验收方法;得到明确确认后再实施。 +3. 每节单独提交。提交前执行该节列出的测试以及 `make verify`;并发代码还要执行 `make race`。 +4. 重构不得改变 CLI 参数、JSON 字段、metadata schema、状态机、磁盘布局或 guest 协议,除非该节明确列出并再次获得批准。 +5. 不引入 `internal`、通用 `pkg`、`utils`、`common` 或按声明种类划分的包。 +6. `types` 只保存跨模块共享的数据和值对象,不保存接口、CLI flag 语义、存储编码或具体实现。 +7. 接口放在拥有该能力的模块,或者真实消费该能力的包中;不为了“以后也许有第二个实现”提前拆接口。 +8. 文件围绕完整职责拆分。禁止为了一个结构体、一个接口或一两个方法单独建文件。 +9. 关键导出类型、字段、方法和复杂流程补充英文注释;涉及顺序、锁或提交边界时在对应源码附近放简短 ASCII 图。 +10. 对照实现只是事实来源,不是必须复制的规范。安全性、类型安全或可维护性更好的现有设计应保留。 + +## 2. 已确定的边界 + +以下结论已经评审,不在实施时重新讨论: + +- 保留根级 `core` 作为应用服务与具体适配器的组装层;CLI 不直接编排跨模块业务流程。 +- 保留 `core.SandboxService` 作为 sandbox 应用服务,不创建 `lifecycle`、`console` 或 `exec` 等小包。 +- 保留当前 `vmm.Backend` 基础接口。它描述每个 VMM 后端都必须提供的进程级能力,不立即拆成多个小接口。 +- snapshot、pause、restore 等未来能力只有在真实实现出现时才增加可选接口。 +- `disk`、`vmm`、`images`、`metadata` 等包拥有各自能力;`types` 不收集这些接口。 +- `cmd/kumabox` 只保留进程入口;命令树、参数解析和展示继续位于 `cli` 及其子包。 +- 不降低镜像内容校验强度来换取速度。性能优化必须保留 digest、diffID 和最终产物验证。 +- Agent 当前的一连接一 goroutine 模型可以保留;最大 session 数不是本轮的前置改造。 + +## 3. 实施顺序 + +```text +R1 错误链正确性 + ↓ +R2 集中配置与显式 VMM Registry + ↓ +R3 SandboxService 内部重组 + ↓ +R4 types 与 CLI 边界清理 + ↓ +R5 公共 CLI 进度渲染 + ↓ +R6 镜像导入性能测量与优化 + ↓ +R7 VMM、Agent、CLI 测试补强 + ↓ +R8 文档与 CI 治理 +``` + +R1–R5 是行为保持型重构。R6 必须先建立基准再决定具体优化。R7、R8 在前面结构稳定后收口。 + +## 4. R1:错误链正确性 + +### 4.1 现状 + +`errdefs.Error` 提供稳定错误码、资源、提交状态和原因,能力比对照实现依赖哨兵错误与 `fmt.Errorf` 的方式更完整,应当保留。 + +当前 `errdefs.Context` 在收到已经分类的 `*errdefs.Error` 时,把整个旧错误再次包装进 `Cause`。外层格式化时会重复输出错误码,例如: + +```text +ARTIFACT_UNAVAILABLE: ... ARTIFACT_UNAVAILABLE: ... +``` + +`Retry` 字段目前没有稳定的写入者或消费方,公共语义不成立。 + +### 4.2 改造 + +- 调整 `errdefs.Context`:保留已有 `Class`、`Code`、`Entity`、`Committed` 等分类,只在原因链中增加一次上下文,不把完整已格式化的 `Error` 再嵌进去。 +- `Error` 分开保存“用于展示的直接原因”和“供 `errors.Is/As` 遍历的原始错误树”。已有分类被重新加上下文时,展示原因从旧分类的 `Cause` 开始;unwrap 仍指向传入的完整错误,从而保留原分类、哨兵和并列 cleanup/unlock/report 错误。 +- 对 `errors.Join` 做显式处理:展示时把命中的旧分类节点替换为它的直接原因,并保留其他非空分支;遍历时保留原始 join 树。不能通过截取 `Error()` 字符串或匹配错误文本实现。 +- 非空的新 `operation/entity/phase/action` 覆盖旧值,空值保留旧值;`Committed` 继续只能从 false 前进到 true。 +- 明确 `Error()`、`Unwrap()`、`errors.Is` 和 `errors.As` 的合同。 +- 删除 `Retry` 字段。全仓核对确认它只有声明,没有生产者、消费者或持久化用途;重试建议继续由 `Action` 和状态机语义表达。 +- 保留各模块使用 `%w` 添加局部上下文的方式,禁止通过字符串匹配错误。 + +### 4.3 不做 + +- 不改现有错误码名称和 CLI 退出码。 +- 不退回只有哨兵错误的模型。 +- 不引入第三方错误框架。 + +### 4.4 验收 + +- 新增 `errdefs/error_test.go`,覆盖 nil、未分类错误、单层分类、多层 `Context`、空字段继承和 `Committed` 单向变化。 +- 多层 `Context` 只打印一次分类码,原 `Error` 不被修改。 +- joined classified + cleanup/unlock 场景中,分类码、主原因和并列错误各打印一次。 +- `errors.Is` 仍能匹配原始原因、原分类错误和 join 中的并列错误;`errors.As` 返回最外层最新上下文。 +- `CodeOf` 对普通、嵌套和 joined 错误保持稳定。 +- `cli/root_test.go` 验证各错误码的退出码映射没有变化。 +- 已提交与未提交错误的 CLI 映射保持不变。 +- `go test ./errdefs ./core ./cli/...` 与 `make verify` 通过。 + +### 4.5 实施记录 + +- 2026-09-18,提交 `19f8456`:分离诊断 cause 与完整 unwrap 树,删除无消费者的 `Retry`,joined 并列错误保持可见和可匹配。 +- 证据:定向测试、完整 `make verify`(race、双平台 vet、build)和双平台 `make lint` 全部通过,lint 为 0 issue。 +- 遗留:无;R1 完成。下一节为 R2,开始前仍需单独确认配置来源、结构和 Registry 方案。 + +## 5. R2:集中配置与显式 VMM Registry + +### 5.1 现状与对照 + +对照实现使用顶层 `config.Config` 汇总目录、VMM 二进制、超时、并发、网络和 cgroup 参数,并采用“默认值 → 配置文件 → 环境变量 → flag → Validate”的加载顺序。这一职责划分合理。 + +KumaBox 的运行策略目前散落在 CLI、`core`、`vmm/cloudhypervisor`、Agent 和镜像代码中。`core` 的 VMM factory 使用包级可变注册表,不利于测试隔离,也会让未来多后端装配依赖隐式初始化顺序。 + +### 5.2 目标结构 + +增加根级 `config` 包。配置按现有模块分组,不建立一层只有转发作用的 profile 或 settings 类型: + +```go +type Config struct { + Paths storage.Roots + Images Images + Metadata Metadata + Sandbox Sandbox + VMM VMM +} +``` + +职责固定为: + +- `Paths`:复用 `storage.Roots`,保存 data、run、log 根目录,不复制第二套路径值类型。 +- `Images`:`mkfs.erofs` 路径、导入并发度,以及 layer/unpacked/boot/archive 大小上限。 +- `Metadata`:SQLite busy timeout 和整个写事务重试预算。 +- `Sandbox`:`mkfs.ext4` 路径和失败补偿 cleanup timeout。 +- `VMM`:默认后端、cgroup parent,以及 Cloud Hypervisor binary、startup timeout、stop grace、abort grace。 + +这些值保留在各模块的 `Options` 中执行;`config.Config` 只汇总和校验,`core` 显式转换。例如 `config.Images` 转成 `images.Options` 与 `erofs.Options`,`config.Metadata` 转成 `sqlite.Options`。模块不能反向读取全局配置。 + +配置流: + +```text +defaults + ↓ +config file(若本节批准支持) + ↓ +environment + ↓ +CLI flags + ↓ +Config.Validate + ↓ +core.New(...) + ↓ +各模块 Options +``` + +配置加载建议采用 Cobra + Viper,但不使用 Viper 的包级全局实例: + +- 每次 `cli.Execute` 创建独立 `viper.New()`,测试和多次进程内调用互不污染。 +- `--config FILE` 是唯一配置文件入口;不从当前目录或用户目录隐式搜索,避免 root CLI 意外读取陌生配置。 +- 文件按扩展名支持 YAML、JSON 和 TOML;显式文件缺失、不可读或字段非法都立即失败。 +- 环境变量使用 `KUMABOX_` 前缀,层级中的点转换成下划线,例如 `vmm.cloud_hypervisor.binary` 对应 `KUMABOX_VMM_CLOUD_HYPERVISOR_BINARY`。 +- 现有 `--root-dir`、`--run-dir`、`--log-dir` 保持;本节新增 `--config`。其他运行参数先通过配置文件或环境变量提供,避免根命令堆积低频 flags。 +- 不支持热重载。每条 daemonless CLI 命令加载、校验一次,随后使用不可变的 `Config` 快照。 + +优先级固定为:显式 flag > 环境变量 > 显式配置文件 > 默认值。未指定 `--config` 时不读文件,不报缺失错误。 + +VMM 注册改为不可变的显式实例: + +```go +backend, err := cloudhypervisor.New(...) +registry, err := vmm.NewRegistry(backend) +service := core.NewSandboxService(..., registry) +``` + +`Registry` 与 `Backend` 放在现有 `vmm/backend.go`,不为一个注册表单独制造小文件。构造函数一次性拒绝 nil backend、非法类型、类型重复或类型不匹配,之后只提供按 `types.VMMType` 查询和数量检查;不暴露运行期 `Register`,也没有包级全局 map。Registry 不负责编排 start/stop。 + +`core/vmm.go` 保留具体适配器装配职责:从 `Config` 创建 cgroup manager 和 Cloud Hypervisor driver,再构造 Registry。未来增加 Firecracker时只在这里追加具体构造,不修改 Registry 和 SandboxService。 + +### 5.3 边界 + +- 稳定协议值继续作为对应包常量,例如 boot profile、kernel 参数名、metadata schema version。 +- 可部署策略进入配置,例如二进制路径、用户可感知的超时、并发数和资源上限。 +- guest agent vsock 端口、NDJSON frame 上限、hybrid-vsock reply 上限、EROFS block size、ext4 magic、Cloud Hypervisor API response 上限、探测轮询间隔和 cgroup CFS period 保持模块常量;它们是协议、安全边界或内部算法,不是部署配置。 +- `config` 不 import `core`、CLI 或具体 adapter;`core` 将配置转换为各模块 Options。 +- 本节不引入反射式 DI 框架,继续使用显式构造函数。 +- 不为旧的 `core.OpenImages(ctx, roots)`、`core.OpenSandbox(ctx, roots, ...)` 保留兼容 wrapper;全仓调用和测试直接迁移为显式 `Config`,避免两套装配入口。 +- 本节需要新增 Viper 依赖;必须使用实例 API,禁止 global Viper、`init()` 和隐式注册。 + +### 5.4 验收 + +- 所有运行策略硬编码都有“保留为协议常量”或“迁移到配置”的明确归属。 +- 两个独立 Registry 测试实例互不影响。 +- 缺少、重复或未知 VMM 后端均返回稳定错误。 +- 配置测试覆盖 defaults、文件、环境变量、flag 四层优先级,显式缺失文件、非法 duration、负数/零上限和路径重叠。 +- 两次 `cli.Execute` 使用不同环境和 flags 时没有跨调用配置泄漏。 +- 配置文件与环境变量能实际传到 images、SQLite、disk、cgroup 和 Cloud Hypervisor 构造器,不只停留在 DTO。 +- 默认 CLI 行为与当前版本一致。 +- 配置表驱动测试、Registry 测试、现有 CLI 集成测试、`make verify` 和 `make lint` 通过。 + +### 5.5 实施记录 + +- 2026-09-19,提交 `bc8a559`:增加调用级 `config.Config` 与显式 `--config`,固定优先级为 flag、环境变量、显式文件、默认值;CLI 每次执行使用独立 Viper 实例。 +- `core.OpenImages` 与 `core.OpenSandbox` 只接受验证后的配置快照,并把 image limits、并发度、SQLite 超时、ext4 formatter、cleanup timeout、cgroup parent 和 Cloud Hypervisor lifecycle 参数显式传给各模块 Options。 +- 删除包级 VMM factory map,增加构造后不可变的 `vmm.Registry`;构造时拒绝 nil、typed nil、非法和重复 backend,查询时区分损坏的持久化类型与本机缺少的 backend。 +- 证据:配置四层优先级、未知字段、非法值、重叠路径、CLI 调用隔离、配置到 image adapter 的集成测试,以及 Registry 和各 adapter Options 测试通过;完整 `make verify` 和 Linux/Darwin `make lint` 均通过,lint 为 0 issue。 +- R2 完成。下一节为 R3;开始前需要单独确认 `SandboxService` 的具名依赖与文件重组方案。 + +## 6. R3:SandboxService 内部重组 + +### 6.1 现状与对照 + +对照实现把大量 create/start/stop/remove 编排放在 `cmd/vm` 和宽 `Hypervisor` 接口中。KumaBox 当前的 `CLI → core.SandboxService → 模块` 依赖方向更清楚,应当保留。 + +问题在于 `core/sandbox.go` 已同时容纳服务定义、依赖、查询、存储生命周期、VMM 生命周期、console 和 exec,阅读与修改成本过高;构造函数位置参数也过多。 + +### 6.2 文件组织 + +只在 `core` 包内按完整职责重组,不增加新包: + +```text +core/ + sandbox.go SandboxService、Dependencies、构造与公共查找 + sandbox_storage.go create、remove、磁盘与 metadata 补偿 + sandbox_runtime.go start、stop、恢复、console、exec +``` + +若实际代码规模表明两个文件即可表达完整职责,应减少文件,而不是机械采用三个文件。 + +### 6.3 依赖构造 + +使用具名依赖结构替换过长的位置参数: + +```go +type SandboxDependencies struct { + Catalog SandboxCatalog + Images ImageCatalog + Disks disk.Store + VMMs *vmm.Registry + Cgroups cgroup.Manager + // 仅列 SandboxService 真正消费的能力。 +} +``` + +接口仍遵循消费方所有原则。若某接口只被 `core.SandboxService` 消费,可以继续定义在 `core`;若它就是某模块稳定公开的能力,则使用模块自己的接口,避免同一能力出现两份近似合同。 + +### 6.4 流程约束 + +重组不得改变以下顺序: + +```text +实体锁 + ↓ +重读并校验 generation/state + ↓ +持久化操作意图 + ↓ +执行宿主副作用 + ↓ +校验真实结果 + ↓ +generation-fenced 最终提交 + ↓ +释放实体锁 +``` + +`console` 和长时间 `exec` 不得在数据转发期间持有实体操作锁。 + +### 6.5 验收 + +- 公开的 `SandboxService` 行为和方法保持兼容。 +- 不出现 `lifecycle`、`console`、`exec` 新包,也不出现单声明文件。 +- 构造依赖可从一个文件完整看出。 +- create/remove/start/stop/console/exec 原有测试全部通过,`make race` 通过。 + +### 6.6 实施记录 + +- 2026-09-19,提交 `a31d591`:`SandboxService` 改用包内具名依赖对象,同一 sandbox catalog 只注入一次,构造时统一校验 adapter、默认 VMM 与 cleanup policy。 +- `core/sandbox.go` 只保留服务定义、组装和查询;存储生命周期进入 `sandbox_storage.go`,运行生命周期、console 和 exec 进入 `sandbox_runtime.go`,没有增加新包。 +- 测试按相同职责拆分,并增加依赖缺失、默认 reporter、ID 生成器和时钟的构造测试;锁、generation、提交与补偿顺序保持原测试覆盖。 +- `make race`、完整 `make verify` 及 Linux/Darwin `make lint` 全部通过,lint 为 0 issue。R3 完成,下一节 R4 实施前需单独确认类型与 CLI 边界方案。 + +## 7. R4:types 与 CLI 边界清理 + +### 7.1 原则 + +`types` 表达可跨模块传递、持久化或稳定共享的领域事实。CLI 负责命令语法、flag、终端和展示。应用层请求只有在多个模块确实共享时才进入 `types`。 + +### 7.2 改造 + +- 移除共享类型校验错误中的 `--cpus`、`--memory`、`--storage` 等 flag 名称。 +- 共享类型返回领域错误,例如 `CPU count must be positive`;CLI 将它映射为具体 flag 错误。 +- CLI 将 `KEY=VALUE` 解析成 `map[string]string`,业务层与 Agent 不解析 CLI 字符串。 +- CLI 根据是否传递 stdin 表达交互输入,不向领域层传递 `Interactive` 布尔语义。 +- exec 使用中性命令模型: + +```go +type Command struct { + Args []string + Env map[string]string +} +``` + +- 只有在 `core`、Agent client 或其他模块共同消费 `Command` 时才把它放进 `types`;否则留在最窄的消费边界。 +- CLI 的 JSON/table DTO 继续留在 `cli/sandbox`,不进入 `types`。 + +### 7.3 不做 + +- 不把接口迁进 `types`。 +- 不建立 `apis` 包;当前没有稳定的 HTTP、gRPC 或 CRD wire contract。 +- 不让持久化模型直接承担 CLI 输出格式。 + +### 7.4 验收 + +- `types` 不含 flag 名称、Cobra 类型、terminal 状态或具体 JSON 展示 DTO。 +- 环境变量重复、空键、非法格式在 CLI 边界有表驱动测试。 +- exec 的 stdin、stdout、stderr 和退出码行为保持不变。 +- `go test ./types ./cli/sandbox ./core ./agent/...` 与 `make verify` 通过。 + +### 7.5 实施记录 + +- 2026-09-19,提交 `bcb8f54`:用共享的 `types.Command` 替换带 CLI 语义的 `ExecConfig`;该类型并入现有 sandbox 模型文件,未增加单声明文件。 +- CLI 负责把重复的 `KEY=VALUE` 参数转换为 map,后出现的同名变量覆盖先出现的值;core 与 Agent client 只消费中立命令值。`--interactive` 只决定 CLI 是否向 core 传递 stdin。 +- `SandboxConfig` 的领域错误不再包含 flag 名称;create 命令在边界上为 CPU、内存和磁盘限制补充对应 flag 上下文。 +- guest NDJSON 协议、CLI 参数、stdout/stderr 流和退出码保持不变。定向测试、完整 `make verify` 及 Linux/Darwin `make lint` 全部通过,lint 为 0 issue。 +- R4 完成。下一节为 R5 公共 CLI 进度渲染。 + +## 8. R5:公共 CLI 进度渲染 + +### 8.1 现状与对照 + +镜像与 sandbox 命令各自包含相似的 spinner、TTY 检测、刷新和结束输出。对照实现有共享 `progress.Tracker`,但通过 `any` 传递事件,类型不匹配时可能静默丢失。 + +### 8.2 改造 + +新增 `cli/progress`,只复用终端展示机制: + +- TTY 检测。 +- 动画帧和刷新节拍。 +- 当前行覆盖与清理。 +- 成功、失败和取消收尾。 +- 非 TTY 时输出有限的普通状态行。 +- context 取消和 goroutine 回收。 + +领域事件继续属于各自模块或 CLI adapter: + +```text +images.Progress / sandbox.Progress + ↓ +对应 CLI adapter + ↓ +cli/progress.Renderer + ↓ +stderr +``` + +Renderer 不接收 `any`,也不理解 layer、sandbox 或 VMM 状态。 + +### 8.3 验收 + +- image import/pull/verify/remove 与 sandbox create/remove/start/stop 共用同一渲染机制。 +- stdout 仍只输出命令结果,进度只写 stderr。 +- pipe/redirect 下没有控制字符和高频刷屏。 +- 成功、失败、取消后都不遗留 goroutine 或半行终端内容。 +- renderer 使用 fake clock/writer 的确定性测试,随后执行 `make race`。 + +### 8.4 实施记录 + +- 2026-09-20,提交 `21e0f13`:新增 `cli/progress.Renderer`,集中负责 TTY 判断、spinner ticker、写入串行化、stdout 前后的终端行清理、成功/失败/取消/已提交错误收尾和 goroutine 回收。 +- `cli/image` 只保留 layer/image 计数与提交语义,`cli/sandbox` 只保留 workflow stage、提交语义和恢复提示;两者都不再维护终端状态、动画帧或 ticker。 +- 非 TTY 输出只写换行结束的普通状态,并去除连续重复状态;command result 继续写 stdout,所有进度继续写 stderr。公共 Renderer 不接收 `any`,也不理解 image 或 sandbox 事件。 +- Renderer 测试使用手动 ticker 和可观察 writer,确定性覆盖动画推进、输出分流、取消、写失败、初始化失败和 ticker 回收;image adapter 继续覆盖并发 layer 计数、提交后报告失败和 remove 计数。 +- `make race`、完整 `make verify` 及 Linux/Darwin `make lint` 全部通过,lint 为 0 issue。R5 完成,下一节为 R6 镜像导入性能测量与优化。 + +## 9. R6:镜像导入性能测量与优化 + +### 9.1 现状与对照 + +对照实现并行处理 layer,并按 digest 加锁,但缓存命中主要只验证普通文件且大小大于零。KumaBox 对 compressed digest、diffID、EROFS 和 boot artifact 的验证更强,不能退化为文件存在性检查。 + +当前风险是同一导入流程对最终产物进行多次全文件摘要计算,并可能对 manifest 中重复的源 digest 重复转换。 + +### 9.2 先测量 + +本节开始时先建立基准和 profile,至少覆盖: + +- 全新导入。 +- 全缓存命中。 +- 一个 layer 损坏后的修复。 +- 多个镜像共享 layer。 +- manifest 重复引用同一 layer。 +- 1、2、4、8 并发转换。 + +分别记录读取字节数、hash 时间、解压时间、EROFS 转换时间、锁等待时间、总耗时和内存峰值。没有测量证据不得修改校验流程。 + +### 9.3 候选优化 + +只有 profile 证明有效时才采用: + +- 单次导入内按 source digest 去重转换任务。 +- 为一次操作缓存已成功的文件身份与摘要结果。 +- 将最终完整验证收敛到 digest 锁内的一次,同时保留锁外转换和锁内复查。 +- 避免先完整读取再转换,继续保持流式处理。 +- 设定有界 worker 数,来源于 R2 配置而不是硬编码。 + +发布顺序保持: + +```text +staging 转换 + ↓ +按 digest 排序加锁 + ↓ +锁内复查现有产物 + ↓ +原子发布或复用 + ↓ +最终完整性校验 + ↓ +metadata 短事务提交 +``` + +### 9.4 验收 + +- 损坏缓存仍能被发现并修复。 +- digest、diffID、whiteout、boot candidate 语义不变。 +- 相同输入的最终 digest 与当前版本一致。 +- benchmark 报告包含改造前后数据;没有可重复收益则不提交优化代码。 +- `make verify`、`make race` 和真实 `mkfs.erofs` Linux 验收通过。 + +### 9.5 实施记录 + +- 2026-09-21:项目负责人因暂时没有 Linux 测试环境,决定跳过本节;未修改镜像导入性能路径,也未把候选优化标记为完成。 +- 遗留:恢复测试环境后执行 9.2 的 benchmark/profile 和 9.4 的真实 `mkfs.erofs` 验收,再根据数据决定是否提交优化。 + +## 10. R7:测试补强 + +### 10.1 VMM 与 cgroup + +- Cloud Hypervisor 参数和设备顺序的 golden/结构化测试。 +- PID、starttime、boot ID、binary 和 socket 身份校验。 +- 启动后早退、API 未就绪、stop TERM→KILL、清理失败和重试。 +- cgroup 创建、限制写入、进程放置、空组删除和残留恢复。 +- Registry 重复注册、未知类型和多个实例隔离。 + +### 10.2 Agent + +对齐成熟实现已有的生命周期覆盖: + +- idle connection 下关闭服务。 +- accept 永久错误。 +- guest 子进程提前退出。 +- stdin reader 被取消后退出。 +- 所有连接在 shutdown 时关闭并被 WaitGroup 等待。 +- 使用 `make race` 验证并发路径。 + +当前不要求增加 session semaphore;若测试或实际负载证明需要,再单独设计配置和拒绝语义。 + +### 10.3 CLI + +- 构建真实 `kumabox` 二进制执行关键命令,而不只调用 Cobra handler。 +- 验证 stdout/stderr 分离、JSON 缩进、退出码、信号取消和非 TTY 输出。 +- 覆盖 `image`、`create`、`ps`、`inspect`、`start`、`stop`、`console` 参数校验;需要 KVM 的实体行为仍留给 Linux runbook。 + +### 10.4 验收 + +- 新测试验证合同和失败边界,不复制实现细节。 +- 测试文件与被测包同目录,不建立生产 `*test` helper 包。 +- `make verify`、`make race`、`make lint` 全部通过。 + +### 10.5 实施记录 + +- 2026-09-21,提交 `ff2d616`:补齐 VMM 参数与进程身份、启动/停止/清理、cgroup 收敛与 Registry 隔离、Agent 连接生命周期以及真实 CLI 二进制合同测试。 +- 证据:完整 `make verify`、`make race`、Linux/Darwin `make lint` 均通过;Linux 专属 cgroup 与进程生命周期测试已交叉编译通过。 +- 遗留:Linux 专属测试尚未在真实 Linux 主机执行,随 R6 的测试环境验收一起完成;R7 代码与当前主机验收完成。 + +## 11. R8:文档与 CI 治理 + +### 11.1 现状 + +当前 `.gitignore` 忽略整个 `docs/`,CI 还会拒绝任何被跟踪的文档。README 同时把这些本地文件声明为唯一规范。这使新 clone、代码审查和历史提交无法获得决定代码行为的规范。 + +成熟开源项目通常把架构、行为、运行手册和决策记录与代码一起评审。对照项目也跟踪其 `docs/`。 + +### 11.2 改造 + +- 从 `.gitignore` 删除 `docs/`。 +- 删除 CI 中“Reject tracked local documentation”步骤。 +- 清理 `docs/` 历史文件,只提交仍然有效的规范、提案、决策和 runbook。 +- 修复文档中已失效的 `internal`、daemon、gRPC 和旧阶段描述;历史材料需要保留时明确标注 non-normative。 +- README 的结构表、命令示例和阶段状态必须与代码一致。 +- 在贡献流程中要求行为或架构变化同时更新对应文档。 + +### 11.3 审批边界 + +这一节会改变仓库治理方式,实施前必须由项目负责人再次确认: + +- 哪些现有文档进入 Git。 +- 历史资料删除还是迁入明确的 archive。 +- 中文设计文档是否作为长期规范。 + +### 11.4 验收 + +- 全新 clone 能获得构建、架构、行为和 Linux 验收说明。 +- README 中不存在指向未提交文件的链接。 +- CI 不再拒绝文档,同时仍检查文档引用或格式。 +- 所有 normative 文档与当前代码完成一次一致性审查。 + +### 11.5 实施记录 + +- 2026-09-21,R8 提交:规范、活动提案和 Linux runbook 进入 Git;README、架构、行为、配置、对照和路线图按当前代码重写。 +- 删除被当前实现取代的旧 design/implementation 与已完成提案;CI 改为检查 Markdown 相对链接,`make verify` 同步执行。 +- 中文继续作为设计规范语言;根 README、CLI help 和代码注释保持英文。R8 完成,本文件归档。 + +## 12. 明确撤回或降低优先级的建议 + +以下内容不作为本轮任务: + +| 原建议 | 当前决定 | 原因 | +|---|---|---| +| 立即拆分 `vmm.Backend` | 撤回 | 当前方法都是基础进程能力;对照实现的胖接口更宽,机械拆分会增加类型断言和装配复杂度 | +| 为每个 VMM 能力建立一个接口 | 撤回 | 等 snapshot、pause、restore 的真实第二能力出现再定义可选接口 | +| Agent 立即增加最大 session 数 | 降低优先级 | 当前模型与对照实现一致;先补取消、关闭和 goroutine 泄漏测试 | +| 使用缓存文件大小判断替代完整 hash | 否决 | 会降低内容寻址存储的完整性保证 | +| 把跨模块编排移回 CLI | 否决 | 当前 `core` 应用服务边界更清楚 | +| 把接口集中放进 `types` | 否决 | `types` 只保存共享数据和值对象 | +| 为 console、exec 建独立包 | 否决 | 它们是 SandboxService 的运行态能力,独立包只会制造碎片 | + +## 13. 完成定义 + +本轮架构改造只有在以下条件全部满足后才能关闭: + +- R1–R8 每节均有单独批准、提交和验收记录。 +- 默认 CLI、JSON、metadata、磁盘目录和 guest 协议保持兼容,获批变更除外。 +- 运行策略不再散落为无法覆盖的硬编码。 +- `core.SandboxService`、VMM Registry、CLI progress 和 exec 数据边界能够从包结构直接理解。 +- 镜像性能优化有基准证据且未削弱完整性校验。 +- VMM、cgroup、Agent 生命周期和真实 CLI 边界具有足够失败路径测试。 +- 有效设计文档能随仓库获取并与代码一起评审。 + +每完成一节,在本文对应标题下追加一条不超过五行的实施记录:提交号、关键变化、测试证据、遗留项。不得提前把尚未实现的内容标记为完成。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..50a9d28 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,100 @@ +# KumaBox 主线路线图 + +> 状态:normative + +本路线图从实际代码和 Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` 的对应实现出发。每个切片单独设计、实现、验收和提交。 + +## 已完成 + +### 基础工程 + +- daemonless CLI、doctor、配置加载、稳定错误码和退出码; +- SQLite transaction contract、flock、受管路径和原子发布; +- 根级模块、`core` 应用服务、显式 VMM registry; +- 公共进度 renderer 和真实 CLI binary 测试。 + +### 镜像 + +- registry pull; +- OCI layout/archive 和 Docker save import; +- digest、diffID、whiteout、boot artifact 验证; +- EROFS conversion、cache reuse、list/inspect/verify/remove; +- `overlay-v1` boot profile 与官方 Ubuntu guest image。 + +### Sandbox 基础生命周期 + +- `create`、`ps`、`inspect`、`rm`; +- `start`、`stop`、`console`、`exec`; +- sparse ext4 COW、cgroup v2、Cloud Hypervisor direct boot; +- PID/starttime/boot ID/binary/socket identity; +- guest agent NDJSON exec 子集。 + +这些功能已完成代码和跨平台门禁。Linux/KVM 行为仍必须在发布前按 runbook 重验。 + +## 下一步:`logs` + +这是下一条命令。Cloud Hypervisor 已把 stdout/stderr 写入每个 sandbox 的持久 `vmm.log`,缺少的是受控读取接口。 + +目标与 Cocoon 对齐: + +```text +kumabox logs SANDBOX +kumabox logs --tail N SANDBOX +kumabox logs -f SANDBOX +``` + +- 按 name/ID 解析 sandbox,再由 VMM backend 提供日志来源,不让 CLI 拼路径。 +- `--tail 0` 输出全部;正数输出最后 N 行。 +- `-f` 支持取消、文件增长和 VMM 重启后的 truncate/reopen。 +- 从未启动时返回明确错误;停止后日志仍可读。 +- stdout 只写日志内容,诊断写 stderr。 +- 日志归 VMM backend 所有;`rm` 在 metadata finalize 前可重试地删除 log dir,失败保留 `deleting`。这补齐当前 rm 只清理 COW、未回收持久日志的缺口。 + +## 随后:网络基础 + +网络在 `logs` 后、`run` 前实现。原因是 Cocoon 的网络身份在 sandbox reserve 之后、VMM create/start 之前建立,并贯穿 start/stop/rm;先做无网络 `run` 会重复修改命令、metadata 和补偿流程。 + +第一版范围: + +1. 根级 `network` 能力与 `network/cni` adapter; +2. `types.NetworkConfig` 和 sandbox 持久网络事实; +3. CNI conflist/bin 配置、netns、TAP、TC redirect 和逐 NIC intent; +4. `create --network NAME --nics N`,默认行为在开工评审时与 Cocoon 当前默认再次确认; +5. Cloud Hypervisor net devices、kernel IP/DNS/hostname 参数; +6. stop quiesce、start recover/unquiesce、rm CNI DEL/netns cleanup; +7. 中断恢复、generation fence 和 Linux runbook。 + +第一版不做 bridge backend、NIC hot-resize 或 Firecracker;接口必须允许这些真实第二实现以后加入。 + +## 网络之后:`run` + +`run` 复用 `create` 和 `start` 的应用服务,不复制流程: + +```text +kumabox run IMAGE --name NAME [resource/network flags] +kumabox run IMAGE --name NAME -- COMMAND [ARGS...] +``` + +默认等待 guest agent。带命令时透传 stdout/stderr 和 exit code,command 退出后 sandbox 保持运行。创建已提交但后续启动或 agent wait 失败时,错误必须说明 sandbox 已存在并可 inspect/stop/rm。 + +## 然后:`status` 与恢复 + +Cocoon 的 `list`/`status` 会把持久状态与进程事实合并,并支持 watch/event。KumaBox 当前 `ps` 有意保持只读 metadata 视图。 + +计划分两步: + +1. 一次性 `status [SANDBOX...] [--json]`:观察 VMM/process/cgroup/network,展示 durable state 与 observed state; +2. `--watch`/`--event`:只有在事件和轮询语义确定后加入。 + +同时提供显式的实体恢复入口,处理遗留 `creating/starting/stopping/deleting`。不能让普通 `ps` 在查询时偷偷修改状态。 + +## 后续能力 + +1. snapshot、restore、hibernate; +2. clone 与 guest identity reseed; +3. NIC resize、额外磁盘和 filesystem attach; +4. Firecracker backend; +5. 节点级 reconcile、GC、admission 和容量统计; +6. 跨节点与可选常驻服务。 + +每个阶段继续以 Cocoon 的真实流程、失败恢复和测试为基线;KumaBox 的模块边界、错误模型和完整性保证保留。 diff --git a/docs/architecture-diagrams.md b/docs/architecture-diagrams.md new file mode 100644 index 0000000..766463f --- /dev/null +++ b/docs/architecture-diagrams.md @@ -0,0 +1,72 @@ +# KumaBox 当前架构图 + +## 命令调用 + +```text + immutable Config + │ +cmd/kumabox → cli ─────┴────► core application service + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + images sandbox vmm + source / erofs catalog / disk cloudhypervisor + │ │ │ + └──────────┬──────┘ ├── cgroup + ▼ └── agent/vsock + metadata/sqlite +``` + +## Sandbox 创建 + +```text +validate request + ↓ +sandbox entity lock + ↓ +resolve + pin image in transaction + ↓ +reserve creating record + ↓ +prepare sparse ext4 COW + ↓ +verify disk → CAS created +``` + +## 启动和停止 + +```text +start: +lock → CAS starting → runtime/cgroup → launch VMM → persist identity + → vm.info Running → CAS running + +stop: +lock → CAS stopping → vm.shutdown → identity-safe TERM → optional KILL + → verify absent → runtime/cgroup cleanup → CAS stopped +``` + +## Guest exec + +```text +CLI streams ─► core validates Running generation + ↓ + Cloud Hypervisor vsock UDS + ↓ CONNECT 1024 + guest agent + ┌─────┴─────┐ + stdin frames stdout/stderr/exit +``` + +## 计划中的网络顺序 + +```text +reserve sandbox + ↓ +persist NIC intents + ↓ +netns → CNI ADD → TAP/TC → persist MAC/IP + ↓ +launch VMM in netns + ↓ +stop: quiesce ── start: recover/unquiesce ── rm: CNI DEL + netns delete +``` diff --git a/docs/proposals/s3-sandbox.md b/docs/proposals/s3-sandbox.md new file mode 100644 index 0000000..f517861 --- /dev/null +++ b/docs/proposals/s3-sandbox.md @@ -0,0 +1,77 @@ +# S3 Sandbox 主线 + +> 状态:基础生命周期已实现;下一条命令为 `logs`,随后进入网络切片。 + +## 已实现闭环 + +```text +import image + → create sandbox + ext4 COW + → start Cloud Hypervisor + → console / guest exec + → stop + → restart or rm +``` + +已实现命令:`create`、`start`、`stop`、`ps`、`inspect`、`console`、`exec`、`rm`。 + +已实现核心合同: + +- image alias 在 create 时解析为完整 manifest digest 并持久 pin; +- `creating/starting/stopping/deleting` 是可恢复的持久意图; +- VMM process identity 防 PID reuse 和 host reboot; +- cgroup 和 runtime 只在进程已确认退出后清理; +- guest exec 使用 bounded NDJSON frame,不把断线冒充 exit 0; +- stdout/stderr、JSON 和 CLI exit code 有真实 binary 测试。 + +## `logs` 切片 + +Cloud Hypervisor adapter 已在 `/var/log/kumabox/sandboxes//vmm.log` 持久化 stdout/stderr。新增命令只公开受控读取能力: + +```text +CLI resolve name/ID + → core validates sandbox ownership + → VMM backend opens owned log stream + → tail/follow renderer copies to stdout +``` + +合同: + +- `logs SANDBOX` 输出全部;`--tail N` 从最后 N 行开始;`-f` 等待增长。 +- VMM restart 截断日志后 follower 从新文件头继续,不能卡在旧 offset。 +- stop 后仍可读;从未 start 返回明确 unavailable/not-found 诊断。 +- cancel 关闭 watcher/file;不泄漏 goroutine 或 FD。 +- CLI 不拼接 log path,`core` 不实现 tail 算法,VMM 模块拥有其日志。 +- `rm` 在最终释放 metadata/name/image pin 前删除 backend 拥有的 log dir;失败保留 `deleting` 并允许相同命令重试。 + +当前 `rm` 只删除 COW 并释放 metadata,持久 `vmm.log` 会留下。该缺口必须在本切片一起修复,行为才与 Cocoon 的 delete cleanup 对齐。 + +## 为什么网络在 `run` 之前 + +参考实现的 create/run 流程先 reserve identity,再配置网络,最后把 network facts 交给 VMM。start、stop 和 rm 都依赖相同事实。如果 KumaBox 现在先做无网络 `run`,之后必须再次修改: + +- create/run flags 和 request; +- sandbox metadata schema; +- launch plan 和 Cloud Hypervisor argv; +- start rollback、stop quiesce、rm cleanup; +- JSON 输出和 runbook。 + +因此 `logs` 完成后直接进入网络;网络闭环验收后实现 `run`。 + +## 网络第一版边界 + +- 只做 CNI backend;不做 bridge 和 hot resize。 +- 支持 0 或多个 NIC,具体默认 NIC 数在开工前再次核对 Cocoon 当前 CLI 默认。 +- MAC、IP、gateway、DNS、conflist、ifname、queue 数和 cleanup intent 持久化。 +- netns/TAP/TC/CNI 操作属于 `network` 模块;`core.SandboxService` 编排其与 catalog/VMM 的顺序。 +- `types` 保存跨模块 NetworkConfig 值;不保存 Network 接口。 +- create 在 reserve 后配网;start recover/unquiesce;stop quiesce;rm 全量 cleanup。 +- partial failure 必须可重试,不能因为 CNI DEL 失败就忘掉 NIC record。 + +## `run` 与 `status` + +网络完成后,`run` 组合现有 create/start,不复制它们。随后实现一次性 `status`,将持久 state 与 VMM/process/cgroup/network observed facts 并列展示。watch/event 和节点级后台收敛留到一次性合同稳定后。 + +## 验收 + +每个切片必须通过 `make verify`、`make race`、`make lint`。`logs` 可在本地用受管文件验证;网络必须增加 Linux CNI runbook,并与 Cocoon 的同资源配置对比 create/start/stop/rm 行为。 diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..851ab7a --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,39 @@ +# Releasing KumaBox + +A release requires both repository gates and Linux acceptance evidence. + +## 1. Repository gates + +```bash +make verify +make race +make lint +``` + +The worktree must contain no generated binary, coverage output, or unrelated artifact. Documentation links must pass `make docs-check`. + +## 2. Linux acceptance + +Run the applicable checked-in runbooks on a Linux/KVM host. Record: + +- commit and version; +- kernel and distribution; +- Cloud Hypervisor, `mkfs.erofs`, `mkfs.ext4`, and Go versions; +- CPU architecture and cgroup mode; +- exact commands, exit codes, image digests, and any retained cleanup state. + +At minimum, a lifecycle release must cover image import/verify, create/start/exec/console/stop/restart/rm, cancellation, and host process identity checks. Network releases must also cover CNI ADD/DEL, outbound connectivity, quiesce/recover, and partial-failure retry. + +## 3. Build + +```bash +make clean +make build +make agent +``` + +`bin/kumabox` is the host CLI, `bin/kumabox-check` is the host checker, and `bin/kumabox-agent` is the Linux guest agent. + +## 4. Version + +Build metadata is injected through `version.Version`, `version.Commit`, and `version.BuildTime`. Create a signed or annotated version tag only after the release commit and Linux evidence are final. diff --git a/docs/runbooks/s2-oci.md b/docs/runbooks/s2-oci.md new file mode 100644 index 0000000..9e8f851 --- /dev/null +++ b/docs/runbooks/s2-oci.md @@ -0,0 +1,82 @@ +# S2 OCI Linux 手动验收 + +状态:2026-09-15 项目负责人已完成测试机验证并明确审批 S2 通过。按 DEC-022,本阶段不启动 VMM、不需要 KVM。 + +## 准备 + +需要 Go、mkfs.erofs >= 1.8、fsck.erofs、jq 和一份包含真实 regular kernel/initrd 的 KumaBox-compatible OCI 镜像。alpine 等普通容器镜像通常不满足启动合同。 + +```bash +make verify +make lint +make build +export PATH="$PWD/bin:$PATH" +mkfs.erofs --version +kumabox doctor +export KUMABOX_S2_REFERENCE='填写真实兼容镜像引用' +KUMABOX_S2_WORK=$(mktemp -d /var/tmp/kumabox-s2.XXXXXX) +kb() { kumabox --root-dir "$KUMABOX_S2_WORK/data" --run-dir "$KUMABOX_S2_WORK/run" --log-dir "$KUMABOX_S2_WORK/log" "$@"; } +kb image ls --json +``` + +空列表必须是 `[]`。本节只写隔离的临时 root;系统 doctor 的 fix/upgrade 由负责人在验收机上显式运行。 + +## 真转换、registry 与幂等 + +```bash +kb image pull "$KUMABOX_S2_REFERENCE" --platform linux/amd64 +kb image inspect "$KUMABOX_S2_REFERENCE" > "$KUMABOX_S2_WORK/first.json" +kb image verify "$KUMABOX_S2_REFERENCE" +find "$KUMABOX_S2_WORK/data/images/layers" -name '*.erofs' -exec fsck.erofs '{}' \; +find "$KUMABOX_S2_WORK/data/images" -type f -exec sha256sum '{}' \; | sort > "$KUMABOX_S2_WORK/before.sha256" +kb image pull "$KUMABOX_S2_REFERENCE" --platform linux/amd64 +kb image inspect "$KUMABOX_S2_REFERENCE" > "$KUMABOX_S2_WORK/second.json" +find "$KUMABOX_S2_WORK/data/images" -type f -exec sha256sum '{}' \; | sort > "$KUMABOX_S2_WORK/after.sha256" +diff "$KUMABOX_S2_WORK/before.sha256" "$KUMABOX_S2_WORK/after.sha256" +diff "$KUMABOX_S2_WORK/first.json" "$KUMABOX_S2_WORK/second.json" +test ! -e "$KUMABOX_S2_WORK/data/images/blobs" +test -z "$(find "$KUMABOX_S2_WORK/data/staging/imports" -mindepth 1 -print -quit)" +``` + +每个 source layer 只有一份 EROFS;重复 pull 不出现转换进程,产物、digest 和 created_at 不变。inspect 包含 compressed source digest、EROFS digest、boot candidates 的 digest/size 与最终选择。 + +## Layout/archive 与别名 + +```bash +kb image import tiny ./testdata/oci-layout --platform linux/amd64 +kb image verify tiny +KUMABOX_S2_ARCHIVE="$KUMABOX_S2_WORK/tiny.bin" +tar -C testdata/oci-layout -czf "$KUMABOX_S2_ARCHIVE" . +kb image import tiny-alias "$KUMABOX_S2_ARCHIVE" --platform linux/amd64 +kb image inspect tiny | jq '.names' +kb image rm tiny +kb image verify tiny-alias +kb image rm tiny-alias +``` + +gzip 通过 magic 检测,与扩展名无关。fixture 启动文件是占位数据,只验证转换/完整性;不能用来启动 VM。实际 Ubuntu 等镜像另外验证 versioned boot basename、whiteout 和 arm64 gzip kernel。 + +## 损坏与恢复 + +针对真实已拉取镜像: + +```bash +KUMABOX_S2_LAYER=$(kb image inspect "$KUMABOX_S2_REFERENCE" | jq -r '.boot.kernel_layer | sub("^sha256:"; "")') +KUMABOX_S2_KERNEL=$(kb image inspect "$KUMABOX_S2_REFERENCE" | jq -r '.boot.kernel_file') +printf x >> "$KUMABOX_S2_WORK/data/images/boot/sha256/$KUMABOX_S2_LAYER/$KUMABOX_S2_KERNEL" +kb image verify "$KUMABOX_S2_REFERENCE"; test "$?" -eq 5 +kb image pull "$KUMABOX_S2_REFERENCE" --platform linux/amd64 +kb image verify "$KUMABOX_S2_REFERENCE" +``` + +verify 报 ARTIFACT_CORRUPT;重拉只重建损坏 layer,恢复原摘要。缺失文件报 ARTIFACT_UNAVAILABLE(6),缺镜像为 NOT_FOUND(3),用法错误为 2。 + +## 并发、取消与崩溃 + +使用另一个空 root 对同一镜像并发 pull 两次;两进程都成功,允许 staging 重复转换,最终只有一份 EROFS。两边都 verify,通过后删除检查共享引用。 + +对空 root 中慢下载/大镜像 import 发送 SIGINT/SIGTERM:进程终止下载和 mkfs.erofs,staging 清理完毕,没有半成品 image 记录。原样重试成功。 + +在转换期间和发布/提交窗口分别 kill -9:inspect 要么 NOT_FOUND,要么完整且 verify 通过;绝不显示 importing/半成品。重试时未知最终文件重建;已提交产物校验后复用。kill -9 的旧 staging 可以保留为不可见孤儿,后续 GC 阶段回收。 + +最后记录宿主架构、mkfs.erofs 版本、镜像完整 manifest digest、各命令退出码与产物文件列表,附到 ROADMAP 的进度日志。不要把 cache、bin 或 coverage 产物加入 Git;本目录中的规范和 runbook 应随代码提交。 diff --git a/docs/runbooks/s3-create.md b/docs/runbooks/s3-create.md new file mode 100644 index 0000000..4f2374e --- /dev/null +++ b/docs/runbooks/s3-create.md @@ -0,0 +1,98 @@ +# S3 Sandbox Linux 验收 + +本 runbook 验证当前无网络生命周期:create、start、exec、console、stop、restart 和 rm。需要 Linux、KVM、cgroup v2、Cloud Hypervisor、`mkfs.erofs`、`mkfs.ext4`、jq,以及一份带 `overlay-v1` profile 和 `kumabox-agent` 的真实镜像。 + +## 准备 + +```bash +make verify +make lint +make build +make agent +export PATH="$PWD/bin:$PATH" +export KUMABOX_S3_IMAGE='填写真实兼容镜像引用' +KUMABOX_S3_WORK=$(mktemp -d /var/tmp/kumabox-s3.XXXXXX) +kb() { sudo kumabox --root-dir "$KUMABOX_S3_WORK/data" --run-dir "$KUMABOX_S3_WORK/run" --log-dir "$KUMABOX_S3_WORK/log" "$@"; } + +sudo kumabox doctor +kb image pull "$KUMABOX_S3_IMAGE" --platform linux/amd64 +kb image verify "$KUMABOX_S3_IMAGE" +test "$(kb image inspect "$KUMABOX_S3_IMAGE" | jq -r '.boot.profile')" = overlay-v1 +``` + +## Create 和查询 + +```bash +kb create "$KUMABOX_S3_IMAGE" --name lifecycle --cpus 2 --memory 1GiB --storage 10GiB --json \ + | tee "$KUMABOX_S3_WORK/create.json" +KUMABOX_S3_ID=$(jq -r '.id' "$KUMABOX_S3_WORK/create.json") +test "$(jq -r '.state' "$KUMABOX_S3_WORK/create.json")" = created +kb ps -a +kb inspect lifecycle | jq -e --arg id "$KUMABOX_S3_ID" '.id == $id and .state == "created"' +test "$(stat -c %s "$KUMABOX_S3_WORK/data/sandboxes/$KUMABOX_S3_ID/cow.raw")" = 10737418240 +blkid "$KUMABOX_S3_WORK/data/sandboxes/$KUMABOX_S3_ID/cow.raw" | grep 'TYPE="ext4"' +``` + +COW apparent size 为 10 GiB,实际占用应明显更小。`ps` 默认不显示 created,`ps -a` 显示完整 UUID 和列标题。 + +## Start、exec 和 console + +```bash +kb start lifecycle --json | tee "$KUMABOX_S3_WORK/start.json" +test "$(jq -r '.state' "$KUMABOX_S3_WORK/start.json")" = running +kb exec lifecycle -- uname -a +kb exec lifecycle -- hostname +echo hello | kb exec -i lifecycle -- cat +kb exec lifecycle -- sh -c 'exit 17'; test "$?" -eq 17 +kb console lifecycle +``` + +console 中确认 guest 完成启动;使用 `Ctrl-]` 后 `.` 断开。检查: + +```bash +cat "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID/process.json" | jq . +cat "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID/cmdline" +test -S "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID/api.sock" +test -S "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID/vsock.uds" +cat "$KUMABOX_S3_WORK/log/sandboxes/$KUMABOX_S3_ID/vmm.log" +``` + +## Stop、持久数据和 restart + +```bash +kb exec lifecycle -- sh -c 'echo persisted >/persist-check' +kb stop lifecycle --json | tee "$KUMABOX_S3_WORK/stop.json" +test "$(jq -r '.state' "$KUMABOX_S3_WORK/stop.json")" = stopped +test ! -e "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID" +kb stop lifecycle +kb start lifecycle +kb exec lifecycle -- cat /persist-check | grep -Fx persisted +kb stop lifecycle +``` + +stop 后 VMM 进程不存在、runtime dir 被清理、对应 cgroup 为空并删除。第二次 stop 幂等成功。restart 保持相同 sandbox ID、image digest 和 COW 数据。 + +## 删除与引用 + +```bash +kb image rm "$KUMABOX_S3_IMAGE"; test "$?" -eq 4 +kb rm lifecycle --json | tee "$KUMABOX_S3_WORK/remove.json" +test "$(jq -r '.id' "$KUMABOX_S3_WORK/remove.json")" = "$KUMABOX_S3_ID" +test ! -e "$KUMABOX_S3_WORK/data/sandboxes/$KUMABOX_S3_ID" +kb image rm "$KUMABOX_S3_IMAGE" +test "$(kb ps -a --json)" = "[]" +``` + +## 恢复与安全边界 + +至少验证: + +1. start 过程中发送 SIGINT,重试 start 能收敛为唯一 VMM; +2. running 时 kill VMM,stop 不向复用 PID 的无关进程发信号; +3. stop 在 TERM 等待阶段中断,重试继续 `stopping`; +4. rm 清理中断,重试继续 `deleting`; +5. 破坏 process.json 的 boot ID、binary 或 socket 后,stop 返回冲突且不发送信号; +6. agent 未启动时 exec 有界失败,VMM 保持可 inspect/stop; +7. 非 TTY 重定向无 ANSI 控制字符,JSON 保持缩进。 + +记录 commit、内核、架构、Cloud Hypervisor、cgroup mode、formatter 版本、完整 image digest、每个命令退出码和失败后的持久状态。 diff --git a/scripts/check-doc-links.sh b/scripts/check-doc-links.sh new file mode 100755 index 0000000..a039479 --- /dev/null +++ b/scripts/check-doc-links.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -euo pipefail + +failed=0 +while IFS= read -r document; do + while IFS= read -r markdown_link; do + target="${markdown_link#](}" + target="${target%)}" + target="${target%%#*}" + case "${target}" in + ""|http://*|https://*|mailto:*) continue ;; + esac + if [[ ! -e "$(dirname "${document}")/${target}" ]]; then + printf 'broken Markdown link: %s -> %s\n' "${document}" "${target}" >&2 + failed=1 + fi + done < <(grep -Eo '\]\([^)]+(\.md|LICENSE)(#[^)]*)?\)' "${document}" || true) +done < <(git ls-files '*.md') + +exit "${failed}" From 2b9ea4f1da37aa3a33f73fd103bc0940bc9cb290 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Mon, 21 Sep 2026 22:40:12 +0800 Subject: [PATCH 36/45] feat: add sandbox log streaming --- README.md | 8 +- cli/root.go | 1 + cli/root_test.go | 2 + cli/sandbox/query.go | 26 ++++ cli/sandbox/query_test.go | 50 +++++++ cli/sandbox/remove_test.go | 22 +++ cmd/kumabox/main_test.go | 2 + core/sandbox_runtime.go | 39 +++++ core/sandbox_runtime_test.go | 37 +++++ core/sandbox_storage.go | 18 ++- core/sandbox_storage_test.go | 40 +++++ core/sandbox_test.go | 37 +++-- docs/ARCHITECTURE.md | 4 +- docs/BEHAVIOR.md | 10 +- docs/COCOON-MAP.md | 6 +- docs/ROADMAP.md | 24 +-- docs/proposals/s3-sandbox.md | 10 +- docs/runbooks/s3-create.md | 16 +- vmm/backend.go | 2 + vmm/backend_test.go | 7 +- vmm/cloudhypervisor/driver.go | 10 ++ vmm/log.go | 265 ++++++++++++++++++++++++++++++++++ vmm/log_test.go | 162 +++++++++++++++++++++ 23 files changed, 749 insertions(+), 49 deletions(-) create mode 100644 vmm/log.go create mode 100644 vmm/log_test.go diff --git a/README.md b/README.md index 2c83b88..51cf621 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ Available commands: ```text kumabox doctor kumabox image pull|import|list|inspect|verify|remove -kumabox create|start|stop|ps|inspect|console|exec|rm +kumabox create|start|stop|ps|inspect|logs|console|exec|rm kumabox version ``` -Image import and sandbox lifecycle are implemented locally. Real Cloud Hypervisor, cgroup, vsock, ext4, and EROFS behavior requires Linux and is covered by the checked-in runbooks. Networking, `run`, log streaming, snapshots, clone, and Firecracker remain planned work; see [the roadmap](docs/ROADMAP.md). +Image import and sandbox lifecycle are implemented locally. Real Cloud Hypervisor, cgroup, vsock, ext4, and EROFS behavior requires Linux and is covered by the checked-in runbooks. Networking, `run`, snapshots, clone, and Firecracker remain planned work; see [the roadmap](docs/ROADMAP.md). ## Build and test @@ -63,12 +63,14 @@ Local import auto-detects OCI layouts, OCI archives, and `docker save` archives. kumabox create demo --name box --cpus 2 --memory 1GiB --storage 10GiB kumabox start box kumabox exec box -- uname -a +kumabox logs --tail 50 box +kumabox logs -f box kumabox console box kumabox stop box kumabox rm box ``` -`create` prepares a sparse ext4 COW disk but does not start the VMM. `start` uses direct kernel boot, records a PID-reuse-safe process identity, and commits `running` only after the Cloud Hypervisor API reports readiness. `stop` requests shutdown, then uses an identity-checked TERM-to-KILL fallback. `exec` uses the guest agent over private hybrid-vsock transport. +`create` prepares a sparse ext4 COW disk but does not start the VMM. `start` uses direct kernel boot, records a PID-reuse-safe process identity, and commits `running` only after the Cloud Hypervisor API reports readiness. `stop` requests shutdown, then uses an identity-checked TERM-to-KILL fallback. `exec` uses the guest agent over private hybrid-vsock transport. `logs` reads persistent backend output with tail and follow support, including after stop. `ps` prints a table with headers. `inspect` and every `--json` mode emit indented JSON. Progress goes to stderr; command results go to stdout. diff --git a/cli/root.go b/cli/root.go index 3ca8816..16c06c9 100644 --- a/cli/root.go +++ b/cli/root.go @@ -141,6 +141,7 @@ func newRootCommand() (*cobra.Command, error) { 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.NewStartCommand(provideConfig)) root.AddCommand(sandboxcmd.NewStopCommand(provideConfig)) diff --git a/cli/root_test.go b/cli/root_test.go index 4d54b1f..695ac35 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -127,6 +127,7 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"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 start sandbox", []string{"start"}, 2}, {"missing stop sandbox", []string{"stop"}, 2}, @@ -137,6 +138,7 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"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}, diff --git a/cli/sandbox/query.go b/cli/sandbox/query.go index 43a68c4..a706d27 100644 --- a/cli/sandbox/query.go +++ b/cli/sandbox/query.go @@ -34,6 +34,32 @@ func NewInspectCommand(configuration configProvider) *cobra.Command { 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 diff --git a/cli/sandbox/query_test.go b/cli/sandbox/query_test.go index b94e67f..27273fa 100644 --- a/cli/sandbox/query_test.go +++ b/cli/sandbox/query_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "os" "path/filepath" "strings" "testing" @@ -13,6 +14,7 @@ import ( "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) { @@ -154,6 +156,54 @@ func TestListCommandRejectsJSONWithQuiet(t *testing.T) { } } +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 } diff --git a/cli/sandbox/remove_test.go b/cli/sandbox/remove_test.go index c691261..32812e9 100644 --- a/cli/sandbox/remove_test.go +++ b/cli/sandbox/remove_test.go @@ -14,6 +14,7 @@ import ( 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) { @@ -25,6 +26,24 @@ func TestRemoveCommandClosesCreateAndImageReferenceLifecycle(t *testing.T) { 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 @@ -54,6 +73,9 @@ func TestRemoveCommandClosesCreateAndImageReferenceLifecycle(t *testing.T) { 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 { diff --git a/cmd/kumabox/main_test.go b/cmd/kumabox/main_test.go index ac6f2eb..57fc274 100644 --- a/cmd/kumabox/main_test.go +++ b/cmd/kumabox/main_test.go @@ -38,6 +38,8 @@ func TestMainBinaryStreamsAndExitCodes(t *testing.T) { {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`}, diff --git a/core/sandbox_runtime.go b/core/sandbox_runtime.go index 0d9308b..524486a 100644 --- a/core/sandbox_runtime.go +++ b/core/sandbox_runtime.go @@ -431,6 +431,45 @@ func (s *SandboxService) Console(ctx context.Context, reference string) (io.Read 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. diff --git a/core/sandbox_runtime_test.go b/core/sandbox_runtime_test.go index 77af019..594a598 100644 --- a/core/sandbox_runtime_test.go +++ b/core/sandbox_runtime_test.go @@ -3,6 +3,7 @@ package core import ( "bytes" "errors" + "io" "net" "reflect" "strings" @@ -328,6 +329,42 @@ func TestConsoleRejectsNonRunningSandboxBeforeRuntimeAccess(t *testing.T) { } } +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{ diff --git a/core/sandbox_storage.go b/core/sandbox_storage.go index fd6eec6..42d7dcc 100644 --- a/core/sandbox_storage.go +++ b/core/sandbox_storage.go @@ -108,7 +108,7 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques // | | // +---- retry resumes here <---+ 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.reporter == nil || s.dependencies.now == nil { + if s == nil || s.dependencies.catalog == nil || s.dependencies.disks == 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 == "" { @@ -139,6 +139,16 @@ func (s *SandboxService) Remove(ctx context.Context, reference string) (result t 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 + } if err := s.dependencies.reporter.Status("marking sandbox for deletion"); err != nil { return types.Sandbox{}, err } @@ -154,6 +164,12 @@ func (s *SandboxService) Remove(ctx context.Context, reference string) (result t 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 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) } diff --git a/core/sandbox_storage_test.go b/core/sandbox_storage_test.go index 2192a93..fa77c6d 100644 --- a/core/sandbox_storage_test.go +++ b/core/sandbox_storage_test.go @@ -3,6 +3,7 @@ package core import ( "errors" "reflect" + "strings" "testing" "github.com/kumabox/kumabox/errdefs" @@ -117,7 +118,9 @@ func TestRemoveMarksDeletingBeforeDiskAndFinalizesAfterCleanup(t *testing.T) { } 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) { @@ -160,13 +163,50 @@ func TestRemoveFailureRetainsDeletingAndRetryFinishes(t *testing.T) { } 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 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{ diff --git a/core/sandbox_test.go b/core/sandbox_test.go index ded36c8..1cf2b1c 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -202,15 +202,19 @@ func (f fakeReporter) Committed(types.Sandbox) error { } 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 + 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 } func (f *fakeRuntime) Type() types.VMMType { @@ -276,11 +280,26 @@ func (f *fakeRuntime) DialVsock(context.Context, vmm.Process, uint32) (io.ReadWr 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 } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8142ef3..bcc918b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,7 +47,7 @@ defaults → explicit config file → environment → flags → Validate SQLite 是资源事实源。文件系统保存大文件和运行产物,不替代 metadata 状态。 - image digest 锁串行化同一内容的发布和删除。 -- sandbox 实体锁串行化 create/start/stop/rm。 +- sandbox 实体锁串行化 create/start/stop/rm;logs follow 不持锁。 - generation compare-and-swap 拒绝陈旧状态提交。 - 慢 I/O 不放在 SQLite 写事务中。 - 发布顺序为“持久意图 → 慢操作 → 验证宿主事实 → 短事务提交”。 @@ -86,6 +86,8 @@ created/stopped/error ── rm ──► deleting ── cleanup ──► remo Cloud Hypervisor readiness 需要同时满足:进程身份仍一致、API 可连接、`vm.info` 为 `Running`。socket 文件存在不等于成功。 +VMM backend 同时拥有持久进程日志的读取和删除能力。`core` 只按 sandbox 中持久化的 VMM 类型路由,CLI 不拼宿主路径。stop 只清理可重建 runtime/cgroup 并保留日志;rm 按 COW → backend logs → metadata finalize 的顺序回收,任何失败保留 `deleting`。 + ## 7. Guest agent host 通过 Cloud Hypervisor hybrid-vsock UDS 连接 guest port 1024。应用协议是有大小上限的 NDJSON:`exec`、`stdin`、`stdin_close`、`started`、`stdout`、`stderr`、`exit`、`error`。guest EOF 没有 terminal frame 时是协议失败,不能当 exit 0。 diff --git a/docs/BEHAVIOR.md b/docs/BEHAVIOR.md index 023accd..75d28ba 100644 --- a/docs/BEHAVIOR.md +++ b/docs/BEHAVIOR.md @@ -107,11 +107,15 @@ lock → CAS starting → prepare runtime/cgroup → launch 参数直接交给 guest,不隐式插入 shell。`-e/--env KEY=VALUE` 可重复;`-i/--interactive` 才连接 stdin,否则立即发送 `stdin_close`。stdout/stderr 独立透传,guest exit code 原样返回。 -### `rm SANDBOX` +### `logs [--tail N] [-f] SANDBOX` + +按 name 或完整 ID 解析 sandbox,并由其持久 VMM backend 提供日志。默认 `--tail 0` 输出完整日志;正数从最后 N 行开始。`-f/--follow` 继续读取追加内容,VMM 重启导致文件截断或替换时从新文件头继续;取消 follow 正常退出。 -拒绝删除活动状态;VMM runtime 和 cgroup 必须先由 `stop` 收敛。命令提交 `deleting`,清理 COW,最后在一个事务中删除 record/name 并释放 image pin。任一步失败都保留 `deleting` 供相同命令重试。 +日志内容只写 stdout。sandbox 从未启动、尚无日志时返回 `ARTIFACT_UNAVAILABLE`。stop 后日志保留并可继续读取;命令不要求 sandbox 处于 running,也不会在 follow 期间持有实体锁。 + +### `rm SANDBOX` -当前版本不会删除持久 `vmm.log` 目录。这是下一条 `logs` 切片必须修复的资源归属缺口:日志在 stop 后可读,但 rm 成功前必须进入可重试清理,不能永久成为孤儿。 +拒绝删除活动状态;VMM runtime 和 cgroup 必须先由 `stop` 收敛。命令提交 `deleting`,依次清理 COW 和 backend 拥有的持久日志,最后在一个事务中删除 record/name 并释放 image pin。任一步失败都保留 `deleting` 供相同命令重试;只有 metadata finalize 成功后 name 和 image pin 才释放。 ## 5. 受管路径 diff --git a/docs/COCOON-MAP.md b/docs/COCOON-MAP.md index d4cc5db..3a29a8b 100644 --- a/docs/COCOON-MAP.md +++ b/docs/COCOON-MAP.md @@ -6,7 +6,7 @@ | 能力 | Cocoon 实现 | KumaBox 状态 | 结论 | |---|---|---|---| -| CLI 生命周期 | `vm create/run/start/stop/list/inspect/console/exec/logs/rm/status` | 已有 create/start/stop/ps/inspect/console/exec/rm;logs 下一步 | 命令行为逐项对齐;KumaBox 当前不加 `vm` 中间层 | +| CLI 生命周期 | `vm create/run/start/stop/list/inspect/console/exec/logs/rm/status` | 已有 create/start/stop/ps/inspect/console/exec/logs/rm | 命令行为逐项对齐;KumaBox 当前不加 `vm` 中间层 | | OCI image | OCI layer 转换、direct boot artifacts | registry/OCI/Docker save、digest/diffID/EROFS/boot 验证 | 已对齐机制;KumaBox 内容校验更严格 | | Boot layout | RO EROFS layers + ext4 COW + overlay initramfs | `overlay-v1`,相同设备/层序机制 | 机制对齐,协议名使用 `kumabox.*` | | 状态 | created/running/stopped/error 与转换 generation | creating/created/starting/running/stopping/stopped/error/deleting | 对齐用户状态;KumaBox 显式持久中间意图 | @@ -14,10 +14,10 @@ | Cloud Hypervisor stop | OCI path: API shutdown → TERM → 5s → KILL | 相同主路径,可配置 grace | 已对齐 | | Console | PTY relay、resize、escape detach | PTY relay、resize、`^].` detach | 已对齐当前需要的交互合同 | | Guest exec | hybrid-vsock + bounded NDJSON frames | 独立实现兼容 exec 子集 | 已对齐子集;clone/reseed 消息未实现 | -| Logs | persistent per-VM log,tail/follow/reopen,delete 清理 log dir | log 已持久化,CLI 与 rm cleanup 未实现 | 下一条命令同时补齐读取和归属清理 | +| Logs | persistent per-VM log,tail/follow/reopen,delete 清理 log dir | backend stream、tail/follow、truncate/reopen、rm cleanup | 已对齐;KumaBox 用同步有界轮询避免 watcher goroutine 泄漏 | | Registry/backends | Cloud Hypervisor + Firecracker | 显式 Registry;只有 Cloud Hypervisor | Firecracker 后续实现相同基础合同 | | Cgroup | per-VM scope、CPU policy、cleanup/GC | per-sandbox scope、基础 CPU limit、cleanup | 基础对齐;完整 policy/GC 后续 | -| CNI network | reserve → netns → NIC intents → ADD/TAP/TC;stop quiesce/start recover/rm DEL | 未实现 | `logs` 后立即进入网络切片 | +| CNI network | reserve → netns → NIC intents → ADD/TAP/TC;stop quiesce/start recover/rm DEL | 未实现 | 下一条主线进入网络切片 | | Run | create + start,网络和资源 flags 一次确定 | 未实现 | 网络持久模型完成后实现,避免返工 | | Status/reconcile | durable + observed state、watch/event、dead process convergence | `ps` 只读 metadata | run 后增加显式 status/recovery | | Snapshot/clone | capture/restore/hibernate/clone、lease 和 identity reseed | 未实现 | 网络稳定后按相同故障边界分阶段实现 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 50a9d28..f337781 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -25,34 +25,16 @@ - `create`、`ps`、`inspect`、`rm`; - `start`、`stop`、`console`、`exec`; +- `logs` 全量/tail/follow、truncate/reopen 恢复和删除时日志清理; - sparse ext4 COW、cgroup v2、Cloud Hypervisor direct boot; - PID/starttime/boot ID/binary/socket identity; - guest agent NDJSON exec 子集。 这些功能已完成代码和跨平台门禁。Linux/KVM 行为仍必须在发布前按 runbook 重验。 -## 下一步:`logs` +## 下一步:网络基础 -这是下一条命令。Cloud Hypervisor 已把 stdout/stderr 写入每个 sandbox 的持久 `vmm.log`,缺少的是受控读取接口。 - -目标与 Cocoon 对齐: - -```text -kumabox logs SANDBOX -kumabox logs --tail N SANDBOX -kumabox logs -f SANDBOX -``` - -- 按 name/ID 解析 sandbox,再由 VMM backend 提供日志来源,不让 CLI 拼路径。 -- `--tail 0` 输出全部;正数输出最后 N 行。 -- `-f` 支持取消、文件增长和 VMM 重启后的 truncate/reopen。 -- 从未启动时返回明确错误;停止后日志仍可读。 -- stdout 只写日志内容,诊断写 stderr。 -- 日志归 VMM backend 所有;`rm` 在 metadata finalize 前可重试地删除 log dir,失败保留 `deleting`。这补齐当前 rm 只清理 COW、未回收持久日志的缺口。 - -## 随后:网络基础 - -网络在 `logs` 后、`run` 前实现。原因是 Cocoon 的网络身份在 sandbox reserve 之后、VMM create/start 之前建立,并贯穿 start/stop/rm;先做无网络 `run` 会重复修改命令、metadata 和补偿流程。 +网络在 `run` 前实现。原因是 Cocoon 的网络身份在 sandbox reserve 之后、VMM create/start 之前建立,并贯穿 start/stop/rm;先做无网络 `run` 会重复修改命令、metadata 和补偿流程。 第一版范围: diff --git a/docs/proposals/s3-sandbox.md b/docs/proposals/s3-sandbox.md index f517861..59c8e79 100644 --- a/docs/proposals/s3-sandbox.md +++ b/docs/proposals/s3-sandbox.md @@ -1,6 +1,6 @@ # S3 Sandbox 主线 -> 状态:基础生命周期已实现;下一条命令为 `logs`,随后进入网络切片。 +> 状态:基础生命周期与 `logs` 已实现;下一阶段进入网络切片。 ## 已实现闭环 @@ -13,7 +13,7 @@ import image → restart or rm ``` -已实现命令:`create`、`start`、`stop`、`ps`、`inspect`、`console`、`exec`、`rm`。 +已实现命令:`create`、`start`、`stop`、`ps`、`inspect`、`console`、`exec`、`logs`、`rm`。 已实现核心合同: @@ -24,7 +24,7 @@ import image - guest exec 使用 bounded NDJSON frame,不把断线冒充 exit 0; - stdout/stderr、JSON 和 CLI exit code 有真实 binary 测试。 -## `logs` 切片 +## 已完成的 `logs` 切片 Cloud Hypervisor adapter 已在 `/var/log/kumabox/sandboxes//vmm.log` 持久化 stdout/stderr。新增命令只公开受控读取能力: @@ -44,7 +44,7 @@ CLI resolve name/ID - CLI 不拼接 log path,`core` 不实现 tail 算法,VMM 模块拥有其日志。 - `rm` 在最终释放 metadata/name/image pin 前删除 backend 拥有的 log dir;失败保留 `deleting` 并允许相同命令重试。 -当前 `rm` 只删除 COW 并释放 metadata,持久 `vmm.log` 会留下。该缺口必须在本切片一起修复,行为才与 Cocoon 的 delete cleanup 对齐。 +实现使用同步轮询跟随受管文件,不创建 watcher goroutine。文件 inode 替换时重新打开;同一 inode 被截断时通过 size 和稳定头部签名回到 offset 0。`rm` 已在 metadata finalize 前执行 backend log cleanup,失败保留 `deleting`。 ## 为什么网络在 `run` 之前 @@ -56,7 +56,7 @@ CLI resolve name/ID - start rollback、stop quiesce、rm cleanup; - JSON 输出和 runbook。 -因此 `logs` 完成后直接进入网络;网络闭环验收后实现 `run`。 +因此当前直接进入网络;网络闭环验收后实现 `run`。 ## 网络第一版边界 diff --git a/docs/runbooks/s3-create.md b/docs/runbooks/s3-create.md index 4f2374e..9495e24 100644 --- a/docs/runbooks/s3-create.md +++ b/docs/runbooks/s3-create.md @@ -1,6 +1,6 @@ # S3 Sandbox Linux 验收 -本 runbook 验证当前无网络生命周期:create、start、exec、console、stop、restart 和 rm。需要 Linux、KVM、cgroup v2、Cloud Hypervisor、`mkfs.erofs`、`mkfs.ext4`、jq,以及一份带 `overlay-v1` profile 和 `kumabox-agent` 的真实镜像。 +本 runbook 验证当前无网络生命周期:create、start、exec、logs、console、stop、restart 和 rm。需要 Linux、KVM、cgroup v2、Cloud Hypervisor、`mkfs.erofs`、`mkfs.ext4`、jq,以及一份带 `overlay-v1` profile 和 `kumabox-agent` 的真实镜像。 ## 准备 @@ -57,6 +57,16 @@ test -S "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID/vsock.uds" cat "$KUMABOX_S3_WORK/log/sandboxes/$KUMABOX_S3_ID/vmm.log" ``` +## Logs、tail 和 follow + +```bash +kb logs lifecycle | tee "$KUMABOX_S3_WORK/log-all.txt" +kb logs --tail 20 lifecycle | tee "$KUMABOX_S3_WORK/log-tail.txt" +kb logs -f lifecycle +``` + +确认全量输出包含启动日志,tail 不超过最后 20 行。保持 `logs -f` 运行,在另一个终端执行 `kb stop lifecycle && kb start lifecycle`;follower 应显示新一轮启动日志且不重复旧文件尾部。按 `Ctrl-C` 后命令正常退出,sandbox 继续运行。 + ## Stop、持久数据和 restart ```bash @@ -79,6 +89,7 @@ kb image rm "$KUMABOX_S3_IMAGE"; test "$?" -eq 4 kb rm lifecycle --json | tee "$KUMABOX_S3_WORK/remove.json" test "$(jq -r '.id' "$KUMABOX_S3_WORK/remove.json")" = "$KUMABOX_S3_ID" test ! -e "$KUMABOX_S3_WORK/data/sandboxes/$KUMABOX_S3_ID" +test ! -e "$KUMABOX_S3_WORK/log/sandboxes/$KUMABOX_S3_ID" kb image rm "$KUMABOX_S3_IMAGE" test "$(kb ps -a --json)" = "[]" ``` @@ -93,6 +104,7 @@ test "$(kb ps -a --json)" = "[]" 4. rm 清理中断,重试继续 `deleting`; 5. 破坏 process.json 的 boot ID、binary 或 socket 后,stop 返回冲突且不发送信号; 6. agent 未启动时 exec 有界失败,VMM 保持可 inspect/stop; -7. 非 TTY 重定向无 ANSI 控制字符,JSON 保持缩进。 +7. logs follow 在 truncate/reopen 后继续,取消后无残留进程或 FD; +8. 非 TTY 重定向无 ANSI 控制字符,JSON 保持缩进。 记录 commit、内核、架构、Cloud Hypervisor、cgroup mode、formatter 版本、完整 image digest、每个命令退出码和失败后的持久状态。 diff --git a/vmm/backend.go b/vmm/backend.go index d67a3a6..42f613a 100644 --- a/vmm/backend.go +++ b/vmm/backend.go @@ -25,7 +25,9 @@ type Backend interface { 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 } // Registry is an immutable routing table from durable VMM identities to their diff --git a/vmm/backend_test.go b/vmm/backend_test.go index a3166c5..f53a841 100644 --- a/vmm/backend_test.go +++ b/vmm/backend_test.go @@ -35,7 +35,12 @@ func (registryBackend) Console(context.Context, Process) (io.ReadWriteCloser, er func (registryBackend) DialVsock(context.Context, Process, uint32) (io.ReadWriteCloser, error) { return nil, nil } -func (registryBackend) Cleanup(context.Context, types.SandboxID) error { return 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} diff --git a/vmm/cloudhypervisor/driver.go b/vmm/cloudhypervisor/driver.go index 76555db..a612ca4 100644 --- a/vmm/cloudhypervisor/driver.go +++ b/vmm/cloudhypervisor/driver.go @@ -351,6 +351,11 @@ func (d *Driver) Console(ctx context.Context, process vmm.Process) (io.ReadWrite 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 { @@ -359,6 +364,11 @@ func (d *Driver) Cleanup(ctx context.Context, id types.SandboxID) error { 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) 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 } From c80d4392fc28374658e5884bc7c961485372180a Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Mon, 21 Sep 2026 22:44:31 +0800 Subject: [PATCH 37/45] chore: keep project docs local --- .github/workflows/ci.yml | 6 +- .gitignore | 2 + CONTRIBUTING.md | 16 +- Makefile | 7 +- README.md | 14 +- docs/ARCHITECTURE.md | 123 -------- docs/BEHAVIOR.md | 133 --------- docs/COCOON-MAP.md | 40 --- docs/CONFIGURATION.md | 65 ---- docs/DECISIONS.md | 55 ---- docs/HOST.md | 49 --- docs/PERFORMANCE.md | 44 --- docs/PRODUCT.md | 61 ---- docs/README.md | 31 -- docs/REFACTORING.md | 547 ---------------------------------- docs/ROADMAP.md | 82 ----- docs/architecture-diagrams.md | 72 ----- docs/proposals/s3-sandbox.md | 77 ----- docs/releasing.md | 39 --- docs/runbooks/s2-oci.md | 82 ----- docs/runbooks/s3-create.md | 110 ------- scripts/check-doc-links.sh | 21 -- 22 files changed, 15 insertions(+), 1661 deletions(-) delete mode 100644 docs/ARCHITECTURE.md delete mode 100644 docs/BEHAVIOR.md delete mode 100644 docs/COCOON-MAP.md delete mode 100644 docs/CONFIGURATION.md delete mode 100644 docs/DECISIONS.md delete mode 100644 docs/HOST.md delete mode 100644 docs/PERFORMANCE.md delete mode 100644 docs/PRODUCT.md delete mode 100644 docs/README.md delete mode 100644 docs/REFACTORING.md delete mode 100644 docs/ROADMAP.md delete mode 100644 docs/architecture-diagrams.md delete mode 100644 docs/proposals/s3-sandbox.md delete mode 100644 docs/releasing.md delete mode 100644 docs/runbooks/s2-oci.md delete mode 100644 docs/runbooks/s3-create.md delete mode 100755 scripts/check-doc-links.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6fda03..9754a36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: # 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 per docs/ROADMAP.md; see that file for the runbooks. + # executed manually on Linux hosts with KVM and the required VMM tooling. verify: runs-on: ubuntu-latest steps: @@ -43,5 +43,5 @@ jobs: go mod tidy git diff --exit-code -- go.mod go.sum - - name: Check documentation links - run: make docs-check + - name: Reject tracked local documentation + run: test -z "$(git ls-files docs)" diff --git a/.gitignore b/.gitignore index ece97d6..098f33d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ dist/ .rewrite-backup/ .claude/ coverage.out +# Local design and implementation notes must never be committed. +docs/ # Local Go and tooling caches are generated, never source. .cache*/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1dd0115..06c8899 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,18 +26,8 @@ make verify make lint ``` -Run the relevant Linux acceptance runbook for changes involving KVM, Cloud Hypervisor, cgroup v2, CNI, EROFS, ext4, or vsock. Record the host versions and result in the pull request. +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 and documentation +## Compatibility -Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` is the feature and behavior reference. A change may intentionally differ when KumaBox has a stronger safety or modularity guarantee, but the difference must be documented in [docs/COCOON-MAP.md](docs/COCOON-MAP.md). - -Update documentation in the same commit when a change affects: - -- commands, flags, output, exit codes, or lifecycle behavior; -- package ownership or dependency direction; -- persisted metadata, managed paths, or recovery rules; -- host requirements, configuration, or guest protocols; -- roadmap status or Linux acceptance steps. - -Use `make docs-check` to validate relative Markdown links. +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 20e6273..46576c7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build agent install test doctor-check docs-check race verify lint vet fmt fmt-check deps clean coverage cloc help +.PHONY: all build agent install test doctor-check race verify lint vet fmt fmt-check deps clean coverage cloc help REPO_PATH := github.com/kumabox/kumabox @@ -82,13 +82,10 @@ doctor-check: ## Check host and guest shell script syntax bash -n scripts/kumabox-check.sh sh -n oci-images/ubuntu/overlay.sh -docs-check: ## Check relative Markdown links - bash scripts/check-doc-links.sh - race: ## Run all Go tests with race detection go test -race ./... -verify: fmt-check vet doctor-check docs-check test build ## Verify formatting, docs, tests and build +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 diff --git a/README.md b/README.md index 51cf621..08b7420 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ kumabox create|start|stop|ps|inspect|logs|console|exec|rm kumabox version ``` -Image import and sandbox lifecycle are implemented locally. Real Cloud Hypervisor, cgroup, vsock, ext4, and EROFS behavior requires Linux and is covered by the checked-in runbooks. Networking, `run`, snapshots, clone, and Firecracker remain planned work; see [the roadmap](docs/ROADMAP.md). +Image import and sandbox lifecycle are implemented locally. Real Cloud Hypervisor, cgroup, vsock, ext4, and EROFS behavior requires Linux acceptance testing. Networking, `run`, snapshots, clone, and Firecracker remain planned work. ## Build and test @@ -40,7 +40,7 @@ sudo make install kumabox doctor ``` -The host checker reports Linux, KVM, cgroup v2, Cloud Hypervisor, `mkfs.erofs`, `mkfs.ext4`, and other runtime prerequisites. See [host requirements](docs/HOST.md). +The host checker reports Linux, KVM, cgroup v2, Cloud Hypervisor, `mkfs.erofs`, `mkfs.ext4`, and other runtime prerequisites. ## Image workflow @@ -82,7 +82,7 @@ Configuration precedence is: explicit flag > environment > explicit --config file > default ``` -KumaBox never searches for an implicit configuration file. Root paths can be set with `--root-dir`, `--run-dir`, and `--log-dir`. All settings and environment variable names are documented in [configuration](docs/CONFIGURATION.md). +KumaBox never searches for an implicit configuration file. Root paths can be set with `--root-dir`, `--run-dir`, and `--log-dir`. ## Architecture @@ -101,15 +101,11 @@ The repository uses root-level modules instead of `internal` or a generic `pkg` | `metadata` | Transaction contracts and SQLite implementation | | `cgroup`, `storage`, `lock/flock` | Host resource adapters | -The full ownership and dependency rules are in [architecture](docs/ARCHITECTURE.md). User-visible contracts are in [behavior](docs/BEHAVIOR.md). - -## Documentation - -Start with [docs/README.md](docs/README.md). Normative design documents, accepted decisions, runbooks, and the active roadmap are tracked in Git and reviewed with code. +Local design notes and Linux runbooks live under the ignored `docs/` directory. They are working material and are never committed. ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md). Changes to command behavior, architecture, persistent data, or guest protocols must update the corresponding normative document in the same commit. +See [CONTRIBUTING.md](CONTRIBUTING.md). ## License diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index bcc918b..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,123 +0,0 @@ -# KumaBox 架构规范 - -> 状态:normative - -## 1. 结构 - -KumaBox 使用按能力划分的根级 Go 包,不使用 `internal`、通用 `pkg`、`utils` 或按“接口/结构体”种类划分的包。 - -```text -cmd/kumabox ──► cli ──► core - │ - ┌───────────┼─────────────┐ - ▼ ▼ ▼ - images sandbox vmm - │ catalog/disk │ - ▼ │ cloudhypervisor - metadata/sqlite ◄─┘ │ - ▲ ▼ - └──────── types ◄──── cgroup/agent -``` - -- `cmd/kumabox`:信号、进程退出码和入口。 -- `cli`:命令树、参数解析、展示和 stdout/stderr 分流。 -- `core`:跨模块应用流程、补偿顺序和具体 adapter 装配。 -- `types`:跨模块资源数据和值对象;不放接口、SQL 编码或 CLI DTO。 -- `images`:镜像来源无关的导入、验证、启动文件选择和删除规则。 -- `sandbox`、`disk`:sandbox 路径、锁和 writable disk。 -- `vmm`:backend 合同、registry、launch plan、process identity。 -- `vmm/cloudhypervisor`:Cloud Hypervisor 参数、进程、API 和 console adapter。 -- `agent`:guest exec 消息和传输。 -- `metadata`:事务合同;`metadata/sqlite` 是当前持久化实现。 - -`cli` 不编排跨模块事务。具体 adapter 不导入 `core` 或 `cli`。接口放在拥有能力的模块,或唯一消费该能力的包中。 - -## 2. 组装入口 - -每条命令加载一次不可变 `config.Config`。`core.OpenImages` 和 `core.OpenSandbox` 创建路径、SQLite store、catalog、disk、cgroup 和 VMM registry。模块收到自己的 Options 后不再读取环境或全局配置。 - -```text -defaults → explicit config file → environment → flags → Validate - ↓ - core assembly -``` - -## 3. 持久化与锁 - -SQLite 是资源事实源。文件系统保存大文件和运行产物,不替代 metadata 状态。 - -- image digest 锁串行化同一内容的发布和删除。 -- sandbox 实体锁串行化 create/start/stop/rm;logs follow 不持锁。 -- generation compare-and-swap 拒绝陈旧状态提交。 -- 慢 I/O 不放在 SQLite 写事务中。 -- 发布顺序为“持久意图 → 慢操作 → 验证宿主事实 → 短事务提交”。 -- 取消后的补偿使用独立且有界的 context。 - -## 4. 镜像流程 - -```text -resolve source → verify source digest/diffID → convert staging - → digest locks → recheck/reuse or atomic publish - → final artifact verification → metadata commit -``` - -manifest 层序保持 base-to-top;VMM 磁盘也按该顺序附加。guest overlay lowerdir 按 top-to-base 使用。重复 source digest 可以复用存储,但不能从 manifest 设备序列中删除。 - -## 5. Sandbox 状态机 - -```text -creating → created → starting → running → stopping → stopped - │ │ │ │ - └────── error ◄──────┴──────────┘ │ - start -created/stopped/error ── rm ──► deleting ── cleanup ──► removed -``` - -- `created` 表示资源已准备、从未启动。 -- `stopped` 表示至少成功启动过一次且 VMM 已退出。 -- `error` 保留失败 phase 和资源所有权。 -- `deleting` 是可重试的清理意图,最终事务同时释放 name、record 和 image pin。 - -## 6. VMM 与进程身份 - -`vmm.Backend` 定义所有 backend 必须具备的基础生命周期能力。`vmm.Registry` 是构造后不可变的显式实例。未来 Firecracker 实现同一合同;只有 snapshot 等真实可选能力出现时才增加窄的可选接口。 - -进程身份至少包含 PID、`/proc` starttime、host boot ID、sandbox ID、generation、binary 和 API socket。观察、信号和清理必须验证完整身份。Linux 信号路径使用 pidfd 固定目标。 - -Cloud Hypervisor readiness 需要同时满足:进程身份仍一致、API 可连接、`vm.info` 为 `Running`。socket 文件存在不等于成功。 - -VMM backend 同时拥有持久进程日志的读取和删除能力。`core` 只按 sandbox 中持久化的 VMM 类型路由,CLI 不拼宿主路径。stop 只清理可重建 runtime/cgroup 并保留日志;rm 按 COW → backend logs → metadata finalize 的顺序回收,任何失败保留 `deleting`。 - -## 7. Guest agent - -host 通过 Cloud Hypervisor hybrid-vsock UDS 连接 guest port 1024。应用协议是有大小上限的 NDJSON:`exec`、`stdin`、`stdin_close`、`started`、`stdout`、`stderr`、`exit`、`error`。guest EOF 没有 terminal frame 时是协议失败,不能当 exit 0。 - -当前协议行为与参考实现的 exec 子集兼容,但 boot profile、kernel 参数和 service 名称属于 KumaBox。 - -## 8. 网络接入原则 - -网络尚未实现。接入时沿用参考实现已经验证的顺序: - -```text -reserve sandbox identity - → prepare netns - → persist per-NIC cleanup intent - → CNI ADD + TAP/TC redirect - → persist resolved MAC/IP/network identity - → launch VMM inside netns -``` - -stop 保留 netns/TAP/IP,并 quiesce host veth;start 先 recover/unquiesce;rm 执行可重试 CNI DEL 和 netns 清理。任何失败都保留足以重试的逐 NIC 记录。网络事实进入共享 `types`,网络能力属于新的根级 `network` 包,编排仍在 `core.SandboxService`。 - -## 9. Cocoon 对齐规则 - -每项功能都检查参考实现的命令、持久事实、锁、进程/网络顺序、失败恢复和测试。默认采用经过验证的语义;若 KumaBox 选择不同方案,必须在对照表中记录原因和兼容影响。不得为了表面同名破坏现有更强的完整性或模块边界。 - -## 10. 测试层级 - -- 单元测试:值对象、解析、状态和纯计划。 -- adapter 测试:真实 SQLite/文件系统、本地 socket、进程和取消。 -- binary 测试:真实 `kumabox` 的输出、退出码和信号。 -- Linux runbook:KVM、Cloud Hypervisor、cgroup v2、EROFS、ext4、vsock,未来包括 CNI。 - -`make verify`、`make race` 和 `make lint` 是提交门禁。Linux 专属行为不能用 mock 结果冒充真实验收。 diff --git a/docs/BEHAVIOR.md b/docs/BEHAVIOR.md deleted file mode 100644 index 75d28ba..0000000 --- a/docs/BEHAVIOR.md +++ /dev/null @@ -1,133 +0,0 @@ -# KumaBox 行为规范 - -> 状态:normative - -本文件只描述当前已实现行为。规划中的命令见 [ROADMAP.md](ROADMAP.md)。 - -## 1. 通用行为 - -- 资源引用接受完整 sandbox UUID 或精确名称,不接受模糊前缀。 -- 表格输出包含标题;JSON 使用两个空格缩进并以换行结尾。 -- stdout 只输出结果,stderr 输出进度和错误。 -- TTY 上进度使用 spinner;重定向时使用有限的普通文本行,不写控制字符。 -- SIGINT/SIGTERM 取消当前操作。已越过提交点的错误会明确保留资源并要求 inspect。 - -退出码: - -| 状态 | 含义 | -|---:|---| -| 0 | 成功 | -| 1 | 未分类内部失败或 guest command 通用失败 | -| 2 | 命令、flag 或参数数量错误 | -| 3 | 资源不存在 | -| 4 | 名称、状态或引用冲突 | -| 5 | 参数、主机、镜像或内容不兼容/损坏 | -| 6 | 产物暂不可用或 metadata store 超时 | - -`exec` 中 guest 进程的非零退出码直接成为本地退出码。 - -## 2. `doctor` - -`kumabox doctor [--fix] [--upgrade] [--subnet=CIDR]` 将参数和标准流交给 `kumabox-check`。无修复参数时只检查。安装、升级或修改宿主机只在用户显式传入相应参数时发生。 - -## 3. 镜像命令 - -### `image pull REF` - -从 registry 解析指定平台的 OCI manifest,验证 config、layer digest 和 diffID,转换并提交本地镜像。默认平台为当前架构对应的 `linux/amd64` 或 `linux/arm64`。 - -### `image import NAME PATH` - -支持 OCI layout 目录、OCI archive 和 Docker save archive。`--format auto|oci|docker` 控制解析器;auto 按内容检测。Docker archive 多镜像时可用 `--source-tag` 选择。压缩与否不依赖扩展名。 - -导入满足: - -1. source 内容完整校验; -2. layer 流式解包并转换为 EROFS; -3. boot whiteout/opaque/覆盖语义按 OCI 层序计算; -4. staging 产物在 digest 锁内原子发布; -5. 完整验证后才提交 metadata。 - -同一内容可复用已验证的受管产物。失败不会留下可见的成功 image record。 - -### 查询和删除 - -- `image list`,别名 `image ls`:表格;`--json` 输出完整数组。 -- `image inspect IMAGE`:缩进 JSON。 -- `image verify IMAGE`:重新验证 layer、EROFS 和 boot artifacts。 -- `image remove IMAGE...`,别名 `image rm`:删除名称;最后一个引用消失后清理产物。 - -被 sandbox pin 的镜像不能删除。 - -## 4. Sandbox 命令 - -### `create IMAGE --name NAME` - -可选资源 flags:`--cpus`、`--memory`、`--storage`、`--json`。默认 2 vCPU、1 GiB 内存和 10 GiB sparse COW;最小内存 512 MiB,最小存储 10 GiB。 - -流程: - -```text -validate → lock → resolve and pin image → reserve creating record - → create/format cow.raw → verify → CAS created -``` - -`created` 不代表 VMM 已启动。创建失败保留 `error` 记录和诊断,清理完成后可由 `rm` 删除。 - -### `start SANDBOX` - -只接受 `created`、`stopped` 或可恢复的 `starting`。启动前验证 pinned image、`overlay-v1` profile、kernel/initramfs、COW、KVM、cgroup 和 VMM binary。 - -```text -lock → CAS starting → prepare runtime/cgroup → launch - → persist process identity → wait vm.info Running → CAS running -``` - -启动失败会终止本次进程并清理 runtime;无法完整补偿时保留可诊断状态。重试同一 sandbox 不创建新身份或新 COW。 - -### `stop SANDBOX` - -对 `created` 和 `stopped` 幂等成功。`running` 先提交 `stopping`,向 Cloud Hypervisor 请求 `vm.shutdown`,随后对完全匹配的进程执行 SIGTERM,超过配置窗口后 SIGKILL。确认进程退出并清理 runtime/cgroup 后才提交 `stopped`。 - -中断后保持 `stopping`,再次执行同一命令继续收敛。 - -### `ps` - -默认只显示活动状态;`-a/--all` 包含所有持久记录。`--quiet` 只输出完整 UUID,`--json` 输出完整数组;二者互斥。查询不修改状态。 - -### `inspect SANDBOX` - -始终输出缩进 JSON,包括 immutable 资源规格、image digest、VMM、state、generation、时间和可选 failure。generation 是状态提交的单调版本,用于阻止陈旧操作覆盖新状态。 - -### `console SANDBOX` - -仅连接 `running` sandbox。命令验证当前 generation、process identity 和 VMM API 后打开 PTY;不会在 relay 期间持有实体锁。默认按 `Ctrl-]` 后 `.` 断开,可用 `--escape-char` 修改。断开 console 不停止 sandbox。 - -### `exec SANDBOX -- COMMAND` - -参数直接交给 guest,不隐式插入 shell。`-e/--env KEY=VALUE` 可重复;`-i/--interactive` 才连接 stdin,否则立即发送 `stdin_close`。stdout/stderr 独立透传,guest exit code 原样返回。 - -### `logs [--tail N] [-f] SANDBOX` - -按 name 或完整 ID 解析 sandbox,并由其持久 VMM backend 提供日志。默认 `--tail 0` 输出完整日志;正数从最后 N 行开始。`-f/--follow` 继续读取追加内容,VMM 重启导致文件截断或替换时从新文件头继续;取消 follow 正常退出。 - -日志内容只写 stdout。sandbox 从未启动、尚无日志时返回 `ARTIFACT_UNAVAILABLE`。stop 后日志保留并可继续读取;命令不要求 sandbox 处于 running,也不会在 follow 期间持有实体锁。 - -### `rm SANDBOX` - -拒绝删除活动状态;VMM runtime 和 cgroup 必须先由 `stop` 收敛。命令提交 `deleting`,依次清理 COW 和 backend 拥有的持久日志,最后在一个事务中删除 record/name 并释放 image pin。任一步失败都保留 `deleting` 供相同命令重试;只有 metadata finalize 成功后 name 和 image pin 才释放。 - -## 5. 受管路径 - -默认路径: - -```text -/var/lib/kumabox/metadata.db -/var/lib/kumabox/images/... -/var/lib/kumabox/sandboxes//cow.raw -/run/kumabox/locks/... -/run/kumabox/sandboxes//... -/var/log/kumabox/sandboxes//vmm.log -``` - -data、run、log roots 必须是绝对、互不重叠且不经过非系统 symlink 的路径。 diff --git a/docs/COCOON-MAP.md b/docs/COCOON-MAP.md deleted file mode 100644 index 3a29a8b..0000000 --- a/docs/COCOON-MAP.md +++ /dev/null @@ -1,40 +0,0 @@ -# KumaBox 与 Cocoon 能力对照 - -> 参考基线:Cocoon `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` -> -> 状态:living reference。每个功能切片完成时更新。 - -| 能力 | Cocoon 实现 | KumaBox 状态 | 结论 | -|---|---|---|---| -| CLI 生命周期 | `vm create/run/start/stop/list/inspect/console/exec/logs/rm/status` | 已有 create/start/stop/ps/inspect/console/exec/logs/rm | 命令行为逐项对齐;KumaBox 当前不加 `vm` 中间层 | -| OCI image | OCI layer 转换、direct boot artifacts | registry/OCI/Docker save、digest/diffID/EROFS/boot 验证 | 已对齐机制;KumaBox 内容校验更严格 | -| Boot layout | RO EROFS layers + ext4 COW + overlay initramfs | `overlay-v1`,相同设备/层序机制 | 机制对齐,协议名使用 `kumabox.*` | -| 状态 | created/running/stopped/error 与转换 generation | creating/created/starting/running/stopping/stopped/error/deleting | 对齐用户状态;KumaBox 显式持久中间意图 | -| 进程身份 | PID/starttime、受管 socket/dir、收敛器 | 另加 boot ID、binary、generation,Linux pidfd | KumaBox 保留更强身份验证 | -| Cloud Hypervisor stop | OCI path: API shutdown → TERM → 5s → KILL | 相同主路径,可配置 grace | 已对齐 | -| Console | PTY relay、resize、escape detach | PTY relay、resize、`^].` detach | 已对齐当前需要的交互合同 | -| Guest exec | hybrid-vsock + bounded NDJSON frames | 独立实现兼容 exec 子集 | 已对齐子集;clone/reseed 消息未实现 | -| Logs | persistent per-VM log,tail/follow/reopen,delete 清理 log dir | backend stream、tail/follow、truncate/reopen、rm cleanup | 已对齐;KumaBox 用同步有界轮询避免 watcher goroutine 泄漏 | -| Registry/backends | Cloud Hypervisor + Firecracker | 显式 Registry;只有 Cloud Hypervisor | Firecracker 后续实现相同基础合同 | -| Cgroup | per-VM scope、CPU policy、cleanup/GC | per-sandbox scope、基础 CPU limit、cleanup | 基础对齐;完整 policy/GC 后续 | -| CNI network | reserve → netns → NIC intents → ADD/TAP/TC;stop quiesce/start recover/rm DEL | 未实现 | 下一条主线进入网络切片 | -| Run | create + start,网络和资源 flags 一次确定 | 未实现 | 网络持久模型完成后实现,避免返工 | -| Status/reconcile | durable + observed state、watch/event、dead process convergence | `ps` 只读 metadata | run 后增加显式 status/recovery | -| Snapshot/clone | capture/restore/hibernate/clone、lease 和 identity reseed | 未实现 | 网络稳定后按相同故障边界分阶段实现 | -| Metadata | JSON/SQLite engines | transaction contract + SQLite | 不复制双后端;保留模块合同测试 | -| Error model | sentinel/wrapped errors | stable code/class/context/committed/action | KumaBox 保留更完整公共错误合同 | -| Package layout | 能力包与 cmd handler 直接编排较多 | `cli → core → modules` | 不复制结构;行为和失败边界对齐 | - -## 网络对齐重点 - -Cocoon 已验证的关键点必须进入 KumaBox 首版网络: - -1. sandbox/VM identity 在创建 netns 和 TAP 前持久 reserve; -2. 每个 NIC 在 CNI ADD 前写 cleanup intent; -3. partial ADD/DEL 保留记录,允许同命令或 GC 重试; -4. VMM 在目标 netns 中启动; -5. stop quiesce host veth,避免 down TAP 引发软中断开销; -6. start recover netns/TAP/IP identity 并 unquiesce; -7. rm 只有在逐 NIC DEL、TAP 和 netns 清理完成后才释放资源记录。 - -KumaBox 第一版先实现 CNI,不同时实现 bridge 与 hot resize。接口在真实第二实现出现时扩展,不预先复制全部能力。 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md deleted file mode 100644 index e94b7d2..0000000 --- a/docs/CONFIGURATION.md +++ /dev/null @@ -1,65 +0,0 @@ -# KumaBox 配置 - -> 状态:normative - -每次命令解析一个独立配置快照。优先级从高到低: - -```text -显式 flag > KUMABOX_* 环境变量 > --config 指定文件 > 默认值 -``` - -未传 `--config` 时不会搜索当前目录、用户目录或 `/etc`。配置文件按扩展名支持 YAML、JSON 和 TOML;未知字段或无效值立即失败。 - -## 根 flags - -| Flag | 配置键 | 默认值 | -|---|---|---| -| `--root-dir` | `paths.data` | `/var/lib/kumabox` | -| `--run-dir` | `paths.run` | `/run/kumabox` | -| `--log-dir` | `paths.log` | `/var/log/kumabox` | -| `--config` | — | 不读取文件 | - -## 配置键 - -| 键 | 环境变量 | 默认值 | -|---|---|---| -| `paths.data` | `KUMABOX_PATHS_DATA` | `/var/lib/kumabox` | -| `paths.run` | `KUMABOX_PATHS_RUN` | `/run/kumabox` | -| `paths.log` | `KUMABOX_PATHS_LOG` | `/var/log/kumabox` | -| `images.erofs_binary` | `KUMABOX_IMAGES_EROFS_BINARY` | `mkfs.erofs` | -| `images.parallelism` | `KUMABOX_IMAGES_PARALLELISM` | `min(4, host CPUs)` | -| `images.layer_size` | `KUMABOX_IMAGES_LAYER_SIZE` | 8 GiB | -| `images.unpacked_size` | `KUMABOX_IMAGES_UNPACKED_SIZE` | 16 GiB | -| `images.boot_size` | `KUMABOX_IMAGES_BOOT_SIZE` | 512 MiB | -| `images.archive_size` | `KUMABOX_IMAGES_ARCHIVE_SIZE` | 32 GiB | -| `metadata.busy_timeout` | `KUMABOX_METADATA_BUSY_TIMEOUT` | `50ms` | -| `metadata.retry_limit` | `KUMABOX_METADATA_RETRY_LIMIT` | `5s` | -| `sandbox.ext4_binary` | `KUMABOX_SANDBOX_EXT4_BINARY` | `mkfs.ext4` | -| `sandbox.cleanup_timeout` | `KUMABOX_SANDBOX_CLEANUP_TIMEOUT` | `10s` | -| `vmm.default` | `KUMABOX_VMM_DEFAULT` | `cloud-hypervisor` | -| `vmm.cgroup_parent` | `KUMABOX_VMM_CGROUP_PARENT` | `/sys/fs/cgroup/kumabox.slice` | -| `vmm.cloud_hypervisor.binary` | `KUMABOX_VMM_CLOUD_HYPERVISOR_BINARY` | `cloud-hypervisor` | -| `vmm.cloud_hypervisor.startup_timeout` | `KUMABOX_VMM_CLOUD_HYPERVISOR_STARTUP_TIMEOUT` | `10s` | -| `vmm.cloud_hypervisor.stop_grace` | `KUMABOX_VMM_CLOUD_HYPERVISOR_STOP_GRACE` | `5s` | -| `vmm.cloud_hypervisor.abort_grace` | `KUMABOX_VMM_CLOUD_HYPERVISOR_ABORT_GRACE` | `3s` | - -## YAML 示例 - -```yaml -paths: - data: /srv/kumabox/data - run: /run/kumabox - log: /srv/kumabox/log -images: - parallelism: 4 -metadata: - busy_timeout: 100ms - retry_limit: 5s -vmm: - cgroup_parent: /sys/fs/cgroup/kumabox.slice - cloud_hypervisor: - binary: /usr/local/bin/cloud-hypervisor - startup_timeout: 15s -``` - -模块不能直接读取这些环境变量。新增运行策略必须先决定它是稳定协议常量,还是进入本配置结构的部署策略。 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md deleted file mode 100644 index 3c8af8c..0000000 --- a/docs/DECISIONS.md +++ /dev/null @@ -1,55 +0,0 @@ -# KumaBox 决策记录 - -> 状态:normative。新决策追加;替代旧决策时明确写出关系。 - -## D001 — daemonless CLI 是当前执行模型 - -每条命令独立加载配置、打开 store、获取跨进程锁、完成操作并退出。当前不提供 daemon、gRPC 或本地控制 socket。运行中的 VMM 自身是独立进程。 - -## D002 — 使用根级能力包 - -不使用 `internal`、通用 `pkg`、`utils` 或按声明种类拆包。`core` 是应用服务和 adapter 组装层,不放在 `cmd` 下。 - -## D003 — `types` 只保存共享数据和值对象 - -接口属于拥有能力的模块,或其真实消费方。CLI DTO、SQL encoding、Cobra flags 和 terminal 状态不进入 `types`。 - -## D004 — SQLite 是当前 metadata 实现 - -`metadata` 提供 transaction contract,`metadata/sqlite` 实现它。资源事实以短事务提交,慢文件和进程操作不持有写事务。跨进程互斥依赖实体 flock 和 generation CAS。 - -## D005 — 镜像使用内容寻址和完整校验 - -导入必须验证 source digest、diffID、EROFS 和 boot artifacts。缓存命中不能只检查路径或大小。发布先进入 staging,在 digest 锁内复查并原子替换,最后提交 metadata。 - -## D006 — OCI boot contract 使用 `overlay-v1` - -可启动镜像显式声明 `io.kumabox.boot.profile=overlay-v1`。kernel 参数和 disk serial 使用 `kumabox.*` 命名。未声明 profile 的旧镜像可以导入和 inspect,但不能 start。 - -## D007 — `created` 与 `stopped` 分离 - -`created` 表示资源已准备且从未启动;`stopped` 表示 VMM 曾运行并已退出。中间状态是持久恢复意图,不是短暂展示值。 - -## D008 — VMM 使用基础 Backend + 显式 Registry - -`vmm.Backend` 保留所有 VMM 都需要的进程级生命周期能力。Registry 在构造时拒绝 nil、重复和类型错误,之后不可变。snapshot/pause 等只有真实实现出现时才增加可选接口。 - -## D009 — 进程操作验证完整身份 - -PID 不构成所有权。信号和清理必须核对 starttime、host boot ID、sandbox ID、generation、binary 和受管 endpoints,并在 Linux 使用 pidfd 固定目标。 - -## D010 — Agent exec 对齐参考实现的 NDJSON 子集 - -消息语义兼容 `exec/stdin/stdin_close/started/stdout/stderr/exit/error`,传输使用 private hybrid-vsock。KumaBox 不复用参考产品名称、boot ABI 或源码。 - -## D011 — 网络必须进入 sandbox 生命周期 - -网络不能只是 VMM argv 的附加字段。sandbox identity 先 reserve,随后建立可回收的逐 NIC intent 和 host plumbing;start/stop/rm 分别负责 recover、quiesce 和 cleanup。网络在 `run` 之前实现,避免重复设计持久模型和补偿。 - -## D012 — Cocoon 是能力基线,不是包结构模板 - -每项主线功能核对固定 commit 的命令、流程、状态、失败恢复和测试。默认保持行为对齐;KumaBox 已有更强的内容完整性、错误分类或模块边界时保留,并在对照表记录差异。 - -## D013 — 规范文档进入 Git - -当前规范、决策、活动提案和 Linux runbook 与代码一起审查。过时设计稿不进入仓库。中文是当前设计规范语言;代码注释、公共 API、CLI help 和根 README 使用英文。 diff --git a/docs/HOST.md b/docs/HOST.md deleted file mode 100644 index 55bbe09..0000000 --- a/docs/HOST.md +++ /dev/null @@ -1,49 +0,0 @@ -# 主机要求与环境自检 - -> 状态:normative - -## 开发与静态测试 - -macOS 和 Linux 都能运行 `make verify`、`make race` 和 `make lint`。不需要 root、KVM 或外部 VMM。测试使用临时目录、SQLite、真实本地 socket 和 fake formatter。 - -## 真实运行 - -Sandbox 启动当前只支持 Linux。需要: - -- `/dev/kvm` 可用且当前用户有权限; -- cgroup v2,并允许在配置的 parent 下创建 scope、写 CPU 控制和迁移进程; -- Cloud Hypervisor 可执行文件; -- `mkfs.erofs` 1.8 或更新版本; -- `mkfs.ext4`; -- 支持 pidfd 和 vsock 的内核; -- 足够的 data、run、log 目录权限。 - -`kumabox doctor` 检查当前主机。普通检查只读;`--fix` 和 `--upgrade` 是显式的宿主修改授权。 - -```bash -sudo kumabox doctor -sudo kumabox doctor --fix -sudo kumabox doctor --upgrade -``` - -## Guest 镜像 - -可启动 OCI 镜像必须: - -- 含 `/boot/vmlinuz*` 与 `/boot/initrd.img*` regular files; -- 声明 `io.kumabox.boot.profile=overlay-v1`; -- initramfs 能识别 `kumabox.layers`、`kumabox.cow` 和相应 virtio disk serial; -- 启动 `kumabox-agent` 并监听 guest vsock port 1024,才能使用 `exec`。 - -参考构建位于 [`oci-images/ubuntu`](../oci-images/ubuntu/README.md)。 - -## 网络阶段的新增要求 - -网络尚未实现。接入 CNI 时将增加: - -- CNI plugin binaries,默认 `/opt/cni/bin`; -- 至少一份 conflist,默认 `/etc/cni/net.d`; -- 创建持久 netns、TAP、veth 和 TC redirect 的权限; -- CNI ADD/DEL 所需的宿主 sysctl 与防火墙配置。 - -这些检查必须先进入 `doctor`,再开放网络 flags,避免命令接受参数后才静默降级。 diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md deleted file mode 100644 index c6c1a5a..0000000 --- a/docs/PERFORMANCE.md +++ /dev/null @@ -1,44 +0,0 @@ -# KumaBox 性能规范 - -> 状态:normative - -性能优化必须先测量,再修改。完整性校验、状态提交顺序、资源身份验证和失败恢复不能为了基准数字被削弱。 - -## 对齐方法 - -Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` 是比较基线。比较时固定:host、kernel、Cloud Hypervisor、镜像内容、CPU、内存、存储、网络模式和缓存冷热状态。记录两边的实际参数,不能依赖不同默认值。 - -## 当前基准场景 - -镜像导入: - -- 全新导入; -- 全缓存命中; -- 单 layer 损坏修复; -- 多 image 共享 layer; -- manifest 重复 layer; -- 1、2、4、8 worker。 - -Sandbox 生命周期: - -- create sparse COW 与 ext4 格式化; -- start 到 Cloud Hypervisor API Running; -- start 到首次 agent exec; -- stop 到 process/cgroup/runtime 清理完成; -- 100 和 1000 条 metadata 查询。 - -未来网络阶段增加:CNI ADD、首次出网、stop quiesce、restart recover、CNI DEL 和多 NIC 成本。 - -## 指标 - -至少记录 wall time、CPU time、读取/写入字节、hash/解压/EROFS 时间、锁等待、峰值 RSS、goroutine 和 FD 数。microVM 指标必须区分: - -```text -process launched → VMM API Running → agent exec ready → workload ready -``` - -## 当前状态 - -结构整理期间的镜像 benchmark 因缺少真实 Linux 环境暂缓,尚未提交性能优化。恢复测试环境后先建立基线和 profile;只有可重复收益才修改热路径。 - -允许的候选方向包括单次导入内 source digest 去重、操作内校验结果复用和减少重复全文件摘要,但最终 digest、diffID、EROFS 与 boot artifact 验证必须保留。 diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md deleted file mode 100644 index b61df07..0000000 --- a/docs/PRODUCT.md +++ /dev/null @@ -1,61 +0,0 @@ -# KumaBox 产品范围 - -> 状态:normative - -KumaBox 是面向 AI agent 工作负载的 daemonless microVM sandbox runtime。每条 CLI 命令独立打开持久化数据、取得资源锁、完成操作并退出;运行中的 sandbox 由 VMM 进程承载。 - -## 当前目标 - -KumaBox 当前主线先完成单机 Linux 上的 OCI microVM 闭环: - -1. 从 registry、OCI layout/archive 或 Docker save archive 导入镜像。 -2. 将镜像层转换为只读 EROFS,并提取直接内核启动所需的 kernel/initramfs。 -3. 创建带私有 sparse ext4 COW 的 sandbox。 -4. 使用 Cloud Hypervisor 启动、停止和重新启动 sandbox。 -5. 通过 console 与 guest agent 进入 guest。 -6. 在完整生命周期稳定后接入 CNI 网络,再做 snapshot、clone 和第二 VMM backend。 - -## 对齐基线 - -Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` 是能力与行为基线。每个切片开始前都要核对其真实实现、测试和失败恢复,而不是只比命令名称。 - -默认对齐: - -- Docker 风格的镜像和 VM 命令面; -- OCI layer + ext4 COW + direct boot 机制; -- `created`、`running`、`stopped` 等用户可理解的生命周期; -- PID/starttime/boot ID 进程身份保护; -- guest exec 的 NDJSON 消息语义; -- Cloud Hypervisor shutdown 后 TERM→KILL 的停止路径; -- CNI netns/TAP、持久网络身份、stop quiesce、start recover、delete cleanup; -- snapshot、clone、status/reconcile 等后续能力的失败边界。 - -允许有意不同: - -- KumaBox 使用根级模块和 `core` 应用服务,不复制 Cocoon 的包结构。 -- `types` 只放共享数据和值对象,不收集接口。 -- `errdefs` 保留稳定错误码、提交状态和完整错误链。 -- 镜像缓存必须验证 digest、diffID 和最终产物,不能退化为只检查文件存在。 -- guest boot 参数和 profile 使用 `kumabox.*` 命名,不冒充其他产品协议。 - -所有差异记录在 [COCOON-MAP.md](COCOON-MAP.md)。 - -## 当前不承诺 - -以下能力尚未实现,不能在帮助、README 或输出中描述成可用: - -- guest 网络和多网卡; -- snapshot、restore、hibernate、clone; -- Firecracker; -- cloud image、UEFI 和 Windows; -- 热插磁盘、文件系统或 NIC; -- 跨节点控制面、daemon 或远程 API; -- 生产级全节点 GC、admission 和容量调度。 - -## 用户合同 - -- stdout 只承载命令结果,stderr 承载进度和诊断。 -- JSON 使用稳定字段名和缩进格式。 -- 状态只在相应宿主事实完成后提交。 -- 失败后保留足够的持久意图,使同一命令可以安全重试。 -- 不向未经身份确认的宿主进程发送信号,不删除归属不确定的资源。 diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 4911292..0000000 --- a/docs/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# KumaBox 文档 - -本目录随代码进入 Git。行为、架构、配置或验收方式发生变化时,相关文档必须在同一提交中更新。 - -## 当前规范 - -| 文档 | 内容 | -|---|---| -| [PRODUCT.md](PRODUCT.md) | 产品范围、能力基线与明确不做的内容 | -| [ARCHITECTURE.md](ARCHITECTURE.md) | 包边界、依赖方向、事实归属和关键流程 | -| [BEHAVIOR.md](BEHAVIOR.md) | 当前命令、状态、输出、错误与恢复语义 | -| [CONFIGURATION.md](CONFIGURATION.md) | 配置文件、环境变量、flags、默认值与优先级 | -| [HOST.md](HOST.md) | 主机依赖与 `doctor` 检查范围 | -| [PERFORMANCE.md](PERFORMANCE.md) | 性能口径、基准场景和优化约束 | -| [ROADMAP.md](ROADMAP.md) | 已完成能力、下一条命令以及网络等后续阶段 | -| [DECISIONS.md](DECISIONS.md) | 仍然有效的架构和产品决策 | -| [COCOON-MAP.md](COCOON-MAP.md) | 与参考实现的能力对齐状态和有意差异 | - -## 实施资料 - -- [proposals/s3-sandbox.md](proposals/s3-sandbox.md):当前沙箱主线的已实现范围与后续切片。 -- [runbooks/s2-oci.md](runbooks/s2-oci.md):Linux 镜像导入验收。 -- [runbooks/s3-create.md](runbooks/s3-create.md):Linux 沙箱生命周期验收。 -- [architecture-diagrams.md](architecture-diagrams.md):当前代码对应的简图。 -- [releasing.md](releasing.md):发布流程。 - -[REFACTORING.md](REFACTORING.md) 记录 2026-09 的结构整理及验收结果。该轮结束后它不再决定功能开发顺序;后续主线以 `ROADMAP.md`、当前行为规范和逐功能评审为准。 - -## 文档语言 - -设计规范以中文维护,代码注释、公共 Go API 注释、命令帮助和根 README 使用英文。面向国际用户的完整英文文档可在功能面稳定后补充,中文规范仍是当前设计评审的事实来源。 diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md deleted file mode 100644 index 104d797..0000000 --- a/docs/REFACTORING.md +++ /dev/null @@ -1,547 +0,0 @@ -# KumaBox 架构改造计划 - -> 状态:**archived**(结构整理记录;R8 完成后不再决定主线功能顺序) -> -> 形成日期:2026-09-18 -> -> 代码基线:KumaBox `3766f75` -> -> 对照基线:Cocoon `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d`,guest agent `v0.2.3` -> -> 相关:包边界见 `ARCHITECTURE.md`;已批准决策见 `DECISIONS.md`;功能阶段见 `ROADMAP.md` -> -> 2026-09-21:项目负责人决定 R8 完成后回到功能主线;后续以 `ROADMAP.md` 和逐功能评审为准。R6 性能实测仍等待 Linux 环境,不因归档而视为已完成。 - -## 1. 目的与执行规则 - -这轮改造只处理现有代码的结构、抽象、配置、错误、性能与测试质量,不增加新的用户功能。目标是在保留当前 CLI 行为、持久化数据和 guest 协议的前提下,使代码职责清晰、可复用、可扩展,并避免为了尚不存在的需求提前制造接口和包。 - -后续实施必须遵守以下规则: - -1. 严格按本文第 3 节顺序逐节推进,同一时间只改一节。 -2. 每节写代码前,先向项目负责人说明具体文件、类型、调用流程、兼容性和验收方法;得到明确确认后再实施。 -3. 每节单独提交。提交前执行该节列出的测试以及 `make verify`;并发代码还要执行 `make race`。 -4. 重构不得改变 CLI 参数、JSON 字段、metadata schema、状态机、磁盘布局或 guest 协议,除非该节明确列出并再次获得批准。 -5. 不引入 `internal`、通用 `pkg`、`utils`、`common` 或按声明种类划分的包。 -6. `types` 只保存跨模块共享的数据和值对象,不保存接口、CLI flag 语义、存储编码或具体实现。 -7. 接口放在拥有该能力的模块,或者真实消费该能力的包中;不为了“以后也许有第二个实现”提前拆接口。 -8. 文件围绕完整职责拆分。禁止为了一个结构体、一个接口或一两个方法单独建文件。 -9. 关键导出类型、字段、方法和复杂流程补充英文注释;涉及顺序、锁或提交边界时在对应源码附近放简短 ASCII 图。 -10. 对照实现只是事实来源,不是必须复制的规范。安全性、类型安全或可维护性更好的现有设计应保留。 - -## 2. 已确定的边界 - -以下结论已经评审,不在实施时重新讨论: - -- 保留根级 `core` 作为应用服务与具体适配器的组装层;CLI 不直接编排跨模块业务流程。 -- 保留 `core.SandboxService` 作为 sandbox 应用服务,不创建 `lifecycle`、`console` 或 `exec` 等小包。 -- 保留当前 `vmm.Backend` 基础接口。它描述每个 VMM 后端都必须提供的进程级能力,不立即拆成多个小接口。 -- snapshot、pause、restore 等未来能力只有在真实实现出现时才增加可选接口。 -- `disk`、`vmm`、`images`、`metadata` 等包拥有各自能力;`types` 不收集这些接口。 -- `cmd/kumabox` 只保留进程入口;命令树、参数解析和展示继续位于 `cli` 及其子包。 -- 不降低镜像内容校验强度来换取速度。性能优化必须保留 digest、diffID 和最终产物验证。 -- Agent 当前的一连接一 goroutine 模型可以保留;最大 session 数不是本轮的前置改造。 - -## 3. 实施顺序 - -```text -R1 错误链正确性 - ↓ -R2 集中配置与显式 VMM Registry - ↓ -R3 SandboxService 内部重组 - ↓ -R4 types 与 CLI 边界清理 - ↓ -R5 公共 CLI 进度渲染 - ↓ -R6 镜像导入性能测量与优化 - ↓ -R7 VMM、Agent、CLI 测试补强 - ↓ -R8 文档与 CI 治理 -``` - -R1–R5 是行为保持型重构。R6 必须先建立基准再决定具体优化。R7、R8 在前面结构稳定后收口。 - -## 4. R1:错误链正确性 - -### 4.1 现状 - -`errdefs.Error` 提供稳定错误码、资源、提交状态和原因,能力比对照实现依赖哨兵错误与 `fmt.Errorf` 的方式更完整,应当保留。 - -当前 `errdefs.Context` 在收到已经分类的 `*errdefs.Error` 时,把整个旧错误再次包装进 `Cause`。外层格式化时会重复输出错误码,例如: - -```text -ARTIFACT_UNAVAILABLE: ... ARTIFACT_UNAVAILABLE: ... -``` - -`Retry` 字段目前没有稳定的写入者或消费方,公共语义不成立。 - -### 4.2 改造 - -- 调整 `errdefs.Context`:保留已有 `Class`、`Code`、`Entity`、`Committed` 等分类,只在原因链中增加一次上下文,不把完整已格式化的 `Error` 再嵌进去。 -- `Error` 分开保存“用于展示的直接原因”和“供 `errors.Is/As` 遍历的原始错误树”。已有分类被重新加上下文时,展示原因从旧分类的 `Cause` 开始;unwrap 仍指向传入的完整错误,从而保留原分类、哨兵和并列 cleanup/unlock/report 错误。 -- 对 `errors.Join` 做显式处理:展示时把命中的旧分类节点替换为它的直接原因,并保留其他非空分支;遍历时保留原始 join 树。不能通过截取 `Error()` 字符串或匹配错误文本实现。 -- 非空的新 `operation/entity/phase/action` 覆盖旧值,空值保留旧值;`Committed` 继续只能从 false 前进到 true。 -- 明确 `Error()`、`Unwrap()`、`errors.Is` 和 `errors.As` 的合同。 -- 删除 `Retry` 字段。全仓核对确认它只有声明,没有生产者、消费者或持久化用途;重试建议继续由 `Action` 和状态机语义表达。 -- 保留各模块使用 `%w` 添加局部上下文的方式,禁止通过字符串匹配错误。 - -### 4.3 不做 - -- 不改现有错误码名称和 CLI 退出码。 -- 不退回只有哨兵错误的模型。 -- 不引入第三方错误框架。 - -### 4.4 验收 - -- 新增 `errdefs/error_test.go`,覆盖 nil、未分类错误、单层分类、多层 `Context`、空字段继承和 `Committed` 单向变化。 -- 多层 `Context` 只打印一次分类码,原 `Error` 不被修改。 -- joined classified + cleanup/unlock 场景中,分类码、主原因和并列错误各打印一次。 -- `errors.Is` 仍能匹配原始原因、原分类错误和 join 中的并列错误;`errors.As` 返回最外层最新上下文。 -- `CodeOf` 对普通、嵌套和 joined 错误保持稳定。 -- `cli/root_test.go` 验证各错误码的退出码映射没有变化。 -- 已提交与未提交错误的 CLI 映射保持不变。 -- `go test ./errdefs ./core ./cli/...` 与 `make verify` 通过。 - -### 4.5 实施记录 - -- 2026-09-18,提交 `19f8456`:分离诊断 cause 与完整 unwrap 树,删除无消费者的 `Retry`,joined 并列错误保持可见和可匹配。 -- 证据:定向测试、完整 `make verify`(race、双平台 vet、build)和双平台 `make lint` 全部通过,lint 为 0 issue。 -- 遗留:无;R1 完成。下一节为 R2,开始前仍需单独确认配置来源、结构和 Registry 方案。 - -## 5. R2:集中配置与显式 VMM Registry - -### 5.1 现状与对照 - -对照实现使用顶层 `config.Config` 汇总目录、VMM 二进制、超时、并发、网络和 cgroup 参数,并采用“默认值 → 配置文件 → 环境变量 → flag → Validate”的加载顺序。这一职责划分合理。 - -KumaBox 的运行策略目前散落在 CLI、`core`、`vmm/cloudhypervisor`、Agent 和镜像代码中。`core` 的 VMM factory 使用包级可变注册表,不利于测试隔离,也会让未来多后端装配依赖隐式初始化顺序。 - -### 5.2 目标结构 - -增加根级 `config` 包。配置按现有模块分组,不建立一层只有转发作用的 profile 或 settings 类型: - -```go -type Config struct { - Paths storage.Roots - Images Images - Metadata Metadata - Sandbox Sandbox - VMM VMM -} -``` - -职责固定为: - -- `Paths`:复用 `storage.Roots`,保存 data、run、log 根目录,不复制第二套路径值类型。 -- `Images`:`mkfs.erofs` 路径、导入并发度,以及 layer/unpacked/boot/archive 大小上限。 -- `Metadata`:SQLite busy timeout 和整个写事务重试预算。 -- `Sandbox`:`mkfs.ext4` 路径和失败补偿 cleanup timeout。 -- `VMM`:默认后端、cgroup parent,以及 Cloud Hypervisor binary、startup timeout、stop grace、abort grace。 - -这些值保留在各模块的 `Options` 中执行;`config.Config` 只汇总和校验,`core` 显式转换。例如 `config.Images` 转成 `images.Options` 与 `erofs.Options`,`config.Metadata` 转成 `sqlite.Options`。模块不能反向读取全局配置。 - -配置流: - -```text -defaults - ↓ -config file(若本节批准支持) - ↓ -environment - ↓ -CLI flags - ↓ -Config.Validate - ↓ -core.New(...) - ↓ -各模块 Options -``` - -配置加载建议采用 Cobra + Viper,但不使用 Viper 的包级全局实例: - -- 每次 `cli.Execute` 创建独立 `viper.New()`,测试和多次进程内调用互不污染。 -- `--config FILE` 是唯一配置文件入口;不从当前目录或用户目录隐式搜索,避免 root CLI 意外读取陌生配置。 -- 文件按扩展名支持 YAML、JSON 和 TOML;显式文件缺失、不可读或字段非法都立即失败。 -- 环境变量使用 `KUMABOX_` 前缀,层级中的点转换成下划线,例如 `vmm.cloud_hypervisor.binary` 对应 `KUMABOX_VMM_CLOUD_HYPERVISOR_BINARY`。 -- 现有 `--root-dir`、`--run-dir`、`--log-dir` 保持;本节新增 `--config`。其他运行参数先通过配置文件或环境变量提供,避免根命令堆积低频 flags。 -- 不支持热重载。每条 daemonless CLI 命令加载、校验一次,随后使用不可变的 `Config` 快照。 - -优先级固定为:显式 flag > 环境变量 > 显式配置文件 > 默认值。未指定 `--config` 时不读文件,不报缺失错误。 - -VMM 注册改为不可变的显式实例: - -```go -backend, err := cloudhypervisor.New(...) -registry, err := vmm.NewRegistry(backend) -service := core.NewSandboxService(..., registry) -``` - -`Registry` 与 `Backend` 放在现有 `vmm/backend.go`,不为一个注册表单独制造小文件。构造函数一次性拒绝 nil backend、非法类型、类型重复或类型不匹配,之后只提供按 `types.VMMType` 查询和数量检查;不暴露运行期 `Register`,也没有包级全局 map。Registry 不负责编排 start/stop。 - -`core/vmm.go` 保留具体适配器装配职责:从 `Config` 创建 cgroup manager 和 Cloud Hypervisor driver,再构造 Registry。未来增加 Firecracker时只在这里追加具体构造,不修改 Registry 和 SandboxService。 - -### 5.3 边界 - -- 稳定协议值继续作为对应包常量,例如 boot profile、kernel 参数名、metadata schema version。 -- 可部署策略进入配置,例如二进制路径、用户可感知的超时、并发数和资源上限。 -- guest agent vsock 端口、NDJSON frame 上限、hybrid-vsock reply 上限、EROFS block size、ext4 magic、Cloud Hypervisor API response 上限、探测轮询间隔和 cgroup CFS period 保持模块常量;它们是协议、安全边界或内部算法,不是部署配置。 -- `config` 不 import `core`、CLI 或具体 adapter;`core` 将配置转换为各模块 Options。 -- 本节不引入反射式 DI 框架,继续使用显式构造函数。 -- 不为旧的 `core.OpenImages(ctx, roots)`、`core.OpenSandbox(ctx, roots, ...)` 保留兼容 wrapper;全仓调用和测试直接迁移为显式 `Config`,避免两套装配入口。 -- 本节需要新增 Viper 依赖;必须使用实例 API,禁止 global Viper、`init()` 和隐式注册。 - -### 5.4 验收 - -- 所有运行策略硬编码都有“保留为协议常量”或“迁移到配置”的明确归属。 -- 两个独立 Registry 测试实例互不影响。 -- 缺少、重复或未知 VMM 后端均返回稳定错误。 -- 配置测试覆盖 defaults、文件、环境变量、flag 四层优先级,显式缺失文件、非法 duration、负数/零上限和路径重叠。 -- 两次 `cli.Execute` 使用不同环境和 flags 时没有跨调用配置泄漏。 -- 配置文件与环境变量能实际传到 images、SQLite、disk、cgroup 和 Cloud Hypervisor 构造器,不只停留在 DTO。 -- 默认 CLI 行为与当前版本一致。 -- 配置表驱动测试、Registry 测试、现有 CLI 集成测试、`make verify` 和 `make lint` 通过。 - -### 5.5 实施记录 - -- 2026-09-19,提交 `bc8a559`:增加调用级 `config.Config` 与显式 `--config`,固定优先级为 flag、环境变量、显式文件、默认值;CLI 每次执行使用独立 Viper 实例。 -- `core.OpenImages` 与 `core.OpenSandbox` 只接受验证后的配置快照,并把 image limits、并发度、SQLite 超时、ext4 formatter、cleanup timeout、cgroup parent 和 Cloud Hypervisor lifecycle 参数显式传给各模块 Options。 -- 删除包级 VMM factory map,增加构造后不可变的 `vmm.Registry`;构造时拒绝 nil、typed nil、非法和重复 backend,查询时区分损坏的持久化类型与本机缺少的 backend。 -- 证据:配置四层优先级、未知字段、非法值、重叠路径、CLI 调用隔离、配置到 image adapter 的集成测试,以及 Registry 和各 adapter Options 测试通过;完整 `make verify` 和 Linux/Darwin `make lint` 均通过,lint 为 0 issue。 -- R2 完成。下一节为 R3;开始前需要单独确认 `SandboxService` 的具名依赖与文件重组方案。 - -## 6. R3:SandboxService 内部重组 - -### 6.1 现状与对照 - -对照实现把大量 create/start/stop/remove 编排放在 `cmd/vm` 和宽 `Hypervisor` 接口中。KumaBox 当前的 `CLI → core.SandboxService → 模块` 依赖方向更清楚,应当保留。 - -问题在于 `core/sandbox.go` 已同时容纳服务定义、依赖、查询、存储生命周期、VMM 生命周期、console 和 exec,阅读与修改成本过高;构造函数位置参数也过多。 - -### 6.2 文件组织 - -只在 `core` 包内按完整职责重组,不增加新包: - -```text -core/ - sandbox.go SandboxService、Dependencies、构造与公共查找 - sandbox_storage.go create、remove、磁盘与 metadata 补偿 - sandbox_runtime.go start、stop、恢复、console、exec -``` - -若实际代码规模表明两个文件即可表达完整职责,应减少文件,而不是机械采用三个文件。 - -### 6.3 依赖构造 - -使用具名依赖结构替换过长的位置参数: - -```go -type SandboxDependencies struct { - Catalog SandboxCatalog - Images ImageCatalog - Disks disk.Store - VMMs *vmm.Registry - Cgroups cgroup.Manager - // 仅列 SandboxService 真正消费的能力。 -} -``` - -接口仍遵循消费方所有原则。若某接口只被 `core.SandboxService` 消费,可以继续定义在 `core`;若它就是某模块稳定公开的能力,则使用模块自己的接口,避免同一能力出现两份近似合同。 - -### 6.4 流程约束 - -重组不得改变以下顺序: - -```text -实体锁 - ↓ -重读并校验 generation/state - ↓ -持久化操作意图 - ↓ -执行宿主副作用 - ↓ -校验真实结果 - ↓ -generation-fenced 最终提交 - ↓ -释放实体锁 -``` - -`console` 和长时间 `exec` 不得在数据转发期间持有实体操作锁。 - -### 6.5 验收 - -- 公开的 `SandboxService` 行为和方法保持兼容。 -- 不出现 `lifecycle`、`console`、`exec` 新包,也不出现单声明文件。 -- 构造依赖可从一个文件完整看出。 -- create/remove/start/stop/console/exec 原有测试全部通过,`make race` 通过。 - -### 6.6 实施记录 - -- 2026-09-19,提交 `a31d591`:`SandboxService` 改用包内具名依赖对象,同一 sandbox catalog 只注入一次,构造时统一校验 adapter、默认 VMM 与 cleanup policy。 -- `core/sandbox.go` 只保留服务定义、组装和查询;存储生命周期进入 `sandbox_storage.go`,运行生命周期、console 和 exec 进入 `sandbox_runtime.go`,没有增加新包。 -- 测试按相同职责拆分,并增加依赖缺失、默认 reporter、ID 生成器和时钟的构造测试;锁、generation、提交与补偿顺序保持原测试覆盖。 -- `make race`、完整 `make verify` 及 Linux/Darwin `make lint` 全部通过,lint 为 0 issue。R3 完成,下一节 R4 实施前需单独确认类型与 CLI 边界方案。 - -## 7. R4:types 与 CLI 边界清理 - -### 7.1 原则 - -`types` 表达可跨模块传递、持久化或稳定共享的领域事实。CLI 负责命令语法、flag、终端和展示。应用层请求只有在多个模块确实共享时才进入 `types`。 - -### 7.2 改造 - -- 移除共享类型校验错误中的 `--cpus`、`--memory`、`--storage` 等 flag 名称。 -- 共享类型返回领域错误,例如 `CPU count must be positive`;CLI 将它映射为具体 flag 错误。 -- CLI 将 `KEY=VALUE` 解析成 `map[string]string`,业务层与 Agent 不解析 CLI 字符串。 -- CLI 根据是否传递 stdin 表达交互输入,不向领域层传递 `Interactive` 布尔语义。 -- exec 使用中性命令模型: - -```go -type Command struct { - Args []string - Env map[string]string -} -``` - -- 只有在 `core`、Agent client 或其他模块共同消费 `Command` 时才把它放进 `types`;否则留在最窄的消费边界。 -- CLI 的 JSON/table DTO 继续留在 `cli/sandbox`,不进入 `types`。 - -### 7.3 不做 - -- 不把接口迁进 `types`。 -- 不建立 `apis` 包;当前没有稳定的 HTTP、gRPC 或 CRD wire contract。 -- 不让持久化模型直接承担 CLI 输出格式。 - -### 7.4 验收 - -- `types` 不含 flag 名称、Cobra 类型、terminal 状态或具体 JSON 展示 DTO。 -- 环境变量重复、空键、非法格式在 CLI 边界有表驱动测试。 -- exec 的 stdin、stdout、stderr 和退出码行为保持不变。 -- `go test ./types ./cli/sandbox ./core ./agent/...` 与 `make verify` 通过。 - -### 7.5 实施记录 - -- 2026-09-19,提交 `bcb8f54`:用共享的 `types.Command` 替换带 CLI 语义的 `ExecConfig`;该类型并入现有 sandbox 模型文件,未增加单声明文件。 -- CLI 负责把重复的 `KEY=VALUE` 参数转换为 map,后出现的同名变量覆盖先出现的值;core 与 Agent client 只消费中立命令值。`--interactive` 只决定 CLI 是否向 core 传递 stdin。 -- `SandboxConfig` 的领域错误不再包含 flag 名称;create 命令在边界上为 CPU、内存和磁盘限制补充对应 flag 上下文。 -- guest NDJSON 协议、CLI 参数、stdout/stderr 流和退出码保持不变。定向测试、完整 `make verify` 及 Linux/Darwin `make lint` 全部通过,lint 为 0 issue。 -- R4 完成。下一节为 R5 公共 CLI 进度渲染。 - -## 8. R5:公共 CLI 进度渲染 - -### 8.1 现状与对照 - -镜像与 sandbox 命令各自包含相似的 spinner、TTY 检测、刷新和结束输出。对照实现有共享 `progress.Tracker`,但通过 `any` 传递事件,类型不匹配时可能静默丢失。 - -### 8.2 改造 - -新增 `cli/progress`,只复用终端展示机制: - -- TTY 检测。 -- 动画帧和刷新节拍。 -- 当前行覆盖与清理。 -- 成功、失败和取消收尾。 -- 非 TTY 时输出有限的普通状态行。 -- context 取消和 goroutine 回收。 - -领域事件继续属于各自模块或 CLI adapter: - -```text -images.Progress / sandbox.Progress - ↓ -对应 CLI adapter - ↓ -cli/progress.Renderer - ↓ -stderr -``` - -Renderer 不接收 `any`,也不理解 layer、sandbox 或 VMM 状态。 - -### 8.3 验收 - -- image import/pull/verify/remove 与 sandbox create/remove/start/stop 共用同一渲染机制。 -- stdout 仍只输出命令结果,进度只写 stderr。 -- pipe/redirect 下没有控制字符和高频刷屏。 -- 成功、失败、取消后都不遗留 goroutine 或半行终端内容。 -- renderer 使用 fake clock/writer 的确定性测试,随后执行 `make race`。 - -### 8.4 实施记录 - -- 2026-09-20,提交 `21e0f13`:新增 `cli/progress.Renderer`,集中负责 TTY 判断、spinner ticker、写入串行化、stdout 前后的终端行清理、成功/失败/取消/已提交错误收尾和 goroutine 回收。 -- `cli/image` 只保留 layer/image 计数与提交语义,`cli/sandbox` 只保留 workflow stage、提交语义和恢复提示;两者都不再维护终端状态、动画帧或 ticker。 -- 非 TTY 输出只写换行结束的普通状态,并去除连续重复状态;command result 继续写 stdout,所有进度继续写 stderr。公共 Renderer 不接收 `any`,也不理解 image 或 sandbox 事件。 -- Renderer 测试使用手动 ticker 和可观察 writer,确定性覆盖动画推进、输出分流、取消、写失败、初始化失败和 ticker 回收;image adapter 继续覆盖并发 layer 计数、提交后报告失败和 remove 计数。 -- `make race`、完整 `make verify` 及 Linux/Darwin `make lint` 全部通过,lint 为 0 issue。R5 完成,下一节为 R6 镜像导入性能测量与优化。 - -## 9. R6:镜像导入性能测量与优化 - -### 9.1 现状与对照 - -对照实现并行处理 layer,并按 digest 加锁,但缓存命中主要只验证普通文件且大小大于零。KumaBox 对 compressed digest、diffID、EROFS 和 boot artifact 的验证更强,不能退化为文件存在性检查。 - -当前风险是同一导入流程对最终产物进行多次全文件摘要计算,并可能对 manifest 中重复的源 digest 重复转换。 - -### 9.2 先测量 - -本节开始时先建立基准和 profile,至少覆盖: - -- 全新导入。 -- 全缓存命中。 -- 一个 layer 损坏后的修复。 -- 多个镜像共享 layer。 -- manifest 重复引用同一 layer。 -- 1、2、4、8 并发转换。 - -分别记录读取字节数、hash 时间、解压时间、EROFS 转换时间、锁等待时间、总耗时和内存峰值。没有测量证据不得修改校验流程。 - -### 9.3 候选优化 - -只有 profile 证明有效时才采用: - -- 单次导入内按 source digest 去重转换任务。 -- 为一次操作缓存已成功的文件身份与摘要结果。 -- 将最终完整验证收敛到 digest 锁内的一次,同时保留锁外转换和锁内复查。 -- 避免先完整读取再转换,继续保持流式处理。 -- 设定有界 worker 数,来源于 R2 配置而不是硬编码。 - -发布顺序保持: - -```text -staging 转换 - ↓ -按 digest 排序加锁 - ↓ -锁内复查现有产物 - ↓ -原子发布或复用 - ↓ -最终完整性校验 - ↓ -metadata 短事务提交 -``` - -### 9.4 验收 - -- 损坏缓存仍能被发现并修复。 -- digest、diffID、whiteout、boot candidate 语义不变。 -- 相同输入的最终 digest 与当前版本一致。 -- benchmark 报告包含改造前后数据;没有可重复收益则不提交优化代码。 -- `make verify`、`make race` 和真实 `mkfs.erofs` Linux 验收通过。 - -### 9.5 实施记录 - -- 2026-09-21:项目负责人因暂时没有 Linux 测试环境,决定跳过本节;未修改镜像导入性能路径,也未把候选优化标记为完成。 -- 遗留:恢复测试环境后执行 9.2 的 benchmark/profile 和 9.4 的真实 `mkfs.erofs` 验收,再根据数据决定是否提交优化。 - -## 10. R7:测试补强 - -### 10.1 VMM 与 cgroup - -- Cloud Hypervisor 参数和设备顺序的 golden/结构化测试。 -- PID、starttime、boot ID、binary 和 socket 身份校验。 -- 启动后早退、API 未就绪、stop TERM→KILL、清理失败和重试。 -- cgroup 创建、限制写入、进程放置、空组删除和残留恢复。 -- Registry 重复注册、未知类型和多个实例隔离。 - -### 10.2 Agent - -对齐成熟实现已有的生命周期覆盖: - -- idle connection 下关闭服务。 -- accept 永久错误。 -- guest 子进程提前退出。 -- stdin reader 被取消后退出。 -- 所有连接在 shutdown 时关闭并被 WaitGroup 等待。 -- 使用 `make race` 验证并发路径。 - -当前不要求增加 session semaphore;若测试或实际负载证明需要,再单独设计配置和拒绝语义。 - -### 10.3 CLI - -- 构建真实 `kumabox` 二进制执行关键命令,而不只调用 Cobra handler。 -- 验证 stdout/stderr 分离、JSON 缩进、退出码、信号取消和非 TTY 输出。 -- 覆盖 `image`、`create`、`ps`、`inspect`、`start`、`stop`、`console` 参数校验;需要 KVM 的实体行为仍留给 Linux runbook。 - -### 10.4 验收 - -- 新测试验证合同和失败边界,不复制实现细节。 -- 测试文件与被测包同目录,不建立生产 `*test` helper 包。 -- `make verify`、`make race`、`make lint` 全部通过。 - -### 10.5 实施记录 - -- 2026-09-21,提交 `ff2d616`:补齐 VMM 参数与进程身份、启动/停止/清理、cgroup 收敛与 Registry 隔离、Agent 连接生命周期以及真实 CLI 二进制合同测试。 -- 证据:完整 `make verify`、`make race`、Linux/Darwin `make lint` 均通过;Linux 专属 cgroup 与进程生命周期测试已交叉编译通过。 -- 遗留:Linux 专属测试尚未在真实 Linux 主机执行,随 R6 的测试环境验收一起完成;R7 代码与当前主机验收完成。 - -## 11. R8:文档与 CI 治理 - -### 11.1 现状 - -当前 `.gitignore` 忽略整个 `docs/`,CI 还会拒绝任何被跟踪的文档。README 同时把这些本地文件声明为唯一规范。这使新 clone、代码审查和历史提交无法获得决定代码行为的规范。 - -成熟开源项目通常把架构、行为、运行手册和决策记录与代码一起评审。对照项目也跟踪其 `docs/`。 - -### 11.2 改造 - -- 从 `.gitignore` 删除 `docs/`。 -- 删除 CI 中“Reject tracked local documentation”步骤。 -- 清理 `docs/` 历史文件,只提交仍然有效的规范、提案、决策和 runbook。 -- 修复文档中已失效的 `internal`、daemon、gRPC 和旧阶段描述;历史材料需要保留时明确标注 non-normative。 -- README 的结构表、命令示例和阶段状态必须与代码一致。 -- 在贡献流程中要求行为或架构变化同时更新对应文档。 - -### 11.3 审批边界 - -这一节会改变仓库治理方式,实施前必须由项目负责人再次确认: - -- 哪些现有文档进入 Git。 -- 历史资料删除还是迁入明确的 archive。 -- 中文设计文档是否作为长期规范。 - -### 11.4 验收 - -- 全新 clone 能获得构建、架构、行为和 Linux 验收说明。 -- README 中不存在指向未提交文件的链接。 -- CI 不再拒绝文档,同时仍检查文档引用或格式。 -- 所有 normative 文档与当前代码完成一次一致性审查。 - -### 11.5 实施记录 - -- 2026-09-21,R8 提交:规范、活动提案和 Linux runbook 进入 Git;README、架构、行为、配置、对照和路线图按当前代码重写。 -- 删除被当前实现取代的旧 design/implementation 与已完成提案;CI 改为检查 Markdown 相对链接,`make verify` 同步执行。 -- 中文继续作为设计规范语言;根 README、CLI help 和代码注释保持英文。R8 完成,本文件归档。 - -## 12. 明确撤回或降低优先级的建议 - -以下内容不作为本轮任务: - -| 原建议 | 当前决定 | 原因 | -|---|---|---| -| 立即拆分 `vmm.Backend` | 撤回 | 当前方法都是基础进程能力;对照实现的胖接口更宽,机械拆分会增加类型断言和装配复杂度 | -| 为每个 VMM 能力建立一个接口 | 撤回 | 等 snapshot、pause、restore 的真实第二能力出现再定义可选接口 | -| Agent 立即增加最大 session 数 | 降低优先级 | 当前模型与对照实现一致;先补取消、关闭和 goroutine 泄漏测试 | -| 使用缓存文件大小判断替代完整 hash | 否决 | 会降低内容寻址存储的完整性保证 | -| 把跨模块编排移回 CLI | 否决 | 当前 `core` 应用服务边界更清楚 | -| 把接口集中放进 `types` | 否决 | `types` 只保存共享数据和值对象 | -| 为 console、exec 建独立包 | 否决 | 它们是 SandboxService 的运行态能力,独立包只会制造碎片 | - -## 13. 完成定义 - -本轮架构改造只有在以下条件全部满足后才能关闭: - -- R1–R8 每节均有单独批准、提交和验收记录。 -- 默认 CLI、JSON、metadata、磁盘目录和 guest 协议保持兼容,获批变更除外。 -- 运行策略不再散落为无法覆盖的硬编码。 -- `core.SandboxService`、VMM Registry、CLI progress 和 exec 数据边界能够从包结构直接理解。 -- 镜像性能优化有基准证据且未削弱完整性校验。 -- VMM、cgroup、Agent 生命周期和真实 CLI 边界具有足够失败路径测试。 -- 有效设计文档能随仓库获取并与代码一起评审。 - -每完成一节,在本文对应标题下追加一条不超过五行的实施记录:提交号、关键变化、测试证据、遗留项。不得提前把尚未实现的内容标记为完成。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index f337781..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,82 +0,0 @@ -# KumaBox 主线路线图 - -> 状态:normative - -本路线图从实际代码和 Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` 的对应实现出发。每个切片单独设计、实现、验收和提交。 - -## 已完成 - -### 基础工程 - -- daemonless CLI、doctor、配置加载、稳定错误码和退出码; -- SQLite transaction contract、flock、受管路径和原子发布; -- 根级模块、`core` 应用服务、显式 VMM registry; -- 公共进度 renderer 和真实 CLI binary 测试。 - -### 镜像 - -- registry pull; -- OCI layout/archive 和 Docker save import; -- digest、diffID、whiteout、boot artifact 验证; -- EROFS conversion、cache reuse、list/inspect/verify/remove; -- `overlay-v1` boot profile 与官方 Ubuntu guest image。 - -### Sandbox 基础生命周期 - -- `create`、`ps`、`inspect`、`rm`; -- `start`、`stop`、`console`、`exec`; -- `logs` 全量/tail/follow、truncate/reopen 恢复和删除时日志清理; -- sparse ext4 COW、cgroup v2、Cloud Hypervisor direct boot; -- PID/starttime/boot ID/binary/socket identity; -- guest agent NDJSON exec 子集。 - -这些功能已完成代码和跨平台门禁。Linux/KVM 行为仍必须在发布前按 runbook 重验。 - -## 下一步:网络基础 - -网络在 `run` 前实现。原因是 Cocoon 的网络身份在 sandbox reserve 之后、VMM create/start 之前建立,并贯穿 start/stop/rm;先做无网络 `run` 会重复修改命令、metadata 和补偿流程。 - -第一版范围: - -1. 根级 `network` 能力与 `network/cni` adapter; -2. `types.NetworkConfig` 和 sandbox 持久网络事实; -3. CNI conflist/bin 配置、netns、TAP、TC redirect 和逐 NIC intent; -4. `create --network NAME --nics N`,默认行为在开工评审时与 Cocoon 当前默认再次确认; -5. Cloud Hypervisor net devices、kernel IP/DNS/hostname 参数; -6. stop quiesce、start recover/unquiesce、rm CNI DEL/netns cleanup; -7. 中断恢复、generation fence 和 Linux runbook。 - -第一版不做 bridge backend、NIC hot-resize 或 Firecracker;接口必须允许这些真实第二实现以后加入。 - -## 网络之后:`run` - -`run` 复用 `create` 和 `start` 的应用服务,不复制流程: - -```text -kumabox run IMAGE --name NAME [resource/network flags] -kumabox run IMAGE --name NAME -- COMMAND [ARGS...] -``` - -默认等待 guest agent。带命令时透传 stdout/stderr 和 exit code,command 退出后 sandbox 保持运行。创建已提交但后续启动或 agent wait 失败时,错误必须说明 sandbox 已存在并可 inspect/stop/rm。 - -## 然后:`status` 与恢复 - -Cocoon 的 `list`/`status` 会把持久状态与进程事实合并,并支持 watch/event。KumaBox 当前 `ps` 有意保持只读 metadata 视图。 - -计划分两步: - -1. 一次性 `status [SANDBOX...] [--json]`:观察 VMM/process/cgroup/network,展示 durable state 与 observed state; -2. `--watch`/`--event`:只有在事件和轮询语义确定后加入。 - -同时提供显式的实体恢复入口,处理遗留 `creating/starting/stopping/deleting`。不能让普通 `ps` 在查询时偷偷修改状态。 - -## 后续能力 - -1. snapshot、restore、hibernate; -2. clone 与 guest identity reseed; -3. NIC resize、额外磁盘和 filesystem attach; -4. Firecracker backend; -5. 节点级 reconcile、GC、admission 和容量统计; -6. 跨节点与可选常驻服务。 - -每个阶段继续以 Cocoon 的真实流程、失败恢复和测试为基线;KumaBox 的模块边界、错误模型和完整性保证保留。 diff --git a/docs/architecture-diagrams.md b/docs/architecture-diagrams.md deleted file mode 100644 index 766463f..0000000 --- a/docs/architecture-diagrams.md +++ /dev/null @@ -1,72 +0,0 @@ -# KumaBox 当前架构图 - -## 命令调用 - -```text - immutable Config - │ -cmd/kumabox → cli ─────┴────► core application service - │ - ┌─────────────────┼─────────────────┐ - ▼ ▼ ▼ - images sandbox vmm - source / erofs catalog / disk cloudhypervisor - │ │ │ - └──────────┬──────┘ ├── cgroup - ▼ └── agent/vsock - metadata/sqlite -``` - -## Sandbox 创建 - -```text -validate request - ↓ -sandbox entity lock - ↓ -resolve + pin image in transaction - ↓ -reserve creating record - ↓ -prepare sparse ext4 COW - ↓ -verify disk → CAS created -``` - -## 启动和停止 - -```text -start: -lock → CAS starting → runtime/cgroup → launch VMM → persist identity - → vm.info Running → CAS running - -stop: -lock → CAS stopping → vm.shutdown → identity-safe TERM → optional KILL - → verify absent → runtime/cgroup cleanup → CAS stopped -``` - -## Guest exec - -```text -CLI streams ─► core validates Running generation - ↓ - Cloud Hypervisor vsock UDS - ↓ CONNECT 1024 - guest agent - ┌─────┴─────┐ - stdin frames stdout/stderr/exit -``` - -## 计划中的网络顺序 - -```text -reserve sandbox - ↓ -persist NIC intents - ↓ -netns → CNI ADD → TAP/TC → persist MAC/IP - ↓ -launch VMM in netns - ↓ -stop: quiesce ── start: recover/unquiesce ── rm: CNI DEL + netns delete -``` diff --git a/docs/proposals/s3-sandbox.md b/docs/proposals/s3-sandbox.md deleted file mode 100644 index 59c8e79..0000000 --- a/docs/proposals/s3-sandbox.md +++ /dev/null @@ -1,77 +0,0 @@ -# S3 Sandbox 主线 - -> 状态:基础生命周期与 `logs` 已实现;下一阶段进入网络切片。 - -## 已实现闭环 - -```text -import image - → create sandbox + ext4 COW - → start Cloud Hypervisor - → console / guest exec - → stop - → restart or rm -``` - -已实现命令:`create`、`start`、`stop`、`ps`、`inspect`、`console`、`exec`、`logs`、`rm`。 - -已实现核心合同: - -- image alias 在 create 时解析为完整 manifest digest 并持久 pin; -- `creating/starting/stopping/deleting` 是可恢复的持久意图; -- VMM process identity 防 PID reuse 和 host reboot; -- cgroup 和 runtime 只在进程已确认退出后清理; -- guest exec 使用 bounded NDJSON frame,不把断线冒充 exit 0; -- stdout/stderr、JSON 和 CLI exit code 有真实 binary 测试。 - -## 已完成的 `logs` 切片 - -Cloud Hypervisor adapter 已在 `/var/log/kumabox/sandboxes//vmm.log` 持久化 stdout/stderr。新增命令只公开受控读取能力: - -```text -CLI resolve name/ID - → core validates sandbox ownership - → VMM backend opens owned log stream - → tail/follow renderer copies to stdout -``` - -合同: - -- `logs SANDBOX` 输出全部;`--tail N` 从最后 N 行开始;`-f` 等待增长。 -- VMM restart 截断日志后 follower 从新文件头继续,不能卡在旧 offset。 -- stop 后仍可读;从未 start 返回明确 unavailable/not-found 诊断。 -- cancel 关闭 watcher/file;不泄漏 goroutine 或 FD。 -- CLI 不拼接 log path,`core` 不实现 tail 算法,VMM 模块拥有其日志。 -- `rm` 在最终释放 metadata/name/image pin 前删除 backend 拥有的 log dir;失败保留 `deleting` 并允许相同命令重试。 - -实现使用同步轮询跟随受管文件,不创建 watcher goroutine。文件 inode 替换时重新打开;同一 inode 被截断时通过 size 和稳定头部签名回到 offset 0。`rm` 已在 metadata finalize 前执行 backend log cleanup,失败保留 `deleting`。 - -## 为什么网络在 `run` 之前 - -参考实现的 create/run 流程先 reserve identity,再配置网络,最后把 network facts 交给 VMM。start、stop 和 rm 都依赖相同事实。如果 KumaBox 现在先做无网络 `run`,之后必须再次修改: - -- create/run flags 和 request; -- sandbox metadata schema; -- launch plan 和 Cloud Hypervisor argv; -- start rollback、stop quiesce、rm cleanup; -- JSON 输出和 runbook。 - -因此当前直接进入网络;网络闭环验收后实现 `run`。 - -## 网络第一版边界 - -- 只做 CNI backend;不做 bridge 和 hot resize。 -- 支持 0 或多个 NIC,具体默认 NIC 数在开工前再次核对 Cocoon 当前 CLI 默认。 -- MAC、IP、gateway、DNS、conflist、ifname、queue 数和 cleanup intent 持久化。 -- netns/TAP/TC/CNI 操作属于 `network` 模块;`core.SandboxService` 编排其与 catalog/VMM 的顺序。 -- `types` 保存跨模块 NetworkConfig 值;不保存 Network 接口。 -- create 在 reserve 后配网;start recover/unquiesce;stop quiesce;rm 全量 cleanup。 -- partial failure 必须可重试,不能因为 CNI DEL 失败就忘掉 NIC record。 - -## `run` 与 `status` - -网络完成后,`run` 组合现有 create/start,不复制它们。随后实现一次性 `status`,将持久 state 与 VMM/process/cgroup/network observed facts 并列展示。watch/event 和节点级后台收敛留到一次性合同稳定后。 - -## 验收 - -每个切片必须通过 `make verify`、`make race`、`make lint`。`logs` 可在本地用受管文件验证;网络必须增加 Linux CNI runbook,并与 Cocoon 的同资源配置对比 create/start/stop/rm 行为。 diff --git a/docs/releasing.md b/docs/releasing.md deleted file mode 100644 index 851ab7a..0000000 --- a/docs/releasing.md +++ /dev/null @@ -1,39 +0,0 @@ -# Releasing KumaBox - -A release requires both repository gates and Linux acceptance evidence. - -## 1. Repository gates - -```bash -make verify -make race -make lint -``` - -The worktree must contain no generated binary, coverage output, or unrelated artifact. Documentation links must pass `make docs-check`. - -## 2. Linux acceptance - -Run the applicable checked-in runbooks on a Linux/KVM host. Record: - -- commit and version; -- kernel and distribution; -- Cloud Hypervisor, `mkfs.erofs`, `mkfs.ext4`, and Go versions; -- CPU architecture and cgroup mode; -- exact commands, exit codes, image digests, and any retained cleanup state. - -At minimum, a lifecycle release must cover image import/verify, create/start/exec/console/stop/restart/rm, cancellation, and host process identity checks. Network releases must also cover CNI ADD/DEL, outbound connectivity, quiesce/recover, and partial-failure retry. - -## 3. Build - -```bash -make clean -make build -make agent -``` - -`bin/kumabox` is the host CLI, `bin/kumabox-check` is the host checker, and `bin/kumabox-agent` is the Linux guest agent. - -## 4. Version - -Build metadata is injected through `version.Version`, `version.Commit`, and `version.BuildTime`. Create a signed or annotated version tag only after the release commit and Linux evidence are final. diff --git a/docs/runbooks/s2-oci.md b/docs/runbooks/s2-oci.md deleted file mode 100644 index 9e8f851..0000000 --- a/docs/runbooks/s2-oci.md +++ /dev/null @@ -1,82 +0,0 @@ -# S2 OCI Linux 手动验收 - -状态:2026-09-15 项目负责人已完成测试机验证并明确审批 S2 通过。按 DEC-022,本阶段不启动 VMM、不需要 KVM。 - -## 准备 - -需要 Go、mkfs.erofs >= 1.8、fsck.erofs、jq 和一份包含真实 regular kernel/initrd 的 KumaBox-compatible OCI 镜像。alpine 等普通容器镜像通常不满足启动合同。 - -```bash -make verify -make lint -make build -export PATH="$PWD/bin:$PATH" -mkfs.erofs --version -kumabox doctor -export KUMABOX_S2_REFERENCE='填写真实兼容镜像引用' -KUMABOX_S2_WORK=$(mktemp -d /var/tmp/kumabox-s2.XXXXXX) -kb() { kumabox --root-dir "$KUMABOX_S2_WORK/data" --run-dir "$KUMABOX_S2_WORK/run" --log-dir "$KUMABOX_S2_WORK/log" "$@"; } -kb image ls --json -``` - -空列表必须是 `[]`。本节只写隔离的临时 root;系统 doctor 的 fix/upgrade 由负责人在验收机上显式运行。 - -## 真转换、registry 与幂等 - -```bash -kb image pull "$KUMABOX_S2_REFERENCE" --platform linux/amd64 -kb image inspect "$KUMABOX_S2_REFERENCE" > "$KUMABOX_S2_WORK/first.json" -kb image verify "$KUMABOX_S2_REFERENCE" -find "$KUMABOX_S2_WORK/data/images/layers" -name '*.erofs' -exec fsck.erofs '{}' \; -find "$KUMABOX_S2_WORK/data/images" -type f -exec sha256sum '{}' \; | sort > "$KUMABOX_S2_WORK/before.sha256" -kb image pull "$KUMABOX_S2_REFERENCE" --platform linux/amd64 -kb image inspect "$KUMABOX_S2_REFERENCE" > "$KUMABOX_S2_WORK/second.json" -find "$KUMABOX_S2_WORK/data/images" -type f -exec sha256sum '{}' \; | sort > "$KUMABOX_S2_WORK/after.sha256" -diff "$KUMABOX_S2_WORK/before.sha256" "$KUMABOX_S2_WORK/after.sha256" -diff "$KUMABOX_S2_WORK/first.json" "$KUMABOX_S2_WORK/second.json" -test ! -e "$KUMABOX_S2_WORK/data/images/blobs" -test -z "$(find "$KUMABOX_S2_WORK/data/staging/imports" -mindepth 1 -print -quit)" -``` - -每个 source layer 只有一份 EROFS;重复 pull 不出现转换进程,产物、digest 和 created_at 不变。inspect 包含 compressed source digest、EROFS digest、boot candidates 的 digest/size 与最终选择。 - -## Layout/archive 与别名 - -```bash -kb image import tiny ./testdata/oci-layout --platform linux/amd64 -kb image verify tiny -KUMABOX_S2_ARCHIVE="$KUMABOX_S2_WORK/tiny.bin" -tar -C testdata/oci-layout -czf "$KUMABOX_S2_ARCHIVE" . -kb image import tiny-alias "$KUMABOX_S2_ARCHIVE" --platform linux/amd64 -kb image inspect tiny | jq '.names' -kb image rm tiny -kb image verify tiny-alias -kb image rm tiny-alias -``` - -gzip 通过 magic 检测,与扩展名无关。fixture 启动文件是占位数据,只验证转换/完整性;不能用来启动 VM。实际 Ubuntu 等镜像另外验证 versioned boot basename、whiteout 和 arm64 gzip kernel。 - -## 损坏与恢复 - -针对真实已拉取镜像: - -```bash -KUMABOX_S2_LAYER=$(kb image inspect "$KUMABOX_S2_REFERENCE" | jq -r '.boot.kernel_layer | sub("^sha256:"; "")') -KUMABOX_S2_KERNEL=$(kb image inspect "$KUMABOX_S2_REFERENCE" | jq -r '.boot.kernel_file') -printf x >> "$KUMABOX_S2_WORK/data/images/boot/sha256/$KUMABOX_S2_LAYER/$KUMABOX_S2_KERNEL" -kb image verify "$KUMABOX_S2_REFERENCE"; test "$?" -eq 5 -kb image pull "$KUMABOX_S2_REFERENCE" --platform linux/amd64 -kb image verify "$KUMABOX_S2_REFERENCE" -``` - -verify 报 ARTIFACT_CORRUPT;重拉只重建损坏 layer,恢复原摘要。缺失文件报 ARTIFACT_UNAVAILABLE(6),缺镜像为 NOT_FOUND(3),用法错误为 2。 - -## 并发、取消与崩溃 - -使用另一个空 root 对同一镜像并发 pull 两次;两进程都成功,允许 staging 重复转换,最终只有一份 EROFS。两边都 verify,通过后删除检查共享引用。 - -对空 root 中慢下载/大镜像 import 发送 SIGINT/SIGTERM:进程终止下载和 mkfs.erofs,staging 清理完毕,没有半成品 image 记录。原样重试成功。 - -在转换期间和发布/提交窗口分别 kill -9:inspect 要么 NOT_FOUND,要么完整且 verify 通过;绝不显示 importing/半成品。重试时未知最终文件重建;已提交产物校验后复用。kill -9 的旧 staging 可以保留为不可见孤儿,后续 GC 阶段回收。 - -最后记录宿主架构、mkfs.erofs 版本、镜像完整 manifest digest、各命令退出码与产物文件列表,附到 ROADMAP 的进度日志。不要把 cache、bin 或 coverage 产物加入 Git;本目录中的规范和 runbook 应随代码提交。 diff --git a/docs/runbooks/s3-create.md b/docs/runbooks/s3-create.md deleted file mode 100644 index 9495e24..0000000 --- a/docs/runbooks/s3-create.md +++ /dev/null @@ -1,110 +0,0 @@ -# S3 Sandbox Linux 验收 - -本 runbook 验证当前无网络生命周期:create、start、exec、logs、console、stop、restart 和 rm。需要 Linux、KVM、cgroup v2、Cloud Hypervisor、`mkfs.erofs`、`mkfs.ext4`、jq,以及一份带 `overlay-v1` profile 和 `kumabox-agent` 的真实镜像。 - -## 准备 - -```bash -make verify -make lint -make build -make agent -export PATH="$PWD/bin:$PATH" -export KUMABOX_S3_IMAGE='填写真实兼容镜像引用' -KUMABOX_S3_WORK=$(mktemp -d /var/tmp/kumabox-s3.XXXXXX) -kb() { sudo kumabox --root-dir "$KUMABOX_S3_WORK/data" --run-dir "$KUMABOX_S3_WORK/run" --log-dir "$KUMABOX_S3_WORK/log" "$@"; } - -sudo kumabox doctor -kb image pull "$KUMABOX_S3_IMAGE" --platform linux/amd64 -kb image verify "$KUMABOX_S3_IMAGE" -test "$(kb image inspect "$KUMABOX_S3_IMAGE" | jq -r '.boot.profile')" = overlay-v1 -``` - -## Create 和查询 - -```bash -kb create "$KUMABOX_S3_IMAGE" --name lifecycle --cpus 2 --memory 1GiB --storage 10GiB --json \ - | tee "$KUMABOX_S3_WORK/create.json" -KUMABOX_S3_ID=$(jq -r '.id' "$KUMABOX_S3_WORK/create.json") -test "$(jq -r '.state' "$KUMABOX_S3_WORK/create.json")" = created -kb ps -a -kb inspect lifecycle | jq -e --arg id "$KUMABOX_S3_ID" '.id == $id and .state == "created"' -test "$(stat -c %s "$KUMABOX_S3_WORK/data/sandboxes/$KUMABOX_S3_ID/cow.raw")" = 10737418240 -blkid "$KUMABOX_S3_WORK/data/sandboxes/$KUMABOX_S3_ID/cow.raw" | grep 'TYPE="ext4"' -``` - -COW apparent size 为 10 GiB,实际占用应明显更小。`ps` 默认不显示 created,`ps -a` 显示完整 UUID 和列标题。 - -## Start、exec 和 console - -```bash -kb start lifecycle --json | tee "$KUMABOX_S3_WORK/start.json" -test "$(jq -r '.state' "$KUMABOX_S3_WORK/start.json")" = running -kb exec lifecycle -- uname -a -kb exec lifecycle -- hostname -echo hello | kb exec -i lifecycle -- cat -kb exec lifecycle -- sh -c 'exit 17'; test "$?" -eq 17 -kb console lifecycle -``` - -console 中确认 guest 完成启动;使用 `Ctrl-]` 后 `.` 断开。检查: - -```bash -cat "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID/process.json" | jq . -cat "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID/cmdline" -test -S "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID/api.sock" -test -S "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID/vsock.uds" -cat "$KUMABOX_S3_WORK/log/sandboxes/$KUMABOX_S3_ID/vmm.log" -``` - -## Logs、tail 和 follow - -```bash -kb logs lifecycle | tee "$KUMABOX_S3_WORK/log-all.txt" -kb logs --tail 20 lifecycle | tee "$KUMABOX_S3_WORK/log-tail.txt" -kb logs -f lifecycle -``` - -确认全量输出包含启动日志,tail 不超过最后 20 行。保持 `logs -f` 运行,在另一个终端执行 `kb stop lifecycle && kb start lifecycle`;follower 应显示新一轮启动日志且不重复旧文件尾部。按 `Ctrl-C` 后命令正常退出,sandbox 继续运行。 - -## Stop、持久数据和 restart - -```bash -kb exec lifecycle -- sh -c 'echo persisted >/persist-check' -kb stop lifecycle --json | tee "$KUMABOX_S3_WORK/stop.json" -test "$(jq -r '.state' "$KUMABOX_S3_WORK/stop.json")" = stopped -test ! -e "$KUMABOX_S3_WORK/run/sandboxes/$KUMABOX_S3_ID" -kb stop lifecycle -kb start lifecycle -kb exec lifecycle -- cat /persist-check | grep -Fx persisted -kb stop lifecycle -``` - -stop 后 VMM 进程不存在、runtime dir 被清理、对应 cgroup 为空并删除。第二次 stop 幂等成功。restart 保持相同 sandbox ID、image digest 和 COW 数据。 - -## 删除与引用 - -```bash -kb image rm "$KUMABOX_S3_IMAGE"; test "$?" -eq 4 -kb rm lifecycle --json | tee "$KUMABOX_S3_WORK/remove.json" -test "$(jq -r '.id' "$KUMABOX_S3_WORK/remove.json")" = "$KUMABOX_S3_ID" -test ! -e "$KUMABOX_S3_WORK/data/sandboxes/$KUMABOX_S3_ID" -test ! -e "$KUMABOX_S3_WORK/log/sandboxes/$KUMABOX_S3_ID" -kb image rm "$KUMABOX_S3_IMAGE" -test "$(kb ps -a --json)" = "[]" -``` - -## 恢复与安全边界 - -至少验证: - -1. start 过程中发送 SIGINT,重试 start 能收敛为唯一 VMM; -2. running 时 kill VMM,stop 不向复用 PID 的无关进程发信号; -3. stop 在 TERM 等待阶段中断,重试继续 `stopping`; -4. rm 清理中断,重试继续 `deleting`; -5. 破坏 process.json 的 boot ID、binary 或 socket 后,stop 返回冲突且不发送信号; -6. agent 未启动时 exec 有界失败,VMM 保持可 inspect/stop; -7. logs follow 在 truncate/reopen 后继续,取消后无残留进程或 FD; -8. 非 TTY 重定向无 ANSI 控制字符,JSON 保持缩进。 - -记录 commit、内核、架构、Cloud Hypervisor、cgroup mode、formatter 版本、完整 image digest、每个命令退出码和失败后的持久状态。 diff --git a/scripts/check-doc-links.sh b/scripts/check-doc-links.sh deleted file mode 100755 index a039479..0000000 --- a/scripts/check-doc-links.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -failed=0 -while IFS= read -r document; do - while IFS= read -r markdown_link; do - target="${markdown_link#](}" - target="${target%)}" - target="${target%%#*}" - case "${target}" in - ""|http://*|https://*|mailto:*) continue ;; - esac - if [[ ! -e "$(dirname "${document}")/${target}" ]]; then - printf 'broken Markdown link: %s -> %s\n' "${document}" "${target}" >&2 - failed=1 - fi - done < <(grep -Eo '\]\([^)]+(\.md|LICENSE)(#[^)]*)?\)' "${document}" || true) -done < <(git ls-files '*.md') - -exit "${failed}" From 50ce5761f468900c9c0622f233accb8e10f74fcb Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Tue, 22 Sep 2026 09:41:24 +0800 Subject: [PATCH 38/45] feat: add CNI network foundation --- config/config.go | 83 +++++- config/config_test.go | 25 ++ core/images.go | 4 +- go.mod | 8 +- go.sum | 38 ++- metadata/sqlite/store.go | 21 +- metadata/sqlite/store_test.go | 52 ++++ network/cni/cni.go | 388 ++++++++++++++++++++++++++ network/cni/cni_test.go | 275 +++++++++++++++++++ network/cni/lifecycle.go | 494 ++++++++++++++++++++++++++++++++++ network/cni/platform_linux.go | 243 +++++++++++++++++ network/cni/platform_other.go | 36 +++ network/network.go | 103 +++++++ network/network_test.go | 37 +++ types/network.go | 152 +++++++++++ types/network_test.go | 31 +++ 16 files changed, 1969 insertions(+), 21 deletions(-) create mode 100644 network/cni/cni.go create mode 100644 network/cni/cni_test.go create mode 100644 network/cni/lifecycle.go create mode 100644 network/cni/platform_linux.go create mode 100644 network/cni/platform_other.go create mode 100644 network/network.go create mode 100644 network/network_test.go create mode 100644 types/network.go create mode 100644 types/network_test.go diff --git a/config/config.go b/config/config.go index f46190f..7c3e66f 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,8 @@ package config import ( "errors" "fmt" + "net" + "path/filepath" "runtime" "strings" "time" @@ -29,6 +31,8 @@ type Config struct { 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"` } @@ -65,6 +69,57 @@ type Sandbox struct { 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. @@ -98,6 +153,10 @@ func Default() Config { }, 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{ @@ -130,6 +189,25 @@ func (c *Config) Validate() error { 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) } @@ -167,7 +245,10 @@ func NewLoader() *Loader { "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, - "vmm.default": defaults.VMM.Default, "vmm.cgroup_parent": defaults.VMM.CgroupParent, + "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, diff --git a/config/config_test.go b/config/config_test.go index 5e106e8..f09381d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -117,3 +117,28 @@ func TestValidateRejectsOverlappingRoots(t *testing.T) { 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 index 971187a..6855e9f 100644 --- a/core/images.go +++ b/core/images.go @@ -21,6 +21,7 @@ import ( "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" "github.com/kumabox/kumabox/types" ) @@ -124,5 +125,6 @@ func NewRegistrySource(reference string) (images.Source, string, error) { // the database shape without an explicit migration. func metadataCollections() []metadata.Collection { result := catalog.Collections() - return append(result, sandboxcatalog.Collections()...) + result = append(result, sandboxcatalog.Collections()...) + return append(result, networkcni.Collections()...) } diff --git a/go.mod b/go.mod index 0405991..c16a9c9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/kumabox/kumabox go 1.24.4 require ( + 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 @@ -12,6 +14,8 @@ require ( 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 ) @@ -27,7 +31,7 @@ require ( 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/mdlayher/socket v0.4.1 // indirect + github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -44,7 +48,7 @@ require ( 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.9.0 // 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 diff --git a/go.sum b/go.sum index e4426a6..1cf5386 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ 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.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= @@ -20,6 +26,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk 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/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= @@ -28,8 +38,8 @@ 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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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= @@ -42,8 +52,8 @@ 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.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= -github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= +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= @@ -52,6 +62,10 @@ 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.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -92,25 +106,33 @@ 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= +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.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +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.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.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +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= diff --git a/metadata/sqlite/store.go b/metadata/sqlite/store.go index 482aa42..3e7e4e5 100644 --- a/metadata/sqlite/store.go +++ b/metadata/sqlite/store.go @@ -28,7 +28,7 @@ const ( // applicationID distinguishes KumaBox metadata from unrelated SQLite files. applicationID = 0x4B554D41 // schemaVersion identifies the current application collection contract. - schemaVersion = 2 + schemaVersion = 3 // firstSchemaVersion is the oldest metadata version with an in-place migration. firstSchemaVersion = 1 // initLockName serializes schema initialization across processes in this directory. @@ -231,8 +231,8 @@ func initialize(ctx context.Context, path string, collections []metadata.Collect switch version { case schemaVersion: return nil - case firstSchemaVersion: - return migrateVersionOne(ctx, db, collections) + case 1, 2: + 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)) } @@ -268,14 +268,17 @@ func initialize(ctx context.Context, path string, collections []metadata.Collect return commit(ctx, tx) } -// migrateVersionOne adds the collections introduced with sandbox management -// and publishes version 2 only after every declaration is durable. Version 1 -// already uses the same collections and records tables, so record payloads and -// image artifacts remain unchanged. +// 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 -> user_version=2 -> COMMIT +// BEGIN IMMEDIATE -> register missing collections -> publish version -> COMMIT // \---------------- any failure: ROLLBACK -----------------/ -func migrateVersionOne(ctx context.Context, db *sql.DB, collections []metadata.Collection) (returnErr error) { +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) diff --git a/metadata/sqlite/store_test.go b/metadata/sqlite/store_test.go index f70eb7e..5dd9783 100644 --- a/metadata/sqlite/store_test.go +++ b/metadata/sqlite/store_test.go @@ -167,6 +167,58 @@ func TestStoreMigratesVersionOneAndPreservesRecords(t *testing.T) { } } +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 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'))") 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/types/network.go b/types/network.go new file mode 100644 index 0000000..c852acb --- /dev/null +++ b/types/network.go @@ -0,0 +1,152 @@ +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 _, 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") + } + 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..b6902d2 --- /dev/null +++ b/types/network_test.go @@ -0,0 +1,31 @@ +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") + } +} From a536d924c8db75c8a125aaa314d678182c5c4544 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Tue, 22 Sep 2026 10:06:58 +0800 Subject: [PATCH 39/45] feat: provision sandbox networks --- cli/root.go | 4 + cli/root_test.go | 13 ++++ cli/sandbox/create.go | 15 +++- cli/sandbox/create_test.go | 35 ++++++++- cli/sandbox/output.go | 84 +++++++++++++++++++-- cli/sandbox/remove_test.go | 2 +- core/sandbox.go | 28 ++++++- core/sandbox_runtime.go | 7 ++ core/sandbox_runtime_test.go | 24 ++++++ core/sandbox_storage.go | 85 ++++++++++++++++----- core/sandbox_storage_test.go | 116 +++++++++++++++++++++++++++++ core/sandbox_test.go | 68 ++++++++++++++++- sandbox/catalog/store.go | 135 ++++++++++++++++++++++++++++++++-- sandbox/catalog/store_test.go | 51 ++++++++++++- types/sandbox.go | 46 +++++++++++- types/sandbox_test.go | 3 + 16 files changed, 672 insertions(+), 44 deletions(-) diff --git a/cli/root.go b/cli/root.go index 16c06c9..bae0f45 100644 --- a/cli/root.go +++ b/cli/root.go @@ -125,8 +125,12 @@ func newRootCommand() (*cobra.Command, error) { 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) diff --git a/cli/root_test.go b/cli/root_test.go index 695ac35..a31fd2a 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -112,6 +112,19 @@ func TestInvalidConfigurationUsesDomainExitCode(t *testing.T) { } } +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")} diff --git a/cli/sandbox/create.go b/cli/sandbox/create.go index 09fd0e2..495ca99 100644 --- a/cli/sandbox/create.go +++ b/cli/sandbox/create.go @@ -26,6 +26,8 @@ func NewCreateCommand(configuration configProvider) *cobra.Command { cpus := types.DefaultSandboxCPUs memory := "1GiB" storageSize := "10GiB" + nics := 1 + networkName := "" asJSON := false command := &cobra.Command{ Use: "create IMAGE", @@ -49,7 +51,16 @@ func NewCreateCommand(configuration configProvider) *cobra.Command { if storageBytes < types.MinSandboxStorage { return invalidFlag("storage", fmt.Errorf("must be at least %d bytes", types.MinSandboxStorage)) } - sandboxConfig := types.SandboxConfig{Name: name, CPUs: cpus, Memory: memoryBytes, Storage: storageBytes} + if nics < 0 || nics > types.MaxSandboxNICs { + return invalidFlag("nics", fmt.Errorf("must be between 0 and %d", types.MaxSandboxNICs)) + } + if nics == 0 && networkName != "" { + return invalidFlag("network", errors.New("requires at least one NIC")) + } + sandboxConfig := types.SandboxConfig{ + Name: name, CPUs: cpus, Memory: memoryBytes, Storage: storageBytes, + NICs: nics, NetworkName: networkName, + } if err := sandboxConfig.Validate(); err != nil { return err } @@ -82,6 +93,8 @@ func NewCreateCommand(configuration configProvider) *cobra.Command { command.Flags().Uint32Var(&cpus, "cpus", cpus, "number of virtual CPUs") command.Flags().StringVar(&memory, "memory", memory, "guest memory (for example 1GiB)") command.Flags().StringVar(&storageSize, "storage", storageSize, "logical sparse COW size (minimum 10GiB)") + command.Flags().IntVar(&nics, "nics", nics, "number of network interfaces (0 disables networking)") + command.Flags().StringVar(&networkName, "network", networkName, "CNI network name (empty selects the default)") command.Flags().BoolVar(&asJSON, "json", false, "print the created sandbox as indented JSON") return command } diff --git a/cli/sandbox/create_test.go b/cli/sandbox/create_test.go index 399ecd2..c74e44e 100644 --- a/cli/sandbox/create_test.go +++ b/cli/sandbox/create_test.go @@ -53,6 +53,8 @@ func TestCreateCommandMapsResourceValidationToFlags(t *testing.T) { {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) { @@ -78,10 +80,21 @@ func TestWriteResultUsesFullIDAndIndentedJSON(t *testing.T) { 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}, + 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 { @@ -97,6 +110,22 @@ func TestWriteResultUsesFullIDAndIndentedJSON(t *testing.T) { 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 TestCreateProgressReportsCommittedOutputFailure(t *testing.T) { @@ -123,7 +152,7 @@ func TestCreateCommandPersistsCreatedSandboxAndFinalCOW(t *testing.T) { seedImage(t, roots) installFakeMKFS(t, base) command := NewCreateCommand(func() config.Config { return sandboxTestConfig(roots) }) - command.SetArgs([]string{"demo", "--name", "box", "--cpus", "1", "--json"}) + command.SetArgs([]string{"demo", "--name", "box", "--cpus", "1", "--nics", "0", "--json"}) var stdout, stderr bytes.Buffer command.SetOut(&stdout) command.SetErr(&stderr) diff --git a/cli/sandbox/output.go b/cli/sandbox/output.go index 87ee411..8e71c55 100644 --- a/cli/sandbox/output.go +++ b/cli/sandbox/output.go @@ -30,6 +30,12 @@ type sandboxOutput struct { 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. @@ -48,6 +54,46 @@ type sandboxFailureOutput struct { 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. @@ -61,8 +107,12 @@ 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, Generation: sandbox.Generation, - CreatedAt: sandbox.CreatedAt.UTC(), UpdatedAt: sandbox.UpdatedAt.UTC(), + 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} @@ -70,6 +120,29 @@ func sandboxResult(sandbox types.Sandbox) sandboxOutput { 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) @@ -121,13 +194,14 @@ func writeSandboxIDs(writer io.Writer, records []types.Sandbox) error { // 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\tCREATED"); err != nil { + 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%s\n", + 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), + formatIECBytes(record.Config.Memory), formatIECBytes(record.Config.Storage), record.Config.NICs, + record.Config.NetworkName, record.CreatedAt.UTC().Format(time.RFC3339), ); err != nil { return err diff --git a/cli/sandbox/remove_test.go b/cli/sandbox/remove_test.go index 32812e9..a65be28 100644 --- a/cli/sandbox/remove_test.go +++ b/cli/sandbox/remove_test.go @@ -111,7 +111,7 @@ func TestRemoveCommandClosesCreateAndImageReferenceLifecycle(t *testing.T) { 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"}) + command.SetArgs([]string{"demo", "--name", name, "--cpus", "1", "--nics", "0"}) var stdout, stderr bytes.Buffer command.SetOut(&stdout) command.SetErr(&stderr) diff --git a/core/sandbox.go b/core/sandbox.go index 4a14608..d64a2a4 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -12,8 +12,11 @@ import ( 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" ) @@ -38,7 +41,7 @@ type imageGuard interface { // 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, time.Time) (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) @@ -74,6 +77,8 @@ type sandboxDependencies struct { catalog sandboxCatalog // disks prepares and cleans sandbox-owned writable disks. disks disk.Backend + // networks owns sandbox network namespaces, CNI allocations, and TAP devices. + networks network.Provider // imagePaths derives immutable artifacts after the image guard verifies them. imagePaths images.Paths // runtimes route persisted VMM identities to process adapters. @@ -99,7 +104,7 @@ type SandboxService struct { // 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.runtimes.Len() == 0 { + 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 { @@ -160,12 +165,27 @@ func OpenSandbox(ctx context.Context, configuration config.Config, reporter Sand 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()) + } + networks, 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()) + } 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, runtimes: runtimes, reporter: reporter, - store: store, defaultVMM: defaultVMM, cleanupTimeout: configuration.Sandbox.CleanupTimeout, + catalog: sandboxCatalog, disks: disks, networks: networks, runtimes: runtimes, reporter: reporter, + store: store, defaultVMM: defaultVMM, + cleanupTimeout: max(configuration.Sandbox.CleanupTimeout, configuration.Network.CleanupTimeout), }) if err != nil { return nil, errors.Join(err, store.Close()) diff --git a/core/sandbox_runtime.go b/core/sandbox_runtime.go index 524486a..66ad391 100644 --- a/core/sandbox_runtime.go +++ b/core/sandbox_runtime.go @@ -59,6 +59,13 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty if err != nil { return types.Sandbox{}, err } + if record.Config.NICs > 0 || record.Network.Backend != "" { + return record, errdefs.New( + errdefs.ClassInvalid, + errdefs.CodeHostIncompatible, + errors.New("starting a networked sandbox is not supported until VMM network attachment is available"), + ) + } backend, err := s.dependencies.runtimes.Backend(record.VMM) if err != nil { return record, err diff --git a/core/sandbox_runtime_test.go b/core/sandbox_runtime_test.go index 594a598..2d6c65f 100644 --- a/core/sandbox_runtime_test.go +++ b/core/sandbox_runtime_test.go @@ -76,6 +76,30 @@ func TestStartCommitsRunningOnlyAfterLaunchReadiness(t *testing.T) { } } +func TestStartRejectsNetworkedSandboxBeforeRuntimeRecovery(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 + if _, err := service.Start(t.Context(), "box"); err == nil { + t.Fatal("Start accepted a networked sandbox before VMM network attachment exists") + } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeHostIncompatible { + t.Fatalf("Start error code = %q, %v; want %q", code, err, errdefs.CodeHostIncompatible) + } + if got := *steps; !reflect.DeepEqual(got, []string{ + "status:resolving sandbox", "resolve", "status:waiting for sandbox operation lock", "resolve", + }) { + t.Fatalf("Start touched runtime state before rejection: %v", got) + } +} + func TestStartRecoversRunningProcessFromStartingState(t *testing.T) { service, steps := newTestSandboxService(t, nil) if _, err := service.Create(t.Context(), CreateSandboxRequest{ diff --git a/core/sandbox_storage.go b/core/sandbox_storage.go index 42d7dcc..45265d0 100644 --- a/core/sandbox_storage.go +++ b/core/sandbox_storage.go @@ -8,17 +8,19 @@ import ( "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 the private disk. -// Only the final generation-fenced transition makes the disk startable. +// 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 -> ID lock -> image locks + reservation -> sparse ext4 COW -> Created -// | | -// +---- failure cleanup <---+ +// 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.runtimes.Len() == 0 || s.dependencies.reporter == nil || s.dependencies.newID == nil || s.dependencies.now == nil || s.dependencies.cleanupTimeout <= 0 { + 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 == "" { @@ -81,6 +83,37 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques } 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 := s.dependencies.networks.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 := s.dependencies.networks.Add(ctx, id, request.Config.NetworkName, specs...) + if err != nil { + return types.Sandbox{}, s.compensate(ctx, record, "network add", err) + } + setup = types.NetworkSetup{Backend: s.dependencies.networks.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("creating sparse ext4 disk"); err != nil { return types.Sandbox{}, s.compensate(ctx, record, "report", err) } @@ -90,7 +123,7 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques 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, s.dependencies.now().UTC()) + 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) } @@ -101,14 +134,14 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques return created, nil } -// Remove records cleanup intent before deleting the COW directory and releases -// the name and image reference only after filesystem cleanup succeeds. +// 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 -> remove files -> forget record + name -// | | -// +---- retry resumes here <---+ +// 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.runtimes.Len() == 0 || s.dependencies.reporter == nil || s.dependencies.now == nil { + 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 == "" { @@ -164,6 +197,14 @@ func (s *SandboxService) Remove(ctx context.Context, reference string) (result t 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 deleting.Config.NICs > 0 || deleting.Network.Backend != "" { + 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 := s.dependencies.networks.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) } @@ -182,20 +223,24 @@ func (s *SandboxService) Remove(ctx context.Context, reference string) (result t return deleting, nil } -// compensate removes the owned disk before forgetting the Creating reservation. -// If cleanup cannot be proven complete, Error retains the resource owner and image pin. +// 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() - removeErr := s.dependencies.disks.Remove(cleanupCtx, record.ID) - if removeErr == nil { + cleanupErr := s.dependencies.disks.Remove(cleanupCtx, record.ID) + if record.Config.NICs > 0 || record.Network.Backend != "" { + cleanupErr = errors.Join(cleanupErr, s.dependencies.networks.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) } - removeErr = forgetErr + cleanupErr = forgetErr } - failure := types.SandboxFailure{Phase: phase, Message: errors.Join(cause, removeErr).Error()} + 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, removeErr, markErr), "create sandbox", record.Config.Name, phase, "inspect or remove the retained error sandbox", false) + 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 index fa77c6d..76d652b 100644 --- a/core/sandbox_storage_test.go +++ b/core/sandbox_storage_test.go @@ -30,6 +30,83 @@ func TestCreateCommitsCreatedAfterDiskPreparation(t *testing.T) { } } +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 := service.dependencies.networks.(*fakeNetwork) + 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: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 := service.dependencies.networks.(*fakeNetwork) + 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 := service.dependencies.networks.(*fakeNetwork) + 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{ @@ -172,6 +249,45 @@ func TestRemoveFailureRetainsDeletingAndRetryFinishes(t *testing.T) { } } +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 := service.dependencies.networks.(*fakeNetwork) + 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{ diff --git a/core/sandbox_test.go b/core/sandbox_test.go index 1cf2b1c..f1a3bea 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -3,6 +3,7 @@ package core import ( "context" "errors" + "fmt" "io" "path/filepath" "runtime" @@ -13,6 +14,7 @@ import ( "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" @@ -47,11 +49,15 @@ func (f *fakeCatalog) Reserve(_ context.Context, _ string, _ types.Digest, recor return nil } -func (f *fakeCatalog) MarkCreated(_ context.Context, _ types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) { +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 } @@ -174,6 +180,62 @@ type fakeDisk struct { remove error } +type fakeNetwork struct { + steps *[]string + prepareErr error + addErr error + deleteErr error + namespace string + interfaces []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 (*fakeNetwork) Recover(context.Context, types.SandboxID, string, []types.NetworkInterface) ([]types.NetworkInterface, error) { + return nil, nil +} + +func (*fakeNetwork) Quiesce(context.Context, types.SandboxID) error { return nil } +func (*fakeNetwork) Unquiesce(context.Context, types.SandboxID) error { 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 @@ -344,7 +406,8 @@ func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]s } service, err := newSandboxService(sandboxDependencies{ paths: paths, imagePaths: imagePaths, images: fakeGuard{image: image, steps: &steps}, - catalog: catalog, disks: fakeDisk{steps: &steps, prepare: diskError}, runtimes: runtimes, + catalog: catalog, disks: fakeDisk{steps: &steps, prepare: diskError}, + networks: &fakeNetwork{steps: &steps, namespace: "/var/run/netns/kumabox-test"}, runtimes: runtimes, defaultVMM: types.VMMCloudHypervisor, cleanupTimeout: 10 * time.Second, reporter: fakeReporter{steps: &steps}, newID: func() (types.SandboxID, error) { return fixedID, nil }, @@ -391,6 +454,7 @@ func TestNewSandboxServiceValidatesNamedDependencies(t *testing.T) { {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 }}, diff --git a/sandbox/catalog/store.go b/sandbox/catalog/store.go index d97f979..158cdc9 100644 --- a/sandbox/catalog/store.go +++ b/sandbox/catalog/store.go @@ -61,6 +61,12 @@ type recordData struct { 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 @@ -86,6 +92,33 @@ type failureData struct { 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. @@ -134,9 +167,49 @@ func (c *Store) Reserve(ctx context.Context, imageReference string, expected typ return errdefs.Context(err, "reserve sandbox", record.Config.Name, "metadata", "choose another name or retry", false) } -// MarkCreated performs the create commit only when state and generation still match. -func (c *Store) MarkCreated(ctx context.Context, id types.SandboxID, expected uint64, updated time.Time) (types.Sandbox, error) { - return c.transition(ctx, id, expected, types.SandboxStateCreating, types.SandboxStateCreated, nil, updated) +// 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. @@ -478,10 +551,14 @@ func deleteRecord(ctx context.Context, writer metadata.Writer, record types.Sand 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, + 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} } @@ -506,10 +583,16 @@ func decode(raw []byte) (types.Sandbox, error) { 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}, + 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} } @@ -519,6 +602,48 @@ func decode(raw []byte) (types.Sandbox, error) { 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) diff --git a/sandbox/catalog/store_test.go b/sandbox/catalog/store_test.go index 532493d..078e047 100644 --- a/sandbox/catalog/store_test.go +++ b/sandbox/catalog/store_test.go @@ -60,6 +60,53 @@ func TestResolveRejectsDanglingNameBinding(t *testing.T) { } } +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 { @@ -158,14 +205,14 @@ func TestReservationPinsImageInsideRemovalTransaction(t *testing.T) { 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, created.Add(time.Second)) + 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, created.Add(2*time.Second)); err == nil { + 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) diff --git a/types/sandbox.go b/types/sandbox.go index cdf1d2e..05d6dc1 100644 --- a/types/sandbox.go +++ b/types/sandbox.go @@ -28,9 +28,14 @@ const ( 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}$`) +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 @@ -132,6 +137,11 @@ type SandboxConfig struct { 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. @@ -148,6 +158,15 @@ func (c SandboxConfig) Validate() error { 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 } @@ -203,6 +222,9 @@ type Sandbox struct { 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. @@ -229,6 +251,28 @@ func (s Sandbox) Validate() error { 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: diff --git a/types/sandbox_test.go b/types/sandbox_test.go index 1fecf82..6f9ea23 100644 --- a/types/sandbox_test.go +++ b/types/sandbox_test.go @@ -31,6 +31,9 @@ func TestSandboxConfigValidationMatchesCreateContract(t *testing.T) { {"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() From fbd0fe6fabf15af50a7444a63ab8d5508276f7df Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Tue, 22 Sep 2026 14:08:18 +0800 Subject: [PATCH 40/45] feat: connect sandbox networking to VMM --- Makefile | 1 + core/sandbox.go | 39 +++++++- core/sandbox_runtime.go | 68 ++++++++++--- core/sandbox_runtime_test.go | 140 +++++++++++++++++++++++++-- core/sandbox_storage.go | 34 +++++-- core/sandbox_storage_test.go | 9 +- core/sandbox_test.go | 50 ++++++++-- network/registry.go | 69 +++++++++++++ network/registry_test.go | 66 +++++++++++++ oci-images/ubuntu/Dockerfile | 7 ++ oci-images/ubuntu/network.sh | 75 ++++++++++++++ oci-images/ubuntu/overlay.sh | 8 ++ types/network.go | 5 +- types/network_test.go | 14 +++ vmm/cloudhypervisor/args.go | 14 +++ vmm/cloudhypervisor/args_test.go | 9 ++ vmm/cloudhypervisor/driver.go | 2 +- vmm/cloudhypervisor/process_linux.go | 36 +++++++ vmm/cloudhypervisor/process_other.go | 2 + vmm/vmm.go | 60 +++++++++++- vmm/vmm_test.go | 20 ++++ 21 files changed, 684 insertions(+), 44 deletions(-) create mode 100644 network/registry.go create mode 100644 network/registry_test.go create mode 100644 oci-images/ubuntu/network.sh diff --git a/Makefile b/Makefile index 46576c7..d6b8880 100644 --- a/Makefile +++ b/Makefile @@ -81,6 +81,7 @@ test: vet ## Run tests with race detection and coverage 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 ./... diff --git a/core/sandbox.go b/core/sandbox.go index d64a2a4..89485c4 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -77,8 +77,10 @@ type sandboxDependencies struct { catalog sandboxCatalog // disks prepares and cleans sandbox-owned writable disks. disks disk.Backend - // networks owns sandbox network namespaces, CNI allocations, and TAP devices. - networks network.Provider + // 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. @@ -87,6 +89,8 @@ type sandboxDependencies struct { 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. @@ -113,6 +117,9 @@ func newSandboxService(dependencies sandboxDependencies) (*SandboxService, error 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{} } @@ -135,6 +142,10 @@ func OpenSandbox(ctx context.Context, configuration config.Config, reporter Sand 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 @@ -169,7 +180,7 @@ func OpenSandbox(ctx context.Context, configuration config.Config, reporter Sand if err != nil { return nil, errors.Join(err, store.Close()) } - networks, err := cni.New(cni.Options{ + cniProvider, err := cni.New(cni.Options{ ConfDir: configuration.Network.CNI.ConfDir, BinDir: configuration.Network.CNI.BinDir, CacheDir: cacheDir, @@ -179,13 +190,18 @@ func OpenSandbox(ctx context.Context, configuration config.Config, reporter Sand 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, + 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()) @@ -201,6 +217,21 @@ func (s *SandboxService) Close() error { 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 +} + // 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) { diff --git a/core/sandbox_runtime.go b/core/sandbox_runtime.go index 66ad391..9274864 100644 --- a/core/sandbox_runtime.go +++ b/core/sandbox_runtime.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "reflect" "runtime" "github.com/kumabox/kumabox/agent" @@ -23,7 +24,7 @@ import ( // | // 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.runtimes.Len() == 0 || s.dependencies.reporter == nil || s.dependencies.now == nil { + 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 == "" { @@ -59,13 +60,6 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty if err != nil { return types.Sandbox{}, err } - if record.Config.NICs > 0 || record.Network.Backend != "" { - return record, errdefs.New( - errdefs.ClassInvalid, - errdefs.CodeHostIncompatible, - errors.New("starting a networked sandbox is not supported until VMM network attachment is available"), - ) - } backend, err := s.dependencies.runtimes.Backend(record.VMM) if err != nil { return record, err @@ -115,7 +109,6 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty 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) } @@ -129,6 +122,9 @@ func (s *SandboxService) Start(ctx context.Context, reference string) (result ty 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{}) } @@ -185,6 +181,9 @@ func (s *SandboxService) recoverStart(ctx context.Context, backend vmm.Backend, 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 } @@ -240,7 +239,10 @@ func (s *SandboxService) launchPlan(record types.Sandbox, image types.Image) (vm if err != nil { return vmm.LaunchPlan{}, err } - cmdline, err := vmm.OverlayV1Cmdline(vmm.OverlayV1Config{LayerCount: len(image.Layers), Hostname: record.Config.Name}) + 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) } @@ -256,9 +258,46 @@ func (s *SandboxService) launchPlan(record types.Sandbox, image types.Image) (vm 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 { @@ -270,6 +309,7 @@ func (s *SandboxService) failStart(ctx context.Context, backend vmm.Backend, sta } 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()) @@ -283,7 +323,7 @@ func (s *SandboxService) failStart(ctx context.Context, backend vmm.Backend, sta // 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.runtimes.Len() == 0 || s.dependencies.reporter == nil || s.dependencies.now == nil { + 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 == "" { @@ -334,6 +374,9 @@ func (s *SandboxService) Stop(ctx context.Context, reference string) (result typ 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) } @@ -377,6 +420,9 @@ func (s *SandboxService) Stop(ctx context.Context, reference string) (result typ 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. diff --git a/core/sandbox_runtime_test.go b/core/sandbox_runtime_test.go index 2d6c65f..6fcc09c 100644 --- a/core/sandbox_runtime_test.go +++ b/core/sandbox_runtime_test.go @@ -76,7 +76,7 @@ func TestStartCommitsRunningOnlyAfterLaunchReadiness(t *testing.T) { } } -func TestStartRejectsNetworkedSandboxBeforeRuntimeRecovery(t *testing.T) { +func TestStartRecoversNetworkBeforeLaunchingInItsNamespace(t *testing.T) { service, steps := newTestSandboxService(t, nil) if _, err := service.Create(t.Context(), CreateSandboxRequest{ ImageReference: "demo", @@ -88,15 +88,76 @@ func TestStartRejectsNetworkedSandboxBeforeRuntimeRecovery(t *testing.T) { t.Fatal(err) } *steps = nil - if _, err := service.Start(t.Context(), "box"); err == nil { - t.Fatal("Start accepted a networked sandbox before VMM network attachment exists") - } else if code, ok := errdefs.CodeOf(err); !ok || code != errdefs.CodeHostIncompatible { - t.Fatalf("Start error code = %q, %v; want %q", code, err, errdefs.CodeHostIncompatible) + 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 touched runtime state before rejection: %v", got) + 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) } } @@ -204,6 +265,73 @@ func TestStopRecordsIntentBeforeTerminatingRunningVMM(t *testing.T) { } } +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 diff --git a/core/sandbox_storage.go b/core/sandbox_storage.go index 45265d0..1399cd9 100644 --- a/core/sandbox_storage.go +++ b/core/sandbox_storage.go @@ -35,6 +35,14 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques 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())) } @@ -88,7 +96,7 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques if err := s.dependencies.reporter.Status("preparing sandbox network"); err != nil { return types.Sandbox{}, s.compensate(ctx, record, "report", err) } - namespace, err := s.dependencies.networks.Prepare(ctx, id) + namespace, err := networkProvider.Prepare(ctx, id) if err != nil { return types.Sandbox{}, s.compensate(ctx, record, "network prepare", err) } @@ -100,11 +108,11 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques for index := range specs { specs[index].Queues = queues } - interfaces, err := s.dependencies.networks.Add(ctx, id, request.Config.NetworkName, specs...) + 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: s.dependencies.networks.Type(), Namespace: namespace, Interfaces: interfaces} + 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) } @@ -113,6 +121,12 @@ func (s *SandboxService) Create(ctx context.Context, request CreateSandboxReques } 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) @@ -182,6 +196,10 @@ func (s *SandboxService) Remove(ctx context.Context, reference string) (result t 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 } @@ -197,11 +215,11 @@ func (s *SandboxService) Remove(ctx context.Context, reference string) (result t 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 deleting.Config.NICs > 0 || deleting.Network.Backend != "" { + 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 := s.dependencies.networks.Delete(ctx, deleting.ID); err != nil { + 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) } } @@ -230,8 +248,10 @@ func (s *SandboxService) compensate(ctx context.Context, record types.Sandbox, p cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.dependencies.cleanupTimeout) defer cancel() cleanupErr := s.dependencies.disks.Remove(cleanupCtx, record.ID) - if record.Config.NICs > 0 || record.Network.Backend != "" { - cleanupErr = errors.Join(cleanupErr, s.dependencies.networks.Delete(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) diff --git a/core/sandbox_storage_test.go b/core/sandbox_storage_test.go index 76d652b..c5a6807 100644 --- a/core/sandbox_storage_test.go +++ b/core/sandbox_storage_test.go @@ -46,7 +46,7 @@ func TestCreatePublishesResolvedNetworkWithCreatedState(t *testing.T) { record.Network.Backend != types.NetworkBackendCNI || len(record.Network.Interfaces) != 2 { t.Fatalf("created network record = %+v", record) } - networks := service.dependencies.networks.(*fakeNetwork) + 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) } @@ -54,6 +54,7 @@ func TestCreatePublishesResolvedNetworkWithCreatedState(t *testing.T) { "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", } @@ -65,7 +66,7 @@ func TestCreatePublishesResolvedNetworkWithCreatedState(t *testing.T) { func TestCreateNetworkFailureCleansResourcesBeforeForgettingReservation(t *testing.T) { failure := errors.New("CNI add failed") service, steps := newTestSandboxService(t, nil) - networks := service.dependencies.networks.(*fakeNetwork) + networks := testNetwork(t, service) networks.addErr = failure if _, err := service.Create(t.Context(), CreateSandboxRequest{ ImageReference: "demo", @@ -86,7 +87,7 @@ func TestCreateRetainsNetworkOwnerWhenCleanupFails(t *testing.T) { addFailure := errors.New("CNI add failed") deleteFailure := errors.New("CNI delete failed") service, steps := newTestSandboxService(t, nil) - networks := service.dependencies.networks.(*fakeNetwork) + networks := testNetwork(t, service) networks.addErr, networks.deleteErr = addFailure, deleteFailure if _, err := service.Create(t.Context(), CreateSandboxRequest{ ImageReference: "demo", @@ -261,7 +262,7 @@ func TestRemoveNetworkFailureRetainsDeletingUntilRetry(t *testing.T) { t.Fatal(err) } failure := errors.New("network cleanup failed") - networks := service.dependencies.networks.(*fakeNetwork) + networks := testNetwork(t, service) networks.deleteErr = failure *steps = nil if _, err := service.Remove(t.Context(), "box"); !errors.Is(err, failure) { diff --git a/core/sandbox_test.go b/core/sandbox_test.go index f1a3bea..da6a2b2 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -184,9 +184,12 @@ 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 } @@ -224,12 +227,23 @@ func (*fakeNetwork) Verify(context.Context, types.SandboxID, []types.NetworkInte return nil } -func (*fakeNetwork) Recover(context.Context, types.SandboxID, string, []types.NetworkInterface) ([]types.NetworkInterface, error) { - return nil, 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 (*fakeNetwork) Quiesce(context.Context, types.SandboxID) error { return nil } -func (*fakeNetwork) Unquiesce(context.Context, types.SandboxID) error { return nil } +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") @@ -404,14 +418,20 @@ func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]s 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: &fakeNetwork{steps: &steps, namespace: "/var/run/netns/kumabox-test"}, runtimes: runtimes, + networks: networks, runtimes: runtimes, defaultVMM: types.VMMCloudHypervisor, cleanupTimeout: 10 * time.Second, - 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) }, + 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) @@ -419,6 +439,19 @@ func newTestSandboxService(t *testing.T, diskError error) (*SandboxService, *[]s 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) @@ -458,6 +491,7 @@ func TestNewSandboxServiceValidatesNamedDependencies(t *testing.T) { {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 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/Dockerfile b/oci-images/ubuntu/Dockerfile index 4618aba..dc46dab 100644 --- a/oci-images/ubuntu/Dockerfile +++ b/oci-images/ubuntu/Dockerfile @@ -25,6 +25,7 @@ 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; \ @@ -32,15 +33,20 @@ RUN set -eu; \ && apt-get 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 \ @@ -57,6 +63,7 @@ RUN set -eu; \ 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 \ diff --git a/oci-images/ubuntu/network.sh b/oci-images/ubuntu/network.sh new file mode 100644 index 0000000..06b1484 --- /dev/null +++ b/oci-images/ubuntu/network.sh @@ -0,0 +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() { + printf '%s\n' "$PREREQ" +} + +case "$1" in +prereqs) + prereqs + exit 0 + ;; +esac + +. /scripts/functions + +[ -n "$rootmnt" ] || exit 0 + +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 + + if [ -z "$HWADDR" ] && [ -r "/sys/class/net/$DEVICE/address" ]; then + HWADDR=$(cat "/sys/class/net/$DEVICE/address") + fi + [ -n "$HWADDR" ] || continue + + 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 + + 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 edf728f..38d7267 100755 --- a/oci-images/ubuntu/overlay.sh +++ b/oci-images/ubuntu/overlay.sh @@ -46,6 +46,7 @@ mountroot() { KUMABOX_LAYERS= KUMABOX_COW= KUMABOX_HOSTNAME= + KUMABOX_NETWORK=false KUMABOX_DEVICE_TIMEOUT=10 for argument in $(cat /proc/cmdline); do case "$argument" in @@ -53,6 +54,7 @@ mountroot() { 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 @@ -73,6 +75,12 @@ mountroot() { *[!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 diff --git a/types/network.go b/types/network.go index c852acb..ce96371 100644 --- a/types/network.go +++ b/types/network.go @@ -135,7 +135,7 @@ func (s NetworkSetup) Validate() error { } seen := make(map[int]struct{}, len(s.Interfaces)) previous := -1 - for _, networkInterface := range s.Interfaces { + for position, networkInterface := range s.Interfaces { if err := networkInterface.Validate(); err != nil { return fmt.Errorf("network interface %d: %w", networkInterface.Index, err) } @@ -145,6 +145,9 @@ func (s NetworkSetup) Validate() error { 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 } diff --git a/types/network_test.go b/types/network_test.go index b6902d2..ade63a1 100644 --- a/types/network_test.go +++ b/types/network_test.go @@ -29,3 +29,17 @@ func TestNetworkSetupZeroValueDisablesNetworking(t *testing.T) { 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/vmm/cloudhypervisor/args.go b/vmm/cloudhypervisor/args.go index 2066ee6..b5b7dbd 100644 --- a/vmm/cloudhypervisor/args.go +++ b/vmm/cloudhypervisor/args.go @@ -35,6 +35,20 @@ func buildArgs(plan vmm.LaunchPlan, apiSocket, vsock string) []string { } 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, diff --git a/vmm/cloudhypervisor/args_test.go b/vmm/cloudhypervisor/args_test.go index 9f506db..67eeed7 100644 --- a/vmm/cloudhypervisor/args_test.go +++ b/vmm/cloudhypervisor/args_test.go @@ -107,6 +107,13 @@ func TestBuildArgsMatchesDirectBootContract(t *testing.T) { {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{ @@ -117,6 +124,8 @@ func TestBuildArgsMatchesDirectBootContract(t *testing.T) { "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", diff --git a/vmm/cloudhypervisor/driver.go b/vmm/cloudhypervisor/driver.go index a612ca4..87e4521 100644 --- a/vmm/cloudhypervisor/driver.go +++ b/vmm/cloudhypervisor/driver.go @@ -173,7 +173,7 @@ func (d *Driver) Launch(ctx context.Context, plan vmm.LaunchPlan) (result vmm.Pr command.Stdout, command.Stderr = logFile, logFile configureProcess(command, scope) - if err := command.Start(); err != nil { + 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) diff --git a/vmm/cloudhypervisor/process_linux.go b/vmm/cloudhypervisor/process_linux.go index 3d9beec..13626a7 100644 --- a/vmm/cloudhypervisor/process_linux.go +++ b/vmm/cloudhypervisor/process_linux.go @@ -9,11 +9,14 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strconv" "strings" "syscall" "time" + "github.com/vishvananda/netns" + "github.com/kumabox/kumabox/types" "github.com/kumabox/kumabox/vmm" ) @@ -37,6 +40,39 @@ func configureProcess(command *exec.Cmd, scope *os.File) { } } +// 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 { diff --git a/vmm/cloudhypervisor/process_other.go b/vmm/cloudhypervisor/process_other.go index f4f9170..00cc1fd 100644 --- a/vmm/cloudhypervisor/process_other.go +++ b/vmm/cloudhypervisor/process_other.go @@ -19,6 +19,8 @@ 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 } diff --git a/vmm/vmm.go b/vmm/vmm.go index c15c2b6..03e5a3f 100644 --- a/vmm/vmm.go +++ b/vmm/vmm.go @@ -6,6 +6,7 @@ package vmm import ( "errors" "fmt" + "net" "path/filepath" "strings" @@ -51,6 +52,9 @@ type LaunchPlan struct { 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. @@ -81,6 +85,9 @@ func (p LaunchPlan) Validate() error { 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 } @@ -91,6 +98,10 @@ type OverlayV1Config struct { 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 @@ -106,8 +117,53 @@ func OverlayV1Cmdline(config OverlayV1Config) (string, error) { for position := config.LayerCount - 1; position >= 0; position-- { serials = append(serials, fmt.Sprintf("%s%d", LayerSerialPrefix, position)) } - return "console=hvc0 loglevel=3 boot=kumabox-overlay kumabox.layers=" + strings.Join(serials, ",") + - " kumabox.cow=" + COWSerial + " kumabox.hostname=" + config.Hostname + " clocksource=kvm-clock rw", nil + 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. diff --git a/vmm/vmm_test.go b/vmm/vmm_test.go index 161c3b7..f998fbf 100644 --- a/vmm/vmm_test.go +++ b/vmm/vmm_test.go @@ -17,6 +17,26 @@ func TestOverlayV1CmdlineListsLayersTopToBase(t *testing.T) { } } +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, From d391c9a986ed9faf97d543bd04869ddb90359eb3 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Tue, 22 Sep 2026 14:32:52 +0800 Subject: [PATCH 41/45] fix: include guest agent build dependencies --- .dockerignore | 5 +++++ oci-images/ubuntu/Dockerfile | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index a966139..0127677 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,10 @@ !go.sum !agent/ !agent/** +!errdefs/ +!errdefs/** +!types/ +!types/** !version/ !version/** !cmd/ @@ -11,4 +15,5 @@ !oci-images/ !oci-images/ubuntu/ !oci-images/ubuntu/overlay.sh +!oci-images/ubuntu/network.sh !oci-images/ubuntu/kumabox-agent.service diff --git a/oci-images/ubuntu/Dockerfile b/oci-images/ubuntu/Dockerfile index dc46dab..882ca79 100644 --- a/oci-images/ubuntu/Dockerfile +++ b/oci-images/ubuntu/Dockerfile @@ -8,6 +8,8 @@ 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 \ @@ -29,8 +31,8 @@ COPY oci-images/ubuntu/network.sh /usr/local/lib/kumabox/initramfs/kumabox-netwo COPY oci-images/ubuntu/kumabox-agent.service /etc/systemd/system/kumabox-agent.service RUN set -eu; \ - apt-get update \ - && apt-get install -y --no-install-recommends \ + apt-get -o Acquire::Retries=5 update \ + && apt-get -o Acquire::Retries=5 install -y --no-install-recommends \ ca-certificates \ initramfs-tools \ iproute2 \ From c74dd064663336dfdf57ebcc12f9871c146eddd0 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Tue, 22 Sep 2026 14:54:15 +0800 Subject: [PATCH 42/45] feat: add run command --- cli/root.go | 1 + cli/root_test.go | 1 + cli/sandbox/create.go | 114 ++++++++++++++++++++++------------- cli/sandbox/create_test.go | 32 ++++++++++ cli/sandbox/progress.go | 5 ++ cli/sandbox/run.go | 58 ++++++++++++++++++ core/sandbox.go | 22 +++++++ core/sandbox_runtime_test.go | 63 +++++++++++++++++++ 8 files changed, 253 insertions(+), 43 deletions(-) create mode 100644 cli/sandbox/run.go diff --git a/cli/root.go b/cli/root.go index bae0f45..49fdf61 100644 --- a/cli/root.go +++ b/cli/root.go @@ -147,6 +147,7 @@ func newRootCommand() (*cobra.Command, error) { root.AddCommand(sandboxcmd.NewListCommand(provideConfig)) root.AddCommand(sandboxcmd.NewLogsCommand(provideConfig)) root.AddCommand(sandboxcmd.NewRemoveCommand(provideConfig)) + root.AddCommand(sandboxcmd.NewRunCommand(provideConfig)) root.AddCommand(sandboxcmd.NewStartCommand(provideConfig)) root.AddCommand(sandboxcmd.NewStopCommand(provideConfig)) root.AddCommand(newVersionCommand()) diff --git a/cli/root_test.go b/cli/root_test.go index a31fd2a..ee45103 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -142,6 +142,7 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"missing inspect sandbox", []string{"inspect"}, 2}, {"missing logs sandbox", []string{"logs"}, 2}, {"missing remove sandbox", []string{"rm"}, 2}, + {"missing run image", []string{"run", "--name", "box"}, 2}, {"missing start sandbox", []string{"start"}, 2}, {"missing stop sandbox", []string{"stop"}, 2}, {"unexpected ps argument", []string{"ps", "box"}, 2}, diff --git a/cli/sandbox/create.go b/cli/sandbox/create.go index 495ca99..2fc46a8 100644 --- a/cli/sandbox/create.go +++ b/cli/sandbox/create.go @@ -20,51 +20,84 @@ import ( // 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 { - name := "" - cpus := types.DefaultSandboxCPUs - memory := "1GiB" - storageSize := "10GiB" - nics := 1 - networkName := "" + 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) { - if cpus == 0 || cpus > types.MaxSandboxCPUs { - return invalidFlag("cpus", fmt.Errorf("must be between 1 and %d", types.MaxSandboxCPUs)) - } - memoryBytes, err := parseBytes(memory) - if err != nil { - return invalidFlag("memory", err) - } - if memoryBytes < types.MinSandboxMemory { - return invalidFlag("memory", fmt.Errorf("must be at least %d bytes", types.MinSandboxMemory)) - } - storageBytes, err := parseBytes(storageSize) + request, err := options.request(args[0]) if err != nil { - return invalidFlag("storage", err) - } - if storageBytes < types.MinSandboxStorage { - return invalidFlag("storage", fmt.Errorf("must be at least %d bytes", types.MinSandboxStorage)) - } - if nics < 0 || nics > types.MaxSandboxNICs { - return invalidFlag("nics", fmt.Errorf("must be between 0 and %d", types.MaxSandboxNICs)) - } - if nics == 0 && networkName != "" { - return invalidFlag("network", errors.New("requires at least one NIC")) - } - sandboxConfig := types.SandboxConfig{ - Name: name, CPUs: cpus, Memory: memoryBytes, Storage: storageBytes, - NICs: nics, NetworkName: networkName, - } - if err := sandboxConfig.Validate(); err != nil { return err } - progress, err := startCreateProgress(command, name) + progress, err := startCreateProgress(command, options.name) if err != nil { return err } @@ -76,25 +109,20 @@ func NewCreateCommand(configuration configProvider) *cobra.Command { committed := false defer func() { closeErr := service.Close() - returnErr = errors.Join(returnErr, errdefs.Context(closeErr, "create sandbox", name, "close metadata", "inspect the sandbox before retrying", committed)) + 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(), core.CreateSandboxRequest{ImageReference: args[0], Config: sandboxConfig}) + 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", name, "output", "sandbox was created; inspect it before retrying", true) + return errdefs.Context(err, "create sandbox", options.name, "output", "sandbox was created; inspect it before retrying", true) } return nil }, } - command.Flags().StringVar(&name, "name", name, "required sandbox name") - command.Flags().Uint32Var(&cpus, "cpus", cpus, "number of virtual CPUs") - command.Flags().StringVar(&memory, "memory", memory, "guest memory (for example 1GiB)") - command.Flags().StringVar(&storageSize, "storage", storageSize, "logical sparse COW size (minimum 10GiB)") - command.Flags().IntVar(&nics, "nics", nics, "number of network interfaces (0 disables networking)") - command.Flags().StringVar(&networkName, "network", networkName, "CNI network name (empty selects the default)") + options.addFlags(command) command.Flags().BoolVar(&asJSON, "json", false, "print the created sandbox as indented JSON") return command } diff --git a/cli/sandbox/create_test.go b/cli/sandbox/create_test.go index c74e44e..489af0d 100644 --- a/cli/sandbox/create_test.go +++ b/cli/sandbox/create_test.go @@ -128,6 +128,38 @@ func TestCreateCommandDefaultsToOneNIC(t *testing.T) { } } +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) diff --git a/cli/sandbox/progress.go b/cli/sandbox/progress.go index 72a63cf..7e96391 100644 --- a/cli/sandbox/progress.go +++ b/cli/sandbox/progress.go @@ -41,6 +41,11 @@ func startCreateProgress(command *cobra.Command, name string) (*sandboxProgress, 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") 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/core/sandbox.go b/core/sandbox.go index 89485c4..07cc4a9 100644 --- a/core/sandbox.go +++ b/core/sandbox.go @@ -232,6 +232,28 @@ func (s *SandboxService) networkProvider(record types.Sandbox) (network.Provider 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) { diff --git a/core/sandbox_runtime_test.go b/core/sandbox_runtime_test.go index 6fcc09c..a034a80 100644 --- a/core/sandbox_runtime_test.go +++ b/core/sandbox_runtime_test.go @@ -15,6 +15,69 @@ import ( "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}} From a798d3215a8acb75d5affbaf9d479d3611abeb31 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Tue, 22 Sep 2026 15:16:22 +0800 Subject: [PATCH 43/45] feat: add live snapshot management --- cli/root.go | 2 + cli/root_test.go | 4 + cli/snapshot/command.go | 134 +++++++++++++ cli/snapshot/output.go | 101 ++++++++++ cli/snapshot/output_test.go | 68 +++++++ cli/snapshot/progress.go | 64 ++++++ core/images.go | 4 +- core/sandbox_test.go | 20 ++ core/snapshot.go | 294 +++++++++++++++++++++++++++ core/snapshot_test.go | 144 ++++++++++++++ metadata/sqlite/store.go | 4 +- metadata/sqlite/store_test.go | 38 ++++ snapshot/catalog/store.go | 343 ++++++++++++++++++++++++++++++++ snapshot/catalog/store_test.go | 49 +++++ snapshot/paths.go | 172 ++++++++++++++++ storage/copy_linux.go | 75 +++++++ storage/copy_linux_test.go | 42 ++++ storage/copy_other.go | 29 +++ storage/directory.go | 69 +++++++ types/snapshot.go | 85 ++++++++ types/snapshot_test.go | 31 +++ vmm/backend.go | 24 +++ vmm/cloudhypervisor/snapshot.go | 88 ++++++++ vmm/vmm.go | 21 ++ 24 files changed, 1902 insertions(+), 3 deletions(-) create mode 100644 cli/snapshot/command.go create mode 100644 cli/snapshot/output.go create mode 100644 cli/snapshot/output_test.go create mode 100644 cli/snapshot/progress.go create mode 100644 core/snapshot.go create mode 100644 core/snapshot_test.go create mode 100644 snapshot/catalog/store.go create mode 100644 snapshot/catalog/store_test.go create mode 100644 snapshot/paths.go create mode 100644 storage/copy_linux.go create mode 100644 storage/copy_linux_test.go create mode 100644 storage/copy_other.go create mode 100644 storage/directory.go create mode 100644 types/snapshot.go create mode 100644 types/snapshot_test.go create mode 100644 vmm/cloudhypervisor/snapshot.go diff --git a/cli/root.go b/cli/root.go index 49fdf61..76dd20e 100644 --- a/cli/root.go +++ b/cli/root.go @@ -13,6 +13,7 @@ import ( 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" @@ -150,6 +151,7 @@ func newRootCommand() (*cobra.Command, error) { 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 diff --git a/cli/root_test.go b/cli/root_test.go index ee45103..7375f72 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -145,6 +145,10 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"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}, 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/core/images.go b/core/images.go index 6855e9f..8ffc9d8 100644 --- a/core/images.go +++ b/core/images.go @@ -23,6 +23,7 @@ import ( "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" ) @@ -126,5 +127,6 @@ func NewRegistrySource(reference string) (images.Source, string, error) { func metadataCollections() []metadata.Collection { result := catalog.Collections() result = append(result, sandboxcatalog.Collections()...) - return append(result, networkcni.Collections()...) + result = append(result, networkcni.Collections()...) + return append(result, snapshotcatalog.Collections()...) } diff --git a/core/sandbox_test.go b/core/sandbox_test.go index da6a2b2..7d10062 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "os" "path/filepath" "runtime" "strings" @@ -291,6 +292,25 @@ type fakeRuntime struct { logsErr error removeLogsErr error logOptions vmm.LogOptions + snapshotPlan vmm.SnapshotPlan + snapshotErr 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) Type() types.VMMType { diff --git a/core/snapshot.go b/core/snapshot.go new file mode 100644 index 0000000..2b22e6e --- /dev/null +++ b/core/snapshot.go @@ -0,0 +1,294 @@ +package core + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/kumabox/kumabox/config" + "github.com/kumabox/kumabox/errdefs" + "github.com/kumabox/kumabox/images" + imagecatalog "github.com/kumabox/kumabox/images/catalog" + filelock "github.com/kumabox/kumabox/lock/flock" + "github.com/kumabox/kumabox/metadata" + "github.com/kumabox/kumabox/metadata/sqlite" + sandboxfs "github.com/kumabox/kumabox/sandbox" + sandboxcatalog "github.com/kumabox/kumabox/sandbox/catalog" + "github.com/kumabox/kumabox/snapshot" + snapshotcatalog "github.com/kumabox/kumabox/snapshot/catalog" + "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 +} + +// OpenSnapshots assembles the local snapshot service. The caller must close it. +func OpenSnapshots(ctx context.Context, configuration config.Config, reporter SnapshotReporter) (*SnapshotService, error) { + if err := configuration.Validate(); err != nil { + return nil, err + } + imagePaths, err := images.NewPaths(configuration.Paths) + if err != nil { + return nil, err + } + sandboxPaths, err := sandboxfs.NewPaths(configuration.Paths) + if err != nil { + return nil, err + } + snapshotPaths, err := snapshot.NewPaths(configuration.Paths) + if err != nil { + return nil, err + } + if err := errors.Join(imagePaths.Ensure(), sandboxPaths.Ensure(), snapshotPaths.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 + } + runtimes, err := openVMMRegistry(configuration) + if err != nil { + return nil, errors.Join(err, store.Close()) + } + if reporter == nil { + reporter = discardSnapshotReporter{} + } + return &SnapshotService{ + paths: snapshotPaths, sandboxPaths: sandboxPaths, + sandboxes: sandboxcatalog.New(store, imagecatalog.Reader{}), snapshots: snapshotcatalog.New(store), + runtimes: runtimes, reporter: reporter, newID: types.NewSnapshotID, now: time.Now, store: store, + }, nil +} + +// Close releases the shared metadata engine. +func (s *SnapshotService) Close() error { + if s == nil || 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 { + return types.Snapshot{}, errdefs.Context(err, "save snapshot", request.SandboxReference, "publish", "inspect snapshot storage before retrying", false) + } + 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 +} + +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..4e486e8 --- /dev/null +++ b/core/snapshot_test.go @@ -0,0 +1,144 @@ +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", + }, + } + 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, + } + return service, sandboxService, steps +} + +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/metadata/sqlite/store.go b/metadata/sqlite/store.go index 3e7e4e5..12caffc 100644 --- a/metadata/sqlite/store.go +++ b/metadata/sqlite/store.go @@ -28,7 +28,7 @@ const ( // applicationID distinguishes KumaBox metadata from unrelated SQLite files. applicationID = 0x4B554D41 // schemaVersion identifies the current application collection contract. - schemaVersion = 3 + schemaVersion = 4 // firstSchemaVersion is the oldest metadata version with an in-place migration. firstSchemaVersion = 1 // initLockName serializes schema initialization across processes in this directory. @@ -231,7 +231,7 @@ func initialize(ctx context.Context, path string, collections []metadata.Collect switch version { case schemaVersion: return nil - case 1, 2: + 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)) diff --git a/metadata/sqlite/store_test.go b/metadata/sqlite/store_test.go index 5dd9783..4128ed2 100644 --- a/metadata/sqlite/store_test.go +++ b/metadata/sqlite/store_test.go @@ -219,6 +219,44 @@ func TestStoreMigratesVersionTwoAndPreservesSandboxRecords(t *testing.T) { } } +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'))") 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..2fef025 --- /dev/null +++ b/snapshot/paths.go @@ -0,0 +1,172 @@ +// 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) +} + +// 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/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/vmm/backend.go b/vmm/backend.go index 42f613a..932e15a 100644 --- a/vmm/backend.go +++ b/vmm/backend.go @@ -30,6 +30,30 @@ type Backend interface { 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 +} + // 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. 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/vmm.go b/vmm/vmm.go index 03e5a3f..6b15683 100644 --- a/vmm/vmm.go +++ b/vmm/vmm.go @@ -214,3 +214,24 @@ type Observation struct { // 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 +} From bacc617a018fec9c6a372a80130db8d9a50fef29 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Tue, 22 Sep 2026 15:36:31 +0800 Subject: [PATCH 44/45] feat: restore sandboxes from snapshots --- cli/root.go | 1 + cli/root_test.go | 1 + cli/sandbox/progress.go | 21 +++ cli/sandbox/restore.go | 46 ++++++ core/sandbox_test.go | 12 ++ core/snapshot.go | 247 ++++++++++++++++++++++++---- core/snapshot_test.go | 121 ++++++++++++++ snapshot/paths.go | 12 ++ vmm/backend.go | 27 +++ vmm/cloudhypervisor/restore.go | 168 +++++++++++++++++++ vmm/cloudhypervisor/restore_test.go | 29 ++++ vmm/vmm.go | 11 ++ vmm/vmm_test.go | 16 ++ 13 files changed, 681 insertions(+), 31 deletions(-) create mode 100644 cli/sandbox/restore.go create mode 100644 vmm/cloudhypervisor/restore.go create mode 100644 vmm/cloudhypervisor/restore_test.go diff --git a/cli/root.go b/cli/root.go index 76dd20e..cd07edd 100644 --- a/cli/root.go +++ b/cli/root.go @@ -148,6 +148,7 @@ func newRootCommand() (*cobra.Command, error) { 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)) diff --git a/cli/root_test.go b/cli/root_test.go index 7375f72..50a967a 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -142,6 +142,7 @@ func TestImageAndUsageExitCodes(t *testing.T) { {"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}, diff --git a/cli/sandbox/progress.go b/cli/sandbox/progress.go index 7e96391..d3a0504 100644 --- a/cli/sandbox/progress.go +++ b/cli/sandbox/progress.go @@ -61,6 +61,27 @@ func startStopProgress(command *cobra.Command, reference string) (*sandboxProgre 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) } 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/core/sandbox_test.go b/core/sandbox_test.go index 7d10062..c5b761d 100644 --- a/core/sandbox_test.go +++ b/core/sandbox_test.go @@ -294,6 +294,8 @@ type fakeRuntime struct { logOptions vmm.LogOptions snapshotPlan vmm.SnapshotPlan snapshotErr error + restorePlan vmm.RestorePlan + restoreErr error } func (f *fakeRuntime) Snapshot(_ context.Context, plan vmm.SnapshotPlan) error { @@ -313,6 +315,16 @@ func (f *fakeRuntime) Snapshot(_ context.Context, plan vmm.SnapshotPlan) error { 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 diff --git a/core/snapshot.go b/core/snapshot.go index 2b22e6e..6584e8c 100644 --- a/core/snapshot.go +++ b/core/snapshot.go @@ -4,19 +4,18 @@ import ( "context" "errors" "fmt" + "os" + "reflect" "time" "github.com/kumabox/kumabox/config" "github.com/kumabox/kumabox/errdefs" - "github.com/kumabox/kumabox/images" - imagecatalog "github.com/kumabox/kumabox/images/catalog" filelock "github.com/kumabox/kumabox/lock/flock" "github.com/kumabox/kumabox/metadata" - "github.com/kumabox/kumabox/metadata/sqlite" sandboxfs "github.com/kumabox/kumabox/sandbox" - sandboxcatalog "github.com/kumabox/kumabox/sandbox/catalog" "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" ) @@ -59,52 +58,42 @@ type SnapshotService struct { 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) { - if err := configuration.Validate(); err != nil { - return nil, err - } - imagePaths, err := images.NewPaths(configuration.Paths) - if err != nil { - return nil, err - } - sandboxPaths, err := sandboxfs.NewPaths(configuration.Paths) + lifecycle, err := OpenSandbox(ctx, configuration, nil) if err != nil { return nil, err } snapshotPaths, err := snapshot.NewPaths(configuration.Paths) if err != nil { - return nil, err - } - if err := errors.Join(imagePaths.Ensure(), sandboxPaths.Ensure(), snapshotPaths.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 + return nil, errors.Join(err, lifecycle.Close()) } - runtimes, err := openVMMRegistry(configuration) - if err != nil { - return nil, errors.Join(err, store.Close()) + if err := snapshotPaths.Ensure(); err != nil { + return nil, errors.Join(err, lifecycle.Close()) } if reporter == nil { reporter = discardSnapshotReporter{} } return &SnapshotService{ - paths: snapshotPaths, sandboxPaths: sandboxPaths, - sandboxes: sandboxcatalog.New(store, imagecatalog.Reader{}), snapshots: snapshotcatalog.New(store), - runtimes: runtimes, reporter: reporter, newID: types.NewSnapshotID, now: time.Now, store: store, + 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 || s.store == nil { + if s == nil { + return nil + } + if s.lifecycle != nil { + return s.lifecycle.Close() + } + if s.store == nil { return nil } return s.store.Close() @@ -222,7 +211,12 @@ func (s *SnapshotService) Save(ctx context.Context, request SaveSnapshotRequest) return types.Snapshot{}, err } if err := s.paths.Publish(id); err != nil { - return types.Snapshot{}, errdefs.Context(err, "save snapshot", request.SandboxReference, "publish", "inspect snapshot storage before retrying", false) + 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) @@ -288,6 +282,197 @@ func (s *SnapshotService) Remove(ctx context.Context, reference string) (result 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 } diff --git a/core/snapshot_test.go b/core/snapshot_test.go index 4e486e8..5a0b01c 100644 --- a/core/snapshot_test.go +++ b/core/snapshot_test.go @@ -51,6 +51,20 @@ func newTestSnapshotService(t *testing.T) (*SnapshotService, *SandboxService, *[ 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"), } @@ -70,10 +84,117 @@ func newTestSnapshotService(t *testing.T) (*SnapshotService, *SandboxService, *[ 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{ diff --git a/snapshot/paths.go b/snapshot/paths.go index 2fef025..fbe2d10 100644 --- a/snapshot/paths.go +++ b/snapshot/paths.go @@ -81,6 +81,18 @@ func (p Paths) StageCOW(id types.SnapshotID) (string, error) { 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) diff --git a/vmm/backend.go b/vmm/backend.go index 932e15a..987cf8c 100644 --- a/vmm/backend.go +++ b/vmm/backend.go @@ -54,6 +54,33 @@ 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. 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/vmm.go b/vmm/vmm.go index 6b15683..239d2b2 100644 --- a/vmm/vmm.go +++ b/vmm/vmm.go @@ -235,3 +235,14 @@ func (p SnapshotPlan) Validate() error { } 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 index f998fbf..539fb08 100644 --- a/vmm/vmm_test.go +++ b/vmm/vmm_test.go @@ -88,3 +88,19 @@ func TestProcessValidationRequiresCompleteIdentity(t *testing.T) { }) } } + +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") + } +} From 3259ee144624745fa4e812b1d608351810953880 Mon Sep 17 00:00:00 2001 From: YoungLH <974840768@qq.com> Date: Wed, 23 Sep 2026 11:10:35 +0800 Subject: [PATCH 45/45] docs: update README.md --- README.md | 286 +++++++++++++++++++++++-------- README.zh-CN.md | 246 ++++++++++++++++++++++++++ assets/readme/architecture.svg | 115 +++++++++++++ assets/readme/comparison.svg | 85 +++++++++ assets/readme/hero.svg | 90 ++++++++++ assets/readme/lifecycle.svg | 86 ++++++++++ assets/readme/product.svg | 78 +++++++++ assets/readme/src/build.py | 6 + assets/readme/src/gen_arch.py | 90 ++++++++++ assets/readme/src/gen_common.py | 122 +++++++++++++ assets/readme/src/gen_compare.py | 45 +++++ assets/readme/src/gen_hero.py | 42 +++++ assets/readme/src/gen_life.py | 50 ++++++ assets/readme/src/gen_product.py | 48 ++++++ 14 files changed, 1318 insertions(+), 71 deletions(-) create mode 100644 README.zh-CN.md create mode 100644 assets/readme/architecture.svg create mode 100644 assets/readme/comparison.svg create mode 100644 assets/readme/hero.svg create mode 100644 assets/readme/lifecycle.svg create mode 100644 assets/readme/product.svg create mode 100644 assets/readme/src/build.py create mode 100644 assets/readme/src/gen_arch.py create mode 100644 assets/readme/src/gen_common.py create mode 100644 assets/readme/src/gen_compare.py create mode 100644 assets/readme/src/gen_hero.py create mode 100644 assets/readme/src/gen_life.py create mode 100644 assets/readme/src/gen_product.py diff --git a/README.md b/README.md index 08b7420..28f5082 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,256 @@

KumaBox logo

+

English · 简体中文

+

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

-# KumaBox +

+ Quick start · + Architecture · + Comparison · + Roadmap +

-KumaBox is a daemonless microVM sandbox runtime for AI agents. The current implementation imports OCI and Docker images, creates persistent sandboxes, boots them with Cloud Hypervisor, and provides console and guest command access. +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. -KumaBox uses Cocoon commit `27ae1e0b2a65c9082c7a1b33c5245bfe43a4854d` as its behavior and capability baseline. Compatibility is evaluated feature by feature. KumaBox keeps its own package structure, error model, metadata schema, and `kumabox.*` guest boot protocol. +> [!WARNING] +> 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. -## Current status +## What KumaBox is -Available commands: +

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

-```text -kumabox doctor -kumabox image pull|import|list|inspect|verify|remove -kumabox create|start|stop|ps|inspect|logs|console|exec|rm -kumabox version -``` +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. -Image import and sandbox lifecycle are implemented locally. Real Cloud Hypervisor, cgroup, vsock, ext4, and EROFS behavior requires Linux acceptance testing. Networking, `run`, snapshots, clone, and Firecracker remain planned work. +Each sandbox is a real machine: -## Build and test +- **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`). -KumaBox requires Go 1.24 or newer. +## Architecture -```bash -make build -make verify -make lint -``` +

KumaBox architecture

-The binaries are written to `bin/`. `make verify` checks formatting, documentation links, Linux and Darwin vet, shell syntax, race-enabled tests, and the build. +**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. -Install locally with: +**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. -```bash -sudo make install -kumabox doctor -``` +**Switchable metadata.** JSON by default. SQLite when you need heavier +concurrency, with `metadata status`, `metadata verify` and verified backups. -The host checker reports Linux, KVM, cgroup v2, Cloud Hypervisor, `mkfs.erofs`, `mkfs.ext4`, and other runtime prerequisites. +| 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 | -## Image workflow +## Warm once, fork many -A bootable image must contain a kernel and initramfs and declare the OCI label `io.kumabox.boot.profile=overlay-v1`. +

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

-```bash -kumabox image pull ghcr.io/example/image:tag --platform linux/amd64 -kumabox image import demo ./docker-save.tar --format docker --platform linux/amd64 -kumabox image import demo ./oci-layout --format oci --platform linux/amd64 -kumabox image list -kumabox image inspect demo -kumabox image verify demo -``` +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`. -Local import auto-detects OCI layouts, OCI archives, and `docker save` archives. `docker export` filesystem archives are unsupported. Source layers are verified, converted to EROFS, and published by digest. Boot candidates follow layer overwrite, whiteout, and opaque-directory semantics. +## Quick start -## Sandbox workflow +You need Linux amd64 or arm64 with `/dev/kvm`, and root. ```bash -kumabox create demo --name box --cpus 2 --memory 1GiB --storage 10GiB -kumabox start box -kumabox exec box -- uname -a -kumabox logs --tail 50 box -kumabox logs -f box -kumabox console box -kumabox stop box -kumabox rm box +# 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 + +# 2. Prepare the host once: Cloud Hypervisor, firmware, CNI plugins, EROFS tools +sudo kumabox-check --upgrade +sudo kumabox doctor + +# 3. Build the published guest image +sudo kumabox image build ghcr.io/kgpp34/kumabox/ubuntu:24.04 --name ubuntu + +# 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 + +# 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 + +# 6. Clean up +sudo kumabox delete fresh my-vm --force +sudo kumabox snapshot rm base +sudo kumabox image rm ubuntu +sudo kumabox gc ``` -`create` prepares a sparse ext4 COW disk but does not start the VMM. `start` uses direct kernel boot, records a PID-reuse-safe process identity, and commits `running` only after the Cloud Hypervisor API reports readiness. `stop` requests shutdown, then uses an identity-checked TERM-to-KILL fallback. `exec` uses the guest agent over private hybrid-vsock transport. `logs` reads persistent backend output with tail and follow support, including after stop. +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. -`ps` prints a table with headers. `inspect` and every `--json` mode emit indented JSON. Progress goes to stderr; command results go to stdout. +### Drive it from an agent -## Configuration +`exec --json` prints `ok`, `exitCode`, and base64-encoded `stdout` and `stderr`. +The process exit code mirrors the guest command's exit code. -Configuration precedence is: +```python +import base64, json, subprocess -```text -explicit flag > environment > explicit --config file > default +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)")) ``` -KumaBox never searches for an implicit configuration file. Root paths can be set with `--root-dir`, `--run-dir`, and `--log-dir`. +Fan out parallel attempts from one warm snapshot: -## Architecture +```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 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 attach/detach/list`, `fs attach/detach/list`, `device attach/detach/list/state` | +| Operations | `doctor`, `metadata`, `usage`, `gc`, `debug launch` | + +`kumabox --help` is the authoritative reference. + +## How KumaBox compares + +

Design choices of KumaBox, CubeSandbox and E2B

+ +[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: + +- **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. + +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. + +## Roadmap + +> 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 -The repository uses root-level modules instead of `internal` or a generic `pkg` tree: +```bash +git clone https://github.com/kgpp34/KumaBox.git && cd KumaBox +make build +make test +go vet ./... +./bin/kumabox version --json +``` + +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 +``` -| Package | Responsibility | -|---|---| -| `cli` | Cobra command tree, argument validation, and presentation | -| `core` | Application services and concrete adapter assembly | -| `types` | Shared image and sandbox values; no capability interfaces | -| `images` | Source resolution, conversion, verification, and removal | -| `sandbox`, `disk` | Sandbox paths, locks, and writable COW disks | -| `vmm` | VMM contracts, launch plans, process identity, and backend registry | -| `vmm/cloudhypervisor` | Cloud Hypervisor process and API adapter | -| `agent` | Guest exec protocol and host/guest transports | -| `metadata` | Transaction contracts and SQLite implementation | -| `cgroup`, `storage`, `lock/flock` | Host resource adapters | +README graphics are generated from code. Edit the scripts in +`assets/readme/src/` and run `python3 assets/readme/src/build.py`. -Local design notes and Linux runbooks live under the ignored `docs/` directory. They are working material and are never committed. +## Security model -## Contributing +- 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. -See [CONTRIBUTING.md](CONTRIBUTING.md). +Report reproducible bugs and security concerns through the issue tracker. Do +not attach secrets, private images or production snapshots. ## License -[MIT](LICENSE) +KumaBox is available under the [MIT License](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/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")