From 4908e4abb2cb97a42281ddb2bb11d255037977bd Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 9 Sep 2026 20:31:50 +0300 Subject: [PATCH 1/3] feat: add microVM execution environments Co-Authored-By: mecatl --- .../microvm-execution-environments/README.md | 8 + .../tasks/01-module-contract.md | 26 + .../tasks/02-profile-paths.md | 37 + .../tasks/03-artifact-verification.md | 30 + .../tasks/04-control-protocol.md | 27 + .../tasks/05-worktree-git.md | 27 + .../tasks/06-virtiofs-assets.md | 27 + .../tasks/07-workspace-rpc.md | 26 + .../tasks/08-exec-rpc.md | 30 + .../tasks/09-network-egress.md | 29 + .../tasks/10-lifecycle-create.md | 27 + .../tasks/11-lifecycle-reconcile.md | 35 + .../tasks/12-multi-session.md | 34 + .../tasks/13-child-environments.md | 30 + .../tasks/14-child-merge-lifecycle.md | 27 + .../tasks/15-operations-docs.md | 31 + .../tasks/16-platform-e2e.md | 29 + .../tasks/17-concrete-runtime.md | 24 + .../tasks/18-guest-multiplex.md | 25 + .../tasks/19-daemon-lifecycle-rpc.md | 25 + .../20-ac-trace-caller-identity-repair.md | 18 + .../21-root-composition-lifecycle-repair.md | 27 + .../tasks/22-artifact-release-ci-repair.md | 27 + .../tasks/23-guest-trust-protocol-repair.md | 27 + .../tasks/24-worktree-git-repair.md | 27 + .../tasks/25-delegation-operations-repair.md | 27 + .../tasks/26-production-e2e-ci-repair.md | 27 + .../tasks/27-ui-profile-policy-repair.md | 22 + .../28-artifact-release-alignment-repair.md | 22 + .../tasks/29-streaming-cancellation-repair.md | 22 + .../30-restart-merge-serialization-repair.md | 22 + .../tasks/31-observability-doctor-repair.md | 22 + .../32-complete-production-e2e-repair.md | 22 + ...-artifact-immutability-release-evidence.md | 22 + .../34-doctor-egress-metrics-correctness.md | 22 + .../35-end-to-end-client-crash-quota-proof.md | 22 + .../tasks/36-final-evidence-metrics-proof.md | 22 + .../tasks/37-live-metric-delta-proof.md | 20 + .../tasks/38-ci-kvm-permission-repair.md | 18 + .../39-ci-platform-reliability-repair.md | 22 + .../tasks/40-ci-kvm-darwin-runner-repair.md | 22 + .../tasks/41-ci-kvm-refresh-race-timeout.md | 21 + .../tasks/42-ci-daemon-kvm-identity-repair.md | 18 + .../43-ci-multi-identity-kvm-peer-proof.md | 20 + .../tasks/44-actionlint-kvm-shell-repair.md | 18 + .../tasks/45-oci-default-guest-image.md | 20 + .../tasks/46-user-bootstrap-daemon-manager.md | 20 + .../tasks/47-mecatui-microvm-first-run.md | 18 + .../tasks/48-microvm-lifecycle-cli-ux.md | 18 + .../tasks/49-first-run-empty-xdg-e2e.md | 18 + .../tasks/50-default-oci-release-contract.md | 18 + .../tasks/51-operator-first-run-docs.md | 18 + .../tasks/52-public-docs-adr-polish.md | 18 + .../53-daemon-identity-managed-restart.md | 18 + .../54-self-contained-release-bootstrap.md | 18 + .../tasks/55-brood-base-lineage-provenance.md | 18 + .../tasks/56-paginated-lifecycle-inventory.md | 18 + .../tasks/57-first-run-dead-end-repair.md | 18 + .../tasks/58-final-first-run-usability-e2e.md | 18 + .../tasks/59-manager-ensure-ready-surface.md | 32 + .../60-brood-admission-inprocess-verifier.md | 28 + .../61-repository-rootfs-guest-contract.md | 29 + .../tasks/62-repository-vm-lifecycle.md | 29 + .../tasks/63-logical-worktree-protocol.md | 35 + .../tasks/64-session-attachment-delegation.md | 34 + .../tasks/65-linux-amd64-mvp-journey.md | 35 + .github/actionlint.yaml | 6 + .github/scripts/install-microvm-release.sh | 151 +++ .github/scripts/lint-vet-workflow_test.sh | 1 + .github/scripts/microvm-ci-release_test.sh | 545 +++++++++ .github/scripts/package-microvm-release.sh | 166 +++ .../prepare-microvm-development-release.sh | 55 + .github/scripts/race-vet-off-workflow_test.sh | 2 +- .../scripts/reuse-platform-release-assets.sh | 221 ++++ .../scripts/sign-microvm-release-evidence.sh | 30 + .github/scripts/upload-release-assets.sh | 40 + .github/scripts/validate-release-ref.sh | 26 + .github/scripts/verify-brood-resolution.sh | 32 + .github/workflows/README.md | 1 + .github/workflows/ci.yml | 49 + .github/workflows/microvm-e2e.yml | 164 +++ .github/workflows/release.yml | 438 ++++++- .ko.yaml | 4 + README.md | 39 + Taskfile.yml | 163 ++- cmd/mecated/command.go | 14 +- cmd/mecated/helpmeta.go | 11 +- cmd/mecated/main.go | 164 ++- cmd/mecated/microvm_command.go | 36 + cmd/mecated/microvm_command_test.go | 296 +++++ cmd/mecated/microvm_development_disabled.go | 15 + .../microvm_development_disabled_test.go | 11 + .../microvm_development_microvm_dev.go | 34 + .../microvm_development_microvm_dev_test.go | 24 + cmd/mecated/microvm_egress_test.go | 71 ++ cmd/mecatui/command.go | 22 +- cmd/mecatui/config.go | 114 +- cmd/mecatui/helpmeta.go | 19 +- cmd/mecatui/main.go | 39 +- cmd/mecatui/microvm_command.go | 147 +++ cmd/mecatui/microvm_command_test.go | 209 ++++ cmd/mecatui/microvm_development_disabled.go | 15 + .../microvm_development_disabled_test.go | 11 + .../microvm_development_microvm_dev.go | 34 + .../microvm_development_microvm_dev_test.go | 27 + cmd/mecatui/microvm_egress_test.go | 49 + cmd/mecatui/sessionadapter/sessionadapter.go | 98 ++ cmd/mecatui/version.go | 22 + docs/READING.md | 2 + docs/acceptance/README.md | 4 + .../microvm-execution-environments.md | 171 +++ docs/adr/0027-cloud-native.md | 41 + .../0326-microvm-execution-environments.md | 215 ++++ docs/adr/README.md | 1 + docs/architecture.md | 30 + docs/architecture/microvm-environments.md | 172 +++ docs/architecture/observability.md | 11 + docs/architecture/ports.md | 24 + docs/design/IMPLEMENTATION-NOTES.md | 403 +++++++ docs/usage.md | 1 + docs/usage/configuration.md | 14 + docs/usage/grpc-api.md | 9 + docs/usage/microvm-environments.md | 201 ++++ engine/adapter/fsconformance/fsconformance.go | 99 ++ engine/agent/subagent_writable_test.go | 56 + environment/microvm/admission.go | 357 ++++++ environment/microvm/admission_test.go | 117 ++ environment/microvm/artifact.go | 834 +++++++++++++ environment/microvm/artifact_sigstore.go | 67 ++ environment/microvm/artifact_test.go | 563 +++++++++ environment/microvm/brood_admission_test.go | 188 +++ .../cmd/mecatl-artifact-digest/main.go | 22 + .../microvm/cmd/mecatl-guest-agent/main.go | 258 +++++ .../cmd/mecatl-guest-agent/main_test.go | 265 +++++ .../cmd/mecatl-guest-agent/mounts_linux.go | 76 ++ .../mecatl-guest-agent/mounts_linux_test.go | 31 + .../cmd/mecatl-guest-agent/mounts_other.go | 21 + .../microvm/cmd/mecatl-microvmd/main.go | 691 +++++++++++ .../microvm/cmd/mecatl-microvmd/main_test.go | 317 +++++ .../mecatl-microvmd/profile_policy_test.go | 38 + .../cmd/mecatl-microvmd/socket_test.go | 52 + .../cmd/mecatl-microvmd/userns_linux.go | 34 + .../cmd/mecatl-microvmd/userns_other.go | 7 + .../cmd/mecatl-oci-tree-digest/main.go | 41 + environment/microvm/composition.go | 171 +++ environment/microvm/control/binding.go | 43 + environment/microvm/control/capability.go | 142 +++ .../microvm/control/controltest/peer.go | 15 + environment/microvm/control/multiplex.go | 499 ++++++++ environment/microvm/control/peer_darwin.go | 36 + environment/microvm/control/peer_linux.go | 36 + .../microvm/control/peer_unsupported.go | 14 + environment/microvm/control/protocol.go | 175 +++ environment/microvm/control/protocol_test.go | 197 ++++ environment/microvm/control/service.go | 69 ++ environment/microvm/control/service_test.go | 92 ++ environment/microvm/control/transport.go | 21 + environment/microvm/daemon.go | 907 +++++++++++++++ environment/microvm/daemon_proxy.go | 209 ++++ environment/microvm/daemon_test.go | 442 +++++++ environment/microvm/e2e/prepare.sh | 198 ++++ environment/microvm/forker.go | 258 +++++ environment/microvm/forker_test.go | 749 ++++++++++++ environment/microvm/gitexec/gitexec.go | 73 ++ environment/microvm/gitexec/gitexec_test.go | 77 ++ environment/microvm/go.mod | 122 ++ environment/microvm/go.sum | 480 ++++++++ environment/microvm/guestagent/guestagent.go | 109 ++ .../microvm/guestagent/protocol_test.go | 125 ++ environment/microvm/guestagent/repository.go | 231 ++++ .../guestagent/repository_capacity_test.go | 140 +++ .../microvm/guestagent/repository_channel.go | 127 ++ .../guestagent/repository_close_test.go | 50 + .../microvm/guestagent/repository_control.go | 162 +++ .../guestagent/repository_error_test.go | 137 +++ environment/microvm/guestagent/vsock_linux.go | 39 + environment/microvm/guestagent/vsock_other.go | 14 + environment/microvm/guestexec/exec.go | 416 +++++++ environment/microvm/guestexec/exec_test.go | 438 +++++++ .../microvm/guestexec/process_linux.go | 32 + .../microvm/guestexec/process_other.go | 14 + environment/microvm/guestexec/process_unix.go | 31 + .../microvm/guestexec/security_linux_test.go | 51 + environment/microvm/guestexec/server.go | 182 +++ environment/microvm/lifecycle.go | 666 +++++++++++ .../microvm/lifecycle_inventory_test.go | 275 +++++ .../microvm/lifecycle_reconcile_test.go | 390 +++++++ environment/microvm/lifecycle_test.go | 223 ++++ environment/microvm/microvm.go | 2 + environment/microvm/multi_session_test.go | 263 +++++ environment/microvm/names.go | 92 ++ environment/microvm/network.go | 286 +++++ environment/microvm/network_test.go | 249 ++++ environment/microvm/oci_execution_image.go | 113 ++ .../microvm/oci_execution_image_test.go | 255 ++++ environment/microvm/operational.go | 378 ++++++ environment/microvm/operational_test.go | 103 ++ environment/microvm/operations.go | 676 +++++++++++ environment/microvm/operations_test.go | 267 +++++ environment/microvm/platform_e2e_test.go | 439 +++++++ .../microvm/process_identity_darwin.go | 25 + environment/microvm/process_identity_linux.go | 34 + environment/microvm/reconcile.go | 398 +++++++ environment/microvm/registry.go | 233 ++++ environment/microvm/repository_attachment.go | 331 ++++++ .../microvm/repository_attachment_store.go | 239 ++++ .../microvm/repository_attachment_test.go | 265 +++++ environment/microvm/repository_authority.go | 119 ++ environment/microvm/repository_composition.go | 61 + .../microvm/repository_composition_test.go | 861 ++++++++++++++ environment/microvm/repository_daemon.go | 243 ++++ environment/microvm/repository_lifecycle.go | 753 ++++++++++++ .../microvm/repository_lifecycle_test.go | 525 +++++++++ environment/microvm/repository_logical.go | 317 +++++ .../microvm/repository_logical_test.go | 475 ++++++++ .../microvm/repository_object_snapshot.go | 214 ++++ .../repository_object_snapshot_test.go | 86 ++ environment/microvm/repository_rootfs.go | 117 ++ environment/microvm/repository_rootfs_test.go | 105 ++ environment/microvm/repository_runtime.go | 498 ++++++++ environment/microvm/resolver.go | 85 ++ environment/microvm/resolver_test.go | 53 + environment/microvm/runtime.go | 877 ++++++++++++++ environment/microvm/runtime_test.go | 478 ++++++++ .../microvm/testdata/sigstore/bundle.json | 1 + .../microvm/testdata/sigstore/public.pem | 4 + .../microvm/testdata/sigstore/statement.json | 1 + environment/microvm/tools.go | 5 + environment/microvm/virtiofs/virtiofs.go | 274 +++++ environment/microvm/virtiofs/virtiofs_test.go | 220 ++++ environment/microvm/workspace/guest.go | 320 +++++ environment/microvm/workspace/protocol.go | 72 ++ environment/microvm/workspace/workspace.go | 169 +++ .../microvm/workspace/workspace_test.go | 112 ++ environment/microvm/worktree/worktree.go | 1032 +++++++++++++++++ environment/microvm/worktree/worktree_test.go | 424 +++++++ go.mod | 7 +- go.sum | 20 +- go.work | 2 + go.work.sum | 353 ++++-- internal/adapter/forker/router.go | 87 ++ internal/adapter/forker/router_test.go | 81 ++ internal/adapter/microvm/client.go | 851 ++++++++++++++ internal/adapter/microvm/client_error_test.go | 57 + .../adapter/microvm/client_profile_test.go | 66 ++ .../adapter/microvm/client_stream_test.go | 193 +++ .../microvm/lifecycle_inventory_test.go | 83 ++ .../adapter/microvm/production_e2e_test.go | 345 ++++++ internal/adapter/microvm/socket_test.go | 53 + internal/adapter/microvmmanager/bootstrap.go | 109 ++ .../microvmmanager/default_operations.go | 712 ++++++++++++ .../microvmmanager/default_operations_test.go | 276 +++++ .../development_release_microvm_dev.go | 168 +++ .../development_release_microvm_dev_test.go | 348 ++++++ internal/adapter/microvmmanager/egress.go | 244 ++++ .../adapter/microvmmanager/egress_test.go | 103 ++ .../microvmmanager/ensure_ready_test.go | 155 +++ internal/adapter/microvmmanager/manager.go | 921 +++++++++++++++ .../adapter/microvmmanager/manager_test.go | 466 ++++++++ .../adapter/microvmmanager/process_linux.go | 36 + .../adapter/microvmmanager/process_other.go | 27 + .../adapter/microvmmanager/userns_linux.go | 56 + .../microvmmanager/userns_linux_test.go | 47 + .../adapter/microvmmanager/userns_other.go | 7 + internal/adapter/server/errorcodes.go | 1 + internal/adapter/server/errors.go | 4 + internal/adapter/server/export_test.go | 6 +- .../adapter/server/microvm_egress_api_test.go | 18 + .../adapter/server/oidc_fixture_test.go | 2 +- .../server/sdk_typescript_scenario11_test.go | 2 +- internal/adapter/server/service.go | 8 +- .../apicheck/microvm_module_contract_test.go | 125 ++ internal/app/build.go | 33 +- internal/app/catalog.go | 4 +- internal/app/driverstore.go | 32 +- internal/app/driverstore_test.go | 12 + internal/app/microvm_lifecycle_e2e_test.go | 160 +++ internal/app/usermodelreview_test.go | 1 + internal/microvmcmd/command.go | 392 +++++++ internal/microvmcmd/command_test.go | 373 ++++++ sdk/typescript/etc/mecatl-sdk-node.api.md | 2 +- sdk/typescript/etc/mecatl-sdk.api.md | 2 +- sdk/typescript/src/errors.ts | 1 + user-docs/building/deployment/index.md | 2 + .../deployment/microvm-environments.md | 99 ++ user-docs/building/what-you-get/core-tools.md | 14 + 286 files changed, 39610 insertions(+), 221 deletions(-) create mode 100644 .claude/plans/microvm-execution-environments/README.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/01-module-contract.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/02-profile-paths.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/03-artifact-verification.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/04-control-protocol.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/05-worktree-git.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/06-virtiofs-assets.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/07-workspace-rpc.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/08-exec-rpc.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/09-network-egress.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/10-lifecycle-create.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/11-lifecycle-reconcile.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/12-multi-session.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/13-child-environments.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/14-child-merge-lifecycle.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/15-operations-docs.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/16-platform-e2e.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/17-concrete-runtime.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/18-guest-multiplex.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/19-daemon-lifecycle-rpc.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/20-ac-trace-caller-identity-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/21-root-composition-lifecycle-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/22-artifact-release-ci-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/23-guest-trust-protocol-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/24-worktree-git-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/25-delegation-operations-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/26-production-e2e-ci-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/27-ui-profile-policy-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/28-artifact-release-alignment-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/29-streaming-cancellation-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/30-restart-merge-serialization-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/31-observability-doctor-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/32-complete-production-e2e-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/33-artifact-immutability-release-evidence.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/34-doctor-egress-metrics-correctness.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/35-end-to-end-client-crash-quota-proof.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/36-final-evidence-metrics-proof.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/37-live-metric-delta-proof.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/38-ci-kvm-permission-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/39-ci-platform-reliability-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/40-ci-kvm-darwin-runner-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/41-ci-kvm-refresh-race-timeout.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/42-ci-daemon-kvm-identity-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/43-ci-multi-identity-kvm-peer-proof.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/44-actionlint-kvm-shell-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/45-oci-default-guest-image.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/46-user-bootstrap-daemon-manager.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/47-mecatui-microvm-first-run.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/48-microvm-lifecycle-cli-ux.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/49-first-run-empty-xdg-e2e.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/50-default-oci-release-contract.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/51-operator-first-run-docs.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/52-public-docs-adr-polish.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/53-daemon-identity-managed-restart.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/54-self-contained-release-bootstrap.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/55-brood-base-lineage-provenance.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/56-paginated-lifecycle-inventory.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/57-first-run-dead-end-repair.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/58-final-first-run-usability-e2e.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/59-manager-ensure-ready-surface.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/60-brood-admission-inprocess-verifier.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/61-repository-rootfs-guest-contract.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/62-repository-vm-lifecycle.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/63-logical-worktree-protocol.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/64-session-attachment-delegation.md create mode 100644 .claude/plans/microvm-execution-environments/tasks/65-linux-amd64-mvp-journey.md create mode 100644 .github/actionlint.yaml create mode 100755 .github/scripts/install-microvm-release.sh create mode 100644 .github/scripts/microvm-ci-release_test.sh create mode 100755 .github/scripts/package-microvm-release.sh create mode 100755 .github/scripts/prepare-microvm-development-release.sh create mode 100755 .github/scripts/reuse-platform-release-assets.sh create mode 100755 .github/scripts/sign-microvm-release-evidence.sh create mode 100755 .github/scripts/upload-release-assets.sh create mode 100755 .github/scripts/validate-release-ref.sh create mode 100755 .github/scripts/verify-brood-resolution.sh create mode 100644 .github/workflows/microvm-e2e.yml create mode 100644 cmd/mecated/microvm_command.go create mode 100644 cmd/mecated/microvm_command_test.go create mode 100644 cmd/mecated/microvm_development_disabled.go create mode 100644 cmd/mecated/microvm_development_disabled_test.go create mode 100644 cmd/mecated/microvm_development_microvm_dev.go create mode 100644 cmd/mecated/microvm_development_microvm_dev_test.go create mode 100644 cmd/mecated/microvm_egress_test.go create mode 100644 cmd/mecatui/microvm_command.go create mode 100644 cmd/mecatui/microvm_command_test.go create mode 100644 cmd/mecatui/microvm_development_disabled.go create mode 100644 cmd/mecatui/microvm_development_disabled_test.go create mode 100644 cmd/mecatui/microvm_development_microvm_dev.go create mode 100644 cmd/mecatui/microvm_development_microvm_dev_test.go create mode 100644 cmd/mecatui/microvm_egress_test.go create mode 100644 cmd/mecatui/sessionadapter/sessionadapter.go create mode 100644 cmd/mecatui/version.go create mode 100644 docs/acceptance/microvm-execution-environments.md create mode 100644 docs/adr/0326-microvm-execution-environments.md create mode 100644 docs/architecture/microvm-environments.md create mode 100644 docs/usage/microvm-environments.md create mode 100644 environment/microvm/admission.go create mode 100644 environment/microvm/admission_test.go create mode 100644 environment/microvm/artifact.go create mode 100644 environment/microvm/artifact_sigstore.go create mode 100644 environment/microvm/artifact_test.go create mode 100644 environment/microvm/brood_admission_test.go create mode 100644 environment/microvm/cmd/mecatl-artifact-digest/main.go create mode 100644 environment/microvm/cmd/mecatl-guest-agent/main.go create mode 100644 environment/microvm/cmd/mecatl-guest-agent/main_test.go create mode 100644 environment/microvm/cmd/mecatl-guest-agent/mounts_linux.go create mode 100644 environment/microvm/cmd/mecatl-guest-agent/mounts_linux_test.go create mode 100644 environment/microvm/cmd/mecatl-guest-agent/mounts_other.go create mode 100644 environment/microvm/cmd/mecatl-microvmd/main.go create mode 100644 environment/microvm/cmd/mecatl-microvmd/main_test.go create mode 100644 environment/microvm/cmd/mecatl-microvmd/profile_policy_test.go create mode 100644 environment/microvm/cmd/mecatl-microvmd/socket_test.go create mode 100644 environment/microvm/cmd/mecatl-microvmd/userns_linux.go create mode 100644 environment/microvm/cmd/mecatl-microvmd/userns_other.go create mode 100644 environment/microvm/cmd/mecatl-oci-tree-digest/main.go create mode 100644 environment/microvm/composition.go create mode 100644 environment/microvm/control/binding.go create mode 100644 environment/microvm/control/capability.go create mode 100644 environment/microvm/control/controltest/peer.go create mode 100644 environment/microvm/control/multiplex.go create mode 100644 environment/microvm/control/peer_darwin.go create mode 100644 environment/microvm/control/peer_linux.go create mode 100644 environment/microvm/control/peer_unsupported.go create mode 100644 environment/microvm/control/protocol.go create mode 100644 environment/microvm/control/protocol_test.go create mode 100644 environment/microvm/control/service.go create mode 100644 environment/microvm/control/service_test.go create mode 100644 environment/microvm/control/transport.go create mode 100644 environment/microvm/daemon.go create mode 100644 environment/microvm/daemon_proxy.go create mode 100644 environment/microvm/daemon_test.go create mode 100755 environment/microvm/e2e/prepare.sh create mode 100644 environment/microvm/forker.go create mode 100644 environment/microvm/forker_test.go create mode 100644 environment/microvm/gitexec/gitexec.go create mode 100644 environment/microvm/gitexec/gitexec_test.go create mode 100644 environment/microvm/go.mod create mode 100644 environment/microvm/go.sum create mode 100644 environment/microvm/guestagent/guestagent.go create mode 100644 environment/microvm/guestagent/protocol_test.go create mode 100644 environment/microvm/guestagent/repository.go create mode 100644 environment/microvm/guestagent/repository_capacity_test.go create mode 100644 environment/microvm/guestagent/repository_channel.go create mode 100644 environment/microvm/guestagent/repository_close_test.go create mode 100644 environment/microvm/guestagent/repository_control.go create mode 100644 environment/microvm/guestagent/repository_error_test.go create mode 100644 environment/microvm/guestagent/vsock_linux.go create mode 100644 environment/microvm/guestagent/vsock_other.go create mode 100644 environment/microvm/guestexec/exec.go create mode 100644 environment/microvm/guestexec/exec_test.go create mode 100644 environment/microvm/guestexec/process_linux.go create mode 100644 environment/microvm/guestexec/process_other.go create mode 100644 environment/microvm/guestexec/process_unix.go create mode 100644 environment/microvm/guestexec/security_linux_test.go create mode 100644 environment/microvm/guestexec/server.go create mode 100644 environment/microvm/lifecycle.go create mode 100644 environment/microvm/lifecycle_inventory_test.go create mode 100644 environment/microvm/lifecycle_reconcile_test.go create mode 100644 environment/microvm/lifecycle_test.go create mode 100644 environment/microvm/microvm.go create mode 100644 environment/microvm/multi_session_test.go create mode 100644 environment/microvm/names.go create mode 100644 environment/microvm/network.go create mode 100644 environment/microvm/network_test.go create mode 100644 environment/microvm/oci_execution_image.go create mode 100644 environment/microvm/oci_execution_image_test.go create mode 100644 environment/microvm/operational.go create mode 100644 environment/microvm/operational_test.go create mode 100644 environment/microvm/operations.go create mode 100644 environment/microvm/operations_test.go create mode 100644 environment/microvm/platform_e2e_test.go create mode 100644 environment/microvm/process_identity_darwin.go create mode 100644 environment/microvm/process_identity_linux.go create mode 100644 environment/microvm/reconcile.go create mode 100644 environment/microvm/registry.go create mode 100644 environment/microvm/repository_attachment.go create mode 100644 environment/microvm/repository_attachment_store.go create mode 100644 environment/microvm/repository_attachment_test.go create mode 100644 environment/microvm/repository_authority.go create mode 100644 environment/microvm/repository_composition.go create mode 100644 environment/microvm/repository_composition_test.go create mode 100644 environment/microvm/repository_daemon.go create mode 100644 environment/microvm/repository_lifecycle.go create mode 100644 environment/microvm/repository_lifecycle_test.go create mode 100644 environment/microvm/repository_logical.go create mode 100644 environment/microvm/repository_logical_test.go create mode 100644 environment/microvm/repository_object_snapshot.go create mode 100644 environment/microvm/repository_object_snapshot_test.go create mode 100644 environment/microvm/repository_rootfs.go create mode 100644 environment/microvm/repository_rootfs_test.go create mode 100644 environment/microvm/repository_runtime.go create mode 100644 environment/microvm/resolver.go create mode 100644 environment/microvm/resolver_test.go create mode 100644 environment/microvm/runtime.go create mode 100644 environment/microvm/runtime_test.go create mode 100644 environment/microvm/testdata/sigstore/bundle.json create mode 100644 environment/microvm/testdata/sigstore/public.pem create mode 100644 environment/microvm/testdata/sigstore/statement.json create mode 100644 environment/microvm/tools.go create mode 100644 environment/microvm/virtiofs/virtiofs.go create mode 100644 environment/microvm/virtiofs/virtiofs_test.go create mode 100644 environment/microvm/workspace/guest.go create mode 100644 environment/microvm/workspace/protocol.go create mode 100644 environment/microvm/workspace/workspace.go create mode 100644 environment/microvm/workspace/workspace_test.go create mode 100644 environment/microvm/worktree/worktree.go create mode 100644 environment/microvm/worktree/worktree_test.go create mode 100644 internal/adapter/forker/router.go create mode 100644 internal/adapter/forker/router_test.go create mode 100644 internal/adapter/microvm/client.go create mode 100644 internal/adapter/microvm/client_error_test.go create mode 100644 internal/adapter/microvm/client_profile_test.go create mode 100644 internal/adapter/microvm/client_stream_test.go create mode 100644 internal/adapter/microvm/lifecycle_inventory_test.go create mode 100644 internal/adapter/microvm/production_e2e_test.go create mode 100644 internal/adapter/microvm/socket_test.go create mode 100644 internal/adapter/microvmmanager/bootstrap.go create mode 100644 internal/adapter/microvmmanager/default_operations.go create mode 100644 internal/adapter/microvmmanager/default_operations_test.go create mode 100644 internal/adapter/microvmmanager/development_release_microvm_dev.go create mode 100644 internal/adapter/microvmmanager/development_release_microvm_dev_test.go create mode 100644 internal/adapter/microvmmanager/egress.go create mode 100644 internal/adapter/microvmmanager/egress_test.go create mode 100644 internal/adapter/microvmmanager/ensure_ready_test.go create mode 100644 internal/adapter/microvmmanager/manager.go create mode 100644 internal/adapter/microvmmanager/manager_test.go create mode 100644 internal/adapter/microvmmanager/process_linux.go create mode 100644 internal/adapter/microvmmanager/process_other.go create mode 100644 internal/adapter/microvmmanager/userns_linux.go create mode 100644 internal/adapter/microvmmanager/userns_linux_test.go create mode 100644 internal/adapter/microvmmanager/userns_other.go create mode 100644 internal/adapter/server/microvm_egress_api_test.go rename authn/oidc/fixture_test.go => internal/adapter/server/oidc_fixture_test.go (98%) create mode 100644 internal/apicheck/microvm_module_contract_test.go create mode 100644 internal/app/microvm_lifecycle_e2e_test.go create mode 100644 internal/microvmcmd/command.go create mode 100644 internal/microvmcmd/command_test.go create mode 100644 user-docs/building/deployment/microvm-environments.md diff --git a/.claude/plans/microvm-execution-environments/README.md b/.claude/plans/microvm-execution-environments/README.md new file mode 100644 index 000000000..3227d78c1 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/README.md @@ -0,0 +1,8 @@ +# MicroVM execution environments orchestration + +Acceptance plan: `docs/acceptance/microvm-execution-environments.md` +Accumulator: `acc/microvm-execution-environments` +Epic: stacklok/mecatl#526 + +Task files are orchestrator-managed. Git ancestry on the accumulator is authoritative. +Every implementation worker uses an isolated worktree and task branch. diff --git a/.claude/plans/microvm-execution-environments/tasks/01-module-contract.md b/.claude/plans/microvm-execution-environments/tasks/01-module-contract.md new file mode 100644 index 000000000..2f64f981e --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/01-module-contract.md @@ -0,0 +1,26 @@ +--- +id: 01-module-contract +title: Nested module boundary and architecture contract +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/01-module-contract" +worktree: ".scratch/task-microvm-01" +issue: "527" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Establish the nested `environment/microvm` module boundary, keep go-microvm/libkrun out of engine/root default builds, and pin the byte-compatible disabled path. Reconcile the proposed ADR and living architecture/resource inventories where this task owns their structural contract. Do not implement runtime behavior owned by later tasks. + +## Acceptance criteria + +- AC1.5: With microVM support disabled or no microVM profile selected, existing + default/no-fs session snapshots, catalogs, prompts, and behavior remain byte-compatible. + - verify: `TestInvariant_microvm_disabled_is_byte_compatible` +- AC1.6: The normal engine module and root default build have no go-microvm/libkrun + import or link dependency; only the nested `environment/microvm` module owns the + heavy runtime. + - verify: `TestADR_0108_MicroVMDependenciesStayOutOfEngineAndRoot` diff --git a/.claude/plans/microvm-execution-environments/tasks/02-profile-paths.md b/.claude/plans/microvm-execution-environments/tasks/02-profile-paths.md new file mode 100644 index 000000000..6ca773b4f --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/02-profile-paths.md @@ -0,0 +1,37 @@ +--- +id: 02-profile-paths +title: Environment profiles and source/worktree/guest path roles +blocked_by: [01-module-contract] +status: done +branch: "plan-microvm-execution-environments/02-profile-paths" +worktree: ".scratch/task-microvm-02" +issue: "533" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Add the operator-only environment-profile surface and the explicit source checkout, prepared host worktree, guest root, and EnvironmentRef projections. Wire host-side discovery to the correct tier without enabling VM provisioning yet. Project configuration must not control privileged environment policy. + +## Acceptance criteria + +- AC1.1: Selecting an enabled operator microVM profile creates a session whose source + checkout, prepared host worktree, guest `/workspace`, and EnvironmentRef are four + explicit values with non-overlapping meanings; the response/UI displays the first + three accurately without exposing driver credentials or control endpoints. + - verify: `TestMicroVMEnvironments_Scenario1_PathRolesAreExplicit` +- AC1.2: `environment_profile` selects execution placement independently from the + existing `profile`; default and `no-fs` retain their existing tool-surface meaning. + - verify: `TestADR_0108_EnvironmentProfileIsIndependentOfToolProfile` +- AC1.3: Project-tier configuration and model input cannot set or weaken the driver + endpoint, image, mounts, resources, egress, seccomp, lifecycle, quotas, signer, or + attestation policy; an unknown, disabled, or unavailable alias fails loudly without + local fallback. + - verify: `TestMicroVMEnvironments_Scenario1_OperatorProfilesAreAuthoritative` +- AC1.4: Project AGENTS/CLAUDE instructions, rules, commands, skills, and agent + definitions are resolved from the prepared session worktree, while user-global + settings, soul, memory, skills/agents, MCP configuration, identity, and provider + credentials remain host-side. + - verify: `TestMicroVMEnvironments_Scenario1_DiscoveryUsesCorrectPathTier` diff --git a/.claude/plans/microvm-execution-environments/tasks/03-artifact-verification.md b/.claude/plans/microvm-execution-environments/tasks/03-artifact-verification.md new file mode 100644 index 000000000..7a5836438 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/03-artifact-verification.md @@ -0,0 +1,30 @@ +--- +id: 03-artifact-verification +title: Runtime, firmware, and execution-image verification +blocked_by: [01-module-contract] +status: done +branch: "plan-microvm-execution-environments/03-artifact-verification" +worktree: ".scratch/task-microvm-03" +issue: "528" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Consume go-microvm's existing runtime/firmware artifacts, add immutable digest resolution, operator trust policy, signature/attestation verification, and atomic cache admission in the nested module. Keep verification offline-testable with fakes; do not require live registries in ordinary tests. + +## Acceptance criteria + +- AC2.1: A correctly signed and attested runtime, firmware, and execution image resolve + to immutable digests, enter the verified cache atomically, and boot a VM whose durable + metadata records those digests and the verification-policy revision. + - verify: `TestMicroVMEnvironments_Scenario2_VerifiedArtifactsBoot` +- AC2.2: A mutable/tag-only input, wrong digest, unsigned artifact, wrong signer, + missing/wrong attestation, revoked identity, corrupted cache entry, or stale policy + fails before VM execution. + - verify: `TestMicroVMEnvironments_Scenario2_UnverifiedArtifactsFailClosed` +- AC2.3: Concurrent sessions requesting the same cold artifact observe one complete + verified cache result; none can execute a partial or pre-verification file. + - verify: `TestMicroVMEnvironments_Scenario2_ConcurrentCacheAdmissionIsAtomic` diff --git a/.claude/plans/microvm-execution-environments/tasks/04-control-protocol.md b/.claude/plans/microvm-execution-environments/tasks/04-control-protocol.md new file mode 100644 index 000000000..d70c2b7de --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/04-control-protocol.md @@ -0,0 +1,27 @@ +--- +id: 04-control-protocol +title: Local control socket and guest protocol handshake +blocked_by: [01-module-contract] +status: done +branch: "plan-microvm-execution-environments/04-control-protocol" +worktree: ".scratch/task-microvm-04" +issue: "530" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Define the local microvmd control service, Linux/macOS peer authentication, environment owner/ref/generation binding, and guest protocol handshake over go-microvm's vsock-to-UDS primitive. This task establishes framing/capabilities and test fakes, not full Workspace or exec methods. + +## Acceptance criteria + +- AC2.4: The local control socket authenticates the configured Unix account using the + platform peer-credential mechanism; possession or guessing of an environment ID, + socket path, or session ID alone authorizes no operation. + - verify: `TestMicroVMEnvironments_Scenario2_LocalPeerCredentialsBindOwner` +- AC2.5: Protocol negotiation rejects a missing required filesystem, streaming, + cancellation, generation-binding, or message-bound capability; it never degrades to + SSH or host-local tool execution. + - verify: `TestMicroVMEnvironments_Scenario2_CapabilityNegotiationFailsClosed` diff --git a/.claude/plans/microvm-execution-environments/tasks/05-worktree-git.md b/.claude/plans/microvm-execution-environments/tasks/05-worktree-git.md new file mode 100644 index 000000000..26ef05169 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/05-worktree-git.md @@ -0,0 +1,27 @@ +--- +id: 05-worktree-git +title: Exact session worktree capture and guest Git reconstruction +blocked_by: [01-module-contract] +status: done +branch: "plan-microvm-execution-environments/05-worktree-git" +worktree: ".scratch/task-microvm-05" +issue: "529" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Prepare one hardened session branch/worktree and exact source-state capture. Reuse or extract Brood Box's linked-worktree validation and guest-local Git metadata reconstruction, including the common object-store alternates design. This task prepares/mount-describes paths but leaves generic Workspace RPC to task 07. + +## Acceptance criteria + +- AC3.1: The prepared worktree contains the source's committed files and the full + declared staged/unstaged/untracked content fidelity; a race or unsupported state + that prevents exact capture aborts creation instead of silently starting clean. + - verify: `TestMicroVMEnvironments_Scenario3_SourceStateCaptureIsExactOrFails` +- AC3.3: Guest Git operates with reconstructed local metadata and the common object + store mounted host-enforced read-only; malicious `.git`/`commondir`, symlink, or + escape inputs cannot expose another host path. + - verify: `TestMicroVMEnvironments_Scenario3_WorktreeGitMetadataIsConfined` diff --git a/.claude/plans/microvm-execution-environments/tasks/06-virtiofs-assets.md b/.claude/plans/microvm-execution-environments/tasks/06-virtiofs-assets.md new file mode 100644 index 000000000..24ba14bc8 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/06-virtiofs-assets.md @@ -0,0 +1,27 @@ +--- +id: 06-virtiofs-assets +title: RW worktree and read-only asset virtio-fs mounts +blocked_by: [03-artifact-verification, 05-worktree-git] +status: done +branch: "plan-microvm-execution-environments/06-virtiofs-assets" +worktree: ".scratch/task-microvm-06" +issue: "529" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Wire the prepared worktree RW at guest `/workspace`, adopt libkrun's host-enforced read-only virtio-fs API through go-microvm for Git objects and materialized skill assets, and prove bidirectional host/guest visibility. Keep mount inputs explicit and confined. + +## Acceptance criteria + +- AC3.2: A guest Write/Edit or Bash mutation under `/workspace` is immediately visible + in the prepared host worktree, and a host edit to that worktree is visible to the + guest. + - verify: `TestMicroVMEnvironments_Scenario3_WorktreeIsBidirectionallyVisible` +- AC3.6: An out-of-repository skill asset reaches the guest only through explicit + materialization or a host-enforced read-only mount; Bash cannot discover an arbitrary + user home/config directory through that capability. + - verify: `TestMicroVMEnvironments_Scenario3_SkillAssetsAreExplicitAndReadOnly` diff --git a/.claude/plans/microvm-execution-environments/tasks/07-workspace-rpc.md b/.claude/plans/microvm-execution-environments/tasks/07-workspace-rpc.md new file mode 100644 index 000000000..a3bc1807a --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/07-workspace-rpc.md @@ -0,0 +1,26 @@ +--- +id: 07-workspace-rpc +title: Guest Workspace RPC and version-aware conformance +blocked_by: [04-control-protocol, 06-virtiofs-assets] +status: done +branch: "plan-microvm-execution-environments/07-workspace-rpc" +worktree: ".scratch/task-microvm-07" +issue: "530" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Implement the guest/host Workspace methods and adapter over the authenticated protocol. Preserve all current FileVersion, read-ledger, create-only, conditional-replace, path confinement, Glob/Grep, and bounded-result semantics. Extend the shared filesystem/environment conformance suites rather than inventing parallel rules. + +## Acceptance criteria + +- AC3.4: Read/Edit/Write/Grep/Glob and Bash observe the same bytes and cwd. A Workspace + read followed by Bash mutation is visible to a later Workspace read, and vice versa. + - verify: `TestInvariant_environment_workspace_runner_affinity` +- AC3.5: Workspace ReadVersion/CreateFile/ReplaceFile preserve opaque versions, + create-only behavior, and backend-atomic conditional replacement; a concurrent host + or guest change yields a model-visible conflict instead of overwriting newer bytes. + - verify: `TestMicroVMEnvironments_Scenario3_RemoteWorkspaceConformance` diff --git a/.claude/plans/microvm-execution-environments/tasks/08-exec-rpc.md b/.claude/plans/microvm-execution-environments/tasks/08-exec-rpc.md new file mode 100644 index 000000000..d992698a0 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/08-exec-rpc.md @@ -0,0 +1,30 @@ +--- +id: 08-exec-rpc +title: Structured guest exec streaming and cancellation +blocked_by: [04-control-protocol, 06-virtiofs-assets] +status: done +branch: "plan-microvm-execution-environments/08-exec-rpc" +worktree: ".scratch/task-microvm-08" +issue: "530" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Implement bound CommandRunner/CommandStreamer behavior over the authenticated guest protocol: ordered stdout/stderr, explicit exits, bounds/backpressure, cancellation, and process-group termination. SSH remains bootstrap/debug only and no stale path can run on the host. + +## Acceptance criteria + +- AC4.1: Exec streams ordered stdout and stderr plus an explicit exit status; a nonzero + guest exit is a completed command result, not a transport failure. + - verify: `TestMicroVMEnvironments_Scenario4_ExecStreamingPreservesChannelsAndExit` +- AC4.2: Cancellation or deadline terminates the entire guest process group within a + bound, returns partial output already produced, and is distinguishable from a + transport fault. + - verify: `TestMicroVMEnvironments_Scenario4_CancelKillsGuestProcessGroup` +- AC4.3: Wrong owner/session/ref/generation, replayed endpoint credentials, + malformed/oversized frames, output overflow, and excessive concurrent execs fail + closed without reaching another environment or the daemon control plane. + - verify: `TestMicroVMEnvironments_Scenario4_GuestProtocolBoundaryIsBounded` diff --git a/.claude/plans/microvm-execution-environments/tasks/09-network-egress.md b/.claude/plans/microvm-execution-environments/tasks/09-network-egress.md new file mode 100644 index 000000000..dcea42efe --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/09-network-egress.md @@ -0,0 +1,29 @@ +--- +id: 09-network-egress +title: Explicit guest networking and egress enforcement +blocked_by: [03-artifact-verification, 04-control-protocol] +status: done +branch: "plan-microvm-execution-environments/09-network-egress" +worktree: ".scratch/task-microvm-09" +issue: "531" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Always configure go-microvm's selected network provider and implement the guest-only deny/allow policy with IPv6 parity or explicit disablement. Surface honest separate guest and host egress status. Ordinary tests use deterministic network fakes; live bypass coverage lands in task 16. + +## Acceptance criteria + +- AC4.4: VM creation always configures the selected network provider. Failure to start + or enforce it aborts creation and never selects implicit or allow-all networking. + - verify: `TestADR_0108_ExplicitNetworkProviderNeverDegrades` +- AC4.5: Deny-all and configured hostname/port/protocol allowlists work from a real + guest; IPv6 is either equivalently filtered or disabled and demonstrably unavailable. + - verify: `TestMicroVMEnvironments_Scenario4_GuestEgressIsFailClosed` +- AC4.6: Session/UI status and documentation separately report guest-process egress and + host-service egress; guest deny-all never claims to constrain LLM providers, + WebFetch, WebSearch, MCP, hooks, OCI pulls, or telemetry. + - verify: `TestMicroVMEnvironments_Scenario4_EgressScopeIsHonest` diff --git a/.claude/plans/microvm-execution-environments/tasks/10-lifecycle-create.md b/.claude/plans/microvm-execution-environments/tasks/10-lifecycle-create.md new file mode 100644 index 000000000..272dfacfb --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/10-lifecycle-create.md @@ -0,0 +1,27 @@ +--- +id: 10-lifecycle-create +title: Transactional environment provisioning and fail-closed resolution +blocked_by: [02-profile-paths, 03-artifact-verification, 04-control-protocol, 05-worktree-git] +status: done +branch: "plan-microvm-execution-environments/10-lifecycle-create" +worktree: ".scratch/task-microvm-10" +issue: "532" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Add the internal provisioning/lifecycle seam and creation transaction across worktree, artifact, VM, protocol, environment ref/generation, and session persistence. Extend the existing resolver without widening tool.Environment into lifecycle management. Fail closed for every invalid ref. + +## Acceptance criteria + +- AC5.1: After a successful creation response, the persisted session, driver registry, + prepared worktree, VM, guest endpoint, owner, profile, generation, and artifact + identities agree; a failure before persistence rolls back or durably queues every + provisional resource for cleanup. + - verify: `TestMicroVMEnvironments_Scenario5_CreateTransactionIsAllOrReconciled` +- AC5.4: A nil resolver, unknown, foreign, stale, destroyed, incompatible, or + generation-mismatched ref fails before tool execution with no local fallback. + - verify: `TestInvariant_remote_environment_resolution_fails_closed` diff --git a/.claude/plans/microvm-execution-environments/tasks/11-lifecycle-reconcile.md b/.claude/plans/microvm-execution-environments/tasks/11-lifecycle-reconcile.md new file mode 100644 index 000000000..fbc7f009c --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/11-lifecycle-reconcile.md @@ -0,0 +1,35 @@ +--- +id: 11-lifecycle-reconcile +title: Restart reattachment, deletion, and crash reconciliation +blocked_by: [07-workspace-rpc, 08-exec-rpc, 09-network-egress, 10-lifecycle-create] +status: done +branch: "plan-microvm-execution-environments/11-lifecycle-reconcile" +worktree: ".scratch/task-microvm-11" +issue: "532" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Complete the session-lifetime environment lifecycle: harness and daemon restart, exact generation reattachment, detach versus delete, retention destruction, durable cleanup/tombstones, and crash/race reconciliation. Update ADR 0027 resource and rehydration inventories for the real resources introduced. + +## Acceptance criteria + +- AC5.2: Restarting mecated reattaches the exact environment generation and preserves + its worktree state without rebuilding the per-session engine unless the independent + engine-rehydration rules require it. + - verify: `TestMicroVMEnvironments_Scenario5_HarnessRestartReattachesExactGeneration` +- AC5.3: Restarting microvmd reconstructs or reconciles its durable registry and either + reattaches the exact live environment or returns an actionable failed precondition; + it never mints an empty replacement for the same ref. + - verify: `TestMicroVMEnvironments_Scenario5_DaemonRestartNeverRecreatesEmptyEnvironment` +- AC5.5: CloseSession detaches process-local handles and leaves the environment + reattachable; explicit deletion prevents new runs, destroys the VM/endpoints/children, + and removes or preserves the worktree according to the dirty-state policy. + - verify: `TestMicroVMEnvironments_Scenario5_DetachAndDeleteAreDistinct` +- AC5.6: Crashes at every lifecycle transition, PID reuse, stale sockets, disk-full, + and two daemons racing one ref converge without deleting another environment or + leaking quota indefinitely. + - verify: `TestMicroVMEnvironments_Scenario5_ReconcilerConvergesAcrossCrashPoints` diff --git a/.claude/plans/microvm-execution-environments/tasks/12-multi-session.md b/.claude/plans/microvm-execution-environments/tasks/12-multi-session.md new file mode 100644 index 000000000..28c0f9345 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/12-multi-session.md @@ -0,0 +1,34 @@ +--- +id: 12-multi-session +title: Same-repository concurrency and resource admission +blocked_by: [11-lifecycle-reconcile] +status: done +branch: "plan-microvm-execution-environments/12-multi-session" +worktree: ".scratch/task-microvm-12" +issue: "533" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Prove and harden concurrent local sessions from one repository. Each session gets independent worktree, Git metadata, VM, endpoint, generation, and quotas while sharing only verified immutable caches/object data. Add mecatui projections for each resolved session. + +## Acceptance criteria + +- AC6.1: Two concurrent session creates from one repository succeed with distinct + worktree roots, branches, indexes, environments, endpoints, and writable files; each + UI displays its own resolved paths. + - verify: `TestMicroVMEnvironments_Scenario6_SameRepoSessionsUseDistinctWorktreesAndVMs` +- AC6.2: File and Git mutations in session A do not alter session B's working files, + index, refs, config, hooks, or guest-local metadata; both can read the shared + host-enforced read-only object store. + - verify: `TestMicroVMEnvironments_Scenario6_SiblingSessionsCannotMutateEachOther` +- AC6.3: VM/worktree/image-pull admission observes per-user and deployment limits for + booting/active VMs, CPU, RAM, disk/inodes, execs, forks, pulls, and boot rate; a + rejection is stable and actionable. + - verify: `TestMicroVMEnvironments_Scenario6_ResourceAdmissionIsBounded` +- AC6.4: Concurrent cache, worktree, endpoint, and cleanup operations do not collide + when session IDs or repository names share prefixes or hostile characters. + - verify: `TestMicroVMEnvironments_Scenario6_ConcurrentResourceNamesAreOpaqueAndConfined` diff --git a/.claude/plans/microvm-execution-environments/tasks/13-child-environments.md b/.claude/plans/microvm-execution-environments/tasks/13-child-environments.md new file mode 100644 index 000000000..42c6d7f13 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/13-child-environments.md @@ -0,0 +1,30 @@ +--- +id: 13-child-environments +title: Direct-write and isolated child environment selection +blocked_by: [07-workspace-rpc, 08-exec-rpc, 11-lifecycle-reconcile] +status: done +branch: "plan-microvm-execution-environments/13-child-environments" +worktree: ".scratch/task-microvm-13" +issue: "534" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Route delegation by EnvironmentRef kind. Keep direct-write Subagent on the parent Environment and mint complete child VM/worktree Environments for isolated Subagent, Parallel, and Team paths. Preserve dispatcher serialization and existing child concurrency bounds. + +## Acceptance criteria + +- AC7.1: A direct-write Subagent uses the parent's Environment, so its filesystem and + Bash mutations appear in the parent worktree and remain parent-mutate-serial. + - verify: `TestMicroVMEnvironments_Scenario7_DirectWriteUsesParentEnvironment` +- AC7.2: A read-only Subagent, Parallel branch, or Team member receives a complete child + Environment whose Workspace and runner share a child namespace and cannot mutate the + parent before merge. + - verify: `TestMicroVMEnvironments_Scenario7_IsolatedChildrenUseCompleteEnvironments` +- AC7.3: Concurrent branches share no writable worktree, Git metadata, guest endpoint, + or environment generation; child creation is bounded by the existing delegation gates + plus driver fork quotas. + - verify: `TestMicroVMEnvironments_Scenario7_ConcurrentChildrenAreIsolatedAndBounded` diff --git a/.claude/plans/microvm-execution-environments/tasks/14-child-merge-lifecycle.md b/.claude/plans/microvm-execution-environments/tasks/14-child-merge-lifecycle.md new file mode 100644 index 000000000..52d22874b --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/14-child-merge-lifecycle.md @@ -0,0 +1,27 @@ +--- +id: 14-child-merge-lifecycle +title: Conflict-aware child merge and durable cleanup +blocked_by: [13-child-environments] +status: done +branch: "plan-microvm-execution-environments/14-child-merge-lifecycle" +worktree: ".scratch/task-microvm-14" +issue: "534" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Implement exact fork-base tracking, additions/replacements/deletions merge, per-parent serialization, conflict preservation, and durable child cleanup/reconciliation. Preserve current cancellation, timeout, background drain, inspection, and resume behavior. + +## Acceptance criteria + +- AC7.4: Merge compares additions, replacements, and deletions against the immutable + fork base, serializes per parent, applies no uncertain partial result, and preserves + inspectable child state on conflict. + - verify: `TestMicroVMEnvironments_Scenario7_MergeIsConflictAwareAndPreservesChild` +- AC7.5: Daemon/harness crash, cancellation, timeout, background drain, and resume do not + cross-attach or leak child environments and retain the existing model-visible stop and + resume semantics. + - verify: `TestMicroVMEnvironments_Scenario7_ChildLifecycleSurvivesTerminationPaths` diff --git a/.claude/plans/microvm-execution-environments/tasks/15-operations-docs.md b/.claude/plans/microvm-execution-environments/tasks/15-operations-docs.md new file mode 100644 index 000000000..d695e7ff3 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/15-operations-docs.md @@ -0,0 +1,31 @@ +--- +id: 15-operations-docs +title: Bounded observability, doctor diagnostics, and user docs +blocked_by: [02-profile-paths, 03-artifact-verification, 09-network-egress, 11-lifecycle-reconcile] +status: done +branch: "plan-microvm-execution-environments/15-operations-docs" +worktree: ".scratch/task-microvm-15" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Add low-cardinality metrics/diagnostics, operator readiness/doctor reporting, architecture/usage/user docs, configuration reference, and resource inventories. Follow the Diagnostics rule: events own model/client facts; operator diagnostics own facts with no event. + +## Acceptance criteria + +- AC8.3: Metrics and diagnostics expose boot latency, active/booting VMs, bounded resource + use, execs, egress denials, artifact verification, cleanup/reconciliation, and quota + rejection without command content, credentials, or unbounded labels. + - verify: `TestMicroVMEnvironments_Scenario8_ObservabilityIsBoundedAndSecretFree` +- AC8.4: An operator-facing doctor/diagnostic path reports hypervisor access, runtime and + firmware verification, control-socket authentication, network-provider readiness, + profile availability, and stale resources with actionable remediation. + - verify: `TestMicroVMEnvironments_Scenario8_DoctorReportsActionableReadiness` +- AC8.5: User documentation explains installation, source/worktree/guest paths, artifact + trust, guest-versus-host egress, session lifetime, retention/deletion, platform + prerequisites, and recovery without claiming schedules or remote/multi-user support. + - verify: inspection — `task docs` and `task site:build` prove the documented surface is linked and buildable; content accuracy is reviewed against ADR-0108 diff --git a/.claude/plans/microvm-execution-environments/tasks/16-platform-e2e.md b/.claude/plans/microvm-execution-environments/tasks/16-platform-e2e.md new file mode 100644 index 000000000..df0477a50 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/16-platform-e2e.md @@ -0,0 +1,29 @@ +--- +id: 16-platform-e2e +title: Linux and macOS live certification and isolation journey +blocked_by: [12-multi-session, 14-child-merge-lifecycle, 15-operations-docs, 17-concrete-runtime] +status: done +branch: "plan-microvm-execution-environments/16-platform-e2e" +worktree: ".scratch/task-microvm-16" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Add the explicit real-hypervisor E2E matrix and adversarial journey for Linux amd64/arm64 and supported Darwin arm64. Ordinary tests remain offline. The live journey uses verified pinned artifacts and covers every assembled capability; do not paper over platform differences. + +## Acceptance criteria + +- AC8.1: Linux amd64, Linux arm64, and Apple Silicon macOS at the documented minimum + version each pass the same live boot, worktree, filesystem, exec/cancel, egress, + detach/reattach, concurrent-session, and delete journey; any platform exception is + explicit and tested. + - verify: demonstration — `task e2e:microvm` runs on the supported-platform CI matrix because ordinary unit tests cannot emulate KVM/HVF +- AC8.2: Through the configured capability surface, a guest cannot read host provider, + MCP, identity, or registry credentials; the daemon control socket; unrelated host + roots; a sibling worktree; or a sibling guest endpoint. Positive controls prove the + intended worktree and allowed network destinations remain usable. + - verify: `TestMicroVMEnvironments_Scenario8_HostSecretAndSiblingIsolation` diff --git a/.claude/plans/microvm-execution-environments/tasks/17-concrete-runtime.md b/.claude/plans/microvm-execution-environments/tasks/17-concrete-runtime.md new file mode 100644 index 000000000..9fd14a009 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/17-concrete-runtime.md @@ -0,0 +1,24 @@ +--- +id: 17-concrete-runtime +title: Concrete microvmd and guest-agent runtime +blocked_by: [03-artifact-verification, 04-control-protocol, 06-virtiofs-assets, 07-workspace-rpc, 08-exec-rpc, 09-network-egress, 10-lifecycle-create, 18-guest-multiplex, 19-daemon-lifecycle-rpc] +status: done +branch: "plan-microvm-execution-environments/17-concrete-runtime" +worktree: ".scratch/task-microvm-17" +issue: "530" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Repair the decomposition gap found by task 16. Compose the already-landed artifact, worktree, virtio-fs, control, Workspace, exec, network, and lifecycle packages into a concrete go-microvm/libkrun runtime, a `mecatl-microvmd` executable, and a minimal guest-agent executable/image entrypoint. The daemon must expose the local control socket, own VMRuntime provisioning/stop/resolve hooks, wire verified runtime/firmware/image sources, bind the guest protocol over vsock, and use the explicit network provider. Add build/package entry points needed by the later real-hypervisor E2E, while keeping ordinary tests offline and the standard root/engine binaries free of libkrun linking. + +This is an enabling repair task: it owns no new numbered plan AC. It must preserve and integrate the already-pinned behavior for AC2.1–AC5.5 rather than duplicate their proofs. Its completion proof is that a deterministic fake-backed daemon composition test exercises create → ready → Workspace/exec → detach/delete through the concrete composition root, and the nested module builds both executables with `GOWORK=off` on supported build targets. + +## Verification obligations + +- `TestMicroVMRuntime_ComposesVerifiedEnvironmentEndToEnd` proves the concrete daemon composition wires the existing verified artifact, worktree, guest protocol, Workspace, exec, network, and lifecycle seams without a host fallback. +- `TestMicroVMRuntime_StandardBinariesDoNotLinkLibkrun` preserves the nested-module boundary while the microvmd/guest-agent build targets compile. +- `task test:microvm-standalone`, `task lint`, and `task test` pass; ordinary tests use fakes and never require KVM/HVF. diff --git a/.claude/plans/microvm-execution-environments/tasks/18-guest-multiplex.md b/.claude/plans/microvm-execution-environments/tasks/18-guest-multiplex.md new file mode 100644 index 000000000..2651a4c81 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/18-guest-multiplex.md @@ -0,0 +1,25 @@ +--- +id: 18-guest-multiplex +title: Multiplexed cross-VM guest protocol and transferable capabilities +blocked_by: [04-control-protocol, 07-workspace-rpc, 08-exec-rpc] +status: done +branch: "plan-microvm-execution-environments/18-guest-multiplex" +worktree: ".scratch/task-microvm-18" +issue: "530" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Repair the concrete-runtime gap: replace the independent Workspace/exec handshakes and shared in-memory credential registry with one authenticated, generation-bound, multiplexed guest-agent protocol over the single vsock binding. One connection/handshake negotiates services; every request carries a service/method/request ID and a transferable single-session capability that the guest can verify without shared host memory. Preserve replay rejection, bounds, stream backpressure, cancellation, and no-host-fallback behavior. + +This enabling task owns no new numbered plan AC. It strengthens the existing AC2.4/AC2.5/AC3.4/AC4.1–AC4.3 proofs so they hold across a real process/VM boundary. + +## Verification obligations + +- `TestGuestProtocol_MultiplexesWorkspaceAndExecAfterOneHandshake` +- `TestGuestProtocol_TransferableCapabilityRejectsReplayAndWrongGeneration` +- Existing task-04, task-07, and task-08 named tests remain green with no shared registry shortcut. +- `task test:microvm-standalone`, `task lint`, and `task test` pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/19-daemon-lifecycle-rpc.md b/.claude/plans/microvm-execution-environments/tasks/19-daemon-lifecycle-rpc.md new file mode 100644 index 000000000..6abc57a15 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/19-daemon-lifecycle-rpc.md @@ -0,0 +1,25 @@ +--- +id: 19-daemon-lifecycle-rpc +title: Concrete daemon lifecycle RPC and preboot guest configuration +blocked_by: [04-control-protocol, 09-network-egress, 10-lifecycle-create, 11-lifecycle-reconcile] +status: done +branch: "plan-microvm-execution-environments/19-daemon-lifecycle-rpc" +worktree: ".scratch/task-microvm-19" +issue: "532" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Task brief + +Repair the concrete-runtime gap: define and implement the local microvmd management RPC for create, resolve/attach, detach, inspect, and delete over the authenticated control socket. Bind every operation to owner/session/ref/generation and the durable lifecycle registry. Add an explicit preboot guest configuration artifact/rootfs hook carrying IPv6-disablement and guest-agent endpoint/capability material so network policy is installed before guest workload execution. Keep hypervisor creation behind the VMRuntime port for offline tests; task 17 supplies the concrete go-microvm implementation. + +This enabling task owns no new numbered plan AC. It strengthens AC2.4, AC4.4/AC4.5, and AC5.1–AC5.6 so the existing lifecycle is reachable through a real daemon surface. + +## Verification obligations + +- `TestMicroVMDaemon_LifecycleRPCIsOwnerAndGenerationBound` +- `TestMicroVMDaemon_PrebootConfigDisablesIPv6BeforeWorkload` +- Existing task-09 through task-11 named tests remain green. +- `task test:microvm-standalone`, `task lint`, `task test`, and docs gates pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/20-ac-trace-caller-identity-repair.md b/.claude/plans/microvm-execution-environments/tasks/20-ac-trace-caller-identity-repair.md new file mode 100644 index 000000000..c0e9d34a6 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/20-ac-trace-caller-identity-repair.md @@ -0,0 +1,18 @@ +--- +id: 20-ac-trace-caller-identity-repair +title: Repair pre-existing caller-identity traceability record +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/20-ac-trace-caller-identity-repair" +worktree: ".scratch/task-microvm-20" +issue: "" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Repair brief + +The assembled microVM plan has 43 ACs and zero trace failures, but the repository-wide `task ac-trace-strict` gate is blocked by the pre-existing landed `docs/acceptance/caller-identity.md`, which has zero structured `ACx.y` criteria. Convert that completed record to the current structured acceptance format without changing its landed behavior or inventing new implementation requirements. Map each existing observable assertion to an existing grep-resolvable proof test; preserve issue/ADR/status/history and citations. Run the bundled acceptance-plan check, `task ac-trace-strict`, and `task docs`. + +This is a terminal integration repair, not part of the microVM feature's numbered AC ownership. diff --git a/.claude/plans/microvm-execution-environments/tasks/21-root-composition-lifecycle-repair.md b/.claude/plans/microvm-execution-environments/tasks/21-root-composition-lifecycle-repair.md new file mode 100644 index 000000000..d002ad294 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/21-root-composition-lifecycle-repair.md @@ -0,0 +1,27 @@ +--- +id: 21-root-composition-lifecycle-repair +title: Wire production environment client and durable lifecycle ownership +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/21-root-composition-lifecycle-repair" +worktree: ".scratch/task-microvm-21" +issue: "533" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Panel repair brief + +Cross-confirmed Spec/Standards/Security/Architecture blockers: root `app.Build` exposes environment profiles but wires no microvmd lifecycle client, provisioner, resolver, detach, or delete path; daemon restart uses only in-memory runtime handles and never starts reconciliation; admission is unwired and leases are discarded. + +Implement the thin authenticated UDS client/adapter in the root module without importing go-microvm. Wire create to `PreparedEnvironment`, resolve to a complete `tool.Environment`, failed-create reconciliation, exact detach, and permanent delete through Service lifecycle paths. Make daemon startup reconcile durable registry/runtime state before serving and make concrete runtime reopening verify exact runner/process/endpoint identity instead of consulting only a fresh map. Persist/reconstruct admission reservations and release only after durable destruction. + +Protects AC1.1, AC5.1–AC5.6, AC6.3. + +## Verification + +- Real `app.Build` offline integration: configured profile create → persist → close/detach → fresh Build/daemon resolve → run → delete. +- Fresh concrete backend instance reopens or safely rejects exact persisted generations; startup reconciliation converges partial cleanup. +- Admission is production-wired, restart-reconstructed, and released on durable delete. +- Existing named AC proofs remain green; lint/test/docs pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/22-artifact-release-ci-repair.md b/.claude/plans/microvm-execution-environments/tasks/22-artifact-release-ci-repair.md new file mode 100644 index 000000000..b9cc94c54 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/22-artifact-release-ci-repair.md @@ -0,0 +1,27 @@ +--- +id: 22-artifact-release-ci-repair +title: Bind artifact evidence to bytes and publish verifiable runtime artifacts +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/22-artifact-release-ci-repair" +worktree: ".scratch/task-microvm-22" +issue: "528" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Panel repair brief + +Cross-confirmed Spec/Security blockers: artifact admission signs the claimed digest but never binds it to the materialized bytes. DevOps blockers: ordinary CI omits nested microVM module gates, and the mecatl release publishes neither microvmd nor a signed/attested guest artifact. + +Cryptographically bind materialized runtime/firmware/image content to the signed subject and reject valid evidence paired with altered bytes. Add normal CI build/race/standalone/lint/vet/cache coverage for every microVM subpackage. Extend release output with deterministic microvmd/guest artifacts or OCI payloads, immutable digests, SBOM, signature, and provenance consistent with documented operator verification. Keep go-microvm v0.0.40 runtime/firmware reuse. + +Protects AC2.1–AC2.3, AC8.1, and Definition of done supply-chain claims. + +## Verification + +- Valid evidence + altered source bytes fails admission. +- Ordinary PR CI fails on a planted nested-package test/lint error. +- Release workflow dry-run/fixture produces reproducible digest/SBOM/signature/provenance references consumable by strict config. +- Lint/test/docs/action lint pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/23-guest-trust-protocol-repair.md b/.claude/plans/microvm-execution-environments/tasks/23-guest-trust-protocol-repair.md new file mode 100644 index 000000000..75f6c7aab --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/23-guest-trust-protocol-repair.md @@ -0,0 +1,27 @@ +--- +id: 23-guest-trust-protocol-repair +title: Drop guest workload privilege and converge on one negotiated protocol +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/23-guest-trust-protocol-repair" +worktree: ".scratch/task-microvm-23" +issue: "530" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Panel repair brief + +Security blocker: model commands run as guest root/PID-neighbour of the privileged agent, can read the HMAC key, alter IPv6/sysctls/mounts, and signal the agent. Reuse blocker: the production multiplex protocol and an unused second capability handshake drift; capability-negotiation tests cover the unused path. + +Run model commands under a dedicated unprivileged UID/GID with no ambient capabilities and no read access to guest-agent capability material. Keep privileged configuration outside its readable namespace and enforce IPv6/mount policy beyond workload control. Remove or fold the dead duplicate handshake so the single production multiplex handshake negotiates actual services/capabilities and task-04 fail-closed tests exercise that path. Preserve transferable generation binding, replay rejection, stream bounds, cancellation, and no host fallback. + +Protects AC2.5, AC4.1–AC4.5, AC8.2. + +## Verification + +- Live/fake guest Bash cannot read capability key, change IPv6 sysctls, signal agent, or mount; ordinary workspace/toolchain use remains possible. +- Production multiplex path rejects missing required capability/version. +- Duplicate unused handshake is removed or demonstrably one shared implementation. +- Existing guest protocol/exec/network tests plus lint/test pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/24-worktree-git-repair.md b/.claude/plans/microvm-execution-environments/tasks/24-worktree-git-repair.md new file mode 100644 index 000000000..efc725069 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/24-worktree-git-repair.md @@ -0,0 +1,27 @@ +--- +id: 24-worktree-git-repair +title: Mount reconstructed Git metadata correctly and harden every host Git call +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/24-worktree-git-repair" +worktree: ".scratch/task-microvm-24" +issue: "529" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Panel repair brief + +Cross-confirmed Spec/Security blocker: reconstructed metadata is mounted at `/run/mecatl/git-metadata` while Git from `/workspace` still reads the original host-path `.git` file. Duplication blocker: retention Dirty/Remove Git calls bypass the hardened environment used by worktree preparation. Reuse review also found non-hermetic tests invoking ambient external diff. + +Make every guest Git command resolve only the reconstructed guest-local metadata—mount it at `/workspace/.git` or set an unavoidable confined `GIT_DIR` contract. Preserve host-enforced read-only common objects. Extract one narrow hardened Git invoker used by prepare, dirty inspection, and cleanup; disable hooks, fsmonitor, pager, external diff, and ambient unsafe config. Make tests hermetic. + +Protects AC3.1–AC3.3, AC6.2, AC8.1. + +## Verification + +- Real/fake guest `git -C /workspace status` and `rev-parse --git-dir` use guest-local metadata. +- Base/sibling refs/config/hooks remain unchanged. +- Ambient `diff.external`, fsmonitor, hooks, pager cannot execute in production or tests. +- Existing worktree/virtiofs tests plus lint/test pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/25-delegation-operations-repair.md b/.claude/plans/microvm-execution-environments/tasks/25-delegation-operations-repair.md new file mode 100644 index 000000000..91c788fa9 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/25-delegation-operations-repair.md @@ -0,0 +1,27 @@ +--- +id: 25-delegation-operations-repair +title: Wire production delegation, observability, doctor, and placement persistence +blocked_by: [21-root-composition-lifecycle-repair] +status: done +branch: "plan-microvm-execution-environments/25-delegation-operations-repair" +worktree: ".scratch/task-microvm-25" +issue: "534" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Panel repair brief + +Spec blockers: no concrete ForkDriver/daemon fork-merge operations are wired; OperationsObserver/Doctor are library-only; production quotas are disabled. Security medium: FileSessionPersister uses only a process mutex and can lose placements across daemons. + +Implement daemon/client fork, merge, and child cleanup operations and register the microVM EnvironmentForker/Merger through root composition. Wire bounded observer events and an operator-invokable doctor into microvmd without creating a duplicate unexported telemetry dead end; either integrate the existing observer or replace it with the smallest exported operational seam. Wire quotas through production lifecycle. Add inter-process locking and directory durability to placement persistence. + +Protects AC6.3, AC7.1–AC7.5, AC8.3–AC8.4. + +## Verification + +- Real composition creates isolated child VM/worktree, conflict-aware merges, and durable cleanup. +- Production daemon emits bounded metrics/diagnostics and doctor is invokable. +- Two independent persisters cannot lose concurrent entries. +- Existing delegation/operations/admission tests plus lint/test/docs pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/26-production-e2e-ci-repair.md b/.claude/plans/microvm-execution-environments/tasks/26-production-e2e-ci-repair.md new file mode 100644 index 000000000..009e80905 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/26-production-e2e-ci-repair.md @@ -0,0 +1,27 @@ +--- +id: 26-production-e2e-ci-repair +title: Exercise production daemon boundaries in a viable platform matrix +blocked_by: [21-root-composition-lifecycle-repair, 22-artifact-release-ci-repair, 23-guest-trust-protocol-repair, 24-worktree-git-repair, 25-delegation-operations-repair] +status: done +branch: "plan-microvm-execution-environments/26-production-e2e-ci-repair" +worktree: ".scratch/task-microvm-26" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Panel repair brief + +Spec/DevOps blockers: live E2E bypasses production artifact admission and Unix peer/control daemon, omits deny-all/UDP/port/IPv6 probes, and uses GitHub-hosted macOS where nested HVF is unavailable. + +Make acceptance E2E launch the production `mecatl-microvmd` and client over a real private UDS with peer credentials, strict digest/signature/attestation inputs, create/resolve/detach/delete lifecycle, guest Git, Workspace/exec, quotas/doctor, delegation, and host-secret/sibling isolation. Exercise deny-all, allowed/denied hostname+port+protocol, UDP as supported, and explicit IPv6 unavailability. Move HVF execution to a controlled self-hosted Apple Silicon runner with a real hypervisor preflight; keep GitHub-hosted macOS for compile/static gates only. Preserve Linux amd64/arm64 cells. + +Protects AC4.5, AC8.1–AC8.2 and the live Definition-of-done gate. + +## Verification + +- Local Linux KVM production-daemon journey passes with strict artifacts and real UDS. +- Wrong peer and altered/unsigned artifact fail. +- CI matrix uses a viable HVF runner and explicit preflight. +- `task e2e:microvm`, action lint, lint/test/docs/site gates pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/27-ui-profile-policy-repair.md b/.claude/plans/microvm-execution-environments/tasks/27-ui-profile-policy-repair.md new file mode 100644 index 000000000..b10b84f69 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/27-ui-profile-policy-repair.md @@ -0,0 +1,22 @@ +--- +id: 27-ui-profile-policy-repair +title: Wire mecatui profile selection and enforce one effective daemon policy +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/27-ui-profile-policy-repair" +worktree: ".scratch/task-microvm-27" +issue: "533" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Second panel repair + +Remaining Spec blockers: mecatui production adapter never calls profile-aware session creation; root profile sends partial policy while daemon enforces unrelated global egress/admission and reports root values, allowing enforcement/status drift. + +Expose environment-profile selection in mecatui configuration/sessionAdapter and use the profile-aware client. Define one effective-policy contract: either daemon-owned opaque profile ID or fully validated requested policy. Ensure mounts, seccomp, lifecycle, quotas, resources, and egress cannot be dropped or merely echoed; reported status comes from daemon-enforced resolved policy. + +Protects AC1.1–AC1.3, AC4.5, AC6.1, AC6.3. + +Verification: real mecatui adapter creates selected profile; daemon rejects unknown/mismatched policy; resolved status equals enforced policy; default path unchanged; lint/test/docs/site pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/28-artifact-release-alignment-repair.md b/.claude/plans/microvm-execution-environments/tasks/28-artifact-release-alignment-repair.md new file mode 100644 index 000000000..73df6217d --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/28-artifact-release-alignment-repair.md @@ -0,0 +1,22 @@ +--- +id: 28-artifact-release-alignment-repair +title: Close artifact launch TOCTOU and align release evidence with admission +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/28-artifact-release-alignment-repair" +worktree: ".scratch/task-microvm-28" +issue: "528" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Second panel repair + +Remaining Spec/DevOps blockers: verified cache paths can mutate after Verify before launch; release upload assumes a pre-existing GitHub Release; matrix cells clobber one SHA256SUMS; published Cosign/GitHub evidence is incompatible with daemon Ed25519 admission; the hand-written SBOM has no dependency inventory. + +Make verified artifacts immutable between admission and launch using a content-addressed immutable handle or launch-time revalidation under lock. Add serialized release creation, per-platform or aggregated checksums, real Syft-equivalent SPDX/CycloneDX component SBOMs, and one evidence format/identity policy consumed by microvmd. Production E2E must admit the exact packaged evidence, not manufacture another scheme. + +Protects AC2.1–AC2.3, AC8.1 and release DoD. + +Verification: mutate-after-verify fails; tag workflow creates release; matrix checksum assets cannot race; generated SBOM includes modules/dependencies; strict daemon accepts published bundle and rejects altered/wrong identity; action lint/lint/test/docs pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/29-streaming-cancellation-repair.md b/.claude/plans/microvm-execution-environments/tasks/29-streaming-cancellation-repair.md new file mode 100644 index 000000000..b06808ec5 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/29-streaming-cancellation-repair.md @@ -0,0 +1,22 @@ +--- +id: 29-streaming-cancellation-repair +title: Preserve ordered streaming and cancellation across microvmd UDS +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/29-streaming-cancellation-repair" +worktree: ".scratch/task-microvm-29" +issue: "530" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Second panel repair + +Cross-confirmed Spec/Security blocker: microvmd buffers stdout/stderr into separate strings, root concatenates stdout before stderr, and client cancellation/disconnect does not cancel the daemon-wide guest execution context. + +Implement framed streaming over the management UDS preserving global frame order, partial output, explicit exit, and backpressure. Bind each request to a per-connection/request context; client cancellation closes/cancels the request and peer disconnect cancels guest execution/process group. Keep transport, cancellation, nonzero exit distinct. + +Protects AC4.1–AC4.3. + +Verification: production UDS E2E interleaves stdout/stderr in order; cancellation without deadline and abrupt disconnect kill guest process group and preserve only prior output; no slot leak; existing guestexec tests plus lint/test pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/30-restart-merge-serialization-repair.md b/.claude/plans/microvm-execution-environments/tasks/30-restart-merge-serialization-repair.md new file mode 100644 index 000000000..b69c2a6c6 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/30-restart-merge-serialization-repair.md @@ -0,0 +1,22 @@ +--- +id: 30-restart-merge-serialization-repair +title: Converge stale generations and serialize exact-base merges in daemon +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/30-restart-merge-serialization-repair" +worktree: ".scratch/task-microvm-30" +issue: "532" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Second panel repair + +Cross-confirmed Spec/Security blockers: fresh LibkrunBackend still cannot rediscover active runtime; unavailable ready records and reconstructed quota stay forever. Production child fork records only committed HEAD, not exact dirty base; daemon merge check/apply lacks shared per-parent serialization. + +Persist enough runner/start/endpoint/runtime identity to securely reopen an exact generation, or identity-check and destroy orphan on daemon restart. Startup reconciliation must converge unavailable ready records and release quotas within an explicit policy, never leave unmanaged workload. Capture exact parent worktree state at fork. Serialize the complete check/patch/apply transaction inside microvmd with a per-parent lock across all clients and revalidate immediately before atomic apply. + +Protects AC5.2–AC5.6, AC6.3, AC7.4–AC7.5. + +Verification: fresh daemon/backend reattaches or safely destroys real/fake generation; stale ready quotas converge; dirty-parent fork/merge exact; multi-client concurrent merge yields one apply and one conflict; lint/test/docs pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/31-observability-doctor-repair.md b/.claude/plans/microvm-execution-environments/tasks/31-observability-doctor-repair.md new file mode 100644 index 000000000..07efe72a7 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/31-observability-doctor-repair.md @@ -0,0 +1,22 @@ +--- +id: 31-observability-doctor-repair +title: Make production observability and doctor state truthful +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/31-observability-doctor-repair" +worktree: ".scratch/task-microvm-31" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Second panel repair + +Spec blockers: production never records egress denials, successful deletion does not decrement active/resource gauges, restart resets metrics despite live records, and doctor checks declarations instead of actual signature/network/profile/stale readiness. + +Wire observer calls at every production egress, verification, create/ready, exec, admission, detach/delete, cleanup/reconcile transition; prevent double-count and reconstruct active/resource gauges from durable state at startup. Make doctor run real artifact verification, network-provider readiness, profile availability/policy consistency, control peer checks, hypervisor preflight, and stale-generation detection with actionable results. + +Protects AC8.3–AC8.4. + +Verification: lifecycle transition table keeps gauges/counters truthful, egress denial increments, restart reconstructs gauges, doctor detects corrupt evidence/unavailable network/profile/stale ready and passes healthy case; lint/test/docs/site pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/32-complete-production-e2e-repair.md b/.claude/plans/microvm-execution-environments/tasks/32-complete-production-e2e-repair.md new file mode 100644 index 000000000..ccde4830b --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/32-complete-production-e2e-repair.md @@ -0,0 +1,22 @@ +--- +id: 32-complete-production-e2e-repair +title: Cover every production acceptance path in the live journey +blocked_by: [27-ui-profile-policy-repair, 28-artifact-release-alignment-repair, 29-streaming-cancellation-repair, 30-restart-merge-serialization-repair, 31-observability-doctor-repair] +status: done +branch: "plan-microvm-execution-environments/32-complete-production-e2e-repair" +worktree: ".scratch/task-microvm-32" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Second panel repair + +Final Spec blocker: `task e2e:microvm` still omits actual app.Build/mecatui profile selection, ordered host-side streaming/cancellation, daemon restart/reconciliation, engine Subagent/Parallel/Team delegation, deny-all/UDP/port/IPv6 cases, and truthful doctor/metrics. Production E2E must use published-compatible strict evidence and actual daemon boundaries. + +Expand live journey to cover all these paths with positive and negative controls. Keep Linux amd64/arm64 live and self-hosted Apple Silicon HVF live; GitHub-hosted macOS remains compile/static only. + +Protects AC1.1, AC4.1–AC4.5, AC5.2–AC5.6, AC7.1–AC7.5, AC8.1–AC8.4. + +Verification: local Linux KVM full production journey passes; wrong peer/evidence/policy fail; CI contract enforces all required cells and self-hosted HVF preflight; lint/test/docs/site/action/ac-trace pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/33-artifact-immutability-release-evidence.md b/.claude/plans/microvm-execution-environments/tasks/33-artifact-immutability-release-evidence.md new file mode 100644 index 000000000..73ffe86d8 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/33-artifact-immutability-release-evidence.md @@ -0,0 +1,22 @@ +--- +id: 33-artifact-immutability-release-evidence +title: Make verified artifacts immutable and publish complete admission evidence +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/33-artifact-immutability-release-evidence" +worktree: ".scratch/task-microvm-33" +issue: "528" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Third panel repair + +Final Spec blockers: an uncooperative same-user writer can mutate ordinary cached paths after validation while runtime consumes them; release evidence signs microvmd/guest binaries but does not publish independently admissible runtime, firmware, and execution-image bundles. + +Make verified cache entries immutable to non-cooperating writers between admission and runtime consumption using ownership/mode/rename isolation, private immutable snapshot/FD, or equivalent platform-safe mechanism—not advisory flock alone. Publish or reference runtime, firmware, and execution-image payloads with independent digests and the exact Sigstore/provenance evidence microvmd admission consumes. Ensure release installation can configure strict admission without manufacturing evidence. + +Protects AC2.1–AC2.3, AC8.1, release DoD. + +Verification: concurrent same-account mutation after LockAndValidate cannot alter launched bytes; release fixture produces strict-admissible evidence for all three artifact kinds; production E2E consumes those packaged-format artifacts; lint/test/docs/action/ac-trace pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/34-doctor-egress-metrics-correctness.md b/.claude/plans/microvm-execution-environments/tasks/34-doctor-egress-metrics-correctness.md new file mode 100644 index 000000000..feddfc549 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/34-doctor-egress-metrics-correctness.md @@ -0,0 +1,22 @@ +--- +id: 34-doctor-egress-metrics-correctness +title: Fix doctor process identity and live egress metrics +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/34-doctor-egress-metrics-correctness" +worktree: ".scratch/task-microvm-34" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Third panel repair + +Final Spec blockers: doctor parses real OS process-start tokens as a synthetic go-microvm string and marks healthy VMs stale; egress denials are sampled only at destruction and cumulative values are treated as increments. + +Use the same platform process-identity parser/verifier in runtime/reconcile/doctor. Wire live provider denial events or delta-aware sampling into observer snapshots for active environments, with restart reconstruction and no double count. + +Protects AC8.3–AC8.4. + +Verification: healthy real identity passes doctor, reused/stale PID fails; live egress denial appears before destruction; repeated sampling is delta-correct; restart remains truthful; lint/test/docs/site/ac-trace pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/35-end-to-end-client-crash-quota-proof.md b/.claude/plans/microvm-execution-environments/tasks/35-end-to-end-client-crash-quota-proof.md new file mode 100644 index 000000000..6270c438a --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/35-end-to-end-client-crash-quota-proof.md @@ -0,0 +1,22 @@ +--- +id: 35-end-to-end-client-crash-quota-proof +title: Prove mecatui through daemon and crash-orphan quota convergence +blocked_by: [33-artifact-immutability-release-evidence, 34-doctor-egress-metrics-correctness] +status: done +branch: "plan-microvm-execution-environments/35-end-to-end-client-crash-quota-proof" +worktree: ".scratch/task-microvm-35" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Third panel repair + +Final Spec blocker: live E2E tests app.Build and mecatui separately, manufactures fresh evidence rather than release-compatible payloads, and deletes a VM before restart instead of proving crash-orphaned ready-state quota convergence. + +Drive the actual mecatui/session adapter over gRPC into app.Build and microvmd with selected profile and resolved paths. Consume release-compatible strict artifact evidence. Crash/restart microvmd while a ready generation still owns quota, then prove secure reattach or identity-checked destruction and quota convergence. Retain ordered streaming/cancel/delegation/egress/doctor controls. + +Protects AC1.1, AC2.1, AC5.6, AC6.3, AC8.1. + +Verification: local Linux KVM complete client-to-daemon journey passes; crash-orphan quota converges without pre-delete; CI matrix runs equivalent live cells; lint/test/docs/site/action/ac-trace pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/36-final-evidence-metrics-proof.md b/.claude/plans/microvm-execution-environments/tasks/36-final-evidence-metrics-proof.md new file mode 100644 index 000000000..138028e3b --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/36-final-evidence-metrics-proof.md @@ -0,0 +1,22 @@ +--- +id: 36-final-evidence-metrics-proof +title: Consume publisher evidence and assert live egress metrics +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/36-final-evidence-metrics-proof" +worktree: ".scratch/task-microvm-36" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Final acceptance repair + +Two final audit blockers remain. Production E2E re-signs packaged provenance with an ephemeral key rather than consuming publisher-produced release evidence. Live denied-network probes do not query LifecycleMetrics or assert `EgressDenials` before destruction. + +Make the release fixture produce the same signed evidence artifact the publisher workflow uploads and make E2E install/consume it unchanged—no test-time re-signing after packaging. Add live metrics retrieval after a denied guest connection and assert the active-generation denial count increases before destroy, then remains delta-correct after final sampling. + +Protects AC2.1, AC8.1, AC8.3. + +Verification: publisher fixture evidence strict-admits unchanged; tampered evidence fails; local Linux KVM full E2E passes with live pre-destroy metric assertion; action/lint/test/docs/site/ac-trace pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/37-live-metric-delta-proof.md b/.claude/plans/microvm-execution-environments/tasks/37-live-metric-delta-proof.md new file mode 100644 index 000000000..2b6692cf2 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/37-live-metric-delta-proof.md @@ -0,0 +1,20 @@ +--- +id: 37-live-metric-delta-proof +title: Prove live and final egress metric delta correctness +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/37-live-metric-delta-proof" +worktree: ".scratch/task-microvm-37" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Final audit repair + +The live E2E proves an active denial increase but would still pass if destruction re-added the provider's cumulative total. While the generation remains active, take repeated samples and prove stability without new denials; after one additional denial prove an exact +1 delta; after destroy/final sample prove the exact final value does not double count. + +Protects AC8.3. + +Verification: local Linux KVM E2E exact deltas, unit delta sampling, lint/test/docs/action/ac-trace. diff --git a/.claude/plans/microvm-execution-environments/tasks/38-ci-kvm-permission-repair.md b/.claude/plans/microvm-execution-environments/tasks/38-ci-kvm-permission-repair.md new file mode 100644 index 000000000..11f5d234c --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/38-ci-kvm-permission-repair.md @@ -0,0 +1,18 @@ +--- +id: 38-ci-kvm-permission-repair +title: Enable KVM access on hosted Linux live runners +blocked_by: [] +status: in-progress +branch: "plan-microvm-execution-environments/38-ci-kvm-permission-repair" +worktree: ".scratch/task-microvm-38" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# CI repair + +PR #580 Linux amd64/arm64 live cells fail in preflight because `/dev/kvm` exists but is not writable by the hosted runner account. Configure KVM access explicitly and securely before the live gate (for example the standard udev/permission setup with sudo), then retain read/write preflight and the full production E2E. Do not skip or downgrade the live cells. + +Verification: both workflow platform definitions contain the required KVM permission setup; action lint/tests pass; local task e2e remains green. diff --git a/.claude/plans/microvm-execution-environments/tasks/39-ci-platform-reliability-repair.md b/.claude/plans/microvm-execution-environments/tasks/39-ci-platform-reliability-repair.md new file mode 100644 index 000000000..115a20c06 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/39-ci-platform-reliability-repair.md @@ -0,0 +1,22 @@ +--- +id: 39-ci-platform-reliability-repair +title: Make microVM platform CI viable and deterministic +blocked_by: [] +status: in-progress +branch: "plan-microvm-execution-environments/39-ci-platform-reliability-repair" +worktree: ".scratch/task-microvm-39" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# CI follow-up + +PR #580 failures: + +- Linux amd64 live job leaves root/user-namespace-owned rootfs files that the next E2E preparation cannot clean; cleanup must be confined, privilege-aware, and deterministic. +- GitHub-hosted Linux arm64 has no `/dev/kvm`; do not pretend the hosted runner can execute libkrun. Keep hosted arm64 compile/static and move live arm64 to an explicit controlled self-hosted KVM cell that does not block ordinary PRs when no runner is configured. +- macOS compile/static test creates an overlong Unix socket path; use the repository's short Darwin socket strategy or an explicit short safe base. + +Verification: local Linux KVM full E2E; workflow contract tests for hosted/static vs controlled/live cells; Darwin tests with path-length guard; action lint/test and full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/40-ci-kvm-darwin-runner-repair.md b/.claude/plans/microvm-execution-environments/tasks/40-ci-kvm-darwin-runner-repair.md new file mode 100644 index 000000000..6f34a2991 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/40-ci-kvm-darwin-runner-repair.md @@ -0,0 +1,22 @@ +--- +id: 40-ci-kvm-darwin-runner-repair +title: Fix hosted KVM access, Darwin socket length, and manual HVF gating +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/40-ci-kvm-darwin-runner-repair" +worktree: ".scratch/task-microvm-40" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# CI repair + +PR #580 latest failures: + +- hosted Linux amd64 setup completes but microvmd still receives `permission denied` opening `/dev/kvm`; verify effective identity/ACL across the actual test process and use the standard hosted-runner KVM setup that survives subsequent steps. +- `TestDoctorProcessStartIdentityDistinguishesHealthyAndReusedPID` still uses an overlong Darwin UDS path; route every microvmd test socket through one short private cross-platform helper. +- controlled macOS HVF remains queued indefinitely with no self-hosted runner; make it explicitly manual/default-off like controlled arm64 KVM while retaining hosted Darwin compile/static. + +Verification: contract tests prove Linux setup works in a later process, all test sockets respect Darwin limit, PR events skip unavailable controlled live cells, local KVM E2E and all standard/action gates pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/41-ci-kvm-refresh-race-timeout.md b/.claude/plans/microvm-execution-environments/tasks/41-ci-kvm-refresh-race-timeout.md new file mode 100644 index 000000000..4122b15bb --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/41-ci-kvm-refresh-race-timeout.md @@ -0,0 +1,21 @@ +--- +id: 41-ci-kvm-refresh-race-timeout +title: Refresh KVM access after preparation and budget race CI +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/41-ci-kvm-refresh-race-timeout" +worktree: ".scratch/task-microvm-41" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# CI repair + +PR #580 at `2163d139`: + +- hosted Linux amd64 proves `/dev/kvm` open immediately after early ACL setup, but artifact/rootfs preparation later invalidates access; refresh and independently open-probe KVM immediately after preparation and immediately before the live test process. Keep access least-privileged and diagnose inode/owner/mode changes. +- normal race job now exceeds its legacy 15-minute timeout after adding the microVM module; raise the bounded CI timeout enough for root+engine+OIDC+microVM+providers rather than cancelling a healthy run. + +Verification: workflow ordering tests pin prepare → KVM refresh/open probe → live test; local repeated prepare+probe+E2E; race timeout contract; action/full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/42-ci-daemon-kvm-identity-repair.md b/.claude/plans/microvm-execution-environments/tasks/42-ci-daemon-kvm-identity-repair.md new file mode 100644 index 000000000..10d155759 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/42-ci-daemon-kvm-identity-repair.md @@ -0,0 +1,18 @@ +--- +id: 42-ci-daemon-kvm-identity-repair +title: Keep live microvmd on the KVM-authorized CI identity +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/42-ci-daemon-kvm-identity-repair" +worktree: ".scratch/task-microvm-42" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# CI repair + +Hosted runner can open `/dev/kvm` immediately before E2E, but microvmd doctor/preflight receives EACCES. Determine the actual daemon effective UID in the production E2E. Keep the primary daemon on the runner UID that owns the KVM ACL; exercise wrong-peer rejection by launching only the unauthorized client under an alternate UID, not the KVM-owning daemon. If a different cause is proven, fix that exact identity transition and pin it. + +Verification: E2E logs daemon UID/KVM open identity, unauthorized peer still rejected, local and hosted-style Linux KVM journey pass, full/action gates pass. diff --git a/.claude/plans/microvm-execution-environments/tasks/43-ci-multi-identity-kvm-peer-proof.md b/.claude/plans/microvm-execution-environments/tasks/43-ci-multi-identity-kvm-peer-proof.md new file mode 100644 index 000000000..c86aba9be --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/43-ci-multi-identity-kvm-peer-proof.md @@ -0,0 +1,20 @@ +--- +id: 43-ci-multi-identity-kvm-peer-proof +title: Separate authorized VM owner identities from wrong-peer probes +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/43-ci-multi-identity-kvm-peer-proof" +worktree: ".scratch/task-microvm-43" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# CI repair + +PR #580 KVM live failures now occur under UID/EUID 1001 during child/Parallel/Team launches, while `/dev/kvm` ACL authorizes only runner UID 1000. Trace why production E2E changes the daemon/runtime identity. Child environments owned by the same daemon must retain its KVM-authorized host identity; a deliberately unauthorized wrong-peer client must not become the runtime owner. If the suite intentionally launches multiple authorized daemon UIDs, grant KVM only to those explicit authorized UIDs and keep the wrong-peer UID distinct. + +The wrong-peer probe currently fails at filesystem permission before peer-credential middleware; accept and assert this stronger OS-level refusal or make the socket traversable without weakening write/connect authorization, but do not treat it as a false failure. + +Verification: logs/assertions pin daemon/child effective identities, child VM KVM opens, wrong peer rejected at OS or middleware, local and hosted-style E2E/full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/44-actionlint-kvm-shell-repair.md b/.claude/plans/microvm-execution-environments/tasks/44-actionlint-kvm-shell-repair.md new file mode 100644 index 000000000..34670d7b9 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/44-actionlint-kvm-shell-repair.md @@ -0,0 +1,18 @@ +--- +id: 44-actionlint-kvm-shell-repair +title: Fix KVM workflow shell portability lint +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/44-actionlint-kvm-shell-repair" +worktree: ".scratch/task-microvm-44" +issue: "535" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# CI repair + +Fix actionlint/ShellCheck SC2155 and SC2016 in the KVM group identity step without changing the proven `sg kvm` execution behavior. Separate assignment from export so lookup failure propagates; pass/log the resolved GID into the inner shell without relying on accidental outer expansion. + +Verification: actionlint, action tests, local KVM E2E, full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/45-oci-default-guest-image.md b/.claude/plans/microvm-execution-environments/tasks/45-oci-default-guest-image.md new file mode 100644 index 000000000..747f115ff --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/45-oci-default-guest-image.md @@ -0,0 +1,20 @@ +--- +id: 45-oci-default-guest-image +title: Reuse Brood Box base through a verified OCI guest-tools image +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/45-oci-default-guest-image" +worktree: ".scratch/task-microvm-45" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Usability round + +Add an OCI execution-image resolver to microvmd that accepts digest-pinned `repo@sha256` only, reuses go-microvm's hardened platform-specific OCI pull/extract/cache, and returns the extracted tree through mecatl's existing Sigstore/tree-digest/launch-snapshot admission. Retain both OCI manifest digest and materialized-tree identity. + +Define and publish a thin mecatl guest-tools image derived from Brood Box's `images/base`—not an agent-specific image and never mutable `:latest`. Add UID/GID 65532 home/cache setup and inject/use the separately verified mecatl guest agent. Do not place LLM credentials, MCP config, SSH credentials, or another coding-agent harness in the image. + +Verification: tag rejection, platform mismatch, cold/warm/concurrent cache, manifest/tree/signature mismatch, tool smoke under UID65532, guest secret absence, live KVM/HVF contract, release image digest/provenance. diff --git a/.claude/plans/microvm-execution-environments/tasks/46-user-bootstrap-daemon-manager.md b/.claude/plans/microvm-execution-environments/tasks/46-user-bootstrap-daemon-manager.md new file mode 100644 index 000000000..2c212b65e --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/46-user-bootstrap-daemon-manager.md @@ -0,0 +1,20 @@ +--- +id: 46-user-bootstrap-daemon-manager +title: Add safe user-local bootstrap and daemon management defaults +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/46-user-bootstrap-daemon-manager" +worktree: ".scratch/task-microvm-46" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Usability round + +Implement a root-module user-local bootstrap/manager that keeps daemon absolute-path validation intact but supplies safe XDG defaults: state 0700, short runtime socket, config 0600, verified artifacts data root, and alias `microvm-local`. Explicit interactive init preflights platform/KVM/HVF/Git, downloads and bootstrap-verifies a release bundle, runs the existing installer, generates strict daemon config plus user-global host alias, starts/reuses microvmd, waits for owner-only socket, runs doctor, and enables the alias only after success. + +Never auto-run sudo/change KVM ACL/groups/user namespaces; never write project config; never auto-enable by detecting KVM/daemon; failure leaves profile disabled and recovery instructions. Add manager status/doctor/recover/delete seams around existing lifecycle. + +Verification: empty-XDG bootstrap, path modes/symlink refusal/Darwin socket, unsupported/preflight/download/evidence/doctor failures, idempotent daemon reuse/no duplicate, enabled-only-after-doctor, no-host-fallback. diff --git a/.claude/plans/microvm-execution-environments/tasks/47-mecatui-microvm-first-run.md b/.claude/plans/microvm-execution-environments/tasks/47-mecatui-microvm-first-run.md new file mode 100644 index 000000000..0c834f012 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/47-mecatui-microvm-first-run.md @@ -0,0 +1,18 @@ +--- +id: 47-mecatui-microvm-first-run +title: Add explicit mecatui microVM first-run and daily-use UX +blocked_by: [46-user-bootstrap-daemon-manager] +status: done +branch: "plan-microvm-execution-environments/47-mecatui-microvm-first-run" +worktree: ".scratch/task-microvm-47" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Usability round + +Add `mecatui microvm init|doctor|status|recover|delete` and an explicit daily `mecatui --microvm` shortcut selecting `microvm-local`. Bare mecatui remains host-local byte-compatible; `connect` mode never starts/probes a local daemon. Init displays trust/egress/resource/path decisions and requires interactive confirmation (or explicit noninteractive acknowledgement). Embedded app.Build receives the generated operator profile only after doctor success. Surface source/worktree/guest paths, enforced egress, detach-vs-delete, and retained dirty worktree location. + +Verification: CLI/help/config discoverability, explicit activation, connect isolation, first-run confirmation, default unchanged, profile selection through real session adapter, delete confirmation/recovery UX. diff --git a/.claude/plans/microvm-execution-environments/tasks/48-microvm-lifecycle-cli-ux.md b/.claude/plans/microvm-execution-environments/tasks/48-microvm-lifecycle-cli-ux.md new file mode 100644 index 000000000..d5d44432a --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/48-microvm-lifecycle-cli-ux.md @@ -0,0 +1,18 @@ +--- +id: 48-microvm-lifecycle-cli-ux +title: Expose safe doctor, status, recovery, and deletion UX +blocked_by: [46-user-bootstrap-daemon-manager, 47-mecatui-microvm-first-run] +status: done +branch: "plan-microvm-execution-environments/48-microvm-lifecycle-cli-ux" +worktree: ".scratch/task-microvm-48" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Usability round + +Extend task 47's existing `mecatui microvm doctor|status|recover|delete` commands with authenticated daemon lifecycle behavior. Add a bounded owner-scoped inventory RPC to microvmd/client/manager rather than reading registry files directly. Status reports healthy/stale/error generations and worktree paths; recover restarts/reconciles but never creates a missing generation; confirmed permanent delete reports clean removal or retained dirty worktree. TUI close remains detach-only and says so. Preserve exact owner/session/ref/generation binding and noninteractive destructive guards. + +Verification: authenticated owner-filtered inventory; status healthy/stale/error; exact recovery/no fallback; destructive confirmation; dirty retention path; actionable errors; existing task-47 command/help tests remain green. diff --git a/.claude/plans/microvm-execution-environments/tasks/49-first-run-empty-xdg-e2e.md b/.claude/plans/microvm-execution-environments/tasks/49-first-run-empty-xdg-e2e.md new file mode 100644 index 000000000..6b777a6c5 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/49-first-run-empty-xdg-e2e.md @@ -0,0 +1,18 @@ +--- +id: 49-first-run-empty-xdg-e2e +title: Prove an empty-XDG first-run microVM journey +blocked_by: [45-oci-default-guest-image, 47-mecatui-microvm-first-run, 48-microvm-lifecycle-cli-ux, 50-default-oci-release-contract] +status: done +branch: "plan-microvm-execution-environments/49-first-run-empty-xdg-e2e" +worktree: ".scratch/task-microvm-49" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Usability round + +Extend real-hypervisor E2E from an empty XDG home: explicit init/confirmation, verified OCI default image install, config generation, daemon start/doctor, `mecatui --microvm` profile session, guest edit visible only in prepared worktree, detach, daemon restart, clean delete, dirty delete retention, status/recover outputs. On daemon restart, prove the documented exact-generation convergence: securely reattach when all runtime/network identity is recoverable, otherwise identity-check and destroy the orphan while retaining the session worktree and returning an actionable no-replacement recovery result. Never require or permit silent empty-VM recreation. Include negative trust/preflight/no-fallback controls. + +Verification: local Linux KVM full first-run covers the actual convergence branch, retained edits/recovery instructions, and a subsequent explicitly-created session when destruction was required; CI platform contracts; ordinary tests remain offline; all gates/ac-trace. diff --git a/.claude/plans/microvm-execution-environments/tasks/50-default-oci-release-contract.md b/.claude/plans/microvm-execution-environments/tasks/50-default-oci-release-contract.md new file mode 100644 index 000000000..c5d24fe43 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/50-default-oci-release-contract.md @@ -0,0 +1,18 @@ +--- +id: 50-default-oci-release-contract +title: Publish and install the default digest-pinned OCI guest image +blocked_by: [45-oci-default-guest-image] +status: done +branch: "plan-microvm-execution-environments/50-default-oci-release-contract" +worktree: ".scratch/task-microvm-50" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Usability repair + +Connect the task45 guest-tools OCI image to the release/bootstrap contract. Release publishing must produce the multi-arch image first, capture immutable per-platform manifest digests, and include the correct digest-pinned OCI reference plus Sigstore/provenance/tree policy in each platform release manifest. Installer must project OCI artifacts without requiring a local execution-image archive/path. `mecatui microvm init` must have a versioned default release channel/platform manifest and `microvm-local` guest image, with explicit override support and no mutable tags. + +Verification: workflow ordering/digest capture; installer OCI projection; init defaults nonempty and strict; tag rejection; release fixture bootstrap and local live pull; full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/51-operator-first-run-docs.md b/.claude/plans/microvm-execution-environments/tasks/51-operator-first-run-docs.md new file mode 100644 index 000000000..ec3f3f807 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/51-operator-first-run-docs.md @@ -0,0 +1,18 @@ +--- +id: 51-operator-first-run-docs +title: Add copy-paste operator quickstart, config, services, and troubleshooting +blocked_by: [49-first-run-empty-xdg-e2e] +status: done +branch: "plan-microvm-execution-environments/51-operator-first-run-docs" +worktree: ".scratch/task-microvm-51" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Documentation round + +Turn `docs/usage/microvm-environments.md` into a first-run operator runbook grounded in the shipped commands/defaults. Include the shortest `mecatui microvm init` and daily `mecatui --microvm` path, one complete valid daemon JSON/reference-generated config explanation, XDG paths/modes, installed release/OCI trust flow, daemon foreground/start/reuse behavior, doctor/status/recover/delete commands, source/worktree/guest inspection, detach/delete semantics, and explicit current limits. Add systemd user and launchd examples only if they match actual foreground process behavior and path requirements. Add a symptom→diagnosis→command/remediation table for KVM, HVF, socket paths/permissions, Sigstore/evidence, stale generations, quota, retained dirty worktrees, egress, OCI/platform errors. + +Verify every command/flag/config field against code and tests; no invented commands. Run docs/configref/matlatl/site gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/52-public-docs-adr-polish.md b/.claude/plans/microvm-execution-environments/tasks/52-public-docs-adr-polish.md new file mode 100644 index 000000000..381d5220c --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/52-public-docs-adr-polish.md @@ -0,0 +1,18 @@ +--- +id: 52-public-docs-adr-polish +title: Make public microVM onboarding concise and fix lifecycle references +blocked_by: [49-first-run-empty-xdg-e2e] +status: done +branch: "plan-microvm-execution-environments/52-public-docs-adr-polish" +worktree: ".scratch/task-microvm-52" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Documentation round + +Update public user docs to lead with a minimal first-run→daily-use→recovery journey, safe defaults, what remains on host, where edits live, and links to the operator runbook. Fix stale ADR links/numbers and ensure navigation reaches the page. Reconcile ADR 0224 metadata/status and point-in-time version references with the landed decision (without rewriting accepted rationale beyond status/link/version corrections allowed before acceptance). Ensure README/architecture/usage/config reference/acceptance/IMPLEMENTATION-NOTES and user docs use consistent command names, alias, paths, OCI terminology, platform matrix, and schedule/remote limitations. + +Run task docs, site build, citation/link checks; report any implementation-doc mismatch rather than documenting nonexistent behavior. diff --git a/.claude/plans/microvm-execution-environments/tasks/53-daemon-identity-managed-restart.md b/.claude/plans/microvm-execution-environments/tasks/53-daemon-identity-managed-restart.md new file mode 100644 index 000000000..03f7f76a0 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/53-daemon-identity-managed-restart.md @@ -0,0 +1,18 @@ +--- +id: 53-daemon-identity-managed-restart +title: Bind bootstrap reuse to daemon binary and policy identity +blocked_by: [] +status: done +branch: "96ccd68b" +worktree: ".scratch/task-microvm-53" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Usability/security repair + +Add authenticated daemon-info/readiness RPC returning protocol version, release/binary identity, loaded config digest/policy revision, profiles, and socket binding. Init may reuse only an exact compatible match; otherwise safely restart the exact managed process or fail with service-manager instructions before enabling alias. Persist/verify PID plus process-start identity and expected args/socket; pidfd on Linux where practical. Doctor must query serving daemon identity, not validate only new files. + +Verification: stale binary/policy daemon never enables alias; exact daemon reuses; managed restart identity-safe; PID reuse same binary rejected; owner isolation/full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/54-self-contained-release-bootstrap.md b/.claude/plans/microvm-execution-environments/tasks/54-self-contained-release-bootstrap.md new file mode 100644 index 000000000..0e596a2bd --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/54-self-contained-release-bootstrap.md @@ -0,0 +1,18 @@ +--- +id: 54-self-contained-release-bootstrap +title: Make the release bootstrap self-contained on every host platform +blocked_by: [] +status: done +branch: "1cc9a74c" +worktree: ".scratch/task-microvm-54" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Usability/security repair + +Use the installer from the digest-verified, safely extracted release bundle; remove the external installer requirement from normal init and bind any development override to explicit opt-in/digest. Publish signed/provenanced host `mecatui` binaries with embedded matching bootstrap defaults for Linux amd64/arm64 and Darwin arm64; keep OCI mecatui separate. Provide a copy-paste release download/bootstrap path that verifies manifest/binary before execution. + +Verification: empty host needs only downloaded signed mecatu binary/init; unrelated installer env ignored/rejected; platform release assets/provenance; action/full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/55-brood-base-lineage-provenance.md b/.claude/plans/microvm-execution-environments/tasks/55-brood-base-lineage-provenance.md new file mode 100644 index 000000000..f6a68e543 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/55-brood-base-lineage-provenance.md @@ -0,0 +1,18 @@ +--- +id: 55-brood-base-lineage-provenance +title: Derive guest-tools image from immutable Brood Box base +blocked_by: [] +status: done +branch: "b0d75fbe" +worktree: ".scratch/task-microvm-55" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Usability/supply-chain repair + +Make guest-tools mechanically derive from an immutable per-platform Brood Box base image or verified filesystem at a pinned revision/digest—not a label over independent Wolfi. Carry actual base manifest/build provenance into the mecatl image attestation and daemon admission. Retain UID65532 setup, guest-agent injection, no agent runtime/config/credentials, and multi-arch tooling smoke. If Brood Box lacks signed immutable base publication, add the minimal upstream release dependency rather than pretending lineage. + +Verification: FROM/input digest is Brood base, provenance correlates exact platform manifest/revision, package/tool compatibility and secret absence, tag rejection/full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/56-paginated-lifecycle-inventory.md b/.claude/plans/microvm-execution-environments/tasks/56-paginated-lifecycle-inventory.md new file mode 100644 index 000000000..32220c600 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/56-paginated-lifecycle-inventory.md @@ -0,0 +1,18 @@ +--- +id: 56-paginated-lifecycle-inventory +title: Paginate lifecycle inventory and recovery status +blocked_by: [] +status: done +branch: "e45e93e0" +worktree: ".scratch/task-microvm-56" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Usability repair + +Replace the 256-lifetime-record failure with deterministic owner-scoped pagination and opaque continuation token across daemon protocol, root client, manager status/recover, and mecatui output. Bound page/message size; include retained destroyed dirty-worktree records; prevent cross-owner/token tampering; recover iterates safely without unbounded memory. + +Verification: >256 records fully discoverable/deletable, deterministic pages, invalid/foreign tokens fail, bounded manager/TUI output and continuation, full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/57-first-run-dead-end-repair.md b/.claude/plans/microvm-execution-environments/tasks/57-first-run-dead-end-repair.md new file mode 100644 index 000000000..4ba928ba8 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/57-first-run-dead-end-repair.md @@ -0,0 +1,18 @@ +--- +id: 57-first-run-dead-end-repair +title: Remove first-run and recovery UX dead ends +blocked_by: [] +status: done +branch: "c2b7a451" +worktree: ".scratch/task-microvm-57" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# UX repair + +Fix review findings: pre-install failures say correct cause + rerun init, not unusable recover; recovery docs/commands require explicit session resume and never claim plain `--microvm` reopens; `--microvm --resume` rejects host-local sessions; init confirmation displays exact resolved paths/resources/egress/trust/download; validate delete target before confirmation; reject `--microvm` combined with explicit `--environment-profile`; public quickstart obtains the self-contained verified release binary/manifest with copy-paste commands. + +Verification: each dead-end/error path, resume environment validation, path summary, flag conflict, delete prompt ordering, docs commands/full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/58-final-first-run-usability-e2e.md b/.claude/plans/microvm-execution-environments/tasks/58-final-first-run-usability-e2e.md new file mode 100644 index 000000000..dfa0b6340 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/58-final-first-run-usability-e2e.md @@ -0,0 +1,18 @@ +--- +id: 58-final-first-run-usability-e2e +title: Prove self-contained first-run usability and documentation +blocked_by: [53-daemon-identity-managed-restart, 54-self-contained-release-bootstrap, 55-brood-base-lineage-provenance, 56-paginated-lifecycle-inventory, 57-first-run-dead-end-repair] +status: done +branch: "5db272d8" +worktree: ".scratch/task-microvm-58" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Final usability proof + +From empty host/XDG using only published signed mecatu binary/release defaults: init, confirmation, Brood-derived OCI pull/admission, daemon identity match, doctor, daily --microvm session, edits/paths, detach/resume, restart convergence, paginated status, recover, clean/dirty delete. Exercise stale daemon, bad installer/evidence/base provenance, host-session resume, and >256 records. Ensure docs exactly match commands. + +Verification: local KVM full production journey, CI contracts, UX/security reviewers zero blockers, all docs/site/full gates. diff --git a/.claude/plans/microvm-execution-environments/tasks/59-manager-ensure-ready-surface.md b/.claude/plans/microvm-execution-environments/tasks/59-manager-ensure-ready-surface.md new file mode 100644 index 000000000..f3ed2eb12 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/59-manager-ensure-ready-surface.md @@ -0,0 +1,32 @@ +--- +id: 59-manager-ensure-ready-surface +title: Replace the separate microVM mode with ordinary idempotent readiness +blocked_by: [] +status: done +branch: "plan-microvm-execution-environments/59-manager-ensure-ready-surface" +worktree: "" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Manager and activation redesign + +Replace the dedicated activation/bootstrap state machine with built-in `microvm-local` +selection through ordinary configuration. Add inter-process-locked idempotent +`EnsureReady`, preserve desired config on failure, remove `--microvm`, init, and recover, +and retain status/doctor/delete entry points. Keep non-microVM behavior unchanged. + +## Acceptance criteria + +- AC1.1: Selecting `microvm-local` through ordinary configuration runs idempotent `EnsureReady` before the first microVM session, while selecting another profile does not start or probe microvmd. + - verify: `TestMicroVMRedesign_Scenario1_ProfileSelectionEnsuresReady` +- AC1.2: Concurrent startup or first-use calls are serialized by the inter-process manager lock and converge on one compatible daemon and configuration. + - verify: `TestMicroVMRedesign_Scenario1_EnsureReadyConvergesUnderManagerLock` +- AC1.3: A failed readiness attempt returns an actionable error without enabling, disabling, or rewriting the operator's desired `environment_profile`; repeating ordinary use retries readiness. + - verify: `TestMicroVMRedesign_Scenario1_ReadinessNeverRewritesDesiredConfig` +- AC1.4: The dedicated `--microvm` flag and required `microvm init` and `microvm recover` commands are absent; `status`, `doctor`, and `delete` remain available and nonduplicative. + - verify: `TestMicroVMRedesign_Scenario1_ObsoleteActivationAndRecoverySurfaceIsRemoved` +- AC1.5: With no microVM profile selected, existing default and no-fs snapshots, catalogs, prompts, startup, and tool behavior remain byte-compatible and no microVM dependency enters the engine module. + - verify: `TestInvariant_microvm_disabled_is_byte_compatible` diff --git a/.claude/plans/microvm-execution-environments/tasks/60-brood-admission-inprocess-verifier.md b/.claude/plans/microvm-execution-environments/tasks/60-brood-admission-inprocess-verifier.md new file mode 100644 index 000000000..4f307b9a8 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/60-brood-admission-inprocess-verifier.md @@ -0,0 +1,28 @@ +--- +id: 60-brood-admission-inprocess-verifier +title: Admit immutable Brood platform digests with in-process Sigstore verification +blocked_by: [59-manager-ensure-ready-surface] +status: done +branch: "plan-microvm-execution-environments/60-brood-admission-inprocess-verifier" +worktree: "" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Artifact trust redesign + +Use Brood base latest only as controlled discovery, resolve and endorse the immutable +platform digest, remove the Brood rebuild and derived-image lineage, and replace runtime +cosign execution/config/temp files with `toolhive-core/container/verifier` in-process. +Keep downstream mecatl endorsement authoritative while upstream signing is deferred. + +## Acceptance criteria + +- AC4.1: Controlled release/admission resolves Brood base `latest` to an immutable platform manifest digest, records the mutable discovery reference and resolution evidence, and admits runtime use only under a valid mecatl downstream endorsement. + - verify: `TestMicroVMRedesign_Scenario4_BroodLatestIsDiscoveryOnly` +- AC4.2: Runtime boots the admitted Brood platform bytes directly without a Brood rebuild or derived guest-tools image, and a changed resolution, wrong platform, missing endorsement, stale policy, or corrupted subject fails before VM execution. + - verify: `TestMicroVMRedesign_Scenario4_RuntimeConsumesOnlyEndorsedBroodDigest` +- AC4.5: Runtime Sigstore verification uses `toolhive-core/container/verifier` in-process and production configuration exposes no cosign executable/path, verification subprocess, or verification temporary-file protocol. + - verify: `TestMicroVMRedesign_Scenario4_SigstoreVerificationIsInProcess` diff --git a/.claude/plans/microvm-execution-environments/tasks/61-repository-rootfs-guest-contract.md b/.claude/plans/microvm-execution-environments/tasks/61-repository-rootfs-guest-contract.md new file mode 100644 index 000000000..5f38e3fb5 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/61-repository-rootfs-guest-contract.md @@ -0,0 +1,29 @@ +--- +id: 61-repository-rootfs-guest-contract +title: Provide the static repository rootfs primitive and guest contract +blocked_by: [60-brood-admission-inprocess-verifier] +status: done +branch: "plan-microvm-execution-environments/61-repository-rootfs-guest-contract" +worktree: "" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Repository rootfs and guest setup + +Provide the primitive that clones admitted Brood bytes, injects the independently +verified guest agent, and establishes static HOME, PATH, workdir, writable home, +package/tool cache, and UID/GID behavior independent of the base image entrypoint. +The primitive contains no environment binding or capability key. Task 62 owns its +repository-generation lifecycle and the proof that sessions and children do not clone it. + +## Acceptance criteria + +- Enabling primitive: One retained materializer clones admitted Brood bytes once, + injects the independently verified guest agent, establishes only static guest setup, + and rejects a second materialization. + - verify: `TestRepositoryRootFSMaterializer_ClonesStaticRootFSAndInjectsGuestAgentOnce` +- AC4.4: Guest setup explicitly establishes workload UID/GID 65532, HOME, PATH, default workdir, writable home, and declared package/tool caches independent of Brood's image command or entrypoint. + - verify: `TestMicroVMRedesign_Scenario4_GuestRuntimeContractIsExplicit` diff --git a/.claude/plans/microvm-execution-environments/tasks/62-repository-vm-lifecycle.md b/.claude/plans/microvm-execution-environments/tasks/62-repository-vm-lifecycle.md new file mode 100644 index 000000000..c91df4931 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/62-repository-vm-lifecycle.md @@ -0,0 +1,29 @@ +--- +id: 62-repository-vm-lifecycle +title: Establish canonical repository identity and a durable singleton VM/rootfs registry +blocked_by: [61-repository-rootfs-guest-contract] +status: done +branch: "plan-microvm-execution-environments/62-repository-vm-lifecycle" +worktree: "" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Repository identity and singleton lifecycle + +Reset the earlier in-progress task. Canonicalize `(operator, Git common directory)` into an +owner-confined repository identity and provide a descriptor-confined durable singleton +registry under inter-process locking. The registry owns one rootfs materializer per key and +concurrent primitive use converges. Keep this task below production VM boot and the guest +data plane: task 63 owns repository-VM startup/routing, and task 64 owns live reattachment. + +## Acceptance criteria + +- AC2.1: Canonically equivalent checkouts for one local operator resolve to one repository identity and one VM generation, while a different Git common directory or operator resolves to a different durable registry entry. + - verify: `TestMicroVMMVP_Scenario2_CanonicalRepositoryIdentitySelectsSingletonVM` +- AC2.3: Repository-controlled names, symlinks, linked-worktree metadata, and hostile path components cannot collide registry identities or escape the owner-scoped state root. + - verify: `TestMicroVMMVP_Scenario2_RepositoryIdentityIsCanonicalAndConfined` +- Enabling primitive: concurrent registry use converges on one durable record and invokes one retained rootfs materializer for the validated canonical identity. + - verify: `TestRepositoryVMRegistryConcurrentEnsureConvergesAndPersistsRecord`, `TestRepositoryVMRegistryMaterializesRootFSOnce` diff --git a/.claude/plans/microvm-execution-environments/tasks/63-logical-worktree-protocol.md b/.claude/plans/microvm-execution-environments/tasks/63-logical-worktree-protocol.md new file mode 100644 index 000000000..5a01d54c5 --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/63-logical-worktree-protocol.md @@ -0,0 +1,35 @@ +--- +id: 63-logical-worktree-protocol +title: Route authenticated logical worktrees through the repository VM +blocked_by: [62-repository-vm-lifecycle] +status: done +branch: "plan-microvm-execution-environments/63-logical-worktree-protocol" +worktree: "" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Logical worktree routing + +Make the daemon start the repository VM from the singleton registry's one materialized +rootfs, using repository-scoped boot authority and authenticated endpoint health rather +than a session-bound preboot file. Create distinct Git worktrees and register each assigned +guest root against one opaque logical `EnvironmentRef` in that VM. Authenticate every +new channel under repository boot authority before host disclosure, then authenticate +registration, handshake, filesystem, and exec against owner, generation, ref, and root. +Preserve Workspace/runner affinity, versioned mutations, streaming, cancellation, and no +host fallback. Treat same-repository worktrees as logical Git/RPC isolation rather than +mutually hostile Bash sandboxes. Session/delegation composition follows in task 64. + +## Acceptance criteria + +- AC3.1: Two logical environments in one repository receive distinct daemon-created worktrees, branches, indexes, logical `EnvironmentRef`s, and guest roots while naming the same repository VM generation. + - verify: `TestMicroVMMVP_Scenario3_LogicalEnvironmentsShareVMNotWorktree` +- AC3.2: A newly accepted vsock/Unix peer proves possession of the repository boot authority before the host sends any binding capability, registration, request, or secret; the challenge binds owner, repository/VM identity, VM generation, and control/data channel purpose, so connection order grants no identity. Guest registration, handshake, and every filesystem or exec request then authenticate the logical `EnvironmentRef` and assigned root before dispatch. + - verify: `TestRepositoryGuestAuthenticationRejectsCompetingConnectorBeforeDisclosure`, `TestMicroVMMVP_Scenario3_EnvironmentRefAuthenticatesAssignedRoot` +- AC3.3: Stale, replayed, wrong-owner, wrong-generation, sibling-ref, path-escape, and cross-worktree protocol requests fail closed; Workspace and CommandRunner remain affined to the same assigned root/cwd with no host fallback. Git worktrees isolate working/index state and logical RPC routing, not mutually hostile same-repository Bash: arbitrary Bash may address sibling guest paths. Different repositories remain VM-isolated and host/other-repository paths remain unavailable. + - verify: `TestInvariant_microvm_logical_environment_is_confined_and_affined` +- AC4.3: One materializer is owned by each repository VM generation; the independently verified guest agent is injected into that private rootfs, and creating sessions or children performs no additional rootfs clone or copy. + - verify: `TestMicroVMMVP_Scenario4_RepositoryGenerationOwnsSingleRootFS` diff --git a/.claude/plans/microvm-execution-environments/tasks/64-session-attachment-delegation.md b/.claude/plans/microvm-execution-environments/tasks/64-session-attachment-delegation.md new file mode 100644 index 000000000..f8198d05d --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/64-session-attachment-delegation.md @@ -0,0 +1,34 @@ +--- +id: 64-session-attachment-delegation +title: Attach sessions and delegated children to repository-scoped execution +blocked_by: [63-logical-worktree-protocol] +status: done +branch: "plan-microvm-execution-environments/64-session-attachment-delegation" +worktree: "" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Session attachment and delegation + +Wire session creation and reattachment to reuse the canonical repository VM while allocating +a distinct logical ref and worktree. Keep direct-write children on the parent Environment +and allocate distinct worktrees for isolated Subagents, Parallel branches, and Team members. +Close detaches without destroying shared state. Reuse the existing basic isolated-child +merge behavior; do not add daemon-wide cross-process serialization or crash-durable merge +recovery. + +## Acceptance criteria + +- AC2.2: Concurrent first use durably converges on one VM/rootfs record for the canonical key; an exact healthy generation reattaches only while every owned dependency remains live in-process, while daemon restart or other missing/inconsistent runtime state fails loudly without minting a replacement and preserves the durable record/rootfs/worktrees. + - verify: `TestMicroVMMVP_Scenario2_DurableSingletonRegistryReattachesOrFailsLoudly` +- AC5.1: Sessions in one repository attach to the same repository VM with distinct logical refs and worktrees; a direct-write child reuses its parent's logical Environment, while a read-only Subagent, Parallel branch, or Team member receives a distinct logical ref and worktree in that VM. + - verify: `TestMicroVMMVP_Scenario5_SessionsAndChildrenReuseRepositoryVM` +- AC5.2: Closing a session or child detaches its process-local handles without destroying the repository VM, rootfs, shared cache, or another attached logical environment. + - verify: `TestMicroVMMVP_Scenario5_CloseDetachesWithoutDestroyingRepositoryVM` +- AC5.3: The existing isolated-child merge path applies a non-conflicting child change and preserves the child on conflict; the MVP makes no cross-process serialization or crash-recovery claim. + - verify: `TestMicroVMMVP_Scenario5_BasicExistingMergeBehavior` +- AC5.4: Production status inventories repository logical attachments—not the superseded session-per-VM registry—in deterministic pages of at most 64, showing two distinct logical worktrees on one repository generation; exact logical deletion removes clean state, retains dirty state for recovery, and never implies repository-VM deletion. + - verify: `TestRepositoryProductionInventoryPaginationAndLogicalDelete` diff --git a/.claude/plans/microvm-execution-environments/tasks/65-linux-amd64-mvp-journey.md b/.claude/plans/microvm-execution-environments/tasks/65-linux-amd64-mvp-journey.md new file mode 100644 index 000000000..9ccd17abb --- /dev/null +++ b/.claude/plans/microvm-execution-environments/tasks/65-linux-amd64-mvp-journey.md @@ -0,0 +1,35 @@ +--- +id: 65-linux-amd64-mvp-journey +title: Deliver the usable Linux amd64 repository-microVM journey +blocked_by: [59-manager-ensure-ready-surface, 60-brood-admission-inprocess-verifier, 61-repository-rootfs-guest-contract, 64-session-attachment-delegation] +status: done +branch: "plan-microvm-execution-environments/65-linux-amd64-mvp-journey" +worktree: "" +issue: "526" +retries: 0 +last_error: "" +accumulator: acc/microvm-execution-environments +--- + +# Linux amd64 MVP journey + +Complete only the operator-visible Linux amd64 path. Preserve private host ownership with +an unprivileged user namespace, default to honest unrestricted IPv4 guest egress while +leaving the guest IPv6 stack enabled but externally unrouted/unsupported, and retain optional +fail-closed tightening. Prove permissive networking in the live journey; prove optional tightening +through AC6.2's production app/profile and network enforcement tests. The live journey also proves +ordinary first use, direct admitted Brood and in-process +verification, one repository VM/rootfs, two sessions sharing a declared cache but not a +worktree, confined filesystem/exec, close-detach, network behavior, and a prompt loud daemon- +restart failure that preserves the generation record, rootfs, and worktrees without replacement. Keep documentation concise and list every deferred surface. + +## Acceptance criteria + +- AC6.1: On Linux amd64, guest workload UID/GID 65532 maps to the daemon user through an unprivileged user namespace, model commands run unprivileged, and session or child worktrees are never widened to world-readable, world-writable, or world-traversable modes. + - verify: `TestMicroVMMVP_Scenario6_LinuxUserNamespaceAvoidsWorldModeWidening` +- AC6.2: The built-in hosted profile provides unrestricted IPv4 guest egress by default and reports that the IPv6 stack remains enabled while external IPv6 is unrouted and unsupported; optional deny-all or allowlist tightening filters IPv4, disables IPv6, and aborts readiness rather than falling back when either enforcement step fails. + - verify: `TestMicroVMMVP_Scenario6_PermissiveIPv4WithOptionalFailClosedTightening` +- AC7.1: Linux amd64 KVM enters through production profile/session composition and proves ordinary first use, direct admitted Brood consumption with in-process verification, one repository VM/rootfs, two sessions with a shared declared cache and distinct worktrees, confined filesystem and exec, unrestricted IPv4 networking with external IPv6 unsupported, close-detach, and prompt explicit daemon-restart failure that preserves the record/rootfs/worktrees and mints no replacement. Optional fail-closed tightening is proven separately by AC6.2's production app/profile and network enforcement tests, not by this live journey. + - verify: demonstration — `task e2e:microvm` is the required Linux amd64 KVM live gate +- AC7.2: Concise architecture, operator, and public documentation describes profile selection, the repository sharing boundary, distinct worktrees, direct Brood admission, Linux ownership, permissive networking, optional tightening, restart failure behavior, and the deferred surfaces without claiming Linux arm64 or macOS live support. + - verify: inspection — `task docs` and `task site:build` prove the linked documentation surfaces build diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000..23d45d1a9 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +self-hosted-runner: + labels: + - microvm-hvf + - microvm-kvm + +config-variables: null diff --git a/.github/scripts/install-microvm-release.sh b/.github/scripts/install-microvm-release.sh new file mode 100755 index 000000000..33c36d39e --- /dev/null +++ b/.github/scripts/install-microvm-release.sh @@ -0,0 +1,151 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "usage: $0 RELEASE_MANIFEST INSTALL_ROOT" >&2 + exit 2 +fi +manifest=$1 +install=$2 +case "$manifest" in /*) ;; *) manifest=$(CDPATH= cd -- "$(dirname -- "$manifest")" && pwd)/$(basename -- "$manifest") ;; esac +assets=$(dirname -- "$manifest") +manifest_name=$(basename -- "$manifest") +case "$manifest_name" in + microvm-release-linux-amd64.json) platform=linux-amd64 ;; + microvm-release-linux-arm64.json) platform=linux-arm64 ;; + microvm-release-darwin-arm64.json) platform=darwin-arm64 ;; + *) echo "unsupported microVM release manifest name: $manifest_name" >&2; exit 2 ;; +esac +digest_tool="$assets/mecatl-artifact-digest-$platform" +test -f "$digest_tool" && test -x "$digest_tool" && test ! -L "$digest_tool" +mkdir -p "$install/artifacts" +install=$(CDPATH= cd -- "$install" && pwd) +records="$install/.admission-artifacts" + +python3 - "$manifest" >"$records" <<'PY' +import json, os, re, sys +with open(sys.argv[1], encoding="utf-8") as stream: + manifest = json.load(stream) +if manifest.get("schema") != "mecatl-microvm-release/v2": + raise SystemExit("unsupported microVM release manifest schema") +artifacts = manifest.get("admission_artifacts") +if not isinstance(artifacts, list) or {a.get("kind") for a in artifacts} != {"runtime", "firmware", "execution-image", "guest-agent"}: + raise SystemExit("release manifest must contain exactly all four admission artifact kinds") +for artifact in artifacts: + kind = artifact.get("kind", "") + reference = artifact.get("reference", "") + digest = artifact.get("digest", "") + provenance = artifact.get("provenance", "") + bundle = artifact.get("sigstore_bundle", "") + if any(not isinstance(v, str) or not v for v in (kind, reference, digest, provenance, bundle)): + raise SystemExit(f"incomplete {kind or 'unknown'} admission artifact") + if any(os.path.basename(v) != v for v in (provenance, bundle)): + raise SystemExit(f"unsafe release asset name for {kind}") + if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise SystemExit(f"invalid tree digest for {kind}") + if kind == "execution-image": + payload = artifact.get("payload", "") + payload_digest = artifact.get("payload_digest", "") + manifest_digest = artifact.get("manifest_digest", "") + discovery_reference = artifact.get("discovery_reference", "") + resolution_evidence = artifact.get("resolution_evidence", "") + platform = artifact.get("platform", "") + if payload or payload_digest: + raise SystemExit("OCI execution-image must not require a local payload") + if not re.fullmatch(r"[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}", reference): + raise SystemExit("execution-image reference must be canonical repo@sha256, never a tag") + if not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest_digest) or not reference.endswith("@" + manifest_digest): + raise SystemExit("execution-image manifest digest does not match its OCI reference") + if discovery_reference != "ghcr.io/stacklok/brood-box/base:latest": + raise SystemExit("execution-image discovery reference is not the controlled Brood base latest") + if not re.fullmatch(r"sha256:[0-9a-f]{64}", resolution_evidence): + raise SystemExit("execution-image resolution evidence is invalid") + if platform not in ("linux/amd64", "linux/arm64"): + raise SystemExit("execution-image platform is invalid") + payload = payload_digest = "-" + else: + payload = artifact.get("payload", "") + payload_digest = artifact.get("payload_digest", "") + manifest_digest = "-" + if not isinstance(payload, str) or not payload or os.path.basename(payload) != payload: + raise SystemExit(f"unsafe release payload for {kind}") + if not re.fullmatch(r"sha256:[0-9a-f]{64}", payload_digest): + raise SystemExit(f"invalid payload digest for {kind}") + if not reference.endswith("@" + digest): + raise SystemExit(f"mutable reference for {kind}") + print("\t".join((kind, payload, payload_digest, reference, digest, manifest_digest, provenance, bundle))) +PY + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi +} + +while IFS=" " read -r kind payload payload_digest reference digest manifest_digest provenance bundle; do + if [ "$payload" = "-" ]; then + test "$kind" = execution-image + continue + fi + archive="$assets/$payload" + test "sha256:$(sha256_file "$archive")" = "$payload_digest" + destination="$install/artifacts/$kind" + rm -rf "$destination" + mkdir -p "$destination" + python3 - "$archive" "$destination" <<'PY' +import posixpath, sys, tarfile + +with tarfile.open(sys.argv[1], "r:gz") as archive: + members = archive.getmembers() + symlinks = set() + for member in members: + name = posixpath.normpath(member.name) + if posixpath.isabs(member.name) or name == ".." or name.startswith("../"): + raise SystemExit(f"unsafe archive member: {member.name}") + if not (member.isdir() or member.isfile() or member.issym() or member.islnk()): + raise SystemExit(f"unsupported archive member type: {member.name}") + if member.issym(): + symlinks.add(name) + if member.islnk(): + target = posixpath.normpath(posixpath.join(posixpath.dirname(name), member.linkname)) + if posixpath.isabs(member.linkname) or target == ".." or target.startswith("../"): + raise SystemExit(f"unsafe hard link: {member.name}") + for member in members: + name = posixpath.normpath(member.name) + parent = posixpath.dirname(name) + while parent not in ("", "."): + if parent in symlinks: + raise SystemExit(f"archive member traverses symlink: {member.name}") + parent = posixpath.dirname(parent) + # The release tree digest includes executable and special permission bits, so + # filtering modes would change the signed subject. The complete preflight above + # confines every write and prevents archive-order symlink traversal; extraction + # can therefore preserve the exact packaged tree identity. + archive.extractall(sys.argv[2], filter="fully_trusted") +PY + actual=$("$digest_tool" "$destination") + test "$actual" = "$digest" +done <"$records" + +python3 - "$manifest" "$assets" "$install" >"$install/microvmd-artifacts.json" <<'PY' +import json, os, sys +with open(sys.argv[1], encoding="utf-8") as stream: + manifest = json.load(stream) +assets, install = sys.argv[2:] +artifacts = [] +for item in manifest["admission_artifacts"]: + kind = item["kind"] + is_oci = kind == "execution-image" + artifacts.append({ + "kind": kind, + "reference": item["reference"], + "digest": item["digest"], + "manifest_digest": item.get("manifest_digest", ""), + "discovery_reference": item.get("discovery_reference", ""), + "resolution_evidence": item.get("resolution_evidence", ""), + "platform": item.get("platform", ""), + "path": "" if is_oci else os.path.join(install, "artifacts", kind), + "provenance": os.path.join(assets, item["provenance"]), + "sigstore_bundle": os.path.join(assets, item["sigstore_bundle"]), + }) +json.dump({"schema": "mecatl-microvmd-artifacts/v1", "artifacts": artifacts}, sys.stdout, separators=(",", ":")) +PY +rm -f "$records" diff --git a/.github/scripts/lint-vet-workflow_test.sh b/.github/scripts/lint-vet-workflow_test.sh index 98f3cc8a6..a0024f782 100644 --- a/.github/scripts/lint-vet-workflow_test.sh +++ b/.github/scripts/lint-vet-workflow_test.sh @@ -48,6 +48,7 @@ expected_vet_script=( 'go vet ./...' '(cd engine && go vet ./...)' '(cd authn/oidc && go vet ./...)' + '(cd environment/microvm && go vet ./...)' '(cd provider/ssefilter && go vet ./...)' '(cd provider/anthropic && go vet ./...)' '(cd provider/openai && go vet ./...)' diff --git a/.github/scripts/microvm-ci-release_test.sh b/.github/scripts/microvm-ci-release_test.sh new file mode 100644 index 000000000..9e419dc03 --- /dev/null +++ b/.github/scripts/microvm-ci-release_test.sh @@ -0,0 +1,545 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +taskfile="$repo_root/Taskfile.yml" +ci="$repo_root/.github/workflows/ci.yml" +e2e="$repo_root/.github/workflows/microvm-e2e.yml" +release="$repo_root/.github/workflows/release.yml" +package="$repo_root/.github/scripts/package-microvm-release.sh" +sign="$repo_root/.github/scripts/sign-microvm-release-evidence.sh" +install="$repo_root/.github/scripts/install-microvm-release.sh" +validate_release_ref="$repo_root/.github/scripts/validate-release-ref.sh" +reuse_platform_assets="$repo_root/.github/scripts/reuse-platform-release-assets.sh" + +require() { + pattern=$1 + file=$2 + if ! grep -F -- "$pattern" "$file" >/dev/null; then + echo "missing '$pattern' in ${file#"$repo_root/"}" >&2 + exit 1 + fi +} + +forbid() { + pattern=$1 + file=$2 + if grep -F -- "$pattern" "$file" >/dev/null; then + echo "forbidden '$pattern' in ${file#"$repo_root/"}" >&2 + exit 1 + fi +} + +# Ordinary PR CI must cross the nested-module boundary at every relevant gate. +require 'environment/microvm/go.sum' "$ci" +require 'cd environment/microvm && go build ./...' "$ci" +require '(cd environment/microvm && bash "$GITHUB_WORKSPACE/.github/scripts/race-test.sh" microvm ./...)' "$ci" +require 'cd environment/microvm && golangci-lint run --config ../../.golangci.yml' "$ci" +require 'cd environment/microvm && go vet ./...' "$ci" +require 'name: MicroVM module (standalone, GOWORK=off)' "$ci" +require 'GOWORK: off' "$ci" + +# Hosted KVM repair makes the daemon runner's primary group the device-owning +# kvm group. The libkrun user namespace maps only the daemon's real UID/GID and +# drops supplementary groups, so a UID ACL or supplementary membership alone +# cannot authorize every parent/child runner process. The wrong-peer probe uses +# an explicitly separate UID/GID. +require 'sudo usermod --append --groups kvm "$(id -un)"' "$e2e" +require 'sg kvm -c' "$e2e" +require 'MECATL_MICROVM_E2E_KVM_GID="$(getent group kvm | cut -d: -f3)"' "$e2e" +require 'export MECATL_MICROVM_E2E_KVM_GID' "$e2e" +forbid 'export MECATL_MICROVM_E2E_KVM_GID="$(' "$e2e" +require 'expected_kvm_gid=${MECATL_MICROVM_E2E_KVM_GID}' "$e2e" +require 'test \"\$(id -g)\" = \"${MECATL_MICROVM_E2E_KVM_GID}\"' "$e2e" +require 'MECATL_MICROVM_E2E_WRONG_PEER_UID: "65534"' "$e2e" + +# Artifact preparation may need Docker but must not require KVM access before +# the explicit ACL refresh makes the device available to this runner. +preflight=$(awk '/^ - name: Explicit artifact preparation preflight$/{in_block=1; next} in_block && /^ - name: /{exit} in_block{print}' "$e2e") +refresh=$(awk '/^ - name: Refresh KVM access after artifact preparation$/{in_block=1; next} in_block && /^ - name: /{exit} in_block{print}' "$e2e") +printf '%s\n' "$preflight" | grep -Fx ' docker info >/dev/null' >/dev/null +printf '%s\n' "$preflight" | grep -Fx ' test -c /dev/kvm' >/dev/null +if printf '%s\n' "$preflight" | grep -E 'test -[rw] /dev/kvm|os\.open\("/dev/kvm"' >/dev/null; then + echo 'KVM access was required before the ACL refresh' >&2 + exit 1 +fi +printf '%s\n' "$refresh" | grep -Fx ' fd = os.open("/dev/kvm", os.O_RDWR | os.O_CLOEXEC)' >/dev/null +preflight_line=$(grep -n '^ - name: Explicit artifact preparation preflight$' "$e2e" | cut -d: -f1) +prepare_line=$(grep -n '^ - name: Prepare packaged microVM artifacts$' "$e2e" | cut -d: -f1) +refresh_line=$(grep -n '^ - name: Refresh KVM access after artifact preparation$' "$e2e" | cut -d: -f1) +cleanup_line=$(grep -n '^ - name: Reclaim preparation-only disk$' "$e2e" | cut -d: -f1) +live_line=$(grep -n '^ - name: Run pinned v0.0.40 real-hypervisor journey$' "$e2e" | cut -d: -f1) +test "$preflight_line" -lt "$prepare_line" +test "$prepare_line" -lt "$refresh_line" +test "$refresh_line" -lt "$cleanup_line" +test "$cleanup_line" -lt "$live_line" + +# Preparation materializes an Alpine fixture and the Brood resolver cache only to +# package and sign evidence. The live tests intentionally admit Brood again, and +# task e2e:microvm gives each default test a fresh run root and artifact cache, so +# reclaim only those two preparation trees and Docker's image/build cache here. +cleanup=$(awk '/^ - name: Reclaim preparation-only disk$/{in_block=1; next} in_block && /^ - name: /{exit} in_block{print}' "$e2e") +printf '%s\n' "$cleanup" | grep -F ' .scratch/microvm-e2e/linux-amd64/rootfs \' >/dev/null +printf '%s\n' "$cleanup" | grep -Fx ' .scratch/microvm-e2e/linux-amd64/oci/resolver-cache' >/dev/null +printf '%s\n' "$cleanup" | grep -Fx ' docker builder prune --all --force' >/dev/null +printf '%s\n' "$cleanup" | grep -Fx ' docker image prune --all --force' >/dev/null +test "$(printf '%s\n' "$cleanup" | grep -Fc '.scratch/microvm-e2e/linux-amd64/')" -eq 2 +for required in package runtime firmware guest-agent release-fixture VERIFIED oci/manifest.json oci/tree-digest; do + if printf '%s\n' "$cleanup" | grep -F ".scratch/microvm-e2e/linux-amd64/$required" >/dev/null; then + echo "cleanup deletes required prepared artifact: $required" >&2 + exit 1 + fi +done + +# The default live gate runs each evidence journey in isolation and discards its +# large run root/cache only after success. A failing journey remains intact, and +# an explicit regex override retains the single-invocation operator behavior. +require "DEFAULT_LIVE_TEST_NAMES='TestMicroVMDefaultPlacementDailyHarnessJourney'" "$taskfile" +require 'if [ -n "${MECATL_MICROVM_E2E_TESTS:-}" ]; then' "$taskfile" +require 'run_live_tests "${MECATL_MICROVM_E2E_TESTS}" "${RUN_ROOT_BASE}"' "$taskfile" +require 'for LIVE_TEST in ${DEFAULT_LIVE_TEST_NAMES}; do' "$taskfile" +require 'TEST_RUN_ROOT="${RUN_ROOT_BASE}/${LIVE_TEST}"' "$taskfile" +require 'run_live_tests "^${LIVE_TEST}$" "${TEST_RUN_ROOT}"' "$taskfile" +require 'rm -rf "${TEST_RUN_ROOT}"' "$taskfile" +forbid 'LIVE_TESTS="${MECATL_MICROVM_E2E_TESTS:-' "$taskfile" + +# The release job must package and publish the nested runtime payloads and evidence. +require 'package-microvm-release.sh' "$release" +require 'gh release create' "$release" +require 'needs: [validate-release-ref, create-release, endorse-brood-resolution]' "$release" +require 'needs: [validate-release-ref, create-release]' "$release" +require 'needs: [validate-release-ref, resolve-brood-base]' "$release" +require 'name: Validate immutable release tag ref' "$release" +require 'if [ "${GITHUB_REF}" != "${signing_ref}" ]; then' "$release" +validation_line=$(grep -n 'name: Require the run itself to use the requested tag ref' "$release" | head -n1 | cut -d: -f1) +first_checkout_line=$(grep -n 'uses: actions/checkout@' "$release" | head -n1 | cut -d: -f1) +test "$validation_line" -lt "$first_checkout_line" +require 'signing_ref: ${{ steps.validate.outputs.signing_ref }}' "$release" +assemble_bundle=$(awk '/^ - name: Assemble versioned platform bootstrap bundle$/{in_block=1; next} in_block && /^ - name: /{exit} in_block{print}' "$release") +test "$(printf '%s\n' "$assemble_bundle" | grep -Fc 'signing_ref="${{ needs.validate-release-ref.outputs.signing_ref }}"')" -eq 1 +require 'release.yml@${signing_ref}' "$release" +checkout_count=$(grep -c 'uses: actions/checkout@' "$release") +bound_checkout_count=$(grep -c 'ref: ${{ github.sha }}' "$release") +head_assertion_count=$(grep -c 'run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"' "$release") +test "$checkout_count" -eq "$bound_checkout_count" +test "$checkout_count" -eq "$head_assertion_count" +forbid 'ref: ${{ env.VERSION }}' "$release" +if "$validate_release_ref" v1.2.3 refs/heads/main >/dev/null 2>&1; then + echo 'branch-dispatched release tag input was accepted' >&2 + exit 1 +fi +signing_ref=$("$validate_release_ref" v1.2.3 refs/tags/v1.2.3) +test "$signing_ref" = refs/tags/v1.2.3 +require 'brood-endorsed-platforms' "$release" +forbid 'publish-guest-tools:' "$release" +forbid 'environment/microvm/images/guest-tools' "$release" +require 'docker buildx imagetools inspect' "$release" +require 'verify-brood-resolution.sh dist/brood/brood-base-index.json dist/brood/brood-platforms.json' "$release" +require 'brood-base-index.json' "$release" +require 'upload-release-assets.sh' "$release" +forbid '--clobber' "$release" +require 'manifest_digest' "$package" +require 'MICROVM_RELEASE_EXECUTION_IMAGE_REF' "$package" +require 'mecatl-microvm-${VERSION}-${PLATFORM}.tar.gz' "$release" +require 'mecatl-artifact-digest-$platform' "$package" +require 'mecatl-artifact-digest-${{ matrix.platform }}.spdx.json' "$release" +forbid 'go run ./cmd/mecatl-artifact-digest' "$install" +require 'MICROVM_RELEASE_DEFAULTS_B64' "$release" +require 'main.microVMReleaseDefaultsB64' "$repo_root/.ko.yaml" +require 'anchore/sbom-action@' "$release" +require 'sign-microvm-release-evidence.sh' "$release" +require 'reuse-platform-release-assets.sh' "$release" + +# Every release asset has one job-level producer. In particular, the microVM +# publisher must not recreate host binaries owned by publish-mecatui-host. +publish_microvm_section=$(awk '/^ publish-microvm:/{on=1} /^ resolve-brood-base:/{on=0} on' "$release") +publish_host_section=$(awk '/^ publish-mecatui-host:/{on=1} /^ publish-mecatui:/{on=0} on' "$release") +if printf '%s\n' "$publish_microvm_section" | grep -F '${binary_name}-${VERSION}-${platform}' >/dev/null; then + echo 'host release asset has multiple producers' >&2 + exit 1 +fi +printf '%s\n' "$publish_host_section" | grep -F '${binary_name}-${VERSION}-${platform}' >/dev/null +printf '%s\n' "$publish_host_section" | grep -F 'binary: [mecated, mecatui]' >/dev/null +printf '%s\n' "$publish_host_section" | grep -F 'main.microVMReleaseVersion' >/dev/null +printf '%s\n' "$publish_host_section" | grep -F 'main.microVMReleaseStampRequired=release' >/dev/null +require '[ "${PLATFORM}" != linux-amd64 ] && [ "$(basename "${asset}")" = install-microvm-release.sh ]' "$release" + +# Functional host-stamp/entrypoint contract: both real binaries must consume the +# same defaults through their package-specific version symbol, execute the shared +# offline command before server/TUI construction, and derive the same XDG socket. +host_scratch="$repo_root/.scratch/microvm-host-entrypoint-test" +rm -rf "$host_scratch" +mkdir -p "$host_scratch/home" "$host_scratch/config" "$host_scratch/data" "$host_scratch/state" "$host_scratch/runtime" +host_version=v0.0.0-host-contract +host_platform=linux-amd64 +host_defaults=$(printf '{"%s":{"version":"%s","platform":"%s","url":"https://example.invalid/microvm.tar.gz","sha256":"%064d","policy_revision":"contract","certificate_identity":"https://example.invalid/release.yml","oidc_issuer":"https://token.actions.githubusercontent.com"}}' "$host_platform" "$host_version" "$host_platform" 0 | base64 -w0) +for binary in mecated mecatui; do + case "$binary" in + mecated) version_symbol=main.microVMReleaseVersion; wrong_version_symbol=main.version ;; + mecatui) version_symbol=main.version; wrong_version_symbol=main.microVMReleaseVersion ;; + esac + go build -trimpath -buildvcs=false \ + -ldflags="-X ${version_symbol}=${host_version} -X main.microVMReleaseDefaultsB64=${host_defaults} -X main.microVMReleaseStampRequired=release" \ + -o "$host_scratch/$binary" "./cmd/$binary" + HOME="$host_scratch/home" XDG_CONFIG_HOME="$host_scratch/config" XDG_DATA_HOME="$host_scratch/data" \ + XDG_STATE_HOME="$host_scratch/state" XDG_RUNTIME_DIR="$host_scratch/runtime" \ + "$host_scratch/$binary" microvm status --output json >"$host_scratch/$binary.json" + + go build -trimpath -buildvcs=false \ + -ldflags="-X ${wrong_version_symbol}=${host_version} -X main.microVMReleaseDefaultsB64=${host_defaults} -X main.microVMReleaseStampRequired=release" \ + -o "$host_scratch/$binary-wrong-version" "./cmd/$binary" + if HOME="$host_scratch/home" XDG_CONFIG_HOME="$host_scratch/config" XDG_DATA_HOME="$host_scratch/data" \ + XDG_STATE_HOME="$host_scratch/state" XDG_RUNTIME_DIR="$host_scratch/runtime" \ + "$host_scratch/$binary-wrong-version" microvm status --output json >/dev/null 2>&1; then + echo "$binary accepted defaults stamped through the other frontend's version symbol" >&2 + exit 1 + fi + + go build -trimpath -buildvcs=false \ + -ldflags="-X ${version_symbol}=${host_version} -X main.missingMicroVMReleaseDefaults=${host_defaults} -X main.microVMReleaseStampRequired=release" \ + -o "$host_scratch/$binary-missing-defaults" "./cmd/$binary" + if HOME="$host_scratch/home" XDG_CONFIG_HOME="$host_scratch/config" XDG_DATA_HOME="$host_scratch/data" \ + XDG_STATE_HOME="$host_scratch/state" XDG_RUNTIME_DIR="$host_scratch/runtime" \ + "$host_scratch/$binary-missing-defaults" microvm status --output json >/dev/null 2>&1; then + echo "$binary accepted a missing defaults stamp symbol" >&2 + exit 1 + fi +done +cmp "$host_scratch/mecated.json" "$host_scratch/mecatui.json" +expected_status=$(printf '{"profile":"microvm-local","configured":false,"running":false,"socket":"/tmp/mv-%s/microvmd.sock","guest_egress":"","generations":[],"continuation":""}\n' "$(id -u)") +test "$(cat "$host_scratch/mecated.json")" = "$expected_status" + +scratch="$repo_root/.scratch/microvm-release-test" +rm -rf "$scratch" +mkdir -p "$scratch/fixture/runtime" "$scratch/fixture/firmware" "$scratch/fixture/execution-image/usr/local/bin" "$scratch/fixture/execution-image/bin" "$scratch/one" "$scratch/two" +printf 'microvmd-fixture\n' >"$scratch/fixture/mecatl-microvmd" +printf 'guest-agent-fixture\n' >"$scratch/fixture/mecatl-guest-agent" +printf 'runtime-fixture\n' >"$scratch/fixture/runtime/libkrun.so" +printf 'firmware-fixture\n' >"$scratch/fixture/firmware/libkrunfw.so" +printf 'guest-agent-fixture\n' >"$scratch/fixture/execution-image/usr/local/bin/mecatl-guest-agent" +ln -s /bin/busybox "$scratch/fixture/execution-image/bin/arch" + +fixture_manifest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +fixture_tree=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +fixture_ref=ghcr.io/stacklok/brood-box/base@${fixture_manifest} +discovery_ref=ghcr.io/stacklok/brood-box/base:latest +resolution_evidence=sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc +execution_platform=linux/amd64 +MICROVM_RELEASE_FIXTURE_DIR="$scratch/fixture" SOURCE_DATE_EPOCH=0 \ + MICROVM_RELEASE_EXECUTION_IMAGE_REF="$fixture_ref" \ + MICROVM_RELEASE_EXECUTION_IMAGE_MANIFEST_DIGEST="$fixture_manifest" \ + MICROVM_RELEASE_EXECUTION_IMAGE_TREE_DIGEST="$fixture_tree" \ + MICROVM_RELEASE_EXECUTION_IMAGE_DISCOVERY_REFERENCE="$discovery_ref" \ + MICROVM_RELEASE_EXECUTION_IMAGE_RESOLUTION_EVIDENCE="$resolution_evidence" \ + MICROVM_RELEASE_EXECUTION_IMAGE_PLATFORM="$execution_platform" \ + "$package" "$scratch/one" linux-amd64 v0.0.0-test +MICROVM_RELEASE_FIXTURE_DIR="$scratch/fixture" SOURCE_DATE_EPOCH=0 \ + MICROVM_RELEASE_EXECUTION_IMAGE_REF="$fixture_ref" \ + MICROVM_RELEASE_EXECUTION_IMAGE_MANIFEST_DIGEST="$fixture_manifest" \ + MICROVM_RELEASE_EXECUTION_IMAGE_TREE_DIGEST="$fixture_tree" \ + MICROVM_RELEASE_EXECUTION_IMAGE_DISCOVERY_REFERENCE="$discovery_ref" \ + MICROVM_RELEASE_EXECUTION_IMAGE_RESOLUTION_EVIDENCE="$resolution_evidence" \ + MICROVM_RELEASE_EXECUTION_IMAGE_PLATFORM="$execution_platform" \ + "$package" "$scratch/two" linux-amd64 v0.0.0-test + +diff -ru "$scratch/one" "$scratch/two" +require 'go-microvm/releases/download/v0.0.40' "$scratch/one/microvm-release-linux-amd64.json" +for kind in runtime firmware; do + require "\"kind\":\"$kind\"" "$scratch/one/microvm-release-linux-amd64.json" + require "mecatl-$kind-linux-amd64.tar.gz" "$scratch/one/microvm-release-linux-amd64.json" + require "mecatl-$kind-linux-amd64.provenance.json" "$scratch/one/microvm-release-linux-amd64.json" + require "mecatl-$kind-linux-amd64.provenance.sigstore.json" "$scratch/one/microvm-release-linux-amd64.json" + test -f "$scratch/one/mecatl-$kind-linux-amd64.tar.gz" + test -f "$scratch/one/mecatl-$kind-linux-amd64.provenance.json" +done +require '"kind":"execution-image"' "$scratch/one/microvm-release-linux-amd64.json" +require "\"reference\":\"$fixture_ref\"" "$scratch/one/microvm-release-linux-amd64.json" +require "\"manifest_digest\":\"$fixture_manifest\"" "$scratch/one/microvm-release-linux-amd64.json" +require "\"digest\":\"$fixture_tree\"" "$scratch/one/microvm-release-linux-amd64.json" +require "\"discovery_reference\":\"$discovery_ref\"" "$scratch/one/microvm-release-linux-amd64.json" +require "\"resolution_evidence\":\"$resolution_evidence\"" "$scratch/one/microvm-release-linux-amd64.json" +require "\"platform\":\"$execution_platform\"" "$scratch/one/microvm-release-linux-amd64.json" +require "$discovery_ref" "$scratch/one/mecatl-execution-image-linux-amd64.provenance.json" +require "${fixture_manifest#sha256:}" "$scratch/one/mecatl-execution-image-linux-amd64.provenance.json" +forbid 'mecatl-execution-image-linux-amd64.tar.gz' "$scratch/one/microvm-release-linux-amd64.json" +test ! -e "$scratch/one/mecatl-execution-image-linux-amd64.tar.gz" +require 'mecatl-microvmd-linux-amd64.provenance.sigstore.json' "$scratch/one/microvm-release-linux-amd64.json" +require 'mecatl-microvmd-linux-amd64.spdx.json' "$scratch/one/microvm-release-linux-amd64.json" +require 'mecatl-microvmd-linux-amd64.provenance.json' "$scratch/one/microvm-release-linux-amd64.json" +require 'mecatl-artifact-digest-linux-amd64.spdx.json' "$scratch/one/microvm-release-linux-amd64.json" +require 'mecatl-artifact-digest-linux-amd64.provenance.json' "$scratch/one/microvm-release-linux-amd64.json" +require 'sha256:' "$scratch/one/microvm-release-linux-amd64.json" + +# The publisher fixture uses the publisher's signing step once. Strict admission +# consumes those exact uploaded bundles; altered statements cannot reuse them. +COSIGN_PASSWORD= cosign generate-key-pair --output-key-prefix "$scratch/publisher" >/dev/null +COSIGN_PASSWORD= MICROVM_RELEASE_SIGNING_KEY="$scratch/publisher.key" "$sign" "$scratch/one" +for kind in runtime firmware execution-image; do + statement="$scratch/one/mecatl-$kind-linux-amd64.provenance.json" + bundle="$scratch/one/mecatl-$kind-linux-amd64.provenance.sigstore.json" + cosign verify-blob --key "$scratch/publisher.pub" --bundle "$bundle" "$statement" >/dev/null + cp "$statement" "$scratch/tampered.provenance.json" + printf ' ' >>"$scratch/tampered.provenance.json" + if cosign verify-blob --key "$scratch/publisher.pub" --bundle "$bundle" "$scratch/tampered.provenance.json" >/dev/null 2>&1; then + echo "tampered $kind publisher evidence was accepted" >&2 + exit 1 + fi +done + +# Packaged installation extracts the exact payloads and projects, rather than +# re-manufacturing, the evidence names consumed by strict microvmd admission. +mkdir -p "$scratch/install" +"$install" "$scratch/one/microvm-release-linux-amd64.json" "$scratch/install" +for kind in runtime firmware guest-agent; do + test -d "$scratch/install/artifacts/$kind" + if [ "$kind" = guest-agent ]; then + evidence=mecatl-guest-agent-artifact-linux-amd64 + test -x "$scratch/install/artifacts/guest-agent/mecatl-guest-agent" + else + evidence="mecatl-$kind-linux-amd64" + fi + require "$evidence.provenance.json" "$scratch/install/microvmd-artifacts.json" + require "$evidence.provenance.sigstore.json" "$scratch/install/microvmd-artifacts.json" +done +test ! -e "$scratch/install/artifacts/execution-image" +require "\"reference\":\"$fixture_ref\"" "$scratch/install/microvmd-artifacts.json" +require "\"manifest_digest\":\"$fixture_manifest\"" "$scratch/install/microvmd-artifacts.json" +require "\"discovery_reference\":\"$discovery_ref\"" "$scratch/install/microvmd-artifacts.json" +require "\"resolution_evidence\":\"$resolution_evidence\"" "$scratch/install/microvmd-artifacts.json" +require "\"platform\":\"$execution_platform\"" "$scratch/install/microvmd-artifacts.json" +require '"path":""' "$scratch/install/microvmd-artifacts.json" + +# Mutable tags must fail before any execution-image projection is accepted. +python3 - "$scratch/one/microvm-release-linux-amd64.json" "$scratch/tagged.json" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as stream: + manifest = json.load(stream) +for artifact in manifest["admission_artifacts"]: + if artifact["kind"] == "execution-image": + artifact["reference"] = "ghcr.io/stacklok/brood-box/base:latest" +with open(sys.argv[2], "w", encoding="utf-8") as stream: + json.dump(manifest, stream, separators=(",", ":")) +PY +if "$install" "$scratch/tagged.json" "$scratch/tagged-install" >/dev/null 2>&1; then + echo "installer accepted mutable execution-image tag" >&2 + exit 1 +fi + +python3 - "$scratch/one/microvm-release-linux-amd64.json" "$scratch/base-tagged.json" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as stream: + manifest = json.load(stream) +for artifact in manifest["admission_artifacts"]: + if artifact["kind"] == "execution-image": + artifact["discovery_reference"] = "ghcr.io/attacker/base:latest" +with open(sys.argv[2], "w", encoding="utf-8") as stream: + json.dump(manifest, stream, separators=(",", ":")) +PY +if "$install" "$scratch/base-tagged.json" "$scratch/base-tagged-install" >/dev/null 2>&1; then + echo "installer accepted mutable Brood Box base tag" >&2 + exit 1 +fi + +# Every manifest subject must be the digest of the exact bytes an operator downloads. +while read -r digest name; do + actual=$(sha256sum "$scratch/one/$name" | cut -d' ' -f1) + test "$actual" = "$digest" +done <"$scratch/one/SHA256SUMS-linux-amd64" + +# Every matrix cell owns uniquely named payload and checksum assets. +require 'SHA256SUMS-$platform' "$package" +require 'mecatl-guest-agent-$platform' "$package" + +# Brood resolution is replayable from the raw index, and release publication is +# immutable across a rerun even if the mutable latest reference has moved. +verify_brood="$repo_root/.github/scripts/verify-brood-resolution.sh" +upload_assets="$repo_root/.github/scripts/upload-release-assets.sh" +mkdir -p "$scratch/bin" "$scratch/release" "$scratch/brood-one" "$scratch/brood-two" +cat >"$scratch/bin/gh" <<'SH' +#!/bin/sh +set -eu +test "$1" = release +case "$2" in + view) + for asset in "$RELEASE_STORE"/*; do + [ ! -e "$asset" ] || basename "$asset" + done + true + ;; + upload) + shift 3 + while [ "$#" -gt 0 ] && [ "$1" != --repo ]; do + cp "$1" "$RELEASE_STORE/$(basename "$1")" + shift + done + ;; + download) + shift 3 + patterns= + dir=. + while [ "$#" -gt 0 ]; do + case "$1" in + --pattern) patterns="${patterns}${patterns:+ }$2"; shift 2 ;; + --dir) dir=$2; shift 2 ;; + --repo) shift 2 ;; + --clobber) shift ;; + *) shift ;; + esac + done + mkdir -p "$dir" + for name in $patterns; do cp "$RELEASE_STORE/$name" "$dir/$name"; done + ;; + *) exit 2 ;; +esac +SH +chmod +x "$scratch/bin/gh" + +make_brood_resolution() { + out=$1 + amd=$2 + arm=$3 + cat >"$out/brood-base-index.json" <"$out/brood-platforms.json" +} + +make_brood_resolution "$scratch/brood-one" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +make_brood_resolution "$scratch/brood-two" eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +"$verify_brood" "$scratch/brood-one/brood-base-index.json" "$scratch/brood-one/brood-platforms.json" +"$verify_brood" "$scratch/brood-two/brood-base-index.json" "$scratch/brood-two/brood-platforms.json" +jq '.manifests += [.manifests[0]]' "$scratch/brood-one/brood-base-index.json" >"$scratch/duplicate-index.json" +if "$verify_brood" "$scratch/duplicate-index.json" "$scratch/brood-one/brood-platforms.json" >/dev/null 2>&1; then + echo 'duplicate linux architecture in Brood index was accepted' >&2 + exit 1 +fi +PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + "$upload_assets" v0.0.0-test "$scratch/brood-one/brood-base-index.json" "$scratch/brood-one/brood-platforms.json" +first_index=$(sha256sum "$scratch/release/brood-base-index.json" | cut -d' ' -f1) +first_platforms=$(sha256sum "$scratch/release/brood-platforms.json" | cut -d' ' -f1) +PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + "$upload_assets" v0.0.0-test "$scratch/brood-one/brood-base-index.json" "$scratch/brood-one/brood-platforms.json" +if PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + "$upload_assets" v0.0.0-test "$scratch/brood-two/brood-base-index.json" "$scratch/brood-two/brood-platforms.json" >/dev/null 2>&1; then + echo 'changed Brood latest resolution replaced frozen release evidence' >&2 + exit 1 +fi +test "$(sha256sum "$scratch/release/brood-base-index.json" | cut -d' ' -f1)" = "$first_index" +test "$(sha256sum "$scratch/release/brood-platforms.json" | cut -d' ' -f1)" = "$first_platforms" + +# A manual rerun reuses the complete platform set before generating fresh +# keyless bundles or a new bootstrap archive. Every downloaded byte must match +# the first run, while incomplete and internally inconsistent sets fail closed. +for name in mecatl-microvmd-linux-amd64 mecatl-guest-agent-linux-amd64 mecatl-artifact-digest-linux-amd64; do + printf '{"name":"%s"}\n' "$name" >"$scratch/one/$name.spdx.json" +done +bootstrap=mecatl-microvm-v0.0.0-test-linux-amd64.tar.gz +( + cd "$scratch/one" + tar --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner --exclude="$bootstrap" -cf - . | gzip -n >"$bootstrap" +) +bootstrap_sha=$(sha256sum "$scratch/one/$bootstrap" | cut -d' ' -f1) +jq -n --arg version v0.0.0-test --arg platform linux-amd64 \ + --arg url "https://github.com/stacklok/mecatl/releases/download/v0.0.0-test/$bootstrap" \ + --arg sha256 "$bootstrap_sha" \ + '{version:$version,platform:$platform,url:$url,sha256:$sha256}' >"$scratch/one/microvm-default-linux-amd64.json" +rm -rf "$scratch/release" +mkdir -p "$scratch/release" +PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + "$upload_assets" v0.0.0-test "$scratch/one"/* +( + cd "$scratch/release" + sha256sum * | sort >"$scratch/first-platform-digests" +) +cp -R "$scratch/one" "$scratch/generated-rerun" +for bundle in "$scratch/generated-rerun"/*.sigstore.json; do + printf '{"different_keyless_bundle":true}\n' >"$bundle" +done +( + cd "$scratch/generated-rerun" + rm -f "$bootstrap" microvm-default-linux-amd64.json + tar --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner --exclude="$bootstrap" -cf - . | gzip -n >"$bootstrap" +) +rerun_sha=$(sha256sum "$scratch/generated-rerun/$bootstrap" | cut -d' ' -f1) +jq -n --arg version v0.0.0-test --arg platform linux-amd64 \ + --arg url "https://github.com/stacklok/mecatl/releases/download/v0.0.0-test/$bootstrap" \ + --arg sha256 "$rerun_sha" \ + '{version:$version,platform:$platform,url:$url,sha256:$sha256}' >"$scratch/generated-rerun/microvm-default-linux-amd64.json" +cat >"$scratch/bin/cosign" <<'SH' +#!/bin/sh +exit 0 +SH +chmod +x "$scratch/bin/cosign" +PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + SIGNING_REF=refs/tags/v0.0.0-test \ + "$reuse_platform_assets" v0.0.0-test microvm linux-amd64 v0.0.0-test "$scratch/generated-rerun" +( + cd "$scratch/generated-rerun" + sha256sum * | sort >"$scratch/second-platform-digests" +) +cmp "$scratch/first-platform-digests" "$scratch/second-platform-digests" + +mv "$scratch/release/mecatl-runtime-linux-amd64.provenance.sigstore.json" "$scratch/missing-asset" +if PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + SIGNING_REF=refs/tags/v0.0.0-test \ + "$reuse_platform_assets" v0.0.0-test microvm linux-amd64 v0.0.0-test "$scratch/incomplete" >/dev/null 2>&1; then + echo 'incomplete platform release asset set was reused' >&2 + exit 1 +fi +mv "$scratch/missing-asset" "$scratch/release/mecatl-runtime-linux-amd64.provenance.sigstore.json" +printf '0%.0s' $(seq 1 64) >"$scratch/release/SHA256SUMS-linux-amd64" +if PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + SIGNING_REF=refs/tags/v0.0.0-test \ + "$reuse_platform_assets" v0.0.0-test microvm linux-amd64 v0.0.0-test "$scratch/mismatched" >/dev/null 2>&1; then + echo 'mismatched platform release asset set was reused' >&2 + exit 1 +fi + +for kind in mecated mecatui; do + host_first="$scratch/host-first-$kind" + host_rerun="$scratch/host-rerun-$kind" + rm -rf "$scratch/release" "$host_first" "$host_rerun" + mkdir -p "$scratch/release" "$host_first" "$host_rerun" + host_binary=$kind-v0.0.0-test-linux-amd64 + printf 'host-binary\n' >"$host_first/$host_binary" + host_sha=$(sha256sum "$host_first/$host_binary" | cut -d' ' -f1) + printf '%s %s\n' "$host_sha" "$host_binary" >"$host_first/$host_binary.sha256" + printf '{"sbom":true}\n' >"$host_first/$host_binary.spdx.json" + printf '{"first_keyless_bundle":true}\n' >"$host_first/$host_binary.sigstore.json" + PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + "$upload_assets" v0.0.0-test "$host_first"/* + printf '{"different_keyless_bundle":true}\n' >"$host_rerun/$host_binary.sigstore.json" + PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + SIGNING_REF=refs/tags/v0.0.0-test \ + "$reuse_platform_assets" v0.0.0-test "$kind" linux-amd64 v0.0.0-test "$host_rerun" + ( + cd "$host_first" + sha256sum * | sort >"$scratch/first-host-digests-$kind" + ) + ( + cd "$host_rerun" + sha256sum * | sort >"$scratch/second-host-digests-$kind" + ) + cmp "$scratch/first-host-digests-$kind" "$scratch/second-host-digests-$kind" + + mv "$scratch/release/$host_binary.spdx.json" "$scratch/missing-host-asset" + if PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + SIGNING_REF=refs/tags/v0.0.0-test \ + "$reuse_platform_assets" v0.0.0-test "$kind" linux-amd64 v0.0.0-test "$scratch/incomplete-host-$kind" >/dev/null 2>&1; then + echo "incomplete $kind host release asset set was reused" >&2 + exit 1 + fi + mv "$scratch/missing-host-asset" "$scratch/release/$host_binary.spdx.json" + printf '0%.0s' $(seq 1 64) >"$scratch/release/$host_binary.sha256" + if PATH="$scratch/bin:$PATH" RELEASE_STORE="$scratch/release" GITHUB_REPOSITORY=stacklok/mecatl \ + SIGNING_REF=refs/tags/v0.0.0-test \ + "$reuse_platform_assets" v0.0.0-test "$kind" linux-amd64 v0.0.0-test "$scratch/mismatched-host-$kind" >/dev/null 2>&1; then + echo "mismatched $kind host release asset set was reused" >&2 + exit 1 + fi +done + +rm -rf "$scratch" diff --git a/.github/scripts/package-microvm-release.sh b/.github/scripts/package-microvm-release.sh new file mode 100755 index 000000000..d7393465e --- /dev/null +++ b/.github/scripts/package-microvm-release.sh @@ -0,0 +1,166 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 3 ]; then + echo "usage: $0 OUTPUT_DIR PLATFORM VERSION" >&2 + exit 2 +fi + +output=$1 +platform=$2 +version=$3 +case "$platform" in + linux-amd64) host_os=linux; arch=amd64; runtime_digest=4de717eba0c2fcfbce564fc4296536b78644b50772f3682c9be9e8809ec76165; firmware_digest=8c036287c6689bec9e8a01697a5b2f646d21bf6af276a7a02b04b83830461441 ;; + linux-arm64) host_os=linux; arch=arm64; runtime_digest=2f1c9f0db4c549158f3b253d6b121f4a705ebfce08dad211ec1438838f064c73; firmware_digest=f7c1ccbc2a71de96883ccabbd5bff40ea1a553948365e102309a652326fbaf8f ;; + darwin-arm64) host_os=darwin; arch=arm64; runtime_digest=c7442f2e6cd6916a5058432a4e2447622b4b509786bb1e636eda90f9dd2facce; firmware_digest=434e803ab08d84b525bb1addfeb8c590d3550d726141fe2c87d906df3e596ffa ;; + *) echo "unsupported microVM release platform: $platform" >&2; exit 2 ;; +esac +case "$version" in + *[!A-Za-z0-9._+-]*) echo "invalid release version" >&2; exit 2 ;; +esac + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +mkdir -p "$output" +output=$(CDPATH= cd -- "$output" && pwd) +microvmd="mecatl-microvmd-$platform" +guest="mecatl-guest-agent-$platform" +digest_tool="mecatl-artifact-digest-$platform" + +if [ -n "${MICROVM_RELEASE_FIXTURE_DIR:-}" ]; then + cp "$MICROVM_RELEASE_FIXTURE_DIR/mecatl-microvmd" "$output/$microvmd" + cp "$MICROVM_RELEASE_FIXTURE_DIR/mecatl-guest-agent" "$output/$guest" +else + ( + cd "$repo_root/environment/microvm" + GOWORK=off CGO_ENABLED=0 GOOS="$host_os" GOARCH="$arch" \ + go build -trimpath -buildvcs=false -ldflags='-buildid=' -o "$output/$microvmd" ./cmd/mecatl-microvmd + GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH="$arch" \ + go build -trimpath -buildvcs=false -ldflags='-buildid=' -o "$output/$guest" ./cmd/mecatl-guest-agent + ) +fi +( + cd "$repo_root/environment/microvm" + GOWORK=off CGO_ENABLED=0 GOOS="$host_os" GOARCH="$arch" \ + go build -trimpath -buildvcs=false -ldflags='-buildid=' -o "$output/$digest_tool" ./cmd/mecatl-artifact-digest +) +chmod 0755 "$output/$microvmd" "$output/$guest" "$output/$digest_tool" +cp "$repo_root/.github/scripts/install-microvm-release.sh" "$output/install-microvm-release.sh" +chmod 0755 "$output/install-microvm-release.sh" + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +download_verified() { + url=$1 + destination=$2 + expected=$3 + curl --fail --location --proto '=https' --tlsv1.2 "$url" -o "$destination" + actual=$(sha256_file "$destination") + if [ "$actual" != "$expected" ]; then + echo "artifact digest mismatch for $url: got $actual, want $expected" >&2 + exit 1 + fi +} + +artifact_work="$output/.artifact-work" +rm -rf "$artifact_work" +mkdir -p "$artifact_work/runtime" "$artifact_work/firmware" "$artifact_work/guest-agent" +cp "$output/$guest" "$artifact_work/guest-agent/mecatl-guest-agent" +chmod 0755 "$artifact_work/guest-agent/mecatl-guest-agent" +if [ -n "${MICROVM_RELEASE_FIXTURE_DIR:-}" ]; then + runtime_source=${MICROVM_RELEASE_RUNTIME_DIR:-$MICROVM_RELEASE_FIXTURE_DIR/runtime} + firmware_source=${MICROVM_RELEASE_FIRMWARE_DIR:-$MICROVM_RELEASE_FIXTURE_DIR/firmware} + cp -R "$runtime_source/." "$artifact_work/runtime/" + cp -R "$firmware_source/." "$artifact_work/firmware/" +else + download_verified "https://github.com/stacklok/go-microvm/releases/download/v0.0.40/go-microvm-runtime-$platform.tar.gz" "$artifact_work/runtime.tar.gz" "$runtime_digest" + download_verified "https://github.com/stacklok/go-microvm/releases/download/v0.0.40/go-microvm-firmware-$platform.tar.gz" "$artifact_work/firmware.tar.gz" "$firmware_digest" + tar -xzf "$artifact_work/runtime.tar.gz" -C "$artifact_work/runtime" --strip-components=1 + tar -xzf "$artifact_work/firmware.tar.gz" -C "$artifact_work/firmware" --strip-components=1 +fi + +execution_ref=${MICROVM_RELEASE_EXECUTION_IMAGE_REF:-} +execution_manifest=${MICROVM_RELEASE_EXECUTION_IMAGE_MANIFEST_DIGEST:-} +execution_tree_digest=${MICROVM_RELEASE_EXECUTION_IMAGE_TREE_DIGEST:-} +execution_discovery_ref=${MICROVM_RELEASE_EXECUTION_IMAGE_DISCOVERY_REFERENCE:-} +execution_resolution_evidence=${MICROVM_RELEASE_EXECUTION_IMAGE_RESOLUTION_EVIDENCE:-} +execution_platform=${MICROVM_RELEASE_EXECUTION_IMAGE_PLATFORM:-} +python3 - "$execution_ref" "$execution_manifest" "$execution_tree_digest" "$execution_discovery_ref" "$execution_resolution_evidence" "$execution_platform" <<'PY' +import re, sys +ref, manifest, tree, discovery_ref, resolution_evidence, platform = sys.argv[1:] +digest = r"sha256:[0-9a-f]{64}" +if not re.fullmatch(r"[a-z0-9][a-z0-9._/-]*@" + digest, ref): + raise SystemExit("execution image must be an exact lowercase repo@sha256 platform reference") +if not re.fullmatch(digest, manifest) or ref.rsplit("@", 1)[1] != manifest: + raise SystemExit("execution image manifest digest does not match its OCI reference") +if not re.fullmatch(digest, tree): + raise SystemExit("execution image must carry its independently computed tree digest") +if discovery_ref != "ghcr.io/stacklok/brood-box/base:latest": + raise SystemExit("execution image discovery must use the controlled Brood base latest reference") +if not re.fullmatch(digest, resolution_evidence): + raise SystemExit("execution image must record immutable resolution evidence") +if platform not in ("linux/amd64", "linux/arm64"): + raise SystemExit("execution image must record an admitted Brood platform") +PY + +artifact_digest() { + (cd "$repo_root/environment/microvm" && GOWORK=off go run ./cmd/mecatl-artifact-digest "$1") +} + +write_artifact() { + artifact_kind=$1 + artifact_tree=$2 + if [ "$artifact_kind" = guest-agent ]; then + artifact_name="mecatl-guest-agent-artifact-$platform" + else + artifact_name="mecatl-$artifact_kind-$platform" + fi + artifact_tree_digest=$(artifact_digest "$artifact_tree") + tar --sort=name --mtime="@${SOURCE_DATE_EPOCH:-0}" --owner=0 --group=0 --numeric-owner -C "$artifact_tree" -cf - . | gzip -n >"$output/$artifact_name.tar.gz" + write_metadata "$artifact_name" "${artifact_tree_digest#sha256:}" + printf '%s\t%s\t%s\n' "$artifact_kind" "$artifact_name" "$artifact_tree_digest" +} + +microvmd_digest=$(sha256_file "$output/$microvmd") +guest_digest=$(sha256_file "$output/$guest") +digest_tool_digest=$(sha256_file "$output/$digest_tool") +printf '%s %s\n%s %s\n%s %s\n' "$guest_digest" "$guest" "$microvmd_digest" "$microvmd" "$digest_tool_digest" "$digest_tool" >"$output/SHA256SUMS-$platform" + +write_metadata() { + metadata_name=$1 + metadata_digest=$2 + cat >"$output/$metadata_name.provenance.json" <"$output/$image_name.provenance.json" <>"$output/SHA256SUMS-$platform" + +cat >"$output/microvm-release-$platform.json" <&2 + exit 2 +fi +source_build_identity=$1 +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) platform=linux-amd64 ;; + *) echo "microVM development releases require Linux amd64" >&2; exit 1 ;; +esac + +"$repo_root/environment/microvm/e2e/prepare.sh" +prepared="$repo_root/.scratch/microvm-e2e/$platform" +output="$repo_root/.scratch/microvm-dev/$platform" +rm -rf "$output" +mkdir -p "$output" +bundle="$output/mecatl-microvm-development-$platform.tar.gz" +members="$output/bundle-members" +find "$prepared/package" -mindepth 1 -printf '%P\0' | LC_ALL=C sort -z >"$members" +tar --no-recursion -C "$prepared/package" --null --verbatim-files-from --files-from="$members" -cf - | gzip -n >"$bundle" +rm -f "$members" +chmod 0600 "$bundle" +key="$output/publisher.pub" +cp "$prepared/release-fixture/publisher.pub" "$key" +chmod 0600 "$key" + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi +} +bundle_sha=$(sha256_file "$bundle") +key_sha=$(sha256_file "$key") +descriptor="$output/release.json" +python3 - "$descriptor" "$source_build_identity" "$bundle" "$bundle_sha" "$key" "$key_sha" <<'PY' +import json, sys +path, source, bundle, bundle_sha, key, key_sha = sys.argv[1:] +value = { + "schema": "mecatl-microvm-development-release/v1", + "platform": "linux-amd64", + "source_build_identity": source, + "bundle_path": bundle, + "bundle_sha256": bundle_sha, + "public_key_path": key, + "public_key_identity": "sha256:" + key_sha, + "policy_revision": "development-" + source, +} +with open(path, "x", encoding="utf-8") as stream: + json.dump(value, stream, separators=(",", ":")) + stream.write("\n") +PY +chmod 0600 "$descriptor" +printf 'prepared unsupported local microVM development release: %s\n' "$descriptor" diff --git a/.github/scripts/race-vet-off-workflow_test.sh b/.github/scripts/race-vet-off-workflow_test.sh index 753461845..022d54534 100644 --- a/.github/scripts/race-vet-off-workflow_test.sh +++ b/.github/scripts/race-vet-off-workflow_test.sh @@ -27,7 +27,7 @@ for job in test-race-root-a test-race-root-b test-race-ui; do fi case "$job" in - test-race-root-a) expected_wrappers=7 ;; + test-race-root-a) expected_wrappers=8 ;; test-race-root-b|test-race-ui) expected_wrappers=1 ;; esac wrapper_calls="$(grep -Fc 'race-test.sh' <<<"$block")" diff --git a/.github/scripts/reuse-platform-release-assets.sh b/.github/scripts/reuse-platform-release-assets.sh new file mode 100755 index 000000000..7a108d7d4 --- /dev/null +++ b/.github/scripts/reuse-platform-release-assets.sh @@ -0,0 +1,221 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 5 ]; then + echo "usage: $0 TAG microvm|mecated|mecatui PLATFORM VERSION OUTPUT_DIR" >&2 + exit 2 +fi + +tag=$1 +kind=$2 +platform=$3 +version=$4 +output=$5 +repo=${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required} + +case "$platform" in + linux-amd64|linux-arm64|darwin-arm64) ;; + *) echo "unsupported release platform: $platform" >&2; exit 2 ;; +esac +case "$version" in + *[!A-Za-z0-9._+-]*) echo "invalid release version" >&2; exit 2 ;; +esac + +case "$kind" in + microvm) + names="SHA256SUMS-$platform +mecatl-microvmd-$platform +mecatl-microvmd-$platform.spdx.json +mecatl-microvmd-$platform.provenance.json +mecatl-microvmd-$platform.provenance.sigstore.json +mecatl-guest-agent-$platform +mecatl-guest-agent-$platform.spdx.json +mecatl-guest-agent-$platform.provenance.json +mecatl-guest-agent-$platform.provenance.sigstore.json +mecatl-artifact-digest-$platform +mecatl-artifact-digest-$platform.spdx.json +mecatl-artifact-digest-$platform.provenance.json +mecatl-artifact-digest-$platform.provenance.sigstore.json +mecatl-runtime-$platform.tar.gz +mecatl-runtime-$platform.provenance.json +mecatl-runtime-$platform.provenance.sigstore.json +mecatl-firmware-$platform.tar.gz +mecatl-firmware-$platform.provenance.json +mecatl-firmware-$platform.provenance.sigstore.json +mecatl-guest-agent-artifact-$platform.tar.gz +mecatl-guest-agent-artifact-$platform.provenance.json +mecatl-guest-agent-artifact-$platform.provenance.sigstore.json +mecatl-execution-image-$platform.provenance.json +mecatl-execution-image-$platform.provenance.sigstore.json +microvm-release-$platform.json +mecatl-microvm-$version-$platform.tar.gz +microvm-default-$platform.json" + if [ "$platform" = linux-amd64 ]; then + names="install-microvm-release.sh +$names" + fi + ;; + mecated|mecatui) + binary="$kind-$version-$platform" + names="$binary +$binary.sha256 +$binary.spdx.json +$binary.sigstore.json" + ;; + *) echo "unsupported release asset set: $kind" >&2; exit 2 ;; +esac + +remote=$(gh release view "$tag" --repo "$repo" --json assets --jq '.assets[].name') +found=0 +total=0 +for name in $names; do + total=$((total + 1)) + if printf '%s\n' "$remote" | grep -Fx "$name" >/dev/null; then + found=$((found + 1)) + fi +done + +if [ "$found" -eq 0 ]; then + mkdir -p "$output" + [ -z "${GITHUB_OUTPUT:-}" ] || echo 'reused=false' >>"$GITHUB_OUTPUT" + echo "No existing $kind asset set for $platform" + exit 0 +fi +if [ "$found" -ne "$total" ]; then + echo "release contains an incomplete $kind asset set for $platform ($found of $total assets)" >&2 + exit 1 +fi + +rm -rf "$output" +mkdir -p "$output" +for name in $names; do + gh release download "$tag" --repo "$repo" --pattern "$name" --dir "$output" >/dev/null +done +if [ "$kind" = microvm ] && [ "$platform" != linux-amd64 ]; then + if ! printf '%s\n' "$remote" | grep -Fx install-microvm-release.sh >/dev/null; then + echo "release is missing the shared microVM installer asset" >&2 + exit 1 + fi + gh release download "$tag" --repo "$repo" --pattern install-microvm-release.sh --dir "$output" >/dev/null +fi + +python3 - "$kind" "$platform" "$version" "$repo" "$output" <<'PY' +import hashlib +import json +import pathlib +import re +import sys +import tarfile + +kind, platform, version, repo, raw_output = sys.argv[1:] +output = pathlib.Path(raw_output) + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + +def load(name): + with (output / name).open(encoding="utf-8") as stream: + return json.load(stream) + +if kind in ("mecated", "mecatui"): + binary = f"{kind}-{version}-{platform}" + line = (output / f"{binary}.sha256").read_text(encoding="utf-8") + if line != f"{digest(output / binary)} {binary}\n": + raise SystemExit(f"host {kind} checksum manifest does not match its binary") + raise SystemExit(0) + +checksums = output / f"SHA256SUMS-{platform}" +expected_checksum_names = { + f"mecatl-microvmd-{platform}", + f"mecatl-guest-agent-{platform}", + f"mecatl-artifact-digest-{platform}", + f"mecatl-runtime-{platform}.tar.gz", + f"mecatl-firmware-{platform}.tar.gz", + f"mecatl-guest-agent-artifact-{platform}.tar.gz", +} +seen = set() +for line in checksums.read_text(encoding="utf-8").splitlines(): + match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9._+-]+)", line) + if not match or match.group(2) not in expected_checksum_names: + raise SystemExit("invalid microVM checksum manifest") + want, name = match.groups() + if name in seen or digest(output / name) != want: + raise SystemExit(f"microVM checksum mismatch for {name}") + seen.add(name) +if seen != expected_checksum_names: + raise SystemExit("microVM checksum manifest is incomplete") + +manifest = load(f"microvm-release-{platform}.json") +if manifest.get("schema") != "mecatl-microvm-release/v2" or manifest.get("version") != version or manifest.get("platform") != platform: + raise SystemExit("microVM release manifest identity mismatch") +artifacts = manifest.get("artifacts", []) +expected_artifact_names = { + f"mecatl-microvmd-{platform}", + f"mecatl-guest-agent-{platform}", + f"mecatl-artifact-digest-{platform}", +} +if len(artifacts) != 3 or {artifact.get("name") for artifact in artifacts} != expected_artifact_names: + raise SystemExit("microVM release manifest artifact set mismatch") +for artifact in artifacts: + name = artifact.get("name", "") + if name not in expected_checksum_names or artifact.get("digest") != f"sha256:{digest(output / name)}": + raise SystemExit(f"microVM manifest digest mismatch for {name}") + for key in ("sbom", "evidence", "provenance"): + ref = artifact.get(key, "") + if pathlib.PurePath(ref).name != ref or not (output / ref).is_file(): + raise SystemExit(f"invalid microVM manifest {key} relationship") +admission_artifacts = manifest.get("admission_artifacts", []) +if len(admission_artifacts) != 4 or {artifact.get("kind") for artifact in admission_artifacts} != {"runtime", "firmware", "execution-image", "guest-agent"}: + raise SystemExit("microVM admission artifact set mismatch") +for artifact in admission_artifacts: + for key in ("provenance", "sigstore_bundle"): + ref = artifact.get(key, "") + if pathlib.PurePath(ref).name != ref or not (output / ref).is_file(): + raise SystemExit(f"invalid admission {key} relationship") + payload = artifact.get("payload") + if payload: + if payload not in expected_checksum_names or artifact.get("payload_digest") != f"sha256:{digest(output / payload)}": + raise SystemExit(f"admission payload digest mismatch for {payload}") + +bundle_name = f"mecatl-microvm-{version}-{platform}.tar.gz" +defaults = load(f"microvm-default-{platform}.json") +expected_url = f"https://github.com/{repo}/releases/download/{version}/{bundle_name}" +if defaults.get("version") != version or defaults.get("platform") != platform or defaults.get("url") != expected_url or defaults.get("sha256") != digest(output / bundle_name): + raise SystemExit("microVM bootstrap defaults do not match the published archive") + +excluded = {bundle_name, f"microvm-default-{platform}.json"} +expected_members = {path.name for path in output.iterdir() if path.is_file()} - excluded +with tarfile.open(output / bundle_name, "r:gz") as archive: + members = archive.getmembers() + actual_members = set() + for member in members: + name = member.name.removeprefix("./") + if not name or member.isdir(): + continue + if "/" in name or not member.isfile() or name in actual_members: + raise SystemExit("unsafe or duplicate microVM bootstrap archive member") + actual_members.add(name) + stream = archive.extractfile(member) + if stream is None or hashlib.sha256(stream.read()).hexdigest() != digest(output / name): + raise SystemExit(f"bootstrap archive member mismatch for {name}") +if actual_members != expected_members: + raise SystemExit("microVM bootstrap archive asset set mismatch") +PY + +issuer=https://token.actions.githubusercontent.com +identity="https://github.com/$repo/.github/workflows/release.yml@${SIGNING_REF:?SIGNING_REF is required to verify existing signatures}" +case "$kind" in + microvm) + for statement in "$output"/*.provenance.json; do + cosign verify-blob --certificate-identity "$identity" --certificate-oidc-issuer "$issuer" \ + --bundle "${statement%.json}.sigstore.json" "$statement" >/dev/null + done + ;; + mecated|mecatui) + cosign verify-blob --certificate-identity "$identity" --certificate-oidc-issuer "$issuer" \ + --bundle "$output/$binary.sigstore.json" "$output/$binary" >/dev/null + ;; +esac + +[ -z "${GITHUB_OUTPUT:-}" ] || echo 'reused=true' >>"$GITHUB_OUTPUT" +echo "Reusing complete verified $kind asset set for $platform" diff --git a/.github/scripts/sign-microvm-release-evidence.sh b/.github/scripts/sign-microvm-release-evidence.sh new file mode 100755 index 000000000..934b1f206 --- /dev/null +++ b/.github/scripts/sign-microvm-release-evidence.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + echo "usage: $0 RELEASE_ASSET_DIR" >&2 + exit 2 +fi +assets=$1 +if [ ! -d "$assets" ]; then + echo "microVM release asset directory does not exist: $assets" >&2 + exit 1 +fi + +found=false +for provenance in "$assets"/*.provenance.json; do + if [ ! -f "$provenance" ]; then + continue + fi + found=true + bundle=${provenance%.json}.sigstore.json + if [ -n "${MICROVM_RELEASE_SIGNING_KEY:-}" ]; then + COSIGN_PASSWORD=${COSIGN_PASSWORD:-} cosign sign-blob --yes --key "$MICROVM_RELEASE_SIGNING_KEY" --bundle "$bundle" "$provenance" + else + cosign sign-blob --yes --bundle "$bundle" "$provenance" + fi +done +if [ "$found" != true ]; then + echo "microVM release has no provenance statements to sign: $assets" >&2 + exit 1 +fi diff --git a/.github/scripts/upload-release-assets.sh b/.github/scripts/upload-release-assets.sh new file mode 100755 index 000000000..fe0c6a7d1 --- /dev/null +++ b/.github/scripts/upload-release-assets.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ]; then + echo "usage: $0 ..." >&2 + exit 2 +fi + +tag=$1 +shift +repo=${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required} +work=.scratch/release-upload-$$ +trap 'rm -rf "$work"' EXIT HUP INT TERM +mkdir -p "$work" + +remote_assets=$(gh release view "$tag" --repo "$repo" --json assets --jq '.assets[].name') + +# Preflight every existing name before uploading anything, so a mismatch cannot +# leave a partially updated release. +for asset in "$@"; do + name=$(basename "$asset") + if printf '%s\n' "$remote_assets" | grep -Fx "$name" >/dev/null; then + gh release download "$tag" --repo "$repo" --pattern "$name" --dir "$work" --clobber >/dev/null + local_digest=$(sha256sum "$asset" | cut -d' ' -f1) + remote_digest=$(sha256sum "$work/$name" | cut -d' ' -f1) + if [ "$local_digest" != "$remote_digest" ]; then + echo "refusing to replace release asset $name: published digest $remote_digest differs from candidate $local_digest" >&2 + exit 1 + fi + fi +done + +for asset in "$@"; do + name=$(basename "$asset") + if printf '%s\n' "$remote_assets" | grep -Fx "$name" >/dev/null; then + echo "Reusing identical release asset $name" + else + gh release upload "$tag" "$asset" --repo "$repo" + fi +done diff --git a/.github/scripts/validate-release-ref.sh b/.github/scripts/validate-release-ref.sh new file mode 100755 index 000000000..49a01e837 --- /dev/null +++ b/.github/scripts/validate-release-ref.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "usage: validate-release-ref.sh VERSION ACTUAL_REF" >&2 + exit 2 +fi + +version=$1 +actual_ref=$2 +expected_ref="refs/tags/${version}" + +case "$version" in + v*) ;; + *) + echo "release version must name a v* tag: ${version}" >&2 + exit 1 + ;; +esac + +if [ "$actual_ref" != "$expected_ref" ]; then + echo "release ref mismatch: got ${actual_ref}, want ${expected_ref}" >&2 + exit 1 +fi + +printf '%s\n' "$actual_ref" diff --git a/.github/scripts/verify-brood-resolution.sh b/.github/scripts/verify-brood-resolution.sh new file mode 100755 index 000000000..0031173f0 --- /dev/null +++ b/.github/scripts/verify-brood-resolution.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +index=$1 +platforms=$2 +evidence="sha256:$(sha256sum "$index" | cut -d' ' -f1)" + +jq -e 'type == "object" and (.manifests | type == "array")' "$index" >/dev/null +jq -e 'type == "object" and (keys | sort == ["amd64", "arm64"])' "$platforms" >/dev/null + +for arch in amd64 arm64; do + manifest=$(jq -er --arg arch "$arch" \ + '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == $arch) | .digest] | + if length == 1 then .[0] else error("expected exactly one linux/" + $arch + " manifest") end' "$index") + if ! printf '%s\n' "$manifest" | grep -E '^sha256:[0-9a-f]{64}$' >/dev/null; then + echo "invalid linux/$arch manifest digest: $manifest" >&2 + exit 1 + fi + jq -e --arg arch "$arch" --arg manifest "$manifest" --arg evidence "$evidence" \ + '.[$arch].manifest_digest == $manifest and + .[$arch].reference == ("ghcr.io/stacklok/brood-box/base@" + $manifest) and + .[$arch].resolution_evidence == $evidence and + .[$arch].platform == ("linux/" + $arch) and + (.[$arch].tree_digest | test("^sha256:[0-9a-f]{64}$")) and + (.[$arch].discovery_reference | type == "string" and endswith(":latest"))' \ + "$platforms" >/dev/null +done diff --git a/.github/workflows/README.md b/.github/workflows/README.md index f588a42d8..cf451d2c9 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -8,6 +8,7 @@ release behavior. This page is only a stable index to operational guidance. - [Performance tracking rationale](../../docs/adr/0019-perf-tracking.md) - [Mecatequi CI adoption](../../docs/usage/mecatequi-ci.md) and its [design rationale](../../docs/adr/0028-mecatequi.md) +- [microVM environment operations](../../docs/usage/microvm-environments.md) Mecatequi's same-repository action pins are maintained by the [release-pin script](../../.claude/skills/cut-release/scripts/bump-release-pins.sh) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1ee5b472..bfcbf3384 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -412,6 +412,7 @@ jobs: go.sum engine/go.sum authn/oidc/go.sum + environment/microvm/go.sum provider/ssefilter/go.sum provider/anthropic/go.sum provider/openai/go.sum @@ -453,6 +454,9 @@ jobs: - name: go build (OIDC authn module) run: cd authn/oidc && go build ./... + - name: go build (microVM module) + run: cd environment/microvm && go build ./... + # provider/ holds the opt-in LLM-provider Go submodules (ADR 0093), each # its own module under the workspace — build them explicitly too. - name: go build (provider modules) @@ -507,6 +511,7 @@ jobs: go.sum engine/go.sum authn/oidc/go.sum + environment/microvm/go.sum provider/ssefilter/go.sum provider/anthropic/go.sum provider/openai/go.sum @@ -529,6 +534,7 @@ jobs: bash .github/scripts/race-test.sh root-a -count=1 "${packages[@]}" (cd engine && bash "$GITHUB_WORKSPACE/.github/scripts/race-test.sh" engine ./...) (cd authn/oidc && bash "$GITHUB_WORKSPACE/.github/scripts/race-test.sh" oidc ./...) + (cd environment/microvm && bash "$GITHUB_WORKSPACE/.github/scripts/race-test.sh" microvm ./...) (cd provider/ssefilter && bash "$GITHUB_WORKSPACE/.github/scripts/race-test.sh" provider-ssefilter ./...) (cd provider/anthropic && bash "$GITHUB_WORKSPACE/.github/scripts/race-test.sh" provider-anthropic ./...) (cd provider/openai && bash "$GITHUB_WORKSPACE/.github/scripts/race-test.sh" provider-openai ./...) @@ -651,6 +657,7 @@ jobs: go test -count=1 ./cmd/mecatui/ui (cd engine && go test ./...) (cd authn/oidc && go test ./...) + (cd environment/microvm && go test ./...) (cd provider/ssefilter && go test ./...) (cd provider/anthropic && go test ./...) (cd provider/openai && go test ./...) @@ -712,6 +719,7 @@ jobs: go.sum engine/go.sum authn/oidc/go.sum + environment/microvm/go.sum provider/ssefilter/go.sum provider/anthropic/go.sum provider/openai/go.sum @@ -748,6 +756,9 @@ jobs: - name: golangci-lint (OIDC authn module) run: cd authn/oidc && golangci-lint run --config ../../.golangci.yml --allow-parallel-runners --timeout=10m ./... + - name: golangci-lint (microVM module) + run: cd environment/microvm && golangci-lint run --config ../../.golangci.yml --allow-parallel-runners --timeout=10m ./... + # provider/ submodules (ADR 0093) — each its own module, sharing the ROOT # golangci config so the depguard allowlists stay the single source of truth. - name: golangci-lint (provider modules) @@ -782,12 +793,17 @@ jobs: # branches). The action-wrappers test pins the composite action.yml env-mapping contract # (actionlint cannot lint a composite action). They are shell, not Go, so they ride here # rather than in `go test`. + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + # The publisher-format release fixture signs once, then verifies the exact + # bundles strict admission consumes and rejects statement tampering. - name: Test GitHub Actions scripts run: | bash .github/actions/mecatequi-extract-prompt/extract-prompt_test.sh bash .github/actions/mecatequi-publish/publish_test.sh bash .github/actions/action-wrappers_test.sh bash .github/scripts/lint-vet-workflow_test.sh + bash .github/scripts/microvm-ci-release_test.sh # Keep cmd/vet authoritative and complete without serializing its cold root-module # cost behind golangci-lint. The two jobs use separate runners, so vet adds no CPU @@ -811,6 +827,7 @@ jobs: go.sum engine/go.sum authn/oidc/go.sum + environment/microvm/go.sum provider/ssefilter/go.sum provider/anthropic/go.sum provider/openai/go.sum @@ -823,6 +840,7 @@ jobs: go vet ./... (cd engine && go vet ./...) (cd authn/oidc && go vet ./...) + (cd environment/microvm && go vet ./...) (cd provider/ssefilter && go vet ./...) (cd provider/anthropic && go vet ./...) (cd provider/openai && go vet ./...) @@ -882,6 +900,7 @@ jobs: go.sum engine/go.sum authn/oidc/go.sum + environment/microvm/go.sum provider/ssefilter/go.sum provider/anthropic/go.sum provider/openai/go.sum @@ -947,6 +966,33 @@ jobs: GOWORK: off run: cd engine && go test ./... + microvm-standalone: + name: MicroVM module (standalone, GOWORK=off) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: environment/microvm/go.mod + cache: true + cache-dependency-path: | + engine/go.sum + environment/microvm/go.sum + + - name: go build (microVM standalone) + env: + GOWORK: off + run: cd environment/microvm && go build ./... + + - name: go test (microVM standalone) + env: + GOWORK: off + run: cd environment/microvm && go test ./... + provider-standalone: name: Provider modules (standalone, GOWORK=off) needs: changes @@ -1049,6 +1095,7 @@ jobs: go.sum engine/go.sum authn/oidc/go.sum + environment/microvm/go.sum provider/ssefilter/go.sum provider/anthropic/go.sum provider/openai/go.sum @@ -1095,6 +1142,7 @@ jobs: go.sum engine/go.sum authn/oidc/go.sum + environment/microvm/go.sum provider/ssefilter/go.sum provider/anthropic/go.sum provider/openai/go.sum @@ -1177,6 +1225,7 @@ jobs: go.sum engine/go.sum authn/oidc/go.sum + environment/microvm/go.sum provider/ssefilter/go.sum provider/anthropic/go.sum provider/openai/go.sum diff --git a/.github/workflows/microvm-e2e.yml b/.github/workflows/microvm-e2e.yml new file mode 100644 index 000000000..2645b5858 --- /dev/null +++ b/.github/workflows/microvm-e2e.yml @@ -0,0 +1,164 @@ +name: MicroVM E2E + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: microvm-e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GOTOOLCHAIN: local + GOFLAGS: -mod=readonly + +jobs: + live-hypervisor: + name: ${{ matrix.name }} + runs-on: ${{ fromJSON(matrix.runner) }} + timeout-minutes: 45 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + include: + - name: Linux amd64 (KVM) + runner: '["ubuntu-24.04"]' + hypervisor: kvm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: environment/microvm/go.mod + cache: true + cache-dependency-path: | + engine/go.sum + environment/microvm/go.sum + + - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Explicit artifact preparation preflight + shell: bash + run: | + set -euo pipefail + docker info >/dev/null + test -c /dev/kvm + + - name: Prepare packaged microVM artifacts + shell: bash + env: + HYPERVISOR: ${{ matrix.hypervisor }} + run: | + set -euo pipefail + if [[ "${HYPERVISOR}" == "kvm" ]]; then + echo "::group::KVM state before artifact preparation" + stat --printf='inode=%i owner=%U(%u) group=%G(%g) mode=%a type=%F\n' /dev/kvm + getfacl --absolute-names /dev/kvm + echo "::endgroup::" + fi + ./environment/microvm/e2e/prepare.sh + if [[ "${HYPERVISOR}" == "kvm" ]]; then + echo "::group::KVM state after artifact preparation" + stat --printf='inode=%i owner=%U(%u) group=%G(%g) mode=%a type=%F\n' /dev/kvm + getfacl --absolute-names /dev/kvm + echo "::endgroup::" + fi + + - name: Refresh KVM access after artifact preparation + if: matrix.hypervisor == 'kvm' + shell: bash + run: | + set -euo pipefail + echo "::group::KVM state before UID ACL refresh" + stat --printf='inode=%i owner=%U(%u) group=%G(%g) mode=%a type=%F\n' /dev/kvm + getfacl --absolute-names /dev/kvm + echo "::endgroup::" + echo "KVM authorization target: user=$(id -un) uid=$(id -u) euid=$(id -u)" + sudo usermod --append --groups kvm "$(id -un)" + sudo setfacl --modify "user:$(id -u):rw" /dev/kvm + echo "::group::KVM state after UID ACL refresh" + stat --printf='inode=%i owner=%U(%u) group=%G(%g) mode=%a type=%F\n' /dev/kvm + getfacl --absolute-names /dev/kvm + echo "::endgroup::" + python3 - <<'PY' + import os + fd = os.open("/dev/kvm", os.O_RDWR | os.O_CLOEXEC) + os.close(fd) + PY + + - name: Reclaim preparation-only disk + shell: bash + run: | + set -euo pipefail + sudo rm -rf -- \ + .scratch/microvm-e2e/linux-amd64/rootfs \ + .scratch/microvm-e2e/linux-amd64/oci/resolver-cache + docker builder prune --all --force + docker image prune --all --force + + - name: Run pinned v0.0.40 real-hypervisor journey + env: + MECATL_MICROVM_E2E_PREPARED: "1" + MECATL_MICROVM_E2E_REFRESH_KVM_ACL: "1" + MECATL_MICROVM_E2E_WRONG_PEER_UID: "65534" + run: | + set -euo pipefail + MECATL_MICROVM_E2E_KVM_GID="$(getent group kvm | cut -d: -f3)" + export MECATL_MICROVM_E2E_KVM_GID + sg kvm -c "echo \"E2E host identity: uid=\$(id -u) euid=\$(id -u) gid=\$(id -g) expected_kvm_gid=${MECATL_MICROVM_E2E_KVM_GID}\"; test \"\$(id -g)\" = \"${MECATL_MICROVM_E2E_KVM_GID}\"; task e2e:microvm" + + linux-arm64-compile-static: + name: Linux arm64 compile/static coverage + runs-on: ubuntu-24.04-arm + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: environment/microvm/go.mod + cache: true + cache-dependency-path: | + engine/go.sum + environment/microvm/go.sum + - name: Compile runtime, production daemon, and tagged E2E + run: | + cd environment/microvm + GOWORK=off go test ./... + cd ../.. + go test -tags=microvm_e2e ./internal/adapter/microvm -run '^$' + + macos-compile-static: + name: macOS 15 arm64 compile/static coverage + runs-on: macos-15 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: environment/microvm/go.mod + cache: true + cache-dependency-path: | + engine/go.sum + environment/microvm/go.sum + - name: Compile production daemon and tagged E2E + run: | + cd environment/microvm + GOWORK=off go test ./cmd/mecatl-microvmd + GOWORK=off go build ./cmd/mecatl-microvmd + cd ../.. + go test -tags=microvm_e2e ./internal/adapter/microvm -run '^$' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21540526d..76789e45e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ permissions: # Never run two releases for the same tag concurrently. Do NOT cancel in # progress — a half-signed release is worse than a slow one. concurrency: - group: release-${{ github.ref }} + group: release-${{ inputs.tag || github.ref_name }} cancel-in-progress: false env: @@ -31,8 +31,39 @@ env: GOFLAGS: -mod=readonly jobs: + validate-release-ref: + name: Validate immutable release tag ref + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + env: + VERSION: ${{ inputs.tag || github.ref_name }} + outputs: + signing_ref: ${{ steps.validate.outputs.signing_ref }} + steps: + - name: Require the run itself to use the requested tag ref + id: validate + run: | + set -euo pipefail + case "${VERSION}" in v*) ;; *) echo "release version must name a v* tag: ${VERSION}" >&2; exit 1 ;; esac + signing_ref="refs/tags/${VERSION}" + if [ "${GITHUB_REF}" != "${signing_ref}" ]; then + echo "release ref mismatch: got ${GITHUB_REF}, want ${signing_ref}" >&2 + exit 1 + fi + echo "signing_ref=${signing_ref}" >> "${GITHUB_OUTPUT}" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 1 + ref: ${{ github.sha }} + - name: Assert checkout is the immutable run commit + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + publish: name: Build, publish, sign, attest + needs: validate-release-ref runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: @@ -53,7 +84,10 @@ jobs: persist-credentials: false # ko reads the base image digest from .ko.yaml; no deep history needed. fetch-depth: 1 - ref: ${{ env.VERSION }} + ref: ${{ github.sha }} + + - name: Assert checkout is the immutable run commit + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" # Sibling action refs use literal version tags; fail before publishing if they # do not match this release tag. @@ -72,6 +106,7 @@ jobs: go.sum engine/go.sum authn/oidc/go.sum + environment/microvm/go.sum - uses: ko-build/setup-ko@61b4d1d396f5b2e7d6bb6fefdce3dc38d1a13445 # v0.10 - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 @@ -156,10 +191,373 @@ jobs: subject-digest: ${{ steps.build.outputs.image_digest }} push-to-registry: true + create-release: + name: Create GitHub Release + needs: validate-release-ref + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + env: + VERSION: ${{ inputs.tag || github.ref_name }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Create release before asset uploads + run: gh release view "${VERSION}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1 || gh release create "${VERSION}" --repo "${GITHUB_REPOSITORY}" --verify-tag --generate-notes + + # The opt-in nested module ships host microvmd and Linux guest-agent payloads + # separately from the standard container images. Every published byte is + # digest-addressed, signed, SBOM-described, and provenance-attested. + publish-microvm: + name: Publish microVM artifacts (${{ matrix.platform }}) + needs: [validate-release-ref, create-release, endorse-brood-resolution] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write # upload immutable release assets + id-token: write # keyless Sigstore evidence + strategy: + fail-fast: false + matrix: + platform: [linux-amd64, linux-arm64, darwin-arm64] + env: + VERSION: ${{ inputs.tag || github.ref_name }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 1 + ref: ${{ github.sha }} + + - name: Assert checkout is the immutable run commit + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: environment/microvm/go.mod + cache: true + cache-dependency-path: | + engine/go.sum + environment/microvm/go.sum + + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Download controlled Brood platform resolution + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: brood-endorsed-platforms + path: dist/brood + + - name: Verify endorsed Brood index and platform resolution + run: .github/scripts/verify-brood-resolution.sh dist/brood/brood-base-index.json dist/brood/brood-platforms.json + + - name: Reuse a complete immutable platform asset set + id: existing-assets + env: + GH_TOKEN: ${{ github.token }} + PLATFORM: ${{ matrix.platform }} + run: .github/scripts/reuse-platform-release-assets.sh "${VERSION}" microvm "${PLATFORM}" "${VERSION}" "dist/${PLATFORM}" + + - name: Build deterministic microvmd and guest payloads + if: steps.existing-assets.outputs.reused != 'true' + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + case "${PLATFORM}" in + linux-amd64) guest_arch=amd64 ;; + linux-arm64|darwin-arm64) guest_arch=arm64 ;; + *) echo "unsupported platform ${PLATFORM}" >&2; exit 2 ;; + esac + export MICROVM_RELEASE_EXECUTION_IMAGE_REF + export MICROVM_RELEASE_EXECUTION_IMAGE_MANIFEST_DIGEST + export MICROVM_RELEASE_EXECUTION_IMAGE_TREE_DIGEST + export MICROVM_RELEASE_EXECUTION_IMAGE_DISCOVERY_REFERENCE + export MICROVM_RELEASE_EXECUTION_IMAGE_RESOLUTION_EVIDENCE + export MICROVM_RELEASE_EXECUTION_IMAGE_PLATFORM + MICROVM_RELEASE_EXECUTION_IMAGE_REF="$(jq -er --arg arch "${guest_arch}" '.[$arch].reference' dist/brood/brood-platforms.json)" + MICROVM_RELEASE_EXECUTION_IMAGE_MANIFEST_DIGEST="$(jq -er --arg arch "${guest_arch}" '.[$arch].manifest_digest' dist/brood/brood-platforms.json)" + MICROVM_RELEASE_EXECUTION_IMAGE_TREE_DIGEST="$(jq -er --arg arch "${guest_arch}" '.[$arch].tree_digest' dist/brood/brood-platforms.json)" + MICROVM_RELEASE_EXECUTION_IMAGE_DISCOVERY_REFERENCE="$(jq -er --arg arch "${guest_arch}" '.[$arch].discovery_reference' dist/brood/brood-platforms.json)" + MICROVM_RELEASE_EXECUTION_IMAGE_RESOLUTION_EVIDENCE="$(jq -er --arg arch "${guest_arch}" '.[$arch].resolution_evidence' dist/brood/brood-platforms.json)" + MICROVM_RELEASE_EXECUTION_IMAGE_PLATFORM="$(jq -er --arg arch "${guest_arch}" '.[$arch].platform' dist/brood/brood-platforms.json)" + .github/scripts/package-microvm-release.sh "dist/${PLATFORM}" "${PLATFORM}" "${VERSION}" + + - name: Generate microvmd dependency SBOM + if: steps.existing-assets.outputs.reused != 'true' + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: dist/${{ matrix.platform }}/mecatl-microvmd-${{ matrix.platform }} + format: spdx-json + output-file: dist/${{ matrix.platform }}/mecatl-microvmd-${{ matrix.platform }}.spdx.json + upload-artifact: false + + - name: Generate guest-agent dependency SBOM + if: steps.existing-assets.outputs.reused != 'true' + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: dist/${{ matrix.platform }}/mecatl-guest-agent-${{ matrix.platform }} + format: spdx-json + output-file: dist/${{ matrix.platform }}/mecatl-guest-agent-${{ matrix.platform }}.spdx.json + upload-artifact: false + + - name: Generate artifact-digest dependency SBOM + if: steps.existing-assets.outputs.reused != 'true' + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: dist/${{ matrix.platform }}/mecatl-artifact-digest-${{ matrix.platform }} + format: spdx-json + output-file: dist/${{ matrix.platform }}/mecatl-artifact-digest-${{ matrix.platform }}.spdx.json + upload-artifact: false + + - name: Sign provenance statements (keyless Sigstore) + if: steps.existing-assets.outputs.reused != 'true' + env: + PLATFORM: ${{ matrix.platform }} + run: .github/scripts/sign-microvm-release-evidence.sh "dist/${PLATFORM}" + + - name: Assemble versioned platform bootstrap bundle + if: steps.existing-assets.outputs.reused != 'true' + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + bundle="mecatl-microvm-${VERSION}-${PLATFORM}.tar.gz" + tar --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner \ + --exclude="${bundle}" -C "dist/${PLATFORM}" -cf - . | gzip -n > "dist/${PLATFORM}/${bundle}" + sha="$(sha256sum "dist/${PLATFORM}/${bundle}" | cut -d' ' -f1)" + signing_ref="${{ needs.validate-release-ref.outputs.signing_ref }}" + signing_ref="$(.github/scripts/validate-release-ref.sh "${VERSION}" "${signing_ref}")" + identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@${signing_ref}" + url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${VERSION}/${bundle}" + jq -n --arg version "${VERSION}" --arg platform "${PLATFORM}" --arg url "${url}" --arg sha256 "${sha}" \ + --arg policy_revision "release-${VERSION}" --arg certificate_identity "${identity}" \ + --arg oidc_issuer 'https://token.actions.githubusercontent.com' \ + '{version:$version,platform:$platform,url:$url,sha256:$sha256,policy_revision:$policy_revision,certificate_identity:$certificate_identity,oidc_issuer:$oidc_issuer}' \ + > "dist/${PLATFORM}/microvm-default-${PLATFORM}.json" + + - name: Upload microVM default metadata for mecatui packaging + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: microvm-default-${{ matrix.platform }} + path: dist/${{ matrix.platform }}/microvm-default-${{ matrix.platform }}.json + if-no-files-found: error + retention-days: 1 + + - name: Upload microVM release assets + env: + GH_TOKEN: ${{ github.token }} + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + assets=() + for asset in dist/"${PLATFORM}"/*; do + if [ "${PLATFORM}" != linux-amd64 ] && [ "$(basename "${asset}")" = install-microvm-release.sh ]; then + continue + fi + assets+=("${asset}") + done + .github/scripts/upload-release-assets.sh "${VERSION}" "${assets[@]}" + + resolve-brood-base: + name: Resolve and freeze Brood Box platform bytes + needs: [validate-release-ref, create-release] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + env: + VERSION: ${{ inputs.tag || github.ref_name }} + GH_TOKEN: ${{ github.token }} + BROOD_DISCOVERY_REFERENCE: ghcr.io/stacklok/brood-box/base:latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 1 + ref: ${{ github.sha }} + - name: Assert checkout is the immutable run commit + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: environment/microvm/go.mod + - name: Reuse the tag's frozen resolution or resolve it once + run: | + set -euo pipefail + assets="$(gh release view "${VERSION}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name')" + has_index=false + has_platforms=false + printf '%s\n' "${assets}" | grep -Fx brood-base-index.json >/dev/null && has_index=true + printf '%s\n' "${assets}" | grep -Fx brood-platforms.json >/dev/null && has_platforms=true + if ${has_index} && ${has_platforms}; then + gh release download "${VERSION}" --repo "${GITHUB_REPOSITORY}" --pattern brood-base-index.json --pattern brood-platforms.json + elif ${has_index} || ${has_platforms}; then + echo 'release contains an incomplete frozen Brood resolution' >&2 + exit 1 + else + docker buildx imagetools inspect "${BROOD_DISCOVERY_REFERENCE}" --raw > brood-base-index.json + resolution_evidence="sha256:$(sha256sum brood-base-index.json | cut -d' ' -f1)" + mkdir -p brood-tree + for arch in amd64 arm64; do + manifest="$(jq -er --arg arch "${arch}" '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == $arch) | .digest] | if length == 1 then .[0] else error("expected exactly one linux/" + $arch + " manifest") end' brood-base-index.json)" + reference="ghcr.io/stacklok/brood-box/base@${manifest}" + CGO_ENABLED=0 GOOS=linux GOARCH="${arch}" GOWORK=off go build -trimpath -buildvcs=false -ldflags='-buildid=' -o "brood-tree/digest-${arch}" ./environment/microvm/cmd/mecatl-oci-tree-digest + mkdir -p "brood-tree/cache-${arch}" + tree="$(docker run --rm --platform "linux/${arch}" -v "${PWD}/brood-tree:/release" 'docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce' "/release/digest-${arch}" "${reference}" "/release/cache-${arch}")" + jq -n --arg reference "${reference}" --arg manifest_digest "${manifest}" --arg tree_digest "${tree}" \ + --arg discovery_reference "${BROOD_DISCOVERY_REFERENCE}" --arg resolution_evidence "${resolution_evidence}" --arg platform "linux/${arch}" \ + '{reference:$reference,manifest_digest:$manifest_digest,tree_digest:$tree_digest,discovery_reference:$discovery_reference,resolution_evidence:$resolution_evidence,platform:$platform}' > "brood-${arch}.json" + done + jq -s '{amd64:.[0],arm64:.[1]}' brood-amd64.json brood-arm64.json > brood-platforms.json + fi + .github/scripts/verify-brood-resolution.sh brood-base-index.json brood-platforms.json + .github/scripts/upload-release-assets.sh "${VERSION}" brood-base-index.json brood-platforms.json + - name: Upload frozen Brood resolution handoff + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: brood-platforms + path: | + brood-base-index.json + brood-platforms.json + if-no-files-found: error + retention-days: 1 + + endorse-brood-resolution: + name: Verify Brood resolution for downstream endorsement + needs: [validate-release-ref, resolve-brood-base] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 1 + ref: ${{ github.sha }} + - name: Assert checkout is the immutable run commit + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: brood-platforms + path: dist/brood + - name: Verify raw index evidence and platform cardinality + run: .github/scripts/verify-brood-resolution.sh dist/brood/brood-base-index.json dist/brood/brood-platforms.json + - name: Forward verified resolution to platform release jobs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: brood-endorsed-platforms + path: | + dist/brood/brood-base-index.json + dist/brood/brood-platforms.json + if-no-files-found: error + retention-days: 1 + + # Release-stamped host binaries are the first-run bootstrap. Each platform + # binary embeds only the matching digest-bound microVM bundle defaults and is + # published as a signed/provenanced release asset. mecated is the canonical + # operator surface; mecatui carries the same defaults for compatibility and + # embedded-server use. This is deliberately separate from the optional OCI + # images below. + publish-mecatui-host: + name: Publish host ${{ matrix.binary }} (${{ matrix.platform }}) + needs: [validate-release-ref, publish-microvm] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + platform: [linux-amd64, linux-arm64, darwin-arm64] + binary: [mecated, mecatui] + env: + VERSION: ${{ inputs.tag || github.ref_name }} + SIGNING_REF: ${{ needs.validate-release-ref.outputs.signing_ref }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 1 + ref: ${{ github.sha }} + - name: Assert checkout is the immutable run commit + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: | + go.sum + engine/go.sum + environment/microvm/go.sum + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Download matching microVM bootstrap defaults + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: microvm-default-${{ matrix.platform }} + path: microvm-default + - name: Reuse a complete immutable host asset set + id: existing-assets + env: + GH_TOKEN: ${{ github.token }} + run: .github/scripts/reuse-platform-release-assets.sh "${VERSION}" "${{ matrix.binary }}" "${{ matrix.platform }}" "${VERSION}" dist + - name: Build host binary with matching bootstrap defaults + if: steps.existing-assets.outputs.reused != 'true' + run: | + set -euo pipefail + platform='${{ matrix.platform }}' + binary_name='${{ matrix.binary }}' + case "${platform}" in + linux-amd64) goos=linux; goarch=amd64 ;; + linux-arm64) goos=linux; goarch=arm64 ;; + darwin-arm64) goos=darwin; goarch=arm64 ;; + *) echo "unsupported platform ${platform}" >&2; exit 2 ;; + esac + case "${binary_name}" in + mecated) version_symbol=main.microVMReleaseVersion ;; + mecatui) version_symbol=main.version ;; + *) echo "unsupported host binary ${binary_name}" >&2; exit 2 ;; + esac + MICROVM_RELEASE_DEFAULTS_B64="$(jq -c --arg platform "${platform}" '{($platform):.}' "microvm-default/microvm-default-${platform}.json" | base64 -w0)" + binary="dist/${binary_name}-${VERSION}-${platform}" + mkdir -p dist + CGO_ENABLED=0 GOOS="${goos}" GOARCH="${goarch}" go build -trimpath -buildvcs=false \ + -ldflags="-s -w -X ${version_symbol}=${VERSION} -X main.microVMReleaseDefaultsB64=${MICROVM_RELEASE_DEFAULTS_B64} -X main.microVMReleaseStampRequired=release" \ + -o "${binary}" "./cmd/${binary_name}" + digest="$(sha256sum "${binary}" | cut -d' ' -f1)" + printf '%s %s\n' "${digest}" "$(basename "${binary}")" > "${binary}.sha256" + - name: Generate host binary SBOM + if: steps.existing-assets.outputs.reused != 'true' + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: dist/${{ matrix.binary }}-${{ env.VERSION }}-${{ matrix.platform }} + format: spdx-json + output-file: dist/${{ matrix.binary }}-${{ env.VERSION }}-${{ matrix.platform }}.spdx.json + upload-artifact: false + - name: Sign host binary (keyless Sigstore) + if: steps.existing-assets.outputs.reused != 'true' + run: | + binary="dist/${{ matrix.binary }}-${VERSION}-${{ matrix.platform }}" + cosign sign-blob --yes --bundle "${binary}.sigstore.json" "${binary}" + - name: Attest host binary provenance + if: steps.existing-assets.outputs.reused != 'true' + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: dist/${{ matrix.binary }}-${{ env.VERSION }}-${{ matrix.platform }} + - name: Upload host binary release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + prefix="dist/${{ matrix.binary }}-${VERSION}-${{ matrix.platform }}" + .github/scripts/upload-release-assets.sh "${VERSION}" "${prefix}"* + # The mecatui image carries cmd/mecatui/kodata/agent.yaml through ko's kodata # support and identifies it with the org.stacklok.broodbox.agent OCI label. publish-mecatui: name: Build, publish, sign, attest mecatui + needs: publish-microvm runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: @@ -181,7 +579,23 @@ jobs: persist-credentials: false # ko reads the base image digest from .ko.yaml; no deep history needed. fetch-depth: 1 - ref: ${{ env.VERSION }} + ref: ${{ github.sha }} + + - name: Assert checkout is the immutable run commit + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + + - name: Download versioned microVM platform defaults + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: microvm-default-* + path: microvm-defaults + merge-multiple: true + + - name: Stamp strict microVM release defaults + run: | + set -euo pipefail + jq -s 'map({key:.platform,value:.}) | from_entries' microvm-defaults/microvm-default-*.json > microvm-defaults.json + echo "MICROVM_RELEASE_DEFAULTS_B64=$(base64 -w0 microvm-defaults.json)" >> "$GITHUB_ENV" - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -193,6 +607,7 @@ jobs: go.sum engine/go.sum authn/oidc/go.sum + environment/microvm/go.sum - uses: ko-build/setup-ko@61b4d1d396f5b2e7d6bb6fefdce3dc38d1a13445 # v0.10 - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 @@ -281,6 +696,7 @@ jobs: publish-mecak8s: name: Build, publish, sign, attest mecak8s + needs: validate-release-ref runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: @@ -302,7 +718,11 @@ jobs: persist-credentials: false # ko reads the base image digest from .ko.yaml; no deep history needed. fetch-depth: 1 - ref: ${{ env.VERSION }} + # Bind every publishing job to the immutable commit validated above. + ref: ${{ github.sha }} + + - name: Assert checkout is the immutable run commit + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -424,7 +844,9 @@ jobs: with: persist-credentials: false fetch-depth: 1 - ref: ${{ env.VERSION }} + ref: ${{ github.sha }} + - name: Assert checked-out immutable release commit + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 @@ -517,7 +939,11 @@ jobs: with: persist-credentials: false fetch-depth: 1 - ref: ${{ env.VERSION }} + # Bind every publishing job to the immutable commit validated above. + ref: ${{ github.sha }} + + - name: Assert checkout is the immutable run commit + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 with: diff --git a/.ko.yaml b/.ko.yaml index b63ec9e92..afbca0200 100644 --- a/.ko.yaml +++ b/.ko.yaml @@ -36,6 +36,8 @@ builds: ldflags: - -s -w - '{{with index .Env "VERSION"}}-X github.com/stacklok/mecatl/internal/buildinfo.BuildID={{.}}{{end}}' + - -X main.microVMReleaseVersion={{.Env.VERSION}} + - -X main.microVMReleaseDefaultsB64={{.Env.MICROVM_RELEASE_DEFAULTS_B64}} # mecak8s (ADR 0048): the storage-free k8s-native agent binary. Same distroless # base + build flags as mecated — it is a thin peer of mecated that composes # app.Build with k8s-native defaults (Redis store + k8s lease + drain gate). @@ -62,3 +64,5 @@ builds: ldflags: - -s -w - '{{with index .Env "VERSION"}}-X github.com/stacklok/mecatl/internal/buildinfo.BuildID={{.}}{{end}}' + - -X main.version={{.Env.VERSION}} + - -X main.microVMReleaseDefaultsB64={{.Env.MICROVM_RELEASE_DEFAULTS_B64}} diff --git a/README.md b/README.md index 85d061530..4250e4e89 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ Read the [Mecatl documentation](https://mecatl.dev/docs/intro) to get started. terminal client that can host a local server or connect to a remote one. - A Kubernetes-native reference runtime that combines Redis-backed state, Kubernetes session leases, drain handling, and disposable replicas. +- Per-session execution environments, including an opt-in local microVM backend + that keeps model providers and credentials on the host while filesystem tools + and Bash run inside the VM. ## Get started @@ -113,6 +116,42 @@ For an embedded deployment, see the > before binding it off-loopback. The [operator guide](./docs/usage.md) covers > bearer auth, TLS/mTLS, OIDC, rate limits, and deployment posture. +## Local microVM execution + +For optional isolated local execution on Linux amd64, install and authenticate the +release-stamped host binary for the journey you use: `mecatui` for an embedded +interactive session, or `mecated` for a headless HTTP/gRPC server. Installing both is +optional. Follow the [verified host-binary installation steps](./docs/usage/microvm-environments.md#before-either-journey) before running either command sequence: + +```sh +# Interactive, mecatui-only journey: +mecatui microvm doctor +mecatui --default-placement microvm-local + +# Or headless, mecated-only journey: +mecated microvm doctor +mecated serve --headless --default-placement microvm-local +# --headless declares an unattended server, so child asks do not wait for a local UI. +# Ordinary POST /v1/sessions uses the deployment default; clients send no path or placement. +``` + +Release binaries verify and prepare the required runtime when the deployment selects the +provider. Source builds do not support this profile; repository developers can use the +separate [developer source workflow](./docs/usage/microvm-environments.md#developer-source-workflow). +`microvm doctor` and `microvm status` are read-only. Guest IPv4 egress is permissive +by default; external IPv6 is unrouted and unsupported. A local composition root can +instead select `--microvm-guest-egress=deny-all` or +`--microvm-guest-egress=allowlist` with repeatable +`--microvm-guest-allow=HOST:PORT/tcp|udp` rules (use `mecated serve --headless ...`, or +local embedded `mecatui --default-placement microvm-local ...`). The allowlist +requires at least one valid hostname rule; invalid input or enforcement failure stops +startup. HTTP/gRPC clients and project configuration cannot set or weaken this +host-only policy. Agent edits live in the host session worktree printed by +`mecated microvm status` or `mecatui microvm status`, not in the original checkout. +The [local microVM operator guide](./docs/usage/microvm-environments.md) covers +verified installation, headless use, guest-egress controls, host-versus-guest +boundaries, and platform limits. + ## User documentation - [Mecatl documentation](https://mecatl.dev/docs/intro) for user guides and diff --git a/Taskfile.yml b/Taskfile.yml index 4629c023e..a3f50a06d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -70,6 +70,9 @@ tasks: # authn/oidc is the opt-in caller-identity adapter module (ADR 0206), # separate from both the dependency-free engine and provider modules. - cd authn/oidc && go build ./... + # environment/microvm is the opt-in local microVM runtime module (ADR 0224). + # It remains isolated from the root and engine dependency graphs. + - cd environment/microvm && go build ./... # provider/ holds the opt-in LLM-provider Go submodules (ADR 0093), each its # own module under the workspace. Building them here keeps the importable # adapters compiling alongside the binaries. @@ -252,15 +255,140 @@ tasks: - cd provider/openai && go test ./... - cd provider/openaichat && go test ./... + microvm:build: + desc: Build the opt-in microvmd and Linux guest-agent artifacts without widening standard binaries. + cmds: + - mkdir -p bin/microvm + - cd environment/microvm && GOWORK=off go build -o ../../bin/microvm/mecatl-microvmd ./cmd/mecatl-microvmd + - cd environment/microvm && GOWORK=off CGO_ENABLED=0 GOOS=linux go build -o ../../bin/microvm/mecatl-guest-agent ./cmd/mecatl-guest-agent + + microvm:package: + desc: Package the independently admitted guest-agent artifact. + deps: [microvm:build] + cmds: + - mkdir -p bin/microvm/guest-agent-artifact + - cp bin/microvm/mecatl-guest-agent bin/microvm/guest-agent-artifact/mecatl-guest-agent + - tar -C bin/microvm/guest-agent-artifact -cf bin/microvm/mecatl-guest-agent-artifact.tar mecatl-guest-agent + + microvm:dev:prepare: + desc: Prepare an unsupported signed local development release under .scratch (Linux amd64; network/tooling required). + cmds: + - .github/scripts/prepare-microvm-development-release.sh "{{.BUILD_ID}}" + - MECATL_MICROVM_DEV_RELEASE_DESCRIPTOR="$(pwd)/.scratch/microvm-dev/linux-amd64/release.json" go test -tags=microvm_dev -run '^TestPreparedDevelopmentReleaseBundleIsImportable$' ./internal/adapter/microvmmanager + + microvm:dev:build: + desc: Build microvm_dev-tagged mecated and mecatui binaries under .scratch; ordinary task build is unchanged. + cmds: + - mkdir -p .scratch/microvm-dev/bin + - go build -tags=microvm_dev -ldflags "{{.BUILD_LDFLAGS}}" -o .scratch/microvm-dev/bin/mecated ./cmd/mecated + - go build -tags=microvm_dev -ldflags "{{.BUILD_LDFLAGS}}" -o .scratch/microvm-dev/bin/mecatui ./cmd/mecatui + + microvm:dev:e2e: + desc: Run the local-development descriptor/tag gate, then the existing Linux amd64 KVM journey (network/KVM required). + deps: [microvm:dev:prepare, microvm:dev:build] + cmds: + - task: test:microvm-dev-tag + - task: e2e:microvm + + microvm:e2e:mecatui-build: + desc: Build the production mecatui command with one generated E2E release-default projection. + requires: + vars: [MECATL_MICROVM_E2E_MECATUI_BIN, MECATL_MICROVM_E2E_VERSION, MECATL_MICROVM_E2E_DEFAULTS_B64] + cmds: + - mkdir -p "$(dirname \"{{.MECATL_MICROVM_E2E_MECATUI_BIN}}\")" .scratch/go-build + - GOTMPDIR="$(pwd)/.scratch/go-build" go build -trimpath -o "{{.MECATL_MICROVM_E2E_MECATUI_BIN}}" -ldflags "-X main.version={{.MECATL_MICROVM_E2E_VERSION}} -X main.microVMReleaseDefaultsB64={{.MECATL_MICROVM_E2E_DEFAULTS_B64}}" ./cmd/mecatui + + e2e:microvm: + desc: Run the required Linux amd64 KVM microVM MVP journey (network required; never part of task test). + cmds: + - cmd: | + set -eu + if [ "${MECATL_MICROVM_E2E_PREPARED:-}" != "1" ]; then + ./environment/microvm/e2e/prepare.sh + fi + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) PLATFORM=linux-amd64 ;; + *) echo "microVM MVP E2E requires Linux amd64 KVM; got $(uname -s)/$(uname -m)" >&2; exit 1 ;; + esac + E2E_ROOT="$(pwd)/.scratch/microvm-e2e/${PLATFORM}" + RUN_ROOT_BASE="$(pwd)/.scratch/microvm-e2e/run" + rm -rf "${RUN_ROOT_BASE}" + mkdir -p "${RUN_ROOT_BASE}" + case "$(uname -s)" in + Linux) E2E_STATE_ROOT=/dev/shm ;; + Darwin) E2E_STATE_ROOT=/private/tmp ;; + esac + MICROVMD_BIN="${E2E_ROOT}/mecatl-microvmd" + INSTALL_ROOT="${E2E_STATE_ROOT}/mecatl-release-install-${PLATFORM}-$$" + rm -rf "${INSTALL_ROOT}" + trap 'rm -rf "${INSTALL_ROOT}"' EXIT + (cd environment/microvm && GOWORK=off go build -o "${MICROVMD_BIN}" ./cmd/mecatl-microvmd) + # The focused live test crosses ordinary first-use readiness and the + # production UDS through the server-owned microVM default. It then drives + # a normal CreateSession agent loop whose Bash call reads and changes the + # isolated guest worktree, and proves that change cannot reach the source. + DEFAULT_LIVE_TEST_NAMES='TestMicroVMDefaultPlacementDailyHarnessJourney' + run_live_tests() { + LIVE_TESTS=$1 + RUN_ROOT=$2 + EXPECTED_TEST=${3:-} + rm -rf "${RUN_ROOT}" + mkdir -p "${RUN_ROOT}/artifact-cache" + if ! TEST_OUTPUT=$( \ + MECATL_MICROVMD_BIN="${MICROVMD_BIN}" \ + MECATL_MICROVM_E2E_ROOT="${RUN_ROOT}" \ + MECATL_MICROVM_E2E_SOCKET_ROOT="${E2E_STATE_ROOT}" \ + MECATL_MICROVM_ARTIFACT_CACHE="${RUN_ROOT}/artifact-cache" \ + MECATL_MICROVM_RELEASE_MANIFEST="${E2E_ROOT}/package/microvm-release-${PLATFORM}.json" \ + MECATL_MICROVM_RELEASE_ASSETS="${E2E_ROOT}/package" \ + MECATL_MICROVM_RELEASE_PUBLIC_KEY="${E2E_ROOT}/release-fixture/publisher.pub" \ + MECATL_MICROVM_INSTALLER="$(pwd)/.github/scripts/install-microvm-release.sh" \ + MECATL_MICROVM_INSTALL_ROOT="${INSTALL_ROOT}" \ + MECATL_MICROVM_RUNTIME_DIR="${E2E_ROOT}/runtime" \ + MECATL_MICROVM_FIRMWARE_DIR="${E2E_ROOT}/firmware" \ + MECATL_MICROVM_ROOTFS_DIR="${E2E_ROOT}/rootfs" \ + MECATL_MICROVM_GUEST_AGENT_DIR="${E2E_ROOT}/guest-agent" \ + go test -v -count=1 -timeout=40m -tags=microvm_e2e -run "${LIVE_TESTS}" ./internal/adapter/microvm 2>&1 + ); then + printf '%s\n' "${TEST_OUTPUT}" + return 1 + fi + printf '%s\n' "${TEST_OUTPUT}" + if ! printf '%s\n' "${TEST_OUTPUT}" | grep -Eq '^=== RUN '; then + echo "microVM E2E regex discovered no tests: ${LIVE_TESTS}" >&2 + return 1 + fi + if [ -n "${EXPECTED_TEST}" ] && ! printf '%s\n' "${TEST_OUTPUT}" | grep -Fq -- "=== RUN ${EXPECTED_TEST}"; then + echo "named microVM E2E test was not executed: ${EXPECTED_TEST}" >&2 + return 1 + fi + } + if [ -n "${MECATL_MICROVM_E2E_TESTS:-}" ]; then + # An operator-provided regex remains one ordinary go test invocation, + # but a no-match is an error rather than a false-green run. + run_live_tests "${MECATL_MICROVM_E2E_TESTS}" "${RUN_ROOT_BASE}" + else + # Each default journey gets a fresh cache. Successful state is removed + # before the next journey; a failure remains available for diagnosis. + for LIVE_TEST in ${DEFAULT_LIVE_TEST_NAMES}; do + TEST_RUN_ROOT="${RUN_ROOT_BASE}/${LIVE_TEST}" + run_live_tests "^${LIVE_TEST}$" "${TEST_RUN_ROOT}" "${LIVE_TEST}" + rm -rf "${TEST_RUN_ROOT}" + done + fi + # Keep the current composition contract beside the live journey. + go test -count=1 -run '^TestMicroVMDefaultPlacementUsesNormalCreateSessionAndExactReattach$' ./internal/app + test: - desc: Run unit tests with the race detector (root + engine + authn/oidc + provider modules + standalone hygiene proofs) + desc: Run unit tests with the race detector (root + engine + opt-in modules + standalone hygiene proofs) cmds: - go test -race ./... # engine/ is a separate module under the workspace — `./...` from the root # does NOT cross the module boundary, so its tests are a second invocation. - cd engine && go test -race ./... - # The opt-in OIDC adapter is a separate non-provider module. + # The opt-in OIDC adapter and microVM runtime each remain separate modules. - cd authn/oidc && go test -race ./... + - cd environment/microvm && go test -race ./... # provider/ submodules (ADR 0093) — each its own module under the workspace; # the per-module `./...` invocation runs their tests through the workspace. - cd provider/ssefilter && go test -race ./... @@ -269,6 +397,8 @@ tasks: - cd provider/openaichat && go test -race ./... - task: test:engine-standalone - task: test:authn-standalone + - task: test:microvm-standalone + - task: test:microvm-dev-tag - task: test:provider-standalone test:authn-standalone: @@ -277,6 +407,17 @@ tasks: - cd authn/oidc && GOWORK=off go build ./... - cd authn/oidc && GOWORK=off go test ./... + test:microvm-dev-tag: + desc: Compile and run the offline developer-only MicroVM activation gates. + cmds: + - go test -tags=microvm_dev ./internal/adapter/microvmmanager ./cmd/mecated ./cmd/mecatui + + test:microvm-standalone: + desc: Prove the opt-in microVM module builds and tests with GOWORK=off. + cmds: + - cd environment/microvm && GOWORK=off go build ./... + - cd environment/microvm && GOWORK=off go test ./... + test:engine-standalone: desc: | Prove the engine module builds + tests with NO workspace and NO host-repo @@ -634,6 +775,9 @@ tasks: - cd authn/oidc && go test -race -coverprofile=../../coverage/authn-oidc.out -covermode=atomic ./... - go tool cover -func=coverage/authn-oidc.out - go tool cover -html=coverage/authn-oidc.out -o coverage/authn-oidc.html + - cd environment/microvm && go test -race -coverprofile=../../coverage/environment-microvm.out -covermode=atomic ./... + - go tool cover -func=coverage/environment-microvm.out + - go tool cover -html=coverage/environment-microvm.out -o coverage/environment-microvm.html # provider/ submodules (ADR 0093) — sibling profiles per module. - cd provider/ssefilter && go test -race -coverprofile=../../coverage/provider-ssefilter.out -covermode=atomic ./... - cd provider/anthropic && go test -race -coverprofile=../../coverage/provider-anthropic.out -covermode=atomic ./... @@ -661,9 +805,12 @@ tasks: # single source of truth across the module boundary. - 'cd engine && "$GOLANGCI_LINT" run --config ../.golangci.yml --allow-parallel-runners ./...' - cd engine && go vet ./... - # authn/oidc is a separate opt-in adapter module, sharing the root config. + # authn/oidc and environment/microvm are separate opt-in adapter modules, + # sharing the root config. - 'cd authn/oidc && "$GOLANGCI_LINT" run --config ../../.golangci.yml --allow-parallel-runners ./...' - cd authn/oidc && go vet ./... + - 'cd environment/microvm && "$GOLANGCI_LINT" run --config ../../.golangci.yml --allow-parallel-runners ./...' + - cd environment/microvm && go vet ./... # provider/ submodules (ADR 0093) — each its own module; sharing the ROOT # golangci config so the depguard allowlists stay the single source of truth. - 'cd provider/ssefilter && "$GOLANGCI_LINT" run --config ../../.golangci.yml --allow-parallel-runners ./...' @@ -702,8 +849,9 @@ tasks: # module boundary, so its scan is a second invocation against engine/go.mod. # No allowlist: the engine library's closure is clean and must stay clean. - cmd: set -o pipefail; cd engine && go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 -format json ./... | go run ../.github/scripts/govulncheck-gate.go # v1.1.4 - # Opt-in OIDC module: independently scan its security-sensitive closure. + # Opt-in OIDC and microVM modules: independently scan their sensitive closures. - cmd: set -o pipefail; cd authn/oidc && go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 -format json ./... | go run ../../.github/scripts/govulncheck-gate.go # v1.1.4 + - cmd: set -o pipefail; cd environment/microvm && go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 -format json ./... | go run ../../.github/scripts/govulncheck-gate.go # v1.1.4 # provider/ submodules (ADR 0093) — each its own module; scan against its # own go.mod so the provider library's closure is checked independently. - cmd: set -o pipefail; cd provider/ssefilter && go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 -format json ./... | go run ../../.github/scripts/govulncheck-gate.go # v1.1.4 @@ -723,6 +871,7 @@ tasks: - '"$GOLANGCI_LINT" run --allow-parallel-runners --fix ./...' - 'cd engine && "$GOLANGCI_LINT" run --config ../.golangci.yml --allow-parallel-runners --fix ./...' - 'cd authn/oidc && "$GOLANGCI_LINT" run --config ../../.golangci.yml --allow-parallel-runners --fix ./...' + - 'cd environment/microvm && "$GOLANGCI_LINT" run --config ../../.golangci.yml --allow-parallel-runners --fix ./...' - 'cd provider/ssefilter && "$GOLANGCI_LINT" run --config ../../.golangci.yml --allow-parallel-runners --fix ./...' - 'cd provider/anthropic && "$GOLANGCI_LINT" run --config ../../.golangci.yml --allow-parallel-runners --fix ./...' - 'cd provider/openai && "$GOLANGCI_LINT" run --config ../../.golangci.yml --allow-parallel-runners --fix ./...' @@ -747,6 +896,7 @@ tasks: - task: lint:reusable-pins - task: lint:action-templates - bash .github/scripts/lint-vet-workflow_test.sh + - bash .github/scripts/microvm-ci-release_test.sh lint:reusable-pins: desc: | @@ -782,7 +932,7 @@ tasks: - cmd: test -z "$(gofmt -l .)" || { echo "gofmt needs to run on:"; gofmt -l .; exit 1; } tidy: - desc: Tidy root, engine, authn/oidc, and standalone-ready provider module manifests, then sync the workspace + desc: Tidy root, engine, and opt-in module manifests, then sync the workspace cmds: # INVARIANT — do NOT reorder these steps. The order # (1) root `go mod tidy` → (2) `go work sync` → (3) each standalone @@ -799,6 +949,9 @@ tasks: # PrincipalFromClaims/GrantTypeFromClaims is published, this command is an # intentional release-sequencing gate and may fail against the older tag. - cd authn/oidc && GOWORK=off go mod tidy + # The microVM runtime is isolated in its own module; its tools-tag anchor + # retains the future runtime dependency without linking it by default. + - cd environment/microvm && GOWORK=off go mod tidy # All provider modules now use resolvable standalone dependencies. The # OpenAI-family modules pin the ssefilter module to an immutable revision # until the provider module release tags are cut. diff --git a/cmd/mecated/command.go b/cmd/mecated/command.go index 2524f77cd..4f69ac1a0 100644 --- a/cmd/mecated/command.go +++ b/cmd/mecated/command.go @@ -81,6 +81,17 @@ func resolveCommand(argv []string) commandResolution { return resolveMCPSubcommand(args) } + // Local microVM administration is a handled one-shot. It runs before daemon + // configuration, provider construction, and listener setup. + if first == "microvm" { + return commandResolution{ + handled: true, + run: subcommandAction(func(stdin io.Reader, stdout, _ io.Writer) error { + return runLocalMicroVMCommand(args[2:], stdin, stdout) + }), + } + } + // `mecated import` is an offline migration command. It never starts a // listener or constructs an LLM provider. if first == "import" { @@ -234,6 +245,7 @@ func writeTopLevelHelp(out io.Writer) { _, _ = fmt.Fprintf(out, " serve start the network daemon (gRPC + HTTP/SSE)\n") _, _ = fmt.Fprintf(out, " acp serve the Agent Client Protocol over stdio\n") _, _ = fmt.Fprintf(out, " mcp login SERVER [flags] authorize an operator-configured OAuth MCP server\n") + _, _ = fmt.Fprintf(out, " microvm doctor|status|delete administer local microVM state (offline)\n") _, _ = fmt.Fprintf(out, " import import a Codex or Claude Code session, skills, and workspace files\n") _, _ = fmt.Fprintf(out, " config init write/print the operator settings.yaml skeleton (--print, --force)\n") _, _ = fmt.Fprintf(out, " config validate validate operator settings.yaml without writing (--file, --learning-patch)\n") @@ -247,7 +259,7 @@ func writeTopLevelHelp(out io.Writer) { // unknownCommandError builds the error message for an unknown leading bare word. func unknownCommandError(arg string) error { - return fmt.Errorf("unknown command %q\n\nAvailable commands:\n serve start the network daemon (gRPC + HTTP/SSE)\n acp serve the Agent Client Protocol over stdio\n mcp MCP OAuth login\n import import Codex or Claude Code data\n config configuration management\n skills skill management\n perf-mcp perf MCP utilities\n\nRun 'mecated --help' for command-specific flags", arg) + return fmt.Errorf("unknown command %q\n\nAvailable commands:\n serve start the network daemon (gRPC + HTTP/SSE)\n acp serve the Agent Client Protocol over stdio\n mcp MCP OAuth login\n microvm local microVM administration\n import import Codex or Claude Code data\n config configuration management\n skills skill management\n perf-mcp perf MCP utilities\n\nRun 'mecated --help' for command-specific flags", arg) } func resolveMCPSubcommand(args []string) commandResolution { diff --git a/cmd/mecated/helpmeta.go b/cmd/mecated/helpmeta.go index f65d8d5cc..9028a122b 100644 --- a/cmd/mecated/helpmeta.go +++ b/cmd/mecated/helpmeta.go @@ -69,10 +69,13 @@ var flagMetaByFlag = map[string]flagMeta{ // Advanced — an operator running mecated by hand never sets them — and // server-boundary, so absent from ACP help (a stdio ACP client already has // its parent's lifetime and needs no socket or readiness barrier). - "grpc-unix-socket": {group: groupServer, common: false, acp: acpExclude}, - "ready-file": {group: groupServer, common: false, acp: acpExclude}, - "lifetime-pipe-fd": {group: groupServer, common: false, acp: acpExclude}, - "metrics-addr": {group: groupServer, common: false, acp: acpExclude}, + "grpc-unix-socket": {group: groupServer, common: false, acp: acpExclude}, + "ready-file": {group: groupServer, common: false, acp: acpExclude}, + "lifetime-pipe-fd": {group: groupServer, common: false, acp: acpExclude}, + "metrics-addr": {group: groupServer, common: false, acp: acpExclude}, + "microvm-guest-egress": {group: groupServer, common: false, acp: acpExclude}, + "microvm-guest-allow": {group: groupServer, common: false, acp: acpExclude}, + "default-placement": {group: groupWorkspaceSession, common: true, acp: acpExclude}, // ── Security (serve-only) ───────────────────────────────────────────── "auth-token": {group: groupSecurity, common: true, acp: acpExclude}, diff --git a/cmd/mecated/main.go b/cmd/mecated/main.go index e20e25df4..cace4a695 100644 --- a/cmd/mecated/main.go +++ b/cmd/mecated/main.go @@ -45,11 +45,15 @@ import ( mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" "github.com/stacklok/mecatl/engine/agent" "github.com/stacklok/mecatl/engine/port" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" "github.com/stacklok/mecatl/internal/adapter/agents" "github.com/stacklok/mecatl/internal/adapter/daemonconfig" "github.com/stacklok/mecatl/internal/adapter/mcpauthority" "github.com/stacklok/mecatl/internal/adapter/mcpbroker" "github.com/stacklok/mecatl/internal/adapter/mcpperf" + microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" "github.com/stacklok/mecatl/internal/adapter/server" "github.com/stacklok/mecatl/internal/adapter/skills" "github.com/stacklok/mecatl/internal/adapter/slogdiag" @@ -61,6 +65,12 @@ import ( "github.com/stacklok/mecatl/internal/configgen" ) +var ( + microVMReleaseVersion = "dev" + microVMReleaseDefaultsB64 string + microVMReleaseStampRequired string +) + // TRUST MODEL (security): the mecated API exposes command and file execution // against the configured workspace. The default listen addresses below bind the // loopback interface (single-user localhost). Authentication is OPTIONAL and @@ -122,15 +132,19 @@ type config struct { // toolhiveLLMFlags holds --toolhive-llm / --toolhive-llm-base-url (issue // #262: auto-detecting the ToolHive LLM gateway proxy), applied onto // app.Config in appConfig alongside providerFlags. - toolhiveLLMFlags *cliconfig.ToolhiveLLMFlags - useMock bool - mockScript string - mockProvider port.LLMProvider - storeDir string - shell string - noBash bool - authorityEvaluator string - cedarAuthorityPolicy string + toolhiveLLMFlags *cliconfig.ToolhiveLLMFlags + useMock bool + mockScript string + mockProvider port.LLMProvider + storeDir string + shell string + noBash bool + authorityEvaluator string + cedarAuthorityPolicy string + defaultPlacement string + microVMGuestEgress microvmmanager.GuestEgressSelection + microVMDevRelease string + microVMDevAcknowledge bool // Context management: the compaction strategy and the token counter. Both // default to the current behaviour exactly (heuristic compactor + heuristic @@ -947,6 +961,9 @@ func run(mode commandMode, remaining []string) error { } composition := appConfig(cfg, sink, mainScoped, roleScoper, obs.metrics, diag) + if err := configureDefaultPlacement(&composition, cfg, diag); err != nil { + return err + } built, err := app.Build(ctx, composition) if err != nil { return err @@ -1064,6 +1081,97 @@ const mecatedServerImplementation = "mecated" // appConfig constructs the command root's declarative app.Config. app.Build loads the // injected provider credential after resolving operator definitions. +func configureDefaultPlacement(cfg *app.Config, command config, diag port.Diagnostics) error { + if command.defaultPlacement != microvmmanager.Alias { + return nil + } + var egress []microvmmanager.GuestEgressSelection + if command.cliExplicit["microvm-guest-egress"] || command.cliExplicit["microvm-guest-allow"] { + egress = append(egress, command.microVMGuestEgress) + } + return configureMicroVMLocalProfile(cfg, diag, command.microVMDevRelease, command.microVMDevAcknowledge, egress...) +} + +func microVMReadyRequestWithDevelopment(descriptor string, acknowledge bool, egress ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + request, enabled, err := microVMDevelopmentReadyRequest(descriptor, acknowledge, buildinfo.BuildID, microVMReleaseStampRequired != "" || microVMReleaseDefaultsB64 != "", egress...) + if enabled || err != nil { + return request, err + } + return microVMReadyRequest(egress...) +} + +func microVMReadyRequest(egress ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + return microvmmanager.ReadyRequestFromDefaults(microVMReleaseDefaultsB64, microVMReleaseVersion, egress...) +} + +func withMicroVMReadinessDiagnostics(ctx context.Context, diag port.Diagnostics) context.Context { + return microvmmanager.WithReadinessObserver(ctx, func(stage microvmmanager.ReadinessStage, message string) { + if diag != nil { + diag.Log(ctx, port.LevelInfo, "microvm-local readiness", "stage", string(stage), "status", message) + } + }) +} + +type microVMReadyManager interface { + EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) +} + +func configureMicroVMLocalProfile(cfg *app.Config, diag port.Diagnostics, developmentDescriptor string, developmentAcknowledge bool, egress ...microvmmanager.GuestEgressSelection) error { + if developmentDescriptor != "" || developmentAcknowledge { + if _, err := microVMReadyRequestWithDevelopment(developmentDescriptor, developmentAcknowledge, egress...); err != nil { + return err + } + if diag != nil { + diag.Log(context.Background(), port.LevelInfo, "microvm-local development release enabled", "support", "unsupported", "trust", "local-artifacts") + } + } + manager, endpoint, err := microvmmanager.DefaultLocal() + if err != nil { + return err + } + return configureMicroVMLocalProfileWithManager(cfg, diag, manager, endpoint, developmentDescriptor, developmentAcknowledge, egress...) +} + +func configureMicroVMLocalProfileWithManager(cfg *app.Config, diag port.Diagnostics, manager microVMReadyManager, endpoint, developmentDescriptor string, developmentAcknowledge bool, egress ...microvmmanager.GuestEgressSelection) error { + if manager == nil || endpoint == "" { + return errors.New("microvm-local readiness manager and endpoint are required") + } + if len(egress) > 1 { + return errors.New("multiple guest egress selections supplied") + } + readiness := func(ctx context.Context) error { + request, requestErr := microVMReadyRequestWithDevelopment(developmentDescriptor, developmentAcknowledge, egress...) + if requestErr != nil { + return requestErr + } + ctx = withMicroVMReadinessDiagnostics(ctx, diag) + readyEndpoint, readyErr := manager.EnsureReady(ctx, request) + if readyErr != nil { + return readyErr + } + if readyEndpoint != endpoint { + return fmt.Errorf("microvm-local readiness returned unexpected endpoint %q", readyEndpoint) + } + return nil + } + const scope server.PlacementScope = "deployment" + provider, err := microvmadapter.NewPlacementProvider(endpoint, cfg.Workspace, microvmmanager.Alias, scope, readiness) + if err != nil { + return err + } + cfg.PlacementProvider = provider + cfg.PlacementScope = scope + if cfg.EnvironmentForkers == nil { + cfg.EnvironmentForkers = make(map[session.EnvironmentKind]tool.EnvironmentForker) + } + if cfg.EnvironmentMergers == nil { + cfg.EnvironmentMergers = make(map[session.EnvironmentKind]tool.EnvironmentMerger) + } + cfg.EnvironmentForkers[session.EnvironmentKind("microvm")] = provider + cfg.EnvironmentMergers[session.EnvironmentKind("microvm")] = provider + return nil +} + func appConfig(cfg config, sink port.EventSink, recorder port.ToolCallRecorder, roleScoper func(string) (port.EventSink, port.ToolCallRecorder), metrics *telemetry.Metrics, diag port.Diagnostics) app.Config { out := app.Config{ Workspace: cfg.workspace, @@ -1548,6 +1656,7 @@ func parseFlagsModeOut(mode commandMode, argv []string, out io.Writer) (*flag.Fl fs := flag.NewFlagSet("mecated", flag.ContinueOnError) fs.SetOutput(out) var cfg config + cfg.microVMGuestEgress = microvmmanager.NewGuestEgressSelection() cwd, _ := os.Getwd() @@ -1589,6 +1698,10 @@ func parseFlagsModeOut(mode commandMode, argv []string, out io.Writer) (*flag.Fl fs.StringVar(&cfg.authorityEvaluator, "authority-evaluator", "local", "authority evaluator: local (default), noop, or cedar; cedar requires --cedar-authority-policy") fs.StringVar(&cfg.cedarAuthorityPolicy, "cedar-authority-policy", "", "path to the static operator Cedar authority policy; read once at startup when --authority-evaluator=cedar") fs.BoolVar(&cfg.noBash, "no-bash", false, "disable the Bash tool entirely (shell-less mode); overrides --shell") + fs.StringVar(&cfg.defaultPlacement, "default-placement", "", "deployment default placement provider (microvm-local or empty for host-local)") + fs.Var(cfg.microVMGuestEgress.ModeValue(), "microvm-guest-egress", "microvm-local guest egress: permissive, deny-all, or allowlist; omitted preserves an existing local policy (first use: permissive); host-operator policy, never client-selectable") + fs.Var(cfg.microVMGuestEgress.AllowValue(), "microvm-guest-allow", "allow one microvm-local guest destination as HOST:PORT/tcp|udp (repeatable; requires --microvm-guest-egress=allowlist; hostnames only, no IP literals or wildcards)") + registerMicroVMDevelopmentFlags(fs, &cfg.microVMDevRelease, &cfg.microVMDevAcknowledge) fs.StringVar(&cfg.compaction, "compaction", "heuristic", "compaction strategy: \"heuristic\" (default, single-summary) or \"cascade\" (tiered snip→strip→collapse→summarize)") fs.StringVar(&cfg.tokenizer, "tokenizer", "heuristic", "token counter for the compaction trigger: \"heuristic\" (default, dependency-free) or \"tiktoken\" (offline tiktoken vocab)") @@ -1792,6 +1905,12 @@ func parseFlagsModeOut(mode commandMode, argv []string, out io.Writer) (*flag.Fl // composition can let CLI out-rank the operator-global settings.yaml posture: key // and WARN if an alias raised above an explicit lower --posture. recordExplicitFlags(fs, &cfg) + if err := validateMicroVMPlacementFlags(mode, cfg); err != nil { + return fs, config{}, err + } + if err := cfg.microVMGuestEgress.Validate(); err != nil { + return fs, config{}, err + } // Default the schedule-fire retention to 7d when the operator did not set it // explicitly (ADR 0059 decision #7 Phase-2, ADR 0073): the scheduler is ON by @@ -1916,6 +2035,33 @@ func recordExplicitFlags(fs *flag.FlagSet, cfg *config) { }) } +func validateMicroVMPlacementFlags(mode commandMode, cfg config) error { + if cfg.defaultPlacement != "" && cfg.defaultPlacement != microvmmanager.Alias { + return fmt.Errorf("unsupported --default-placement %q (supported: %s)", cfg.defaultPlacement, microvmmanager.Alias) + } + egressSet := cfg.cliExplicit["microvm-guest-egress"] || cfg.cliExplicit["microvm-guest-allow"] + if egressSet && cfg.defaultPlacement != microvmmanager.Alias { + return errors.New("microVM guest egress flags require --default-placement microvm-local") + } + if mode == modeACP && egressSet { + return errors.New("microVM guest egress flags are supported only by 'mecated serve'") + } + return validateMicroVMDevelopmentFlags(mode, cfg) +} + +func validateMicroVMDevelopmentFlags(mode commandMode, cfg config) error { + if mode != modeServe && (cfg.microVMDevRelease != "" || cfg.microVMDevAcknowledge) { + return errors.New("microVM development release flags are supported only by 'mecated serve'") + } + if (cfg.microVMDevRelease == "") != !cfg.microVMDevAcknowledge { + return errors.New("--microvm-dev-release and --microvm-dev-acknowledge-untrusted-local-artifacts are required together") + } + if cfg.microVMDevRelease != "" && cfg.defaultPlacement != microvmmanager.Alias { + return errors.New("microVM development release flags require --default-placement microvm-local") + } + return nil +} + // applyScheduleFireRetentionDefault sets the schedule-fire retention to 7 days // when the operator did not pass --schedule-fire-retention explicitly. The // scheduler is ON by default on any schedule-capable store (ADR 0073), so the diff --git a/cmd/mecated/microvm_command.go b/cmd/mecated/microvm_command.go new file mode 100644 index 000000000..9df429304 --- /dev/null +++ b/cmd/mecated/microvm_command.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "io" + "os" + + "golang.org/x/term" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" + "github.com/stacklok/mecatl/internal/microvmcmd" +) + +var newLocalMicroVMManager = func() (microvmcmd.Manager, error) { + manager, _, err := microvmmanager.DefaultLocal() + return manager, err +} + +func runLocalMicroVMCommand(args []string, stdin io.Reader, stdout io.Writer) error { + if microVMReleaseStampRequired != "" { + if _, err := microVMReadyRequest(); err != nil { + return err + } + } + manager, err := newLocalMicroVMManager() + if err != nil { + return err + } + interactive := false + if inFile, inOK := stdin.(*os.File); inOK { + if outFile, outOK := stdout.(*os.File); outOK { + interactive = term.IsTerminal(int(inFile.Fd())) && term.IsTerminal(int(outFile.Fd())) + } + } + return microvmcmd.Run(context.Background(), microvmcmd.FrontendMecated, args, stdin, stdout, manager, interactive) +} diff --git a/cmd/mecated/microvm_command_test.go b/cmd/mecated/microvm_command_test.go new file mode 100644 index 000000000..7ea5d1ad4 --- /dev/null +++ b/cmd/mecated/microvm_command_test.go @@ -0,0 +1,296 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stacklok/mecatl/engine/port" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" + "github.com/stacklok/mecatl/internal/app" + "github.com/stacklok/mecatl/internal/microvmcmd" +) + +func TestResolveMicroVMIsOfflineHandledCommand(t *testing.T) { + res := resolveCommand([]string{"mecated", "microvm", "status", "--output", "json"}) + if res.err != nil || !res.handled || res.run == nil || res.mode != "" { + t.Fatalf("resolution = %+v", res) + } +} + +func TestMecatedMicroVMUsesSharedCommandAndDoesNotEnterServerPath(t *testing.T) { + original := newLocalMicroVMManager + t.Cleanup(func() { newLocalMicroVMManager = original }) + manager := &mecatedMicroVMFake{status: microvmmanager.Status{Configured: true, Running: true, Socket: "/run/microvmd.sock", Continuation: "token"}} + newLocalMicroVMManager = func() (microvmcmd.Manager, error) { return manager, nil } + + res := resolveCommand([]string{"mecated", "microvm", "status"}) + var out strings.Builder + if err := res.run(strings.NewReader(""), &out, io.Discard); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), `mecated microvm status --continuation "token"`) || strings.Contains(out.String(), "mecatui microvm status --continuation") || manager.statusCalls != 1 { + t.Fatalf("output=%q status calls=%d", out.String(), manager.statusCalls) + } +} + +func TestMecatedMicroVMHelpAllMatchesAdvertisedTopLevelGuidance(t *testing.T) { + manager := &mecatedMicroVMFake{} + original := newLocalMicroVMManager + t.Cleanup(func() { newLocalMicroVMManager = original }) + newLocalMicroVMManager = func() (microvmcmd.Manager, error) { return manager, nil } + res := resolveCommand([]string{"mecated", "microvm", "--help-all"}) + var out strings.Builder + if res.err != nil || !res.handled || res.run == nil { + t.Fatalf("resolution = %+v", res) + } + if err := res.run(strings.NewReader(""), &out, io.Discard); err != nil { + t.Fatal(err) + } + for _, want := range []string{"Usage: mecated microvm", "doctor", "status", "delete", "--output text|json", "mecated serve --headless"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("help-all omitted %q: %q", want, out.String()) + } + } + if manager.doctorCalls != 0 || manager.statusCalls != 0 || manager.deleteCalls != 0 { + t.Fatalf("help called manager: doctor=%d status=%d delete=%d", manager.doctorCalls, manager.statusCalls, manager.deleteCalls) + } +} + +func TestMecatedReleaseStampFeedsReadinessDefaults(t *testing.T) { + originalVersion, originalDefaults := microVMReleaseVersion, microVMReleaseDefaultsB64 + t.Cleanup(func() { + microVMReleaseVersion, microVMReleaseDefaultsB64 = originalVersion, originalDefaults + }) + microVMReleaseVersion = "v-test-mecated" + platform := runtime.GOOS + "-" + runtime.GOARCH + encoded, err := json.Marshal(map[string]microvmmanager.ReleaseDefaults{ + platform: { + Version: microVMReleaseVersion, Platform: platform, URL: "https://example.invalid/microvm.tar.gz", + SHA256: strings.Repeat("a", 64), PolicyRevision: "policy-test", + CertificateIdentity: "https://example.invalid/release.yml", OIDCIssuer: "https://token.actions.githubusercontent.com", + }, + }) + if err != nil { + t.Fatal(err) + } + microVMReleaseDefaultsB64 = base64.StdEncoding.EncodeToString(encoded) + request, err := microVMReadyRequest() + if err != nil { + t.Fatal(err) + } + if request.Release.URL != "https://example.invalid/microvm.tar.gz" || request.Policy.PolicyRevision != "policy-test" { + t.Fatalf("readiness request did not consume mecated stamp: %+v", request) + } + microVMReleaseVersion = "v-wrong-symbol" + if _, err := microVMReadyRequest(); err == nil { + t.Fatal("mecated readiness accepted defaults stamped for another version") + } +} + +func TestMecatedReadinessStagesUseCommandDiagnostics(t *testing.T) { + diag := &readinessDiagnostics{} + ctx := withMicroVMReadinessDiagnostics(t.Context(), diag) + microvmmanager.ReportReadinessStage(ctx, microvmmanager.StageDownload) + if len(diag.records) != 1 || diag.records[0] != "microvm-local readiness stage download status Downloading microVM components (up to about 2 GiB)" { + t.Fatalf("diagnostics = %q", diag.records) + } +} + +func TestConfigureMicroVMLocalProfileWiresHeadlessReadinessDiagnosticsAndEgress(t *testing.T) { + originalVersion, originalDefaults := microVMReleaseVersion, microVMReleaseDefaultsB64 + t.Cleanup(func() { microVMReleaseVersion, microVMReleaseDefaultsB64 = originalVersion, originalDefaults }) + microVMReleaseVersion = "v-test-composition" + platform := runtime.GOOS + "-" + runtime.GOARCH + defaults, err := json.Marshal(map[string]microvmmanager.ReleaseDefaults{platform: { + Version: microVMReleaseVersion, Platform: platform, URL: "https://example.invalid/microvm.tar.gz", SHA256: strings.Repeat("a", 64), + PolicyRevision: "policy-test", CertificateIdentity: "identity", OIDCIssuer: "issuer", + }}) + if err != nil { + t.Fatal(err) + } + microVMReleaseDefaultsB64 = base64.StdEncoding.EncodeToString(defaults) + manager := &mecatedMicroVMFake{endpoint: "unix:///run/private/microvmd.sock"} + diag := &readinessDiagnostics{} + var cfg app.Config + cfg.Workspace = t.TempDir() + selection := microvmmanager.GuestEgressSelection{Mode: microvmmanager.GuestEgressDenyAll} + if err := configureMicroVMLocalProfileWithManager(&cfg, diag, manager, manager.endpoint, "", false, selection); err != nil { + t.Fatal(err) + } + if cfg.PlacementProvider == nil || cfg.PlacementScope != "deployment" { + t.Fatalf("placement provider was not selected: scope=%q", cfg.PlacementScope) + } + if cfg.EnvironmentForkers["microvm"] == nil || cfg.EnvironmentMergers["microvm"] == nil { + t.Fatal("microvm delegation routing was not configured") + } +} + +func TestMecatedCLICompositionValidatesMicroVMDefaultAfterReadiness(t *testing.T) { + originalVersion, originalDefaults := microVMReleaseVersion, microVMReleaseDefaultsB64 + t.Cleanup(func() { microVMReleaseVersion, microVMReleaseDefaultsB64 = originalVersion, originalDefaults }) + microVMReleaseVersion = "v-test-cli-composition" + platform := runtime.GOOS + "-" + runtime.GOARCH + defaults, err := json.Marshal(map[string]microvmmanager.ReleaseDefaults{platform: { + Version: microVMReleaseVersion, Platform: platform, URL: "https://example.invalid/microvm.tar.gz", SHA256: strings.Repeat("a", 64), + PolicyRevision: "policy-cli-composition", CertificateIdentity: "identity", OIDCIssuer: "issuer", + }}) + if err != nil { + t.Fatal(err) + } + microVMReleaseDefaultsB64 = base64.StdEncoding.EncodeToString(defaults) + + listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "microvmd.sock")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + requestSeen := make(chan error, 1) + go serveCLICompositionPlacement(listener, requestSeen) + + workspace, store := t.TempDir(), t.TempDir() + parsed, err := parseFlagsMode(modeServe, []string{"--mock", "--default-placement=microvm-local", "--workspace=" + workspace, "--store-dir=" + store}) + if err != nil { + t.Fatalf("parse documented CLI selection: %v", err) + } + composition := appConfig(parsed, nil, nil, nil, nil, nil) + manager := &mecatedMicroVMFake{endpoint: "unix://" + listener.Addr().String()} + if err := configureMicroVMLocalProfileWithManager(&composition, nil, manager, manager.endpoint, "", false); err != nil { + t.Fatalf("configure CLI-selected placement: %v", err) + } + built, err := app.Build(t.Context(), composition) + if err != nil { + t.Fatalf("CLI composition did not become service-ready: %v", err) + } + defer built.Close() + if err := <-requestSeen; err != nil { + t.Fatal(err) + } + if manager.request.Policy.PolicyRevision != "policy-cli-composition" { + t.Fatalf("readiness request policy = %q", manager.request.Policy.PolicyRevision) + } +} + +func serveCLICompositionPlacement(listener net.Listener, result chan<- error) { + for range 2 { + conn, err := listener.Accept() + if err != nil { + result <- err + return + } + var request map[string]any + if err := readCLICompositionFrame(conn, &request); err != nil { + _ = conn.Close() + result <- err + return + } + binding, _ := request["binding"].(map[string]any) + response := map[string]any{"binding": binding} + if request["operation"] == "create" { + sessionID, _ := binding["session_id"].(string) + response = map[string]any{ + "binding": map[string]any{"owner": "local", "session_id": sessionID, "environment_id": "env-cli", "ref": "env-cli@1", "generation": 1}, + "created": map[string]any{ + "ref": map[string]any{"Kind": "microvm", "ID": "env-cli@1"}, "generation": 1, + "host_worktree": "/private/worktree", "guest_root": "/workspace", "profile": "microvm-local", + "guest_egress": "permissive", "host_egress": "not constrained", + }, + } + } + err = writeCLICompositionFrame(conn, response) + _ = conn.Close() + if err != nil { + result <- err + return + } + } + result <- nil +} + +func readCLICompositionFrame(r io.Reader, value any) error { + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return err + } + payload := make([]byte, binary.BigEndian.Uint32(header[:])) + if _, err := io.ReadFull(r, payload); err != nil { + return err + } + return json.Unmarshal(payload, value) +} + +func writeCLICompositionFrame(w io.Writer, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + var frame bytes.Buffer + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(payload))) + frame.Write(header[:]) + frame.Write(payload) + _, err = w.Write(frame.Bytes()) + return err +} + +func TestMecatedHelpDiscoversMicroVMCommands(t *testing.T) { + var out strings.Builder + writeTopLevelHelp(&out) + for _, want := range []string{"microvm doctor|status|delete", "local microVM state", "offline"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("help omitted %q:\n%s", want, out.String()) + } + } +} + +type readinessDiagnostics struct { + records []string +} + +func (d *readinessDiagnostics) Log(_ context.Context, _ port.Level, message string, args ...any) { + parts := []string{message} + for _, arg := range args { + parts = append(parts, fmt.Sprint(arg)) + } + d.records = append(d.records, strings.Join(parts, " ")) +} + +func (d *readinessDiagnostics) With(...any) port.Diagnostics { return d } + +type mecatedMicroVMFake struct { + status microvmmanager.Status + statusCalls int + doctorCalls int + deleteCalls int + endpoint string + request microvmmanager.ReadyRequest +} + +func (f *mecatedMicroVMFake) EnsureReady(ctx context.Context, request microvmmanager.ReadyRequest) (string, error) { + f.request = request + microvmmanager.ReportReadinessStage(ctx, microvmmanager.StagePrepare) + microvmmanager.ReportReadinessStage(ctx, microvmmanager.StageReady) + return f.endpoint, nil +} + +func (f *mecatedMicroVMFake) Doctor(context.Context) (string, error) { + f.doctorCalls++ + return "", nil +} +func (f *mecatedMicroVMFake) Status(context.Context, ...microvmmanager.StatusRequest) (microvmmanager.Status, error) { + f.statusCalls++ + return f.status, nil +} +func (f *mecatedMicroVMFake) Delete(context.Context, microvmmanager.DeleteRequest) (microvmmanager.DeleteResult, error) { + f.deleteCalls++ + return microvmmanager.DeleteResult{}, nil +} diff --git a/cmd/mecated/microvm_development_disabled.go b/cmd/mecated/microvm_development_disabled.go new file mode 100644 index 000000000..f11281e33 --- /dev/null +++ b/cmd/mecated/microvm_development_disabled.go @@ -0,0 +1,15 @@ +//go:build !microvm_dev + +package main + +import ( + "flag" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" +) + +func registerMicroVMDevelopmentFlags(*flag.FlagSet, *string, *bool) {} + +func microVMDevelopmentReadyRequest(string, bool, string, bool, ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, bool, error) { + return microvmmanager.ReadyRequest{}, false, nil +} diff --git a/cmd/mecated/microvm_development_disabled_test.go b/cmd/mecated/microvm_development_disabled_test.go new file mode 100644 index 000000000..8799289ff --- /dev/null +++ b/cmd/mecated/microvm_development_disabled_test.go @@ -0,0 +1,11 @@ +//go:build !microvm_dev + +package main + +import "testing" + +func TestOrdinaryBuildDoesNotExposeMicroVMDevelopmentFlags(t *testing.T) { + if _, err := parseFlags([]string{"--microvm-dev-release=/absolute/release.json"}); err == nil { + t.Fatal("ordinary mecated build exposed --microvm-dev-release") + } +} diff --git a/cmd/mecated/microvm_development_microvm_dev.go b/cmd/mecated/microvm_development_microvm_dev.go new file mode 100644 index 000000000..a89745049 --- /dev/null +++ b/cmd/mecated/microvm_development_microvm_dev.go @@ -0,0 +1,34 @@ +//go:build microvm_dev + +package main + +import ( + "errors" + "flag" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" +) + +func init() { + flagMetaByFlag["microvm-dev-release"] = flagMeta{group: groupServer, common: false, acp: acpExclude} + flagMetaByFlag["microvm-dev-acknowledge-untrusted-local-artifacts"] = flagMeta{group: groupServer, common: false, acp: acpExclude} +} + +func registerMicroVMDevelopmentFlags(fs *flag.FlagSet, descriptor *string, acknowledge *bool) { + fs.StringVar(descriptor, "microvm-dev-release", "", "UNSUPPORTED DEVELOPMENT ONLY: absolute path to a local microVM development release descriptor") + fs.BoolVar(acknowledge, "microvm-dev-acknowledge-untrusted-local-artifacts", false, "UNSUPPORTED DEVELOPMENT ONLY: acknowledge that local microVM artifacts are not a published release") +} + +func microVMDevelopmentReadyRequest(descriptor string, acknowledge bool, sourceBuildIdentity string, releaseStamped bool, egress ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, bool, error) { + if descriptor == "" && !acknowledge { + return microvmmanager.ReadyRequest{}, false, nil + } + if descriptor == "" || !acknowledge { + return microvmmanager.ReadyRequest{}, true, errors.New("--microvm-dev-release and --microvm-dev-acknowledge-untrusted-local-artifacts are required together") + } + if releaseStamped { + return microvmmanager.ReadyRequest{}, true, errors.New("published release binaries cannot activate microVM development releases") + } + request, err := microvmmanager.ReadyRequestFromDevelopmentDescriptor(descriptor, sourceBuildIdentity, egress...) + return request, true, err +} diff --git a/cmd/mecated/microvm_development_microvm_dev_test.go b/cmd/mecated/microvm_development_microvm_dev_test.go new file mode 100644 index 000000000..9e79afe22 --- /dev/null +++ b/cmd/mecated/microvm_development_microvm_dev_test.go @@ -0,0 +1,24 @@ +//go:build microvm_dev + +package main + +import ( + "strings" + "testing" +) + +func TestMicroVMDevelopmentFlagsRequirePairAndServeMode(t *testing.T) { + if _, err := parseFlagsMode(modeServe, []string{"--microvm-dev-release=/absolute/release.json"}); err == nil || !strings.Contains(err.Error(), "required together") { + t.Fatalf("unpaired descriptor error = %v", err) + } + if _, err := parseFlagsMode(modeACP, []string{"--microvm-dev-release=/absolute/release.json", "--microvm-dev-acknowledge-untrusted-local-artifacts"}); err == nil || !strings.Contains(err.Error(), "mecated serve") { + t.Fatalf("ACP development release error = %v", err) + } +} + +func TestMicroVMDevelopmentModeRejectsReleaseStampedBinary(t *testing.T) { + _, enabled, err := microVMDevelopmentReadyRequest("/absolute/release.json", true, "source", true) + if !enabled || err == nil || !strings.Contains(err.Error(), "published release binaries") { + t.Fatalf("release-stamped development request: enabled=%v err=%v", enabled, err) + } +} diff --git a/cmd/mecated/microvm_egress_test.go b/cmd/mecated/microvm_egress_test.go new file mode 100644 index 000000000..0b6382731 --- /dev/null +++ b/cmd/mecated/microvm_egress_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "runtime" + "strings" + "testing" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" +) + +func TestMecatedMicroVMGuestEgressFlags(t *testing.T) { + cfg, err := parseFlagsMode(modeServe, []string{ + "--default-placement=microvm-local", + "--microvm-guest-egress=allowlist", + "--microvm-guest-allow=API.Example.COM.:443/tcp", + "--microvm-guest-allow=dns.example.com:53/udp", + }) + if err != nil { + t.Fatal(err) + } + if cfg.microVMGuestEgress.Mode != microvmmanager.GuestEgressAllowlist || len(cfg.microVMGuestEgress.Allow) != 2 || cfg.microVMGuestEgress.Allow[0].Hostname != "api.example.com" { + t.Fatalf("parsed egress = %#v", cfg.microVMGuestEgress) + } +} + +func TestMecatedMicroVMGuestEgressDefaultsAndCombinations(t *testing.T) { + cfg, err := parseFlagsMode(modeServe, nil) + if err != nil { + t.Fatal(err) + } + if cfg.microVMGuestEgress.Mode != microvmmanager.GuestEgressPermissive || len(cfg.microVMGuestEgress.Allow) != 0 { + t.Fatalf("default egress = %#v", cfg.microVMGuestEgress) + } + for _, args := range [][]string{ + {"--default-placement=microvm-local", "--microvm-guest-egress=allowlist"}, + {"--default-placement=microvm-local", "--microvm-guest-egress=deny-all", "--microvm-guest-allow=example.com:443/tcp"}, + {"--default-placement=microvm-local", "--microvm-guest-allow=example.com:443/tcp"}, + } { + if _, err := parseFlagsMode(modeServe, args); err == nil { + t.Fatalf("parseFlagsMode(%v) succeeded", args) + } + } + if _, err := parseFlagsMode(modeACP, []string{"--default-placement=microvm-local", "--microvm-guest-egress=deny-all"}); err == nil || !strings.Contains(err.Error(), "mecated serve") { + t.Fatalf("ACP rejection = %v", err) + } +} + +func TestMecatedMicroVMGuestEgressReachesReadyRequest(t *testing.T) { + originalVersion, originalDefaults := microVMReleaseVersion, microVMReleaseDefaultsB64 + t.Cleanup(func() { microVMReleaseVersion, microVMReleaseDefaultsB64 = originalVersion, originalDefaults }) + microVMReleaseVersion = "v-test-mecated" + platform := runtime.GOOS + "-" + runtime.GOARCH + defaults, err := json.Marshal(map[string]microvmmanager.ReleaseDefaults{platform: { + Version: microVMReleaseVersion, Platform: platform, URL: "https://example.invalid/microvm.tar.gz", SHA256: strings.Repeat("a", 64), + PolicyRevision: "policy-test", CertificateIdentity: "identity", OIDCIssuer: "issuer", + }}) + if err != nil { + t.Fatal(err) + } + microVMReleaseDefaultsB64 = base64.StdEncoding.EncodeToString(defaults) + selection := microvmmanager.GuestEgressSelection{Mode: microvmmanager.GuestEgressDenyAll} + request, err := microVMReadyRequest(selection) + if err != nil { + t.Fatal(err) + } + if request.Policy.GuestEgressMode != microvmmanager.GuestEgressDenyAll || len(request.Policy.GuestAllow) != 0 { + t.Fatalf("request policy = %#v", request.Policy) + } +} diff --git a/cmd/mecatui/command.go b/cmd/mecatui/command.go index cd0f37946..32baf55ca 100644 --- a/cmd/mecatui/command.go +++ b/cmd/mecatui/command.go @@ -31,8 +31,9 @@ import ( type transportMode string const ( - modeLocal transportMode = "local" - modeConnect transportMode = "connect" + modeLocal transportMode = "local" + modeConnect transportMode = "connect" + helpLongFlag = "--help" // modeLogin is the CLI-only `mecatui llm login` subcommand. modeLogin transportMode = "llm-login" // modeRemoteLogout removes one saved remote enrolment without starting a transport. @@ -41,6 +42,7 @@ const ( // distinct from modeLogin so an address can never accidentally invoke the // ToolHive browser flow. modeRemoteLogin transportMode = "remote-login" + modeMicroVM transportMode = "microvm" ) // topLevelCommand is the single catalog for named entry points. Resolution, @@ -94,6 +96,14 @@ var topLevelCommands = []topLevelCommand{ purpose: "run the ToolHive LLM gateway OIDC browser flow (no session)", resolve: resolveLLMCommand, }, + { + name: "microvm", + synopsis: "microvm doctor|status|delete", + purpose: "inspect or delete owner-scoped microVM state on this host", + resolve: func(args []string) invocationResolution { + return invocationResolution{mode: modeMicroVM, remaining: args} + }, + }, } // invocationResolution is the pure classification of a complete CLI invocation: @@ -140,7 +150,7 @@ func resolveInvocation(argv []string) invocationResolution { first := args[1] if strings.HasPrefix(first, "-") { - if first == "--help" || first == "-h" { + if first == helpLongFlag || first == "-h" { if len(args) != 2 { return invocationResolution{err: helpUsageError("help does not accept additional operands")} } @@ -150,7 +160,7 @@ func resolveInvocation(argv []string) invocationResolution { return invocationResolution{err: helpUsageError("help does not accept additional operands")} } for _, arg := range args[2:] { - if arg == "--help" || arg == "-h" { + if arg == helpLongFlag || arg == "-h" { return invocationResolution{helpIndex: true} } } @@ -190,7 +200,7 @@ func resolveHelpCommand(args []string) invocationResolution { } for _, command := range topLevelCommands { if args[0] == command.name { - return command.resolve([]string{"--help"}) + return command.resolve([]string{helpLongFlag}) } } return invocationResolution{err: helpUsageError(fmt.Sprintf("unknown help target %q", args[0]))} @@ -290,7 +300,7 @@ func resolveDebugCommand(mode transportMode, address string, args []string) invo // --help` renders help instead of failing on the missing ADDRESS. func isHelpMetaFlag(arg string) bool { switch arg { - case "--help", "-h", "--help-all": + case helpLongFlag, "-h", "--help-all": return true } return false diff --git a/cmd/mecatui/config.go b/cmd/mecatui/config.go index e849d5b43..d70fc15e3 100644 --- a/cmd/mecatui/config.go +++ b/cmd/mecatui/config.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "flag" "fmt" @@ -13,6 +14,8 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" + microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" "github.com/stacklok/mecatl/internal/app" "github.com/stacklok/mecatl/internal/cliconfig" ) @@ -43,17 +46,27 @@ type config struct { workspace string // workspaceExplicit distinguishes an operator-supplied --workspace from the // empty default. Remote connect rejects the former without resolving it. - workspaceExplicit bool - mode string - theme string - themeDir string - authToken string - anonymous bool - useTLS bool - tlsExplicit bool - tlsCA string - insecure bool - listThemes bool + workspaceExplicit bool + defaultPlacement string + microVMGuestEgress microvmmanager.GuestEgressSelection + microVMEgressSet bool + microVMDevRelease string + microVMDevAcknowledge bool + // microVMEndpoint and microVMReadiness configure the embedded server's + // trusted default placement provider. + microVMEndpoint string + microVMReadiness func(context.Context) error + microVMProvider *microvmadapter.Client + mode string + theme string + themeDir string + authToken string + anonymous bool + useTLS bool + tlsExplicit bool + tlsCA string + insecure bool + listThemes bool // debug enables mecatui's client-side diagnostic surfaces. An explicit // --debug value outranks MECATUI_DEBUG and the legacy per-surface aliases. debug bool @@ -359,11 +372,16 @@ func parseFlags(args []string) (config, error) { // local/connect, the command word / ADDRESS — resolveInvocation strips them). func parseTransportFlags(mode transportMode, out io.Writer, args []string, browseSessions ...bool) (*flag.FlagSet, config, error) { var cfg config + cfg.microVMGuestEgress = microvmmanager.NewGuestEgressSelection() cfg.transportMode = mode cfg.browseSessions = len(browseSessions) > 0 && browseSessions[0] fs := flag.NewFlagSet("mecatui", flag.ContinueOnError) fs.SetOutput(out) fs.StringVar(&cfg.workspace, "workspace", "", "embedded server only: absolute deployment workspace root (default: cwd); not accepted by connect") + fs.StringVar(&cfg.defaultPlacement, "default-placement", "", "embedded deployment default placement (microvm-local: managed local microVM; empty: host-local)") + fs.Var(cfg.microVMGuestEgress.ModeValue(), "microvm-guest-egress", "microvm-local guest egress: permissive, deny-all, or allowlist; omitted preserves an existing local policy (first use: permissive); local embedded mode only") + fs.Var(cfg.microVMGuestEgress.AllowValue(), "microvm-guest-allow", "allow one microvm-local guest destination as HOST:PORT/tcp|udp (repeatable; requires allowlist; hostnames only, no IP literals or wildcards)") + registerMicroVMDevelopmentFlags(fs, &cfg.microVMDevRelease, &cfg.microVMDevAcknowledge) fs.StringVar(&cfg.mode, "mode", "default", "permission mode: default | plan | accept-edits") fs.Func("debug-mcp", "debug sessions only: select one already-configured server-global streaming-HTTP MCP server by name (repeatable)", func(value string) error { cfg.debugMCP = append(cfg.debugMCP, value) @@ -478,29 +496,8 @@ func parseTransportFlags(mode transportMode, out io.Writer, args []string, brows return fs, config{}, err } - // --help-flags is the bare/local common flag reference. - if cfg.helpFlags { - if mode != modeLocal || cfg.browseSessions { - return fs, config{}, errors.New("--help-flags is available only as bare 'mecatui --help-flags'") - } - writeBareCommonHelp(fs.Output(), fs) - return nil, config{}, flag.ErrHelp - } - - // --help-all was parsed as a normal flag; render and return ErrHelp (exit 0). - if cfg.helpAll { - out := fs.Output() - if cfg.browseSessions { - writeSessionsHelpAll(out, fs, mode) - } else { - switch mode { - case modeConnect: - writeConnectHelpAll(out, fs) - default: - writeBareHelpAll(out, fs) - } - } - return nil, config{}, flag.ErrHelp + if helpFS, handled, err := handleTransportHelp(fs, cfg, mode); handled { + return helpFS, config{}, err } // By-name applicability rejection (ADR 0087): connect rejects embedded-only @@ -515,6 +512,9 @@ func parseTransportFlags(mode transportMode, out io.Writer, args []string, brows if err := finalizeParsedConfig(fs, &cfg); err != nil { return fs, config{}, err } + if err := validateMicroVMFlags(mode, cfg); err != nil { + return fs, config{}, err + } if err := validateResumeSelectors(cfg); err != nil { return fs, config{}, err } @@ -531,6 +531,50 @@ func parseTransportFlags(mode transportMode, out io.Writer, args []string, brows return fs, cfg, nil } +func handleTransportHelp(fs *flag.FlagSet, cfg config, mode transportMode) (*flag.FlagSet, bool, error) { + if cfg.helpFlags { + if mode != modeLocal || cfg.browseSessions { + return fs, true, errors.New("--help-flags is available only as bare 'mecatui --help-flags'") + } + writeBareCommonHelp(fs.Output(), fs) + return nil, true, flag.ErrHelp + } + if !cfg.helpAll { + return fs, false, nil + } + + out := fs.Output() + if cfg.browseSessions { + writeSessionsHelpAll(out, fs, mode) + } else if mode == modeConnect { + writeConnectHelpAll(out, fs) + } else { + writeBareHelpAll(out, fs) + } + return nil, true, flag.ErrHelp +} + +func validateMicroVMFlags(mode transportMode, cfg config) error { + if cfg.microVMEgressSet && cfg.defaultPlacement != microvmmanager.Alias { + return errors.New("microVM guest egress flags require --default-placement microvm-local in local embedded mode") + } + if err := cfg.microVMGuestEgress.Validate(); err != nil { + return err + } + if cfg.microVMDevRelease == "" && !cfg.microVMDevAcknowledge { + return nil + } + if mode != modeLocal || cfg.defaultPlacement != microvmmanager.Alias { + return errors.New("microVM development release flags require local embedded mode with --default-placement microvm-local") + } + var egress []microvmmanager.GuestEgressSelection + if cfg.microVMEgressSet { + egress = append(egress, cfg.microVMGuestEgress) + } + _, _, err := microVMDevelopmentReadyRequest(cfg.microVMDevRelease, cfg.microVMDevAcknowledge, version, microVMReleaseStampRequired != "" || microVMReleaseDefaultsB64 != "", egress...) + return err +} + // resolveRemoteTLSPolicy applies the connect transport policy only after the // command grammar has supplied its target. tlsExplicit preserves the distinction // between an omitted --tls and an explicit --tls=false. It classifies targets @@ -652,6 +696,8 @@ func recordExplicitFlag(f *flag.Flag, cfg *config) { cfg.terminalTitleFlagSet = true case "workspace": cfg.workspaceExplicit = true + case "microvm-guest-egress", "microvm-guest-allow": + cfg.microVMEgressSet = true } markRetentionCLIFlag(&cfg.retentionCLISet, f.Name) } diff --git a/cmd/mecatui/helpmeta.go b/cmd/mecatui/helpmeta.go index 186ea2cf8..4de1cbbba 100644 --- a/cmd/mecatui/helpmeta.go +++ b/cmd/mecatui/helpmeta.go @@ -67,14 +67,17 @@ var flagApplicabilityByFlag = map[string]flagApplicability{ "insecure": {group: groupTransport, common: false, local: false, connect: true}, // ── Session (shared) ────────────────────────────────────────────────── - "workspace": {group: groupSession, common: true, local: true, connect: true}, - "mode": {group: groupSession, common: true, local: true, connect: true}, - "debug-mcp": {group: groupSession, common: true, local: true, connect: true}, - "resume": {group: groupSession, common: true, local: true, connect: true}, - "resume-latest": {group: groupSession, common: true, local: true, connect: true}, - "prompt": {group: groupSession, common: true, local: true, connect: true}, - "p": {group: groupSession, common: false, local: true, connect: true}, // alias for --prompt - "prompt-file": {group: groupSession, common: true, local: true, connect: true}, + "workspace": {group: groupSession, common: true, local: true, connect: true}, + "default-placement": {group: groupSession, common: true, local: true, connect: false}, + "microvm-guest-egress": {group: groupSession, common: false, local: true, connect: false}, + "microvm-guest-allow": {group: groupSession, common: false, local: true, connect: false}, + "mode": {group: groupSession, common: true, local: true, connect: true}, + "debug-mcp": {group: groupSession, common: true, local: true, connect: true}, + "resume": {group: groupSession, common: true, local: true, connect: true}, + "resume-latest": {group: groupSession, common: true, local: true, connect: true}, + "prompt": {group: groupSession, common: true, local: true, connect: true}, + "p": {group: groupSession, common: false, local: true, connect: true}, // alias for --prompt + "prompt-file": {group: groupSession, common: true, local: true, connect: true}, // ── UI (shared) ─────────────────────────────────────────────────────── "theme": {group: groupUI, common: true, local: true, connect: true}, diff --git a/cmd/mecatui/main.go b/cmd/mecatui/main.go index d3aa25998..dab62b69f 100644 --- a/cmd/mecatui/main.go +++ b/cmd/mecatui/main.go @@ -43,9 +43,13 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/theme" "github.com/stacklok/mecatl/cmd/mecatui/ui" "github.com/stacklok/mecatl/engine/port" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" "github.com/stacklok/mecatl/internal/adapter/clientauth" "github.com/stacklok/mecatl/internal/adapter/credentialstore" "github.com/stacklok/mecatl/internal/adapter/mcpauthority" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" + "github.com/stacklok/mecatl/internal/adapter/server" "github.com/stacklok/mecatl/internal/adapter/slogdiag" "github.com/stacklok/mecatl/internal/adapter/xdgconfig" "github.com/stacklok/mecatl/internal/app" @@ -175,11 +179,33 @@ func runWithOptions(argv []string, options runOptions) error { if handled, err := runSpecialMode(res); handled { return err } + if res.mode == modeMicroVM { + if err := validateMicroVMReleaseStamp(); err != nil { + return err + } + manager, err := defaultMicroVMManager() + if err != nil { + return err + } + interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) + return runMicroVMCommand(context.Background(), res.remaining, os.Stdin, os.Stdout, manager, interactive) + } cfg, err := parseRunConfig(res) if err != nil { return err } + if cfg.transportMode == modeLocal && cfg.defaultPlacement == microvmmanager.Alias { + endpoint, endpointErr := defaultMicroVMEndpoint() + if endpointErr != nil { + return endpointErr + } + if err := configureSelectedEnvironmentReadiness(&cfg, endpoint, func() (microVMReadyManager, error) { + return defaultMicroVMManager() + }); err != nil { + return err + } + } if cfg.providerKeys.AuthFileWarning != "" { fmt.Fprintln(os.Stderr, "mecatui: WARNING: "+wrapAuthFileWarning(cfg.providerKeys.AuthFileWarning)) } @@ -220,8 +246,8 @@ func runWithOptions(argv []string, options runOptions) error { // Bubble Tea quits); second signal during cleanup = immediate hard os.Exit(130). ctx, forceExit := setupSignalHandler() - // Resolve where to connect: an explicit external server, a server already - // running on the loopback default, or an embedded server we host in-process. + // Resolve where to connect before any potentially mutating readiness work. Resume + // placement is authoritative and must reject a mismatched profile first. target, dial, transCleanup, err := resolveTransport(ctx, cfg) if err != nil { if reason, ok := client.AuthFailure(err, cfg.authToken != ""); ok { @@ -252,7 +278,7 @@ func runWithOptions(argv []string, options runOptions) error { resumeCfg.resumeID = options.connectResumeSessionID resumeCfg.resumeLatest = false } - resume, uiWorkspace, err := startupResumeConfig(ctx, cl, resumeCfg) + resume, uiWorkspace, err := startupResumeAndReadiness(ctx, cl, resumeCfg, os.Stderr) if options.connectResumeSessionID != "" { // An auth-recovery candidate is opportunistic. Only a verified terminal // boundary is adopted; every other state and every ambiguous verification @@ -1176,6 +1202,13 @@ func embeddedConfig(cfg config, diag port.Diagnostics) app.Config { if !cfg.providerKeysResolved { keys = cfg.providerFlags.Resolve() } + if cfg.microVMProvider != nil { + const scope server.PlacementScope = "deployment" + out.PlacementProvider = cfg.microVMProvider + out.PlacementScope = scope + out.EnvironmentForkers = map[session.EnvironmentKind]tool.EnvironmentForker{session.EnvironmentKind("microvm"): cfg.microVMProvider} + out.EnvironmentMergers = map[session.EnvironmentKind]tool.EnvironmentMerger{session.EnvironmentKind("microvm"): cfg.microVMProvider} + } cfg.providerFlags.ApplyResolved(&out, keys) out.UseOpenAI = keys.OpenAI != "" cfg.toolhiveLLMFlags.Apply(&out) diff --git a/cmd/mecatui/microvm_command.go b/cmd/mecatui/microvm_command.go new file mode 100644 index 000000000..d1cbe7c41 --- /dev/null +++ b/cmd/mecatui/microvm_command.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" + "github.com/stacklok/mecatl/internal/adapter/server" + "github.com/stacklok/mecatl/internal/microvmcmd" +) + +type microVMManager interface { + microvmcmd.Manager + EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) +} + +type microVMReadyManager interface { + EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) +} + +func defaultMicroVMManager() (microVMManager, error) { + manager, _, err := microvmmanager.DefaultLocal() + return manager, err +} + +func defaultMicroVMEndpoint() (string, error) { + _, endpoint, err := microvmmanager.DefaultLocal() + return endpoint, err +} + +func runMicroVMCommand(ctx context.Context, args []string, in io.Reader, out io.Writer, manager microVMManager, interactive bool) error { + return microvmcmd.Run(ctx, microvmcmd.FrontendMecatui, args, in, out, manager, interactive) +} + +func validateMicroVMReleaseStamp() error { + if microVMReleaseStampRequired == "" { + return nil + } + _, err := microVMReadyRequest() + return err +} + +func microVMReadyRequestWithDevelopment(descriptor string, acknowledge bool, egress ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + request, enabled, err := microVMDevelopmentReadyRequest(descriptor, acknowledge, version, microVMReleaseStampRequired != "" || microVMReleaseDefaultsB64 != "", egress...) + if enabled || err != nil { + return request, err + } + return microVMReadyRequest(egress...) +} + +func microVMReadyRequest(egress ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + return microvmmanager.ReadyRequestFromDefaults(microVMReleaseDefaultsB64, version, egress...) +} + +type microVMPrewarmKey struct{} + +type readinessHandoff struct { + mu sync.Mutex + ready func(context.Context) error + skipNext bool +} + +func (h *readinessHandoff) call(ctx context.Context) error { + h.mu.Lock() + defer h.mu.Unlock() + prewarm, _ := ctx.Value(microVMPrewarmKey{}).(bool) + if !prewarm && h.skipNext { + h.skipNext = false + return nil + } + if err := h.ready(ctx); err != nil { + return err + } + if prewarm { + h.skipNext = true + } + return nil +} + +func ensureSelectedEnvironmentReady(ctx context.Context, cfg config, out io.Writer) error { + if cfg.transportMode != modeLocal || cfg.defaultPlacement != microvmmanager.Alias || cfg.microVMReadiness == nil { + return nil + } + ctx = context.WithValue(ctx, microVMPrewarmKey{}, true) + if cfg.microVMDevRelease != "" { + _, _ = fmt.Fprintln(out, "mecatui: microvm-local: unsupported development release enabled; trusting acknowledged local artifacts") + } + ctx = microvmmanager.WithReadinessObserver(ctx, func(_ microvmmanager.ReadinessStage, message string) { + _, _ = fmt.Fprintln(out, "mecatui: microvm-local:", message) + }) + return cfg.microVMReadiness(ctx) +} + +func startupResumeAndReadiness(ctx context.Context, source startupResumeSource, cfg config, out io.Writer) (*client.ResumeSelection, string, error) { + resume, workspace, err := startupResumeConfig(ctx, source, cfg) + if err != nil { + return nil, "", err + } + if err := ensureSelectedEnvironmentReady(ctx, cfg, out); err != nil { + return nil, "", err + } + return resume, workspace, nil +} + +func configureSelectedEnvironmentReadiness(cfg *config, endpoint string, factory func() (microVMReadyManager, error)) error { + if cfg.transportMode != modeLocal || cfg.defaultPlacement != microvmmanager.Alias { + return nil + } + if endpoint == "" { + return errors.New("microvm-local endpoint is empty") + } + cfg.microVMEndpoint = endpoint + ready := &readinessHandoff{ready: func(ctx context.Context) error { + manager, err := factory() + if err != nil { + return err + } + var egress []microvmmanager.GuestEgressSelection + if cfg.microVMEgressSet { + egress = append(egress, cfg.microVMGuestEgress) + } + request, err := microVMReadyRequestWithDevelopment(cfg.microVMDevRelease, cfg.microVMDevAcknowledge, egress...) + if err != nil { + return err + } + readyEndpoint, err := manager.EnsureReady(ctx, request) + if err != nil { + return err + } + if readyEndpoint != endpoint { + return fmt.Errorf("microvm-local readiness returned unexpected endpoint %q", readyEndpoint) + } + return nil + }} + cfg.microVMReadiness = ready.call + provider, err := microvmadapter.NewPlacementProvider(endpoint, cfg.workspace, microvmmanager.Alias, server.PlacementScope("deployment"), cfg.microVMReadiness) + if err != nil { + return err + } + cfg.microVMProvider = provider + return nil +} diff --git a/cmd/mecatui/microvm_command_test.go b/cmd/mecatui/microvm_command_test.go new file mode 100644 index 000000000..44c17ff57 --- /dev/null +++ b/cmd/mecatui/microvm_command_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "testing" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" + "github.com/stacklok/mecatl/internal/microvmcmd" +) + +func TestMicroVMLifecycleUX_StatusAndDeleteReportExactGeneration(t *testing.T) { + mgr := &fakeMicroVMManager{status: microvmmanager.Status{ + Configured: true, Running: true, Socket: "/run/microvmd.sock", + Generations: []microvmmanager.Generation{{SessionID: "s1", Ref: "env-1@7", Generation: 7, WorktreePath: "/worktrees/s1", Health: "stale"}}, + }, deleteResult: microvmmanager.DeleteResult{WorktreePath: "/worktrees/s1", WorktreeRetained: true}} + var out strings.Builder + if err := runMicroVMCommand(context.Background(), []string{"status"}, strings.NewReader(""), &out, mgr, false); err != nil { + t.Fatal(err) + } + for _, want := range []string{"session=s1", "ref=env-1@7", "repository-generation=7", "health=stale", "worktree=/worktrees/s1"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("status omitted %q:\n%s", want, out.String()) + } + } + out.Reset() + args := []string{"delete", "--session", "s1", "--ref", "env-1@7", "--generation", "7", "--yes"} + if err := runMicroVMCommand(context.Background(), args, strings.NewReader(""), &out, mgr, false); err != nil { + t.Fatal(err) + } + if mgr.deleteRequest != (microvmmanager.DeleteRequest{SessionID: "s1", Ref: "env-1@7", Generation: 7}) { + t.Fatalf("delete request = %+v", mgr.deleteRequest) + } + if !strings.Contains(out.String(), "dirty worktree retained: /worktrees/s1") { + t.Fatalf("dirty retention was not reported: %s", out.String()) + } +} + +func TestMecatuiMicroVM_RepositoryGenerationShowsDistinctLogicalWorktrees(t *testing.T) { + mgr := &fakeMicroVMManager{status: microvmmanager.Status{ + Configured: true, Running: true, Continuation: "next", + Generations: []microvmmanager.Generation{ + {SessionID: "session-a", Ref: "logical-a@4", Generation: 4, WorktreePath: "/worktrees/a", State: "ready", Health: "healthy"}, + {SessionID: "session-b", Ref: "logical-b@4", Generation: 4, WorktreePath: "/worktrees/b", State: "ready", Health: "healthy"}, + }, + }, deleteResult: microvmmanager.DeleteResult{WorktreePath: "/worktrees/b", WorktreeRetained: true}} + var out strings.Builder + if err := runMicroVMCommand(t.Context(), []string{"status", "--page-size", "2"}, strings.NewReader(""), &out, mgr, false); err != nil { + t.Fatal(err) + } + for _, want := range []string{"session=session-a", "session=session-b", "repository-generation=4", "worktree=/worktrees/a", "worktree=/worktrees/b", "--continuation"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("repository status omitted %q:\n%s", want, out.String()) + } + } + out.Reset() + if err := runMicroVMCommand(t.Context(), []string{"delete", "--session", "session-b", "--ref", "logical-b@4", "--generation", "4", "--yes"}, strings.NewReader(""), &out, mgr, false); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "shared repository VM is not deleted") || !strings.Contains(out.String(), "dirty worktree retained") { + t.Fatalf("logical delete UX is ambiguous:\n%s", out.String()) + } +} + +func TestMecatuiMicroVM_StatusOutputIsOneBoundedContinuablePage(t *testing.T) { + generations := make([]microvmmanager.Generation, 64) + for i := range generations { + generations[i] = microvmmanager.Generation{SessionID: fmt.Sprintf("s-%03d", i), Ref: fmt.Sprintf("env-%03d@1", i), Generation: 1, WorktreePath: fmt.Sprintf("/worktrees/%03d", i), Health: "stale"} + } + mgr := &fakeMicroVMManager{status: microvmmanager.Status{Configured: true, Running: true, Generations: generations, Continuation: "opaque-next"}} + var out strings.Builder + if err := runMicroVMCommand(context.Background(), []string{"status", "--page-size", "64"}, strings.NewReader(""), &out, mgr, false); err != nil { + t.Fatal(err) + } + if got := strings.Count(out.String(), "logical worktree: "); got != 64 { + t.Fatalf("generation rows = %d, want 64", got) + } + want := `mecatui microvm status --continuation "opaque-next"` + if !strings.Contains(out.String(), want) { + t.Fatalf("status omitted continuation command %q:\n%s", want, out.String()) + } + if strings.Contains(out.String(), "mecated microvm status --continuation") { + t.Fatalf("status named uninvoked mecated frontend:\n%s", out.String()) + } + if mgr.statusRequest.PageSize != 64 || mgr.statusRequest.Continuation != "" { + t.Fatalf("status request = %+v", mgr.statusRequest) + } +} + +func TestMicroVMFirstRunRepair_DeleteValidatesTargetBeforePrompt(t *testing.T) { + mgr := &fakeMicroVMManager{status: microvmmanager.Status{Configured: true, Running: true}} + var out strings.Builder + err := runMicroVMCommand(context.Background(), []string{"delete", "--session", "missing", "--ref", "env@2", "--generation", "2"}, strings.NewReader("yes\n"), &out, mgr, true) + if err == nil || !strings.Contains(err.Error(), "not present in microvm status") { + t.Fatalf("invalid target error = %v", err) + } + if strings.Contains(out.String(), "[y/N]") || mgr.deleteCalls != 0 { + t.Fatalf("invalid target reached confirmation/delete: out=%q calls=%d", out.String(), mgr.deleteCalls) + } +} + +func TestMicroVMDoctorFailureGuidesRetryNotStatus(t *testing.T) { + mgr := &fakeMicroVMManager{doctorErr: errors.New("KVM unavailable")} + err := runMicroVMCommand(t.Context(), []string{"doctor"}, strings.NewReader(""), io.Discard, mgr, false) + if err == nil || !strings.Contains(err.Error(), "rerun 'mecated microvm doctor' (or 'mecatui microvm doctor')") || strings.Contains(err.Error(), "microvm status") { + t.Fatalf("doctor failure guidance = %v", err) + } +} + +func TestMicroVMDoctorPrintsStructuredWarningsOnce(t *testing.T) { + mgr := &fakeMicroVMManager{doctorReport: "PASS hypervisor ready; remediation: none\nWARN stale-resources 2 stale resources; remediation: inspect retained worktrees\n"} + var out strings.Builder + if err := runMicroVMCommand(t.Context(), []string{"doctor"}, strings.NewReader(""), &out, mgr, false); err != nil { + t.Fatal(err) + } + if strings.Count(out.String(), "WARN stale-resources") != 1 || !strings.Contains(out.String(), "inspect retained worktrees") { + t.Fatalf("doctor warning was not surfaced exactly once:\n%s", out.String()) + } + if strings.Contains(out.String(), "microvm-local is ready") { + t.Fatalf("generic success duplicated the structured report:\n%s", out.String()) + } +} + +func TestMicroVMDeleteFindsTargetAfterFirstStatusPage(t *testing.T) { + first := make([]microvmmanager.Generation, 64) + for i := range first { + first[i] = microvmmanager.Generation{SessionID: fmt.Sprintf("s-%03d", i), Ref: fmt.Sprintf("env-%03d@1", i), Generation: 1} + } + mgr := &fakeMicroVMManager{statusPages: map[string]microvmmanager.Status{ + "": {Configured: true, Running: true, Generations: first, Continuation: "page-2"}, + "page-2": {Configured: true, Running: true, Generations: []microvmmanager.Generation{{SessionID: "target", Ref: "env-target@9", Generation: 9}}}, + }} + var out strings.Builder + args := []string{"delete", "--session", "target", "--ref", "env-target@9", "--generation", "9", "--yes"} + if err := runMicroVMCommand(t.Context(), args, strings.NewReader(""), &out, mgr, false); err != nil { + t.Fatal(err) + } + if mgr.statusCalls != 2 || mgr.deleteCalls != 1 { + t.Fatalf("status calls=%d delete calls=%d, want 2 and 1", mgr.statusCalls, mgr.deleteCalls) + } +} + +func TestMecatuiMicroVMCompatibilityFrontendUsesSharedImplementation(t *testing.T) { + manager := &fakeMicroVMManager{status: microvmmanager.Status{Configured: true, Running: true, Continuation: "next"}} + var compatibility, canonical strings.Builder + args := []string{"status", "--output", "json"} + if err := runMicroVMCommand(t.Context(), args, strings.NewReader(""), &compatibility, manager, false); err != nil { + t.Fatal(err) + } + if err := microvmcmd.Run(t.Context(), microvmcmd.FrontendMecatui, args, strings.NewReader(""), &canonical, manager, false); err != nil { + t.Fatal(err) + } + if compatibility.String() != canonical.String() { + t.Fatalf("compatibility output differs:\n%s\ncanonical:\n%s", compatibility.String(), canonical.String()) + } + + compatibility.Reset() + if err := runMicroVMCommand(t.Context(), []string{"--help"}, strings.NewReader(""), &compatibility, manager, false); err != nil { + t.Fatal(err) + } + for _, want := range []string{"current local OS principal", "never targets a remote server", "compatibility frontend"} { + if !strings.Contains(compatibility.String(), want) { + t.Fatalf("compatibility help omitted %q:\n%s", want, compatibility.String()) + } + } +} + +type fakeMicroVMManager struct { + status microvmmanager.Status + statusPages map[string]microvmmanager.Status + statusRequest microvmmanager.StatusRequest + statusCalls int + doctorReport string + doctorErr error + deleteRequest microvmmanager.DeleteRequest + deleteResult microvmmanager.DeleteResult + readyEndpoint string + readyErr error + ensureCalls int + deleteCalls int +} + +func (f *fakeMicroVMManager) EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) { + f.ensureCalls++ + return f.readyEndpoint, f.readyErr +} +func (f *fakeMicroVMManager) Doctor(context.Context) (string, error) { + return f.doctorReport, f.doctorErr +} +func (f *fakeMicroVMManager) Status(_ context.Context, requests ...microvmmanager.StatusRequest) (microvmmanager.Status, error) { + f.statusCalls++ + if len(requests) > 0 { + f.statusRequest = requests[0] + if f.statusPages != nil { + return f.statusPages[requests[0].Continuation], nil + } + } + return f.status, nil +} +func (f *fakeMicroVMManager) Delete(_ context.Context, request microvmmanager.DeleteRequest) (microvmmanager.DeleteResult, error) { + f.deleteCalls++ + f.deleteRequest = request + return f.deleteResult, nil +} + +var _ microVMManager = (*fakeMicroVMManager)(nil) diff --git a/cmd/mecatui/microvm_development_disabled.go b/cmd/mecatui/microvm_development_disabled.go new file mode 100644 index 000000000..f11281e33 --- /dev/null +++ b/cmd/mecatui/microvm_development_disabled.go @@ -0,0 +1,15 @@ +//go:build !microvm_dev + +package main + +import ( + "flag" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" +) + +func registerMicroVMDevelopmentFlags(*flag.FlagSet, *string, *bool) {} + +func microVMDevelopmentReadyRequest(string, bool, string, bool, ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, bool, error) { + return microvmmanager.ReadyRequest{}, false, nil +} diff --git a/cmd/mecatui/microvm_development_disabled_test.go b/cmd/mecatui/microvm_development_disabled_test.go new file mode 100644 index 000000000..4d3e02fac --- /dev/null +++ b/cmd/mecatui/microvm_development_disabled_test.go @@ -0,0 +1,11 @@ +//go:build !microvm_dev + +package main + +import "testing" + +func TestOrdinaryBuildDoesNotExposeMicroVMDevelopmentFlags(t *testing.T) { + if _, err := parseFlags([]string{"--microvm-dev-release=/absolute/release.json"}); err == nil { + t.Fatal("ordinary mecatui build exposed --microvm-dev-release") + } +} diff --git a/cmd/mecatui/microvm_development_microvm_dev.go b/cmd/mecatui/microvm_development_microvm_dev.go new file mode 100644 index 000000000..d5b996264 --- /dev/null +++ b/cmd/mecatui/microvm_development_microvm_dev.go @@ -0,0 +1,34 @@ +//go:build microvm_dev + +package main + +import ( + "errors" + "flag" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" +) + +func init() { + flagApplicabilityByFlag["microvm-dev-release"] = flagApplicability{group: groupSession, common: false, local: true, connect: false} + flagApplicabilityByFlag["microvm-dev-acknowledge-untrusted-local-artifacts"] = flagApplicability{group: groupSession, common: false, local: true, connect: false} +} + +func registerMicroVMDevelopmentFlags(fs *flag.FlagSet, descriptor *string, acknowledge *bool) { + fs.StringVar(descriptor, "microvm-dev-release", "", "UNSUPPORTED DEVELOPMENT ONLY: absolute path to a local microVM development release descriptor") + fs.BoolVar(acknowledge, "microvm-dev-acknowledge-untrusted-local-artifacts", false, "UNSUPPORTED DEVELOPMENT ONLY: acknowledge that local microVM artifacts are not a published release") +} + +func microVMDevelopmentReadyRequest(descriptor string, acknowledge bool, sourceBuildIdentity string, releaseStamped bool, egress ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, bool, error) { + if descriptor == "" && !acknowledge { + return microvmmanager.ReadyRequest{}, false, nil + } + if descriptor == "" || !acknowledge { + return microvmmanager.ReadyRequest{}, true, errors.New("--microvm-dev-release and --microvm-dev-acknowledge-untrusted-local-artifacts are required together") + } + if releaseStamped { + return microvmmanager.ReadyRequest{}, true, errors.New("published release binaries cannot activate microVM development releases") + } + request, err := microvmmanager.ReadyRequestFromDevelopmentDescriptor(descriptor, sourceBuildIdentity, egress...) + return request, true, err +} diff --git a/cmd/mecatui/microvm_development_microvm_dev_test.go b/cmd/mecatui/microvm_development_microvm_dev_test.go new file mode 100644 index 000000000..bc2b023a7 --- /dev/null +++ b/cmd/mecatui/microvm_development_microvm_dev_test.go @@ -0,0 +1,27 @@ +//go:build microvm_dev + +package main + +import ( + "strings" + "testing" +) + +func TestMicroVMDevelopmentFlagsRequirePairAndLocalProfile(t *testing.T) { + if _, err := parseFlags([]string{"--default-placement=microvm-local", "--microvm-dev-release=/absolute/release.json"}); err == nil || !strings.Contains(err.Error(), "required together") { + t.Fatalf("unpaired descriptor error = %v", err) + } + if _, _, err := parseTransportFlags(modeConnect, t.Output(), []string{"--microvm-dev-release=/absolute/release.json", "--microvm-dev-acknowledge-untrusted-local-artifacts"}); err == nil { + t.Fatal("connect mode accepted development release flags") + } + if _, err := parseFlags([]string{"--microvm-dev-release=/absolute/release.json", "--microvm-dev-acknowledge-untrusted-local-artifacts"}); err == nil || !strings.Contains(err.Error(), "--default-placement microvm-local") { + t.Fatalf("missing profile error = %v", err) + } +} + +func TestMicroVMDevelopmentModeRejectsReleaseStampedBinary(t *testing.T) { + _, enabled, err := microVMDevelopmentReadyRequest("/absolute/release.json", true, "source", true) + if !enabled || err == nil || !strings.Contains(err.Error(), "published release binaries") { + t.Fatalf("release-stamped development request: enabled=%v err=%v", enabled, err) + } +} diff --git a/cmd/mecatui/microvm_egress_test.go b/cmd/mecatui/microvm_egress_test.go new file mode 100644 index 000000000..a038091ed --- /dev/null +++ b/cmd/mecatui/microvm_egress_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" +) + +func TestMecatuiMicroVMGuestEgressFlags(t *testing.T) { + cfg, err := parseFlags([]string{ + "--default-placement=microvm-local", + "--microvm-guest-egress=allowlist", + "--microvm-guest-allow=API.Example.COM.:443/tcp", + }) + if err != nil { + t.Fatal(err) + } + if cfg.microVMGuestEgress.Mode != microvmmanager.GuestEgressAllowlist || len(cfg.microVMGuestEgress.Allow) != 1 || cfg.microVMGuestEgress.Allow[0].Hostname != "api.example.com" { + t.Fatalf("parsed egress = %#v", cfg.microVMGuestEgress) + } +} + +func TestMecatuiMicroVMGuestEgressScopeAndCombinations(t *testing.T) { + for _, args := range [][]string{ + {"--microvm-guest-egress=deny-all"}, + {"--default-placement=other", "--microvm-guest-egress=deny-all"}, + {"--default-placement=microvm-local", "--microvm-guest-egress=allowlist"}, + {"--default-placement=microvm-local", "--microvm-guest-allow=example.com:443/tcp"}, + } { + if _, err := parseFlags(args); err == nil { + t.Fatalf("parseFlags(%v) succeeded", args) + } + } + _, _, err := parseTransportFlags(modeConnect, &strings.Builder{}, []string{"--microvm-guest-egress=deny-all"}) + if err == nil || !strings.Contains(err.Error(), "not applicable") { + t.Fatalf("remote rejection = %v", err) + } +} + +func TestMecatuiMicroVMGuestEgressDefaultIsPermissive(t *testing.T) { + cfg, err := parseFlags(nil) + if err != nil { + t.Fatal(err) + } + if cfg.microVMGuestEgress.Mode != microvmmanager.GuestEgressPermissive || len(cfg.microVMGuestEgress.Allow) != 0 { + t.Fatalf("default egress = %#v", cfg.microVMGuestEgress) + } +} diff --git a/cmd/mecatui/sessionadapter/sessionadapter.go b/cmd/mecatui/sessionadapter/sessionadapter.go new file mode 100644 index 000000000..34b414e38 --- /dev/null +++ b/cmd/mecatui/sessionadapter/sessionadapter.go @@ -0,0 +1,98 @@ +// Package sessionadapter bridges mecatui's session interface to its gRPC client. +package sessionadapter + +import ( + "context" + "errors" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" +) + +// Client is the gRPC session surface used by Adapter. +type Client interface { + CreateSession(context.Context, mecatlv1.PermissionMode, client.ModelSelection) (string, client.Capabilities, client.ResolvedModel, error) + CreateSessionWithCarryover(context.Context, client.ModelSelection, string) (string, client.Capabilities, client.ResolvedModel, error) + CloseSession(context.Context, string) error + GetSession(context.Context, string) (client.SessionSnapshot, error) + SetMode(context.Context, string, string) (string, error) + ForkSession(context.Context, string, string, string) (string, error) +} + +// Adapter fixes launch-time mode while preserving mecatui's per-call model selection. +type Adapter struct { + cl Client + workspace string + mode string + debugTarget string + debugMCP []string +} + +// New constructs mecatui's session adapter. +func New(cl Client, workspace, mode, _ string) *Adapter { + return &Adapter{cl: cl, workspace: workspace, mode: mode} +} + +// WithDebugTarget binds a dedicated debug session to target when non-empty. +func (s *Adapter) WithDebugTarget(target string, mcpServers []string) *Adapter { + s.debugTarget = target + s.debugMCP = append([]string(nil), mcpServers...) + return s +} + +// CreateSession creates a session in the launch workspace. +func (s *Adapter) CreateSession(ctx context.Context, sel client.ModelSelection, mode string) (string, client.Capabilities, client.ResolvedModel, error) { + if s.debugTarget != "" { + if mode == "" { + mode = s.mode + } + debugClient, ok := s.cl.(interface { + CreateDebugSession(context.Context, string, mecatlv1.PermissionMode, client.ModelSelection, ...string) (string, string, client.Capabilities, client.ResolvedModel, error) + }) + if !ok { + return "", client.Capabilities{}, client.ResolvedModel{}, errors.New("session client does not support debugging") + } + id, target, capabilities, model, err := debugClient.CreateDebugSession(ctx, s.debugTarget, client.ModeFromString(mode), sel, s.debugMCP...) + if target != "" { + s.debugTarget = target + } + return id, capabilities, model, err + } + return s.CreateSessionInWorkspace(ctx, s.workspace, sel, mode) +} + +// DebugTargetID returns the resolved debug target, if any. +func (s *Adapter) DebugTargetID() string { return s.debugTarget } + +// CreateSessionInWorkspace creates a session in an explicitly selected workspace. +func (s *Adapter) CreateSessionInWorkspace(ctx context.Context, _ string, sel client.ModelSelection, mode string) (string, client.Capabilities, client.ResolvedModel, error) { + if mode == "" { + mode = s.mode + } + return s.cl.CreateSession(ctx, client.ModeFromString(mode), sel) +} + +// CreateSessionWithCarryover creates a session seeded from an existing session. +func (s *Adapter) CreateSessionWithCarryover(ctx context.Context, sourceSessionID string, sel client.ModelSelection, _ string) (string, client.Capabilities, client.ResolvedModel, error) { + return s.cl.CreateSessionWithCarryover(ctx, sel, sourceSessionID) +} + +// CloseSession releases a server-side session. +func (s *Adapter) CloseSession(ctx context.Context, id string) error { + return s.cl.CloseSession(ctx, id) +} + +// GetSession returns the client-visible session snapshot. +func (s *Adapter) GetSession(ctx context.Context, id string) (client.SessionSnapshot, error) { + return s.cl.GetSession(ctx, id) +} + +// SetMode changes a session's permission mode. +func (s *Adapter) SetMode(ctx context.Context, id, mode string) (string, error) { + return s.cl.SetMode(ctx, id, mode) +} + +// ForkSession forks a session while preserving its title and model. +func (s *Adapter) ForkSession(ctx context.Context, srcID, reasoningEffort string) (string, error) { + return s.cl.ForkSession(ctx, srcID, "", reasoningEffort) +} diff --git a/cmd/mecatui/version.go b/cmd/mecatui/version.go new file mode 100644 index 000000000..d0ab09c9d --- /dev/null +++ b/cmd/mecatui/version.go @@ -0,0 +1,22 @@ +package main + +import "github.com/stacklok/mecatl/internal/buildinfo" + +// version is replaceable in tests and directly linker-stampable for release +// builds. Ordinary builds inherit the canonical build ID at package init. +var version = "dev" + +func init() { + if version == "dev" { + version = buildinfo.BuildID + } +} + +// microVMReleaseStampRequired is set on published host binaries so a missing or +// misnamed defaults/version linker assignment fails before any lifecycle command. +var microVMReleaseStampRequired string + +// microVMReleaseDefaultsB64 is release-generated, base64 JSON stamped into the +// published mecatui binary. Ordinary source builds intentionally leave it empty; +// microvm-local is supported by the signed Linux-amd64 release binary. +var microVMReleaseDefaultsB64 string diff --git a/docs/READING.md b/docs/READING.md index 0e514fa0a..5aa7dbccd 100644 --- a/docs/READING.md +++ b/docs/READING.md @@ -36,6 +36,7 @@ is listed, and its follow-on reading is noted. Read any that cover your area. | [Context & compaction](architecture/context-and-compaction.md) | How does the token budget + compaction cascade keep a long run inside the context window? | [agent loop](architecture/agent-loop.md) | | [Memory](architecture/memory.md) | How does cross-session recall (Remember/Recall/SearchMemory) work? What is the user model? | [agent loop](architecture/agent-loop.md) | | [Observability](architecture/observability.md) | What telemetry, persistence, and reliability seams exist? How do the event log, session lease, and remote drivers work? | [ports](architecture/ports.md) | +| [Local microVM environments](architecture/microvm-environments.md) | How does the opt-in local runtime preserve environment affinity, isolate paths and credentials, verify artifacts, govern guest egress, recover lifecycle state, and report readiness? | [ports](architecture/ports.md) | | [Parallelism](architecture/parallelism.md) | How does fork-join parallelism (the Parallel tool) work? How are team-member workspaces isolated? What is worktree binding? | [subagents & teams](architecture/subagents-and-teams.md) | | [Extensibility](architecture/extensibility.md) | What MCP, skills, progressive disclosure, and engine-as-library seams exist? | [ports](architecture/ports.md) | | [Deployment & hardening](architecture/deployment-and-hardening.md) | How is the server hardened (auth, rate limiting, health, graceful shutdown)? How do workspace trust, the posture ladder, and permission/bash governance work? | [API surface](architecture/api-surface.md) | @@ -57,6 +58,7 @@ is listed, and its follow-on reading is noted. Read any that cover your area. | 2 | [Install](usage/install.md) | | 3 | [Quickstart](usage/quickstart.md) — the 60-second demo | | 4 | [Running `mecated`](usage/mecated.md) — the server | +| Optional | [Local microVM environments](usage/microvm-environments.md) — first run, daily use, recovery, paths, and limits | | Then | Any topic branch from the [usage guide](usage.md): guardrails, model routing, workspace trust, skills/soul/user-model, mecak8s, configuration, permissions, hooks, mecatequi CI, the gRPC/HTTP APIs, troubleshooting | --- diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index 6a57700bf..4248df15e 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -285,6 +285,10 @@ PR after verification. There is no cleanup or status-only PR. - [Canonical Shell command tool](canonical-shell-command-tool.md) — canonical `Shell` and `ShellStatus` model-facing names, safe legacy `Bash` input normalization, and parser-backed portable-POSIX feedback for model-facing shell commands. Status: proposed. +- [MicroVM execution environments](microvm-execution-environments.md) — local + repository-scoped microVMs with distinct logical Git worktree attachments, guest + filesystem/Bash, explicit guest egress posture, verified runtime artifacts, durable + attachment inventory, and fail-closed restart limits. Status: landed. ## See also diff --git a/docs/acceptance/microvm-execution-environments.md b/docs/acceptance/microvm-execution-environments.md new file mode 100644 index 000000000..cddf9c580 --- /dev/null +++ b/docs/acceptance/microvm-execution-environments.md @@ -0,0 +1,171 @@ +# MicroVM execution environments — acceptance plan + +**Phase:** MVP rescope — repository-scoped local microVM execution +**Status:** landed +**Evidence:** Tasks 59–65 are complete. The required Linux amd64 KVM journey passes; all other live-platform claims remain deferred. +**Issue:** [stacklok/mecatl#526](https://github.com/stacklok/mecatl/issues/526) +**ADR:** [ADR 0326](../adr/0326-microvm-execution-environments.md) +**Accumulator branch:** `acc/microvm-execution-environments` + +The MVP uses one mutable microVM and one rootfs for one local operator and one canonical +Git repository. Sessions and isolated children reuse that repository VM but receive +separate daemon-created worktrees and authenticated logical `EnvironmentRef`s. This is a +small local, single-user, Git-only capability: it deliberately does not solve fleet +management, comprehensive lifecycle automation, or cross-process merge coordination. + +Tasks 59–65 preserve their completed behavior and acceptance criteria and replace +all earlier superseded redesign tasks. + +## In scope — 7 scenarios + +### Scenario 1 — ordinary deployment-default selection converges on a ready local backend + +This completed scenario follows +[ADR 0326 — readiness](../adr/0326-microvm-execution-environments.md#5-make-readiness-part-of-ordinary-use). + +**Acceptance:** + +- AC1.1: Selecting `microvm-local` as the trusted deployment default runs idempotent `EnsureReady` before MicroVM placement, while the host-local default does not start or probe microvmd. + - verify: `TestMicroVMDefaultPlacementUsesNormalCreateSessionAndExactReattach` +- AC1.2: Concurrent startup or first-use calls are serialized by the inter-process manager lock and converge on one compatible daemon and configuration. + - verify: `TestMicroVMRedesign_Scenario1_EnsureReadyConvergesUnderManagerLock` +- AC1.3: A failed readiness attempt returns an actionable error without changing the configured deployment default; repeating ordinary use retries readiness. + - verify: `TestMicroVMRedesign_Scenario1_ReadinessNeverRewritesDesiredConfig` +- AC1.4: The dedicated `--microvm` flag and required `microvm init` and `microvm recover` commands are absent; `status`, `doctor`, and `delete` remain available and nonduplicative. + - verify: `TestMicroVMRedesign_Scenario1_ObsoleteActivationAndRecoverySurfaceIsRemoved` +- AC1.5: With no microVM profile selected, existing default and no-fs snapshots, catalogs, prompts, startup, and tool behavior remain byte-compatible and no microVM dependency enters the engine module. + - verify: `TestInvariant_microvm_disabled_is_byte_compatible` + +### Scenario 2 — canonical repository identity owns one durable VM generation + +This follows [ADR 0326 — singleton lifecycle](../adr/0326-microvm-execution-environments.md#2-use-one-durable-vmrootfs-record-per-operator-and-canonical-git-repository). + +The daemon uses `(operator, canonical Git common directory)` as the only repository VM +key. This scenario establishes lifecycle identity and the rootfs singleton; it does not add +logical guest multiplexing, worktree routing, deletion UX, or retention policy. + +**Acceptance:** + +- AC2.1: Canonically equivalent checkouts for one local operator resolve to one repository identity and one VM generation, while a different Git common directory or operator resolves to a different durable registry entry. + - verify: `TestMicroVMMVP_Scenario2_CanonicalRepositoryIdentitySelectsSingletonVM` +- AC2.2: Concurrent first use durably converges on one VM/rootfs record for the canonical key; an exact healthy generation reattaches only while every owned dependency remains live in-process, while daemon restart or other missing/inconsistent runtime state fails loudly without minting a replacement and preserves the durable record/rootfs/worktrees. + - verify: `TestMicroVMMVP_Scenario2_DurableSingletonRegistryReattachesOrFailsLoudly` +- AC2.3: Repository-controlled names, symlinks, linked-worktree metadata, and hostile path components cannot collide registry identities or escape the owner-scoped state root. + - verify: `TestMicroVMMVP_Scenario2_RepositoryIdentityIsCanonicalAndConfined` + +### Scenario 3 — logical worktrees are authenticated and confined + +This follows [the logical-routing architecture](../architecture/microvm-environments.md#logical-worktree-routing). + +The daemon creates worktrees and registers their logical roots with the already-running +repository guest. Filesystem and exec remain bound to one `EnvironmentRef` and one root. + +**Acceptance:** + +- AC3.1: Two logical environments in one repository receive distinct daemon-created worktrees, branches, indexes, logical `EnvironmentRef`s, and guest roots while naming the same repository VM generation. + - verify: `TestMicroVMMVP_Scenario3_LogicalEnvironmentsShareVMNotWorktree` +- AC3.2: A newly accepted vsock/Unix peer proves possession of the repository boot authority before the host sends any binding capability, registration, request, or secret; the challenge binds owner, repository/VM identity, VM generation, and control/data channel purpose, so connection order grants no identity. Guest registration, handshake, and every filesystem or exec request then authenticate the logical `EnvironmentRef` and assigned root before dispatch. + - verify: `TestRepositoryGuestAuthenticationRejectsCompetingConnectorBeforeDisclosure`, `TestMicroVMMVP_Scenario3_EnvironmentRefAuthenticatesAssignedRoot` +- AC3.3: Stale, replayed, wrong-owner, wrong-generation, sibling-ref, path-escape, and cross-worktree protocol requests fail closed; Workspace and CommandRunner remain affined to the same assigned root/cwd with no host fallback. Git worktrees isolate working/index state and logical RPC routing, not mutually hostile same-repository Bash: arbitrary Bash may address sibling guest paths. Different repositories remain VM-isolated and host/other-repository paths remain unavailable. + - verify: `TestInvariant_microvm_logical_environment_is_confined_and_affined` + +### Scenario 4 — immutable Brood bytes and one explicit repository rootfs + +This follows [ADR 0326 — direct Brood consumption](../adr/0326-microvm-execution-environments.md#4-consume-brood-directly-and-provide-an-explicit-linux-guest). + +The artifact and static guest-contract work in tasks 60–61 remains complete. The remaining +MVP step attaches that primitive to the repository generation. + +**Acceptance:** + +- AC4.1: Controlled release/admission resolves Brood base `latest` to an immutable platform manifest digest, records the mutable discovery reference and resolution evidence, and admits runtime use only under a valid mecatl downstream endorsement. + - verify: `TestMicroVMRedesign_Scenario4_BroodLatestIsDiscoveryOnly` +- AC4.2: Runtime boots the admitted Brood platform bytes directly without a Brood rebuild or derived guest-tools image, and a changed resolution, wrong platform, missing endorsement, stale policy, or corrupted subject fails before VM execution. + - verify: `TestMicroVMRedesign_Scenario4_RuntimeConsumesOnlyEndorsedBroodDigest` +- AC4.3: One materializer is owned by each repository VM generation; the independently verified guest agent is injected into that private rootfs, and creating sessions or children performs no additional rootfs clone or copy. + - verify: `TestMicroVMMVP_Scenario4_RepositoryGenerationOwnsSingleRootFS` +- AC4.4: Guest setup explicitly establishes workload UID/GID 65532, HOME, PATH, default workdir, writable home, and declared package/tool caches independent of Brood's image command or entrypoint. + - verify: `TestMicroVMRedesign_Scenario4_GuestRuntimeContractIsExplicit` +- AC4.5: Runtime Sigstore verification uses `toolhive-core/container/verifier` in-process and production configuration exposes no cosign executable/path, verification subprocess, or verification temporary-file protocol. + - verify: `TestMicroVMRedesign_Scenario4_SigstoreVerificationIsInProcess` + +### Scenario 5 — sessions and delegation attach to repository-scoped execution + +This follows [the attachment architecture](../architecture/microvm-environments.md#session-and-delegation-attachment). + +The existing Environment forker/merger behavior is sufficient for the MVP. No new +cross-process merge coordinator or crash-durable merge journal is required. + +**Acceptance:** + +- AC5.1: Sessions in one repository attach to the same repository VM with distinct logical refs and worktrees; a direct-write child reuses its parent's logical Environment, while a read-only Subagent, Parallel branch, or Team member receives a distinct logical ref and worktree in that VM. + - verify: `TestMicroVMMVP_Scenario5_SessionsAndChildrenReuseRepositoryVM` +- AC5.2: Closing a session or child detaches its process-local handles without destroying the repository VM, rootfs, shared cache, or another attached logical environment. + - verify: `TestMicroVMMVP_Scenario5_CloseDetachesWithoutDestroyingRepositoryVM` +- AC5.3: The existing isolated-child merge path applies a non-conflicting child change and preserves the child on conflict; the MVP makes no cross-process serialization or crash-recovery claim. + - verify: `TestMicroVMMVP_Scenario5_BasicExistingMergeBehavior` +- AC5.4: Production status inventories repository logical attachments—not the superseded session-per-VM registry—in deterministic pages of at most 64, showing two distinct logical worktrees on one repository generation; exact logical deletion removes clean state, retains dirty state for recovery, and never implies repository-VM deletion. + - verify: `TestRepositoryProductionInventoryPaginationAndLogicalDelete` + +### Scenario 6 — Linux ownership and useful networking are honest + +This follows [ADR 0326's Linux guest decision](../adr/0326-microvm-execution-environments.md#4-consume-brood-directly-and-provide-an-explicit-linux-guest). + +Linux amd64 is the only live target. The default network is useful rather than presented as +containment; tightening remains optional and fail-closed when selected. + +**Acceptance:** + +- AC6.1: On Linux amd64, guest workload UID/GID 65532 maps to the daemon user through an unprivileged user namespace, model commands run unprivileged, and session or child worktrees are never widened to world-readable, world-writable, or world-traversable modes. + - verify: `TestMicroVMMVP_Scenario6_LinuxUserNamespaceAvoidsWorldModeWidening` +- AC6.2: The built-in hosted profile provides unrestricted IPv4 guest egress by default and reports that the IPv6 stack remains enabled while external IPv6 is unrouted and unsupported; optional deny-all or allowlist tightening filters IPv4, disables IPv6, and aborts readiness rather than falling back when either enforcement step fails. + - verify: `TestMicroVMMVP_Scenario6_PermissiveIPv4WithOptionalFailClosedTightening` + +### Scenario 7 — one Linux amd64 journey proves the MVP + +This follows [the required live journey](../architecture/microvm-environments.md#required-live-journey-and-limits). + +The live proof is intentionally narrow and operator-oriented. + +**Acceptance:** + +- AC7.1: Linux amd64 KVM enters through production profile/session composition and proves ordinary first use, direct admitted Brood consumption with in-process verification, one repository VM/rootfs, two sessions with a shared declared cache and distinct worktrees, confined filesystem and exec, unrestricted IPv4 networking with external IPv6 unsupported, close-detach, and prompt explicit daemon-restart failure that preserves the record/rootfs/worktrees and mints no replacement. Optional fail-closed tightening is proven separately by AC6.2's production app/profile and network enforcement tests, not by this live journey. + - verify: demonstration — `task e2e:microvm` is the required Linux amd64 KVM live gate +- AC7.2: Concise architecture, operator, and public documentation describes profile selection, the repository sharing boundary, distinct worktrees, direct Brood admission, Linux ownership, permissive networking, optional tightening, restart failure behavior, and the deferred surfaces without claiming Linux arm64 or macOS live support. + - verify: inspection — `task docs` and `task site:build` prove the linked documentation surfaces build + +## Out of scope — explicitly deferred + +- repository-VM deletion UX and sophisticated retention policy; +- crash-orphan reconciliation beyond an actionable, safe, loud failure; +- crash-durable merge recovery and cross-process/multi-client merge proofs; +- macOS live ownership parity and Linux arm64 live support; +- upstream Brood signing and independent artifact/config refresh channels; +- per-session fairness, quotas, dashboards, and exhaustive cache-poisoning controls. + +Non-Git environments, schedules, remote/multi-user microvmd, cross-principal VM sharing, +unified host+guest egress containment, and moving provider/MCP credentials into the guest +also remain out of scope. + +## Cross-cutting deliverables + +- Historical tasks 1–58 and completed tasks 59–61 remain intact. Tasks 62–65 are the only + remaining implementation tasks. +- The durable registry fails closed on inconsistent restart state; it does not silently + recreate, delete, or reconcile an orphan. +- Default, no-fs, and engine-standalone gates preserve the opt-in module boundary. +- Every remaining numbered AC is quoted with its exact `verify:` line in exactly one of + tasks 62–65. + +## Definition of done + +1. Tasks 62–65 and every acceptance criterion above are complete. +2. `task lint`, `task test`, `task api:check`, and nested microVM module gates pass. +3. `task docs`, `task site:build`, and `task ac-trace-strict` pass. +4. `go run ./cmd/mecademo` still prints a complete offline session. +5. Linux amd64 KVM passes the required MVP journey; no other platform is represented as live evidence. + +## Exit criteria + +When the definition of done holds on the accumulator and review finds no ship blocker, the +orchestrator may flip this plan from `in-progress` to `landed`. diff --git a/docs/adr/0027-cloud-native.md b/docs/adr/0027-cloud-native.md index 5a107f16e..0800423de 100644 --- a/docs/adr/0027-cloud-native.md +++ b/docs/adr/0027-cloud-native.md @@ -563,6 +563,35 @@ construction, not a cached override). The in-tree Kinds never reach the resolver zero ref is stamped from the first resolved live Environment on the next save (no migration sweep). The re-audit verdict is CLEAN: no restart-losable state beyond what the snapshot already holds, no new outlives-a-call resource. + +**MicroVM lifecycle re-audit (List 1 / List 2 — ADR 0108 Scenario 5).** The production +backend changes the earlier clean result: it adds the durable daemon registry and the +session-lifetime VM/process/socket/worktree resource family (List 1 row 70). The registry +holds lifecycle progress and tombstones atomically and is therefore also the new fidelity +source in List 2 row 43. Harness-local environment handles remain reconstructible from the +session's existing exact `EnvironmentRef`; engine rehydration stays an independent decision. +Deletion and retention first persist a tombstone, then generation-fenced idempotent cleanup +converges after crashes or disk-full retries. PID plus process-start identity, VM id, +endpoint, and generation must all agree before destructive runtime work, so stale sockets, +PID reuse, and a second daemon cannot target another environment or resurrect a deleted one. + +**Same-repository concurrency re-audit (Scenario 6).** The daemon-wide +`AdmissionController` adds process-lifetime per-owner/deployment counters and rolling boot +windows (List 1 row 71; List 2 row 44). Successful lifecycle creation retains one lease +covering the active VM, worktree, CPU, RAM, disk, and inode allocation; failed transactions +release it, and exec/fork/pull callers use the same atomic controller for their shorter-lived +leases. A daemon restart must rebuild active reservations from row 70's registry records plus +the operator profile before accepting creates. Exec/fork/pull reservations disappear with +their processes; rolling boot history resets at daemon restart by design. + +**MicroVM operations re-audit (List 1 / List 2 — ADR 0108 Scenario 8).** The +process-scoped `OperationsObserver` adds one fixed-size in-memory metric snapshot +(List 1 row 73). It has no goroutine, descriptor, dynamic label set, or cleanup hook; +the composing daemon drops it at exit. The doctor is per-invocation and adds no +outlives-a-call resource. List 2 is unchanged: metrics are reset-by-design operator +observations, not session/run state, while stale-resource truth remains the existing +durable registry and tombstones in rows 70/43. + ### Phase 6: crash-orphaned running-session recovery (SHIPPED) A session's `state` is persisted mid-turn — every `e.save` after `BeginTurn` @@ -861,6 +890,14 @@ durable artifact survives and is reloaded), or **lost** (gone, possibly leaking) | 68 | mecak8s plaintext drain-only listener | `cmd/mecak8s` (`serve`) | process | bound before serving; `boundedShutdown` shuts down its `http.Server`, and deferred listener close releases it on every later startup failure | **reconstructible/reset-by-design**: listener state is process-local and recreated from `--drain-addr` (default `0.0.0.0:8082`); no session state exists | `cmd/mecak8s/serve.go` (`drainHTTPMux`, `serve`, `boundedShutdown`); ADR 0290 | | 69 | Worktree selector HMAC key (ADR 0291) | `app.Build` | process lifetime | no goroutine, registry, map, cache, or external handle; the random key dies with Build/process and needs no explicit cleanup | **reset-by-design**: the key and selectors are not persisted. Restart invalidates every selector, clients relist worktrees, and the server re-enumerates current choices for constant-time matching. There is no selector registry/map to reattach | `internal/app/build.go` (`Build`); `internal/adapter/server/worktree_selector.go` (`WorktreeSelectorIssuer`) | | 70 | Managed temporary command/job reaper worker, root GC lock/completion record, and per-lease locks (ADR 0281) | `app.Build` / `managedtemp.Namespace` | process worker; durable private managed root | `Built.Close` cancels the worker and waits only `shutdown_reap_timeout`; each startup/periodic sweep is bounded by `reap_timeout`. The root lock is non-blocking, lease locks are acquired non-blockingly per candidate, and only validated unlocked leases past TTL are removed. System or unsupported modes construct no worker and make no mutation | **mixed**: the ticker/worker reset on restart; owner-only completion record and lease manifests persist. A crashed holder releases advisory locks, so a later interval-gated Build sweep can reclaim eligible residue; incomplete/cancelled scans publish no completion record. No List 2 row: this is adapter-owned cleanup coordination, not session/run state | `internal/app/managed_temp_worker.go` (`startManagedTempWorker`); `internal/adapter/managedtemp/reaper_unix.go` (`Sweep`) | +| 73 | Repository-scoped local microVM generation: VM/runner process, private endpoint, and one private materialized rootfs with its injected guest agent, plus the durable registry record and flock (ADR 0224 Scenario 2) | `mecatl-microvmd`; one `RepositoryVMRecord` per `(operator, canonical Git common directory)` in `RepositoryVMRegistry` | repository generation; the VM and rootfs intentionally outlive sessions and harness processes, while each registry lock is held only for one lookup/admission/reattachment transaction | first admission writes the exact provisioning identity before materializing one rootfs and starting one VM. Later same-key use only validates and reattaches the ready record. The MVP has no deletion, retention, replacement, or destructive reconciliation path: a partial record, missing rootfs, absent runtime, or mismatched generation/VM/PID/process-start/endpoint identity fails loudly and leaves the durable record untouched | **persisted/fail-loud**: each opaque owner/repository state directory holds one versioned JSON record committed by fsync + atomic rename + directory fsync under an inter-process flock. A restarted daemon reattaches only after the private rootfs and complete runtime identity match. It never calls Start or rematerializes on an existing record. See List 2 row 47 | `environment/microvm/repository_lifecycle.go` (`RepositoryVMRegistry`, `RepositoryVMRecord`, `Ensure`); `environment/microvm/repository_rootfs.go` (`repositoryRootFSMaterializer`) | +| 74 | Local microVM resource-admission counters and rolling boot windows (ADR 0108 Scenario 6) | `mecatl-microvmd`; one mutex-guarded `AdmissionController`, with one retained `AdmissionLease` per live environment and short leases around exec/fork/pull operations | daemon process for counters/windows; environment lifetime for a VM/worktree/CPU/RAM/disk/inode lease; operation lifetime for exec/fork/pull | `Acquire` checks per-owner then deployment bounds atomically; booting→active uses atomic `Replace`; failed creation and completed operations call idempotent `Release`. Positive limits cover booting/active VMs, worktrees, CPU, RAM, disk, inodes, execs, forks, pulls, and per-owner/deployment boot rates. Rejections are stable `AdmissionError` values naming scope, owner, resource, usage, request, and limit | **derived/reset split**: before serving creates after restart, active VM/worktree/CPU/RAM/disk/inode reservations are re-derived from row 73 registry records and their operator profiles. Exec/fork/pull reservations vanish with the old processes. Rolling boot-window timestamps reset at daemon restart by design. See List 2 row 48 | `environment/microvm/admission.go` (`AdmissionController`, `AdmissionLease`); `environment/microvm/lifecycle.go` (`Create`) | +| 75 | Active microVM child-environment fork quota, identity reservations, and per-parent merge lock stripes (ADR 0108 Scenario 7) | one `microvm.EnvironmentForker` per configured driver route plus the authoritative `mecatl-microvmd` `Daemon`; `KindRouter` selects the client route from the parent `EnvironmentRef.Kind` | process/client instance for quota and collision bookkeeping; daemon process for 64 fixed merge mutex stripes; each admitted child holds one quota slot plus its ref, writable worktree, Git metadata, guest endpoint, and generation reservation until cleanup; daemon stripes serialize the whole merge transaction by parent ref across clients | admission is fail-fast before driver work when the bounded channel is full. Fork persists an immutable Git tree containing the parent's exact dirty state. Merge generation-checks parent/child and holds the daemon parent stripe across binding reload, complete additions/replacements/deletions discovery, patch construction/check, immediate parent revalidation, and one atomic apply; conflict preserves the child. Successful cleanup uses a cancellation-detached context, idempotently destroys the exact child generation, removes all five reservations, and releases the slot even when destroy reports an error; failed/aliased driver results are destroyed immediately and never reach a child loop. Existing Subagent/Parallel/Team gates remain the outer concurrency bounds | **persisted/reset split**: fixed client/daemon merge locks and client admission bookkeeping reset at process restart. The daemon registry persists child `ParentRef` + exact-tree `ForkBase` with the generation, so restart/resume cannot cross-attach; cleanup-pending children reconcile through the generation-fenced lifecycle path. See List 2 row 49 | `environment/microvm/forker.go` (`EnvironmentForker`, `NewEnvironmentForker`, `Merge`); `environment/microvm/daemon.go` (`Daemon`, `merge`); `environment/microvm/operational.go` (`LifecycleChildren`); `environment/microvm/lifecycle.go` (`EnvironmentRecord`); `environment/microvm/reconcile.go` (`Reconciler`); `internal/adapter/forker/router.go` (`KindRouter`) | +| 76 | Local microVM operations metrics snapshot (ADR 0108 Scenario 8) | one `microvm.OperationsObserver` per daemon composition | process; fixed closed-dimension counters, gauges, and boot-latency buckets | no goroutine, fd, timer, dynamic label map, or cleanup hook. Producers may pass command/destination text only to methods that discard it immediately; diagnostics emit fixed messages and bounded dimensions. The observer dies with the daemon | **reset-by-design**: operational counters and gauges are observations, not session/run state. Durable stale-resource and cleanup truth remains in List 1 row 73 / List 2 row 47. The per-invocation Doctor adds no outlives-a-call resource | `environment/microvm/operations.go` (`OperationsObserver`, `Doctor`) | +| 77 | Multiplexed microVM guest connection reader + bounded capability replay set (ADR 0224 data plane) | host `control.Client` owns one response-demultiplexing reader; guest `guestagent.Server`/`RepositoryServer` owns one `CapabilityVerifier` per generation endpoint | live environment connection / guest-agent process | `guestagent.Services.Close` closes the sole stream and unblocks the reader; repository-logical close first authenticates and unregisters the complete generation/ref/root binding, atomically blocks dispatch, removes the registration and its per-binding replay entries, and closes the confined workspace. The set retains at most 4096 live consumed nonces and fails closed; closed refs do not consume that lifetime budget. Request/output/frame bounds remain service-owned; repository data connections have a 16-slot persistent-data cap plus one separately reserved control connection so health/register/unregister cannot be starved. No host fallback exists | **derived/reset-safe**: a reattached harness mints a fresh capability from the generation binding. Every guest-agent boot receives fresh independently provisioned key material, so capabilities signed for an earlier boot fail before the replay set resets. No List 2 row | `environment/microvm/control/multiplex.go` (`Client`, `ServeMultiplex`); `environment/microvm/control/capability.go` (`CapabilityVerifier`, `Forget`); `environment/microvm/guestagent/guestagent.go` (`Server`, `Connect`); `environment/microvm/guestagent/repository.go` (`RepositoryServer`, `Unregister`) | +| 78 | Local microVM inventory-continuation signing key | one random 256-bit HMAC key owned by the authoritative `microvmd` `Daemon` | daemon process | no goroutine, fd, timer, collection, or explicit cleanup; the fixed-size key dies with the daemon. It authenticates bounded owner/cursor tokens and is never projected or persisted | **reset-by-design**: a daemon restart invalidates outstanding status continuations; the operator restarts pagination from the first deterministic owner-scoped page. Tokens are navigation state, not session/run state, so no List 2 row | `environment/microvm/daemon.go` (`Daemon`, `encodeInventoryCursor`, `decodeInventoryCursor`) | +| 79 | Repository-scoped microVM generation and logical-registration runtime | production `mecatl-microvmd` owns one `RepositoryComposition`; `RepositoryRuntime` owns generation handles/capability issuers, boot-authority copies, and Unix guest listeners, while `RepositoryServer` owns the guest registration table | repository VM generation for the VM/rootfs/listener/boot authority; logical-environment lifetime for each registration and data-plane stream | Every newly accepted connection must prove the repository generation boot authority over an unpredictable owner/repository/VM/generation/purpose challenge before any capability or protocol payload is sent; rejected peers are closed and arrival order grants no identity. `LogicalEnvironment.Close` performs authenticated unregister, closes the data-plane workspace/runner, then removes only that worktree. The repository VM/rootfs/listener survives sibling close. Daemon exit drops process handles; repository-VM deletion remains deferred | **persisted/fail-closed split**: `RepositoryVMRegistry` persists the exact owner/repository/generation/VM/rootfs/process/endpoint tuple and boot-authority digest. Exact reattachment is accepted only while the VM instance, capability issuer, boot authority, guest transport, and hosted network handle all remain live in the current daemon process. Hosted networking cannot be reconstructed safely after daemon restart, so startup health and resolve fail promptly while preserving the record/rootfs/worktrees and minting no replacement. Logical guest registrations are intentionally process/guest-memory state and are recreated only when the repository runtime remains healthy. See List 1 row 80/List 2 row 50 for the separate durable operator inventory | `environment/microvm/repository_lifecycle.go` (`RepositoryVMRegistry`); `environment/microvm/repository_runtime.go` (`RepositoryRuntime`); `environment/microvm/repository_composition.go` (`RepositoryComposition`); `environment/microvm/guestagent/repository.go` (`RepositoryServer`); `environment/microvm/guestagent/repository_channel.go` (`AuthenticateHostRepositoryChannel`) | +| 80 | Repository logical-attachment operator inventory | one private versioned `attachment.json` beside each logical worktree, loaded into `RepositoryAttachmentManager.records` | logical attachment and retained-dirty recovery lifetime; the map is one daemon process cache | registration fsyncs owner/session/ref/generation, repository identity, confined worktree metadata, and deletion-retained state before publishing the record. Clean close/delete removes it; dirty delete atomically records retained stale state. Live workspace/runner handles remain process-local | **persist-in-store**: a fresh manager scans only the fixed owner/repository/logical hierarchy, rejects symlinks or inconsistent identities, and reconstructs bounded status plus exact logical delete without reattaching the guest or deleting the repository VM. Pagination signing tokens still reset per row 78. See List 2 row 50 | `environment/microvm/repository_attachment_store.go`; `environment/microvm/repository_attachment.go` (`RepositoryAttachmentManager`) | **Remote-login resource re-audit (List 1 / List 2 — ADR 0277).** Remote mecatui adds rows 64–66: the durable public registry plus per-registry/per-target flock domains, the encrypted credential source plus root-scoped keyring key/root flock, and the connected-target refresh/validator transport. These are adapter-owned credential or connection resources, not session/run state, so List 2 gains no row. All flocks are held per operation and process death releases them; their sentinel files persist. There is no cross-store journal, so the documented partial-state crash residual is not rehydratable session state. Registry and credential records are reattached from disk/keyring; validator and access-token state are derived again on the next connect. `mecatui` closes the refresh source, validator, and store before a target-switch restart completes. @@ -1205,6 +1242,10 @@ what is persisted), **reset-by-design** (documented, acceptable), | 45 | Session-scoped MCP broker incarnation, local attachment, and parked authorization (P10–P11; List 1 rows 68–70) | the snapshot persists `session.Session.ExternalBinding` plus the exact private `PendingAuthorization`; the process-owned broker holds logical state, grants/tokens, callback/replay state, timers, and prepared continuations in memory | same-process controls reattach only when the opaque binding matches exactly. **The binding value itself, not merely its persistence, is what defeats a restart**: it is `bindingPrefix + "." + generation`, where `bindingPrefix` is random per process and `generation` is an in-memory counter (`internal/adapter/mcpbroker/runtime.go`), so a byte-perfect restored value is structurally guaranteed stale the moment the broker process restarts — no persistence fix closes this alone. A process restart loses in-process broker authority and timers; a lease-gated status-unavailable observation pairs the parked call as interrupted and never replays the protected mutation. The one narrow exception is the pre-prompt workspace-enrollment seam (`rebindBrokerAttachment`, `internal/adapter/server/mcp_broker.go`), which adopts the live incarnation ONLY where nothing durable was built on the lost one — it must not widen to authorization control paths, which stay fail-closed. A future remote broker can return pending, granted, or a concrete terminal status through the same control flow | **durable pending / reset runtime / derive control**: the aggregate preserves exact effective and deferred calls, while timers and prepared runs are recreated only from authoritative live state. Public wire and client surfaces remain deferred to P12–P14 | P10, P11 | | 46 | Workspace-enrollment outer correlation versus ToolHive inner authorization storage (ADR 0311; List 1 rows 68–69 and 71) | the snapshot persists the exact `PendingWorkspaceEnrollment`; mecatl's runtime owns its process-local callback state/incarnation mapping, while ToolHive memory or Redis storage owns separate inner upstream authorization/token records | Redis-backed ToolHive records may survive restart, but the outer operation-to-session correlation does not. Rebinding the pre-prompt session to a fresh runtime only discards the exact old pending correlation and permits a fresh enrollment; it is not authority to search ToolHive storage or resume an old operation. A wrong replica has the same correlation loss | **persist aggregate / reset outer correlation / do not infer inner recovery**: terminal observations are repeatable in-process until the aggregate save succeeds; after process loss, discard only the exact old pre-prompt correlation and begin a fresh enrollment. Durable outer correlation, replica routing, and restart recovery remain deferred | ADR 0311 | +| 47 | Repository-scoped local microVM singleton record (ADR 0224; List 1 row 73) | `environment/microvm.RepositoryVMRegistry`: canonical owner/repository key, generation, VM/process-start/endpoint identity, and private rootfs path | restart reloads the exact ready generation. Reattachment succeeds only after the rootfs and complete runtime identity match. Provisioning, malformed, missing, or foreign state fails loudly; neither a harness nor a racing daemon mints a replacement, recopies the rootfs, or destroys uncertain resources | **persist-in-store**: one owner/repository-confined, flock-serialized, size-bounded versioned JSON record committed by fsync + atomic rename + directory fsync. Concurrent first use converges while holding the same key lock across durable admission, one rootfs materialization, VM start, and the ready commit | 0224 Scenario 2 | +| 48 | Local microVM admission usage and rolling boot windows (ADR 0108; List 1 row 74) | process-local `environment/microvm.AdmissionController`: per-owner/deployment usage maps plus per-owner/deployment boot timestamps | restart loses the counters and timestamps while row 47's registry still names every live/provisional environment and its owner/profile | **derive/reset split**: daemon startup re-reserves active or booting VM/worktree/CPU/RAM/disk/inode usage from row 47 records plus the strict operator profile before opening admission. In-flight exec/fork/pull work ended with the old process and starts at zero. Boot-window timestamps reset by design | 0108 Scenario 6 | +| 49 | MicroVM child identity, immutable exact-tree fork base, cleanup state, and process-local admission/merge synchronization (ADR 0108 Scenario 7; List 1 row 75) | durable `EnvironmentRecord.ParentRef` + `ForkBase` on each child generation; process-local bounded channel and collision maps in `environment/microvm.EnvironmentForker`; fixed merge lock stripes in the daemon | daemon/harness restart loses only quota/collision bookkeeping and zero-value mutex state; the registry retains exact child generation, parent generation, immutable Git tree containing the fork-time dirty parent, and monotonic cleanup state | **persist/derive split**: persist parent/exact-tree base and cleanup checkpoints in the existing flocked registry; reconstruct operations from those exact identities. Rebuild empty admission maps and daemon lock stripes after restart. The daemon lock covers all clients and the complete check/patch/revalidate/apply transaction. Merge may omit the client-cached base only after restart because the daemon reloads the durable value; a mismatch conflicts/fails rather than attaching elsewhere. Cancellation/timeout/drain cleanup is detached from the run context, and the reconciler finishes cleanup-pending exact child generations without touching ready parents | 0108 Scenario 7 | +| 50 | Repository logical-attachment inventory and dirty-retention decision (List 1 row 80) | private versioned metadata stores exact owner/session/ref/generation, repository generation, source/worktree/metadata/branch paths, and deletion-retained state; the manager map is derived | restart drops live guest registrations and Workspace/runner handles but reloads status/delete identity. A dirty retained worktree remains a visible destroyed/stale row; an exact repeated delete is rejected rather than crossing attachment identity | **persist-in-store**: fsync + atomic rename + directory fsync under the confined logical root. Startup rejects malformed, symlinked, duplicate, or path-inconsistent records. Exact delete can inspect and clean/retain the recorded worktree without guest reattachment; repository-VM deletion and a retention reconciler remain deferred | Repository inventory correction | Two ledger observations worth stating in prose: - **The awaiting state was the one place where durability and liveness diverged** diff --git a/docs/adr/0326-microvm-execution-environments.md b/docs/adr/0326-microvm-execution-environments.md new file mode 100644 index 000000000..e99952da7 --- /dev/null +++ b/docs/adr/0326-microvm-execution-environments.md @@ -0,0 +1,215 @@ +# ADR 0326 — Repository-scoped local microVM execution environments + +- Status: Accepted +- Date: 2026-08-19 +- Scope: Linux amd64 local microVM MVP; repository VM identity; logical worktree routing; artifact admission; guest ownership and networking +- Supersedes: the earlier branch-only session-per-VM, derived guest-tools image, deny-default networking, explicit init/recover, external cosign, and per-session rootfs decisions recorded in prior revisions of this unmerged ADR +- Superseded by: none + +## Context + +ADRs 0208, 0211, and 0214 already define the engine contract: a +`tool.Environment` affines one `Workspace` and optional bound `CommandRunner`, isolated +children receive complete Environments, and a durable open `EnvironmentRef` reattaches a +non-in-tree backend without local fallback. The unmerged microVM implementation proved the +seam, but its session-per-VM design made ordinary coding expensive and grew lifecycle, +operations, and platform ambitions beyond the first usable release. + +The useful MVP reuse boundary is one local operator and one Git repository. Sessions need +separate writable Git state but can intentionally share packages, guest home, declared +caches, and a rootfs. Different repositories must not share that mutable state. + +Brood provides the base. Its mutable `latest` reference is discovery rather than execution +identity, so controlled admission resolves and endorses immutable platform bytes. Linux +amd64 KVM is the sole live platform in this decision. Linux arm64 and macOS live support are +future work, not implied by compile coverage. + +## Decision + +### 1. Keep the core Environment contract unchanged + +The backend implements the existing `tool.Environment`, `Workspace`, bound +`CommandRunner`/`CommandStreamer`, `EnvironmentForker`, `EnvironmentMerger`, and the +server-owned `PlacementProvider`/exact `PlacementReattacher` contracts. Repository identity, +OCI admission, and VM lifecycle remain outside `engine/`; go-microvm/libkrun and artifact +dependencies remain in the opt-in module and composition. + +Providers, permissions, hooks, MCP, memory, identity, and credentials remain on the host. +Only execution tools and the independently admitted guest agent run in the VM. + +### 2. Use one durable VM/rootfs record per operator and canonical Git repository + +The daemon canonicalizes the Git common directory and combines it with the authenticated +local operator identity. That tuple is the only repository key. Repository-controlled +display names, linked-worktree paths, symlinks, and path components cannot choose a state +directory directly. + +A durable, inter-process-locked registry admits one VM generation and one private rootfs +materialization for that key. Concurrent first use converges on that singleton. Exact +reattachment is allowed only while the VM, capability issuer, guest transport, and hosted +network provider are all still owned and live in the current daemon process. Daemon restart +loses the in-process hosted network provider, so readiness and resolve fail promptly with a +phase-specific inconsistent/unavailable error while preserving the durable record, rootfs, +and worktrees. Missing, foreign, or otherwise inconsistent runtime state follows the same +safe behavior: the MVP does not mint an empty replacement, destroy an orphan, or claim +comprehensive crash reconciliation. + +The repository VM shares its rootfs, package installation, guest home, and declared caches +among logical environments in that repository. This is an intentional trust boundary. +Another operator or canonical Git common directory receives another registry entry, VM, +rootfs, home, and cache boundary. + +This registry task does not itself add guest multiplexing. Logical worktree registration +and routing are the next layer over the singleton lifecycle. + +### 3. Route authenticated logical worktrees inside the repository VM + +The daemon creates a distinct host Git worktree, branch, and index for each session or +isolated child and registers its guest-visible root. Each opaque `EnvironmentRef` binds the +owner, repository VM generation, and assigned root. Before the host sends a binding +capability, registration, request, or secret on a newly accepted vsock/Unix connection, the +peer must answer an unpredictable challenge with the repository generation's boot +authority. The proof binds owner, repository identity, VM identity, generation, and channel +purpose (`control` or `data`); connection arrival order conveys no identity. Guest handshake +and every filesystem or exec request then authenticate the complete logical binding before +dispatch. + +A protocol request cannot choose an arbitrary host path, switch its `EnvironmentRef` to a +sibling worktree, replay a stale binding, or fall back to host execution. Workspace and bound +runner observe the same assigned root and cwd and preserve the existing versioned +filesystem and streaming/cancellation contracts. This logical RPC confinement is not a +kernel boundary between same-repository processes: arbitrary Bash in the repository VM may +address another worktree through its guest-visible path. That is the accepted +operator+repository trust boundary, analogous to same-principal agents using Git worktrees; +worktrees isolate Git working/index state and routing, not mutually hostile processes. +Different canonical repositories remain isolated in different VMs and do not expose one +another's host paths or guest roots. + +Sessions in one repository reuse the same VM with distinct refs and worktrees. A +direct-write child reuses its parent's Environment; a read-only Subagent, Parallel branch, +or Team member receives a distinct daemon-created worktree and ref in that VM. Closing a +session or child detaches process-local handles; it does not destroy the repository VM, +rootfs, shared caches, or sibling logical environments. + +The MVP uses the existing isolated-child merge behavior: non-conflicting changes apply and +conflicts preserve the child. It does not add crash-durable merge recovery, daemon-wide +cross-process serialization, or multi-client merge proofs. + +### 4. Consume Brood directly and provide an explicit Linux guest + +Brood `latest` is used only in controlled discovery. Release/admission resolves the Linux +amd64 platform manifest digest, records the discovery evidence, and creates a downstream +mecatl endorsement. Runtime accepts only admitted immutable bytes and fails before boot on +changed resolution, wrong platform, stale policy, missing endorsement, or corruption. + +Mecatl does not rebuild Brood or publish a derived guest-tools image. The independently +built guest agent is verified separately and injected into the repository generation's one +private rootfs materialization. Guest setup explicitly establishes workload UID/GID 65532, +`HOME`, `PATH`, default workdir, writable home, and declared package/tool cache directories. +Creating a session or child does not clone the rootfs again. + +Sigstore verification runs in-process through `toolhive-core/container/verifier`. +Production admission has no cosign executable/path, subprocess, or temporary-file protocol. +Upstream Brood signing is deferred; the downstream endorsement is the MVP trust root. + +On Linux, an unprivileged user namespace maps guest workload UID/GID 65532 to the daemon +user. Host worktrees are not widened to world-readable, world-writable, or +world-traversable modes. Model commands run unprivileged. Live macOS ownership parity and +Linux arm64 are not part of this MVP. + +The built-in hosted topology defaults to unrestricted IPv4 guest egress. The guest IPv6 +stack remains enabled, but go-microvm hosted networking does not route external IPv6; external +IPv6 is unsupported and is reported that way rather than as dual-stack connectivity. +Operators may select deny-all or an allowlist; tightening filters IPv4 and disables guest +IPv6, and fails readiness closed if either enforcement step cannot start. Guest policy does +not govern host providers, WebFetch, WebSearch, MCP, hooks, OCI discovery, or telemetry. + +### 5. Make readiness part of ordinary use + +Operator configuration selects `microvm-local` as the deployment's trusted default +placement provider. Startup or first use runs the completed idempotent `EnsureReady` operation under its +inter-process manager lock. Failure is actionable and never rewrites desired configuration; +ordinary use retries it. + +There is no dedicated `--microvm`, required `init`, or `recover`. Existing nonduplicative +`status`, `doctor`, and `delete` commands remain, but the MVP adds no repository-VM deletion +UX. Status returns deterministic owner-scoped pages of at most 64 entries and prints an +opaque continuation token when another page exists. Restart supports exact reattachment only +for dependencies still live in-process; a daemon restart loses hosted networking and therefore +returns a loud safe failure without replacing or deleting the preserved generation. + +### 6. Keep source-build release activation developer-only + +Published binaries embed authenticated release defaults; ordinary source builds fail closed. +For local development only, the `microvm_dev` compile-time tag registers two local-root +flags: `--microvm-dev-release=ABSOLUTE_DESCRIPTOR_PATH` and +`--microvm-dev-acknowledge-untrusted-local-artifacts`. Both are required. Untagged +binaries do not expose them, and a release-stamped binary rejects the development path +regardless of its build tags. + +The descriptor schema is `mecatl-microvm-development-release/v1`. Before readiness it +strictly binds source-build identity, Linux amd64, owner-only regular non-symlink local +bundle and public-key paths, bundle SHA-256, public-key identity, and policy revision. +Unknown, missing, unsafe, or mismatched values fail closed. The admitted local bundle then +uses the same manager transaction and verification as a downloaded release: safe +extraction, bundled installer, artifact signatures, provenance, immutable references, and +daemon policy checks remain mandatory. + +This unsupported workflow exists only in local `mecated serve` and embedded local +`mecatui` composition. It adds no HTTP/gRPC field, settings key, environment-variable +control, arbitrary URL, or installer override. Preparation and tagged binaries stay under +`.scratch`; ordinary `task build` remains unchanged. + +## Consequences + +### Benefits + +- Two sessions reuse one boot, rootfs, package set, home, and declared cache while retaining + distinct Git worktrees. +- Repository and operator boundaries prevent accidental cross-repository mutable-state + sharing. +- The existing Environment seam and merge behavior are reused rather than widened. +- Direct admitted Brood bytes and in-process verification keep the distribution and trust + path small. +- One Linux amd64 first-use and restart journey can prove the complete MVP. + +### Costs and limits + +- Same-repository code can influence later sessions through shared packages, home, and + caches, and arbitrary Bash can address sibling guest worktree paths; exhaustive + cache-poisoning defenses and kernel isolation between same-repository worktrees are not + claimed. +- A broken or orphaned recorded generation can require manual intervention because the MVP + fails loudly rather than reconciling or deleting it. +- No new repository-VM deletion UX or sophisticated retention policy ships in the MVP. +- Merge correctness is the existing single-client behavior, not a crash-durable or + cross-process transaction. +- Permissive egress is not network containment. + +## Deferred decisions + +The following require later ADRs or amendments after MVP evidence: + +- repository-VM deletion UX and sophisticated retention; +- crash-orphan reconciliation beyond loud safe failure; +- crash-durable merge recovery and cross-process/multi-client merge proofs; +- macOS live ownership parity and Linux arm64 live support; +- upstream Brood signing and independent refresh channels; +- per-session fairness, quotas, dashboards, and exhaustive cache-poisoning controls. + +Non-Git sources, schedules, remote or multi-user microvmd, cross-principal VM sharing, +unified host+guest egress containment, and guest provider/MCP credentials also remain out of +scope. + +## Superseded branch-only decisions + +This unmerged ADR replaces session-lifetime VMs, child VMs, rebuilt Brood and a derived +guest image, deny-default networking, explicit activation/recovery, external cosign, world +mode widening, and per-session rootfs copies. Tasks 59–61 completed readiness, admission, +and the static rootfs primitive. Tasks 62–65 implement only the MVP above. + +## See also + +- [Acceptance plan](../acceptance/microvm-execution-environments.md) +- [MicroVM architecture](../architecture/microvm-environments.md) +- [Operator guide](../usage/microvm-environments.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index c03bada41..03b9fb379 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -137,6 +137,7 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE - [0208 — Execution environments and version-aware file mutation](./0208-execution-environment.md) *(runtime-seam deferral superseded by 0211; version protocol authoritative)* - [0211 — Execution-environment runtime seam](./0211-execution-environment-runtime-seam.md) *(supersedes 0208 decisions 1–3; phase-3 persistence deferral superseded by 0214)* - [0214 — Execution-environment persistence and reattachment](./0214-environment-persistence.md) *(supersedes 0211 decision 6 only)* +- [0326 — Local microVM execution environments and developer-only release activation](./0326-microvm-execution-environments.md) ### Agents, teams & delegation - [0283 — Managed delegation-fork lifecycle](./0283-managed-delegation-fork-lifecycle.md) *(proposed; depends on 0281)* diff --git a/docs/architecture.md b/docs/architecture.md index 64728dd1c..61ea9a5cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,6 +63,7 @@ This page is the overview and router; the big picture and the layering rule are - **[Providers — OpenAI adapter & multi-provider](architecture/providers.md)** - **[The API surface](architecture/api-surface.md)** - **[Observability, persistence & reliability](architecture/observability.md)** +- **[Local microVM execution environments](architecture/microvm-environments.md)** — opt-in runtime boundary, path model, artifact/network trust, lifecycle, and operations. - **[Context management & the compaction cascade](architecture/context-and-compaction.md)** — token counting, compaction, and the shared configured/live/catalog context-window resolver. - **[Memory — cross-session recall & consolidation](architecture/memory.md)** - **[Parallelism — fork-join](architecture/parallelism.md)** @@ -1146,6 +1147,35 @@ or selector. Local embedded and daemon deployments configure their root privatel the public API. ACP's required cwd is only checked against the trusted local binding and cannot select authority. +A microVM placement keeps tools and shell in guest `/workspace` while host-owned settings, +soul, memory, MCP, hooks, identity, provider credentials, and the TUI remain outside the +guest. Sessions persist only exact private `EnvironmentRef` identity; unknown, disabled, +mismatched, or unavailable generations fail without host-local fallback. Lifecycle +composition reattaches the exact generation and explicitly detaches or deletes it without +exposing source, worktree, endpoint, or guest paths on public APIs. + +The user-local manager surface is `mecatui microvm doctor|status|delete`; daily use selects +`mecatui --default-placement microvm-local`. The shared provisioning boundary performs +idempotent readiness immediately before the first provision attempt. The live +`microvm-local` support boundary is the signed Linux-amd64 `mecatui` release binary: +ordinary source builds have no authenticated release defaults and fail closed. For source +development only, [ADR 0326](adr/0326-microvm-execution-environments.md#6-keep-source-build-release-activation-developer-only) defines a +separately tagged `microvm_dev` binary whose local composition roots require explicit +acknowledgement and a strict owner-only local release descriptor. Untagged and published +binaries do not expose this path; release verification and daemon-policy checks remain. +Readiness +preflights Git, Python 3, KVM access, and an actual ephemeral unprivileged user-namespace +creation before downloading or provisioning repository state. Readiness and doctor +authenticate the daemon serving the owner-only socket and require its protocol, +release/binary, loaded-config, policy, profile-set, and socket identities to match. +A stale daemon is signaled only after its persisted PID, process-start token, binary, +arguments, and socket identify the exact managed process; otherwise the alias remains +disabled with user-service-manager guidance. It supports local single-user Git sessions on +Linux amd64 with KVM. Linux arm64 and Apple Silicon macOS have compile/static coverage only; +live support is deferred. Schedules, remote placement, multi-user enforcement, non-Git +sources, and unified host+guest egress policy remain out of scope. See the [microVM architecture](architecture/microvm-environments.md) and +[operator guide](usage/microvm-environments.md) (ADR 0108). + Discovery is source-session scoped. `ListCommands(session_id)` and `ListWorktrees(session_id)` first authorize the owner and exactly reattach that source. No-FS returns empty before filesystem discovery. Worktree entries contain bounded diff --git a/docs/architecture/microvm-environments.md b/docs/architecture/microvm-environments.md new file mode 100644 index 000000000..db8dcd242 --- /dev/null +++ b/docs/architecture/microvm-environments.md @@ -0,0 +1,172 @@ +# Local microVM execution environments + +> Part of the [mecatl architecture guide](../architecture.md) and listed in +> [`docs/READING.md`](../READING.md). + +**What this covers:** the Linux amd64 repository-scoped microVM MVP: singleton VM/rootfs +identity, logical worktree routing, artifact admission, Linux ownership, networking, and +attachment. + +**Prerequisites:** [the ports](ports.md), especially `tool.Environment`, `Workspace`, bound +`CommandRunner`, and durable `EnvironmentRef` reattachment. + +**Follow-on:** [the operator runbook](../usage/microvm-environments.md). + +The backend is opt-in and lives outside the engine. Root composition talks to local +microvmd; go-microvm/libkrun and artifact machinery do not enter the importable engine. +Providers, permissions, hooks, MCP, memory, identity, and credentials remain on the host. + +> **MVP status:** the repository-scoped backend is implemented for Linux amd64 KVM. +> Compile/static coverage on other platforms is not a live-support claim. + +## Repository identity and singleton lifecycle + +The reuse key is `(authenticated local operator, canonical Git common directory)`. +Canonicalization resolves linked worktrees and symlinks before deriving an opaque, +owner-confined state identity. Caller-controlled names and paths never become registry +selectors directly. + +Microvmd owns an inter-process-locked durable registry. Each key admits one VM generation +and one private rootfs materialization. Concurrent first use converges on that record. +Different operators or Git common directories receive different records and mutable state. + +Exact reattachment is permitted only while every owned dependency remains live in the +current daemon process. The go-microvm hosted network provider is in-process and cannot be +safely reconstructed after daemon restart, so restart readiness and resolve fail promptly +with an actionable phase-specific error while preserving the VM record, rootfs, and logical +worktrees. Missing, foreign, or otherwise inconsistent VM/process/endpoint state follows the +same fail-closed path. The MVP never silently creates a replacement or destroys an uncertain +resource. + +The VM's rootfs, installed packages, guest home, and declared caches are deliberately +shared within the repository key. Code in one session can influence a later same-repository +session through that shared state. No equivalent sharing crosses the repository key. + +## Logical worktree routing + +The lifecycle registry does not expose an arbitrary host root through the logical RPC +protocol. For each session or isolated child, the daemon creates a host Git worktree with +its own branch and index, registers one guest-visible root, and mints an opaque +`EnvironmentRef` binding: + +1. local owner; +2. repository VM generation; +3. logical environment identity; +4. assigned worktree root. + +A newly accepted vsock/Unix connection has no identity from arrival order. Before sending a +binding capability, registration, request, or secret, the host sends an unpredictable +challenge; only the repository guest agent can answer with the generation boot authority. +The proof covers owner, repository and VM identity, generation, and `control`/`data` purpose. +A failed connector is closed without receiving protocol payload, and the host may authenticate +the next connection. + +Guest handshake and every filesystem or exec request then authenticate the complete logical +binding. The guest resolves Workspace requests through its registration table rather than +accepting an arbitrary host path. Stale generations, replayed credentials, sibling refs, and +path escapes fail before dispatch. Transport failure never falls back to host filesystem or +shell. + +Workspace and CommandRunner remain affined to the same root and cwd. Existing opaque file +versions, create-only and conditional writes, ordered stdout/stderr, exit status, bounded +output, and process-group cancellation remain the data-plane contract. This confines +protocol requests; it does not create a kernel sandbox between worktrees in one repository +VM. Arbitrary Bash can address a sibling's guest-visible path. The accepted trust boundary +is operator+repository, like same-principal Git worktrees: branches, indexes, working state, +and logical routing are distinct, but the Bash processes are not mutually hostile. +Different canonical repositories use different VMs and cannot address each other's guest +roots or host paths. + +## Session and delegation attachment + +Two sessions in one repository attach to one VM generation but hold different refs and +worktrees. Direct-write Subagents use the parent's complete Environment. Read-only +Subagents, Parallel branches, and Team members use new daemon-created worktrees and refs in +the same repository VM. + +Closing a session or child releases process-local handles and unregisters the attachment; +it does not stop the repository VM or remove its rootfs, caches, or another logical +worktree. The MVP uses the existing isolated-child merge path: a non-conflicting change +applies and a conflict preserves the child. Daemon-wide multi-client merge serialization +and crash-durable merge recovery are explicitly not claimed. + +## Base image, guest agent, and Linux ownership + +Brood `latest` is discovery only. Controlled admission resolves Linux amd64 platform bytes +to an immutable digest and records a downstream mecatl endorsement. Runtime verifies that +endorsement in process through `toolhive-core/container/verifier` and fails before boot for +wrong, stale, missing, or corrupted evidence. + +Mecatl neither rebuilds Brood nor publishes a derived guest-tools image. It independently +verifies and injects the guest agent into the one private repository rootfs. Static setup +establishes workload UID/GID 65532, `HOME`, `PATH`, default workdir, writable home, and +declared caches. Session and child creation do not copy the rootfs. + +On Linux, the backend passes `WithUserNamespaceUID(65532, 65532)` to go-microvm. +Those arguments name the namespace-side UID/GID; go-microvm maps them to the daemon's +host UID/GID when it creates the unprivileged user namespace. Host worktrees retain their +private modes; the backend never widens them to world-readable, world-writable, or +world-traversable. Model commands run unprivileged. Linux arm64 live support and macOS +ownership parity are deferred, regardless of compile/static coverage. + +## Networking + +`microvm-local` defaults to unrestricted guest IPv4 egress. The guest IPv6 stack remains +enabled, but go-microvm's hosted topology does not route external IPv6; external IPv6 is +unsupported rather than a dual-stack claim. At the two host-local composition roots, +operators select `--microvm-guest-egress=permissive|deny-all|allowlist`; allowlist mode +uses repeatable `--microvm-guest-allow=HOST:PORT/tcp|udp` rules. That means +`mecated serve --headless ...` for the local server, or local embedded +`mecatui --default-placement microvm-local ...`. Rules normalize hostnames and reject +IP literals, wildcards, malformed values, and duplicates. The selection is host-only: +no engine port, HTTP/gRPC request, project setting, or release-default field carries it. +No API client selects this placement; `profile` remains limited to the default tool surface +or explicit `no-fs` attenuation. + +A selected tightening mode filters IPv4 and disables IPv6, aborting readiness if either +enforcement step fails; it never falls back to permissive operation. The validated +selection overlays only the guest-egress fields after authenticated release/resource +defaults are built. Because daemon config identity includes policy, changing the selection +causes the existing mismatch/restart convergence rather than reusing a daemon with stale +policy. Omitting the flags leaves the prior permissive request byte-compatible. + +Guest egress policy does not govern host providers, WebFetch, WebSearch, MCP, hooks, OCI +discovery, or telemetry. + +## Readiness and bounded operations + +Ordinary `--default-placement microvm-local` deployment selection calls the completed idempotent +`EnsureReady` flow from the signed Linux-amd64 release binary. Ordinary source builds do +not embed authenticated release defaults and fail closed. Before release download or +repository provisioning, readiness checks Git, Python 3, read-write KVM access, the Linux +user-namespace controls, and an actual ephemeral namespace creation to detect disabled or +exhausted quota. Doctor repeats those non-destructive host checks. Neither path changes +ACLs, groups, sysctls, or quota. The manager lock converges concurrent startup, and errors never rewrite +desired configuration. There is no dedicated activation flag, init command, or recover +command. + +Status reads the durable repository logical-attachment inventory as the sole `microvm-local` +inventory authority; it survives daemon restart and shows the shared repository generation with each +exact session/ref/worktree and health, in deterministic owner-scoped pages of at most 64 entries +plus an opaque continuation token. Exact delete remains available after restart, removes only the +selected logical attachment and a clean worktree, and records a dirty worktree as retained stale +recovery state. It never deletes the repository VM. +Doctor remains diagnostic. Repository-VM deletion UX and a broad reconciliation state machine +are deferred. Same-process reattachment requires every dependency to remain live; daemon +restart fails loudly and preserves state because hosted networking cannot be reconstructed safely. + +## Required live journey and limits + +Linux amd64 KVM is the only required live platform. Its journey proves ordinary first use, +direct admitted Brood boot and in-process verification, one VM/rootfs, two sessions sharing +a declared cache but not a worktree, confined filesystem and exec, unrestricted IPv4 +networking with the external-IPv6 limitation explicit, detach, and prompt daemon-restart +failure with preserved state and no replacement. Optional fail-closed tightening is proven +by AC6.2's production app/profile and network enforcement tests rather than a second live VM. + +Deferred after the MVP: repository-VM deletion UX, +sophisticated retention, crash-orphan reconciliation, crash-durable and cross-process +merge, Linux arm64 and macOS live support, upstream Brood signing, independent refresh +channels, per-session fairness and quotas, dashboards, and exhaustive cache-poisoning +controls. Non-Git, scheduled, remote, multi-user, and cross-principal placement also remain +out of scope. See [ADR 0326](../adr/0326-microvm-execution-environments.md). diff --git a/docs/architecture/observability.md b/docs/architecture/observability.md index ee4779e99..4f8a329bd 100644 --- a/docs/architecture/observability.md +++ b/docs/architecture/observability.md @@ -126,6 +126,17 @@ coverage for allocations, RSS, tokens, cache-hit rate, and goroutine hygiene. Se `mecatl_schedule_fire_duration_seconds` (the due→terminal wall-clock, recorded only for a fired/failed fire; a skipped fire has no run, duration 0). The duration histogram shares the `latencyInstruments` explicit-bucket ladder. +- **MicroVM runtime operations** (`environment/microvm/operations.go`) — the opt-in + daemon runtime has a separate bounded `OperationsObserver`: fixed boot-latency + buckets; active/booting and aggregate vCPU/memory/disk gauges; and counters for + execs, guest-egress denials, artifact verification, cleanup, reconciliation, and + quota rejection. Dimensions are closed enums only. Commands and denied + destinations enter only as deliberately discarded producer arguments; session/ + environment IDs, paths, credentials, and free text are never labels or diagnostics. + Operational failures have no client `session.Event`, so the observer uses the + injected `port.Diagnostics`; model-visible facts remain event-owned. `Doctor` runs + every hypervisor/artifact/control/network/profile/stale-resource probe and renders + stable actionable PASS/WARN/FAIL findings. > A broader performance-observability effort lands incrementally on a > separate unauthenticated admin listener: `mecated --metrics-addr` remains > loopback-only (default `127.0.0.1:9090`), while embedded `mecatui --perf` diff --git a/docs/architecture/ports.md b/docs/architecture/ports.md index d2fd4e8ea..cd25434a5 100644 --- a/docs/architecture/ports.md +++ b/docs/architecture/ports.md @@ -154,6 +154,30 @@ snapshot field, and `server.Config.EnvironmentResolver` reattaches a live fake proves the contract). The version-aware file-mutation foundation is [ADR 0208](../adr/0208-execution-environment.md). +The production microVM adapter places execution in one repository-scoped VM generation +per authenticated local operator and canonical Git common directory. Sessions and isolated +children attach as distinct logical `EnvironmentRef`/worktree pairs inside that generation; +direct-write children reuse the parent attachment. Closing an attachment unregisters and +closes only its process-local data-plane handles and worktree lifecycle. It does not stop the +repository VM, remove the private rootfs or declared caches, or affect sibling attachments. + +The built-in hosted topology defaults to unrestricted guest IPv4 egress. The guest IPv6 +stack remains enabled, but go-microvm hosted networking does not route external IPv6, so +external IPv6 is explicitly unsupported rather than presented as dual-stack connectivity. +Operators may select deny-all or hostname/port/protocol allowlisting as a tightening: that +mode filters IPv4, disables guest IPv6, and aborts readiness if either enforcement step fails. +Guest policy contains guest processes only; host-side LLM providers, WebFetch, WebSearch, +MCP, hooks, OCI discovery, and telemetry remain outside it. + +The daemon persists one flock-serialized singleton registry record and private rootfs per +repository key, plus private durable metadata for each logical attachment. Live VM, +hosted-network, listener, capability-issuer, guest-registration, Workspace, and runner +handles remain process-local. Exact reattachment is therefore supported only while all of +those dependencies remain live in the current daemon. After daemon restart, readiness and +resolve fail promptly while preserving the singleton record, rootfs, and worktrees; the MVP +never silently provisions a replacement or destroys an uncertain resource. Durable +attachment metadata still supports bounded status and exact logical deletion after restart. + `tool.MemoryStore` and `tool.EnvironmentForker` live alongside it for the same layering reason (the tools that need them depend on the interface, not a `port`). diff --git a/docs/design/IMPLEMENTATION-NOTES.md b/docs/design/IMPLEMENTATION-NOTES.md index 3e084aaef..9a929fe6b 100644 --- a/docs/design/IMPLEMENTATION-NOTES.md +++ b/docs/design/IMPLEMENTATION-NOTES.md @@ -83,6 +83,238 @@ a future Kubernetes Secret `resourceVersion` CAS backend. See [ADR 0218](../adr/0218-credential-store.md) and [ADR 0221](../adr/0221-read-only-credential-source.md). +## Local microVM redesign contract (ADR 0326) + +Tasks 59–65 complete ordinary `microvm-local` readiness, immutable Brood admission with +in-process `toolhive-core/container/verifier`, the one-shot rootfs materializer, +repository-scoped lifecycle, logical worktree attachment, and the Linux amd64 MVP journey. + +The MVP key is `(authenticated local operator, canonical Git common directory)`. An +inter-process-locked durable registry admits one VM generation and one rootfs materializer +for that key. Different keys do not share rootfs, packages, guest home, or declared caches. +Exact reattachment is allowed only while every owned dependency remains live in-process. +Daemon restart loses the in-process hosted network provider, so readiness and resolve fail +promptly with a named phase while preserving the durable record, rootfs, and worktrees; no +replacement or destructive reconciliation occurs. The registry layer itself does not +multiplex guest environments. + +The daemon creates and registers one distinct Git worktree for each session or isolated +child. A newly accepted guest connection first proves repository-generation boot authority +for its control/data purpose; no capability or protocol payload is sent before that proof. +Each logical `EnvironmentRef` then authenticates owner, VM generation, and assigned root; +filesystem and exec protocol requests are confined to that root and remain +Workspace/runner-affined. This is logical RPC and Git-state separation, not kernel isolation: +same-repository Bash may address sibling guest paths, while different repositories remain +VM-isolated. Sessions reuse the repository VM through distinct refs. Direct-write children +reuse the parent Environment; read-only Subagents, Parallel branches, and Team members +receive distinct refs and worktrees. Close detaches handles without destroying shared VM +state. The existing single-client isolated-child merge behavior is sufficient: no +crash-durable merge journal, cross-process serialization, or multi-client proof is part of +the MVP. + +The runtime consumes admitted Brood Linux amd64 bytes directly, independently verifies and +injects the guest agent into the singleton rootfs, and creates no session/child rootfs copy. +On Linux the backend supplies namespace-side UID/GID 65532 through go-microvm's +`WithUserNamespaceUID`; go-microvm maps those IDs to the daemon's host UID/GID when it +creates the unprivileged user namespace, without world-mode widening. The built-in hosted +network provides unrestricted IPv4; the guest IPv6 stack remains enabled but external IPv6 +is unrouted and unsupported. Optional deny-all or allowlist tightening filters IPv4, +disables IPv6, and is fail-closed. Linux amd64 KVM is the sole live claim. + +Status uses deterministic owner-scoped pages of at most 64 entries with opaque continuation +tokens. Explicit deferrals are repository-VM deletion UX, +sophisticated retention, crash-orphan reconciliation beyond safe loud failure, +crash-durable/cross-process merge, Linux arm64 and macOS live support, upstream Brood +signing, independent refresh channels, per-session fairness/quotas, dashboards, and +exhaustive cache-poisoning controls. The historical subsection below records the superseded +accumulator implementation only; it is not target architecture. + +### Local microVM development release activation (ADR 0326) + +The unsupported source workflow is compile-time absent unless both local roots are built with +`microvm_dev`. Those tagged roots alone register the descriptor and acknowledgement flags; +the flags must appear together, are local-context-only, and are rejected when release stamp +state is present. `internal/adapter/microvmmanager/development_release_microvm_dev.go` +(`ReadyRequestFromDevelopmentDescriptor`) strictly decodes the owner-only descriptor and +binds its Linux-amd64 platform, source identity, local bundle/key paths, outer digest, +public-key identity, and policy revision before readiness. Its result enters +`internal/adapter/microvmmanager/manager.go` (`EnsureReady`) as the same release/policy +shape used by embedded production defaults. The only acquisition delta is the private local +bundle path consumed by `internal/adapter/microvmmanager/default_operations.go` (`Download`); +the digest check, safe extraction, bundled installer, installed-artifact validation, and +microvmd signature/provenance admission remain shared. There is no environment, settings, +HTTP/gRPC, arbitrary-URL, or external-installer path. The developer Taskfile tasks keep tagged +preparation, binaries, and tests separate from ordinary `task build`, with generated assets +under `.scratch`. + +### Historical accumulator implementation (superseded) + +The opt-in nested runtime keeps observability separate from the engine event stream. +`environment/microvm/operations.go` (`OperationsObserver`) owns a mutex-protected, +fixed-dimension snapshot: fixed boot-latency buckets, active/booting VM and aggregate +vCPU/memory/disk gauges, plus exec, guest-egress-denial, artifact-verification, +cleanup/reconciliation, and quota-rejection counters. `ArtifactKind`, `Outcome`, and +`QuotaKind` are closed dimensions; invalid values are ignored. Exec command content +and denied destinations are accepted only at the producer method boundary and +immediately discarded, making the absence contract executable rather than relying on +a caller to redact. Diagnostics use fixed messages and bounded outcome/kind/quota +attributes. These daemon lifecycle facts have no client `session.Event`, so using the +injected `port.Diagnostics` follows the event-owns-client-facts rule. + +The same file's `Doctor` runs the complete readiness set even after one probe fails: +hypervisor access; runtime and firmware verification; control-socket peer +authentication; selected network-provider readiness; non-empty operator profile +availability; and stale registry resources. Findings are stable PASS/WARN/FAIL lines +with a fixed remediation for every check. Stale resources WARN without hiding other +failures. Platform composition supplies real probes; ordinary tests supply deterministic +offline probes and do not require KVM or HVF. + +The same authenticated lifecycle protocol now owns operator inventory and maintenance. +`environment/microvm/repository_attachment_store.go` durably records each exact repository +logical owner/session/ref/generation and its confined worktree plus dirty-retention decision; +a fresh daemon reconstructs the bounded status/delete inventory without restoring live guest +handles. `environment/microvm/daemon.go` (`inventory`) filters durable records by the +requested local owner, sorts actionable records by exact session/ref/generation identity, +and returns at most 64 rows plus an HMAC-authenticated opaque continuation. Tokens bind the +owner and cursor, so modification or cross-owner reuse fails closed; each page probes only +its ready runtime identities and projects exact session/ref/generation, lifecycle state, +host worktree, and `healthy`/`stale`/`error` status. Destroyed tombstones are normally omitted; a tombstone +whose dirty worktree was retained remains as an actionable `destroyed`/`stale` inventory +row, so checked orphan destruction does not hide the recovery path or imply a replacement. +`internal/adapter/microvm/client.go` (`Inventory`) +rechecks the owner and bound; `internal/adapter/microvmmanager/manager.go` (`Status`) +never opens registry files. `Status` holds one bounded page and its continuation instead of +a lifetime inventory. Readiness starts or reuses the verified daemon, invokes `Daemon.reconcile`, and +walks bounded inventory pages without retaining prior rows; status prints one page plus a +copyable `--continuation` command. Startup and readiness share the existing reconciler and +never call Create or enable host fallback. Generation deletion takes the complete local +owner/session/ref/generation binding and returns a bounded cleanup result distinguishing a +removed clean worktree from the retained dirty path. The shared +`internal/microvmcmd` command validates that the complete selector appears in the +current status inventory before it asks for interactive confirmation (or honors +`--yes`); ordinary TUI close remains detach-only. Continuing a preserved chat uses ordinary profile selection with +`--default-placement microvm-local --resume SESSION_ID`; exact persisted placement is +reattached by the server and never reselected by the resume request. The canonical command surface is +`mecated microvm doctor|status|delete`; `mecatui microvm` invokes exactly the same +local-host implementation as a compatibility frontend and never follows `mecatui connect`. Automatic readiness reports the exact resolved +manager paths plus download, trust, resource, and egress policy; failures direct the +operator to correct the reported cause and retry ordinary profile selection. Published +standalone host binaries are bound by signed checksum manifests and provenance, embed only +their matching version/platform bundle defaults, and remain separate from the OCI image; +their verified bundle supplies its own installer. No ambient installer override exists. +`environment/microvm/daemon.go` +(`LifecycleInfo`) exposes protocol, release/running-binary/config identities, policy +revision, sorted profiles, and socket only after Unix peer authentication. +`internal/adapter/microvmmanager/manager.go` (`EnsureReady`) reuses a serving daemon only +when that projection exactly matches the newly installed binary and config. +Otherwise `internal/adapter/microvmmanager/default_operations.go` (`Stop`) verifies +the persisted PID plus process-start token, binary digest, exact launch arguments, +and socket before signaling; PID reuse or ambiguous service ownership fails with +service-manager guidance while the alias remains disabled. Before any download or repository +provisioning, `internal/adapter/microvmmanager/default_operations.go` (`Preflight`) checks Git, +Python 3, KVM, and actual ephemeral unprivileged-user-namespace creation; disabled controls +and exhausted quota fail actionably without changing host policy. `Doctor` repeats that host +preflight, first queries +this serving identity, then runs the complete readiness probes against the same +exact installation rather than validating only newly written files. Initialization +defaults to 2 vCPUs, 4 GiB RAM, and deny-all guest +egress. `internal/adapter/microvmmanager/manager.go` (`DefaultPaths`) separates config (`$XDG_CONFIG_HOME/mecatl`), artifacts and +the daemon binary (`$XDG_DATA_HOME/mecatl/microvm`), durable registry/worktrees +(`$XDG_STATE_HOME/mecatl/microvm`), and the short owner-only runtime socket; each +uses the documented home or `/tmp/mecatl-microvm-UID` fallback. The live support scope +remains local single-user Git sessions on Linux amd64 KVM. Linux arm64 and Apple Silicon +macOS have compile/static release coverage only; live support, schedules, remote/multi-user +placement, non-Git sources, and unified host+guest egress are explicit limits. + +Artifact launch no longer treats `flock` as a security boundary. After revalidating all +three cache entries, `environment/microvm/artifact.go` (`LockAndValidate`) copies their +exact tree identities into one private per-launch snapshot and returns replacement +`VerifiedArtifacts`; lifecycle and the direct provisioner launcher pass the replacements, +not the published cache paths, to runtime. A digest check over each completed copy rejects +a source race. Because go-microvm starts its runner asynchronously, the concrete backend +copies runtime and firmware once more into generation-owned executable daemon state before +returning from `Start`; rootfs is already a generation-owned clone. The launch snapshot can +then be removed without racing the runner's firmware load, while deletion/reconciliation +remove the owned copies. Release manifest v2 +uses the same `ArtifactTreeDigest` subjects independently for runtime, firmware, and the +execution image. Because Brood Box does not publish a signed immutable base, the +release workflow builds its base recipe directly from the pinned upstream commit, +signs the resulting digest, captures both exact platform manifests, and passes that +immutable index as the guest-tools Dockerfile's required `FROM`. Guest-tools strips +the Brood SSH/user surface, injects only the guest agent, and smoke-tests both +architectures as UID/GID 65532. Its lineage attestation and the packaged +execution-image provenance both correlate the upstream Git revision with the exact +platform base manifest. The installer projects those values and strict daemon +admission verifies both resolved dependencies; missing lineage or a base tag fails +closed. The release bundle includes a platform-specific artifact-digest binary; +the manager executes the installer from that digest-verified, safely extracted bundle, +rather than accepting an ambient external installer or requiring a repository checkout +or Go toolchain on first run. A development/test override is an explicit absolute path +paired with the SHA-256 of its exact bytes; the old unrelated installer environment is +ignored. The release workflow also publishes signed and provenance-attested host +`mecatui` binaries for Linux amd64/arm64 and Darwin arm64, each embedding only its matching +version/platform bundle defaults; only the signed Linux-amd64 host binary is the live MVP +support path, while ordinary source builds intentionally contain no authenticated defaults. +The release workflow signs those exact statements, the installer +rechecks archive and tree digests while projecting their evidence paths, and the live +production E2E signs and consumes the packaged statements rather than manufacturing a +second provenance shape. The tagged real-hypervisor proof now signs those packaged +statements in place, runs the packaged installer, and supplies only its projected +artifact paths/evidence to strict daemon admission. + +The guest data plane is one connection, not parallel Workspace and exec sockets. +`environment/microvm/control/multiplex.go` (`ServeMultiplex`) performs one bounded +handshake, negotiates both services and the mandatory filesystem/streaming/cancellation/ +generation-binding/message-bound capabilities, then dispatches frames by service, +method, and monotonic request ID. The former independent `AcceptGuest`/`OpenGuest` +negotiation path is removed, so lifecycle persists the agreement returned by the live +production connection and fail-closed capability/version tests exercise that path. +`environment/microvm/control/capability.go` +(`CapabilityVerifier`) verifies a transferable HMAC capability bound to the complete +owner/session/ref/generation tuple using independently provisioned guest key material; +its bounded nonce set (4096 entries, then fail closed) rejects connection replay +without a host-shared registry. Every request repeats a compact proof of the +authenticated capability and duplicate request IDs are rejected. One +reader demultiplexes replies while serialized bounded writes provide stream +backpressure; context cancellation is a request-ID frame and the exec handler still +terminates the guest process group. `environment/microvm/guestagent/guestagent.go` +(`Connect`) constructs the Workspace and runner over that same client, so no failure +path can dial a host filesystem or shell fallback. A guest-agent restart uses fresh +per-boot key material, making capabilities from the previous verifier invalid before +the in-memory replay set resets. + +The guest root process performs only privileged bootstrap and protocol service. On Linux, +the libkrun backend passes namespace-side workload UID/GID 65532 to go-microvm's +`WithUserNamespaceUID`; go-microvm maps those IDs to the daemon's host identity in an +unprivileged single-ID user namespace, so virtio-fs can switch request credentials without +host `CAP_CHOWN` or sudo. Where guest-root ownership is available, +`/etc/mecatl/guest-agent.json` remains beneath a mode-0700 parent at mode 0600. Under +the rootless workload mapping the root agent loads it, verifies that it is the parent's +only entry, and removes both before any model command starts. It then enables +`PR_SET_NO_NEW_PRIVS` and starts every model command through +`environment/microvm/guestexec/server.go` as the fixed UID/GID 65532 with no +supplementary groups or ambient capabilities. Thus workspace commands remain usable +while the HMAC key, root agent signals, IPv6 sysctls, and mount policy stay outside the +workload's authority. `TestInvariant_guest_workload_is_unprivileged` and the real-VM +`TestMicroVMEnvironments_Scenario8_HostSecretAndSiblingIsolation` pin the structural +and kernel-observable halves of this boundary. + +The durable lifecycle itself remains in `environment/microvm/registry.go` +(`FileRegistry`) and `environment/microvm/reconcile.go` (`Reconciler`). Runtime, +reconciliation, and doctor all compare the durable runner token through +`environment/microvm/runtime.go` (`ProcessStartIdentity`), which reads the platform +process-start identity rather than trusting PID liveness or a synthetic token. +Operational counters remain reset-by-design process observations, while startup reconstructs +active/booting VM and aggregate resource gauges from the authoritative registry +before reconciliation. The registry observer keys by generation, so repeated saves +and delete checkpoints do not double-count; a committed destroyed tombstone removes +the generation exactly once. Active hosted-network cumulative denial totals are +sampled as per-generation deltas; repeated snapshots are idempotent, final teardown +captures only an unseen delta, and a restarted observer establishes a fresh baseline +before counting new denials. Metrics never become session state or credentials. +See the [architecture](../architecture/microvm-environments.md) and +[operator guide](../usage/microvm-environments.md). + --- ## Caller identity embedding and OIDC module boundary @@ -6139,6 +6371,177 @@ the scoped WRITE path is deferred** (see below). constructs `AgentDef`s from wire metadata — `Memory` is **NOT** carried on the wire in v1 (no proto change); a driver-served def stays cold-start. +### Historical environment placement implementation (superseded by ADR 0326 redesign) + +This subsection describes the existing accumulator code and its test seams. Its +session-per-VM lifecycle, derived image, deny-default networking, explicit init/recover, +`--microvm`, external cosign, mode widening, and per-generation rootfs clone are removal +inputs, not target decisions. The authoritative target is the redesign contract under +[Local microVM redesign contract](#local-microvm-redesign-contract-adr-0326), the living +[architecture](../architecture/microvm-environments.md), and ADR 0326. + +`environment_profile` is independent from the existing tool-surface `profile`. The +request carries only an alias resolved against `permconfig.Resolver`'s operator-only +`environment_profiles` registry; project blocks are WARN-ignored. The host registry +owns only enabled/disabled state and the daemon endpoint. The create protocol sends +the alias, owner/session, and source checkout; `mecatl-microvmd` is the single policy +authority for artifacts, fixed mounts, resources, egress, seccomp, lifecycle, +quotas, signer, and attestation. Host profile keys that would restate those values +are strict parse errors, so they cannot be silently dropped or echoed as if enforced. +Unknown, disabled, unavailable, malformed, or provisioner-failed aliases return a +loud create error with no local fallback. + +The preparation seam returns four distinct roles: source checkout, prepared host +worktree, guest `/workspace`, and opaque `EnvironmentRef`. `Session.Workspace` stores +the prepared host worktree and the complete live Environment carries the guest root +and ref. gRPC/HTTP responses project only the alias and first three paths; privileged +policy and control endpoints never cross the response mapper. Root composition now +constructs the thin authenticated UDS client for enabled aliases and wires provision, +session-aware exact-generation resolve, detach, and permanent delete. When no enabled +alias exists those seams remain nil, preserving the default path byte-for-byte. + +The nested microVM adapter owns guest networking. Each environment starts an +explicit go-microvm hosted provider with a deny-default policy: either no guest +destination is allowed, or only configured hostname/port/protocol triples are. +Provider startup, endpoint validation, and policy enforcement are creation gates; +none may fall back to libkrun's implicit network. The module-pinned go-microvm release enforces +`VirtioFSMount.ReadOnly` at both libkrun and guest mount layers; its network filter +remains IPv4-only, so the preboot guest agent disables IPv6 on +all/current/future interfaces and creation fails if that cannot be enforced. + +This boundary is guest-only. Session responses expose separate `guest_egress` and +`host_egress` status returned by microvmd from the policy it enforced; the host +never derives those strings from a second profile copy. Guest deny-all does **not** constrain host-side LLM providers, +WebFetch, WebSearch, MCP, hooks, OCI pulls, or telemetry. + +The environment generation is session-lifetime and restart-stable. The local daemon +exposes one bounded management protocol in `environment/microvm/daemon.go` +(`Daemon`): create, resolve/attach, inspect, detach, and delete authenticate the +Unix peer before examining caller identifiers. Create binds the requested owner/session +to the allocator-selected durable ref/generation; all later operations compare the full +owner/session/environment/ref/generation tuple with `FileRegistry` before invoking any +runtime method. Responses repeat that authoritative binding. A mismatch therefore cannot +reattach, inspect, detach, or tombstone another generation. + +`environment/microvm/lifecycle.go` (`GuestPrebootArtifact`) is the runtime-independent +preboot seam. Before `VMRuntime.Create`, lifecycle constructs the rootfs artifact for +`/etc/mecatl/guest-agent.json` with IPv6 disablement, the exact control binding, endpoint, +required guest capabilities, and message bound before calling the runtime; the runtime +contract requires installation before guest workload services start and must fail creation +if it cannot install the complete artifact. +This replaces any post-workload IPv6 setup as a creation guarantee while keeping +hypervisor construction behind `VMRuntime` for offline tests. The OCI execution-image +resolver in `environment/microvm/oci_execution_image.go` (`OCIExecutionImageResolver`) +accepts only canonical `repo@sha256` references. It wraps the module-pinned go-microvm release's +platform-specific remote fetch, hardened layer extraction, and OCI cache with an exact +manifest/platform check, then returns the extracted directory to the existing mecatl +materialized-tree digest, Sigstore, atomic verified-cache, and private launch-snapshot +admission. `ArtifactRequest.ManifestDigest` and `VerifiedArtifact.ManifestDigest` keep +the OCI identity distinct from `Digest`, which remains the signed extracted-tree +identity; both persist in `EnvironmentRecord` and `SessionPlacement`. Cold pulls are +serialized per resolver, warm/concurrent requests use go-microvm's manifest cache, and +runtime never receives an OCI reference. + +The concrete +`environment/microvm/runtime.go` (`GoMicroVMRuntime`) passes the three verified +artifact paths to go-microvm's libkrun runtime/firmware sources and clones the +verified execution image into a private per-generation rootfs before installing +the secret capability (never mutating the verified cache), projects the validated mount plan onto v0.0.40's real +host-enforced `ReadOnly`, starts one uniquely rooted hosted provider per generation, +creates the owner-only host Unix listener before boot, binds vsock, explicitly starts +the verified `/usr/local/bin/mecatl-guest-agent`, and retains the transferable-capability +issuer plus unified guest services. The Linux guest entrypoint mounts the three fixed +virtio-fs tags without mutating host ownership, configures the standard hosted network +before disabling IPv6, and reconnects after a data-plane detach. Per-generation +network and go-microvm data directories prevent concurrent sessions from sharing a +socket or runtime state. The +`environment/microvm/composition.go` (`NewRuntimeDaemon`) is the daemon +composition root used by `environment/microvm/cmd/mecatl-microvmd`; ordinary +tests replace only the hypervisor backend and network provider. + +The nested adapter's `environment/microvm/registry.go` (`FileRegistry`) reloads a versioned JSON registry under +an inter-process flock on every operation and commits updates by file fsync, atomic rename, +and directory fsync. An environment id cannot be overwritten by another generation, and +lifecycle state/tombstones/cleanup checkpoints only advance, so a stale second daemon cannot +resurrect a deleted generation. The record includes the process-start identity in addition +to PID; runtime reattach/destroy must match generation, VM id, endpoint, and process identity +before touching a process or socket. + +`environment/microvm/reconcile.go` (`EnvironmentManager`) keeps detach and destruction +separate. Detach verifies and releases process-local handles without changing the ready +record. Explicit and retention deletion first persist `EnvironmentDeleting` plus a tombstone, +then idempotently destroy the generation's VM, endpoint, and descendants. Clean worktrees +are removed; dirty worktrees are marked preserved. Cleanup checkpoints and the final destroyed +tombstone are durable, so disk-full or process death leaves retryable work rather than an +untracked resource. `Reconciler` handles provisioning, cleanup-pending, deleting, missing +runtime, PID reuse, and stale-generation states without ever calling Create. On startup, +a ready generation that cannot reattach is durably moved to cleanup-pending and destroyed +only through its exact persisted PID/start token, VM, endpoint, and generation identity; +an identity mismatch leaves the foreign resource untouched and fails startup. The destroyed +tombstone commits before reconstructed admission is released, so stale ready quota cannot +persist indefinitely. A restarted resolver uses `NewReattachingResolver`; unavailable or +identity-mismatched generations return an actionable error and never mint an empty replacement. This resolver path remains +independent from `Service.rehydrateSession`: reattaching a microVM does not itself rebuild the +per-session engine. + +Same-repository sessions allocate opaque, collision-resistant names through +`environment/microvm/names.go` (`OpaqueIdentityAllocator`): caller-controlled session and +repository strings never become a path, socket, VM id, or Git ref. Each allocation carries a +distinct generation, endpoint, worktree, reconstructed metadata directory, and branch. The +worktrees share only the validated common object store, mounted through the host-enforced +read-only virtio-fs plan; files, indexes, refs, config, hooks, and guest metadata remain +session-owned. + +Delegated read-only children use `environment/microvm/forker.go` +(`EnvironmentForker`) as both the complete-environment fork seam and the merger. +Fork writes the parent worktree's exact dirty state into an immutable Git tree with an +isolated temporary index, verifies two consecutive captures agree, and asks worktree +preparation to materialize that tree rather than rereading a mutable parent. The daemon +persists the resulting non-empty tree id with `EnvironmentRecord.ParentRef`/`ForkBase` and +validates both-or-neither on every registry save. Merge is generation-fenced and serialized +inside `Daemon` by a fixed parent-ref lock stripe, so independent harness clients share the +same lock. The driver contract compares the child with that immutable base, classifies +additions/replacements/deletions, checks every changed path against the current parent, +constructs and checks the patch, then revalidates immediately before one atomic apply; a +conflict preserves the child. Process-local quota and +collision maps reset after a harness restart, while the registry parent/base +binding remains authoritative for resume and exact-generation reconciliation. +Cleanup always uses a cancellation-detached context, so cancellation, timeout, +and background drain release the intended child without crossing into its +parent; cleanup-pending child records use the same idempotent tombstone/checkpoint +reconciler as parent generations. + +`environment/microvm/artifact.go` (`VerifiedCache`) binds trust evidence to the +actual executable bytes: after copying a resolver source into private staging it +computes the canonical tree digest (paths, modes, and bytes), requires it to equal +the signed/attested subject digest, and only then atomically renames the entry. +Cache reload repeats both the tree/metadata comparison and evidence verification. +The launch path then reacquires all three content-addressed entry locks, repeats +those checks, and holds the locks until the runtime consumes the paths; otherwise-valid +evidence placed beside bytes altered after `Verify` therefore cannot launch. Admission +and release use one provenance/bundle format. Release and GitHub live cells verify +keyless Sigstore bundles under an exact workflow certificate identity and OIDC issuer; +the mutually-exclusive public-key mode binds the exact public-key bytes by SHA-256 for +private/offline operation. The local live E2E generates that key only in private scratch +state and still exercises signed provenance, wrong-key/identity/predicate failures, and +the launch-time byte recheck. The +root release workflow creates the GitHub Release before its matrix uploads, gives +each platform uniquely named checksum and payload assets, builds dependency-bearing +SPDX SBOMs with Syft, signs the provenance statements, and identifies the reused module-pinned go-microvm runtime and firmware. Ordinary pull-request CI independently builds, races, lints, vets, caches, +and runs `GOWORK=off` over every nested package. + +`environment/microvm/admission.go` (`AdmissionController`) atomically enforces positive +per-owner and deployment bounds for booting/active VMs, worktrees, CPU, RAM, disk, inodes, +execs, forks, pulls, and rolling boot rates. Lifecycle acquisition occurs before identity or +resource creation, transitions booting to active without an accounting gap, and persists the +retained reservation in the generation record. Daemon startup reconstructs reservations from +the registry before reconciliation and before accepting requests; destruction releases capacity +only after the destroyed tombstone commits. `AdmissionError` is the stable actionable rejection +projection. Public clients receive only bounded `PlacementMetadata`; exact refs, +host worktrees, guest roots, and daemon endpoints remain private. Per-session composition +reattaches the exact persisted ref through the deployment-owned provider before resolving +project inputs through the resulting Workspace. + ### Session profiles — `"no-fs"` (issue #55) The filesystem is **optional per session**, but placement is always server-owned. diff --git a/docs/usage.md b/docs/usage.md index 03b80199c..80faa2342 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -108,6 +108,7 @@ Every shipped executable accepts exact top-level `--version` and prints its buil | 17. Troubleshooting / FAQ | [troubleshooting.md](usage/troubleshooting.md) | | 18. OpenAI Codex subscription | [OpenAI Codex subscription](#openai-codex-subscription-experimental) | | 19. ToolHive LLM gateway | [ToolHive LLM gateway](#toolhive-llm-gateway) | +| 20. Local microVM environments | [microvm-environments.md](usage/microvm-environments.md) | ## mecatui command discovery diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index bc4f710dc..d963a3904 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -231,6 +231,20 @@ opaque selector on ClearSession/ForkSession. Sessions persist the exact private `EnvironmentRef`, not a duplicate workspace path; public inventory exposes bounded placement metadata only. +`microvm-local` is selected only by the local composition root with +`--default-placement microvm-local`. It becomes the deployment default used by ordinary +CreateSession requests; clients cannot select a placement alias, path, or exact ref. +Project settings cannot weaken this policy. + +The public session API remains path-free and accepts only the deployment default or +`profile:"no-fs"`. Guest policy does not govern host provider, WebFetch/WebSearch, MCP, +hooks, artifact pulls, or telemetry. The microVM path fails loudly without host fallback. + +After a daemon restart, the repository-scoped MVP safely refuses reattachment when its +in-process hosted network and capability handles cannot be reconstructed. It preserves the +repository record, private rootfs, and logical worktrees; durable status and exact logical +worktree deletion remain available. See [microVM environments](microvm-environments.md). + ### Model `--model` (empty by default — the selected provider's own default is used: diff --git a/docs/usage/grpc-api.md b/docs/usage/grpc-api.md index 29b078e3a..2f74f126f 100644 --- a/docs/usage/grpc-api.md +++ b/docs/usage/grpc-api.md @@ -171,6 +171,15 @@ successful response or roll back the compacted snapshot. The capability is additive. New clients reading an old server see false and should hide the action. Calling an old server's unknown method returns `UNIMPLEMENTED`. +`CreateSessionRequest` has no placement selector or workspace field. It accepts only the +deployment default (`profile` omitted) or explicit filesystem attenuation +(`profile: "no-fs"`). A deployment started with `--default-placement microvm-local` +performs built-in readiness/provisioning on the ordinary default path. The exact +`EnvironmentRef` remains private and durable; responses expose only bounded +`PlacementMetadata`. The repository-scoped MVP preserves its durable record, rootfs, and +logical worktrees but refuses session reattachment after daemon restart when hosted +network and capability handles cannot be safely reconstructed. + **Inventory & introspection** (read-only; most are snapshots taken at startup): | RPC | Kind | Purpose | diff --git a/docs/usage/microvm-environments.md b/docs/usage/microvm-environments.md new file mode 100644 index 000000000..4f2124881 --- /dev/null +++ b/docs/usage/microvm-environments.md @@ -0,0 +1,201 @@ +# Local microVM environments + +`microvm-local` is an opt-in deployment default placement provider for a local Git repository on Linux +amd64 with KVM. Model-controlled filesystem and shell tools run in the microVM. +Providers, MCP, hooks, credentials, memory, and the mecatl server remain on the host. +It supports one local operator and one canonical Git repository per VM. Linux arm64, +macOS, remote placement, schedules, multi-user sharing, and non-Git sources are not +available. + +Install only the host binary for the journey you use. Installing both `mecatui` and +`mecated` is optional. + +## Before either journey + +Use a published, release-stamped binary. Verify it before installation: + +```sh +VERSION=vX.Y.Z +PLATFORM=linux-amd64 +BINARY=mecatui # or mecated +mkdir -p "$HOME/.local/bin" .scratch/mecatl-host-release +cd .scratch/mecatl-host-release +gh release download "$VERSION" --repo stacklok/mecatl \ + --pattern "${BINARY}-${VERSION}-${PLATFORM}" \ + --pattern "${BINARY}-${VERSION}-${PLATFORM}.sha256" \ + --pattern "${BINARY}-${VERSION}-${PLATFORM}.sigstore.json" +cosign verify-blob \ + --bundle "${BINARY}-${VERSION}-${PLATFORM}.sigstore.json" \ + --certificate-identity "https://github.com/stacklok/mecatl/.github/workflows/release.yml@refs/tags/${VERSION}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "${BINARY}-${VERSION}-${PLATFORM}" +gh attestation verify "${BINARY}-${VERSION}-${PLATFORM}" --repo stacklok/mecatl +sha256sum --check "${BINARY}-${VERSION}-${PLATFORM}.sha256" +install -m 0755 "${BINARY}-${VERSION}-${PLATFORM}" "$HOME/.local/bin/${BINARY}" +export PATH="$HOME/.local/bin:$PATH" +cd ../.. +``` + +The Sigstore identity and GitHub attestation verify the host binary; the checksum also +checks for corruption. The verified binary supplies the runtime release and trust +information required by this profile. Host requirements are Git, Python 3, read-write +access to `/dev/kvm`, and enabled unprivileged user namespaces with available per-user +quota. Mecatl does not run sudo or change groups, ACLs, sysctls, or namespace limits. + +## Developer source workflow + +This workflow is for repository developers testing the local profile. It is separate +from ordinary release-binary use. Build the developer binaries and use the generated +local descriptor only from this checkout: + +```sh +task microvm:dev:prepare +task microvm:dev:build +DESCRIPTOR="$(pwd)/.scratch/microvm-dev/linux-amd64/release.json" + +# Interactive root: +.scratch/microvm-dev/bin/mecatui \ + --default-placement microvm-local \ + --microvm-dev-release="$DESCRIPTOR" \ + --microvm-dev-acknowledge-untrusted-local-artifacts + +# Or headless server root: +.scratch/microvm-dev/bin/mecated serve --headless \ + --default-placement microvm-local \ + --microvm-dev-release="$DESCRIPTOR" \ + --microvm-dev-acknowledge-untrusted-local-artifacts +``` + +The descriptor, bundle, and key paths must be absolute, owner-only regular files and +must identify the matching Linux amd64 developer build and runtime bundle. Both flags +are required. They are unavailable to published binaries and cannot be supplied through +settings, environment variables, HTTP, or gRPC. Runtime artifact verification and +admission still apply. + +## mecatui-only journey + +From the Git repository: + +```sh +mecatui microvm doctor +mecatui --default-placement microvm-local +``` + +`microvm doctor` is diagnostic only. It can report `backend: not configured` on a fresh +home. Selecting `microvm-local` prepares the verified local runtime and creates the +session. Bare `mecatui` remains host-local. + +Inspect placement and resume with the same profile: + +```sh +mecatui microvm status +mecatui --default-placement microvm-local --resume SESSION_ID +``` + +Placement selection is per `mecatui` invocation. A microVM session retains its exact +persisted placement and is never moved to host execution; the same +`--default-placement microvm-local` is needed only when starting a new embedded server. + +## Headless mecated-only journey + +In one terminal: + +```sh +mecated microvm doctor +mecated serve --headless --mock --default-placement microvm-local +``` + +In another terminal, create the placed session, copy `session_id`, then prompt and +inspect it: + +```sh +curl -sS -X POST http://127.0.0.1:8081/v1/sessions \ + -H 'Content-Type: application/json' \ + -d '{}' + +SESSION_ID=copy-from-create-response +curl -sS -N -X POST "http://127.0.0.1:8081/v1/sessions/${SESSION_ID}/prompt" \ + -H 'Content-Type: application/json' \ + -d '{"text":"Inspect this repository and report its test command."}' +curl -sS "http://127.0.0.1:8081/v1/sessions/${SESSION_ID}" +mecated microvm status +``` + +The deployment default prepares the local runtime. `--headless` is for unattended API use: +configure main-agent permissions for autonomous work. `--mock` is only for offline +smoke testing; configure a real provider for model work. Public session responses expose +only bounded placement metadata; exact refs and host/guest paths remain private. + +## Guest egress policy + +Guest IPv4 egress is permissive by default. External IPv6 is unrouted and unsupported. +The local host operator can restrict guest networking: + +```sh +# Block all guest egress. +mecatui --default-placement microvm-local \ + --microvm-guest-egress=deny-all + +# Allow only these guest destinations. +mecated serve --headless \ + --microvm-guest-egress=allowlist \ + --microvm-guest-allow=api.example.com:443/tcp \ + --microvm-guest-allow=dns.example.com:53/udp +``` + +`--microvm-guest-egress` accepts `permissive`, `deny-all`, or `allowlist`. +`--microvm-guest-allow` is repeatable and requires at least one valid rule with +`allowlist`. Rules use `HOST:PORT/tcp|udp`; IP literals (including IPv6), wildcards, +invalid ports or protocols, whitespace/control characters, and duplicate rules are +invalid. Invalid policy or unavailable enforcement stops readiness. Existing validated +owner-only policy is retained across daemon restarts and session resumes; pass an +explicit mode to change it, including `--microvm-guest-egress=permissive` to reset it. + +These flags are host-local only. HTTP/gRPC requests and project configuration cannot select +or weaken `microvm-local` placement or guest egress. Guest egress does not cover host +provider, WebFetch, WebSearch, MCP, hook, artifact, or telemetry traffic. The fixed +VM defaults are 2 virtual CPUs and 4 GiB memory. + +## Worktrees and operations + +One local operator and canonical repository share a mutable VM, root filesystem, guest +home, packages, and declared caches. Each session and isolated child receives its own +Git worktree and logical environment reference. Worktrees separate Git state and +routing; they do not provide kernel isolation between mutually hostile processes in the +same VM. Different canonical repositories receive different VMs. A direct-write child +uses its parent's environment. + +`microvm doctor` and `microvm status` are read-only. Use status before deleting one +specific logical attachment: + +```sh +mecated microvm status --output json +mecated microvm delete --session ID --ref REF --generation N --yes +``` + +`mecatui microvm ...` provides the same local commands. Delete retains dirty worktrees +and does not delete the shared repository VM. After a daemon restart, repository +records, rootfs, and worktrees can remain available while live hosted dependencies do +not. Such sessions report the problem; mecatl does not fall back to host filesystem or +shell execution and does not create an empty replacement environment. + +## Paths and trust boundary + +| Purpose | Default | +|---|---| +| Daemon config | `~/.config/mecatl/microvmd.json` | +| Verified data and daemon binary | `~/.local/share/mecatl/microvm/` | +| Registry, worktrees, PID, and log | `~/.local/state/mecatl/microvm/` | +| Control socket | `$XDG_RUNTIME_DIR/mecatl-microvm/microvmd.sock`, with a short owner-specific fallback | + +The manager owns these private XDG paths. Artifact, transport, or readiness failures do +not fall back to host filesystem or shell execution. + +## See also + +- [MicroVM architecture](../architecture/microvm-environments.md) explains the execution + boundary, lifecycle, and routing model. +- [Configuration reference](../configuration-reference.md) lists the operator settings + schema. +- [Public deployment guide](https://github.com/stacklok/mecatl/blob/main/user-docs/building/deployment/microvm-environments.md) + provides a concise deployment overview. diff --git a/engine/adapter/fsconformance/fsconformance.go b/engine/adapter/fsconformance/fsconformance.go index 1a5dd804b..7160ca879 100644 --- a/engine/adapter/fsconformance/fsconformance.go +++ b/engine/adapter/fsconformance/fsconformance.go @@ -140,6 +140,33 @@ func Run(t *testing.T, newWS func(t *testing.T) tool.Workspace) { } }) + t.Run("grep", func(t *testing.T) { + ws := newWS(t) + files := map[string]string{ + "a/one.go": "alpha\nneedle one\n", + "a/two.go": "needle two\n", + "a/skip.txt": "needle excluded\n", + } + for path, content := range files { + if err := setFile(ctx, ws, path, []byte(content)); err != nil { + t.Fatalf("Write %s: %v", path, err) + } + } + got, err := ws.Grep(ctx, `needle`, "a/*.go") + if err != nil { + t.Fatalf("Grep: %v", err) + } + if len(got) != 2 { + t.Fatalf("Grep returned %d matches (%v), want 2", len(got), got) + } + if got[0].Path != "a/one.go" || got[0].Line != 2 || got[0].Text != "needle one" { + t.Errorf("first Grep match = %+v", got[0]) + } + if got[1].Path != "a/two.go" || got[1].Line != 1 || got[1].Text != "needle two" { + t.Errorf("second Grep match = %+v", got[1]) + } + }) + t.Run("path escape rejected", func(t *testing.T) { ws := newWS(t) escapes := []string{ @@ -711,3 +738,75 @@ func RunNamespace(t *testing.T, newWS func(t *testing.T) tool.Workspace) { } }) } + +// ExternalAccess models another actor in the same execution namespace, such as +// the Environment's bound CommandRunner. It lets adapters prove Workspace and +// runner affinity without duplicating the Workspace contract. +type ExternalAccess struct { + Read func(path string) ([]byte, error) + Write func(path string, data []byte) error +} + +// RunMutationAffinity proves Workspace operations and an external actor observe +// the same backing bytes in both directions. +func RunMutationAffinity(t *testing.T, newFixture func(t *testing.T) (tool.Workspace, ExternalAccess)) { + t.Helper() + ctx := context.Background() + + ws, external := newFixture(t) + if _, err := ws.CreateFile(ctx, "workspace.txt", []byte("from workspace")); err != nil { + t.Fatalf("Workspace CreateFile: %v", err) + } + got, err := external.Read("workspace.txt") + if err != nil { + t.Fatalf("external read after Workspace mutation: %v", err) + } + if string(got) != "from workspace" { + t.Fatalf("external read = %q, want Workspace bytes", got) + } + + if err := external.Write("external.txt", []byte("from runner")); err != nil { + t.Fatalf("external mutation: %v", err) + } + got, err = ws.Read(ctx, "external.txt") + if err != nil { + t.Fatalf("Workspace read after external mutation: %v", err) + } + if string(got) != "from runner" { + t.Fatalf("Workspace read = %q, want external bytes", got) + } +} + +// RunExternalMutationConflict proves a version read before an out-of-band +// mutation cannot authorize overwriting the newer bytes. +func RunExternalMutationConflict(t *testing.T, newFixture func(t *testing.T) (tool.Workspace, ExternalAccess)) { + t.Helper() + ctx := context.Background() + ws, external := newFixture(t) + + if _, err := ws.CreateFile(ctx, "conflict.txt", []byte("original")); err != nil { + t.Fatalf("CreateFile: %v", err) + } + _, old, err := ws.ReadVersion(ctx, "conflict.txt") + if err != nil { + t.Fatalf("ReadVersion: %v", err) + } + if err := external.Write("conflict.txt", []byte("newer external bytes")); err != nil { + t.Fatalf("external mutation: %v", err) + } + if _, err := ws.ReplaceFile(ctx, "conflict.txt", old, []byte("stale overwrite")); err == nil { + t.Fatal("ReplaceFile after external mutation succeeded, want version conflict") + } else { + var mismatch *tool.VersionMismatchError + if !errors.As(err, &mismatch) { + t.Fatalf("ReplaceFile error = %v, want *tool.VersionMismatchError", err) + } + } + got, err := external.Read("conflict.txt") + if err != nil { + t.Fatalf("external read after conflict: %v", err) + } + if string(got) != "newer external bytes" { + t.Fatalf("conflicting replace wrote %q, want newer external bytes preserved", got) + } +} diff --git a/engine/agent/subagent_writable_test.go b/engine/agent/subagent_writable_test.go index 2a637b8a6..616494bfb 100644 --- a/engine/agent/subagent_writable_test.go +++ b/engine/agent/subagent_writable_test.go @@ -2,12 +2,14 @@ package agent_test import ( "context" + "encoding/json" "errors" "strings" "sync/atomic" "testing" "github.com/stacklok/mecatl/engine/adapter/memfs" + "github.com/stacklok/mecatl/engine/adapter/memledger" "github.com/stacklok/mecatl/engine/adapter/memstore" "github.com/stacklok/mecatl/engine/adapter/mockllm" "github.com/stacklok/mecatl/engine/agent" @@ -684,3 +686,57 @@ func TestStorelessWritableSubagentFailureKeepsPlainPartialNote(t *testing.T) { t.Fatalf("the combined resume-or-discard decision must not appear where resume is unsupported, got:\n%s", body) } } + +func TestMicroVMEnvironments_Scenario7_DirectWriteUsesParentEnvironment(t *testing.T) { + parentWS := memfs.NewWorkspace("/workspace") + parentRef := session.EnvironmentRef{Kind: "microvm", ID: "parent", Revision: "7"} + parentEnv := tool.MustEnvironment(parentRef, parentWS, memledger.New(), microVMMutationRunner{ws: parentWS}) + var seenRef session.EnvironmentRef + bash := &environmentProbeTool{run: func(ctx context.Context, call session.ToolCall, env tool.Environment) (session.ToolResult, error) { + seenRef = env.Ref() + if _, err := env.CommandRunner().Run(ctx, "write"); err != nil { + return session.NewToolError(call.ID, err.Error()), nil + } + return session.NewToolResult(call.ID, "wrote through parent runner"), nil + }} + writable := childEngineWith(mockllm.New( + mockllm.ToolCallTurn(toolCall("w1", "Bash", `{}`)), + mockllm.TextTurn("done"), + ), catalogWith(t, bash)) + task := newWritableSubagent(t, writable, agent.WithChildForker(&failingForker{t})) + + call := toolCall("p1", "Subagent", `{"prompt":"implement","mode":"read-write"}`) + mutator, ok := task.(interface{ MutatesParent(session.ToolCall) bool }) + if !ok || !mutator.MutatesParent(call) { + t.Fatal("direct-write microVM Subagent must be parent-mutate-serial") + } + result, err := task.Execute(context.Background(), call, parentEnv) + if err != nil || result.IsError { + t.Fatalf("direct-write Execute: result=%+v err=%v", result, err) + } + if seenRef != parentRef { + t.Fatalf("child EnvironmentRef = %+v, want parent %+v", seenRef, parentRef) + } + if got, err := parentWS.Read(context.Background(), "direct-write.txt"); err != nil || string(got) != "parent mutation" { + t.Fatalf("parent mutation = %q, %v", got, err) + } +} + +type microVMMutationRunner struct{ ws tool.Workspace } + +func (r microVMMutationRunner) Run(ctx context.Context, _ string) (tool.CommandResult, error) { + _, err := r.ws.CreateFile(ctx, "direct-write.txt", []byte("parent mutation")) + return tool.CommandResult{}, err +} + +type environmentProbeTool struct { + run func(context.Context, session.ToolCall, tool.Environment) (session.ToolResult, error) +} + +func (*environmentProbeTool) Spec() tool.ToolSpec { + return tool.ToolSpec{Name: "Bash", Description: "test", Schema: json.RawMessage(`{"type":"object"}`)} +} +func (*environmentProbeTool) ReadOnly() bool { return false } +func (t *environmentProbeTool) Execute(ctx context.Context, call session.ToolCall, env tool.Environment) (session.ToolResult, error) { + return t.run(ctx, call, env) +} diff --git a/environment/microvm/admission.go b/environment/microvm/admission.go new file mode 100644 index 000000000..fe7239a70 --- /dev/null +++ b/environment/microvm/admission.go @@ -0,0 +1,357 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "sync" + "time" +) + +// Resource names the independently-accounted admission dimensions. +type Resource string + +const ( + // ResourceBootingVMs accounts VMs between admission and readiness. + ResourceBootingVMs Resource = "booting-vms" + // ResourceActiveVMs accounts ready or conservatively retained VMs. + ResourceActiveVMs Resource = "active-vms" + // ResourceWorktrees accounts prepared session worktrees. + ResourceWorktrees Resource = "worktrees" + // ResourceCPU accounts virtual CPUs. + ResourceCPU Resource = "cpu" + // ResourceRAMBytes accounts guest memory bytes. + ResourceRAMBytes Resource = "ram-bytes" + // ResourceDiskBytes accounts guest and worktree disk bytes. + ResourceDiskBytes Resource = "disk-bytes" + // ResourceInodes accounts guest and worktree filesystem entries. + ResourceInodes Resource = "inodes" + // ResourceExecs accounts concurrent guest executions. + ResourceExecs Resource = "execs" + // ResourceForks accounts concurrent environment forks. + ResourceForks Resource = "forks" + // ResourcePulls accounts concurrent immutable artifact pulls. + ResourcePulls Resource = "pulls" + // ResourceBootRate accounts VM starts in a rolling time window. + ResourceBootRate Resource = "boot-rate" +) + +// AdmissionScope identifies which independently-enforced quota rejected work. +type AdmissionScope string + +const ( + // AdmissionScopeUser identifies an owner-specific quota. + AdmissionScopeUser AdmissionScope = "user" + // AdmissionScopeDeployment identifies a daemon-wide quota. + AdmissionScopeDeployment AdmissionScope = "deployment" +) + +// ResourceUsage is a reservation delta. Boots records boot attempts for rate +// limiting and is not retained in concurrent usage after admission. +type ResourceUsage struct { + BootingVMs int64 + ActiveVMs int64 + Worktrees int64 + CPU int64 + RAMBytes int64 + DiskBytes int64 + Inodes int64 + Execs int64 + Forks int64 + Pulls int64 + Boots int64 +} + +// RateLimit bounds admitted boots in one rolling window. A zero Count disables it. +type RateLimit struct { + Count int64 + Window time.Duration +} + +// AdmissionLimits applies every positive bound to both the owner and deployment. +type AdmissionLimits struct { + PerUser ResourceUsage + Deployment ResourceUsage + PerUserBootRate RateLimit + DeploymentBootRate RateLimit +} + +// AdmissionError is stable and actionable for callers and operator diagnostics. +type AdmissionError struct { + Scope AdmissionScope + Owner string + Resource Resource + Used int64 + Request int64 + Limit int64 +} + +func (e *AdmissionError) Error() string { + if e == nil { + return "microvm admission rejected" + } + owner := "deployment" + if e.Scope == AdmissionScopeUser { + owner = e.Owner + } + return fmt.Sprintf("microvm admission rejected: scope=%s owner=%q resource=%s used=%d request=%d limit=%d", e.Scope, owner, e.Resource, e.Used, e.Request, e.Limit) +} + +// AdmissionController atomically accounts per-owner and deployment resources. +type AdmissionController struct { + mu sync.Mutex + limits AdmissionLimits + now func() time.Time + users map[string]ResourceUsage + deployment ResourceUsage + userBoots map[string][]time.Time + boots []time.Time +} + +// NewAdmissionController validates limits and constructs an empty controller. +func NewAdmissionController(limits AdmissionLimits, now func() time.Time) (*AdmissionController, error) { + if now == nil { + now = time.Now + } + if err := validateUsage(limits.PerUser); err != nil { + return nil, fmt.Errorf("per-user admission limits: %w", err) + } + if err := validateUsage(limits.Deployment); err != nil { + return nil, fmt.Errorf("deployment admission limits: %w", err) + } + if err := validateRateLimit(limits.PerUserBootRate); err != nil { + return nil, fmt.Errorf("per-user boot-rate admission limit: %w", err) + } + if err := validateRateLimit(limits.DeploymentBootRate); err != nil { + return nil, fmt.Errorf("deployment boot-rate admission limit: %w", err) + } + return &AdmissionController{limits: limits, now: now, users: make(map[string]ResourceUsage), userBoots: make(map[string][]time.Time)}, nil +} + +// Acquire atomically reserves usage or returns an AdmissionError. +func (a *AdmissionController) Acquire(owner string, request ResourceUsage) (*AdmissionLease, error) { + return a.AcquireContext(context.Background(), owner, request) +} + +// AcquireContext is Acquire with cancellation checked before accounting. +func (a *AdmissionController) AcquireContext(ctx context.Context, owner string, request ResourceUsage) (*AdmissionLease, error) { + if err := context.Cause(ctx); err != nil { + return nil, err + } + if a == nil { + return nil, errors.New("microvm admission is not configured") + } + if owner == "" { + return nil, errors.New("microvm admission owner is required") + } + if err := validateUsage(request); err != nil { + return nil, fmt.Errorf("microvm admission request: %w", err) + } + a.mu.Lock() + defer a.mu.Unlock() + if err := a.checkLocked(owner, ResourceUsage{}, request); err != nil { + return nil, err + } + a.applyLocked(owner, ResourceUsage{}, request) + a.recordBootsLocked(owner, request.Boots) + return &AdmissionLease{controller: a, owner: owner, usage: withoutBoots(request)}, nil +} + +// Reconstruct restores every still-live durable reservation after daemon +// restart. Destroyed records are intentionally excluded: their durable state is +// the release commit boundary. +func (a *AdmissionController) Reconstruct(records []EnvironmentRecord) error { + if a == nil { + return errors.New("microvm admission is not configured") + } + a.mu.Lock() + defer a.mu.Unlock() + a.users = make(map[string]ResourceUsage) + a.deployment = ResourceUsage{} + for _, record := range records { + if record.State == EnvironmentDestroyed || record.Owner == "" || record.AdmissionUsage == (ResourceUsage{}) { + continue + } + if err := validateUsage(record.AdmissionUsage); err != nil { + return fmt.Errorf("reconstruct microvm admission for %s: %w", record.EnvironmentID, err) + } + if err := a.checkLocked(record.Owner, ResourceUsage{}, record.AdmissionUsage); err != nil { + return fmt.Errorf("reconstruct microvm admission for %s: %w", record.EnvironmentID, err) + } + a.applyLocked(record.Owner, ResourceUsage{}, record.AdmissionUsage) + } + return nil +} + +// Release returns a durable generation's retained capacity after its destroyed +// tombstone has been committed. It is intentionally separate from lease release +// so restart-reconstructed reservations follow the same boundary. +func (a *AdmissionController) Release(owner string, usage ResourceUsage) { + if a == nil || owner == "" || usage == (ResourceUsage{}) { + return + } + a.mu.Lock() + a.applyLocked(owner, usage, ResourceUsage{}) + a.mu.Unlock() +} + +// AdmissionLease owns one reservation. Release is idempotent. +type AdmissionLease struct { + mu sync.Mutex + controller *AdmissionController + owner string + usage ResourceUsage + released bool +} + +// Replace atomically transitions a reservation, for example booting VM to active VM. +func (l *AdmissionLease) Replace(next ResourceUsage) error { + if l == nil { + return errors.New("microvm admission lease is nil") + } + if err := validateUsage(next); err != nil { + return fmt.Errorf("microvm admission replacement: %w", err) + } + l.mu.Lock() + defer l.mu.Unlock() + if l.released { + return errors.New("microvm admission lease is released") + } + a := l.controller + a.mu.Lock() + defer a.mu.Unlock() + if err := a.checkLocked(l.owner, l.usage, next); err != nil { + return err + } + a.applyLocked(l.owner, l.usage, next) + a.recordBootsLocked(l.owner, next.Boots) + l.usage = withoutBoots(next) + return nil +} + +// Release returns all retained capacity exactly once. +func (l *AdmissionLease) Release() { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + if l.released { + return + } + l.controller.mu.Lock() + l.controller.applyLocked(l.owner, l.usage, ResourceUsage{}) + l.controller.mu.Unlock() + l.released = true +} + +func (a *AdmissionController) checkLocked(owner string, previous, next ResourceUsage) error { + user := subtractUsage(a.users[owner], previous) + deployment := subtractUsage(a.deployment, previous) + if err := checkUsage(AdmissionScopeUser, owner, user, next, a.limits.PerUser); err != nil { + return err + } + if err := checkUsage(AdmissionScopeDeployment, owner, deployment, next, a.limits.Deployment); err != nil { + return err + } + if next.Boots > 0 { + now := a.now() + if limit := a.limits.PerUserBootRate; limit.Count > 0 { + a.userBoots[owner] = trimBoots(a.userBoots[owner], now.Add(-limit.Window)) + if exceeds(int64(len(a.userBoots[owner])), next.Boots, limit.Count) { + return &AdmissionError{Scope: AdmissionScopeUser, Owner: owner, Resource: ResourceBootRate, Used: int64(len(a.userBoots[owner])), Request: next.Boots, Limit: limit.Count} + } + } + if limit := a.limits.DeploymentBootRate; limit.Count > 0 { + a.boots = trimBoots(a.boots, now.Add(-limit.Window)) + if exceeds(int64(len(a.boots)), next.Boots, limit.Count) { + return &AdmissionError{Scope: AdmissionScopeDeployment, Owner: owner, Resource: ResourceBootRate, Used: int64(len(a.boots)), Request: next.Boots, Limit: limit.Count} + } + } + } + return nil +} + +func (a *AdmissionController) applyLocked(owner string, previous, next ResourceUsage) { + a.users[owner] = addUsage(subtractUsage(a.users[owner], previous), withoutBoots(next)) + a.deployment = addUsage(subtractUsage(a.deployment, previous), withoutBoots(next)) +} + +func (a *AdmissionController) recordBootsLocked(owner string, count int64) { + for range count { + now := a.now() + if a.limits.PerUserBootRate.Count > 0 { + a.userBoots[owner] = append(a.userBoots[owner], now) + } + if a.limits.DeploymentBootRate.Count > 0 { + a.boots = append(a.boots, now) + } + } +} + +func checkUsage(scope AdmissionScope, owner string, used, request, limit ResourceUsage) error { + for _, item := range usageItems(used, request, limit) { + if item.limit > 0 && exceeds(item.used, item.request, item.limit) { + return &AdmissionError{Scope: scope, Owner: owner, Resource: item.resource, Used: item.used, Request: item.request, Limit: item.limit} + } + } + return nil +} + +type usageItem struct { + resource Resource + used, request, limit int64 +} + +func usageItems(used, request, limit ResourceUsage) []usageItem { + return []usageItem{ + {ResourceBootingVMs, used.BootingVMs, request.BootingVMs, limit.BootingVMs}, + {ResourceActiveVMs, used.ActiveVMs, request.ActiveVMs, limit.ActiveVMs}, + {ResourceWorktrees, used.Worktrees, request.Worktrees, limit.Worktrees}, + {ResourceCPU, used.CPU, request.CPU, limit.CPU}, + {ResourceRAMBytes, used.RAMBytes, request.RAMBytes, limit.RAMBytes}, + {ResourceDiskBytes, used.DiskBytes, request.DiskBytes, limit.DiskBytes}, + {ResourceInodes, used.Inodes, request.Inodes, limit.Inodes}, + {ResourceExecs, used.Execs, request.Execs, limit.Execs}, + {ResourceForks, used.Forks, request.Forks, limit.Forks}, + {ResourcePulls, used.Pulls, request.Pulls, limit.Pulls}, + } +} + +func validateUsage(usage ResourceUsage) error { + for _, item := range usageItems(ResourceUsage{}, usage, ResourceUsage{}) { + if item.request < 0 { + return fmt.Errorf("%s must not be negative", item.resource) + } + } + if usage.Boots < 0 { + return errors.New("boots must not be negative") + } + return nil +} + +func validateRateLimit(limit RateLimit) error { + if limit.Count < 0 || limit.Window < 0 || (limit.Count == 0) != (limit.Window == 0) { + return errors.New("count and window must be positive or both disabled") + } + return nil +} + +func exceeds(used, request, limit int64) bool { + return used > limit || request > limit-used +} + +func addUsage(a, b ResourceUsage) ResourceUsage { + return ResourceUsage{BootingVMs: a.BootingVMs + b.BootingVMs, ActiveVMs: a.ActiveVMs + b.ActiveVMs, Worktrees: a.Worktrees + b.Worktrees, CPU: a.CPU + b.CPU, RAMBytes: a.RAMBytes + b.RAMBytes, DiskBytes: a.DiskBytes + b.DiskBytes, Inodes: a.Inodes + b.Inodes, Execs: a.Execs + b.Execs, Forks: a.Forks + b.Forks, Pulls: a.Pulls + b.Pulls} +} +func subtractUsage(a, b ResourceUsage) ResourceUsage { + return ResourceUsage{BootingVMs: a.BootingVMs - b.BootingVMs, ActiveVMs: a.ActiveVMs - b.ActiveVMs, Worktrees: a.Worktrees - b.Worktrees, CPU: a.CPU - b.CPU, RAMBytes: a.RAMBytes - b.RAMBytes, DiskBytes: a.DiskBytes - b.DiskBytes, Inodes: a.Inodes - b.Inodes, Execs: a.Execs - b.Execs, Forks: a.Forks - b.Forks, Pulls: a.Pulls - b.Pulls} +} +func withoutBoots(usage ResourceUsage) ResourceUsage { usage.Boots = 0; return usage } +func trimBoots(in []time.Time, cutoff time.Time) []time.Time { + i := 0 + for i < len(in) && !in[i].After(cutoff) { + i++ + } + return in[i:] +} diff --git a/environment/microvm/admission_test.go b/environment/microvm/admission_test.go new file mode 100644 index 000000000..dfb0fba7e --- /dev/null +++ b/environment/microvm/admission_test.go @@ -0,0 +1,117 @@ +package microvm + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestMicroVMEnvironments_Scenario6_ResourceAdmissionIsBounded(t *testing.T) { + t.Parallel() + now := time.Unix(1_700_000_000, 0) + limits := AdmissionLimits{ + PerUser: ResourceUsage{BootingVMs: 1, ActiveVMs: 1, Worktrees: 1, CPU: 2, RAMBytes: 4 << 30, DiskBytes: 20 << 30, Inodes: 1000, Execs: 1, Forks: 1, Pulls: 1}, + Deployment: ResourceUsage{BootingVMs: 2, ActiveVMs: 2, Worktrees: 2, CPU: 4, RAMBytes: 8 << 30, DiskBytes: 40 << 30, Inodes: 2000, Execs: 2, Forks: 2, Pulls: 2}, + PerUserBootRate: RateLimit{Count: 1, Window: time.Minute}, + DeploymentBootRate: RateLimit{Count: 2, Window: time.Minute}, + } + admission, err := NewAdmissionController(limits, func() time.Time { return now }) + if err != nil { + t.Fatalf("NewAdmissionController: %v", err) + } + + vm := ResourceUsage{BootingVMs: 1, Worktrees: 1, CPU: 2, RAMBytes: 4 << 30, DiskBytes: 20 << 30, Inodes: 1000, Boots: 1} + lease, err := admission.Acquire("alice", vm) + if err != nil { + t.Fatalf("first admission: %v", err) + } + _, err = admission.Acquire("alice", vm) + var rejected *AdmissionError + if !errors.As(err, &rejected) || rejected.Scope != AdmissionScopeUser || rejected.Resource != ResourceBootingVMs || !strings.Contains(err.Error(), "alice") || !strings.Contains(err.Error(), "limit=1") { + t.Fatalf("same-user rejection = %v, want stable actionable booting-vm quota", err) + } + if err := lease.Replace(ResourceUsage{ActiveVMs: 1, Worktrees: 1, CPU: 2, RAMBytes: 4 << 30, DiskBytes: 20 << 30, Inodes: 1000}); err != nil { + t.Fatalf("mark active: %v", err) + } + bobVM, err := admission.Acquire("bob", vm) + if err != nil { + t.Fatalf("second user admission: %v", err) + } + if err := bobVM.Replace(ResourceUsage{ActiveVMs: 1, Worktrees: 1, CPU: 2, RAMBytes: 4 << 30, DiskBytes: 20 << 30, Inodes: 1000}); err != nil { + t.Fatalf("mark second user active: %v", err) + } + _, err = admission.Acquire("carol", ResourceUsage{CPU: 1}) + if !errors.As(err, &rejected) || rejected.Scope != AdmissionScopeDeployment || rejected.Resource != ResourceCPU { + t.Fatalf("deployment CPU rejection = %v", err) + } + _, err = admission.Acquire("carol", ResourceUsage{BootingVMs: 1, Boots: 1}) + if !errors.As(err, &rejected) || rejected.Scope != AdmissionScopeDeployment || rejected.Resource != ResourceBootRate { + t.Fatalf("deployment boot-rate rejection = %v", err) + } + bobVM.Release() + + for name, usage := range map[string]ResourceUsage{ + "cpu": {CPU: 2}, "ram": {RAMBytes: 4 << 30}, "disk": {DiskBytes: 20 << 30}, "inodes": {Inodes: 1000}, + "execs": {Execs: 1}, "forks": {Forks: 1}, "pulls": {Pulls: 1}, + } { + t.Run(name, func(t *testing.T) { + first, acquireErr := admission.Acquire("bob", usage) + if acquireErr != nil { + t.Fatalf("first acquire: %v", acquireErr) + } + defer first.Release() + if _, acquireErr = admission.Acquire("bob", usage); acquireErr == nil { + t.Fatal("second acquire succeeded beyond per-user limit") + } + }) + } + + fx := newLifecycleFixture("success") + fx.request.Owner = "alice" + fx.request.Resources = ResourceUsage{CPU: 2, RAMBytes: 4 << 30, DiskBytes: 20 << 30, Inodes: 1000} + lifecycle := NewLifecycle(LifecycleDeps{Identities: fx.identities, Worktrees: fx.worktrees, Artifacts: fx.artifacts, VMs: fx.vms, Protocol: fx.protocol, Registry: fx.registry, Sessions: fx.sessions, Admission: admission}) + if _, createErr := lifecycle.Create(context.Background(), fx.request); createErr == nil || !strings.Contains(createErr.Error(), "worktrees") { + t.Fatalf("lifecycle admission error = %v, want worktree quota rejection", createErr) + } + if len(fx.registry.records) != 0 { + t.Fatalf("rejected lifecycle create crossed provisioning boundary: %+v", fx.registry.records) + } + + lease.Release() + lease.Release() // idempotent accounting + if _, err := admission.Acquire("alice", vm); err == nil || !strings.Contains(err.Error(), "boot-rate") { + t.Fatalf("boot-rate rejection = %v, want actionable rate limit", err) + } + now = now.Add(time.Minute) + if next, err := admission.Acquire("alice", vm); err != nil { + t.Fatalf("admission after rate window: %v", err) + } else { + next.Release() + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := admission.AcquireContext(ctx, "alice", ResourceUsage{Execs: 1}); !errors.Is(err, context.Canceled) { + t.Fatalf("AcquireContext error = %v, want context cancellation", err) + } + + restarted, err := NewAdmissionController(limits, func() time.Time { return now }) + if err != nil { + t.Fatalf("NewAdmissionController(restart): %v", err) + } + retained := ResourceUsage{ActiveVMs: 1, Worktrees: 1, CPU: 2, RAMBytes: 4 << 30, DiskBytes: 20 << 30, Inodes: 1000} + if err := restarted.Reconstruct([]EnvironmentRecord{{State: EnvironmentReady, Owner: "alice", AdmissionUsage: retained}}); err != nil { + t.Fatalf("Reconstruct: %v", err) + } + if _, err := restarted.Acquire("alice", ResourceUsage{CPU: 1}); err == nil { + t.Fatal("restart reconstruction discarded the durable active reservation") + } + restarted.Release("alice", retained) + if lease, err := restarted.Acquire("alice", ResourceUsage{CPU: 1}); err != nil { + t.Fatalf("admission remained reserved after durable destruction: %v", err) + } else { + lease.Release() + } +} diff --git a/environment/microvm/artifact.go b/environment/microvm/artifact.go new file mode 100644 index 000000000..85b4628f3 --- /dev/null +++ b/environment/microvm/artifact.go @@ -0,0 +1,834 @@ +package microvm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gofrs/flock" + "github.com/stacklok/go-microvm/extract" +) + +// ArtifactKind identifies one executable input to a microVM. +type ArtifactKind string + +const ( + // ArtifactRuntime is the go-microvm runner and libkrun bundle. + ArtifactRuntime ArtifactKind = "runtime" + // ArtifactFirmware is the libkrunfw bundle. + ArtifactFirmware ArtifactKind = "firmware" + // ArtifactExecutionImage is the admitted Brood guest root filesystem. + ArtifactExecutionImage ArtifactKind = "execution-image" + // ArtifactGuestAgent is the independently built guest protocol binary. + ArtifactGuestAgent ArtifactKind = "guest-agent" +) + +var ( + // ErrMutableArtifact rejects references that are not digest-pinned. + ErrMutableArtifact = errors.New("microvm artifact is not pinned to an immutable digest") + // ErrDigestMismatch rejects resolver output that differs from the requested digest. + ErrDigestMismatch = errors.New("microvm artifact digest mismatch") + // ErrUnverifiedArtifact rejects evidence that does not satisfy operator policy. + ErrUnverifiedArtifact = errors.New("microvm artifact verification failed") + // ErrCorruptCacheEntry rejects cache payload or metadata corruption. + ErrCorruptCacheEntry = errors.New("verified artifact cache entry is corrupt") + // ErrStalePolicy rejects an entry admitted under another policy revision. + ErrStalePolicy = errors.New("verified artifact cache entry uses a stale policy") +) + +// ArtifactRequest is an operator-resolved artifact reference. Digest must be a +// canonical sha256 digest; Reference is retained only for resolver lookup. +type ArtifactRequest struct { + Kind ArtifactKind + Reference string + Digest string + ManifestDigest string + DiscoveryReference string + ResolutionEvidence string + Platform string +} + +// Attestation is a signed in-toto statement binding an artifact digest to a predicate. +type Attestation struct { + PredicateType string `json:"predicate_type"` + SubjectDigest string `json:"subject_digest"` + Statement []byte `json:"statement"` +} + +// VerificationEvidence carries one Sigstore bundle for the attestation statement. +type VerificationEvidence struct { + Bundle []byte `json:"bundle"` + Attestation Attestation `json:"attestation"` +} + +// EvidenceVerifier verifies a Sigstore bundle against exact operator-owned identity policy. +type EvidenceVerifier interface { + Verify(context.Context, []byte, []byte, string, string) error +} + +// ResolvedArtifact is immutable resolver output. Runtime and firmware Sources +// are passed through go-microvm's extract.Source model; the execution image is +// represented by the same materialization seam until VM assembly consumes it. +type ResolvedArtifact struct { + Kind ArtifactKind + Digest string + ManifestDigest string + Source extract.Source + Evidence VerificationEvidence +} + +// ArtifactResolver resolves a digest-pinned reference without granting it cache +// or execution authority. +type ArtifactResolver interface { + Resolve(context.Context, ArtifactRequest) (ResolvedArtifact, error) +} + +// TrustPolicy is operator-owned verification configuration. +type TrustPolicy struct { + Revision string + CertificateIdentity string + OIDCIssuer string + PublicKeyIdentity string + Verifier EvidenceVerifier + RequiredAttestations map[ArtifactKind]string + RevokedIdentities map[string]struct{} +} + +// VerifiedArtifact is a cache-admitted artifact. Source always points at the +// immutable admitted payload, never resolver staging content. +type VerifiedArtifact struct { + Kind ArtifactKind + Digest string + ManifestDigest string + DiscoveryReference string + ResolutionEvidence string + Platform string + Path string + Source extract.Source +} + +// VerifiedArtifacts is the complete set required to launch a microVM. +type VerifiedArtifacts struct { + Runtime VerifiedArtifact + Firmware VerifiedArtifact + ExecutionImage VerifiedArtifact + GuestAgent VerifiedArtifact +} + +// ByKind returns the verified artifact of kind, or its zero value. +func (a VerifiedArtifacts) ByKind(kind ArtifactKind) VerifiedArtifact { + switch kind { + case ArtifactRuntime: + return a.Runtime + case ArtifactFirmware: + return a.Firmware + case ArtifactExecutionImage: + return a.ExecutionImage + case ArtifactGuestAgent: + return a.GuestAgent + default: + return VerifiedArtifact{} + } +} + +// All returns every independently admitted launch artifact in launch order. +func (a VerifiedArtifacts) All() []VerifiedArtifact { + return []VerifiedArtifact{a.Runtime, a.Firmware, a.ExecutionImage, a.GuestAgent} +} + +// Launcher is the narrow handoff to the later VM lifecycle implementation. +type Launcher interface { + Launch(context.Context, VerifiedArtifacts) (vmID string, err error) +} + +// MetadataStore durably records the artifact identities used by a launched VM. +type MetadataStore interface { + Save(context.Context, EnvironmentMetadata) error +} + +// EnvironmentMetadata is the durable artifact-verification portion of an +// environment record. +type EnvironmentMetadata struct { + SessionID string + VMID string + Artifacts map[ArtifactKind]string + ManifestDigests map[ArtifactKind]string + PolicyRevision string +} + +// Provisioner verifies a complete artifact set before crossing the launch seam. +type Provisioner struct { + cache *VerifiedCache + resolver ArtifactResolver + policy TrustPolicy + launcher Launcher + metadata MetadataStore + observer *OperationsObserver +} + +// NewProvisioner constructs the artifact-gated launch coordinator. +func NewProvisioner(cache *VerifiedCache, resolver ArtifactResolver, policy TrustPolicy, launcher Launcher, metadata MetadataStore, observers ...*OperationsObserver) *Provisioner { + var observer *OperationsObserver + if len(observers) > 0 { + observer = observers[0] + } + return &Provisioner{cache: cache, resolver: resolver, policy: clonePolicy(policy), launcher: launcher, metadata: metadata, observer: observer} +} + +// Verify admits the complete artifact set without crossing the VM launch seam. +// It returns the policy revision that admitted the immutable identities. +func (p *Provisioner) Verify(ctx context.Context, requests map[ArtifactKind]ArtifactRequest) (VerifiedArtifacts, string, error) { + if p.cache == nil || p.resolver == nil { + return VerifiedArtifacts{}, "", errors.New("microvm artifact verification is not fully configured") + } + var artifacts VerifiedArtifacts + for _, kind := range []ArtifactKind{ArtifactRuntime, ArtifactFirmware, ArtifactExecutionImage, ArtifactGuestAgent} { + request, ok := requests[kind] + if !ok || request.Kind != kind { + p.observeVerification(kind, OutcomeFailure) + return VerifiedArtifacts{}, "", fmt.Errorf("%w: missing %s", ErrMutableArtifact, kind) + } + verified, err := p.cache.resolve(ctx, request, p.resolver, p.policy) + if err != nil { + p.observeVerification(kind, OutcomeFailure) + return VerifiedArtifacts{}, "", fmt.Errorf("verify %s: %w", kind, err) + } + p.observeVerification(kind, OutcomeSuccess) + switch kind { + case ArtifactRuntime: + artifacts.Runtime = verified + case ArtifactFirmware: + artifacts.Firmware = verified + case ArtifactExecutionImage: + artifacts.ExecutionImage = verified + case ArtifactGuestAgent: + artifacts.GuestAgent = verified + } + } + return artifacts, p.policy.Revision, nil +} + +func (p *Provisioner) observeVerification(kind ArtifactKind, outcome Outcome) { + if p.observer != nil { + p.observer.ArtifactVerification(kind, outcome) + } +} + +// Provision admits the complete artifact set, launches it, and records the +// immutable identities and policy revision used by that VM. +func (p *Provisioner) Provision(ctx context.Context, sessionID string, requests map[ArtifactKind]ArtifactRequest) (EnvironmentMetadata, error) { + if p.launcher == nil || p.metadata == nil { + return EnvironmentMetadata{}, errors.New("microvm artifact provisioning is not fully configured") + } + artifacts, policyRevision, err := p.Verify(ctx, requests) + if err != nil { + return EnvironmentMetadata{}, err + } + + vmID, err := p.launchVerified(ctx, artifacts) + if err != nil { + return EnvironmentMetadata{}, fmt.Errorf("launch verified microvm: %w", err) + } + metadata := EnvironmentMetadata{ + SessionID: sessionID, + VMID: vmID, + Artifacts: map[ArtifactKind]string{ + ArtifactRuntime: artifacts.Runtime.Digest, ArtifactFirmware: artifacts.Firmware.Digest, + ArtifactExecutionImage: artifacts.ExecutionImage.Digest, ArtifactGuestAgent: artifacts.GuestAgent.Digest, + }, + ManifestDigests: map[ArtifactKind]string{ + ArtifactExecutionImage: artifacts.ExecutionImage.ManifestDigest, + }, + PolicyRevision: policyRevision, + } + if err := p.metadata.Save(ctx, metadata); err != nil { + return EnvironmentMetadata{}, fmt.Errorf("persist microvm artifact metadata: %w", err) + } + return metadata, nil +} + +func (p *Provisioner) launchVerified(ctx context.Context, artifacts VerifiedArtifacts) (string, error) { + launchArtifacts, release, err := p.LockAndValidate(ctx, artifacts) + if err != nil { + return "", err + } + defer release() + return p.launcher.Launch(ctx, launchArtifacts) +} + +// LockAndValidate revalidates admitted bytes while holding every corresponding +// cache lock, then copies them into one private launch snapshot. The caller must +// pass the returned identities to runtime and release them only after runtime has +// consumed their paths. Cache-path mutation cannot alter the snapshot bytes. +func (p *Provisioner) LockAndValidate(ctx context.Context, artifacts VerifiedArtifacts) (VerifiedArtifacts, func(), error) { + locks := make([]*flock.Flock, 0, len(artifacts.All())) + var snapshotRoot string + release := func() { + if snapshotRoot != "" { + _ = os.RemoveAll(snapshotRoot) + } + for i := len(locks) - 1; i >= 0; i-- { + _ = locks[i].Unlock() + } + } + for _, artifact := range artifacts.All() { + request := ArtifactRequest{ + Kind: artifact.Kind, Digest: artifact.Digest, ManifestDigest: artifact.ManifestDigest, + DiscoveryReference: artifact.DiscoveryReference, ResolutionEvidence: artifact.ResolutionEvidence, Platform: artifact.Platform, + } + entryLock, err := p.cache.lockEntry(ctx, request) + if err != nil { + release() + return VerifiedArtifacts{}, nil, err + } + locks = append(locks, entryLock) + entry := filepath.Dir(artifact.Path) + verified, err := loadVerifiedEntry(ctx, entry, request, p.policy) + if err != nil || verified.Path != artifact.Path { + release() + if err != nil { + return VerifiedArtifacts{}, nil, err + } + return VerifiedArtifacts{}, nil, ErrCorruptCacheEntry + } + } + + var err error + stagingRoot := filepath.Join(p.cache.root, "staging") + if err = os.MkdirAll(stagingRoot, 0o700); err != nil { + release() + return VerifiedArtifacts{}, nil, err + } + snapshotRoot, err = os.MkdirTemp(stagingRoot, "launch-") + if err != nil { + release() + return VerifiedArtifacts{}, nil, err + } + launchArtifacts, err := snapshotArtifacts(artifacts, snapshotRoot) + if err != nil { + release() + return VerifiedArtifacts{}, nil, err + } + return launchArtifacts, release, nil +} + +// VerifiedCache admits artifacts by atomic rename after materialization and +// verification. A file lock serializes cold admission across processes. +type VerifiedCache struct { + root string +} + +// NewVerifiedCache creates an atomic verified cache rooted at root. +func NewVerifiedCache(root string) *VerifiedCache { return &VerifiedCache{root: root} } + +type cacheMetadata struct { + Kind ArtifactKind `json:"kind"` + Digest string `json:"digest"` + ManifestDigest string `json:"manifest_digest,omitempty"` + DiscoveryReference string `json:"discovery_reference,omitempty"` + ResolutionEvidence string `json:"resolution_evidence,omitempty"` + Platform string `json:"platform,omitempty"` + ContentDigest string `json:"content_digest"` + PolicyRevision string `json:"policy_revision"` + Evidence VerificationEvidence `json:"evidence"` +} + +func (c *VerifiedCache) resolve(ctx context.Context, request ArtifactRequest, resolver ArtifactResolver, policy TrustPolicy) (VerifiedArtifact, error) { + if err := validateRequest(request, policy); err != nil { + return VerifiedArtifact{}, err + } + entry := filepath.Join(c.root, "entries", string(request.Kind), artifactCacheKey(request)) + entryLock, err := c.lockEntry(ctx, request) + if err != nil { + return VerifiedArtifact{}, err + } + defer func() { _ = entryLock.Unlock() }() + + if _, err := os.Lstat(entry); err == nil { + return loadVerifiedEntry(ctx, entry, request, policy) + } else if !errors.Is(err, os.ErrNotExist) { + return VerifiedArtifact{}, err + } + return c.admit(ctx, entry, request, resolver, policy) +} + +func validateRequest(request ArtifactRequest, policy TrustPolicy) error { + pinnedDigest := request.Digest + if request.ManifestDigest != "" { + pinnedDigest = request.ManifestDigest + } + if !validDigest(request.Digest) || !validDigest(pinnedDigest) || request.Reference == "" || !strings.HasSuffix(request.Reference, "@"+pinnedDigest) { + return ErrMutableArtifact + } + if policy.Revision == "" { + return fmt.Errorf("%w: empty policy revision", ErrUnverifiedArtifact) + } + broodFieldsSet := request.DiscoveryReference != "" || request.ResolutionEvidence != "" || request.Platform != "" + if request.Kind == ArtifactExecutionImage && request.ManifestDigest != "" { + if request.DiscoveryReference != "ghcr.io/stacklok/brood-box/base:latest" || + !validDigest(request.ManifestDigest) || !validDigest(request.ResolutionEvidence) || + (request.Platform != "linux/amd64" && request.Platform != "linux/arm64") { + return fmt.Errorf("%w: invalid Brood discovery resolution", ErrUnverifiedArtifact) + } + } else if broodFieldsSet { + return fmt.Errorf("%w: Brood discovery evidence is execution-image only", ErrUnverifiedArtifact) + } + return nil +} + +func artifactCacheKey(request ArtifactRequest) string { + tree := strings.TrimPrefix(request.Digest, "sha256:") + if request.ManifestDigest == "" { + return tree + } + return strings.TrimPrefix(request.ManifestDigest, "sha256:") + "-" + tree +} + +func (c *VerifiedCache) lockEntry(ctx context.Context, request ArtifactRequest) (*flock.Flock, error) { + entryDir := filepath.Join(c.root, "entries", string(request.Kind)) + if err := os.MkdirAll(entryDir, 0o700); err != nil { + return nil, err + } + lockDir := filepath.Join(c.root, "locks", string(request.Kind)) + if err := os.MkdirAll(lockDir, 0o700); err != nil { + return nil, err + } + entryLock := flock.New(filepath.Join(lockDir, artifactCacheKey(request)+".lock")) + locked, err := entryLock.TryLockContext(ctx, 10*time.Millisecond) + if err != nil { + return nil, err + } + if !locked { + return nil, ctx.Err() + } + return entryLock, nil +} + +func (c *VerifiedCache) admit(ctx context.Context, entry string, request ArtifactRequest, resolver ArtifactResolver, policy TrustPolicy) (VerifiedArtifact, error) { + resolved, err := resolver.Resolve(ctx, request) + if err != nil { + return VerifiedArtifact{}, err + } + if resolved.Kind != request.Kind || resolved.Digest != request.Digest || resolved.ManifestDigest != request.ManifestDigest { + return VerifiedArtifact{}, ErrDigestMismatch + } + if resolved.Source == nil { + return VerifiedArtifact{}, fmt.Errorf("%w: artifact has no source", ErrUnverifiedArtifact) + } + + stagingRoot := filepath.Join(c.root, "staging") + if err := os.MkdirAll(stagingRoot, 0o700); err != nil { + return VerifiedArtifact{}, err + } + work, err := os.MkdirTemp(stagingRoot, "admit-") + if err != nil { + return VerifiedArtifact{}, err + } + defer func() { _ = os.RemoveAll(work) }() + materialized, err := resolved.Source.Ensure(ctx, work) + if err != nil { + return VerifiedArtifact{}, err + } + stage := filepath.Join(work, "entry") + payload := filepath.Join(stage, "payload") + if err := copyTree(materialized, payload); err != nil { + return VerifiedArtifact{}, fmt.Errorf("stage artifact: %w", err) + } + contentDigest, err := digestTree(payload) + if err != nil { + return VerifiedArtifact{}, err + } + if contentDigest != resolved.Digest { + return VerifiedArtifact{}, ErrDigestMismatch + } + if err := verifyArtifact(ctx, request, resolved.Evidence, policy); err != nil { + return VerifiedArtifact{}, err + } + meta := cacheMetadata{ + Kind: resolved.Kind, Digest: resolved.Digest, ManifestDigest: resolved.ManifestDigest, + DiscoveryReference: request.DiscoveryReference, ResolutionEvidence: request.ResolutionEvidence, Platform: request.Platform, + ContentDigest: contentDigest, PolicyRevision: policy.Revision, Evidence: resolved.Evidence, + } + if err := writeMetadata(filepath.Join(stage, "verification.json"), meta); err != nil { + return VerifiedArtifact{}, err + } + if err := os.Rename(stage, entry); err != nil { + return VerifiedArtifact{}, fmt.Errorf("atomically admit artifact: %w", err) + } + if err := syncDir(filepath.Dir(entry)); err != nil { + return VerifiedArtifact{}, err + } + return verifiedFromEntry(entry, meta), nil +} + +func loadVerifiedEntry(ctx context.Context, entry string, request ArtifactRequest, policy TrustPolicy) (VerifiedArtifact, error) { + data, err := os.ReadFile(filepath.Join(entry, "verification.json")) + if err != nil { + return VerifiedArtifact{}, fmt.Errorf("%w: metadata: %v", ErrCorruptCacheEntry, err) + } + var meta cacheMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return VerifiedArtifact{}, fmt.Errorf("%w: metadata: %v", ErrCorruptCacheEntry, err) + } + if meta.Kind != request.Kind || meta.Digest != request.Digest || meta.ManifestDigest != request.ManifestDigest || + meta.DiscoveryReference != request.DiscoveryReference || meta.ResolutionEvidence != request.ResolutionEvidence || meta.Platform != request.Platform { + return VerifiedArtifact{}, ErrCorruptCacheEntry + } + if meta.PolicyRevision != policy.Revision { + return VerifiedArtifact{}, ErrStalePolicy + } + got, err := digestTree(filepath.Join(entry, "payload")) + if err != nil || got != meta.ContentDigest || got != meta.Digest { + return VerifiedArtifact{}, ErrCorruptCacheEntry + } + if err := verifyArtifact(ctx, request, meta.Evidence, policy); err != nil { + return VerifiedArtifact{}, err + } + return verifiedFromEntry(entry, meta), nil +} + +func verifiedFromEntry(entry string, meta cacheMetadata) VerifiedArtifact { + payload := filepath.Join(entry, "payload") + return VerifiedArtifact{ + Kind: meta.Kind, Digest: meta.Digest, ManifestDigest: meta.ManifestDigest, + DiscoveryReference: meta.DiscoveryReference, ResolutionEvidence: meta.ResolutionEvidence, Platform: meta.Platform, + Path: payload, Source: extract.Dir(payload), + } +} + +func snapshotArtifacts(artifacts VerifiedArtifacts, root string) (VerifiedArtifacts, error) { + var snapshot VerifiedArtifacts + for _, artifact := range artifacts.All() { + path := filepath.Join(root, string(artifact.Kind)) + if err := copyTree(artifact.Path, path); err != nil { + return VerifiedArtifacts{}, fmt.Errorf("snapshot verified %s artifact: %w", artifact.Kind, err) + } + digest, err := digestTree(path) + if err != nil || digest != artifact.Digest { + return VerifiedArtifacts{}, ErrCorruptCacheEntry + } + verified := VerifiedArtifact{ + Kind: artifact.Kind, Digest: artifact.Digest, ManifestDigest: artifact.ManifestDigest, + DiscoveryReference: artifact.DiscoveryReference, ResolutionEvidence: artifact.ResolutionEvidence, Platform: artifact.Platform, + Path: path, Source: extract.Dir(path), + } + switch artifact.Kind { + case ArtifactRuntime: + snapshot.Runtime = verified + case ArtifactFirmware: + snapshot.Firmware = verified + case ArtifactExecutionImage: + snapshot.ExecutionImage = verified + case ArtifactGuestAgent: + snapshot.GuestAgent = verified + default: + return VerifiedArtifacts{}, ErrCorruptCacheEntry + } + } + return snapshot, nil +} + +func trustIdentity(policy TrustPolicy) (string, error) { + keyless := policy.CertificateIdentity != "" && policy.OIDCIssuer != "" && policy.PublicKeyIdentity == "" + keyed := policy.CertificateIdentity == "" && policy.OIDCIssuer == "" && policy.PublicKeyIdentity != "" + switch { + case keyless: + return policy.CertificateIdentity, nil + case keyed: + return policy.PublicKeyIdentity, nil + default: + return "", fmt.Errorf("%w: sigstore trust policy is incomplete or ambiguous", ErrUnverifiedArtifact) + } +} + +type provenanceDependency struct { + URI string `json:"uri"` + Digest map[string]string `json:"digest"` + Platform string `json:"platform"` +} + +func verifyArtifact(ctx context.Context, request ArtifactRequest, evidence VerificationEvidence, policy TrustPolicy) error { + identity, err := trustIdentity(policy) + if err != nil || policy.Verifier == nil { + return fmt.Errorf("%w: sigstore trust policy is incomplete or ambiguous", ErrUnverifiedArtifact) + } + if _, revoked := policy.RevokedIdentities[identity]; revoked { + return fmt.Errorf("%w: signer identity is revoked", ErrUnverifiedArtifact) + } + required := policy.RequiredAttestations[request.Kind] + if required == "" || evidence.Attestation.PredicateType != required || evidence.Attestation.SubjectDigest != request.Digest || len(evidence.Attestation.Statement) == 0 || len(evidence.Bundle) == 0 { + return fmt.Errorf("%w: required attestation is missing or does not match", ErrUnverifiedArtifact) + } + var statement struct { + PredicateType string `json:"predicateType"` + Subject []struct { + Digest map[string]string `json:"digest"` + } `json:"subject"` + Predicate struct { + BuildDefinition struct { + ResolvedDependencies []provenanceDependency `json:"resolvedDependencies"` + } `json:"buildDefinition"` + } `json:"predicate"` + } + if err := json.Unmarshal(evidence.Attestation.Statement, &statement); err != nil || statement.PredicateType != required { + return fmt.Errorf("%w: attestation statement is malformed or has the wrong predicate", ErrUnverifiedArtifact) + } + matched := false + for _, subject := range statement.Subject { + if "sha256:"+subject.Digest["sha256"] == request.Digest { + matched = true + break + } + } + if !matched { + return fmt.Errorf("%w: attestation subject digest does not match", ErrUnverifiedArtifact) + } + if err := verifyBroodResolution(statement.Predicate.BuildDefinition.ResolvedDependencies, request); err != nil { + return err + } + if err := policy.Verifier.Verify(ctx, evidence.Attestation.Statement, evidence.Bundle, identity, policy.OIDCIssuer); err != nil { + return fmt.Errorf("%w: Sigstore bundle: %v", ErrUnverifiedArtifact, err) + } + return nil +} + +func verifyBroodResolution(dependencies []provenanceDependency, request ArtifactRequest) error { + if request.Kind != ArtifactExecutionImage || request.ManifestDigest == "" { + return nil + } + for _, dependency := range dependencies { + if dependency.URI == request.DiscoveryReference && + "sha256:"+dependency.Digest["sha256"] == request.ManifestDigest && + "sha256:"+dependency.Digest["resolutionEvidence"] == request.ResolutionEvidence && + dependency.Platform == request.Platform { + return nil + } + } + return fmt.Errorf("%w: Brood discovery resolution does not match", ErrUnverifiedArtifact) +} + +func validDigest(value string) bool { + const prefix = "sha256:" + if !strings.HasPrefix(value, prefix) || len(value) != len(prefix)+sha256.Size*2 { + return false + } + decoded, err := hex.DecodeString(strings.TrimPrefix(value, prefix)) + return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value) +} + +func clonePolicy(policy TrustPolicy) TrustPolicy { + cloned := TrustPolicy{ + Revision: policy.Revision, + CertificateIdentity: policy.CertificateIdentity, + OIDCIssuer: policy.OIDCIssuer, + PublicKeyIdentity: policy.PublicKeyIdentity, + Verifier: policy.Verifier, + RequiredAttestations: make(map[ArtifactKind]string, len(policy.RequiredAttestations)), + RevokedIdentities: make(map[string]struct{}, len(policy.RevokedIdentities)), + } + for kind, predicate := range policy.RequiredAttestations { + cloned.RequiredAttestations[kind] = predicate + } + for identity := range policy.RevokedIdentities { + cloned.RevokedIdentities[identity] = struct{}{} + } + return cloned +} + +func copyTree(source, destination string) error { + info, err := os.Lstat(source) + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("artifact source must be a directory") + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return err + } + if err := os.Mkdir(destination, info.Mode().Perm()); err != nil { + return err + } + if err := os.Chmod(destination, info.Mode()); err != nil { + return err + } + destinationRoot, err := os.OpenRoot(destination) + if err != nil { + return err + } + defer func() { _ = destinationRoot.Close() }() + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + switch { + case entry.IsDir(): + if rel == "." { + return nil + } + if err := destinationRoot.MkdirAll(rel, info.Mode().Perm()); err != nil { + return err + } + return destinationRoot.Chmod(rel, info.Mode()) + case info.Mode()&os.ModeSymlink != 0: + targetName, err := os.Readlink(path) + if err != nil { + return err + } + if !containedSymlink(source, path, targetName) { + return fmt.Errorf("artifact symlink %q escapes its tree", rel) + } + return destinationRoot.Symlink(targetName, rel) + case info.Mode().IsRegular(): + return copyFileToRoot(destinationRoot, path, rel, info.Mode()) + default: + return fmt.Errorf("unsupported artifact entry %q", rel) + } + }) +} + +func containedSymlink(root, path, target string) bool { + if target == "" { + return false + } + var resolved string + if filepath.IsAbs(target) { + resolved = filepath.Join(root, strings.TrimLeftFunc(target, func(r rune) bool { return r == '/' || r == '\\' })) + } else { + resolved = filepath.Join(filepath.Dir(path), target) + } + rel, err := filepath.Rel(root, filepath.Clean(resolved)) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func copyFileToRoot(root *os.Root, source, destination string, mode fs.FileMode) error { + input, err := os.Open(source) + if err != nil { + return err + } + defer func() { _ = input.Close() }() + output, err := root.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + return err + } + if _, err := io.Copy(output, input); err != nil { + _ = output.Close() + return err + } + if err := output.Close(); err != nil { + return err + } + return root.Chmod(destination, mode) +} + +// ArtifactTreeDigest returns the canonical materialized-tree identity used by +// strict artifact admission and release provenance subjects. +func ArtifactTreeDigest(root string) (string, error) { return digestTree(root) } + +func digestTree(root string) (string, error) { + var paths []string + if err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + if path != root { + paths = append(paths, path) + } + return nil + }); err != nil { + return "", err + } + sort.Strings(paths) + h := sha256.New() + for _, path := range paths { + rel, err := filepath.Rel(root, path) + if err != nil { + return "", err + } + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if !info.IsDir() && !info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 { + return "", errors.New("cache payload contains unsupported entry") + } + writeHashField(h, filepath.ToSlash(rel)) + writeHashField(h, info.Mode().String()) + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err != nil { + return "", err + } + if !containedSymlink(root, path, target) { + return "", errors.New("cache payload symlink escapes its tree") + } + writeHashField(h, target) + } else if info.Mode().IsRegular() { + file, err := os.Open(path) + if err != nil { + return "", err + } + if _, err := io.Copy(h, file); err != nil { + _ = file.Close() + return "", err + } + if err := file.Close(); err != nil { + return "", err + } + } + } + return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil +} + +func writeHashField(h hash.Hash, value string) { + _, _ = h.Write([]byte(value)) + _, _ = h.Write([]byte{0}) +} + +func writeMetadata(path string, metadata cacheMetadata) error { + data, err := json.Marshal(metadata) + if err != nil { + return err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return err + } + if err := file.Sync(); err != nil { + _ = file.Close() + return err + } + return file.Close() +} + +func syncDir(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = dir.Close() }() + return dir.Sync() +} diff --git a/environment/microvm/artifact_sigstore.go b/environment/microvm/artifact_sigstore.go new file mode 100644 index 000000000..eaa2b00fb --- /dev/null +++ b/environment/microvm/artifact_sigstore.go @@ -0,0 +1,67 @@ +package microvm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + + "github.com/stacklok/toolhive-core/container/verifier" +) + +// SigstoreVerifier verifies stored Sigstore bundles in process. +type SigstoreVerifier struct { + publicKey []byte + publicKeyIdentity string +} + +// NewSigstoreVerifier creates an offline keyless verifier using ToolHive Core's +// embedded Sigstore trusted material. +func NewSigstoreVerifier() *SigstoreVerifier { + return &SigstoreVerifier{} +} + +// NewSigstoreKeyVerifier creates an offline verifier pinned to one public key. +// The key bytes are copied so later caller mutation cannot change the trust root. +func NewSigstoreKeyVerifier(publicKey []byte) (*SigstoreVerifier, error) { + if len(publicKey) == 0 { + return nil, errors.New("sigstore public key is required") + } + key := append([]byte(nil), publicKey...) + return &SigstoreVerifier{publicKey: key, publicKeyIdentity: PublicKeyIdentity(key)}, nil +} + +// PublicKeyIdentity returns the stable identity operator policy binds to exact +// public-key bytes. +func PublicKeyIdentity(publicKey []byte) string { + sum := sha256.Sum256(publicKey) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// Verify checks that bundle signs statement under the configured exact keyless +// certificate identity or public-key identity. +func (v *SigstoreVerifier) Verify(ctx context.Context, statement, bundle []byte, identity, issuer string) error { + if err := context.Cause(ctx); err != nil { + return err + } + if v == nil || identity == "" || len(statement) == 0 || len(bundle) == 0 { + return errors.New("incomplete Sigstore verification input") + } + digest := sha256.Sum256(statement) + subject := "sha256:" + hex.EncodeToString(digest[:]) + if len(v.publicKey) > 0 { + if issuer != "" || identity != v.publicKeyIdentity { + return errors.New("sigstore public-key identity mismatch") + } + _, err := verifier.VerifyBundleOfflineWithKey(bundle, subject, v.publicKey) + return err + } + if issuer == "" { + return errors.New("incomplete Sigstore keyless identity policy") + } + _, err := verifier.VerifyBundleOffline(bundle, subject, &verifier.Identity{ + SignerIdentity: identity, + CertIssuer: issuer, + }) + return err +} diff --git a/environment/microvm/artifact_test.go b/environment/microvm/artifact_test.go new file mode 100644 index 000000000..df07773b1 --- /dev/null +++ b/environment/microvm/artifact_test.go @@ -0,0 +1,563 @@ +package microvm + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/stacklok/go-microvm/extract" +) + +func TestInvariant_artifact_observer_counts_only_attempted_verifications(t *testing.T) { + t.Parallel() + root := t.TempDir() + sources := filepath.Join(root, "sources") + if err := os.Mkdir(sources, 0o700); err != nil { + t.Fatal(err) + } + public, private := testKey() + requests, resolver := testArtifactSet(t, sources, private, "builder@example.com") + resolved := resolver.artifacts[ArtifactRuntime] + resolved.Evidence.Bundle[0] ^= 0xff + resolver.artifacts[ArtifactRuntime] = resolved + observer := NewOperationsObserver(nil) + provisioner := NewProvisioner(NewVerifiedCache(filepath.Join(root, "cache")), resolver, testPolicy("policy-v1", public), nil, nil, observer) + + if _, _, err := provisioner.Verify(context.Background(), requests); err == nil { + t.Fatal("corrupt runtime evidence unexpectedly verified") + } + snapshot := observer.Snapshot() + if snapshot.ArtifactVerifications[ArtifactRuntime][OutcomeFailure] != 1 || snapshot.ArtifactVerifications[ArtifactFirmware][OutcomeFailure] != 0 || snapshot.ArtifactVerifications[ArtifactExecutionImage][OutcomeFailure] != 0 { + t.Fatalf("artifact verification counters are not attempt-truthful: %+v", snapshot.ArtifactVerifications) + } +} + +func TestMicroVMEnvironments_Scenario2_VerifiedArtifactsBoot(t *testing.T) { + t.Parallel() + ctx := context.Background() + root := t.TempDir() + pub, private := testKey() + policy := testPolicy("policy-7", pub) + requests, resolver := testArtifactSet(t, root, private, "builder@example.com") + launcher := &recordingLauncher{} + metadata := &memoryMetadataStore{} + provisioner := NewProvisioner(NewVerifiedCache(filepath.Join(root, "cache")), resolver, policy, launcher, metadata) + + got, err := provisioner.Provision(ctx, "session-1", requests) + if err != nil { + t.Fatalf("Provision() error = %v", err) + } + if got.VMID != "vm-1" { + t.Fatalf("VMID = %q, want vm-1", got.VMID) + } + if got.PolicyRevision != policy.Revision { + t.Fatalf("PolicyRevision = %q, want %q", got.PolicyRevision, policy.Revision) + } + if len(got.Artifacts) != 4 { + t.Fatalf("artifact metadata count = %d, want 4", len(got.Artifacts)) + } + for _, kind := range []ArtifactKind{ArtifactRuntime, ArtifactFirmware, ArtifactExecutionImage, ArtifactGuestAgent} { + want := requests[kind].Digest + if got.Artifacts[kind] != want { + t.Errorf("metadata digest for %s = %q, want %q", kind, got.Artifacts[kind], want) + } + if launcher.artifacts.ByKind(kind).Digest != want { + t.Errorf("launched digest for %s = %q, want %q", kind, launcher.artifacts.ByKind(kind).Digest, want) + } + } + if !launcher.sourcesUsable.Load() { + t.Error("launcher did not receive usable private artifact sources") + } + persisted, ok := metadata.bySession["session-1"] + if !ok { + t.Fatal("durable metadata was not saved") + } + if persisted.PolicyRevision != policy.Revision { + t.Errorf("persisted policy revision = %q, want %q", persisted.PolicyRevision, policy.Revision) + } +} + +func TestInvariant_verified_artifact_launch_uses_private_snapshot(t *testing.T) { + t.Parallel() + ctx := context.Background() + root := t.TempDir() + pub, private := testKey() + policy := testPolicy("policy-7", pub) + requests, resolver := testArtifactSet(t, root, private, "builder@example.com") + provisioner := NewProvisioner(NewVerifiedCache(filepath.Join(root, "cache")), resolver, policy, nil, nil) + artifacts, _, err := provisioner.Verify(ctx, requests) + if err != nil { + t.Fatalf("Verify() error = %v", err) + } + + launchArtifacts, release, err := provisioner.LockAndValidate(ctx, artifacts) + if err != nil { + t.Fatalf("LockAndValidate() error = %v", err) + } + defer release() + if launchArtifacts.Runtime.Path == artifacts.Runtime.Path { + t.Fatal("launch runtime still names the mutable verified-cache entry") + } + original := filepath.Join(artifacts.Runtime.Path, "artifact.bin") + if err := os.WriteFile(original, []byte("same-account-post-validation-mutation"), 0o600); err != nil { + t.Fatalf("plant non-cooperating cache mutation: %v", err) + } + launched, err := os.ReadFile(filepath.Join(launchArtifacts.Runtime.Path, "artifact.bin")) + if err != nil { + t.Fatalf("read private launch snapshot: %v", err) + } + if string(launched) != "runtime-complete" { + t.Fatalf("launched runtime bytes = %q, want verified bytes", launched) + } +} + +func TestMicroVMEnvironments_Scenario2_UnverifiedArtifactsFailClosed(t *testing.T) { + t.Parallel() + ctx := context.Background() + pub, private := testKey() + + tests := []struct { + name string + mutate func(map[ArtifactKind]ArtifactRequest, *fakeResolver, *TrustPolicy) + }{ + {name: "mutable tag only", mutate: func(req map[ArtifactKind]ArtifactRequest, _ *fakeResolver, _ *TrustPolicy) { + r := req[ArtifactExecutionImage] + r.Digest = "" + r.Reference = "registry.example/app:latest" + req[ArtifactExecutionImage] = r + }}, + {name: "wrong digest", mutate: func(_ map[ArtifactKind]ArtifactRequest, resolver *fakeResolver, _ *TrustPolicy) { + r := resolver.artifacts[ArtifactRuntime] + r.Digest = digestFor("different") + resolver.artifacts[ArtifactRuntime] = r + }}, + {name: "unsigned", mutate: func(_ map[ArtifactKind]ArtifactRequest, resolver *fakeResolver, _ *TrustPolicy) { + r := resolver.artifacts[ArtifactFirmware] + r.Evidence.Bundle = nil + resolver.artifacts[ArtifactFirmware] = r + }}, + {name: "wrong signer", mutate: func(_ map[ArtifactKind]ArtifactRequest, _ *fakeResolver, policy *TrustPolicy) { + policy.CertificateIdentity = "https://github.com/attacker/repo/.github/workflows/release.yml@refs/tags/v1" + }}, + {name: "missing attestation", mutate: func(_ map[ArtifactKind]ArtifactRequest, resolver *fakeResolver, _ *TrustPolicy) { + r := resolver.artifacts[ArtifactExecutionImage] + r.Evidence.Attestation = Attestation{} + resolver.artifacts[ArtifactExecutionImage] = r + }}, + {name: "wrong attestation", mutate: func(_ map[ArtifactKind]ArtifactRequest, resolver *fakeResolver, _ *TrustPolicy) { + r := resolver.artifacts[ArtifactFirmware] + r.Evidence.Attestation.PredicateType = "https://example.com/wrong" + resolver.artifacts[ArtifactFirmware] = r + }}, + {name: "valid evidence with altered materialized bytes", mutate: func(_ map[ArtifactKind]ArtifactRequest, resolver *fakeResolver, _ *TrustPolicy) { + r := resolver.artifacts[ArtifactRuntime] + r.Source = staticSource{content: []byte("altered-after-signing")} + resolver.artifacts[ArtifactRuntime] = r + }}, + {name: "revoked identity", mutate: func(_ map[ArtifactKind]ArtifactRequest, _ *fakeResolver, policy *TrustPolicy) { + policy.RevokedIdentities = map[string]struct{}{policy.CertificateIdentity: {}} + }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + policy := testPolicy("policy-7", pub) + requests, resolver := testArtifactSet(t, root, private, "builder@example.com") + tc.mutate(requests, resolver, &policy) + launcher := &recordingLauncher{} + p := NewProvisioner(NewVerifiedCache(filepath.Join(root, "cache")), resolver, policy, launcher, &memoryMetadataStore{}) + if _, err := p.Provision(ctx, "session-bad", requests); err == nil { + t.Fatal("Provision() error = nil, want fail-closed rejection") + } + if launcher.calls.Load() != 0 { + t.Fatalf("launcher calls = %d, want 0", launcher.calls.Load()) + } + }) + } + + t.Run("corrupted cache entry", func(t *testing.T) { + root := t.TempDir() + policy := testPolicy("policy-7", pub) + requests, resolver := testArtifactSet(t, root, private, "builder@example.com") + cache := NewVerifiedCache(filepath.Join(root, "cache")) + firstLauncher := &recordingLauncher{} + p := NewProvisioner(cache, resolver, policy, firstLauncher, &memoryMetadataStore{}) + if _, err := p.Provision(ctx, "session-good", requests); err != nil { + t.Fatalf("initial Provision() error = %v", err) + } + verified, _, err := p.Verify(ctx, requests) + if err != nil { + t.Fatalf("load admitted artifacts: %v", err) + } + runtimePath := verified.Runtime.Path + runtimeFile := filepath.Join(runtimePath, "artifact.bin") + if err := os.Chmod(runtimeFile, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(runtimeFile, []byte("corrupt"), 0o600); err != nil { + t.Fatal(err) + } + secondLauncher := &recordingLauncher{} + p = NewProvisioner(cache, resolver, policy, secondLauncher, &memoryMetadataStore{}) + if _, err := p.Provision(ctx, "session-corrupt", requests); !errors.Is(err, ErrCorruptCacheEntry) { + t.Fatalf("Provision() error = %v, want ErrCorruptCacheEntry", err) + } + if secondLauncher.calls.Load() != 0 { + t.Fatalf("launcher calls = %d, want 0", secondLauncher.calls.Load()) + } + }) + + t.Run("mutated after verification before launch", func(t *testing.T) { + root := t.TempDir() + policy := testPolicy("policy-7", pub) + requests, resolver := testArtifactSet(t, root, private, "builder@example.com") + launcher := &recordingLauncher{} + p := NewProvisioner(NewVerifiedCache(filepath.Join(root, "cache")), resolver, policy, launcher, &memoryMetadataStore{}) + artifacts, _, err := p.Verify(ctx, requests) + if err != nil { + t.Fatalf("Verify() error = %v", err) + } + runtimeFile := filepath.Join(artifacts.Runtime.Path, "artifact.bin") + if err := os.WriteFile(runtimeFile, []byte("mutated-after-verify"), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := p.LockAndValidate(ctx, artifacts); !errors.Is(err, ErrCorruptCacheEntry) { + t.Fatalf("LockAndValidate() error = %v, want ErrCorruptCacheEntry", err) + } + if launcher.calls.Load() != 0 { + t.Fatalf("launcher calls = %d, want 0", launcher.calls.Load()) + } + }) + + t.Run("stale policy", func(t *testing.T) { + root := t.TempDir() + requests, resolver := testArtifactSet(t, root, private, "builder@example.com") + cache := NewVerifiedCache(filepath.Join(root, "cache")) + p := NewProvisioner(cache, resolver, testPolicy("policy-7", pub), &recordingLauncher{}, &memoryMetadataStore{}) + if _, err := p.Provision(ctx, "session-old", requests); err != nil { + t.Fatalf("initial Provision() error = %v", err) + } + launcher := &recordingLauncher{} + p = NewProvisioner(cache, resolver, testPolicy("policy-8", pub), launcher, &memoryMetadataStore{}) + if _, err := p.Provision(ctx, "session-stale", requests); !errors.Is(err, ErrStalePolicy) { + t.Fatalf("Provision() error = %v, want ErrStalePolicy", err) + } + if launcher.calls.Load() != 0 { + t.Fatalf("launcher calls = %d, want 0", launcher.calls.Load()) + } + }) +} + +func TestMicroVMEnvironments_Scenario2_ConcurrentCacheAdmissionIsAtomic(t *testing.T) { + t.Parallel() + ctx := context.Background() + root := t.TempDir() + pub, private := testKey() + policy := testPolicy("policy-7", pub) + requests, resolver := testArtifactSet(t, root, private, "builder@example.com") + started := make(chan struct{}) + release := make(chan struct{}) + blocking := &blockingSource{content: []byte("runtime-complete"), started: started, release: release} + runtime := resolver.artifacts[ArtifactRuntime] + runtime.Source = blocking + resolver.artifacts[ArtifactRuntime] = runtime + cache := NewVerifiedCache(filepath.Join(root, "cache")) + launcher := &recordingLauncher{} + p := NewProvisioner(cache, resolver, policy, launcher, &memoryMetadataStore{}) + + const sessions = 12 + errCh := make(chan error, sessions) + for i := 0; i < sessions; i++ { + go func() { + _, err := p.Provision(ctx, "concurrent", requests) + errCh <- err + }() + } + <-started + if launcher.calls.Load() != 0 { + t.Fatalf("launcher observed pre-verification artifact: calls = %d", launcher.calls.Load()) + } + close(release) + for range sessions { + if err := <-errCh; err != nil { + t.Fatalf("concurrent Provision() error = %v", err) + } + } + if blocking.calls.Load() != 1 { + t.Fatalf("cold runtime materializations = %d, want 1", blocking.calls.Load()) + } + if launcher.calls.Load() != sessions { + t.Fatalf("launcher calls = %d, want %d", launcher.calls.Load(), sessions) + } + if launcher.partial.Load() { + t.Fatal("a launcher observed partial artifact contents") + } +} + +type fakeResolver struct { + artifacts map[ArtifactKind]ResolvedArtifact +} + +func (r *fakeResolver) Resolve(_ context.Context, request ArtifactRequest) (ResolvedArtifact, error) { + artifact, ok := r.artifacts[request.Kind] + if !ok { + return ResolvedArtifact{}, errors.New("not found") + } + return artifact, nil +} + +type staticSource struct { + content []byte + name string +} + +func (s staticSource) Ensure(_ context.Context, cacheDir string) (string, error) { + dir, err := os.MkdirTemp(cacheDir, "source-") + if err != nil { + return "", err + } + name := s.name + if name == "" { + name = "artifact.bin" + } + if err := os.WriteFile(filepath.Join(dir, name), s.content, 0o700); err != nil { + return "", err + } + return dir, nil +} + +type blockingSource struct { + content []byte + started chan struct{} + release chan struct{} + calls atomic.Int32 + once sync.Once +} + +func (s *blockingSource) Ensure(ctx context.Context, cacheDir string) (string, error) { + s.calls.Add(1) + dir, err := os.MkdirTemp(cacheDir, "source-") + if err != nil { + return "", err + } + file, err := os.OpenFile(filepath.Join(dir, "artifact.bin"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700) + if err != nil { + return "", err + } + if _, err := file.Write(s.content[:len(s.content)/2]); err != nil { + return "", err + } + s.once.Do(func() { close(s.started) }) + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-s.release: + } + if _, err := file.Write(s.content[len(s.content)/2:]); err != nil { + return "", err + } + if err := file.Close(); err != nil { + return "", err + } + return dir, nil +} + +type recordingLauncher struct { + calls atomic.Int32 + partial atomic.Bool + sourcesUsable atomic.Bool + mu sync.Mutex + artifacts VerifiedArtifacts +} + +func (l *recordingLauncher) Launch(ctx context.Context, artifacts VerifiedArtifacts) (string, error) { + for _, artifact := range artifacts.All() { + name := "artifact.bin" + if artifact.Kind == ArtifactGuestAgent { + name = guestAgentArtifactName + } + content, err := os.ReadFile(filepath.Join(artifact.Path, name)) + if err != nil || len(content) == 0 || string(content) == "runtime-" { + l.partial.Store(true) + return "", errors.New("partial artifact") + } + if _, err := artifact.Source.Ensure(ctx, ""); err != nil { + return "", errors.New("unusable private artifact source") + } + } + l.sourcesUsable.Store(true) + l.calls.Add(1) + l.mu.Lock() + l.artifacts = artifacts + l.mu.Unlock() + return "vm-1", nil +} + +type memoryMetadataStore struct { + mu sync.Mutex + bySession map[string]EnvironmentMetadata +} + +func (s *memoryMetadataStore) Save(_ context.Context, metadata EnvironmentMetadata) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.bySession == nil { + s.bySession = make(map[string]EnvironmentMetadata) + } + s.bySession[metadata.SessionID] = metadata + return nil +} + +func TestArtifactVerificationAcceptsOnePinnedPublicKeyIdentity(t *testing.T) { + t.Parallel() + root := t.TempDir() + requests, resolver := testArtifactSet(t, root, nil, "") + identity := PublicKeyIdentity([]byte("public-key")) + policy := TrustPolicy{ + Revision: "key-policy-v1", + PublicKeyIdentity: identity, + Verifier: keyEvidenceVerifier{identity: identity}, + RequiredAttestations: map[ArtifactKind]string{ + ArtifactRuntime: "https://slsa.dev/provenance/v1", ArtifactFirmware: "https://slsa.dev/provenance/v1", + ArtifactExecutionImage: "https://slsa.dev/provenance/v1", ArtifactGuestAgent: "https://slsa.dev/provenance/v1", + }, + } + provisioner := NewProvisioner(NewVerifiedCache(filepath.Join(root, "cache")), resolver, policy, nil, nil) + if _, _, err := provisioner.Verify(context.Background(), requests); err != nil { + t.Fatalf("Verify(public-key policy): %v", err) + } + + policy.PublicKeyIdentity = PublicKeyIdentity([]byte("wrong-key")) + provisioner = NewProvisioner(NewVerifiedCache(filepath.Join(root, "wrong-cache")), resolver, policy, nil, nil) + if _, _, err := provisioner.Verify(context.Background(), requests); err == nil { + t.Fatal("Verify(wrong public-key identity) error = nil") + } + policy.CertificateIdentity = "ambiguous" + policy.OIDCIssuer = "issuer" + provisioner = NewProvisioner(NewVerifiedCache(filepath.Join(root, "ambiguous-cache")), resolver, policy, nil, nil) + if _, _, err := provisioner.Verify(context.Background(), requests); err == nil { + t.Fatal("Verify(ambiguous key and keyless policy) error = nil") + } +} + +func TestArtifactCacheAcceptsContainedSymlinksInExecutionTree(t *testing.T) { + root := t.TempDir() + if err := os.Chmod(root, 0o775); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "target"), []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(root, "target"), 0o664); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + digest, err := digestTree(root) + if err != nil { + t.Fatalf("digestTree: %v", err) + } + staged := filepath.Join(t.TempDir(), "staged") + if err := copyTree(root, staged); err != nil { + t.Fatalf("copyTree: %v", err) + } + got, err := digestTree(staged) + if err != nil { + t.Fatalf("digest staged tree: %v", err) + } + if got != digest { + t.Fatalf("staged digest = %q, want %q", got, digest) + } +} + +func testArtifactSet(t *testing.T, root string, _ ed25519.PrivateKey, _ string) (map[ArtifactKind]ArtifactRequest, *fakeResolver) { + t.Helper() + requests := make(map[ArtifactKind]ArtifactRequest) + artifacts := make(map[ArtifactKind]ResolvedArtifact) + for _, kind := range []ArtifactKind{ArtifactRuntime, ArtifactFirmware, ArtifactExecutionImage, ArtifactGuestAgent} { + content := []byte(string(kind) + "-complete") + source := staticSource{content: content} + if kind == ArtifactGuestAgent { + source.name = guestAgentArtifactName + } + materialized, err := source.Ensure(context.Background(), root) + if err != nil { + t.Fatal(err) + } + digest, err := digestTree(materialized) + if err != nil { + t.Fatal(err) + } + request := ArtifactRequest{Kind: kind, Reference: "registry.example/" + string(kind) + "@" + digest, Digest: digest} + attestation := Attestation{ + PredicateType: "https://slsa.dev/provenance/v1", + SubjectDigest: digest, + Statement: []byte(`{"_type":"https://in-toto.io/Statement/v1","subject":[{"digest":{"sha256":"` + strings.TrimPrefix(digest, "sha256:") + `"}}],"predicateType":"https://slsa.dev/provenance/v1"}`), + } + requests[kind] = request + artifacts[kind] = ResolvedArtifact{ + Kind: kind, Digest: digest, Source: source, + Evidence: VerificationEvidence{Bundle: []byte("sigstore-bundle"), Attestation: attestation}, + } + } + return requests, &fakeResolver{artifacts: artifacts} +} + +func testPolicy(revision string, _ ed25519.PublicKey) TrustPolicy { + return TrustPolicy{ + Revision: revision, + CertificateIdentity: "https://github.com/stacklok/mecatl/.github/workflows/release.yml@refs/tags/v1.0.0", + OIDCIssuer: "https://token.actions.githubusercontent.com", + Verifier: testEvidenceVerifier{}, + RequiredAttestations: map[ArtifactKind]string{ + ArtifactRuntime: "https://slsa.dev/provenance/v1", ArtifactFirmware: "https://slsa.dev/provenance/v1", + ArtifactExecutionImage: "https://slsa.dev/provenance/v1", ArtifactGuestAgent: "https://slsa.dev/provenance/v1", + }, + } +} + +type keyEvidenceVerifier struct{ identity string } + +func (v keyEvidenceVerifier) Verify(_ context.Context, statement, bundle []byte, identity, issuer string) error { + if len(statement) == 0 || string(bundle) != "sigstore-bundle" || identity != v.identity || issuer != "" { + return errors.New("bundle or public-key identity rejected") + } + return nil +} + +type testEvidenceVerifier struct{} + +func (testEvidenceVerifier) Verify(_ context.Context, statement, bundle []byte, identity, issuer string) error { + if len(statement) == 0 || string(bundle) != "sigstore-bundle" || identity != "https://github.com/stacklok/mecatl/.github/workflows/release.yml@refs/tags/v1.0.0" || issuer != "https://token.actions.githubusercontent.com" { + return errors.New("bundle or identity rejected") + } + return nil +} + +func testKey() (ed25519.PublicKey, ed25519.PrivateKey) { return testKeyFromByte(3) } + +func testKeyFromByte(b byte) (ed25519.PublicKey, ed25519.PrivateKey) { + seed := make([]byte, ed25519.SeedSize) + for i := range seed { + seed[i] = b + } + private := ed25519.NewKeyFromSeed(seed) + return private.Public().(ed25519.PublicKey), private +} + +func digestFor(value string) string { + sum := sha256.Sum256([]byte(value)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +var _ extract.Source = staticSource{} diff --git a/environment/microvm/brood_admission_test.go b/environment/microvm/brood_admission_test.go new file mode 100644 index 000000000..385d4a00f --- /dev/null +++ b/environment/microvm/brood_admission_test.go @@ -0,0 +1,188 @@ +package microvm + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMicroVMRedesign_Scenario4_BroodLatestIsDiscoveryOnly(t *testing.T) { + t.Parallel() + + release, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "release.yml")) + if err != nil { + t.Fatal(err) + } + text := string(release) + if !strings.Contains(text, "BROOD_DISCOVERY_REFERENCE: ghcr.io/stacklok/brood-box/base:latest") { + t.Fatal("release workflow does not name Brood latest as its controlled discovery reference") + } + for _, obsolete := range []string{"publish-brood-base:", "publish-guest-tools:", "environment/microvm/images/guest-tools"} { + if strings.Contains(text, obsolete) { + t.Fatalf("release workflow still contains obsolete Brood rebuild/derived publication %q", obsolete) + } + } +} + +func TestMicroVMRedesign_Scenario4_RuntimeConsumesOnlyEndorsedBroodDigest(t *testing.T) { + t.Parallel() + root := t.TempDir() + sources := filepath.Join(root, "sources") + if err := os.Mkdir(sources, 0o700); err != nil { + t.Fatal(err) + } + requests, resolver := testArtifactSet(t, sources, nil, "") + execution := requests[ArtifactExecutionImage] + execution.ManifestDigest = "sha256:" + strings.Repeat("c", 64) + execution.Reference = "ghcr.io/stacklok/brood-box/base@" + execution.ManifestDigest + execution.DiscoveryReference = "ghcr.io/stacklok/brood-box/base:latest" + execution.Platform = "linux/amd64" + execution.ResolutionEvidence = "sha256:" + strings.Repeat("d", 64) + requests[ArtifactExecutionImage] = execution + + resolved := resolver.artifacts[ArtifactExecutionImage] + resolved.ManifestDigest = execution.ManifestDigest + resolved.Evidence.Attestation.Statement = broodEndorsement(execution) + resolver.artifacts[ArtifactExecutionImage] = resolved + + policy := testPolicy("brood-policy-v1", nil) + verify := func(cache string, candidate map[ArtifactKind]ArtifactRequest, candidateResolver *fakeResolver, candidatePolicy TrustPolicy) error { + _, _, err := NewProvisioner(NewVerifiedCache(filepath.Join(root, cache)), candidateResolver, candidatePolicy, nil, nil).Verify(context.Background(), candidate) + return err + } + provision := func(cache string, candidate map[ArtifactKind]ArtifactRequest, candidateResolver *fakeResolver, candidatePolicy TrustPolicy) (int32, error) { + launcher := &recordingLauncher{} + _, err := NewProvisioner(NewVerifiedCache(filepath.Join(root, cache)), candidateResolver, candidatePolicy, launcher, &memoryMetadataStore{}).Provision(context.Background(), "session", candidate) + return launcher.calls.Load(), err + } + launcher := &recordingLauncher{} + if _, err := NewProvisioner(NewVerifiedCache(filepath.Join(root, "valid")), resolver, policy, launcher, &memoryMetadataStore{}).Provision(context.Background(), "valid-session", requests); err != nil { + t.Fatalf("provision endorsed Brood platform bytes: %v", err) + } + if launcher.calls.Load() != 1 || launcher.artifacts.ExecutionImage.ManifestDigest != execution.ManifestDigest { + t.Fatalf("launcher consumed %+v, want admitted Brood manifest %s exactly once", launcher.artifacts.ExecutionImage, execution.ManifestDigest) + } + + cases := map[string]func(map[ArtifactKind]ArtifactRequest, *fakeResolver, *TrustPolicy){ + "changed-resolution": func(candidate map[ArtifactKind]ArtifactRequest, _ *fakeResolver, _ *TrustPolicy) { + entry := candidate[ArtifactExecutionImage] + entry.ManifestDigest = "sha256:" + strings.Repeat("e", 64) + entry.Reference = "ghcr.io/stacklok/brood-box/base@" + entry.ManifestDigest + candidate[ArtifactExecutionImage] = entry + }, + "wrong-platform": func(candidate map[ArtifactKind]ArtifactRequest, _ *fakeResolver, _ *TrustPolicy) { + entry := candidate[ArtifactExecutionImage] + entry.Platform = "linux/arm64" + candidate[ArtifactExecutionImage] = entry + }, + "missing-endorsement": func(_ map[ArtifactKind]ArtifactRequest, candidateResolver *fakeResolver, _ *TrustPolicy) { + entry := candidateResolver.artifacts[ArtifactExecutionImage] + entry.Evidence.Bundle = nil + candidateResolver.artifacts[ArtifactExecutionImage] = entry + }, + "corrupted-subject": func(_ map[ArtifactKind]ArtifactRequest, candidateResolver *fakeResolver, _ *TrustPolicy) { + entry := candidateResolver.artifacts[ArtifactExecutionImage] + entry.Evidence.Attestation.Statement = []byte(`{"predicateType":"https://slsa.dev/provenance/v1","subject":[{"digest":{"sha256":"bad"}}]}`) + candidateResolver.artifacts[ArtifactExecutionImage] = entry + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + candidate, candidateResolver := cloneArtifactFixture(requests, resolver) + candidatePolicy := policy + mutate(candidate, candidateResolver, &candidatePolicy) + if launches, err := provision(name, candidate, candidateResolver, candidatePolicy); err == nil { + t.Fatal("verification error = nil, want rejection before launch") + } else if launches != 0 { + t.Fatalf("VM launches = %d, want 0 after rejected admission", launches) + } + }) + } + + if err := verify("stale", requests, resolver, policy); err != nil { + t.Fatal(err) + } + stale := policy + stale.Revision = "brood-policy-v2" + if launches, err := provision("stale", requests, resolver, stale); !errors.Is(err, ErrStalePolicy) { + t.Fatalf("stale policy error = %v, want ErrStalePolicy", err) + } else if launches != 0 { + t.Fatalf("VM launches = %d, want 0 under stale policy", launches) + } +} + +func TestMicroVMRedesign_Scenario4_SigstoreVerificationIsInProcess(t *testing.T) { + t.Parallel() + statement, err := os.ReadFile("testdata/sigstore/statement.json") + if err != nil { + t.Fatal(err) + } + bundle, err := os.ReadFile("testdata/sigstore/bundle.json") + if err != nil { + t.Fatal(err) + } + publicKey, err := os.ReadFile("testdata/sigstore/public.pem") + if err != nil { + t.Fatal(err) + } + verifier, err := NewSigstoreKeyVerifier(publicKey) + if err != nil { + t.Fatal(err) + } + identity := PublicKeyIdentity(publicKey) + if err := verifier.Verify(context.Background(), statement, bundle, identity, ""); err != nil { + t.Fatalf("verify real cosign-format bundle in process: %v", err) + } + if err := verifier.Verify(context.Background(), append(statement, ' '), bundle, identity, ""); err == nil { + t.Fatal("altered statement verification error = nil") + } + + source, err := os.ReadFile("artifact_sigstore.go") + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{`"os/exec"`, "exec.Command", "MkdirTemp", "cosign path", "workDir"} { + if strings.Contains(string(source), forbidden) { + t.Fatalf("runtime verifier retains subprocess/temp-file protocol %q", forbidden) + } + } + daemon, err := os.ReadFile(filepath.Join("cmd", "mecatl-microvmd", "main.go")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(daemon)), "cosign_path") { + t.Fatal("production daemon configuration still exposes cosign_path") + } + for _, path := range []string{ + filepath.Join("..", "..", "internal", "adapter", "microvmmanager", "manager.go"), + filepath.Join("..", "..", "internal", "adapter", "microvmmanager", "bootstrap.go"), + } { + production, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + lower := strings.ToLower(string(production)) + if strings.Contains(lower, "cosignpath") || strings.Contains(lower, "cosign_path") || strings.Contains(lower, "lookpath(\"cosign\")") { + t.Fatalf("production configuration %s retains an ambient cosign dependency", path) + } + } +} + +func broodEndorsement(request ArtifactRequest) []byte { + return []byte(`{"_type":"https://in-toto.io/Statement/v1","subject":[{"digest":{"sha256":"` + strings.TrimPrefix(request.Digest, "sha256:") + `"}}],"predicateType":"https://slsa.dev/provenance/v1","predicate":{"buildDefinition":{"resolvedDependencies":[{"uri":"` + request.DiscoveryReference + `","digest":{"sha256":"` + strings.TrimPrefix(request.ManifestDigest, "sha256:") + `","resolutionEvidence":"` + strings.TrimPrefix(request.ResolutionEvidence, "sha256:") + `"},"platform":"` + request.Platform + `"}]}}}`) +} + +func cloneArtifactFixture(requests map[ArtifactKind]ArtifactRequest, resolver *fakeResolver) (map[ArtifactKind]ArtifactRequest, *fakeResolver) { + clonedRequests := make(map[ArtifactKind]ArtifactRequest, len(requests)) + for kind, request := range requests { + clonedRequests[kind] = request + } + clonedArtifacts := make(map[ArtifactKind]ResolvedArtifact, len(resolver.artifacts)) + for kind, artifact := range resolver.artifacts { + clonedArtifacts[kind] = artifact + } + return clonedRequests, &fakeResolver{artifacts: clonedArtifacts} +} diff --git a/environment/microvm/cmd/mecatl-artifact-digest/main.go b/environment/microvm/cmd/mecatl-artifact-digest/main.go new file mode 100644 index 000000000..7e0ffb182 --- /dev/null +++ b/environment/microvm/cmd/mecatl-artifact-digest/main.go @@ -0,0 +1,22 @@ +// Command mecatl-artifact-digest prints the canonical strict-admission tree digest. +package main + +import ( + "fmt" + "os" + + microvm "github.com/stacklok/mecatl/environment/microvm" +) + +func main() { + if len(os.Args) != 2 { + _, _ = fmt.Fprintln(os.Stderr, "usage: mecatl-artifact-digest TREE") + os.Exit(2) + } + digest, err := microvm.ArtifactTreeDigest(os.Args[1]) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + _, _ = fmt.Fprintln(os.Stdout, digest) +} diff --git a/environment/microvm/cmd/mecatl-guest-agent/main.go b/environment/microvm/cmd/mecatl-guest-agent/main.go new file mode 100644 index 000000000..a38649695 --- /dev/null +++ b/environment/microvm/cmd/mecatl-guest-agent/main.go @@ -0,0 +1,258 @@ +// Command mecatl-guest-agent serves the unified Workspace and exec protocol inside the guest. +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/stacklok/mecatl/environment/microvm" + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/guestagent" + "github.com/stacklok/mecatl/environment/microvm/guestexec" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +type repositoryBootConfig struct { + Owner, RepositoryKey, VMID, Endpoint string + Generation uint32 +} + +type bootConfig struct { + microvm.GuestPrebootConfig + CapabilityKey string `json:"capability_key"` + Repository *repositoryBootConfig `json:"repository,omitempty"` +} + +var errCapabilityMaterialWorkloadOwned = errors.New("guest capability material is owned by the workload mapping") + +func main() { + configPath := flag.String("config", microvm.GuestPrebootConfigPath, "immutable guest preboot configuration") + flag.Parse() + if err := run(*configPath); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(configPath string) error { //nolint:gocyclo // explicit legacy/repository guest boot state machine + cfg, err := loadConfig(configPath) + if err != nil { + return err + } + if err := hardenPrivilegedConfig(configPath, rootlessSafeChown, os.Chmod); err != nil { + if !errors.Is(err, errCapabilityMaterialWorkloadOwned) { + return fmt.Errorf("harden guest capability material: %w", err) + } + if err := consumePrivilegedConfig(configPath); err != nil { + return fmt.Errorf("consume guest capability material: %w", err) + } + } + if cfg.Repository != nil { + if err := prepareRepositoryGuestMount(); err != nil { + return fmt.Errorf("mount repository guest namespace: %w", err) + } + } else if err := prepareGuestMounts(); err != nil { + return fmt.Errorf("mount guest execution environment: %w", err) + } + if err := prepareGuestNetwork(); err != nil { + return fmt.Errorf("configure guest network: %w", err) + } + if cfg.DisableIPv6 { + if err := microvm.NewSysctlGuestNetwork(writeSysctl).DisableIPv6(context.Background()); err != nil { + return err + } + } + key, err := base64.RawStdEncoding.DecodeString(cfg.CapabilityKey) + if err != nil { + return fmt.Errorf("decode guest capability key: %w", err) + } + var serve func(context.Context, io.ReadWriteCloser) error + if cfg.Repository != nil { + repositoryServer, serverErr := guestagent.NewRepositoryServer(guestagent.RepositoryServerConfig{ + Owner: cfg.Repository.Owner, RepositoryKey: cfg.Repository.RepositoryKey, VMID: cfg.Repository.VMID, Endpoint: cfg.Repository.Endpoint, + Generation: cfg.Repository.Generation, AuthorityKey: key, ExecLimits: guestexec.Limits{MaxFrameBytes: cfg.MaxMessageBytes}, Shell: "/bin/sh", + WorkloadIdentity: guestexec.DefaultWorkloadIdentity(), RuntimeContract: guestexec.DefaultRuntimeContract(), + }) + err = serverErr + if repositoryServer != nil { + serve = repositoryServer.ServeAuthenticated + } + } else { + var server *guestagent.Server + server, err = guestagent.NewServer(guestagent.ServerConfig{ + Binding: cfg.Binding, CapabilityKey: key, WorkspaceRoot: worktree.GuestWorkspace, + ExecLimits: guestexec.Limits{MaxFrameBytes: cfg.MaxMessageBytes}, Shell: "/bin/sh", + WorkloadIdentity: guestexec.DefaultWorkloadIdentity(), RuntimeContract: guestexec.DefaultRuntimeContract(), + }) + if server != nil { + serve = server.Serve + } + } + for i := range key { + key[i] = 0 + } + if err != nil { + return err + } + if err := lockWorkloadPrivileges(); err != nil { + return fmt.Errorf("lock guest workload privilege escalation: %w", err) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + dial := func(ctx context.Context) (io.ReadWriteCloser, error) { + return guestagent.DialHostVsock(ctx, control.GuestControlPort) + } + if cfg.Repository != nil { + return serveRepositoryConnections(ctx, dial, serve) + } + stream, err := dial(ctx) + if err != nil { + return err + } + defer func() { _ = stream.Close() }() + return serve(ctx, stream) +} + +// Keep one connection beyond the persistent logical-data capacity so health, +// register, and unregister control exchanges cannot be starved by attachments. +const maxRepositoryConnections = 17 + +func serveRepositoryConnections(ctx context.Context, dial func(context.Context) (io.ReadWriteCloser, error), serve func(context.Context, io.ReadWriteCloser) error) error { + slots := make(chan struct{}, maxRepositoryConnections) + for { + select { + case slots <- struct{}{}: + case <-ctx.Done(): + return ctx.Err() + } + stream, err := dial(ctx) + if err != nil { + <-slots + if ctx.Err() != nil { + return ctx.Err() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + continue + } + } + go func() { + stopClose := context.AfterFunc(ctx, func() { _ = stream.Close() }) + _ = serve(ctx, stream) + stopClose() + _ = stream.Close() + <-slots + }() + } +} + +func rootlessSafeChown(path string, uid, gid int) error { + return rootlessSafeChownWith(path, uid, gid, os.Chown, os.Lstat) +} + +func rootlessSafeChownWith(path string, uid, gid int, chown func(string, int, int) error, lstat func(string) (os.FileInfo, error)) error { + err := chown(path, uid, gid) + if err == nil || (!errors.Is(err, syscall.EPERM) && !errors.Is(err, syscall.EINVAL)) { + return err + } + info, statErr := lstat(path) + if statErr != nil { + return statErr + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return err + } + if stat.Uid == guestexec.DefaultWorkloadIdentity().UID || stat.Gid == guestexec.DefaultWorkloadIdentity().GID { + return errCapabilityMaterialWorkloadOwned + } + // A single-ID user namespace reports EINVAL for unmapped guest root; older + // rootless libkrun paths report EPERM. In both cases preserve the mapped host + // owner only when it is not the workload identity. Modes 0700/0600 then keep + // the capability outside workload UID/GID 65532. + return nil +} + +func consumePrivilegedConfig(path string) error { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("guest capability config is not a regular file") + } + parent := filepath.Dir(path) + entries, err := os.ReadDir(parent) + if err != nil || len(entries) != 1 || entries[0].Name() != filepath.Base(path) { + return errors.New("guest capability config parent contains unexpected entries") + } + if err := os.Remove(path); err != nil { + return err + } + return os.Remove(parent) +} + +func hardenPrivilegedConfig(path string, chown func(string, int, int) error, chmod func(string, os.FileMode) error) error { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("guest capability config is not a regular file") + } + parent := filepath.Dir(path) + parentInfo, err := os.Lstat(parent) + if err != nil || !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 { + return errors.New("guest capability config parent is not a directory") + } + if err := chown(parent, 0, 0); err != nil { + return err + } + if err := chmod(parent, 0o700); err != nil { + return err + } + if err := chown(path, 0, 0); err != nil { + return err + } + return chmod(path, 0o600) +} + +func loadConfig(path string) (bootConfig, error) { + file, err := os.Open(path) // #nosec G304 -- root-owned immutable path selected by the image entrypoint/operator. + if err != nil { + return bootConfig{}, fmt.Errorf("open guest preboot config: %w", err) + } + defer func() { _ = file.Close() }() + var cfg bootConfig + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&cfg); err != nil { + return bootConfig{}, fmt.Errorf("decode guest preboot config: %w", err) + } + if cfg.CapabilityKey == "" || cfg.MaxMessageBytes == 0 || (cfg.Repository == nil && cfg.Binding.Ref == "") || + (cfg.Repository != nil && (cfg.Repository.Owner == "" || cfg.Repository.RepositoryKey == "" || cfg.Repository.VMID == "" || cfg.Repository.Endpoint == "" || cfg.Repository.Generation == 0)) { + return bootConfig{}, errors.New("guest preboot config is incomplete") + } + return cfg, nil +} + +func writeSysctl(key, value string) error { + path := filepath.Join("/proc/sys", filepath.FromSlash(keyToPath(key))) + return os.WriteFile(path, []byte(value), 0o600) +} + +func keyToPath(key string) string { + result := []byte(key) + for i := range result { + if result[i] == '.' { + result[i] = '/' + } + } + return string(result) +} diff --git a/environment/microvm/cmd/mecatl-guest-agent/main_test.go b/environment/microvm/cmd/mecatl-guest-agent/main_test.go new file mode 100644 index 000000000..53f8efd89 --- /dev/null +++ b/environment/microvm/cmd/mecatl-guest-agent/main_test.go @@ -0,0 +1,265 @@ +//go:build linux + +package main + +import ( + "context" + "errors" + "io" + "net" + "os" + "path/filepath" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/guestagent" + "github.com/stacklok/mecatl/environment/microvm/guestexec" +) + +func TestRepositoryGuestServesConcurrentLogicalConnections(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + started := make(chan struct{}) + var active atomic.Int32 + dial := func(context.Context) (io.ReadWriteCloser, error) { + guest, host := net.Pipe() + _ = host.Close() + return guest, nil + } + serve := func(ctx context.Context, _ io.ReadWriteCloser) error { + if active.Add(1) == 2 { + close(started) + } + <-ctx.Done() + return ctx.Err() + } + done := make(chan error, 1) + go func() { done <- serveRepositoryConnections(ctx, dial, serve) }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("repository guest serialized logical connections") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("serveRepositoryConnections() = %v", err) + } + case <-time.After(time.Second): + t.Fatal("repository connection accept loop did not stop") + } +} + +func TestRepositoryGuestWaitsForDelayedHostChallenge(t *testing.T) { + const ( + owner = "operator" + repositoryKey = "repository" + vmID = "vm-1" + generation = 7 + endpoint = "/guest.sock" + ) + key := []byte("0123456789abcdef0123456789abcdef") + identity := guestexec.WorkloadIdentity{UID: uint32(os.Geteuid()), GID: uint32(os.Getegid())} + runtimeContract := guestexec.DefaultRuntimeContract() + runtimeContract.Identity = identity + server, err := guestagent.NewRepositoryServer(guestagent.RepositoryServerConfig{ + Owner: owner, RepositoryKey: repositoryKey, VMID: vmID, Endpoint: endpoint, Generation: generation, + AuthorityKey: key, WorkloadIdentity: identity, RuntimeContract: runtimeContract, + }) + if err != nil { + t.Fatal(err) + } + defer server.Close() + issuer, err := control.NewCapabilityIssuer(key) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(t.Context()) + connections := make(chan io.ReadWriteCloser, maxRepositoryConnections) + dial := func(ctx context.Context) (io.ReadWriteCloser, error) { + guest, host := net.Pipe() + select { + case connections <- host: + return guest, nil + case <-ctx.Done(): + _ = guest.Close() + _ = host.Close() + return nil, ctx.Err() + } + } + done := make(chan error, 1) + go func() { done <- serveRepositoryConnections(ctx, dial, server.ServeAuthenticated) }() + defer func() { + cancel() + select { + case loopErr := <-done: + if !errors.Is(loopErr, context.Canceled) { + t.Errorf("serveRepositoryConnections() = %v", loopErr) + } + case <-time.After(time.Second): + t.Error("repository connection loop did not stop") + } + }() + + // The live host can take longer than the host-side unauthenticated-peer budget + // to accept a reverse-vsock connection queued by the guest. + time.Sleep(350 * time.Millisecond) + + nextConnection := func() io.ReadWriteCloser { + t.Helper() + select { + case stream := <-connections: + return stream + case <-time.After(time.Second): + t.Fatal("guest did not originate a repository connection") + return nil + } + } + controlExchange := func(request guestagent.RepositoryControlRequest) { + t.Helper() + stream := nextConnection() + defer stream.Close() + if err := guestagent.AuthenticateHostRepositoryChannel(ctx, stream, key, owner, repositoryKey, vmID, generation, guestagent.RepositoryChannelControl); err != nil { + t.Fatalf("authenticate control connection: %v", err) + } + codec := control.NewCodec(control.DefaultMaxMessageBytes) + if err := codec.Write(stream, request); err != nil { + t.Fatal(err) + } + var response guestagent.RepositoryControlResponse + if err := codec.Read(stream, &response); err != nil { + t.Fatal(err) + } + if response.ErrorCode != "" { + t.Fatalf("control response = %q", response.ErrorCode) + } + } + register := func(binding control.Binding) { + t.Helper() + capability, issueErr := issuer.Issue(binding) + if issueErr != nil { + t.Fatal(issueErr) + } + controlExchange(guestagent.RepositoryControlRequest{Operation: guestagent.RepositoryRegister, Binding: binding, Capability: capability}) + } + useData := func(binding control.Binding, want string) { + t.Helper() + capability, issueErr := issuer.Issue(binding) + if issueErr != nil { + t.Fatal(issueErr) + } + stream := nextConnection() + if authErr := guestagent.AuthenticateHostRepositoryChannel(ctx, stream, key, owner, repositoryKey, vmID, generation, guestagent.RepositoryChannelData); authErr != nil { + _ = stream.Close() + t.Fatalf("authenticate data connection: %v", authErr) + } + services, connectErr := guestagent.Connect(ctx, stream, binding, capability) + if connectErr != nil { + _ = stream.Close() + t.Fatalf("connect data services: %v", connectErr) + } + defer services.Close() + got, readErr := services.Workspace.Read(ctx, "value.txt") + if readErr != nil || string(got) != want { + t.Fatalf("data read = %q, %v; want %q", got, readErr, want) + } + } + newBinding := func(name string) control.Binding { + t.Helper() + root := filepath.Join(t.TempDir(), "worktree") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "value.txt"), []byte(name), 0o600); err != nil { + t.Fatal(err) + } + return control.Binding{Owner: owner, SessionID: "session-" + name, EnvironmentID: "logical-" + name, Ref: "logical-" + name, Generation: generation, AssignedRoot: root} + } + + first := newBinding("first") + register(first) + useData(first, "first") + controlExchange(guestagent.RepositoryControlRequest{Operation: guestagent.RepositoryHealth, Health: &guestagent.RepositoryHealthChallenge{ + Owner: owner, RepositoryKey: repositoryKey, VMID: vmID, Generation: generation, + Status: guestagent.RepositoryHealthStatus{Live: true, Generation: generation, VMID: vmID, PID: 42, ProcessIdentity: "boot", Endpoint: endpoint}, + }}) + second := newBinding("second") + register(second) + useData(second, "second") +} + +func TestRootlessSafeChownAcceptsUnmappedGuestRoot(t *testing.T) { + path := filepath.Join(t.TempDir(), "guest-agent.json") + if err := os.WriteFile(path, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + err := rootlessSafeChownWith(path, 0, 0, func(string, int, int) error { + return syscall.EINVAL + }, os.Lstat) + if err != nil { + t.Fatalf("unmapped guest-root chown: %v", err) + } +} + +func TestConsumePrivilegedConfigRemovesWorkloadOwnedMaterial(t *testing.T) { + parent := filepath.Join(t.TempDir(), "mecatl") + path := filepath.Join(parent, "guest-agent.json") + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + if err := consumePrivilegedConfig(path); err != nil { + t.Fatalf("consume privileged config: %v", err) + } + if _, err := os.Stat(parent); !os.IsNotExist(err) { + t.Fatalf("capability material remains reachable: %v", err) + } +} + +func TestInvariant_guest_capability_material_is_agent_only(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "mecatl", "guest-agent.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + + type ownership struct { + path string + uid, gid int + } + var owners []ownership + modes := make(map[string]os.FileMode) + err := hardenPrivilegedConfig(path, + func(path string, uid, gid int) error { + owners = append(owners, ownership{path: path, uid: uid, gid: gid}) + return nil + }, + func(path string, mode os.FileMode) error { + modes[path] = mode + return nil + }, + ) + if err != nil { + t.Fatalf("hardenPrivilegedConfig: %v", err) + } + if len(owners) != 2 { + t.Fatalf("ownership operations = %v, want config and parent", owners) + } + for _, owner := range owners { + if owner.uid != 0 || owner.gid != 0 { + t.Fatalf("privileged config ownership = %v, want guest root", owners) + } + } + if modes[path] != 0o600 || modes[filepath.Dir(path)] != 0o700 { + t.Fatalf("privileged config modes = %v, want file 0600 and parent 0700", modes) + } +} diff --git a/environment/microvm/cmd/mecatl-guest-agent/mounts_linux.go b/environment/microvm/cmd/mecatl-guest-agent/mounts_linux.go new file mode 100644 index 000000000..3144eaaeb --- /dev/null +++ b/environment/microvm/cmd/mecatl-guest-agent/mounts_linux.go @@ -0,0 +1,76 @@ +//go:build linux + +package main + +import ( + "fmt" + "log/slog" + "os" + "syscall" + "time" + + "github.com/stacklok/go-microvm/guest/netcfg" + "golang.org/x/sys/unix" + + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +func lockWorkloadPrivileges() error { + return unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) +} + +func prepareGuestNetwork() error { + return netcfg.Configure(slog.New(slog.NewTextHandler(os.Stderr, nil))) +} + +type guestMount struct { + path, tag string + readOnly bool +} + +func guestMounts() []guestMount { + return []guestMount{ + {path: worktree.GuestWorkspace, tag: "mecatl-workspace"}, + {path: worktree.GuestMetadata, tag: "mecatl-git-metadata"}, + {path: worktree.GuestObjectStore, tag: "mecatl-git-objects", readOnly: true}, + } +} + +func prepareGuestMounts() error { + return mountGuestFilesystems(guestMounts()) +} + +func repositoryGuestMounts() []guestMount { + return []guestMount{ + {path: "/run/mecatl/repositories", tag: "mecatl-repository-logical"}, + {path: worktree.GuestObjectStore, tag: "mecatl-git-objects", readOnly: true}, + } +} + +func prepareRepositoryGuestMount() error { + return mountGuestFilesystems(repositoryGuestMounts()) +} + +func mountGuestFilesystems(mounts []guestMount) error { + for _, mount := range mounts { + if err := os.MkdirAll(mount.path, 0o755); err != nil { + return err + } + flags := uintptr(syscall.MS_NOSUID | syscall.MS_NODEV) + if mount.readOnly { + flags |= syscall.MS_RDONLY + } + var err error + for range 20 { + err = syscall.Mount(mount.tag, mount.path, "virtiofs", flags, "") + if err == nil { + break + } + time.Sleep(500 * time.Millisecond) + } + if err != nil { + return fmt.Errorf("mount virtiofs tag %q at %s: %w", mount.tag, mount.path, err) + } + } + return nil +} diff --git a/environment/microvm/cmd/mecatl-guest-agent/mounts_linux_test.go b/environment/microvm/cmd/mecatl-guest-agent/mounts_linux_test.go new file mode 100644 index 000000000..7eb71db82 --- /dev/null +++ b/environment/microvm/cmd/mecatl-guest-agent/mounts_linux_test.go @@ -0,0 +1,31 @@ +//go:build linux + +package main + +import ( + "testing" + + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +func TestRepositoryGuestMountsSharedObjectsReadOnly(t *testing.T) { + mounts := repositoryGuestMounts() + if len(mounts) != 2 || mounts[0].path != "/run/mecatl/repositories" || mounts[0].tag != "mecatl-repository-logical" || mounts[0].readOnly || + mounts[1].path != worktree.GuestObjectStore || mounts[1].tag != "mecatl-git-objects" || !mounts[1].readOnly { + t.Fatalf("repository guest mounts = %+v", mounts) + } +} + +func TestADR_0224_GuestGitMetadataHasConfinedMount(t *testing.T) { + mounts := guestMounts() + for _, mount := range mounts { + if mount.tag != "mecatl-git-metadata" { + continue + } + if mount.path != worktree.GuestMetadata || mount.path == worktree.GuestWorkspace+"/.git" { + t.Fatalf("Git metadata mount = %+v, want separate guest-local mount %s", mount, worktree.GuestMetadata) + } + return + } + t.Fatal("guest mount plan has no Git metadata mount") +} diff --git a/environment/microvm/cmd/mecatl-guest-agent/mounts_other.go b/environment/microvm/cmd/mecatl-guest-agent/mounts_other.go new file mode 100644 index 000000000..1d4cf3f89 --- /dev/null +++ b/environment/microvm/cmd/mecatl-guest-agent/mounts_other.go @@ -0,0 +1,21 @@ +//go:build !linux + +package main + +import "errors" + +func lockWorkloadPrivileges() error { + return errors.New("microVM guest agent requires Linux") +} + +func prepareGuestNetwork() error { + return errors.New("microVM guest agent requires Linux") +} + +func prepareRepositoryGuestMount() error { + return errors.New("microVM guest agent requires Linux") +} + +func prepareGuestMounts() error { + return errors.New("microVM guest agent requires Linux") +} diff --git a/environment/microvm/cmd/mecatl-microvmd/main.go b/environment/microvm/cmd/mecatl-microvmd/main.go new file mode 100644 index 000000000..9b8c9a7ec --- /dev/null +++ b/environment/microvm/cmd/mecatl-microvmd/main.go @@ -0,0 +1,691 @@ +// Command mecatl-microvmd runs the local authenticated microVM lifecycle daemon. +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "syscall" + + "github.com/stacklok/go-microvm/extract" + + "github.com/stacklok/mecatl/engine/port" + "github.com/stacklok/mecatl/environment/microvm" + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +type daemonConfig struct { + ReleaseIdentity string `json:"release_identity"` + BinaryIdentity string `json:"binary_identity"` + PolicyRevision string `json:"policy_revision"` + ArtifactCache string `json:"artifact_cache,omitempty"` + RuntimeDir string `json:"runtime_dir,omitempty"` + CertificateIdentity string `json:"certificate_identity,omitempty"` + OIDCIssuer string `json:"oidc_issuer,omitempty"` + PublicKey string `json:"public_key,omitempty"` + PublicKeyIdentity string `json:"public_key_identity,omitempty"` + Attestations map[microvm.ArtifactKind]string `json:"required_attestations"` + Artifacts []artifactConfig `json:"artifacts"` + GuestEgress microvm.GuestEgressPolicy `json:"guest_egress"` + Admission microvm.AdmissionLimits `json:"admission"` + Profiles map[string]profileConfig `json:"profiles"` + loadedConfigDigest string +} + +type profileConfig struct { + Resources map[string]string `json:"resources"` +} + +type artifactConfig struct { + Kind microvm.ArtifactKind `json:"kind"` + Reference string `json:"reference"` + Digest string `json:"digest"` + ManifestDigest string `json:"manifest_digest,omitempty"` + DiscoveryReference string `json:"discovery_reference,omitempty"` + ResolutionEvidence string `json:"resolution_evidence,omitempty"` + Platform string `json:"platform,omitempty"` + Path string `json:"path,omitempty"` + Provenance string `json:"provenance"` + SigstoreBundle string `json:"sigstore_bundle"` +} + +func artifactRequest(config artifactConfig) microvm.ArtifactRequest { + return microvm.ArtifactRequest{ + Kind: config.Kind, Reference: config.Reference, Digest: config.Digest, ManifestDigest: config.ManifestDigest, + DiscoveryReference: config.DiscoveryReference, ResolutionEvidence: config.ResolutionEvidence, Platform: config.Platform, + } +} + +type configuredResolver struct { + local map[string]microvm.ResolvedArtifact + ociEvidence map[string]microvm.VerificationEvidence + oci *microvm.OCIExecutionImageResolver +} + +func (r *configuredResolver) Resolve(ctx context.Context, request microvm.ArtifactRequest) (microvm.ResolvedArtifact, error) { + if request.Kind == microvm.ArtifactExecutionImage && request.ManifestDigest != "" { + if r.oci == nil { + return microvm.ResolvedArtifact{}, errors.New("OCI execution-image cache is not configured") + } + return r.oci.Resolve(ctx, request) + } + artifact, ok := r.local[request.Reference] + if !ok || artifact.Kind != request.Kind || artifact.Digest != request.Digest { + return microvm.ResolvedArtifact{}, microvm.ErrDigestMismatch + } + return artifact, nil +} + +func (r *configuredResolver) configureOCI(cacheRoot string) { + r.oci = microvm.NewOCIExecutionImageResolver(cacheRoot, nil, r.ociEvidence) +} + +type stderrDiagnostics struct{} + +func (stderrDiagnostics) Log(_ context.Context, level port.Level, msg string, args ...any) { + _, _ = fmt.Fprintln(os.Stderr, append([]any{"microvmd", level, msg}, args...)...) +} +func (d stderrDiagnostics) With(...any) port.Diagnostics { return d } + +func main() { + stateDir := flag.String("state-dir", "", "private absolute daemon state directory") + socketPath := flag.String("socket", "", "private absolute lifecycle Unix socket") + configPath := flag.String("config", "", "operator-owned artifact and network configuration") + doctor := flag.Bool("doctor", false, "check microVM runtime readiness and exit") + flag.Parse() + var err error + if *doctor { + err = runDoctor(*stateDir, *socketPath, *configPath) + } else { + err = run(*stateDir, *socketPath, *configPath) + } + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func setDeterministicArtifactUmask() { + // OCI extraction must preserve image modes independently of the shell that + // launched this long-lived daemon. Private manager state is explicitly 0600/0700. + _ = syscall.Umask(0o022) +} + +func run(stateDir, socketPath, configPath string) error { //nolint:gocyclo // explicit fail-closed composition validation + setDeterministicArtifactUmask() + if !filepath.IsAbs(stateDir) || !filepath.IsAbs(socketPath) || !filepath.IsAbs(configPath) { + return errors.New("state-dir, socket, and config must be absolute") + } + _, _ = fmt.Fprintf(os.Stderr, "microvmd process identity uid=%d euid=%d gid=%d egid=%d\n", os.Getuid(), os.Geteuid(), os.Getgid(), os.Getegid()) + cfg, resolver, policy, err := loadDaemonConfig(configPath) + if err != nil { + return err + } + info, err := servingDaemonInfo(cfg, socketPath) + if err != nil { + return err + } + if cfg.BinaryIdentity != info.BinaryIdentity { + return errors.New("running microvmd binary does not match configured binary identity") + } + if err := os.MkdirAll(stateDir, 0o700); err != nil { + return err + } + uid := os.Getuid() + if uid < 0 || uint64(uid) > uint64(^uint32(0)) { + return errors.New("daemon account UID is outside the supported range") + } + controlService, err := control.NewService(control.ServiceConfig{AccountUID: uint32(uid)}) // #nosec G115 -- range checked above. + if err != nil { + return err + } + runtimeDir := cfg.RuntimeDir + if runtimeDir == "" { + runtimeDir = filepath.Join(stateDir, "vsock") + } else if !filepath.IsAbs(runtimeDir) { + return errors.New("runtime_dir must be absolute") + } + runtimeArtifactDir := filepath.Join(stateDir, "runtime-artifacts") + for _, dir := range []string{runtimeDir, runtimeArtifactDir} { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } + artifactRequests := make(map[microvm.ArtifactKind]microvm.ArtifactRequest, len(cfg.Artifacts)) + for _, artifact := range cfg.Artifacts { + artifactRequests[artifact.Kind] = artifactRequest(artifact) + } + artifactCache := cfg.ArtifactCache + if artifactCache == "" { + artifactCache = filepath.Join(stateDir, "artifacts") + } else if !filepath.IsAbs(artifactCache) { + return errors.New("artifact_cache must be absolute") + } + resolver.configureOCI(filepath.Join(artifactCache, "oci")) + observer := microvm.NewOperationsObserver(stderrDiagnostics{}) + artifactVerifier := microvm.NewProvisioner(microvm.NewVerifiedCache(artifactCache), resolver, policy, nil, nil, observer) + backend := microvm.NewLibkrunBackend(runtimeArtifactDir) + network := microvm.NewHostedBootNetworkController() + repositories, err := microvm.NewRepositoryComposition(filepath.Join(stateDir, "repositories"), microvm.RepositoryRuntimeConfig{ + Backend: backend, Network: network, GuestEgress: cfg.GuestEgress, UnixEndpoint: true, EndpointRoot: runtimeDir, + }) + if err != nil { + return err + } + repositoryProvisioner := func(ctx context.Context, request microvm.ProvisionRequest) (microvm.LogicalEnvironmentRequest, microvm.EnforcedProfileStatus, error) { + if request.Owner == "" || request.SessionID == "" || request.Profile == "" || request.SourceCheckout == "" { + return microvm.LogicalEnvironmentRequest{}, microvm.EnforcedProfileStatus{}, errors.New("incomplete microvm provision request") + } + profile, ok := cfg.Profiles[request.Profile] + if !ok { + return microvm.LogicalEnvironmentRequest{}, microvm.EnforcedProfileStatus{}, errors.New("unknown microvm environment profile") + } + if _, ok := artifactRequests[microvm.ArtifactExecutionImage]; !ok { + return microvm.LogicalEnvironmentRequest{}, microvm.EnforcedProfileStatus{}, errors.New("microvm daemon has no execution image") + } + if _, ok := artifactRequests[microvm.ArtifactGuestAgent]; !ok { + return microvm.LogicalEnvironmentRequest{}, microvm.EnforcedProfileStatus{}, errors.New("microvm daemon has no independently admitted guest agent") + } + if _, err := profileResourceUsage(profile.Resources); err != nil { + return microvm.LogicalEnvironmentRequest{}, microvm.EnforcedProfileStatus{}, err + } + verified, _, err := artifactVerifier.Verify(ctx, artifactRequests) + if err != nil { + return microvm.LogicalEnvironmentRequest{}, microvm.EnforcedProfileStatus{}, err + } + status := microvm.EnforcedProfileStatus{Profile: request.Profile, GuestEgress: cfg.GuestEgress.Status(), HostEgress: "not constrained: LLM providers, WebFetch, WebSearch, MCP, hooks, OCI pulls, telemetry"} + return microvm.LogicalEnvironmentRequest{Owner: request.Owner, Checkout: request.SourceCheckout, Verified: verified}, status, nil + } + root, err := microvm.NewRuntimeDaemon(microvm.RuntimeDaemonConfig{ + Control: controlService, Observer: observer, Info: info, + Repository: repositories, RepositoryProvisioner: repositoryProvisioner, + }) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil { + return err + } + if err := removeExistingSocket(socketPath); err != nil { + return err + } + listener, err := net.Listen("unix", socketPath) + if err != nil { + return err + } + defer func() { _ = listener.Close() }() + if err := os.Chmod(socketPath, 0o600); err != nil { + return err + } + defer func() { _ = os.Remove(socketPath) }() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + return root.Serve(ctx, listener) +} + +func newPlacementBuilder(resources *microvm.OpaqueIdentityAllocator, artifactRequests map[microvm.ArtifactKind]microvm.ArtifactRequest, profiles map[string]profileConfig, egress microvm.GuestEgressPolicy) microvm.PlacementBuilder { + return func(_ context.Context, request microvm.ProvisionRequest) (microvm.CreateRequest, error) { + if request.Owner == "" || request.SessionID == "" || request.Profile == "" || request.SourceCheckout == "" { + return microvm.CreateRequest{}, errors.New("incomplete microvm provision request") + } + profile, ok := profiles[request.Profile] + if !ok { + return microvm.CreateRequest{}, errors.New("unknown microvm environment profile") + } + if _, ok := artifactRequests[microvm.ArtifactExecutionImage]; !ok { + return microvm.CreateRequest{}, errors.New("microvm daemon has no execution image") + } + if _, ok := artifactRequests[microvm.ArtifactGuestAgent]; !ok { + return microvm.CreateRequest{}, errors.New("microvm daemon has no independently admitted guest agent") + } + names, err := resources.AllocateResources(request.SessionID, filepath.Base(request.SourceCheckout)) + if err != nil { + return microvm.CreateRequest{}, err + } + usage, err := profileResourceUsage(profile.Resources) + if err != nil { + return microvm.CreateRequest{}, err + } + return microvm.CreateRequest{Owner: request.Owner, SessionID: request.SessionID, Profile: request.Profile, + Worktree: worktree.Request{Source: request.SourceCheckout, WorktreePath: names.WorktreePath, MetadataPath: names.MetadataPath, Branch: names.Branch}, + ArtifactRequests: artifactRequests, Resources: usage, + ProfileStatus: microvm.EnforcedProfileStatus{Profile: request.Profile, GuestEgress: egress.Status(), HostEgress: "not constrained: LLM providers, WebFetch, WebSearch, MCP, hooks, OCI pulls, telemetry"}}, nil + } +} + +func profileResourceUsage(values map[string]string) (microvm.ResourceUsage, error) { + var usage microvm.ResourceUsage + var err error + if raw := values["cpus"]; raw != "" { + usage.CPU, err = strconv.ParseInt(raw, 10, 64) + if err != nil || usage.CPU <= 0 { + return usage, errors.New("invalid microvm CPU resource") + } + } + if raw := values["memory"]; raw != "" { + usage.RAMBytes, err = parseResourceBytes(raw) + if err != nil { + return usage, err + } + } + if raw := values["disk"]; raw != "" { + usage.DiskBytes, err = parseResourceBytes(raw) + if err != nil { + return usage, err + } + } + if raw := values["inodes"]; raw != "" { + usage.Inodes, err = strconv.ParseInt(raw, 10, 64) + if err != nil || usage.Inodes <= 0 { + return usage, errors.New("invalid microvm inode resource") + } + } + return usage, nil +} + +func parseResourceBytes(raw string) (int64, error) { + multipliers := map[string]int64{"": 1, "KiB": 1 << 10, "MiB": 1 << 20, "GiB": 1 << 30} + for suffix, multiplier := range multipliers { + if !strings.HasSuffix(raw, suffix) { + continue + } + number := strings.TrimSuffix(raw, suffix) + if number == "" { + continue + } + value, err := strconv.ParseInt(number, 10, 64) + if err == nil && value > 0 && value <= (1<<63-1)/multiplier { + return value * multiplier, nil + } + } + return 0, errors.New("invalid microvm byte resource") +} + +type doctorChecker struct { + stateDir string + socketPath string + cfg daemonConfig + artifacts microvm.ArtifactVerifier + requests map[microvm.ArtifactKind]microvm.ArtifactRequest + + artifactProbe func(context.Context, microvm.ReadinessCheck) error + artifactOnce sync.Once + artifactErr error + hypervisorProbe func(context.Context) error + controlPeerProbe func(context.Context) error + networkProbe func(context.Context) error + profileProbe func(context.Context) ([]string, error) + staleProbe func(context.Context) (int, error) +} + +func runDoctor(stateDir, socketPath, configPath string) error { + setDeterministicArtifactUmask() + if !filepath.IsAbs(stateDir) || !filepath.IsAbs(socketPath) || !filepath.IsAbs(configPath) { + return errors.New("state-dir, socket, and config must be absolute") + } + cfg, resolver, policy, err := loadDaemonConfig(configPath) + if err != nil { + return err + } + artifactCache := cfg.ArtifactCache + if artifactCache == "" { + artifactCache = filepath.Join(stateDir, "artifacts") + } else if !filepath.IsAbs(artifactCache) { + return errors.New("artifact_cache must be absolute") + } + resolver.configureOCI(filepath.Join(artifactCache, "oci")) + requests := make(map[microvm.ArtifactKind]microvm.ArtifactRequest, len(cfg.Artifacts)) + for _, artifact := range cfg.Artifacts { + requests[artifact.Kind] = artifactRequest(artifact) + } + checker := &doctorChecker{ + stateDir: stateDir, socketPath: socketPath, cfg: cfg, requests: requests, + artifacts: microvm.NewProvisioner(microvm.NewVerifiedCache(artifactCache), resolver, policy, nil, nil), + } + report := microvm.NewDoctor(checker).Run(context.Background()) + if runtime.GOOS == "linux" { + _, _ = fmt.Fprintf(os.Stdout, "microvmd doctor identity uid=%d euid=%d gid=%d egid=%d hypervisor=/dev/kvm flags=O_RDWR userns=CLONE_NEWUSER\n", os.Getuid(), os.Geteuid(), os.Getgid(), os.Getegid()) + } else { + _, _ = fmt.Fprintf(os.Stdout, "microvmd doctor identity uid=%d euid=%d hypervisor=Hypervisor.framework\n", os.Getuid(), os.Geteuid()) + } + _, _ = fmt.Fprint(os.Stdout, report.String()) + if !report.Ready() { + return errors.New("microvmd doctor found failed readiness checks") + } + return nil +} + +func (d *doctorChecker) Check(ctx context.Context, check microvm.ReadinessCheck) error { + switch check { + case microvm.CheckHypervisor: + if d.hypervisorProbe != nil { + return d.hypervisorProbe(ctx) + } + return checkHypervisorAccess(ctx) + case microvm.CheckRuntime, microvm.CheckFirmware: + if d.artifactProbe != nil { + return d.artifactProbe(ctx, check) + } + d.artifactOnce.Do(func() { + if d.artifacts == nil { + d.artifactErr = errors.New("artifact verifier is not configured") + return + } + _, _, d.artifactErr = d.artifacts.Verify(ctx, d.requests) + }) + return d.artifactErr + case microvm.CheckControlSocket: + if d.controlPeerProbe != nil { + return d.controlPeerProbe(ctx) + } + return checkControlPeer(ctx, d.socketPath) + case microvm.CheckNetwork: + if d.networkProbe != nil { + return d.networkProbe(ctx) + } + runtimeDir := d.cfg.RuntimeDir + if runtimeDir == "" { + runtimeDir = filepath.Join(d.stateDir, "vsock") + } + return checkNetworkProvider(ctx, d.cfg.GuestEgress, runtimeDir) + default: + return fmt.Errorf("unknown readiness check %q", check) + } +} + +func checkHypervisorAccess(ctx context.Context) error { + if runtime.GOOS == "linux" { + if err := checkLinuxUserNamespaceCapability(ctx); err != nil { + return err + } + return checkLinuxKVMAccess(os.OpenFile, os.Getuid(), os.Geteuid()) + } + if runtime.GOOS != "darwin" { + return fmt.Errorf("unsupported host platform %s", runtime.GOOS) + } + output, err := exec.CommandContext(ctx, "sysctl", "-n", "kern.hv_support").Output() + if err != nil { + return fmt.Errorf("query Hypervisor.framework support: %w", err) + } + if strings.TrimSpace(string(output)) != "1" { + return errors.New("hypervisor.framework is unavailable") + } + return nil +} + +func checkLinuxKVMAccess(openFile func(string, int, os.FileMode) (*os.File, error), uid, euid int) error { + file, err := openFile("/dev/kvm", os.O_RDWR|syscall.O_CLOEXEC, 0) + if err != nil { + return fmt.Errorf("open /dev/kvm with O_RDWR as uid=%d euid=%d: %w", uid, euid, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close /dev/kvm opened with O_RDWR as uid=%d euid=%d: %w", uid, euid, err) + } + return nil +} + +func checkControlPeer(ctx context.Context, socketPath string) error { + info, err := os.Lstat(socketPath) + if err != nil { + return err + } + if info.Mode()&os.ModeSocket == 0 || info.Mode().Perm()&0o077 != 0 { + return errors.New("control endpoint is not a private Unix socket") + } + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "unix", socketPath) + if err != nil { + return fmt.Errorf("connect to microvmd control peer: %w", err) + } + defer func() { _ = conn.Close() }() + uid := os.Getuid() + if uid < 0 || uint64(uid) > uint64(^uint32(0)) { + return errors.New("daemon account UID is outside the supported range") + } + service, err := control.NewService(control.ServiceConfig{AccountUID: uint32(uid)}) // #nosec G115 -- range checked above. + if err != nil { + return err + } + if err := service.Authenticate(conn); err != nil { + return fmt.Errorf("authenticate microvmd control peer: %w", err) + } + return nil +} + +func checkNetworkProvider(ctx context.Context, policy microvm.GuestEgressPolicy, stateDir string) error { + networkDir := filepath.Join(stateDir, "doctor-network") + if err := os.MkdirAll(networkDir, 0o700); err != nil { + return err + } + defer func() { _ = os.RemoveAll(networkDir) }() + controller := microvm.NewHostedBootNetworkController() + handle, err := controller.StartForDoctor(ctx, policy, networkDir) + if err != nil { + return err + } + handle.Provider.Stop() + return nil +} + +func (d *doctorChecker) Profiles(ctx context.Context) ([]string, error) { + if d.profileProbe != nil { + return d.profileProbe(ctx) + } + if !validDaemonTrustPolicy(d.cfg) { + return nil, errors.New("daemon profile has incomplete or ambiguous Sigstore trust policy") + } + artifacts := make(map[microvm.ArtifactKind]artifactConfig, len(d.cfg.Artifacts)) + for _, artifact := range d.cfg.Artifacts { + artifacts[artifact.Kind] = artifact + } + for _, kind := range []microvm.ArtifactKind{microvm.ArtifactRuntime, microvm.ArtifactFirmware, microvm.ArtifactExecutionImage, microvm.ArtifactGuestAgent} { + artifact, ok := artifacts[kind] + if !ok || artifact.Reference == "" || artifact.Digest == "" || artifact.Provenance == "" || artifact.SigstoreBundle == "" || d.cfg.Attestations[kind] == "" { + return nil, fmt.Errorf("daemon profile has inconsistent %s Sigstore policy", kind) + } + if kind == microvm.ArtifactExecutionImage && !validBroodResolution(artifact) { + return nil, errors.New("daemon execution image lacks exact Brood discovery resolution evidence") + } + } + if _, err := microvm.NewAdmissionController(d.cfg.Admission, nil); err != nil { + return nil, fmt.Errorf("daemon profile admission policy: %w", err) + } + if len(d.cfg.Profiles) == 0 { + return nil, errors.New("no daemon environment profiles are configured") + } + profiles := make([]string, 0, len(d.cfg.Profiles)) + for name, profile := range d.cfg.Profiles { + if name == "" { + return nil, errors.New("daemon environment profile name is empty") + } + if _, err := profileResourceUsage(profile.Resources); err != nil { + return nil, fmt.Errorf("daemon environment profile %q: %w", name, err) + } + profiles = append(profiles, name) + } + return profiles, nil +} + +func (d *doctorChecker) StaleResources(ctx context.Context) (int, error) { + if d.staleProbe != nil { + return d.staleProbe(ctx) + } + // Repository generation health is owned by authenticated daemon info and + // repository inventory. The superseded session-per-VM registry is not a + // production health authority. + return 0, nil +} + +func staleReadyGeneration(ctx context.Context, record microvm.EnvironmentRecord) bool { + identity, err := microvm.ProcessStartIdentity(ctx, record.RunnerPID) + if err != nil || identity != record.ProcessIdentity { + return true + } + info, err := os.Lstat(record.Endpoint) + return err != nil || info.Mode()&os.ModeSocket == 0 || info.Mode()&os.ModeSymlink != 0 +} + +func servingDaemonInfo(cfg daemonConfig, socketPath string) (microvm.DaemonInfo, error) { + if !strings.HasPrefix(cfg.ReleaseIdentity, "sha256:") || len(cfg.ReleaseIdentity) != 71 || !strings.HasPrefix(cfg.BinaryIdentity, "sha256:") || len(cfg.BinaryIdentity) != 71 || cfg.loadedConfigDigest == "" { + return microvm.DaemonInfo{}, errors.New("daemon config omits release or binary identity") + } + binaryPath, err := os.Executable() + if err != nil { + return microvm.DaemonInfo{}, fmt.Errorf("resolve running microvmd binary: %w", err) + } + binaryIdentity, err := fileIdentity(binaryPath) + if err != nil { + return microvm.DaemonInfo{}, fmt.Errorf("hash running microvmd binary: %w", err) + } + profiles := make([]string, 0, len(cfg.Profiles)) + for profile := range cfg.Profiles { + profiles = append(profiles, profile) + } + sort.Strings(profiles) + return microvm.DaemonInfo{ProtocolVersion: microvm.LifecycleProtocolVersion, ReleaseIdentity: cfg.ReleaseIdentity, BinaryIdentity: binaryIdentity, ConfigDigest: cfg.loadedConfigDigest, PolicyRevision: cfg.PolicyRevision, Profiles: profiles, Socket: socketPath}, nil +} + +func fileIdentity(path string) (string, error) { + file, err := os.Open(path) // #nosec G304 -- identity is computed for an explicit executable or operator config path. + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil +} + +func removeExistingSocket(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSocket == 0 { + return errors.New("refusing to replace non-socket microvmd endpoint") + } + return os.Remove(path) +} + +func validDaemonTrustPolicy(cfg daemonConfig) bool { + if cfg.PolicyRevision == "" { + return false + } + keyless := cfg.CertificateIdentity != "" && cfg.OIDCIssuer != "" && cfg.PublicKey == "" && cfg.PublicKeyIdentity == "" + keyed := cfg.CertificateIdentity == "" && cfg.OIDCIssuer == "" && filepath.IsAbs(cfg.PublicKey) && cfg.PublicKeyIdentity != "" + return keyless || keyed +} + +func validBroodResolution(entry artifactConfig) bool { + return entry.DiscoveryReference == "ghcr.io/stacklok/brood-box/base:latest" && + validSHA256(entry.ManifestDigest) && validSHA256(entry.ResolutionEvidence) && + (entry.Platform == "linux/amd64" || entry.Platform == "linux/arm64") +} + +func validSHA256(value string) bool { + hexValue := strings.TrimPrefix(value, "sha256:") + decoded, err := hex.DecodeString(hexValue) + return err == nil && len(decoded) == sha256.Size && value == "sha256:"+hexValue && value == strings.ToLower(value) +} + +func validateArtifactConfig(entry artifactConfig) (bool, error) { + isOCI := entry.Kind == microvm.ArtifactExecutionImage && entry.ManifestDigest != "" + if isOCI && !validBroodResolution(entry) { + return false, errors.New("execution image requires exact Brood discovery resolution evidence") + } + if !filepath.IsAbs(entry.Provenance) || !filepath.IsAbs(entry.SigstoreBundle) || entry.Reference == "" || + (!isOCI && !filepath.IsAbs(entry.Path)) || (isOCI && entry.Path != "") { + return false, fmt.Errorf("invalid %s artifact source or evidence path", entry.Kind) + } + return isOCI, nil +} + +func loadDaemonConfig(path string) (daemonConfig, *configuredResolver, microvm.TrustPolicy, error) { + data, err := os.ReadFile(path) // #nosec G304 -- explicit operator-owned absolute configuration path. + if err != nil { + return daemonConfig{}, nil, microvm.TrustPolicy{}, err + } + var cfg daemonConfig + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&cfg); err != nil { + return daemonConfig{}, nil, microvm.TrustPolicy{}, err + } + digest := sha256.Sum256(data) + cfg.loadedConfigDigest = "sha256:" + hex.EncodeToString(digest[:]) + if !validDaemonTrustPolicy(cfg) { + return daemonConfig{}, nil, microvm.TrustPolicy{}, errors.New("incomplete or ambiguous Sigstore trust policy") + } + var verifier microvm.EvidenceVerifier + if cfg.PublicKey != "" { + publicKey, readErr := os.ReadFile(cfg.PublicKey) // #nosec G304 -- operator-owned absolute trust-root path. + if readErr != nil { + return daemonConfig{}, nil, microvm.TrustPolicy{}, fmt.Errorf("read Sigstore public key: %w", readErr) + } + if microvm.PublicKeyIdentity(publicKey) != cfg.PublicKeyIdentity { + return daemonConfig{}, nil, microvm.TrustPolicy{}, errors.New("sigstore public-key identity mismatch") + } + verifier, err = microvm.NewSigstoreKeyVerifier(publicKey) + } else { + verifier = microvm.NewSigstoreVerifier() + } + if err != nil { + return daemonConfig{}, nil, microvm.TrustPolicy{}, errors.New("incomplete Sigstore trust policy") + } + policy := microvm.TrustPolicy{ + Revision: cfg.PolicyRevision, CertificateIdentity: cfg.CertificateIdentity, + OIDCIssuer: cfg.OIDCIssuer, PublicKeyIdentity: cfg.PublicKeyIdentity, + Verifier: verifier, RequiredAttestations: cfg.Attestations, + } + resolver := &configuredResolver{ + local: make(map[string]microvm.ResolvedArtifact, len(cfg.Artifacts)), + ociEvidence: make(map[string]microvm.VerificationEvidence), + } + for _, entry := range cfg.Artifacts { + isOCI, validateErr := validateArtifactConfig(entry) + if validateErr != nil { + return daemonConfig{}, nil, microvm.TrustPolicy{}, validateErr + } + statement, err := os.ReadFile(entry.Provenance) // #nosec G304 -- operator-owned absolute configuration path. + if err != nil { + return daemonConfig{}, nil, microvm.TrustPolicy{}, fmt.Errorf("read %s provenance: %w", entry.Kind, err) + } + bundle, err := os.ReadFile(entry.SigstoreBundle) // #nosec G304 -- operator-owned absolute configuration path. + if err != nil { + return daemonConfig{}, nil, microvm.TrustPolicy{}, fmt.Errorf("read %s Sigstore bundle: %w", entry.Kind, err) + } + evidence := microvm.VerificationEvidence{Bundle: bundle, Attestation: microvm.Attestation{ + PredicateType: cfg.Attestations[entry.Kind], SubjectDigest: entry.Digest, Statement: statement, + }} + if isOCI { + resolver.ociEvidence[entry.Reference] = evidence + continue + } + resolver.local[entry.Reference] = microvm.ResolvedArtifact{ + Kind: entry.Kind, Digest: entry.Digest, Source: extract.Dir(entry.Path), Evidence: evidence, + } + } + return cfg, resolver, policy, nil +} diff --git a/environment/microvm/cmd/mecatl-microvmd/main_test.go b/environment/microvm/cmd/mecatl-microvmd/main_test.go new file mode 100644 index 000000000..db8f5168e --- /dev/null +++ b/environment/microvm/cmd/mecatl-microvmd/main_test.go @@ -0,0 +1,317 @@ +package main + +import ( + "context" + "errors" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "testing" + + "github.com/stacklok/mecatl/environment/microvm" +) + +func TestDaemonArtifactExtractionIgnoresCallerUmask(t *testing.T) { + const helperPathEnv = "MECATL_TEST_DAEMON_UMASK_PATH" + if path := os.Getenv(helperPathEnv); path != "" { + _ = syscall.Umask(0o077) + setDeterministicArtifactUmask() + if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil { + t.Fatal(err) + } + return + } + + path := filepath.Join(t.TempDir(), "artifact") + cmd := exec.Command(os.Args[0], "-test.run=^TestDaemonArtifactExtractionIgnoresCallerUmask$") + cmd.Env = append(os.Environ(), helperPathEnv+"="+path) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("umask helper: %v: %s", err, output) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o644 { + t.Fatalf("artifact mode = %#o, want 0644", info.Mode().Perm()) + } +} + +func TestHypervisorAccessReportsEffectiveIdentity(t *testing.T) { + t.Parallel() + err := checkLinuxKVMAccess(func(string, int, os.FileMode) (*os.File, error) { + return nil, os.ErrPermission + }, 1001, 65534) + if err == nil || !strings.Contains(err.Error(), "uid=1001 euid=65534") || !strings.Contains(err.Error(), "O_RDWR") { + t.Fatalf("KVM access error did not identify the effective opener: %v", err) + } +} + +func TestDoctorRunsProductionReadinessProbes(t *testing.T) { + t.Parallel() + calls := make(map[microvm.ReadinessCheck]int) + var artifactCalls atomic.Int32 + checker := &doctorChecker{ + artifacts: doctorArtifactVerifier{calls: &artifactCalls, err: errors.New("corrupt signature evidence")}, + hypervisorProbe: func(context.Context) error { calls[microvm.CheckHypervisor]++; return nil }, + controlPeerProbe: func(context.Context) error { calls[microvm.CheckControlSocket]++; return nil }, + networkProbe: func(context.Context) error { + calls[microvm.CheckNetwork]++ + return errors.New("hosted provider unavailable") + }, + profileProbe: func(context.Context) ([]string, error) { + calls[microvm.CheckProfiles]++ + return nil, errors.New("profile policy inconsistent") + }, + staleProbe: func(context.Context) (int, error) { calls[microvm.CheckStaleResources]++; return 1, nil }, + } + report := microvm.NewDoctor(checker).Run(context.Background()) + if report.Ready() { + t.Fatal("doctor accepted corrupt evidence, unavailable network, and inconsistent profiles") + } + for _, check := range []microvm.ReadinessCheck{microvm.CheckHypervisor, microvm.CheckControlSocket, microvm.CheckNetwork, microvm.CheckProfiles, microvm.CheckStaleResources} { + if calls[check] != 1 { + t.Fatalf("probe %s calls = %d, want 1", check, calls[check]) + } + } + if artifactCalls.Load() != 1 { + t.Fatalf("artifact verifier calls = %d, want one complete-set verification", artifactCalls.Load()) + } + text := report.String() + for _, want := range []string{"corrupt signature evidence", "hosted provider unavailable", "profile policy inconsistent", "stale resources", "remediation:"} { + if !strings.Contains(text, want) { + t.Fatalf("doctor output missing %q:\n%s", want, text) + } + } +} + +func TestDoctorArtifactVerificationSharedAcrossConcurrentReadinessChecks(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + verificationErr := errors.New("shared artifact verification failure") + checker := doctorChecker{artifacts: doctorArtifactVerifier{calls: &calls, err: verificationErr}} + + start := make(chan struct{}) + results := make(chan error, 2) + var checks sync.WaitGroup + for _, check := range []microvm.ReadinessCheck{microvm.CheckRuntime, microvm.CheckFirmware} { + checks.Add(1) + go func() { + defer checks.Done() + <-start + results <- checker.Check(context.Background(), check) + }() + } + close(start) + checks.Wait() + close(results) + + if calls.Load() != 1 { + t.Fatalf("artifact verifier calls = %d, want 1", calls.Load()) + } + for err := range results { + if !errors.Is(err, verificationErr) { + t.Fatalf("readiness check error = %v, want shared %v", err, verificationErr) + } + } +} + +func TestDoctorDoesNotConsultSupersededLegacyRegistry(t *testing.T) { + t.Parallel() + stateDir := t.TempDir() + registry, err := microvm.OpenFileRegistry(filepath.Join(stateDir, "registry.json")) + if err != nil { + t.Fatal(err) + } + record := microvm.EnvironmentRecord{ + State: microvm.EnvironmentReady, EnvironmentID: "legacy-only", Generation: 1, + Ref: microvm.EnvironmentRef{Kind: microvm.Kind, ID: "legacy-only@1"}, + VMID: "vm-legacy", Endpoint: filepath.Join(stateDir, "missing.sock"), + } + if err := registry.Save(context.Background(), record); err != nil { + t.Fatal(err) + } + stale, err := (&doctorChecker{stateDir: stateDir}).StaleResources(context.Background()) + if err != nil { + t.Fatal(err) + } + if stale != 0 { + t.Fatalf("legacy-only stale generations = %d, want ignored", stale) + } +} + +func TestDoctorProcessStartIdentityDistinguishesHealthyAndReusedPID(t *testing.T) { + t.Parallel() + ctx := context.Background() + identity, err := microvm.ProcessStartIdentity(ctx, os.Getpid()) + if err != nil { + t.Fatalf("read current process identity: %v", err) + } + endpoint := shortPrivateMicrovmdSocketPath(t, "guest.sock") + listener, err := net.Listen("unix", endpoint) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + + record := microvm.EnvironmentRecord{ + State: microvm.EnvironmentReady, EnvironmentID: "healthy", Generation: 1, + Ref: microvm.EnvironmentRef{Kind: microvm.Kind, ID: "healthy@1"}, VMID: "vm-healthy", + Endpoint: endpoint, RunnerPID: os.Getpid(), ProcessIdentity: identity, + } + if staleReadyGeneration(ctx, record) { + t.Fatal("doctor marked a healthy real process generation stale") + } + record.ProcessIdentity = identity + "-reused" + if !staleReadyGeneration(ctx, record) { + t.Fatal("doctor accepted a reused PID with a stale process-start identity") + } +} + +func TestInvariant_microvmd_execution_image_requires_exact_brood_resolution(t *testing.T) { + t.Parallel() + manifest := "sha256:" + strings.Repeat("a", 64) + valid := artifactConfig{ + Kind: microvm.ArtifactExecutionImage, Reference: "ghcr.io/stacklok/brood-box/base@" + manifest, + Digest: "sha256:" + strings.Repeat("c", 64), ManifestDigest: manifest, + DiscoveryReference: "ghcr.io/stacklok/brood-box/base:latest", + ResolutionEvidence: "sha256:" + strings.Repeat("d", 64), Platform: "linux/amd64", + Provenance: "/evidence/statement", SigstoreBundle: "/evidence/bundle", + } + if isOCI, err := validateArtifactConfig(valid); err != nil || !isOCI { + t.Fatalf("exact Brood resolution rejected: isOCI=%v err=%v", isOCI, err) + } + for name, mutate := range map[string]func(*artifactConfig){ + "missing": func(config *artifactConfig) { config.ResolutionEvidence = "" }, + "wrong platform": func(config *artifactConfig) { config.Platform = "darwin/arm64" }, + "wrong source": func(config *artifactConfig) { config.DiscoveryReference = "ghcr.io/attacker/base:latest" }, + } { + t.Run(name, func(t *testing.T) { + entry := valid + mutate(&entry) + if _, err := validateArtifactConfig(entry); err == nil { + t.Fatal("invalid Brood resolution was admitted") + } + }) + } +} + +func TestDoctorRejectsInconsistentDaemonProfile(t *testing.T) { + t.Parallel() + profiles, err := (&doctorChecker{cfg: daemonConfig{PolicyRevision: "policy-v1"}}).Profiles(context.Background()) + if err == nil || len(profiles) != 0 || !strings.Contains(err.Error(), "trust policy") { + t.Fatalf("inconsistent profile result = profiles:%v err:%v", profiles, err) + } +} + +func TestDoctorAcceptsFinalSigstorePolicyAndDaemonProfiles(t *testing.T) { + t.Parallel() + attestations := map[microvm.ArtifactKind]string{} + artifacts := make([]artifactConfig, 0, 4) + for _, kind := range []microvm.ArtifactKind{microvm.ArtifactRuntime, microvm.ArtifactFirmware, microvm.ArtifactExecutionImage, microvm.ArtifactGuestAgent} { + attestations[kind] = "https://slsa.dev/provenance/v1" + artifact := artifactConfig{ + Kind: kind, Reference: "registry.example/" + string(kind), Digest: "sha256:pinned", + Provenance: "/evidence/" + string(kind) + ".intoto.jsonl", SigstoreBundle: "/evidence/" + string(kind) + ".sigstore.json", + } + if kind == microvm.ArtifactExecutionImage { + artifact.ManifestDigest = "sha256:" + strings.Repeat("a", 64) + artifact.Reference = "ghcr.io/stacklok/brood-box/base@" + artifact.ManifestDigest + artifact.DiscoveryReference = "ghcr.io/stacklok/brood-box/base:latest" + artifact.ResolutionEvidence = "sha256:" + strings.Repeat("d", 64) + artifact.Platform = "linux/amd64" + } + artifacts = append(artifacts, artifact) + } + checker := doctorChecker{cfg: daemonConfig{ + PolicyRevision: "policy-v1", + CertificateIdentity: "https://github.com/stacklok/mecatl/.github/workflows/release.yml@refs/tags/v1", + OIDCIssuer: "https://token.actions.githubusercontent.com", Attestations: attestations, Artifacts: artifacts, + Profiles: map[string]profileConfig{"locked-down": {Resources: map[string]string{"cpus": "2", "memory": "4GiB"}}}, + }} + profiles, err := checker.Profiles(context.Background()) + if err != nil { + t.Fatalf("final daemon policy rejected: %v", err) + } + if len(profiles) != 1 || profiles[0] != "locked-down" { + t.Fatalf("profiles = %v, want daemon-owned locked-down alias", profiles) + } +} + +func TestDoctorAcceptsPinnedPublicKeyPolicy(t *testing.T) { + t.Parallel() + attestations := map[microvm.ArtifactKind]string{} + artifacts := make([]artifactConfig, 0, 4) + for _, kind := range []microvm.ArtifactKind{microvm.ArtifactRuntime, microvm.ArtifactFirmware, microvm.ArtifactExecutionImage, microvm.ArtifactGuestAgent} { + attestations[kind] = "https://slsa.dev/provenance/v1" + artifact := artifactConfig{Kind: kind, Reference: string(kind) + "@sha256:pinned", Digest: "sha256:pinned", Provenance: "/evidence/statement", SigstoreBundle: "/evidence/bundle"} + if kind == microvm.ArtifactExecutionImage { + artifact.ManifestDigest = "sha256:" + strings.Repeat("a", 64) + artifact.Reference = "ghcr.io/stacklok/brood-box/base@" + artifact.ManifestDigest + artifact.DiscoveryReference = "ghcr.io/stacklok/brood-box/base:latest" + artifact.ResolutionEvidence = "sha256:" + strings.Repeat("d", 64) + artifact.Platform = "linux/amd64" + } + artifacts = append(artifacts, artifact) + } + checker := doctorChecker{cfg: daemonConfig{ + PolicyRevision: "local-e2e-v1", + PublicKey: "/private/e2e/cosign.pub", PublicKeyIdentity: microvm.PublicKeyIdentity([]byte("public-key")), + Attestations: attestations, Artifacts: artifacts, + Profiles: map[string]profileConfig{"locked-down": {Resources: map[string]string{"cpus": "1", "memory": "256MiB"}}}, + }} + if profiles, err := checker.Profiles(context.Background()); err != nil || len(profiles) != 1 { + t.Fatalf("public-key policy profiles = %v, %v", profiles, err) + } + + checker.cfg.CertificateIdentity = "unexpected-keyless-identity" + checker.cfg.OIDCIssuer = "https://issuer.example" + if _, err := checker.Profiles(context.Background()); err == nil { + t.Fatal("ambiguous public-key and keyless policy was accepted") + } +} + +func TestDoctorHealthyConfigurationPasses(t *testing.T) { + t.Parallel() + report := microvm.NewDoctor(readinessCheckerWithHealthyDefaults(&doctorChecker{})).Run(context.Background()) + if !report.Ready() { + t.Fatalf("healthy doctor failed:\n%s", report.String()) + } +} + +type doctorArtifactVerifier struct { + calls *atomic.Int32 + err error +} + +func (v doctorArtifactVerifier) Verify(context.Context, map[microvm.ArtifactKind]microvm.ArtifactRequest) (microvm.VerifiedArtifacts, string, error) { + v.calls.Add(1) + return microvm.VerifiedArtifacts{}, "", v.err +} + +func readinessCheckerWithHealthyDefaults(checker *doctorChecker) *doctorChecker { + if checker.artifactProbe == nil { + checker.artifactProbe = func(context.Context, microvm.ReadinessCheck) error { return nil } + } + if checker.hypervisorProbe == nil { + checker.hypervisorProbe = func(context.Context) error { return nil } + } + if checker.controlPeerProbe == nil { + checker.controlPeerProbe = func(context.Context) error { return nil } + } + if checker.networkProbe == nil { + checker.networkProbe = func(context.Context) error { return nil } + } + if checker.profileProbe == nil { + checker.profileProbe = func(context.Context) ([]string, error) { return []string{"locked-down"}, nil } + } + if checker.staleProbe == nil { + checker.staleProbe = func(context.Context) (int, error) { return 0, nil } + } + return checker +} diff --git a/environment/microvm/cmd/mecatl-microvmd/profile_policy_test.go b/environment/microvm/cmd/mecatl-microvmd/profile_policy_test.go new file mode 100644 index 000000000..12c508d73 --- /dev/null +++ b/environment/microvm/cmd/mecatl-microvmd/profile_policy_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/stacklok/mecatl/environment/microvm" +) + +func TestADR_0224_DaemonProfilesAreAuthoritative(t *testing.T) { + resources, err := microvm.NewOpaqueIdentityAllocator(t.TempDir(), t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + artifacts := map[microvm.ArtifactKind]microvm.ArtifactRequest{ + microvm.ArtifactExecutionImage: {Kind: microvm.ArtifactExecutionImage, Reference: "image@sha256:daemon"}, + microvm.ArtifactGuestAgent: {Kind: microvm.ArtifactGuestAgent, Reference: "guest-agent@sha256:daemon"}, + } + profiles := map[string]profileConfig{ + "secure": {Resources: map[string]string{"cpus": "2", "memory": "512MiB"}}, + } + builder := newPlacementBuilder(resources, artifacts, profiles, microvm.GuestEgressPolicy{Mode: microvm.EgressDenyAll}) + + if _, err := builder(context.Background(), microvm.ProvisionRequest{Owner: "local", SessionID: "s1", Profile: "unknown", SourceCheckout: "/source"}); err == nil || !strings.Contains(err.Error(), "unknown") { + t.Fatalf("unknown profile error = %v", err) + } + request, err := builder(context.Background(), microvm.ProvisionRequest{Owner: "local", SessionID: "s1", Profile: "secure", SourceCheckout: "/source"}) + if err != nil { + t.Fatalf("build placement: %v", err) + } + if request.Resources.CPU != 2 || request.Resources.RAMBytes != 512<<20 { + t.Fatalf("daemon profile resources = %+v", request.Resources) + } + if request.ProfileStatus.Profile != "secure" || request.ProfileStatus.GuestEgress != "deny-all (IPv4 filtered; IPv6 disabled)" || request.ProfileStatus.HostEgress == "" { + t.Fatalf("daemon enforced status = %+v", request.ProfileStatus) + } +} diff --git a/environment/microvm/cmd/mecatl-microvmd/socket_test.go b/environment/microvm/cmd/mecatl-microvmd/socket_test.go new file mode 100644 index 000000000..b1d73a0be --- /dev/null +++ b/environment/microvm/cmd/mecatl-microvmd/socket_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +const darwinUnixSocketPathBytes = 104 + +func shortPrivateMicrovmdSocketPath(t *testing.T, name string) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "mecatl-microvmd-test-") + if err != nil { + t.Fatalf("create short private microvmd test directory: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(dir); err != nil { + t.Errorf("remove short private microvmd test directory: %v", err) + } + }) + return filepath.Join(dir, name) +} + +func TestInvariant_microvmd_test_socket_path_is_darwin_safe_private_and_cleaned(t *testing.T) { + var dir string + t.Run("allocate", func(t *testing.T) { + path := shortPrivateMicrovmdSocketPath(t, "microvmd.sock") + dir = filepath.Dir(path) + if len(path) >= darwinUnixSocketPathBytes { + t.Fatalf("microvmd test socket path is %d bytes, want < %d: %q", len(path), darwinUnixSocketPathBytes, path) + } + if !strings.HasPrefix(path, filepath.Clean("/tmp")+string(filepath.Separator)) { + t.Fatalf("microvmd test socket escaped short temporary base: %q", path) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat microvmd test socket directory: %v", err) + } + if info.Mode().Perm()&0o077 != 0 { + t.Fatalf("microvmd test socket directory mode = %o, want owner-only", info.Mode().Perm()) + } + if err := os.WriteFile(filepath.Join(dir, "cleanup-marker"), []byte("owned"), 0o600); err != nil { + t.Fatalf("create cleanup marker: %v", err) + } + }) + if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("microvmd test socket directory survived cleanup: %v", err) + } +} diff --git a/environment/microvm/cmd/mecatl-microvmd/userns_linux.go b/environment/microvm/cmd/mecatl-microvmd/userns_linux.go new file mode 100644 index 000000000..a8c177d8b --- /dev/null +++ b/environment/microvm/cmd/mecatl-microvmd/userns_linux.go @@ -0,0 +1,34 @@ +//go:build linux + +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "syscall" +) + +func checkLinuxUserNamespaceCapability(ctx context.Context) error { + data, err := os.ReadFile("/proc/sys/user/max_user_namespaces") + if err != nil { + return fmt.Errorf("read user namespace quota: %w", err) + } + maximum, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64) + if err != nil || maximum == 0 { + return errors.New("unprivileged user namespaces are disabled; set user.max_user_namespaces to a positive value") + } + if data, err = os.ReadFile("/proc/sys/kernel/unprivileged_userns_clone"); err == nil && strings.TrimSpace(string(data)) == "0" { + return errors.New("unprivileged user namespaces are disabled; enable kernel.unprivileged_userns_clone") + } + cmd := exec.CommandContext(ctx, "true") + cmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWUSER} + if err := cmd.Run(); err != nil { + return fmt.Errorf("create unprivileged user namespace: %w (enable the capability or free per-user namespace quota)", err) + } + return nil +} diff --git a/environment/microvm/cmd/mecatl-microvmd/userns_other.go b/environment/microvm/cmd/mecatl-microvmd/userns_other.go new file mode 100644 index 000000000..059b173c0 --- /dev/null +++ b/environment/microvm/cmd/mecatl-microvmd/userns_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package main + +import "context" + +func checkLinuxUserNamespaceCapability(context.Context) error { return nil } diff --git a/environment/microvm/cmd/mecatl-oci-tree-digest/main.go b/environment/microvm/cmd/mecatl-oci-tree-digest/main.go new file mode 100644 index 000000000..657fea391 --- /dev/null +++ b/environment/microvm/cmd/mecatl-oci-tree-digest/main.go @@ -0,0 +1,41 @@ +// Command mecatl-oci-tree-digest pulls one digest-pinned platform image through +// the production go-microvm extractor and prints its materialized tree identity. +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/stacklok/mecatl/environment/microvm" +) + +func main() { + if len(os.Args) != 3 { + fmt.Fprintln(os.Stderr, "usage: mecatl-oci-tree-digest REPO@SHA256 CACHE_DIR") + os.Exit(2) + } + ref := os.Args[1] + at := len(ref) - len("sha256:") - 64 - 1 + if at <= 0 || at >= len(ref) || ref[at] != '@' { + fmt.Fprintln(os.Stderr, "OCI reference must be digest-pinned") + os.Exit(2) + } + manifest := ref[at+1:] + resolver := microvm.NewOCIExecutionImageResolver(os.Args[2], nil, map[string]microvm.VerificationEvidence{ + ref: {Bundle: []byte("release-tree-digest")}, + }) + resolved, err := resolver.Resolve(context.Background(), microvm.ArtifactRequest{ + Kind: microvm.ArtifactExecutionImage, Reference: ref, ManifestDigest: manifest, + }) + if err != nil { + if errors.Is(err, microvm.ErrMutableArtifact) { + fmt.Fprintln(os.Stderr, "OCI reference must be digest-pinned") + } else { + fmt.Fprintln(os.Stderr, err) + } + os.Exit(1) + } + fmt.Println(resolved.Digest) +} diff --git a/environment/microvm/composition.go b/environment/microvm/composition.go new file mode 100644 index 000000000..0c3989223 --- /dev/null +++ b/environment/microvm/composition.go @@ -0,0 +1,171 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "net" + + "github.com/stacklok/mecatl/environment/microvm/control" +) + +// RuntimeDaemonConfig is the concrete microvmd composition root. The narrow +// lifecycle seams remain injectable so ordinary tests need no KVM/HVF or network. +type RuntimeDaemonConfig struct { + Control *control.Service + Backend GoMicroVMBackend + Network *NetworkController + GuestEgress GuestEgressPolicy + DialGuest GuestDialer + UnixGuestEndpoint bool + CapabilityKey CapabilityKeySource + Identities IdentityAllocator + Worktrees WorktreeLifecycle + Artifacts ArtifactVerifier + Registry ReconcileRegistry + Sessions SessionPersister + Admission *AdmissionController + Provisioner PlacementBuilder + ChildRequests ChildRequestBuilder + Observer *OperationsObserver + Info DaemonInfo + Retention WorktreeRetention + Repository *RepositoryComposition + RepositoryProvisioner RepositoryPlacementBuilder +} + +// RuntimeDaemon owns the daemon protocol and concrete go-microvm runtime. +type RuntimeDaemon struct { + Daemon *Daemon + Runtime *GoMicroVMRuntime + Repository *RepositoryComposition + reconciler *Reconciler + registry ReconcileRegistry + admission *AdmissionController + observer *OperationsObserver +} + +// NewRuntimeDaemon composes artifact verification, worktree preparation, +// go-microvm, guest protocol, and durable lifecycle into one daemon root. +func NewRuntimeDaemon(cfg RuntimeDaemonConfig) (*RuntimeDaemon, error) { + if cfg.Repository != nil { + daemon, err := NewDaemon(DaemonConfig{ + Control: cfg.Control, Observer: cfg.Observer, Info: cfg.Info, + RepositoryAttachments: repositoryAttachments(cfg.Repository), RepositoryProvisioner: cfg.RepositoryProvisioner, + RepositoryStartupError: repositoryStartupError(cfg.Repository), + }) + if err != nil { + return nil, err + } + return &RuntimeDaemon{Daemon: daemon, Repository: cfg.Repository, observer: cfg.Observer}, nil + } + runtime, err := NewGoMicroVMRuntime(GoMicroVMRuntimeConfig{ + Backend: cfg.Backend, Network: cfg.Network, GuestEgress: cfg.GuestEgress, + DialGuest: cfg.DialGuest, UnixGuestEndpoint: cfg.UnixGuestEndpoint, CapabilityKey: cfg.CapabilityKey, Observer: cfg.Observer, + }) + if err != nil { + return nil, err + } + if cfg.Identities == nil || cfg.Worktrees == nil || cfg.Artifacts == nil || cfg.Registry == nil || cfg.Sessions == nil || cfg.Retention == nil { + return nil, errors.New("microvmd composition is not fully configured") + } + registry := cfg.Registry + if cfg.Observer != nil { + registry = observedRegistry{ReconcileRegistry: cfg.Registry, observer: cfg.Observer} + } + lifecycle := NewLifecycle(LifecycleDeps{ + Identities: cfg.Identities, Worktrees: cfg.Worktrees, Artifacts: cfg.Artifacts, + VMs: runtime, Protocol: runtime, Registry: registry, Sessions: cfg.Sessions, + Admission: cfg.Admission, Observer: cfg.Observer, + }) + children := NewLifecycleChildren(lifecycle, registry, cfg.Admission, cfg.ChildRequests, cfg.Observer) + reconciler := NewReconcilerWithAdmission(registry, runtime, cfg.Retention, cfg.Admission, cfg.Observer) + daemon, err := NewDaemon(DaemonConfig{ + Control: cfg.Control, Creator: lifecycle, Provisioner: cfg.Provisioner, Registry: registry, + Runtime: runtime, Worktrees: cfg.Retention, Admission: cfg.Admission, Children: children, Reconciler: reconciler, Observer: cfg.Observer, Info: cfg.Info, + RepositoryAttachments: repositoryAttachments(cfg.Repository), RepositoryProvisioner: cfg.RepositoryProvisioner, + RepositoryStartupError: repositoryStartupError(cfg.Repository), + }) + if err != nil { + return nil, err + } + return &RuntimeDaemon{Daemon: daemon, Runtime: runtime, Repository: cfg.Repository, reconciler: reconciler, registry: registry, admission: cfg.Admission, observer: cfg.Observer}, nil +} + +func repositoryAttachments(repository *RepositoryComposition) *RepositoryAttachmentManager { + if repository == nil { + return nil + } + return repository.Attachments +} + +func repositoryStartupError(repository *RepositoryComposition) error { + if repository == nil { + return nil + } + return repository.RestartHealthError +} + +type observedRegistry struct { + ReconcileRegistry + observer *OperationsObserver +} + +func (r observedRegistry) Save(ctx context.Context, record EnvironmentRecord) error { + if err := r.ReconcileRegistry.Save(ctx, record); err != nil { + return err + } + r.observer.ObserveRecord(record) + return nil +} + +// Serve accepts authenticated local management connections until cancellation. +func (d *RuntimeDaemon) Serve(ctx context.Context, listener net.Listener) error { + if d == nil || d.Daemon == nil || listener == nil { + return errors.New("microvmd server is not configured") + } + if d.Repository == nil { + records, err := d.registry.List(ctx) + if err != nil { + return fmt.Errorf("load microvmd registry before serving: %w", err) + } + if d.observer != nil { + d.observer.Reconstruct(records) + } + if d.admission != nil { + if err := d.admission.Reconstruct(records); err != nil { + return err + } + } + err = d.reconciler.Reconcile(ctx) + if d.observer != nil { + outcome := OutcomeSuccess + if err != nil { + outcome = OutcomeFailure + } + d.observer.ReconciliationFinished(outcome) + } + if err != nil { + return fmt.Errorf("reconcile microvmd before serving: %w", err) + } + } + go func() { + <-ctx.Done() + _ = listener.Close() + }() + for { + conn, err := listener.Accept() + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + go func() { + defer func() { _ = conn.Close() }() + if err := d.Daemon.ServeConn(ctx, conn); err != nil && d.observer != nil { + d.observer.LifecycleRequestFailed(err) + } + }() + } +} diff --git a/environment/microvm/control/binding.go b/environment/microvm/control/binding.go new file mode 100644 index 000000000..ba938ee4f --- /dev/null +++ b/environment/microvm/control/binding.go @@ -0,0 +1,43 @@ +// Package control defines the authenticated local microvmd control plane and +// its bounded, versioned guest handshake. +package control + +import ( + "errors" + "fmt" +) + +var ( + // ErrUnauthenticatedPeer means the Unix peer is not the configured daemon account. + ErrUnauthenticatedPeer = errors.New("microvm control peer is not authenticated") + // ErrBindingMismatch means an operation does not match its registered owner/session/ref/generation. + ErrBindingMismatch = errors.New("microvm control binding mismatch") +) + +// Binding identifies one immutable environment generation. Its fields are identifiers, +// not credentials; authorization additionally requires an authenticated Unix peer. +type Binding struct { + Owner string `json:"owner"` + SessionID string `json:"session_id"` + EnvironmentID string `json:"environment_id"` + Ref string `json:"ref"` + Generation uint32 `json:"generation"` + // AssignedRoot is the guest-visible root authenticated for repository-logical + // environments. Legacy session-per-VM bindings leave it empty. + AssignedRoot string `json:"assigned_root,omitempty"` +} + +func (b Binding) validate() error { + if b.Owner == "" || b.SessionID == "" || b.EnvironmentID == "" || b.Ref == "" || b.Generation == 0 { + return fmt.Errorf("invalid microvm control binding") + } + return nil +} + +// ValidateLogical verifies the complete repository-logical authentication tuple. +func (b Binding) ValidateLogical(owner string, generation uint32) error { + if b.validate() != nil || b.Owner != owner || b.Generation != generation || b.AssignedRoot == "" { + return ErrBindingMismatch + } + return nil +} diff --git a/environment/microvm/control/capability.go b/environment/microvm/control/capability.go new file mode 100644 index 000000000..4f9925fa4 --- /dev/null +++ b/environment/microvm/control/capability.go @@ -0,0 +1,142 @@ +package control + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "sync" +) + +var ( + // ErrUnauthenticatedCapability reports an invalid, stale, replayed, or wrongly bound guest capability. + ErrUnauthenticatedCapability = errors.New("microvm guest capability is not authenticated") +) + +const ( + capabilityVersion = 1 + maxCapabilityNonces = 4096 +) + +type capabilityClaims struct { + Version int `json:"version"` + Binding Binding `json:"binding"` + Nonce string `json:"nonce"` +} + +// CapabilityIssuer mints transferable, generation-bound guest capabilities. +// It contains only an independently provisioned signing key and shares no registry with the guest. +type CapabilityIssuer struct { + key []byte +} + +// NewCapabilityIssuer constructs an issuer from independently provisioned key material. +func NewCapabilityIssuer(key []byte) (*CapabilityIssuer, error) { + if len(key) < sha256.Size { + return nil, errors.New("microvm capability key is too short") + } + return &CapabilityIssuer{key: append([]byte(nil), key...)}, nil +} + +// Issue mints a signed, single-use capability for one immutable binding. +func (i *CapabilityIssuer) Issue(binding Binding) (string, error) { + if i == nil || binding.validate() != nil { + return "", ErrUnauthenticatedCapability + } + var nonce [sha256.Size]byte + if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { + return "", fmt.Errorf("mint microvm guest capability: %w", err) + } + claims := capabilityClaims{Version: capabilityVersion, Binding: binding, Nonce: base64.RawURLEncoding.EncodeToString(nonce[:])} + payload, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("encode microvm guest capability: %w", err) + } + mac := hmac.New(sha256.New, i.key) + _, _ = mac.Write(payload) + signature := mac.Sum(nil) + return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +// CapabilityVerifier verifies capabilities using its own key copy and rejects nonce replay locally. +type CapabilityVerifier struct { + key []byte + mu sync.Mutex + used map[[sha256.Size]byte]Binding +} + +// NewCapabilityVerifier constructs a guest-side verifier from provisioned key material. +func NewCapabilityVerifier(key []byte) (*CapabilityVerifier, error) { + if len(key) < sha256.Size { + return nil, errors.New("microvm capability key is too short") + } + return &CapabilityVerifier{key: append([]byte(nil), key...), used: make(map[[sha256.Size]byte]Binding)}, nil +} + +// Verify authenticates and consumes a capability for expected. +func (v *CapabilityVerifier) Verify(token string, expected Binding) error { + if v == nil || expected.validate() != nil { + return ErrUnauthenticatedCapability + } + parts := splitCapability(token) + if len(parts) != 2 { + return ErrUnauthenticatedCapability + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return ErrUnauthenticatedCapability + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return ErrUnauthenticatedCapability + } + mac := hmac.New(sha256.New, v.key) + _, _ = mac.Write(payload) + if !hmac.Equal(signature, mac.Sum(nil)) { + return ErrUnauthenticatedCapability + } + var claims capabilityClaims + decoderErr := json.Unmarshal(payload, &claims) + if decoderErr != nil || claims.Version != capabilityVersion || claims.Binding != expected || claims.Nonce == "" { + return ErrUnauthenticatedCapability + } + nonce := sha256.Sum256([]byte(claims.Nonce)) + v.mu.Lock() + defer v.mu.Unlock() + if _, replayed := v.used[nonce]; replayed { + return ErrUnauthenticatedCapability + } + if len(v.used) >= maxCapabilityNonces { + return ErrUnauthenticatedCapability + } + v.used[nonce] = expected + return nil +} + +// Forget removes replay state belonging to one unregistered logical binding. +// It does not affect capabilities consumed by sibling environments. +func (v *CapabilityVerifier) Forget(binding Binding) { + if v == nil { + return + } + v.mu.Lock() + defer v.mu.Unlock() + for nonce, usedBinding := range v.used { + if usedBinding == binding { + delete(v.used, nonce) + } + } +} + +func splitCapability(token string) []string { + for index := range token { + if token[index] == '.' { + return []string{token[:index], token[index+1:]} + } + } + return nil +} diff --git a/environment/microvm/control/controltest/peer.go b/environment/microvm/control/controltest/peer.go new file mode 100644 index 000000000..11a133e18 --- /dev/null +++ b/environment/microvm/control/controltest/peer.go @@ -0,0 +1,15 @@ +// Package controltest provides deterministic offline control-protocol fakes. +package controltest + +import "net" + +// StaticPeerAuthenticator returns fixed peer credentials without consulting the host. +type StaticPeerAuthenticator struct { + UID uint32 + Err error +} + +// PeerUID implements control.PeerAuthenticator. +func (a StaticPeerAuthenticator) PeerUID(net.Conn) (uint32, error) { + return a.UID, a.Err +} diff --git a/environment/microvm/control/multiplex.go b/environment/microvm/control/multiplex.go new file mode 100644 index 000000000..f09627d26 --- /dev/null +++ b/environment/microvm/control/multiplex.go @@ -0,0 +1,499 @@ +package control + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "sync" + "sync/atomic" +) + +// ServiceName identifies a negotiated guest data-plane service. +type ServiceName string + +const ( + // ServiceWorkspace selects version-aware filesystem operations. + ServiceWorkspace ServiceName = "workspace" + // ServiceExec selects bounded command execution and output streaming. + ServiceExec ServiceName = "exec" +) + +type multiplexHello struct { + Version uint16 `json:"version"` + Binding Binding `json:"binding"` + Capability string `json:"capability"` + Services []ServiceName `json:"services"` + Capabilities Capabilities `json:"capabilities"` + MaxMessageBytes uint32 `json:"max_message_bytes"` +} + +type multiplexReply struct { + Agreement Agreement `json:"agreement"` + Services []ServiceName `json:"services"` + ErrorCode string `json:"error_code,omitempty"` +} + +type frameKind string + +const ( + kindRequest frameKind = "request" + kindStream frameKind = "stream" + kindEnd frameKind = "end" + kindCancel frameKind = "cancel" + kindError frameKind = "error" +) + +type multiplexFrame struct { + Kind frameKind `json:"kind"` + Service ServiceName `json:"service,omitempty"` + Method string `json:"method,omitempty"` + RequestID uint64 `json:"request_id"` + Capability string `json:"capability,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +// Client multiplexes concurrent workspace and exec requests over one authenticated stream. +type Client struct { + stream io.ReadWriteCloser + codec Codec + + writeMu sync.Mutex + requestMu sync.Mutex + mu sync.Mutex + capability string + binding Binding + agreement Agreement + pending map[uint64]chan multiplexFrame + nextID atomic.Uint64 + done chan struct{} + err error + once sync.Once +} + +// OpenClient authenticates one host stream and negotiates all requested services once. +func OpenClient(ctx context.Context, stream io.ReadWriteCloser, binding Binding, capability string, services []ServiceName, maxMessageBytes uint32) (*Client, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if stream == nil || binding.validate() != nil || capability == "" || len(services) == 0 { + return nil, ErrUnauthenticatedCapability + } + if maxMessageBytes == 0 || maxMessageBytes > DefaultMaxMessageBytes { + maxMessageBytes = DefaultMaxMessageBytes + } + codec := NewCodec(DefaultMaxMessageBytes) + hello := multiplexHello{ + Version: ProtocolVersion, Binding: binding, Capability: capability, + Services: append([]ServiceName(nil), services...), Capabilities: RequiredCapabilities(), + MaxMessageBytes: maxMessageBytes, + } + if err := codec.Write(stream, hello); err != nil { + return nil, err + } + var reply multiplexReply + if err := codec.Read(stream, &reply); err != nil { + return nil, err + } + if reply.ErrorCode != "" { + switch reply.ErrorCode { + case "unauthenticated": + return nil, ErrUnauthenticatedCapability + case "protocol_version": + return nil, ErrProtocolVersion + case "message_bound": + return nil, ErrMessageBound + case "capability_mismatch": + return nil, ErrCapabilityMismatch + default: + return nil, fmt.Errorf("microvm guest handshake rejected: %s", reply.ErrorCode) + } + } + if err := validateAgreement(reply.Agreement, hello.Version, hello.Capabilities, hello.MaxMessageBytes); err != nil { + return nil, err + } + if !sameServices(reply.Services, services) { + return nil, ErrCapabilityMismatch + } + client := &Client{ + stream: stream, codec: NewCodec(reply.Agreement.MaxMessageBytes), capability: capabilityProof(capability), binding: binding, + agreement: reply.Agreement, pending: make(map[uint64]chan multiplexFrame), done: make(chan struct{}), + } + go client.readLoop() + return client, nil +} + +func sameServices(got, want []ServiceName) bool { + if len(got) != len(want) { + return false + } + set := make(map[ServiceName]struct{}, len(got)) + for _, service := range got { + set[service] = struct{}{} + } + for _, service := range want { + if _, ok := set[service]; !ok { + return false + } + } + return true +} + +func capabilityProof(capability string) string { + digest := sha256.Sum256([]byte(capability)) + return base64.RawURLEncoding.EncodeToString(digest[:16]) +} + +// Agreement returns a copy of the services/capabilities negotiated by the production handshake. +func (c *Client) Agreement() Agreement { + if c == nil { + return Agreement{} + } + agreement := c.agreement + agreement.Capabilities = append(Capabilities(nil), agreement.Capabilities...) + return agreement +} + +// IsBoundTo reports whether the authenticated connection owns binding. +func (c *Client) IsBoundTo(binding Binding) bool { + return c != nil && c.binding == binding +} + +// Call performs one unary multiplexed request. +func (c *Client) Call(ctx context.Context, service ServiceName, method string, request, response any) error { + return c.Stream(ctx, service, method, request, nil, response) +} + +// Stream performs one request, delivering ordered stream payloads before the final response. +func (c *Client) Stream(ctx context.Context, service ServiceName, method string, request any, receive func(json.RawMessage) error, response any) error { //nolint:gocyclo // explicit request stream/cancel state machine + payload, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("encode microvm multiplex request: %w", err) + } + c.requestMu.Lock() + id := c.nextID.Add(1) + responses := make(chan multiplexFrame, 1) + c.mu.Lock() + if c.err != nil { + err = c.err + c.mu.Unlock() + c.requestMu.Unlock() + return err + } + c.pending[id] = responses + c.mu.Unlock() + writeErr := c.write(multiplexFrame{ + Kind: kindRequest, Service: service, Method: method, RequestID: id, + Capability: c.capability, Payload: payload, + }) + c.requestMu.Unlock() + defer func() { + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + }() + if writeErr != nil { + return writeErr + } + ctxDone := ctx.Done() + cancelled := false + for { + select { + case <-ctxDone: + if err := c.write(multiplexFrame{Kind: kindCancel, Service: service, Method: method, RequestID: id, Capability: c.capability}); err != nil { + return ctx.Err() + } + cancelled = true + ctxDone = nil + case <-c.done: + if cancelled { + return ctx.Err() + } + c.mu.Lock() + err := c.err + c.mu.Unlock() + if err == nil { + err = io.EOF + } + return err + case frame := <-responses: + if frame.Service != service || frame.Method != method { + return ErrMalformedFrame + } + switch frame.Kind { + case kindStream: + if receive != nil { + if err := receive(frame.Payload); err != nil { + _ = c.write(multiplexFrame{Kind: kindCancel, Service: service, Method: method, RequestID: id, Capability: c.capability}) + return err + } + } + case kindEnd: + if cancelled { + return ctx.Err() + } + if response != nil && len(frame.Payload) != 0 { + if err := json.Unmarshal(frame.Payload, response); err != nil { + return fmt.Errorf("decode microvm multiplex response: %w", err) + } + } + return nil + case kindError: + return &RemoteError{Code: frame.ErrorCode} + default: + return ErrMalformedFrame + } + } + } +} + +func (c *Client) write(frame multiplexFrame) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + if err := c.codec.Write(c.stream, frame); err != nil { + wrapped := fmt.Errorf("write microvm multiplex frame: %w", err) + if !errors.Is(err, ErrFrameTooLarge) && !errors.Is(err, ErrMalformedFrame) { + c.fail(wrapped) + } + return wrapped + } + return nil +} + +func (c *Client) readLoop() { + for { + var frame multiplexFrame + if err := c.codec.Read(c.stream, &frame); err != nil { + c.fail(err) + return + } + c.mu.Lock() + responses := c.pending[frame.RequestID] + c.mu.Unlock() + if responses != nil { + select { + case responses <- frame: + case <-c.done: + return + } + } + } +} + +func (c *Client) fail(err error) { + c.once.Do(func() { + c.mu.Lock() + c.err = err + c.mu.Unlock() + close(c.done) + }) +} + +// Close closes the sole guest stream and unblocks all requests. +func (c *Client) Close() error { + err := c.stream.Close() + c.fail(io.EOF) + return err +} + +// RemoteError is a bounded service error code returned by the guest. +type RemoteError struct{ Code string } + +func (e *RemoteError) Error() string { return "microvm guest service error: " + e.Code } + +// Handler serves one negotiated multiplex service. +type Handler func(context.Context, string, json.RawMessage, func(any) error) (any, string, error) + +// ServeMultiplex authenticates one capability, negotiates services, and dispatches request IDs. +func ServeMultiplex(ctx context.Context, stream io.ReadWriteCloser, expected Binding, verifier *CapabilityVerifier, handlers map[ServiceName]Handler, maxMessageBytes uint32) error { + return serveMultiplex(ctx, stream, verifier, maxMessageBytes, func(claim Binding) (Binding, map[ServiceName]Handler, bool) { + return expected, handlers, claim == expected + }) +} + +// RegisteredHandlerResolver resolves one complete logical binding to its assigned-root handlers. +type RegisteredHandlerResolver func(Binding) (map[ServiceName]Handler, bool) + +// ServeRegisteredMultiplex authenticates a logical binding before selecting any +// assigned-root handler. Each connection remains bound to that exact tuple. +func ServeRegisteredMultiplex(ctx context.Context, stream io.ReadWriteCloser, verifier *CapabilityVerifier, resolve RegisteredHandlerResolver, maxMessageBytes uint32) error { + if resolve == nil { + return ErrUnauthenticatedCapability + } + return serveMultiplex(ctx, stream, verifier, maxMessageBytes, func(claim Binding) (Binding, map[ServiceName]Handler, bool) { + handlers, ok := resolve(claim) + return claim, handlers, ok + }) +} + +func serveMultiplex(ctx context.Context, stream io.ReadWriteCloser, verifier *CapabilityVerifier, maxMessageBytes uint32, resolve func(Binding) (Binding, map[ServiceName]Handler, bool)) error { //nolint:gocyclo // explicit bounded connection state machine + if stream == nil || verifier == nil || resolve == nil { + return ErrUnauthenticatedCapability + } + if maxMessageBytes == 0 || maxMessageBytes > DefaultMaxMessageBytes { + maxMessageBytes = DefaultMaxMessageBytes + } + handshakeCodec := NewCodec(DefaultMaxMessageBytes) + var hello multiplexHello + if err := handshakeCodec.Read(stream, &hello); err != nil { + return err + } + expected, handlers, registered := resolve(hello.Binding) + agreement := Agreement{ + Version: ProtocolVersion, Capabilities: RequiredCapabilities(), + MaxMessageBytes: min(hello.MaxMessageBytes, maxMessageBytes), + } + reply := multiplexReply{Agreement: agreement, Services: append([]ServiceName(nil), hello.Services...)} + var negotiationErr error + switch { + case hello.Version != ProtocolVersion: + negotiationErr = ErrProtocolVersion + reply.ErrorCode = "protocol_version" + case !registered || expected.validate() != nil || hello.Binding != expected: + negotiationErr = ErrUnauthenticatedCapability + reply.ErrorCode = "unauthenticated" + case hello.MaxMessageBytes == 0: + negotiationErr = ErrMessageBound + reply.ErrorCode = "message_bound" + case !servicesAvailable(hello.Services, handlers): + negotiationErr = ErrCapabilityMismatch + reply.ErrorCode = "capability_mismatch" + case validateAgreement(agreement, hello.Version, hello.Capabilities, hello.MaxMessageBytes) != nil: + negotiationErr = ErrCapabilityMismatch + reply.ErrorCode = "capability_mismatch" + case verifier.Verify(hello.Capability, expected) != nil: + negotiationErr = ErrUnauthenticatedCapability + reply.ErrorCode = "unauthenticated" + } + if err := handshakeCodec.Write(stream, reply); err != nil { + return err + } + if negotiationErr != nil { + return negotiationErr + } + codec := NewCodec(reply.Agreement.MaxMessageBytes) + connectionCtx, stop := context.WithCancel(ctx) + var writeMu sync.Mutex + write := func(frame multiplexFrame) error { + writeMu.Lock() + defer writeMu.Unlock() + return codec.Write(stream, frame) + } + var requestsMu sync.Mutex + requests := make(map[uint64]context.CancelFunc) + var handlersWG sync.WaitGroup + var highestRequestID uint64 + defer func() { + stop() + requestsMu.Lock() + cancels := make([]context.CancelFunc, 0, len(requests)) + for _, cancel := range requests { + cancels = append(cancels, cancel) + } + requestsMu.Unlock() + for _, cancel := range cancels { + cancel() + } + handlersWG.Wait() + }() + for { + var frame multiplexFrame + if err := codec.Read(stream, &frame); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { + return io.EOF + } + return err + } + switch frame.Kind { + case kindCancel: + if !hmac.Equal([]byte(frame.Capability), []byte(capabilityProof(hello.Capability))) { + return ErrUnauthenticatedCapability + } + requestsMu.Lock() + cancel := requests[frame.RequestID] + requestsMu.Unlock() + if cancel != nil { + cancel() + } + case kindRequest: + if !hmac.Equal([]byte(frame.Capability), []byte(capabilityProof(hello.Capability))) { + return ErrUnauthenticatedCapability + } + if frame.RequestID == 0 || frame.RequestID <= highestRequestID || frame.Method == "" { + return ErrMalformedFrame + } + highestRequestID = frame.RequestID + handler := handlers[frame.Service] + if handler == nil { + if err := write(multiplexFrame{Kind: kindError, Service: frame.Service, Method: frame.Method, RequestID: frame.RequestID, ErrorCode: "unsupported_service"}); err != nil { + return err + } + continue + } + requestCtx, cancel := context.WithCancel(connectionCtx) + requestsMu.Lock() + if _, duplicate := requests[frame.RequestID]; duplicate { + requestsMu.Unlock() + cancel() + return ErrMalformedFrame + } + requests[frame.RequestID] = cancel + requestsMu.Unlock() + handlersWG.Add(1) + go func(frame multiplexFrame) { + defer handlersWG.Done() + defer func() { + cancel() + requestsMu.Lock() + delete(requests, frame.RequestID) + requestsMu.Unlock() + }() + send := func(value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + return write(multiplexFrame{Kind: kindStream, Service: frame.Service, Method: frame.Method, RequestID: frame.RequestID, Payload: payload}) + } + final, code, err := handler(requestCtx, frame.Method, frame.Payload, send) + if err != nil { + if code == "" { + code = "internal" + } + _ = write(multiplexFrame{Kind: kindError, Service: frame.Service, Method: frame.Method, RequestID: frame.RequestID, ErrorCode: code}) + return + } + payload, marshalErr := json.Marshal(final) + if marshalErr != nil { + _ = write(multiplexFrame{Kind: kindError, Service: frame.Service, Method: frame.Method, RequestID: frame.RequestID, ErrorCode: "internal"}) + return + } + _ = write(multiplexFrame{Kind: kindEnd, Service: frame.Service, Method: frame.Method, RequestID: frame.RequestID, Payload: payload}) + }(frame) + default: + return ErrMalformedFrame + } + } +} + +func servicesAvailable(services []ServiceName, handlers map[ServiceName]Handler) bool { + if len(services) == 0 { + return false + } + seen := make(map[ServiceName]struct{}, len(services)) + for _, service := range services { + if handlers[service] == nil { + return false + } + if _, duplicate := seen[service]; duplicate { + return false + } + seen[service] = struct{}{} + } + return true +} diff --git a/environment/microvm/control/peer_darwin.go b/environment/microvm/control/peer_darwin.go new file mode 100644 index 000000000..592251f64 --- /dev/null +++ b/environment/microvm/control/peer_darwin.go @@ -0,0 +1,36 @@ +//go:build darwin + +package control + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +type platformPeerAuthenticator struct{} + +func (platformPeerAuthenticator) PeerUID(conn net.Conn) (uint32, error) { + unixConn, ok := conn.(*net.UnixConn) + if !ok { + return 0, fmt.Errorf("control connection is %T, not *net.UnixConn", conn) + } + raw, err := unixConn.SyscallConn() + if err != nil { + return 0, fmt.Errorf("access control socket: %w", err) + } + var ( + credential *unix.Xucred + credentialErr error + ) + if err := raw.Control(func(fd uintptr) { + credential, credentialErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + }); err != nil { + return 0, fmt.Errorf("inspect control peer: %w", err) + } + if credentialErr != nil { + return 0, fmt.Errorf("inspect control peer credentials: %w", credentialErr) + } + return credential.Uid, nil +} diff --git a/environment/microvm/control/peer_linux.go b/environment/microvm/control/peer_linux.go new file mode 100644 index 000000000..00ada2e52 --- /dev/null +++ b/environment/microvm/control/peer_linux.go @@ -0,0 +1,36 @@ +//go:build linux + +package control + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +type platformPeerAuthenticator struct{} + +func (platformPeerAuthenticator) PeerUID(conn net.Conn) (uint32, error) { + unixConn, ok := conn.(*net.UnixConn) + if !ok { + return 0, fmt.Errorf("control connection is %T, not *net.UnixConn", conn) + } + raw, err := unixConn.SyscallConn() + if err != nil { + return 0, fmt.Errorf("access control socket: %w", err) + } + var ( + credential *unix.Ucred + credentialErr error + ) + if err := raw.Control(func(fd uintptr) { + credential, credentialErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }); err != nil { + return 0, fmt.Errorf("inspect control peer: %w", err) + } + if credentialErr != nil { + return 0, fmt.Errorf("inspect control peer credentials: %w", credentialErr) + } + return credential.Uid, nil +} diff --git a/environment/microvm/control/peer_unsupported.go b/environment/microvm/control/peer_unsupported.go new file mode 100644 index 000000000..b62c4e6f2 --- /dev/null +++ b/environment/microvm/control/peer_unsupported.go @@ -0,0 +1,14 @@ +//go:build !linux && !darwin + +package control + +import ( + "fmt" + "net" +) + +type platformPeerAuthenticator struct{} + +func (platformPeerAuthenticator) PeerUID(net.Conn) (uint32, error) { + return 0, fmt.Errorf("Unix peer credentials are unsupported on this platform") +} diff --git a/environment/microvm/control/protocol.go b/environment/microvm/control/protocol.go new file mode 100644 index 000000000..75f735598 --- /dev/null +++ b/environment/microvm/control/protocol.go @@ -0,0 +1,175 @@ +package control + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" +) + +const ( + // ProtocolVersion is the only guest protocol version accepted by this release. + ProtocolVersion uint16 = 1 + // DefaultMaxMessageBytes is the hard upper bound negotiated for protocol messages. + DefaultMaxMessageBytes uint32 = 1 << 20 +) + +var ( + // ErrCapabilityMismatch reports a missing mandatory guest capability. + ErrCapabilityMismatch = errors.New("microvm guest capability mismatch") + // ErrMessageBound reports a missing or invalid negotiated message bound. + ErrMessageBound = errors.New("microvm guest message bound is missing") + // ErrProtocolVersion reports an incompatible guest protocol version. + ErrProtocolVersion = errors.New("microvm guest protocol version mismatch") + // ErrFrameTooLarge reports a frame larger than the codec's immutable bound. + ErrFrameTooLarge = errors.New("microvm protocol frame exceeds bound") + // ErrMalformedFrame reports invalid bounded protocol framing or JSON. + ErrMalformedFrame = errors.New("malformed microvm protocol frame") +) + +// Capability is a guest service property required before any operation is sent. +type Capability string + +const ( + // CapabilityFilesystem requires version-aware guest filesystem operations. + CapabilityFilesystem Capability = "filesystem" + // CapabilityStreaming requires ordered output streaming. + CapabilityStreaming Capability = "streaming" + // CapabilityCancellation requires guest process-group cancellation. + CapabilityCancellation Capability = "cancellation" + // CapabilityGenerationBinding requires every operation to bind one generation. + CapabilityGenerationBinding Capability = "generation-binding" + // CapabilityMessageBound requires a negotiated maximum message size. + CapabilityMessageBound Capability = "message-bound" +) + +// Capabilities is a deterministic capability list. +type Capabilities []Capability + +// RequiredCapabilities returns a fresh list of all mandatory version-1 capabilities. +func RequiredCapabilities() Capabilities { + return Capabilities{ + CapabilityFilesystem, + CapabilityStreaming, + CapabilityCancellation, + CapabilityGenerationBinding, + CapabilityMessageBound, + } +} + +// Without returns a copy excluding capability. +func (c Capabilities) Without(capability Capability) Capabilities { + result := make(Capabilities, 0, len(c)) + for _, candidate := range c { + if candidate != capability { + result = append(result, candidate) + } + } + return result +} + +func (c Capabilities) contains(capability Capability) bool { + for _, candidate := range c { + if candidate == capability { + return true + } + } + return false +} + +// Agreement is the capability set and bound selected for a connection. +type Agreement struct { + Version uint16 `json:"version"` + Capabilities Capabilities `json:"capabilities"` + MaxMessageBytes uint32 `json:"max_message_bytes"` +} + +// Codec reads and writes unsigned 32-bit big-endian length-prefixed JSON frames. +type Codec struct { + max uint32 +} + +// NewCodec creates a codec with an immutable per-frame byte bound. +func NewCodec(maxMessageBytes uint32) Codec { + return Codec{max: maxMessageBytes} +} + +// Write emits exactly one bounded frame. +func (c Codec) Write(dst io.Writer, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("encode microvm protocol frame: %w", err) + } + if len(payload) == 0 || uint64(len(payload)) > uint64(c.max) { + return ErrFrameTooLarge + } + var header [4]byte + // The payload was bounded by a uint32-valued maximum above. + binary.BigEndian.PutUint32(header[:], uint32(len(payload))) //nolint:gosec // proven in the preceding check + frame := make([]byte, len(header)+len(payload)) + copy(frame, header[:]) + copy(frame[len(header):], payload) + if err := writeFull(dst, frame); err != nil { + return fmt.Errorf("write microvm protocol frame: %w", err) + } + return nil +} + +func writeFull(dst io.Writer, frame []byte) error { + for len(frame) > 0 { + written, err := dst.Write(frame) + if err != nil { + return err + } + if written <= 0 || written > len(frame) { + return io.ErrShortWrite + } + frame = frame[written:] + } + return nil +} + +// Read receives exactly one bounded frame and rejects trailing JSON values. +func (c Codec) Read(src io.Reader, value any) error { + var header [4]byte + if _, err := io.ReadFull(src, header[:]); err != nil { + return fmt.Errorf("read microvm protocol frame header: %w", err) + } + size := binary.BigEndian.Uint32(header[:]) + if size == 0 { + return ErrMalformedFrame + } + if size > c.max { + return ErrFrameTooLarge + } + payload := make([]byte, size) + if _, err := io.ReadFull(src, payload); err != nil { + return fmt.Errorf("read microvm protocol frame body: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return fmt.Errorf("%w: %v", ErrMalformedFrame, err) + } + if decoder.Decode(&struct{}{}) != io.EOF { + return ErrMalformedFrame + } + return nil +} + +func validateAgreement(agreement Agreement, version uint16, capabilities Capabilities, maxMessageBytes uint32) error { + if version != ProtocolVersion || agreement.Version != ProtocolVersion { + return ErrProtocolVersion + } + for _, capability := range RequiredCapabilities() { + if !capabilities.contains(capability) || !agreement.Capabilities.contains(capability) { + return fmt.Errorf("%w: missing %s", ErrCapabilityMismatch, capability) + } + } + if maxMessageBytes == 0 || agreement.MaxMessageBytes == 0 || agreement.MaxMessageBytes > maxMessageBytes || agreement.MaxMessageBytes > DefaultMaxMessageBytes { + return ErrMessageBound + } + return nil +} diff --git a/environment/microvm/control/protocol_test.go b/environment/microvm/control/protocol_test.go new file mode 100644 index 000000000..2bac78453 --- /dev/null +++ b/environment/microvm/control/protocol_test.go @@ -0,0 +1,197 @@ +package control + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "go/ast" + "go/parser" + "go/token" + "io" + "net" + "os" + "strings" + "testing" +) + +func TestMicroVMEnvironments_Scenario2_CapabilityNegotiationFailsClosed(t *testing.T) { + binding := Binding{ + Owner: "caller:alice", + SessionID: "session-1", + EnvironmentID: "environment-1", + Ref: "microvm:environment-1", + Generation: 7, + } + key := []byte("0123456789abcdef0123456789abcdef") + + for _, missing := range RequiredCapabilities() { + t.Run("missing "+string(missing), func(t *testing.T) { + hello := validMultiplexHello(t, binding, key) + hello.Capabilities = hello.Capabilities.Without(missing) + reply, serverErr := exchangeProductionHandshake(t, binding, key, hello) + if !errors.Is(serverErr, ErrCapabilityMismatch) { + t.Fatalf("production multiplex without %q: got %v, want ErrCapabilityMismatch", missing, serverErr) + } + if reply.ErrorCode != "capability_mismatch" { + t.Fatalf("production multiplex reply without %q: got %q, want capability_mismatch", missing, reply.ErrorCode) + } + }) + } + + t.Run("missing message bound", func(t *testing.T) { + hello := validMultiplexHello(t, binding, key) + hello.MaxMessageBytes = 0 + reply, serverErr := exchangeProductionHandshake(t, binding, key, hello) + if !errors.Is(serverErr, ErrMessageBound) || reply.ErrorCode != "message_bound" { + t.Fatalf("zero message bound: server=%v reply=%q, want ErrMessageBound", serverErr, reply.ErrorCode) + } + }) + + t.Run("wrong generation", func(t *testing.T) { + hello := validMultiplexHello(t, binding, key) + hello.Binding.Generation++ + reply, serverErr := exchangeProductionHandshake(t, binding, key, hello) + if !errors.Is(serverErr, ErrUnauthenticatedCapability) || reply.ErrorCode != "unauthenticated" { + t.Fatalf("wrong generation: server=%v reply=%q, want unauthenticated", serverErr, reply.ErrorCode) + } + }) + + t.Run("wrong protocol version", func(t *testing.T) { + hello := validMultiplexHello(t, binding, key) + hello.Version++ + reply, serverErr := exchangeProductionHandshake(t, binding, key, hello) + if !errors.Is(serverErr, ErrProtocolVersion) || reply.ErrorCode != "protocol_version" { + t.Fatalf("wrong version: server=%v reply=%q, want ErrProtocolVersion", serverErr, reply.ErrorCode) + } + }) + + t.Run("production client receives negotiated capabilities", func(t *testing.T) { + issuer, err := NewCapabilityIssuer(key) + if err != nil { + t.Fatal(err) + } + capability, err := issuer.Issue(binding) + if err != nil { + t.Fatal(err) + } + verifier, err := NewCapabilityVerifier(key) + if err != nil { + t.Fatal(err) + } + host, guest := net.Pipe() + done := make(chan error, 1) + go func() { + done <- ServeMultiplex(context.Background(), guest, binding, verifier, map[ServiceName]Handler{ + ServiceWorkspace: func(context.Context, string, json.RawMessage, func(any) error) (any, string, error) { + return struct{}{}, "", nil + }, + ServiceExec: func(context.Context, string, json.RawMessage, func(any) error) (any, string, error) { + return struct{}{}, "", nil + }, + }, DefaultMaxMessageBytes) + }() + client, err := OpenClient(context.Background(), host, binding, capability, []ServiceName{ServiceWorkspace, ServiceExec}, DefaultMaxMessageBytes) + if err != nil { + t.Fatalf("OpenClient: %v", err) + } + agreement := client.Agreement() + if err := validateAgreement(agreement, ProtocolVersion, RequiredCapabilities(), DefaultMaxMessageBytes); err != nil { + t.Fatalf("negotiated production agreement: %v", err) + } + _ = client.Close() + _ = guest.Close() + if err := <-done; err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) && !errors.Is(err, os.ErrClosed) { + t.Fatalf("ServeMultiplex: %v", err) + } + }) + + t.Run("oversized frame", func(t *testing.T) { + var frame bytes.Buffer + if err := NewCodec(64).Write(&frame, map[string]string{"value": "too large"}); err != nil { + t.Fatalf("write oversized test frame: %v", err) + } + var value map[string]string + if err := NewCodec(8).Read(&frame, &value); !errors.Is(err, ErrFrameTooLarge) { + t.Fatalf("read oversized frame: got %v, want ErrFrameTooLarge", err) + } + }) + + if _, err := GuestControlOption("/run/mecatl/guest-control.sock"); err != nil { + t.Fatalf("wire go-microvm vsock-to-UDS control transport: %v", err) + } + if _, err := GuestControlOption("relative-control.sock"); err == nil { + t.Fatal("relative guest control socket unexpectedly accepted") + } +} + +func TestGuestProtocolHasSingleProductionHandshake(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + file, err := parser.ParseFile(fset, entry.Name(), nil, 0) + if err != nil { + t.Fatal(err) + } + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && (fn.Name.Name == "AcceptGuest" || fn.Name.Name == "OpenGuest") { + t.Fatalf("dead independent handshake %s remains in %s", fn.Name.Name, entry.Name()) + } + } + } +} + +func validMultiplexHello(t *testing.T, binding Binding, key []byte) multiplexHello { + t.Helper() + issuer, err := NewCapabilityIssuer(key) + if err != nil { + t.Fatal(err) + } + capability, err := issuer.Issue(binding) + if err != nil { + t.Fatal(err) + } + return multiplexHello{ + Version: ProtocolVersion, Binding: binding, Capability: capability, + Services: []ServiceName{ServiceWorkspace, ServiceExec}, Capabilities: RequiredCapabilities(), + MaxMessageBytes: DefaultMaxMessageBytes, + } +} + +func exchangeProductionHandshake(t *testing.T, expected Binding, key []byte, hello multiplexHello) (multiplexReply, error) { + t.Helper() + verifier, err := NewCapabilityVerifier(key) + if err != nil { + t.Fatal(err) + } + host, guest := net.Pipe() + serverErr := make(chan error, 1) + go func() { + serverErr <- ServeMultiplex(context.Background(), guest, expected, verifier, map[ServiceName]Handler{ + ServiceWorkspace: func(context.Context, string, json.RawMessage, func(any) error) (any, string, error) { + return struct{}{}, "", nil + }, + ServiceExec: func(context.Context, string, json.RawMessage, func(any) error) (any, string, error) { + return struct{}{}, "", nil + }, + }, DefaultMaxMessageBytes) + }() + codec := NewCodec(DefaultMaxMessageBytes) + if err := codec.Write(host, hello); err != nil { + t.Fatal(err) + } + var reply multiplexReply + if err := codec.Read(host, &reply); err != nil { + t.Fatal(err) + } + _ = host.Close() + _ = guest.Close() + return reply, <-serverErr +} diff --git a/environment/microvm/control/service.go b/environment/microvm/control/service.go new file mode 100644 index 000000000..9c2183b5b --- /dev/null +++ b/environment/microvm/control/service.go @@ -0,0 +1,69 @@ +package control + +import ( + "fmt" + "net" +) + +// PeerAuthenticator extracts kernel-authenticated credentials from a local connection. +type PeerAuthenticator interface { + PeerUID(net.Conn) (uint32, error) +} + +// ServiceConfig configures the private local microvmd control service. +type ServiceConfig struct { + AccountUID uint32 + Bindings []Binding + PeerAuthenticator PeerAuthenticator +} + +// Service authorizes local control operations against kernel peer credentials and +// the complete immutable environment binding. +type Service struct { + accountUID uint32 + bindings map[string]Binding + peers PeerAuthenticator +} + +// NewService constructs a local control service. Bindings are copied. +func NewService(cfg ServiceConfig) (*Service, error) { + peers := cfg.PeerAuthenticator + if peers == nil { + peers = platformPeerAuthenticator{} + } + bindings := make(map[string]Binding, len(cfg.Bindings)) + for _, binding := range cfg.Bindings { + if err := binding.validate(); err != nil { + return nil, err + } + if _, exists := bindings[binding.EnvironmentID]; exists { + return nil, fmt.Errorf("duplicate microvm environment binding %q", binding.EnvironmentID) + } + bindings[binding.EnvironmentID] = binding + } + return &Service{accountUID: cfg.AccountUID, bindings: bindings, peers: peers}, nil +} + +// Authenticate verifies the Unix peer before any caller-controlled identifier is read. +func (s *Service) Authenticate(conn net.Conn) error { + if s == nil || s.peers == nil { + return ErrUnauthenticatedPeer + } + uid, err := s.peers.PeerUID(conn) + if err != nil || uid != s.accountUID { + return ErrUnauthenticatedPeer + } + return nil +} + +// Authorize authenticates the Unix peer before looking up or comparing identifiers. +func (s *Service) Authorize(conn net.Conn, claim Binding) error { + if err := s.Authenticate(conn); err != nil { + return err + } + expected, ok := s.bindings[claim.EnvironmentID] + if !ok || expected != claim { + return ErrBindingMismatch + } + return nil +} diff --git a/environment/microvm/control/service_test.go b/environment/microvm/control/service_test.go new file mode 100644 index 000000000..6bbc15297 --- /dev/null +++ b/environment/microvm/control/service_test.go @@ -0,0 +1,92 @@ +package control_test + +import ( + "errors" + "net" + "os" + "path/filepath" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/control/controltest" +) + +func TestMicroVMEnvironments_Scenario2_LocalPeerCredentialsBindOwner(t *testing.T) { + binding := control.Binding{ + Owner: "caller:alice", + SessionID: "session-1", + EnvironmentID: "environment-1", + Ref: "microvm:environment-1", + Generation: 7, + } + service, err := control.NewService(control.ServiceConfig{ + AccountUID: uint32(os.Getuid()), + Bindings: []control.Binding{binding}, + }) + if err != nil { + t.Fatalf("new control service: %v", err) + } + + socketPath := filepath.Join(t.TempDir(), "control.sock") + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) + if err != nil { + t.Fatalf("listen on local control socket: %v", err) + } + defer listener.Close() + + accepted := make(chan *net.UnixConn, 1) + acceptErr := make(chan error, 1) + go func() { + conn, acceptErrValue := listener.AcceptUnix() + if acceptErrValue != nil { + acceptErr <- acceptErrValue + return + } + accepted <- conn + }() + + client, err := net.DialUnix("unix", nil, listener.Addr().(*net.UnixAddr)) + if err != nil { + t.Fatalf("dial local control socket: %v", err) + } + defer client.Close() + + var serverConn *net.UnixConn + select { + case serverConn = <-accepted: + defer serverConn.Close() + case err = <-acceptErr: + t.Fatalf("accept local control socket: %v", err) + } + + if err := service.Authorize(serverConn, binding); err != nil { + t.Fatalf("authorize configured local account: %v", err) + } + + for name, mutate := range map[string]func(*control.Binding){ + "owner": func(got *control.Binding) { got.Owner = "caller:mallory" }, + "session": func(got *control.Binding) { got.SessionID = "session-guessed" }, + "ref": func(got *control.Binding) { got.Ref = "microvm:stale" }, + "generation": func(got *control.Binding) { got.Generation++ }, + } { + t.Run(name+" mismatch", func(t *testing.T) { + claim := binding + mutate(&claim) + if err := service.Authorize(serverConn, claim); !errors.Is(err, control.ErrBindingMismatch) { + t.Fatalf("authorize mismatched %s: got %v, want ErrBindingMismatch", name, err) + } + }) + } + + foreignService, err := control.NewService(control.ServiceConfig{ + AccountUID: 2000, + Bindings: []control.Binding{binding}, + PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 2001}, + }) + if err != nil { + t.Fatalf("new foreign-peer control service: %v", err) + } + if err := foreignService.Authorize(serverConn, binding); !errors.Is(err, control.ErrUnauthenticatedPeer) { + t.Fatalf("authorize guessed identifiers from foreign peer: got %v, want ErrUnauthenticatedPeer", err) + } +} diff --git a/environment/microvm/control/transport.go b/environment/microvm/control/transport.go new file mode 100644 index 000000000..18f71627e --- /dev/null +++ b/environment/microvm/control/transport.go @@ -0,0 +1,21 @@ +package control + +import ( + "fmt" + "path/filepath" + + gomicrovm "github.com/stacklok/go-microvm" +) + +// GuestControlPort is the reserved versioned guest-control vsock port. +const GuestControlPort uint32 = 10777 + +// GuestControlOption wires the guest control channel through go-microvm's +// vsock-to-Unix-domain-socket primitive. SSH and host-local fallbacks are not part +// of the control protocol. +func GuestControlOption(socketPath string) (gomicrovm.Option, error) { + if socketPath == "" || !filepath.IsAbs(socketPath) { + return nil, fmt.Errorf("microvm guest control socket path must be absolute") + } + return gomicrovm.WithVsock(GuestControlPort, socketPath), nil +} diff --git a/environment/microvm/daemon.go b/environment/microvm/daemon.go new file mode 100644 index 000000000..30d695aac --- /dev/null +++ b/environment/microvm/daemon.go @@ -0,0 +1,907 @@ +package microvm + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "sort" + "strings" + "sync" + + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/environment/microvm/control" +) + +// LifecycleProtocolVersion is the local microvmd management protocol version. +const LifecycleProtocolVersion uint16 = 3 + +// LifecycleOperation is one closed management operation. +type LifecycleOperation string + +const ( + // LifecycleInfo returns the authenticated serving daemon identity and loaded policy. + LifecycleInfo LifecycleOperation = "info" + // LifecycleCreate provisions and durably registers one new generation. + LifecycleCreate LifecycleOperation = "create" + // LifecycleResolve reattaches one exact ready generation. + LifecycleResolve LifecycleOperation = "resolve" + // LifecycleInspect verifies one exact ready runtime identity. + LifecycleInspect LifecycleOperation = "inspect" + // LifecycleDetach drops process-local handles without changing durable state. + LifecycleDetach LifecycleOperation = "detach" + // LifecycleDelete tombstones and destroys one exact generation. + LifecycleDelete LifecycleOperation = "delete" + // LifecycleWorkspace proxies bounded guest filesystem calls. + LifecycleWorkspace LifecycleOperation = "workspace" + // LifecycleExec proxies bounded guest command execution. + LifecycleExec LifecycleOperation = "exec" + // LifecycleFork creates one isolated child generation from the bound parent. + LifecycleFork LifecycleOperation = "fork" + // LifecycleMerge conflict-checks and applies one bound child to its parent. + LifecycleMerge LifecycleOperation = "merge" + // LifecycleMetrics exports the fixed-dimension operations snapshot. + LifecycleMetrics LifecycleOperation = "metrics" + // LifecycleInventory returns one bounded owner-filtered generation inventory. + LifecycleInventory LifecycleOperation = "inventory" + // LifecycleReconcile re-drives durable cleanup without provisioning a generation. + LifecycleReconcile LifecycleOperation = "reconcile" + // LifecycleChildDelete force-cleans an exact delegated child generation. + LifecycleChildDelete LifecycleOperation = "child-delete" +) + +var errLifecycleProtocol = errors.New("invalid microvmd lifecycle protocol request") + +// LifecycleRequest is one bounded request on the authenticated daemon socket. +// Create binds the requested owner/session and returns the allocated ref/generation; +// every operation on an existing environment requires the complete Binding. +type LifecycleRequest struct { + Version uint16 `json:"version"` + Operation LifecycleOperation `json:"operation"` + Binding control.Binding `json:"binding"` + Create *CreateRequest `json:"create,omitempty"` + Provision *ProvisionRequest `json:"provision,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// ProvisionRequest is the thin root-module create shape. The daemon expands +// operator-owned artifact and resource policy before entering Lifecycle.Create. +type ProvisionRequest struct { + Owner string `json:"owner"` + SessionID string `json:"session_id"` + Profile string `json:"profile"` + SourceCheckout string `json:"source_checkout"` +} + +// DaemonInfo is the authenticated identity of the process serving this socket. +type DaemonInfo struct { + ProtocolVersion uint16 `json:"protocol_version"` + ReleaseIdentity string `json:"release_identity"` + BinaryIdentity string `json:"binary_identity"` + ConfigDigest string `json:"config_digest"` + PolicyRevision string `json:"policy_revision"` + Profiles []string `json:"profiles"` + Socket string `json:"socket"` +} + +// Equal reports an exact compatibility match, including profile order. +func (d DaemonInfo) Equal(other DaemonInfo) bool { + if d.ProtocolVersion != other.ProtocolVersion || d.ReleaseIdentity != other.ReleaseIdentity || d.BinaryIdentity != other.BinaryIdentity || d.ConfigDigest != other.ConfigDigest || d.PolicyRevision != other.PolicyRevision || d.Socket != other.Socket || len(d.Profiles) != len(other.Profiles) { + return false + } + for i := range d.Profiles { + if d.Profiles[i] != other.Profiles[i] { + return false + } + } + return true +} + +// LifecycleCreated is the non-resource-handle create result carried on the wire. +type LifecycleCreated struct { + Ref EnvironmentRef `json:"ref"` + Generation uint32 `json:"generation"` + HostWorktree string `json:"host_worktree"` + GuestRoot string `json:"guest_root"` + Profile string `json:"profile"` + GuestEgress string `json:"guest_egress"` + HostEgress string `json:"host_egress"` +} + +// LifecycleExecStream is one ordered stdout or stderr chunk preceding the final response. +type LifecycleExecStream struct { + Channel string `json:"channel"` + Data []byte `json:"data"` +} + +// LifecycleResponse reports the exact durable generation observed after an operation. +type LifecycleResponse struct { + Binding control.Binding `json:"binding,omitempty"` + Record *EnvironmentRecord `json:"record,omitempty"` + Created *LifecycleCreated `json:"created,omitempty"` + Stream *LifecycleExecStream `json:"stream,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorText string `json:"error,omitempty"` + Err error `json:"-"` +} + +// LifecycleGenerationHealth is the operator-facing health of one exact generation. +type LifecycleGenerationHealth string + +const ( + // GenerationHealthy means the exact runtime identity is live. + GenerationHealthy LifecycleGenerationHealth = "healthy" + // GenerationStale means the durable generation is not currently reattachable. + GenerationStale LifecycleGenerationHealth = "stale" + // GenerationError means the runtime health probe itself failed. + GenerationError LifecycleGenerationHealth = "error" +) + +const ( + defaultInventoryPageSize = 50 + maxInventoryPageSize = 64 + maxInventoryTokenBytes = 1024 +) + +// LifecycleInventoryRequest asks for one deterministic owner-scoped page. +type LifecycleInventoryRequest struct { + PageSize int `json:"page_size,omitempty"` + Continuation string `json:"continuation,omitempty"` +} + +// LifecycleInventoryPage is one bounded page and its opaque continuation. +type LifecycleInventoryPage struct { + Entries []LifecycleInventoryEntry `json:"entries"` + Continuation string `json:"continuation,omitempty"` +} + +// LifecycleInventoryEntry is the bounded, non-secret lifecycle projection. +type LifecycleInventoryEntry struct { + Owner string `json:"owner"` + SessionID string `json:"session_id"` + EnvironmentID string `json:"environment_id"` + Ref string `json:"ref"` + WorktreePath string `json:"worktree_path"` + Generation uint32 `json:"generation"` + State EnvironmentState `json:"state"` + Health LifecycleGenerationHealth `json:"health"` + Error string `json:"error,omitempty"` +} + +// LifecycleDeleteResult reports whether the exact generation's worktree was removed. +type LifecycleDeleteResult struct { + WorktreePath string `json:"worktree_path"` + WorktreeRetained bool `json:"worktree_retained"` +} + +// ChildForkPayload is the bounded descriptive input for a child fork. +type ChildForkPayload struct { + Label string `json:"label"` +} + +// ChildMergePayload identifies the exact child generation merged into Binding. +type ChildMergePayload struct { + Child control.Binding `json:"child"` +} + +// ChildLifecycle owns daemon-side child creation and conflict-aware merge. +type ChildLifecycle interface { + Fork(context.Context, EnvironmentRecord, string) (EnvironmentRecord, error) + Merge(context.Context, EnvironmentRecord, EnvironmentRecord) error +} + +// EnvironmentCreator is the lifecycle transaction used by the create RPC. +type EnvironmentCreator interface { + Create(context.Context, CreateRequest) (CreatedEnvironment, error) +} + +// PlacementBuilder expands the thin root request using daemon-owned policy. +type PlacementBuilder func(context.Context, ProvisionRequest) (CreateRequest, error) + +// LifecycleReconciler repairs durable lifecycle state without provisioning. +type LifecycleReconciler interface { + Reconcile(context.Context) error +} + +// RepositoryPlacementBuilder resolves daemon-owned profile and immutable artifact +// policy into one repository-scoped logical attachment request. +type RepositoryPlacementBuilder func(context.Context, ProvisionRequest) (LogicalEnvironmentRequest, EnforcedProfileStatus, error) + +// DaemonConfig wires the authenticated protocol to durable lifecycle seams. +type DaemonConfig struct { + Control *control.Service + Creator EnvironmentCreator + Provisioner PlacementBuilder + Registry ReconcileRegistry + Runtime LifecycleRuntime + Worktrees WorktreeRetention + Admission *AdmissionController + Children ChildLifecycle + Reconciler LifecycleReconciler + Observer *OperationsObserver + Info DaemonInfo + RepositoryAttachments *RepositoryAttachmentManager + RepositoryProvisioner RepositoryPlacementBuilder + RepositoryStartupError error +} + +type repositoryDaemonBinding struct { + binding control.Binding + status EnforcedProfileStatus +} + +// Daemon owns the local management protocol. Hypervisor creation remains +// exclusively behind Lifecycle -> VMRuntime. +type Daemon struct { + control *control.Service + creator EnvironmentCreator + provisioner PlacementBuilder + registry ReconcileRegistry + runtime LifecycleRuntime + manager *EnvironmentManager + children ChildLifecycle + reconciler LifecycleReconciler + admission *AdmissionController + observer *OperationsObserver + info DaemonInfo + repositoryAttachments *RepositoryAttachmentManager + repositoryProvisioner RepositoryPlacementBuilder + repositoryStartupErr error + repositoryMu sync.Mutex + repositoryBindings map[string]repositoryDaemonBinding + inventoryKey [sha256.Size]byte + mergeLocks [mergeLockStripes]sync.Mutex +} + +// NewDaemon constructs the fail-closed local lifecycle service. +func NewDaemon(cfg DaemonConfig) (*Daemon, error) { + repositoryOnly := cfg.RepositoryAttachments != nil + if cfg.Control == nil || (!repositoryOnly && (cfg.Registry == nil || cfg.Runtime == nil || cfg.Worktrees == nil)) { + return nil, errors.New("microvmd lifecycle service is not fully configured") + } + var manager *EnvironmentManager + if !repositoryOnly { + manager = NewEnvironmentManagerWithAdmission(cfg.Registry, cfg.Runtime, cfg.Worktrees, cfg.Admission, cfg.Observer) + } + daemon := &Daemon{ + control: cfg.Control, creator: cfg.Creator, provisioner: cfg.Provisioner, registry: cfg.Registry, runtime: cfg.Runtime, + manager: manager, + children: cfg.Children, reconciler: cfg.Reconciler, admission: cfg.Admission, observer: cfg.Observer, info: cfg.Info, + repositoryAttachments: cfg.RepositoryAttachments, repositoryProvisioner: cfg.RepositoryProvisioner, + repositoryStartupErr: cfg.RepositoryStartupError, + repositoryBindings: make(map[string]repositoryDaemonBinding), + } + if _, err := rand.Read(daemon.inventoryKey[:]); err != nil { + return nil, fmt.Errorf("initialize microvmd inventory pagination: %w", err) + } + return daemon, nil +} + +type lifecycleServeError struct { + operation LifecycleOperation + code string + cause error +} + +func (e *lifecycleServeError) Error() string { return e.cause.Error() } +func (e *lifecycleServeError) Unwrap() error { return e.cause } + +func newLifecycleServeError(operation LifecycleOperation, code string, cause error) error { + return &lifecycleServeError{operation: operation, code: code, cause: cause} +} + +// ServeConn authenticates the peer, then reads and writes one bounded lifecycle +// exchange on a local socket. Exec responses are ordered stream frames followed by +// one final exit response. +func (d *Daemon) ServeConn(ctx context.Context, conn net.Conn) error { + if d == nil || d.control == nil { + return errors.New("microvmd lifecycle service is not configured") + } + if err := d.control.Authenticate(conn); err != nil { + return newLifecycleServeError("connection", lifecycleErrorCode(err), err) + } + codec := control.NewCodec(control.DefaultMaxMessageBytes) + var request LifecycleRequest + if err := codec.Read(conn, &request); err != nil { + return newLifecycleServeError("connection", "transport", err) + } + requestCtx, cancel := context.WithCancel(ctx) + defer cancel() + go func() { + var probe [1]byte + _, _ = conn.Read(probe[:]) + cancel() + }() + write := func(response LifecycleResponse) error { return codec.Write(conn, response) } + var response LifecycleResponse + if request.Operation == LifecycleExec { + response = d.handleExecStream(requestCtx, request, func(frame LifecycleExecStream) error { + return write(LifecycleResponse{Stream: &frame}) + }) + } else { + response = d.handleAuthenticated(requestCtx, request) + } + if err := write(response); err != nil { + return newLifecycleServeError(request.Operation, "transport", err) + } + if response.Err != nil { + return newLifecycleServeError(request.Operation, response.ErrorCode, response.Err) + } + return nil +} + +// Handle authenticates the peer before examining any caller-controlled identity, +// then binds the operation to the authoritative durable registry record. +func (d *Daemon) Handle(ctx context.Context, conn net.Conn, request LifecycleRequest) LifecycleResponse { + if d == nil || d.control == nil { + return lifecycleFailure(errors.New("microvmd lifecycle service is not configured")) + } + if err := d.control.Authenticate(conn); err != nil { + return lifecycleFailure(err) + } + return d.handleAuthenticated(ctx, request) +} + +func (d *Daemon) handleExecStream(ctx context.Context, request LifecycleRequest, send func(LifecycleExecStream) error) LifecycleResponse { + if request.Version != LifecycleProtocolVersion || request.Operation != LifecycleExec { + return lifecycleFailure(errLifecycleProtocol) + } + if d.repositoryAttachments != nil && strings.HasPrefix(request.Binding.EnvironmentID, "logical-") { + response, err := d.repositoryExecStream(ctx, request, send) + if err != nil { + return lifecycleFailure(err) + } + return response + } + record, err := d.boundRecord(ctx, request.Binding) + if err != nil { + return lifecycleFailure(err) + } + response, err := d.proxyExecStream(ctx, request, record, send) + if err != nil { + return lifecycleFailure(err) + } + return response +} + +func (d *Daemon) handleAuthenticated(ctx context.Context, request LifecycleRequest) LifecycleResponse { + if request.Version != LifecycleProtocolVersion { + return lifecycleFailure(errLifecycleProtocol) + } + if response, handled := d.handleRepositoryRequest(ctx, request); handled { + return response + } + return d.handleStandardRequest(ctx, request) +} + +func (d *Daemon) handleStandardRequest(ctx context.Context, request LifecycleRequest) LifecycleResponse { //nolint:gocyclo // closed protocol routing is clearest as one switch + if d.repositoryAttachments != nil { + switch request.Operation { + case LifecycleInfo, LifecycleInventory, LifecycleReconcile, LifecycleMetrics: + default: + return lifecycleFailure(ErrEnvironmentUnavailable) + } + } + var response LifecycleResponse + var err error + switch request.Operation { + case LifecycleInfo: + if request.Binding != (control.Binding{}) || request.Create != nil || request.Provision != nil || len(request.Payload) != 0 || d.info.ProtocolVersion != LifecycleProtocolVersion { + err = errLifecycleProtocol + break + } + if d.repositoryStartupErr != nil { + err = d.repositoryStartupErr + break + } + response.Payload, err = json.Marshal(d.info) + case LifecycleCreate: + response, err = d.create(ctx, request) + case LifecycleResolve, LifecycleInspect, LifecycleDetach, LifecycleDelete, LifecycleChildDelete, LifecycleWorkspace, LifecycleExec: + response, err = d.existing(ctx, request) + case LifecycleFork: + response, err = d.fork(ctx, request) + case LifecycleMerge: + response, err = d.merge(ctx, request) + case LifecycleMetrics: + if d.observer == nil || request.Binding != (control.Binding{}) || request.Create != nil || request.Provision != nil { + err = errLifecycleProtocol + break + } + response.Payload, err = json.Marshal(d.observer.Snapshot()) + case LifecycleInventory: + response, err = d.inventory(ctx, request) + case LifecycleReconcile: + err = d.reconcile(ctx, request) + default: + err = errLifecycleProtocol + } + if err != nil { + return lifecycleFailure(err) + } + return response +} + +func (d *Daemon) inventory(ctx context.Context, request LifecycleRequest) (LifecycleResponse, error) { + if err := validateOwnerRequest(request, true); err != nil { + return LifecycleResponse{}, err + } + pageRequest := LifecycleInventoryRequest{PageSize: defaultInventoryPageSize} + if len(request.Payload) != 0 { + if len(request.Payload) > maxInventoryTokenBytes+128 || json.Unmarshal(request.Payload, &pageRequest) != nil { + return LifecycleResponse{}, errLifecycleProtocol + } + } + if pageRequest.PageSize <= 0 { + pageRequest.PageSize = defaultInventoryPageSize + } + if pageRequest.PageSize > maxInventoryPageSize { + pageRequest.PageSize = maxInventoryPageSize + } + cursor, err := d.decodeInventoryCursor(request.Binding.Owner, pageRequest.Continuation) + if err != nil { + return LifecycleResponse{}, err + } + if d.repositoryAttachments != nil { + return d.repositoryInventory(ctx, request.Binding.Owner, pageRequest.PageSize, cursor) + } + records, err := d.registry.List(ctx) + if err != nil { + return LifecycleResponse{}, err + } + records = ownerInventoryRecords(records, request.Binding.Owner) + sort.Slice(records, func(i, j int) bool { return inventoryRecordLess(records[i], records[j]) }) + start := sort.Search(len(records), func(i int) bool { return inventoryRecordAfter(records[i], cursor) }) + end := min(start+pageRequest.PageSize, len(records)) + entries := make([]LifecycleInventoryEntry, 0, end-start) + for _, record := range records[start:end] { + entry := LifecycleInventoryEntry{ + Owner: record.Owner, SessionID: record.SessionID, EnvironmentID: record.EnvironmentID, + Ref: record.Ref.ID, Generation: record.Generation, WorktreePath: record.WorktreePath, State: record.State, + } + if record.State == EnvironmentDestroyed || record.Tombstone { + entry.Health = GenerationStale + entry.Error = "orphan generation was identity-checked and destroyed; dirty worktree retained for recovery; explicitly create a new session to continue" + } else if record.State != EnvironmentReady { + entry.Health, entry.Error = GenerationStale, "durable generation state "+string(record.State)+" is not ready" + } else if status, inspectErr := d.runtime.Inspect(ctx, record); inspectErr != nil { + entry.Health, entry.Error = GenerationError, boundedLifecycleError(inspectErr) + } else if validateRuntimeIdentity(record, status) != nil { + entry.Health, entry.Error = GenerationStale, "runtime identity does not match the durable generation" + } else { + entry.Health = GenerationHealthy + } + entries = append(entries, entry) + } + page := LifecycleInventoryPage{Entries: entries} + if end < len(records) { + page.Continuation, err = d.encodeInventoryCursor(request.Binding.Owner, records[end-1]) + if err != nil { + return LifecycleResponse{}, err + } + } + payload, err := json.Marshal(page) + return LifecycleResponse{Payload: payload}, err +} + +func (d *Daemon) repositoryInventory(ctx context.Context, owner string, pageSize int, cursor inventoryCursor) (LifecycleResponse, error) { + records := d.repositoryAttachments.inventory(owner) + sort.Slice(records, func(i, j int) bool { + left, right := records[i].binding, records[j].binding + if left.SessionID != right.SessionID { + return left.SessionID < right.SessionID + } + if left.Ref != right.Ref { + return left.Ref < right.Ref + } + if left.Generation != right.Generation { + return left.Generation < right.Generation + } + return left.EnvironmentID < right.EnvironmentID + }) + after := func(record repositoryAttachmentRecord) bool { + if cursor == (inventoryCursor{}) { + return true + } + binding := record.binding + if binding.SessionID != cursor.SessionID { + return binding.SessionID > cursor.SessionID + } + if binding.Ref != cursor.Ref { + return binding.Ref > cursor.Ref + } + if binding.Generation != cursor.Generation { + return binding.Generation > cursor.Generation + } + return binding.EnvironmentID > cursor.EnvironmentID + } + start := sort.Search(len(records), func(i int) bool { return after(records[i]) }) + end := min(start+pageSize, len(records)) + entries := make([]LifecycleInventoryEntry, 0, end-start) + type repositoryHealthResult struct{ err error } + healthByGeneration := make(map[string]repositoryHealthResult) + for _, record := range records[start:end] { + binding := record.binding + entry := LifecycleInventoryEntry{ + Owner: binding.Owner, SessionID: binding.SessionID, EnvironmentID: binding.EnvironmentID, + Ref: binding.Ref, Generation: record.repository.Generation, + WorktreePath: record.worktreePath, State: record.repository.State, + } + switch { + case record.deleted && record.worktreeRetained: + entry.State, entry.Health = EnvironmentDestroyed, GenerationStale + entry.Error = "logical attachment was deleted; dirty worktree retained for recovery; repository VM deletion is not supported" + case record.deleted: + continue + case record.repository.State != EnvironmentReady: + entry.Health = GenerationStale + entry.Error = "repository generation state " + string(record.repository.State) + " is not ready" + default: + key := fmt.Sprintf("%s\x00%s\x00%d", record.repository.Owner, record.repository.RepositoryKey, record.repository.Generation) + health, ok := healthByGeneration[key] + if !ok { + health.err = d.repositoryAttachments.health(ctx, record.repository) + healthByGeneration[key] = health + } + switch { + case health.err == nil: + entry.Health = GenerationHealthy + case errors.Is(health.err, ErrRepositoryVMInconsistent): + entry.Health, entry.Error = GenerationStale, boundedLifecycleError(health.err) + default: + entry.Health, entry.Error = GenerationError, boundedLifecycleError(health.err) + } + } + entries = append(entries, entry) + } + page := LifecycleInventoryPage{Entries: entries} + if end < len(records) { + last := records[end-1].binding + var err error + page.Continuation, err = d.encodeInventoryCursor(owner, EnvironmentRecord{ + SessionID: last.SessionID, EnvironmentID: last.EnvironmentID, + Ref: EnvironmentRef{ID: last.Ref}, Generation: last.Generation, + }) + if err != nil { + return LifecycleResponse{}, err + } + } + payload, err := json.Marshal(page) + return LifecycleResponse{Payload: payload}, err +} + +type inventoryCursor struct { + Owner string `json:"owner"` + SessionID string `json:"session_id"` + Ref string `json:"ref"` + Generation uint32 `json:"generation"` + EnvironmentID string `json:"environment_id"` +} + +func ownerInventoryRecords(records []EnvironmentRecord, owner string) []EnvironmentRecord { + out := records[:0] + for _, record := range records { + if record.Owner != owner || ((record.State == EnvironmentDestroyed || record.Tombstone) && !record.PreserveWorktree) { + continue + } + out = append(out, record) + } + return out +} + +func inventoryRecordLess(left, right EnvironmentRecord) bool { + if left.SessionID != right.SessionID { + return left.SessionID < right.SessionID + } + if left.Ref.ID != right.Ref.ID { + return left.Ref.ID < right.Ref.ID + } + if left.Generation != right.Generation { + return left.Generation < right.Generation + } + return left.EnvironmentID < right.EnvironmentID +} + +func inventoryRecordAfter(record EnvironmentRecord, cursor inventoryCursor) bool { + if cursor == (inventoryCursor{}) { + return true + } + key := EnvironmentRecord{SessionID: cursor.SessionID, Ref: EnvironmentRef{ID: cursor.Ref}, Generation: cursor.Generation, EnvironmentID: cursor.EnvironmentID} + return inventoryRecordLess(key, record) +} + +func (d *Daemon) encodeInventoryCursor(owner string, record EnvironmentRecord) (string, error) { + payload, err := json.Marshal(inventoryCursor{Owner: owner, SessionID: record.SessionID, Ref: record.Ref.ID, Generation: record.Generation, EnvironmentID: record.EnvironmentID}) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, d.inventoryKey[:]) + _, _ = mac.Write(payload) + return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +func (d *Daemon) decodeInventoryCursor(owner, token string) (inventoryCursor, error) { + if token == "" { + return inventoryCursor{}, nil + } + if len(token) > maxInventoryTokenBytes { + return inventoryCursor{}, errLifecycleProtocol + } + parts := strings.Split(token, ".") + if len(parts) != 2 { + return inventoryCursor{}, errLifecycleProtocol + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil || base64.RawURLEncoding.EncodeToString(payload) != parts[0] { + return inventoryCursor{}, errLifecycleProtocol + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || base64.RawURLEncoding.EncodeToString(signature) != parts[1] { + return inventoryCursor{}, errLifecycleProtocol + } + mac := hmac.New(sha256.New, d.inventoryKey[:]) + _, _ = mac.Write(payload) + if !hmac.Equal(signature, mac.Sum(nil)) { + return inventoryCursor{}, errLifecycleProtocol + } + var cursor inventoryCursor + if json.Unmarshal(payload, &cursor) != nil || cursor.Owner != owner || cursor.SessionID == "" || cursor.Ref == "" || cursor.Generation == 0 || cursor.EnvironmentID == "" { + return inventoryCursor{}, errLifecycleProtocol + } + return cursor, nil +} + +func (d *Daemon) reconcile(ctx context.Context, request LifecycleRequest) error { + if err := validateOwnerRequest(request, false); err != nil { + return err + } + if d.repositoryAttachments != nil { + return d.repositoryStartupErr + } + if d.reconciler == nil { + return ErrEnvironmentUnavailable + } + return d.reconciler.Reconcile(ctx) +} + +func validateOwnerRequest(request LifecycleRequest, allowPayload bool) error { + want := control.Binding{Owner: request.Binding.Owner} + if request.Binding.Owner == "" || request.Binding != want || request.Create != nil || request.Provision != nil || (!allowPayload && len(request.Payload) != 0) { + return control.ErrBindingMismatch + } + return nil +} + +func boundedLifecycleError(err error) string { + text := strings.ToValidUTF8(err.Error(), "�") + runes := []rune(text) + if len(runes) > 512 { + text = string(runes[:512]) + } + return text +} + +func (d *Daemon) fork(ctx context.Context, request LifecycleRequest) (LifecycleResponse, error) { + if d.children == nil { + return LifecycleResponse{}, ErrEnvironmentUnavailable + } + parent, err := d.boundRecord(ctx, request.Binding) + if err != nil { + return LifecycleResponse{}, err + } + var payload ChildForkPayload + if json.Unmarshal(request.Payload, &payload) != nil || payload.Label == "" || len(payload.Label) > 256 { + return LifecycleResponse{}, errLifecycleProtocol + } + child, err := d.children.Fork(ctx, parent, payload.Label) + if err != nil { + return LifecycleResponse{}, err + } + if child.ParentRef != parent.Ref || child.State != EnvironmentReady { + return LifecycleResponse{}, ErrInvalidFork + } + return LifecycleResponse{Binding: bindingForRecord(child), Record: recordPointer(child)}, nil +} + +func (d *Daemon) merge(ctx context.Context, request LifecycleRequest) (LifecycleResponse, error) { + if d.children == nil { + return LifecycleResponse{}, ErrEnvironmentUnavailable + } + if err := claimValidate(request.Binding); err != nil { + return LifecycleResponse{}, control.ErrBindingMismatch + } + parentRef := session.EnvironmentRef{Kind: session.EnvironmentKind(Kind), ID: request.Binding.Ref} + lock := &d.mergeLocks[parentMergeLock(parentRef)] + lock.Lock() + defer lock.Unlock() + + // Binding lookup, child validation, conflict discovery, patch construction, + // revalidation, and apply are one daemon-owned transaction across clients. + parent, err := d.boundRecord(ctx, request.Binding) + if err != nil { + return LifecycleResponse{}, err + } + var payload ChildMergePayload + if json.Unmarshal(request.Payload, &payload) != nil { + return LifecycleResponse{}, errLifecycleProtocol + } + child, err := d.boundRecord(ctx, payload.Child) + if err != nil { + return LifecycleResponse{}, err + } + if child.ParentRef != parent.Ref { + return LifecycleResponse{}, ErrInvalidFork + } + if err := d.children.Merge(ctx, parent, child); err != nil { + return LifecycleResponse{}, err + } + return LifecycleResponse{Binding: bindingForRecord(parent), Record: recordPointer(parent)}, nil +} + +func (d *Daemon) create(ctx context.Context, request LifecycleRequest) (LifecycleResponse, error) { + if (request.Create == nil) == (request.Provision == nil) || request.Binding.Owner == "" || request.Binding.SessionID == "" { + return LifecycleResponse{}, errLifecycleProtocol + } + if request.Binding.EnvironmentID != "" || request.Binding.Ref != "" || request.Binding.Generation != 0 { + return LifecycleResponse{}, control.ErrBindingMismatch + } + created, err := d.performCreate(ctx, request) + if err != nil { + return LifecycleResponse{}, err + } + environmentID, generation, err := parseEnvironmentRef(created.Ref) + if err != nil || generation != created.Generation { + return LifecycleResponse{}, ErrEnvironmentStale + } + record, err := d.registry.Lookup(ctx, environmentID) + if err != nil { + return LifecycleResponse{}, fmt.Errorf("load created microvm generation: %w", err) + } + binding := bindingForRecord(record) + if binding.Owner != request.Binding.Owner || binding.SessionID != request.Binding.SessionID || + binding.Ref != created.Ref.ID || binding.Generation != created.Generation { + return LifecycleResponse{}, control.ErrBindingMismatch + } + wireCreated := LifecycleCreated{ + Ref: created.Ref, Generation: created.Generation, + HostWorktree: created.HostWorktree, GuestRoot: created.GuestRoot, + Profile: record.ProfileStatus.Profile, GuestEgress: record.ProfileStatus.GuestEgress, HostEgress: record.ProfileStatus.HostEgress, + } + return LifecycleResponse{Binding: binding, Record: recordPointer(record), Created: &wireCreated}, nil +} + +func (d *Daemon) performCreate(ctx context.Context, request LifecycleRequest) (CreatedEnvironment, error) { + if request.Provision == nil { + if d.creator == nil || request.Create.Owner != request.Binding.Owner || request.Create.SessionID != request.Binding.SessionID { + return CreatedEnvironment{}, control.ErrBindingMismatch + } + return d.creator.Create(ctx, *request.Create) + } + if d.provisioner == nil || d.creator == nil || request.Provision.Owner != request.Binding.Owner || request.Provision.SessionID != request.Binding.SessionID { + return CreatedEnvironment{}, control.ErrBindingMismatch + } + expanded, err := d.provisioner(ctx, *request.Provision) + if err != nil { + return CreatedEnvironment{}, err + } + return d.creator.Create(ctx, expanded) +} + +func (d *Daemon) existing(ctx context.Context, request LifecycleRequest) (LifecycleResponse, error) { + var response LifecycleResponse + record, err := d.boundRecord(ctx, request.Binding) + if err != nil { + return LifecycleResponse{}, err + } + if request.Operation == LifecycleWorkspace || request.Operation == LifecycleExec { + return d.proxy(ctx, request, record) + } + switch request.Operation { + case LifecycleResolve: + record, err = NewReattachingResolver(d.registry, d.runtime).Resolve(ctx, record.Ref, record.Owner) + case LifecycleInspect: + var status RuntimeStatus + status, err = d.runtime.Inspect(ctx, record) + if err == nil { + err = validateRuntimeIdentity(record, status) + } + case LifecycleDetach: + err = d.manager.Detach(ctx, record.Ref, record.Owner) + if d.observer != nil { + outcome := OutcomeSuccess + if err != nil { + outcome = OutcomeFailure + } + d.observer.DetachFinished(outcome) + } + case LifecycleDelete, LifecycleChildDelete: + if request.Operation == LifecycleChildDelete { + err = d.manager.DeleteChild(ctx, record.Ref, record.Owner) + } else { + err = d.manager.Delete(ctx, record.Ref, record.Owner, DeleteExplicit) + } + if err == nil { + record, err = d.registry.Lookup(ctx, record.EnvironmentID) + if err == nil { + response.Payload, err = json.Marshal(LifecycleDeleteResult{WorktreePath: record.WorktreePath, WorktreeRetained: record.PreserveWorktree || !record.WorktreeDeleted}) + } + } + } + if err != nil { + return LifecycleResponse{}, err + } + response.Binding, response.Record = bindingForRecord(record), recordPointer(record) + return response, nil +} + +func (d *Daemon) boundRecord(ctx context.Context, claim control.Binding) (EnvironmentRecord, error) { + if err := claimValidate(claim); err != nil { + return EnvironmentRecord{}, control.ErrBindingMismatch + } + record, err := d.registry.Lookup(ctx, claim.EnvironmentID) + if err != nil { + return EnvironmentRecord{}, err + } + if bindingForRecord(record) != claim { + return EnvironmentRecord{}, control.ErrBindingMismatch + } + if record.State == EnvironmentDestroyed || record.Tombstone { + return EnvironmentRecord{}, ErrEnvironmentDestroyed + } + if record.State != EnvironmentReady { + return EnvironmentRecord{}, ErrEnvironmentStale + } + return record, nil +} + +func bindingForRecord(record EnvironmentRecord) control.Binding { + return control.Binding{Owner: record.Owner, SessionID: record.SessionID, EnvironmentID: record.EnvironmentID, Ref: record.Ref.ID, Generation: record.Generation} +} + +func claimValidate(claim control.Binding) error { + if claim.Owner == "" || claim.SessionID == "" || claim.EnvironmentID == "" || claim.Ref == "" || claim.Generation == 0 { + return control.ErrBindingMismatch + } + return nil +} + +func recordPointer(record EnvironmentRecord) *EnvironmentRecord { + cloned := cloneEnvironmentRecord(record) + return &cloned +} + +func lifecycleFailure(err error) LifecycleResponse { + if errors.Is(err, ErrRepositoryLogicalRootUnavailable) { + return LifecycleResponse{ + ErrorCode: "repository_logical_root_unavailable", + ErrorText: ErrRepositoryLogicalRootUnavailable.Error(), + Err: ErrRepositoryLogicalRootUnavailable, + } + } + return LifecycleResponse{ErrorCode: lifecycleErrorCode(err), ErrorText: err.Error(), Err: err} +} + +func lifecycleErrorCode(err error) string { + switch { + case errors.Is(err, control.ErrUnauthenticatedPeer): + return "unauthenticated" + case errors.Is(err, control.ErrBindingMismatch), errors.Is(err, ErrEnvironmentForeign), errors.Is(err, ErrEnvironmentStale): + return "binding_mismatch" + case errors.Is(err, ErrEnvironmentUnknown): + return "not_found" + case errors.Is(err, ErrEnvironmentDestroyed): + return "destroyed" + case errors.Is(err, ErrEnvironmentUnavailable), errors.Is(err, ErrRuntimeIdentityMismatch): + return "unavailable" + case errors.Is(err, ErrRepositoryLogicalRootUnavailable): + return "repository_logical_root_unavailable" + default: + return "failed_precondition" + } +} diff --git a/environment/microvm/daemon_proxy.go b/environment/microvm/daemon_proxy.go new file mode 100644 index 000000000..b567a9748 --- /dev/null +++ b/environment/microvm/daemon_proxy.go @@ -0,0 +1,209 @@ +package microvm + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/guestagent" + "github.com/stacklok/mecatl/environment/microvm/guestexec" +) + +type servicesRuntime interface { + Services(EnvironmentRef) (*guestagent.Services, error) +} + +type proxyWorkspaceRequest struct { + Operation string `json:"operation"` + Path string `json:"path,omitempty"` + Pattern string `json:"pattern,omitempty"` + PathGlob string `json:"path_glob,omitempty"` + Data []byte `json:"data,omitempty"` + Version string `json:"version,omitempty"` + VersionValid bool `json:"version_valid,omitempty"` +} +type proxyWorkspaceResponse struct { + Data []byte `json:"data,omitempty"` + Version string `json:"version,omitempty"` + VersionValid bool `json:"version_valid,omitempty"` + Info *tool.FileInfo `json:"info,omitempty"` + Paths []string `json:"paths,omitempty"` + Matches []tool.GrepMatch `json:"matches,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +func (d *Daemon) proxyExecStream(ctx context.Context, request LifecycleRequest, record EnvironmentRecord, send func(LifecycleExecStream) error) (LifecycleResponse, error) { + runtime, ok := d.runtime.(servicesRuntime) + if !ok { + return LifecycleResponse{}, ErrEnvironmentUnavailable + } + services, err := runtime.Services(record.Ref) + if err != nil { + return LifecycleResponse{}, err + } + var input struct { + Command string `json:"command"` + TemporaryScope tool.TemporaryScope `json:"temporary_scope,omitempty"` + } + if json.Unmarshal(request.Payload, &input) != nil || input.Command == "" { + return LifecycleResponse{}, errLifecycleProtocol + } + var execLease *AdmissionLease + if d.admission != nil { + execLease, err = d.admission.AcquireContext(ctx, record.Owner, ResourceUsage{Execs: 1}) + if err != nil { + if d.observer != nil { + d.observer.QuotaRejected(QuotaExecs) + } + return LifecycleResponse{}, err + } + defer execLease.Release() + } + exit, err := services.Runner.RunFramesWithTemporaryScope(ctx, input.Command, input.TemporaryScope, func(frame guestexec.OutputFrame) error { + return send(LifecycleExecStream{Channel: frame.Channel, Data: frame.Data}) + }) + if d.observer != nil { + outcome := OutcomeSuccess + if err != nil { + outcome = OutcomeFailure + } + d.observer.ExecFinished(outcome, input.Command) + } + if err != nil { + return LifecycleResponse{}, err + } + payload, err := json.Marshal(struct { + ExitCode int `json:"exit_code"` + }{ExitCode: exit}) + if err != nil { + return LifecycleResponse{}, err + } + return LifecycleResponse{Binding: bindingForRecord(record), Record: recordPointer(record), Payload: payload}, nil +} + +func (d *Daemon) proxy(ctx context.Context, request LifecycleRequest, record EnvironmentRecord) (LifecycleResponse, error) { + runtime, ok := d.runtime.(servicesRuntime) + if !ok { + return LifecycleResponse{}, ErrEnvironmentUnavailable + } + services, err := runtime.Services(record.Ref) + if err != nil { + return LifecycleResponse{}, err + } + var payload any + switch request.Operation { + case LifecycleWorkspace: + payload, err = proxyWorkspace(ctx, services.Workspace, request.Payload) + case LifecycleExec: + var input struct { + Command string `json:"command"` + TemporaryScope tool.TemporaryScope `json:"temporary_scope,omitempty"` + } + if json.Unmarshal(request.Payload, &input) != nil || input.Command == "" { + return LifecycleResponse{}, errLifecycleProtocol + } + var execLease *AdmissionLease + if d.admission != nil { + execLease, err = d.admission.AcquireContext(ctx, record.Owner, ResourceUsage{Execs: 1}) + if err != nil { + if d.observer != nil { + d.observer.QuotaRejected(QuotaExecs) + } + return LifecycleResponse{}, err + } + defer execLease.Release() + } + var result tool.CommandResult + result, err = services.Runner.RunWithTemporaryScope(ctx, input.Command, input.TemporaryScope) + if d.observer != nil { + outcome := OutcomeSuccess + if err != nil { + outcome = OutcomeFailure + } + d.observer.ExecFinished(outcome, input.Command) + } + payload = struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + }{result.Stdout, result.Stderr, result.ExitCode} + } + if err != nil { + return LifecycleResponse{}, err + } + encoded, err := json.Marshal(payload) + if err != nil { + return LifecycleResponse{}, err + } + return LifecycleResponse{Binding: bindingForRecord(record), Record: recordPointer(record), Payload: encoded}, nil +} + +func proxyWorkspace(ctx context.Context, ws tool.Workspace, raw json.RawMessage) (proxyWorkspaceResponse, error) { + var request proxyWorkspaceRequest + if json.Unmarshal(raw, &request) != nil { + return proxyWorkspaceResponse{}, errLifecycleProtocol + } + response := proxyWorkspaceResponse{} + var err error + switch request.Operation { + case "read": + var version tool.FileVersion + response.Data, version, err = ws.ReadVersion(ctx, request.Path) + if err == nil { + if encoded, encodeErr := tool.EncodeFileVersion(version); encodeErr == nil { + response.Version, response.VersionValid = encoded, true + } + } + case "stat": + info, statErr := ws.Stat(ctx, request.Path) + err = statErr + if err == nil { + response.Info = &info + } + case "create": + version, createErr := ws.CreateFile(ctx, request.Path, request.Data) + err = createErr + if err == nil { + if encoded, encodeErr := tool.EncodeFileVersion(version); encodeErr == nil { + response.Version, response.VersionValid = encoded, true + } + } + case "replace": + version, replaceErr := ws.ReplaceFile(ctx, request.Path, tool.NewFileVersion(request.Version), request.Data) + err = replaceErr + if !request.VersionValid { + err = &tool.VersionMismatchError{Path: request.Path} + } + if err == nil { + if encoded, encodeErr := tool.EncodeFileVersion(version); encodeErr == nil { + response.Version, response.VersionValid = encoded, true + } + } + case "glob": + response.Paths, err = ws.Glob(ctx, request.Pattern) + case "grep": + response.Matches, err = ws.Grep(ctx, request.Pattern, request.PathGlob) + default: + return response, errLifecycleProtocol + } + if err != nil { + response.ErrorCode = proxyWorkspaceError(err) + } + return response, nil +} + +func proxyWorkspaceError(err error) string { + var mismatch *tool.VersionMismatchError + switch { + case errors.As(err, &mismatch): + return "version_mismatch" + case errors.Is(err, fs.ErrExist): + return "exists" + case errors.Is(err, fs.ErrNotExist): + return "not_found" + default: + return "internal" + } +} diff --git a/environment/microvm/daemon_test.go b/environment/microvm/daemon_test.go new file mode 100644 index 000000000..38fb50dfe --- /dev/null +++ b/environment/microvm/daemon_test.go @@ -0,0 +1,442 @@ +package microvm + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "os" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/control/controltest" + "github.com/stacklok/mecatl/environment/microvm/guestagent" + "github.com/stacklok/mecatl/environment/microvm/guestexec" +) + +func TestMicroVMDaemon_DaemonInfoIsAuthenticatedAndExact(t *testing.T) { + t.Parallel() + want := DaemonInfo{ + ProtocolVersion: LifecycleProtocolVersion, + ReleaseIdentity: "sha256:release", BinaryIdentity: "sha256:binary", ConfigDigest: "sha256:config", + PolicyRevision: "release-v1", Profiles: []string{"microvm-local"}, Socket: "/run/user/1000/microvmd.sock", + } + newDaemon := func(peerUID uint32) *Daemon { + t.Helper() + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: peerUID}}) + if err != nil { + t.Fatal(err) + } + record := readyRecord("info", 1) + daemon, err := NewDaemon(DaemonConfig{Control: auth, Registry: newLifecycleRegistry(record), Runtime: &lifecycleRuntime{live: map[string]RuntimeStatus{}}, Worktrees: &lifecycleWorktrees{}, Info: want}) + if err != nil { + t.Fatal(err) + } + return daemon + } + + server, client := net.Pipe() + defer server.Close() + defer client.Close() + response := newDaemon(1000).Handle(context.Background(), server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleInfo}) + if response.Err != nil { + t.Fatal(response.Err) + } + var got DaemonInfo + if err := json.Unmarshal(response.Payload, &got); err != nil || !got.Equal(want) { + t.Fatalf("daemon info = %+v, %v; want %+v", got, err, want) + } + + foreignServer, foreignClient := net.Pipe() + defer foreignServer.Close() + defer foreignClient.Close() + foreign := newDaemon(1001).Handle(context.Background(), foreignServer, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleInfo}) + if !errors.Is(foreign.Err, control.ErrUnauthenticatedPeer) || len(foreign.Payload) != 0 { + t.Fatalf("foreign owner received daemon identity: %+v", foreign) + } +} + +func TestMicroVMDaemon_InfoFailsNamedRestartHealthPhaseWhenRepositoryBackendWasLost(t *testing.T) { + t.Parallel() + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + restartErr := errors.New("repository restart health phase: hosted network backend is not live") + daemon, err := NewDaemon(DaemonConfig{ + Control: auth, Registry: newLifecycleRegistry(readyRecord("restart-health", 1)), Runtime: &lifecycleRuntime{live: map[string]RuntimeStatus{}}, Worktrees: &lifecycleWorktrees{}, + Info: DaemonInfo{ProtocolVersion: LifecycleProtocolVersion}, RepositoryStartupError: restartErr, + }) + if err != nil { + t.Fatal(err) + } + server, client := net.Pipe() + defer server.Close() + defer client.Close() + response := daemon.Handle(t.Context(), server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleInfo}) + if !errors.Is(response.Err, restartErr) || len(response.Payload) != 0 { + t.Fatalf("daemon info restart health = %+v", response) + } +} + +func TestMicroVMDaemon_DelegationOperationsAreGenerationBound(t *testing.T) { + t.Parallel() + ctx := context.Background() + parent := readyRecord("parent-rpc", 3) + child := readyRecord("child-rpc", 4) + child.SessionID = "parallel-parent-rpc-1" + child.ParentRef = parent.Ref + child.ForkBase = "base-commit" + registry := newLifecycleRegistry(parent, child) + children := &daemonTestChildren{child: child} + runtime := &lifecycleRuntime{live: map[string]RuntimeStatus{parent.EnvironmentID: exactRuntimeStatus(parent), child.EnvironmentID: exactRuntimeStatus(child)}} + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{Control: auth, Registry: registry, Runtime: runtime, Worktrees: &lifecycleWorktrees{}, Children: children}) + if err != nil { + t.Fatal(err) + } + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + forkPayload, _ := json.Marshal(ChildForkPayload{Label: "branch-1"}) + forked := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleFork, Binding: bindingForRecord(parent), Payload: forkPayload}) + if forked.Err != nil || forked.Binding != bindingForRecord(child) || forked.Record == nil || children.forks != 1 { + t.Fatalf("fork response=%+v forks=%d", forked, children.forks) + } + mergePayload, _ := json.Marshal(ChildMergePayload{Child: bindingForRecord(child)}) + merged := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleMerge, Binding: bindingForRecord(parent), Payload: mergePayload}) + if merged.Err != nil || children.merges != 1 { + t.Fatalf("merge response=%+v merges=%d", merged, children.merges) + } + + stale := bindingForRecord(child) + stale.Generation++ + mergePayload, _ = json.Marshal(ChildMergePayload{Child: stale}) + if response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleMerge, Binding: bindingForRecord(parent), Payload: mergePayload}); !errors.Is(response.Err, control.ErrBindingMismatch) || children.merges != 1 { + t.Fatalf("stale child reached merge: response=%+v merges=%d", response, children.merges) + } + deleted := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleChildDelete, Binding: bindingForRecord(child)}) + if deleted.Err != nil { + t.Fatalf("child delete: %+v", deleted) + } + stored, err := registry.Lookup(ctx, child.EnvironmentID) + if err != nil || stored.State != EnvironmentDestroyed || !stored.WorktreeDeleted { + t.Fatalf("child cleanup was not durable: record=%+v err=%v", stored, err) + } +} + +type daemonTestChildren struct { + child EnvironmentRecord + forks, merges int +} + +func (d *daemonTestChildren) Fork(_ context.Context, parent EnvironmentRecord, label string) (EnvironmentRecord, error) { + if parent.Ref != d.child.ParentRef || label == "" { + return EnvironmentRecord{}, ErrInvalidFork + } + d.forks++ + return d.child, nil +} + +func (d *daemonTestChildren) Merge(_ context.Context, parent, child EnvironmentRecord) error { + if child.ParentRef != parent.Ref { + return ErrInvalidFork + } + d.merges++ + return nil +} + +func TestMicroVMDaemon_LifecycleRPCIsOwnerAndGenerationBound(t *testing.T) { + t.Parallel() + ctx := context.Background() + record := readyRecord("env-rpc", 7) + createdRecord := readyRecord("env-created", 9) + createdRecord.SessionID = "session-created" + registry := newLifecycleRegistry(record) + creator := &daemonTestCreator{registry: registry, record: createdRecord} + runtime := &lifecycleRuntime{live: map[string]RuntimeStatus{ + record.EnvironmentID: exactRuntimeStatus(record), createdRecord.EnvironmentID: exactRuntimeStatus(createdRecord), + }} + worktrees := &lifecycleWorktrees{} + auth, err := control.NewService(control.ServiceConfig{ + AccountUID: 1000, + PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}, + }) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{ + Control: auth, Creator: creator, Registry: registry, Runtime: runtime, Worktrees: worktrees, + }) + if err != nil { + t.Fatal(err) + } + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + createBinding := control.Binding{Owner: createdRecord.Owner, SessionID: createdRecord.SessionID} + createRequest := CreateRequest{Owner: createdRecord.Owner, SessionID: createdRecord.SessionID} + created := daemon.Handle(ctx, server, LifecycleRequest{ + Version: LifecycleProtocolVersion, Operation: LifecycleCreate, Binding: createBinding, Create: &createRequest, + }) + if created.Err != nil || created.Binding != bindingForRecord(createdRecord) || creator.calls != 1 { + t.Fatalf("create response is not durably generation-bound: response=%+v calls=%d", created, creator.calls) + } + staleCreate := createBinding + staleCreate.EnvironmentID, staleCreate.Ref, staleCreate.Generation = "guessed", "guessed@4", 4 + if response := daemon.Handle(ctx, server, LifecycleRequest{ + Version: LifecycleProtocolVersion, Operation: LifecycleCreate, Binding: staleCreate, Create: &createRequest, + }); !errors.Is(response.Err, control.ErrBindingMismatch) || creator.calls != 1 { + t.Fatalf("preselected create generation reached creator: response=%+v calls=%d", response, creator.calls) + } + + binding := bindingForRecord(record) + mutations := map[string]func(*control.Binding){ + "owner": func(claim *control.Binding) { claim.Owner = "caller:mallory" }, + "generation": func(claim *control.Binding) { claim.Generation++ }, + } + for name, mutate := range mutations { + for _, operation := range []LifecycleOperation{LifecycleResolve, LifecycleInspect, LifecycleDetach, LifecycleDelete} { + claim := binding + mutate(&claim) + response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: operation, Binding: claim}) + if !errors.Is(response.Err, control.ErrBindingMismatch) { + t.Fatalf("%s with stale %s: got %v, want binding mismatch", operation, name, response.Err) + } + } + } + if runtime.detachCalls != 0 || runtime.destroyCalls != 0 { + t.Fatalf("mismatched requests reached runtime: detaches=%d destroys=%d", runtime.detachCalls, runtime.destroyCalls) + } + + serveErr := make(chan error, 1) + go func() { + serveErr <- daemon.ServeConn(ctx, server) + }() + codec := control.NewCodec(control.DefaultMaxMessageBytes) + if err := codec.Write(client, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleInspect, Binding: binding}); err != nil { + t.Fatal(err) + } + var wireResponse LifecycleResponse + if err := codec.Read(client, &wireResponse); err != nil { + t.Fatal(err) + } + if err := <-serveErr; err != nil || wireResponse.Binding != binding || wireResponse.Record == nil { + t.Fatalf("authenticated socket inspect response=%+v serveErr=%v", wireResponse, err) + } + + for _, operation := range []LifecycleOperation{LifecycleResolve, LifecycleInspect, LifecycleDetach} { + response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: operation, Binding: binding}) + if response.Err != nil || response.Binding != binding || response.Record == nil || response.Record.Ref != record.Ref { + t.Fatalf("%s exact generation response = %+v", operation, response) + } + } + response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleDelete, Binding: binding}) + if response.Err != nil || runtime.detachCalls != 1 || runtime.destroyCalls != 1 { + t.Fatalf("delete exact generation response=%+v detaches=%d destroys=%d", response, runtime.detachCalls, runtime.destroyCalls) + } + got, err := registry.Lookup(ctx, record.EnvironmentID) + if err != nil || got.State != EnvironmentDestroyed || !got.Tombstone { + t.Fatalf("delete did not commit durable tombstone: record=%+v err=%v", got, err) + } +} + +func TestMicroVMDaemon_PrebootConfigDisablesIPv6BeforeWorkload(t *testing.T) { + t.Parallel() + fx := newLifecycleFixture("success") + lifecycle := NewLifecycle(LifecycleDeps{ + Identities: fx.identities, Worktrees: fx.worktrees, Artifacts: fx.artifacts, + VMs: fx.vms, Protocol: fx.protocol, Registry: fx.registry, Sessions: fx.sessions, + }) + if _, err := lifecycle.Create(context.Background(), fx.request); err != nil { + t.Fatal(err) + } + preboot := fx.vms.created.Preboot + if preboot.RootfsPath != GuestPrebootConfigPath || !preboot.Config.DisableIPv6 { + t.Fatalf("VM started without preboot IPv6 disablement: %+v", preboot) + } + wantBinding := fx.protocol.binding + if preboot.Config.AgentEndpoint != fx.vms.created.Endpoint || preboot.Config.Binding != wantBinding { + t.Fatalf("preboot guest-agent binding drifted from VM generation: %+v request=%+v", preboot.Config, fx.vms.created) + } + if !sameCapabilities(preboot.Config.Capabilities, control.RequiredCapabilities()) || preboot.Config.MaxMessageBytes != control.DefaultMaxMessageBytes { + t.Fatalf("preboot capabilities are incomplete: %+v", preboot.Config) + } +} + +func TestDaemonExecDisconnectCancelsGuestGroupPreservesOutputAndReleasesSlot(t *testing.T) { + record := readyRecord("exec-disconnect", 7) + binding := bindingForRecord(record) + root := t.TempDir() + if os.Geteuid() == 0 { + if err := os.Chmod(root, 0o777); err != nil { + t.Fatal(err) + } + } + identity := guestexec.WorkloadIdentity{UID: uint32(os.Geteuid()), GID: uint32(os.Getegid())} + if identity.UID == 0 || identity.GID == 0 { + identity = guestexec.DefaultWorkloadIdentity() + } + key := []byte("0123456789abcdef0123456789abcdef") + guest, err := guestagent.NewServer(guestagent.ServerConfig{ + Binding: binding, CapabilityKey: key, WorkspaceRoot: root, + ExecLimits: guestexec.Limits{MaxConcurrent: 1, CancelGrace: 200 * time.Millisecond}, WorkloadIdentity: identity, + }) + if err != nil { + t.Fatal(err) + } + issuer, err := control.NewCapabilityIssuer(key) + if err != nil { + t.Fatal(err) + } + capability, err := issuer.Issue(binding) + if err != nil { + t.Fatal(err) + } + host, guestConn := net.Pipe() + guestDone := make(chan error, 1) + go func() { guestDone <- guest.Serve(context.Background(), guestConn) }() + services, err := guestagent.Connect(context.Background(), host, binding, capability) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = services.Close() + _ = guestConn.Close() + if serveErr := <-guestDone; serveErr != nil && !errors.Is(serveErr, io.EOF) { + t.Errorf("guest server: %v", serveErr) + } + }() + + baseRuntime := &lifecycleRuntime{live: map[string]RuntimeStatus{record.EnvironmentID: exactRuntimeStatus(record)}} + runtime := &daemonServicesRuntime{lifecycleRuntime: baseRuntime, services: services} + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{Control: auth, Registry: newLifecycleRegistry(record), Runtime: runtime, Worktrees: &lifecycleWorktrees{}}) + if err != nil { + t.Fatal(err) + } + payload, _ := json.Marshal(map[string]string{"command": "printf 'partial\\n'; (trap '' TERM; while :; do :; done) & child=$!; printf '%s\\n' \"$child\"; wait"}) + request := LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleExec, Binding: binding, Payload: payload} + + serverConn, clientConn := net.Pipe() + serveDone := make(chan error, 1) + go func() { serveDone <- daemon.ServeConn(context.Background(), serverConn) }() + codec := control.NewCodec(control.DefaultMaxMessageBytes) + if err := codec.Write(clientConn, request); err != nil { + t.Fatal(err) + } + var prior strings.Builder + for len(strings.Fields(prior.String())) < 2 { + var response LifecycleResponse + if err := codec.Read(clientConn, &response); err != nil { + t.Fatal(err) + } + if response.Stream != nil { + prior.Write(response.Stream.Data) + } + } + fields := strings.Fields(prior.String()) + childPID, err := strconv.Atoi(fields[1]) + if err != nil { + t.Fatalf("child pid in %q: %v", prior.String(), err) + } + _ = clientConn.Close() + select { + case <-serveDone: + case <-time.After(2 * time.Second): + t.Fatal("peer disconnect did not cancel guest execution") + } + deadline := time.Now().Add(time.Second) + for { + err = syscall.Kill(childPID, 0) + if errors.Is(err, syscall.ESRCH) { + break + } + if time.Now().After(deadline) { + t.Fatalf("guest child %d survived disconnect: %v", childPID, err) + } + time.Sleep(time.Millisecond) + } + if !strings.Contains(prior.String(), "partial\n") { + t.Fatalf("prior output was lost: %q", prior.String()) + } + + payload, _ = json.Marshal(map[string]string{"command": "printf 'after\\n'"}) + request.Payload = payload + serverConn, clientConn = net.Pipe() + serveDone = make(chan error, 1) + go func() { serveDone <- daemon.ServeConn(context.Background(), serverConn) }() + if err := codec.Write(clientConn, request); err != nil { + t.Fatal(err) + } + var after strings.Builder + for { + var response LifecycleResponse + if err := codec.Read(clientConn, &response); err != nil { + t.Fatal(err) + } + if response.Stream != nil { + after.Write(response.Stream.Data) + continue + } + if response.ErrorCode != "" { + t.Fatalf("exec slot leaked after disconnect: %s", response.ErrorCode) + } + break + } + _ = clientConn.Close() + if err := <-serveDone; err != nil { + t.Fatalf("second exec: %v", err) + } + if after.String() != "after\n" { + t.Fatalf("second exec output = %q", after.String()) + } +} + +type daemonServicesRuntime struct { + *lifecycleRuntime + services *guestagent.Services +} + +func (r *daemonServicesRuntime) Services(EnvironmentRef) (*guestagent.Services, error) { + return r.services, nil +} + +type daemonTestCreator struct { + registry *lifecycleRegistry + record EnvironmentRecord + calls int +} + +func (c *daemonTestCreator) Create(ctx context.Context, _ CreateRequest) (CreatedEnvironment, error) { + c.calls++ + if err := c.registry.Save(ctx, c.record); err != nil { + return CreatedEnvironment{}, err + } + return CreatedEnvironment{Ref: c.record.Ref, Generation: c.record.Generation, HostWorktree: c.record.WorktreePath, GuestRoot: c.record.GuestRoot}, nil +} + +func sameCapabilities(left, right control.Capabilities) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} diff --git a/environment/microvm/e2e/prepare.sh b/environment/microvm/e2e/prepare.sh new file mode 100755 index 000000000..f0e9c208a --- /dev/null +++ b/environment/microvm/e2e/prepare.sh @@ -0,0 +1,198 @@ +#!/bin/sh +set -eu + +release=v0.0.40 +alpine_version=3.22.1 +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) platform=linux-amd64; alpine_arch=x86_64; goarch=amd64 ;; + Linux-aarch64|Linux-arm64) platform=linux-arm64; alpine_arch=aarch64; goarch=arm64 ;; + Darwin-arm64) + major=$(sw_vers -productVersion | cut -d. -f1) + if [ "$major" -lt 15 ]; then + echo "microVM E2E requires macOS 15 or newer" >&2 + exit 1 + fi + platform=darwin-arm64; alpine_arch=aarch64; goarch=arm64 + ;; + *) + echo "unsupported microVM E2E platform: $(uname -s)/$(uname -m)" >&2 + exit 1 + ;; +esac + +case "$platform" in + linux-amd64) + runtime_sha=4de717eba0c2fcfbce564fc4296536b78644b50772f3682c9be9e8809ec76165 + firmware_sha=8c036287c6689bec9e8a01697a5b2f646d21bf6af276a7a02b04b83830461441 + rootfs_sha=0e5cc5702ad72a4e151f219976ba946d50161c3acce210ef3b122a529aba1270 + ;; + linux-arm64) + runtime_sha=2f1c9f0db4c549158f3b253d6b121f4a705ebfce08dad211ec1438838f064c73 + firmware_sha=f7c1ccbc2a71de96883ccabbd5bff40ea1a553948365e102309a652326fbaf8f + rootfs_sha=188416d41f9f0c9a6e9427b75149e43ccf3a89587b2d27c9ad506e7ffca78d1c + ;; + darwin-arm64) + runtime_sha=c7442f2e6cd6916a5058432a4e2447622b4b509786bb1e636eda90f9dd2facce + firmware_sha=434e803ab08d84b525bb1addfeb8c590d3550d726141fe2c87d906df3e596ffa + rootfs_sha=188416d41f9f0c9a6e9427b75149e43ccf3a89587b2d27c9ad506e7ffca78d1c + ;; +esac + +state_root="$repo_root/.scratch/microvm-e2e" +mkdir -p "$state_root" +state_root_physical=$(CDPATH= cd -- "$state_root" && pwd -P) +if [ "$state_root_physical" != "$state_root" ]; then + echo "refusing symlinked microVM E2E state root: $state_root -> $state_root_physical" >&2 + exit 1 +fi +cache="$state_root/$platform" +if [ -L "$cache" ]; then + echo "refusing symlinked microVM E2E state root: $cache" >&2 + exit 1 +fi +mkdir -p "$cache" +cache_physical=$(CDPATH= cd -- "$cache" && pwd -P) +if [ "$cache_physical" != "$cache" ]; then + echo "refusing microVM E2E platform state outside exact root: $cache -> $cache_physical" >&2 + exit 1 +fi +downloads="$cache/downloads" +mkdir -p "$downloads" + +cleanup_prepared_tree() { + target=$1 + case "$target" in + "$cache/runtime"|"$cache/firmware"|"$cache/rootfs"|"$cache/guest-agent") ;; + *) + echo "refusing to clean outside microVM E2E state root: $target" >&2 + exit 1 + ;; + esac + if rm -rf -- "$target" 2>/dev/null; then + return + fi + if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then + sudo -n rm -rf -- "$target" + return + fi + echo "cannot clean microVM E2E state tree $target; remove its user-namespace-owned files or configure non-interactive sudo" >&2 + exit 1 +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +download_verified() { + url=$1 + destination=$2 + expected=$3 + if [ ! -f "$destination" ] || [ "$(sha256_file "$destination")" != "$expected" ]; then + rm -f "$destination.tmp" + curl --fail --location --proto '=https' --tlsv1.2 "$url" -o "$destination.tmp" + actual=$(sha256_file "$destination.tmp") + if [ "$actual" != "$expected" ]; then + rm -f "$destination.tmp" + echo "artifact digest mismatch for $url: got $actual, want $expected" >&2 + exit 1 + fi + mv "$destination.tmp" "$destination" + fi +} + +runtime_archive="$downloads/go-microvm-runtime-$platform.tar.gz" +firmware_archive="$downloads/go-microvm-firmware-$platform.tar.gz" +rootfs_archive="$downloads/alpine-minirootfs-$alpine_version-$alpine_arch.tar.gz" +download_verified "https://github.com/stacklok/go-microvm/releases/download/$release/go-microvm-runtime-$platform.tar.gz" "$runtime_archive" "$runtime_sha" +download_verified "https://github.com/stacklok/go-microvm/releases/download/$release/go-microvm-firmware-$platform.tar.gz" "$firmware_archive" "$firmware_sha" +download_verified "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/$alpine_arch/alpine-minirootfs-$alpine_version-$alpine_arch.tar.gz" "$rootfs_archive" "$rootfs_sha" + +cleanup_prepared_tree "$cache/runtime" +cleanup_prepared_tree "$cache/firmware" +cleanup_prepared_tree "$cache/rootfs" +cleanup_prepared_tree "$cache/guest-agent" +mkdir -p "$cache/runtime" "$cache/firmware" "$cache/rootfs" "$cache/guest-agent" +tar -xzf "$runtime_archive" -C "$cache/runtime" --strip-components=1 +tar -xzf "$firmware_archive" -C "$cache/firmware" --strip-components=1 +tar -xzf "$rootfs_archive" -C "$cache/rootfs" + +# Install real guest Git from Alpine's signed repositories in a digest-pinned +# container. This works identically from Linux and Apple Silicon hosts while +# keeping package tooling out of the production guest. +alpine_builder='docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce' +docker run --rm --platform "linux/$goarch" \ + -v "$cache/rootfs:/target" "$alpine_builder" \ + sh -c 'apk --root /target --initdb --repositories-file /etc/apk/repositories add git ca-certificates curl >/dev/null' + +( + cd "$repo_root/environment/microvm" + GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH="$goarch" go build -trimpath -o "$cache/guest-agent/mecatl-guest-agent" ./cmd/mecatl-guest-agent +) +chmod 0755 "$cache/guest-agent/mecatl-guest-agent" +printf '%s\n' "$release $runtime_sha $firmware_sha alpine-$alpine_version $rootfs_sha" >"$cache/VERIFIED" + +fixture="$cache/release-fixture" +package="$cache/package" +oci="$cache/oci" +rm -rf "$fixture" "$package" "$oci" +mkdir -p "$fixture" "$oci/context" +( + cd "$repo_root/environment/microvm" + GOWORK=off go build -trimpath -o "$fixture/mecatl-microvmd" ./cmd/mecatl-microvmd +) +cp "$cache/guest-agent/mecatl-guest-agent" "$fixture/mecatl-guest-agent" + +# Resolve Brood's mutable discovery reference, then consume only the selected +# immutable platform manifest. The downstream release statement endorses this +# resolution; no Brood rebuild or derived execution image is published. +discovery_ref=ghcr.io/stacklok/brood-box/base:latest +docker buildx imagetools inspect "$discovery_ref" --raw >"$oci/manifest.json" +resolution_evidence="sha256:$(sha256_file "$oci/manifest.json")" +image_manifest=$(python3 - "$oci/manifest.json" "$goarch" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as stream: + manifest = json.load(stream) +matches = [item["digest"] for item in manifest.get("manifests", []) + if item.get("platform", {}).get("os") == "linux" + and item.get("platform", {}).get("architecture") == sys.argv[2]] +if len(matches) != 1: + raise SystemExit("Brood base index did not contain one exact platform manifest") +print(matches[0]) +PY +) +image_ref="ghcr.io/stacklok/brood-box/base@$image_manifest" +( + cd "$repo_root/environment/microvm" + GOWORK=off go run ./cmd/mecatl-oci-tree-digest "$image_ref" "$oci/resolver-cache" +) >"$oci/tree-digest" +image_tree_digest=$(cat "$oci/tree-digest") +MICROVM_RELEASE_FIXTURE_DIR="$fixture" \ +MICROVM_RELEASE_RUNTIME_DIR="$cache/runtime" \ +MICROVM_RELEASE_FIRMWARE_DIR="$cache/firmware" \ +MICROVM_RELEASE_EXECUTION_IMAGE_REF="$image_ref" \ +MICROVM_RELEASE_EXECUTION_IMAGE_MANIFEST_DIGEST="$image_manifest" \ +MICROVM_RELEASE_EXECUTION_IMAGE_TREE_DIGEST="$image_tree_digest" \ +MICROVM_RELEASE_EXECUTION_IMAGE_DISCOVERY_REFERENCE="$discovery_ref" \ +MICROVM_RELEASE_EXECUTION_IMAGE_RESOLUTION_EVIDENCE="$resolution_evidence" \ +MICROVM_RELEASE_EXECUTION_IMAGE_PLATFORM="linux/$goarch" \ +SOURCE_DATE_EPOCH=0 \ + "$repo_root/.github/scripts/package-microvm-release.sh" "$package" "$platform" e2e +publisher_key="$fixture/publisher" +COSIGN_PASSWORD= cosign generate-key-pair --output-key-prefix "$publisher_key" >/dev/null +COSIGN_PASSWORD= MICROVM_RELEASE_SIGNING_KEY="$publisher_key.key" \ + "$repo_root/.github/scripts/sign-microvm-release-evidence.sh" "$package" +chmod 0600 "$publisher_key.pub" +rm -f "$publisher_key.key" +cleanup_prepared_tree "$cache/runtime" +cleanup_prepared_tree "$cache/firmware" +mkdir -p "$cache/runtime" "$cache/firmware" +tar -xzf "$package/mecatl-runtime-$platform.tar.gz" -C "$cache/runtime" +tar -xzf "$package/mecatl-firmware-$platform.tar.gz" -C "$cache/firmware" + +printf 'prepared packaged, verified microVM E2E artifacts in %s\n' "$cache" diff --git a/environment/microvm/forker.go b/environment/microvm/forker.go new file mode 100644 index 000000000..31cbb73c3 --- /dev/null +++ b/environment/microvm/forker.go @@ -0,0 +1,258 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" +) + +const defaultMaxActiveForks = 8 + +var ( + // ErrForkQuotaExceeded is returned before driver work starts when the active + // child-environment quota is full. + ErrForkQuotaExceeded = errors.New("microvm child environment fork quota exceeded") + // ErrInvalidFork rejects an incomplete, aliased, or generation-inconsistent + // child returned by the driver. + ErrInvalidFork = errors.New("invalid microvm child environment") + // ErrMergeConflict means the parent changed from the child's immutable fork + // base on at least one path the child changed. The parent is left untouched. + ErrMergeConflict = errors.New("microvm child environment merge conflict") +) + +// ForkRequest asks the driver to capture an isolated child from one exact parent +// environment generation. Label is descriptive only and never an identity. +type ForkRequest struct { + Parent session.EnvironmentRef + Label string +} + +// ForkedEnvironment is the complete child capability plus the driver-owned +// identities needed to prove concurrent children do not share mutable resources. +type ForkedEnvironment struct { + Environment tool.Environment + WorktreePath string + MetadataPath string + Endpoint string + Generation uint32 + // BaseRevision identifies the immutable tree captured before the child can + // mutate. The daemon persists it with the child generation. + BaseRevision string +} + +// MergeRequest asks the daemon to atomically apply one child's additions, +// replacements, and deletions relative to its durable immutable fork base. +// BaseRevision is supplied by the creating harness when available; after a +// harness restart it may be empty and the daemon resolves it from the durable +// child record. Parent and Child are always generation-fenced. +type MergeRequest struct { + Parent session.EnvironmentRef + Child session.EnvironmentRef + BaseRevision string +} + +// ForkDriver is the daemon-side child lifecycle seam. Fork must capture Parent +// exactly and durably retain its immutable base before returning a complete +// Workspace+runner Environment. Merge must validate parentage and the durable +// base, detect every conflict before applying any path, then atomically apply +// additions, replacements, and deletions. Destroy is idempotent for the returned +// generation. +type ForkDriver interface { + Fork(context.Context, ForkRequest) (ForkedEnvironment, error) + Merge(context.Context, MergeRequest) error + Destroy(context.Context, session.EnvironmentRef) error +} + +type activeFork struct { + forked ForkedEnvironment + parent session.EnvironmentRef +} + +const mergeLockStripes = 64 + +// EnvironmentForker adapts the microVM driver to the engine's complete-child +// EnvironmentForker and EnvironmentMerger seams. Its quota is held for the whole +// child lifetime, not merely while the create RPC is in flight. Fixed lock +// stripes serialize every merge for the same parent without retaining an +// unbounded parent-key map. +type EnvironmentForker struct { + driver ForkDriver + quota chan struct{} + + mu sync.Mutex + active map[session.EnvironmentRef]activeFork + roots map[string]struct{} + metadata map[string]struct{} + endpoints map[string]struct{} + generations map[uint32]struct{} + mergeLocks [mergeLockStripes]sync.Mutex +} + +// NewEnvironmentForker constructs a fail-fast, active-child-bounded driver +// forker. Non-positive limits use a conservative default. +func NewEnvironmentForker(driver ForkDriver, maxActive int) *EnvironmentForker { + if maxActive <= 0 { + maxActive = defaultMaxActiveForks + } + return &EnvironmentForker{ + driver: driver, + quota: make(chan struct{}, maxActive), + active: make(map[session.EnvironmentRef]activeFork), + roots: make(map[string]struct{}), + metadata: make(map[string]struct{}), + endpoints: make(map[string]struct{}), + generations: make(map[uint32]struct{}), + } +} + +var ( + _ tool.EnvironmentForker = (*EnvironmentForker)(nil) + _ tool.EnvironmentMerger = (*EnvironmentForker)(nil) +) + +// Fork creates one complete isolated child. It rejects a driver that aliases any +// active child's ref, writable worktree, Git metadata, endpoint, or generation. +func (f *EnvironmentForker) Fork(ctx context.Context, base tool.Environment, label string) (tool.Environment, func() error, string, error) { + if f == nil || f.driver == nil || base.Ref().Kind != Kind { + return tool.Environment{}, nil, "", fmt.Errorf("%w: unsupported parent environment", ErrInvalidFork) + } + select { + case f.quota <- struct{}{}: + case <-ctx.Done(): + return tool.Environment{}, nil, "", context.Cause(ctx) + default: + return tool.Environment{}, nil, "", ErrForkQuotaExceeded + } + releaseQuota := true + defer func() { + if releaseQuota { + <-f.quota + } + }() + + forked, err := f.driver.Fork(ctx, ForkRequest{Parent: base.Ref(), Label: label}) + if err != nil { + return tool.Environment{}, nil, "", err + } + if err := validateForkedEnvironment(base, forked); err != nil { + _ = f.driver.Destroy(context.WithoutCancel(ctx), forked.Environment.Ref()) + return tool.Environment{}, nil, "", err + } + if !f.reserve(base.Ref(), forked) { + _ = f.driver.Destroy(context.WithoutCancel(ctx), forked.Environment.Ref()) + return tool.Environment{}, nil, "", fmt.Errorf("%w: driver returned an identity already held by another child", ErrInvalidFork) + } + + releaseQuota = false + var once sync.Once + var cleanupErr error + cleanup := func() error { + once.Do(func() { + cleanupErr = f.driver.Destroy(context.WithoutCancel(ctx), forked.Environment.Ref()) + f.release(forked) + <-f.quota + }) + return cleanupErr + } + return forked.Environment, cleanup, "", nil +} + +// Merge applies one child generation through the daemon's durable fork-base +// transaction. Every merge targeting the same parent takes the same lock stripe; +// the driver performs conflict discovery before its atomic apply. No cleanup is +// attempted here, so a conflicting child remains inspectable and caller-owned. +func (f *EnvironmentForker) Merge(ctx context.Context, child, parent tool.Environment) error { + if f == nil || f.driver == nil || child.Ref().Kind != Kind || parent.Ref().Kind != Kind || child.Ref() == parent.Ref() { + return fmt.Errorf("%w: invalid merge environments", ErrInvalidFork) + } + if err := context.Cause(ctx); err != nil { + return err + } + + request := MergeRequest{Parent: parent.Ref(), Child: child.Ref()} + f.mu.Lock() + if active, ok := f.active[child.Ref()]; ok { + if active.parent != parent.Ref() { + f.mu.Unlock() + return fmt.Errorf("%w: child belongs to another parent", ErrInvalidFork) + } + request.BaseRevision = active.forked.BaseRevision + } + f.mu.Unlock() + + lock := &f.mergeLocks[parentMergeLock(parent.Ref())] + lock.Lock() + defer lock.Unlock() + return f.driver.Merge(ctx, request) +} + +func parentMergeLock(ref session.EnvironmentRef) uint64 { + // FNV-1a, kept inline to avoid a hash allocation on this off-hot-path lock. + const ( + offset = uint64(14695981039346656037) + prime = uint64(1099511628211) + ) + hash := offset + for _, value := range []string{string(ref.Kind), ref.ID} { + for i := range len(value) { + hash ^= uint64(value[i]) + hash *= prime + } + hash ^= 0xff + hash *= prime + } + return hash % mergeLockStripes +} + +func validateForkedEnvironment(base tool.Environment, forked ForkedEnvironment) error { + env := forked.Environment + ref := env.Ref() + _, generation, err := parseEnvironmentRef(EnvironmentRef{Kind: string(ref.Kind), ID: ref.ID}) + if err != nil || ref == base.Ref() || env.Workspace() == nil || env.CommandRunner() == nil || + forked.WorktreePath == "" || forked.MetadataPath == "" || forked.Endpoint == "" || + forked.Generation == 0 || generation != forked.Generation || forked.BaseRevision == "" { + return fmt.Errorf("%w: incomplete or generation-inconsistent driver result", ErrInvalidFork) + } + return nil +} + +func (f *EnvironmentForker) reserve(parent session.EnvironmentRef, child ForkedEnvironment) bool { + f.mu.Lock() + defer f.mu.Unlock() + ref := child.Environment.Ref() + if _, ok := f.active[ref]; ok { + return false + } + if _, ok := f.roots[child.WorktreePath]; ok { + return false + } + if _, ok := f.metadata[child.MetadataPath]; ok { + return false + } + if _, ok := f.endpoints[child.Endpoint]; ok { + return false + } + if _, ok := f.generations[child.Generation]; ok { + return false + } + f.active[ref] = activeFork{forked: child, parent: parent} + f.roots[child.WorktreePath] = struct{}{} + f.metadata[child.MetadataPath] = struct{}{} + f.endpoints[child.Endpoint] = struct{}{} + f.generations[child.Generation] = struct{}{} + return true +} + +func (f *EnvironmentForker) release(child ForkedEnvironment) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.active, child.Environment.Ref()) + delete(f.roots, child.WorktreePath) + delete(f.metadata, child.MetadataPath) + delete(f.endpoints, child.Endpoint) + delete(f.generations, child.Generation) +} diff --git a/environment/microvm/forker_test.go b/environment/microvm/forker_test.go new file mode 100644 index 000000000..235a658d1 --- /dev/null +++ b/environment/microvm/forker_test.go @@ -0,0 +1,749 @@ +package microvm + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stacklok/mecatl/engine/adapter/memfs" + "github.com/stacklok/mecatl/engine/adapter/memledger" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/gitexec" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +func TestMicroVMEnvironments_Scenario7_IsolatedChildrenUseCompleteEnvironments(t *testing.T) { + t.Parallel() + parentWS := memfs.NewWorkspace("/workspace") + parent := tool.MustEnvironment(session.EnvironmentRef{Kind: Kind, ID: "parent@1"}, parentWS, memledger.New(), namespaceRunner{namespace: "parent"}) + driver := newForkDriver(parent.Ref()) + forker := NewEnvironmentForker(driver, 4) + + for _, label := range []string{"subagent", "parallel", "team"} { + child, cleanup, _, err := forker.Fork(context.Background(), parent, label) + if err != nil { + t.Fatalf("Fork(%q): %v", label, err) + } + if child.Workspace() == nil || child.CommandRunner() == nil { + t.Fatalf("Fork(%q) returned incomplete environment", label) + } + if child.Ref() == parent.Ref() || child.Workspace().Root() == parent.Workspace().Root() { + t.Fatalf("Fork(%q) reused parent namespace: ref=%+v root=%q", label, child.Ref(), child.Workspace().Root()) + } + result, err := child.CommandRunner().Run(context.Background(), "pwd") + if err != nil || result.Stdout != child.Workspace().Root() { + t.Fatalf("Fork(%q) workspace/runner affinity: stdout=%q err=%v root=%q", label, result.Stdout, err, child.Workspace().Root()) + } + if _, err := child.Workspace().CreateFile(context.Background(), "child.txt", []byte(label)); err != nil { + t.Fatalf("child CreateFile: %v", err) + } + if _, err := parentWS.Read(context.Background(), "child.txt"); err == nil { + t.Fatalf("Fork(%q) mutated parent before merge", label) + } + if err := cleanup(); err != nil { + t.Fatalf("cleanup(%q): %v", label, err) + } + } +} + +func TestMicroVMEnvironments_Scenario7_ConcurrentChildrenAreIsolatedAndBounded(t *testing.T) { + t.Parallel() + parent := tool.MustEnvironment(session.EnvironmentRef{Kind: Kind, ID: "parent@1"}, memfs.NewWorkspace("/workspace"), memledger.New(), namespaceRunner{namespace: "parent"}) + driver := newForkDriver(parent.Ref()) + driver.block = make(chan struct{}) + forker := NewEnvironmentForker(driver, 2) + + type result struct { + child tool.Environment + cleanup func() error + err error + } + results := make(chan result, 3) + for i := 0; i < 2; i++ { + go func(i int) { + child, cleanup, _, err := forker.Fork(context.Background(), parent, fmt.Sprintf("branch-%d", i)) + results <- result{child: child, cleanup: cleanup, err: err} + }(i) + } + driver.waitStarted(t, 2) + + _, _, _, err := forker.Fork(context.Background(), parent, "over-quota") + if !errors.Is(err, ErrForkQuotaExceeded) { + t.Fatalf("third concurrent Fork error = %v, want ErrForkQuotaExceeded", err) + } + close(driver.block) + + var children []tool.Environment + for range 2 { + got := <-results + if got.err != nil { + t.Fatalf("admitted Fork: %v", got.err) + } + children = append(children, got.child) + defer func(cleanup func() error) { + if err := cleanup(); err != nil { + t.Errorf("cleanup: %v", err) + } + }(got.cleanup) + } + if children[0].Ref() == children[1].Ref() || children[0].Workspace().Root() == children[1].Workspace().Root() { + t.Fatalf("concurrent children shared ref or worktree: (%+v, %q) (%+v, %q)", children[0].Ref(), children[0].Workspace().Root(), children[1].Ref(), children[1].Workspace().Root()) + } + + forks := driver.snapshot() + if len(forks) != 2 || forks[0].MetadataPath == forks[1].MetadataPath || forks[0].Endpoint == forks[1].Endpoint || forks[0].Generation == forks[1].Generation { + t.Fatalf("concurrent child identities are not isolated: %+v", forks) + } +} + +type namespaceRunner struct{ namespace string } + +func (r namespaceRunner) Run(context.Context, string) (tool.CommandResult, error) { + return tool.CommandResult{Stdout: r.namespace}, nil +} + +type recordedFork struct { + MetadataPath string + Endpoint string + Generation uint32 +} + +type forkDriver struct { + mu sync.Mutex + parent session.EnvironmentRef + seq uint32 + started chan struct{} + block chan struct{} + forks []recordedFork +} + +func newForkDriver(parent session.EnvironmentRef) *forkDriver { + return &forkDriver{parent: parent, started: make(chan struct{}, 16)} +} + +func (d *forkDriver) Fork(ctx context.Context, request ForkRequest) (ForkedEnvironment, error) { + if request.Parent != d.parent { + return ForkedEnvironment{}, fmt.Errorf("fork parent = %+v, want %+v", request.Parent, d.parent) + } + d.mu.Lock() + d.seq++ + seq := d.seq + d.mu.Unlock() + d.started <- struct{}{} + if d.block != nil { + select { + case <-d.block: + case <-ctx.Done(): + return ForkedEnvironment{}, context.Cause(ctx) + } + } + root := fmt.Sprintf("/child/%d", seq) + ref := session.EnvironmentRef{Kind: Kind, ID: fmt.Sprintf("child-%d@%d", seq, seq+1)} + fork := recordedFork{MetadataPath: fmt.Sprintf("/metadata/%d", seq), Endpoint: fmt.Sprintf("endpoint-%d", seq), Generation: seq + 1} + d.mu.Lock() + d.forks = append(d.forks, fork) + d.mu.Unlock() + return ForkedEnvironment{ + Environment: tool.MustEnvironment(ref, memfs.NewWorkspace(root), memledger.New(), namespaceRunner{namespace: root}), + WorktreePath: root, + MetadataPath: fork.MetadataPath, + Endpoint: fork.Endpoint, + Generation: fork.Generation, + BaseRevision: fmt.Sprintf("base-%d", seq), + }, nil +} + +func (*forkDriver) Merge(context.Context, MergeRequest) error { return nil } + +func (*forkDriver) Destroy(context.Context, session.EnvironmentRef) error { return nil } + +func (d *forkDriver) waitStarted(t *testing.T, count int) { + t.Helper() + for range count { + select { + case <-d.started: + case <-t.Context().Done(): + t.Fatal("timed out waiting for driver fork") + } + } +} + +func (d *forkDriver) snapshot() []recordedFork { + d.mu.Lock() + defer d.mu.Unlock() + return append([]recordedFork(nil), d.forks...) +} + +type mergeForkDriver struct { + mu sync.Mutex + parentRef session.EnvironmentRef + parent map[string]string + children map[session.EnvironmentRef]map[string]string + bases map[session.EnvironmentRef]map[string]string + baseRevisions map[session.EnvironmentRef]string + destroyedRefs map[session.EnvironmentRef]bool + seq uint32 + mergeStarted chan struct{} + blockMerges chan struct{} + concurrentMergeCount int + maxConcurrentMergeCnt int + destroyCtxCancelled bool +} + +func newMergeForkDriver(parent session.EnvironmentRef, files map[string]string) *mergeForkDriver { + return &mergeForkDriver{ + parentRef: parent, parent: maps.Clone(files), children: make(map[session.EnvironmentRef]map[string]string), + bases: make(map[session.EnvironmentRef]map[string]string), baseRevisions: make(map[session.EnvironmentRef]string), + destroyedRefs: make(map[session.EnvironmentRef]bool), mergeStarted: make(chan struct{}, 8), + } +} + +func (d *mergeForkDriver) Fork(_ context.Context, request ForkRequest) (ForkedEnvironment, error) { + d.mu.Lock() + defer d.mu.Unlock() + if request.Parent != d.parentRef { + return ForkedEnvironment{}, ErrInvalidFork + } + d.seq++ + generation := d.seq + 1 + ref := session.EnvironmentRef{Kind: Kind, ID: fmt.Sprintf("merge-child-%d@%d", d.seq, generation)} + base := maps.Clone(d.parent) + baseRevision := fmt.Sprintf("base-%d", d.seq) + d.bases[ref] = base + d.children[ref] = maps.Clone(base) + d.baseRevisions[ref] = baseRevision + root := fmt.Sprintf("/merge-child/%d", d.seq) + return ForkedEnvironment{ + Environment: tool.MustEnvironment(ref, memfs.NewWorkspace(root), memledger.New(), namespaceRunner{namespace: root}), + WorktreePath: root, MetadataPath: root + "/git", Endpoint: fmt.Sprintf("merge-endpoint-%d", d.seq), + Generation: generation, BaseRevision: baseRevision, + }, nil +} + +func (d *mergeForkDriver) Merge(ctx context.Context, request MergeRequest) error { + d.mu.Lock() + d.concurrentMergeCount++ + if d.concurrentMergeCount > d.maxConcurrentMergeCnt { + d.maxConcurrentMergeCnt = d.concurrentMergeCount + } + block := d.blockMerges + d.mu.Unlock() + d.mergeStarted <- struct{}{} + if block != nil { + select { + case <-block: + case <-ctx.Done(): + d.finishMerge() + return context.Cause(ctx) + } + } + defer d.finishMerge() + + d.mu.Lock() + defer d.mu.Unlock() + base, exists := d.bases[request.Child] + child := d.children[request.Child] + if !exists || request.Parent != d.parentRef || (request.BaseRevision != "" && request.BaseRevision != d.baseRevisions[request.Child]) { + return ErrInvalidFork + } + changed := make(map[string]struct{}) + for path, baseValue := range base { + if childValue, ok := child[path]; !ok || childValue != baseValue { + changed[path] = struct{}{} + } + } + for path, childValue := range child { + if baseValue, ok := base[path]; !ok || childValue != baseValue { + changed[path] = struct{}{} + } + } + for path := range changed { + baseValue, baseOK := base[path] + parentValue, parentOK := d.parent[path] + if baseOK != parentOK || baseValue != parentValue { + return fmt.Errorf("%w: %s", ErrMergeConflict, path) + } + } + next := maps.Clone(d.parent) + for path := range changed { + if value, ok := child[path]; ok { + next[path] = value + } else { + delete(next, path) + } + } + d.parent = next + return nil +} + +func (d *mergeForkDriver) finishMerge() { + d.mu.Lock() + d.concurrentMergeCount-- + d.mu.Unlock() +} + +func (d *mergeForkDriver) Destroy(ctx context.Context, ref session.EnvironmentRef) error { + d.mu.Lock() + defer d.mu.Unlock() + d.destroyCtxCancelled = d.destroyCtxCancelled || ctx.Err() != nil + d.destroyedRefs[ref] = true + delete(d.children, ref) + return nil +} + +func (d *mergeForkDriver) setChild(ref session.EnvironmentRef, path, value string) { + d.mu.Lock() + defer d.mu.Unlock() + d.children[ref][path] = value +} + +func (d *mergeForkDriver) deleteChild(ref session.EnvironmentRef, path string) { + d.mu.Lock() + defer d.mu.Unlock() + delete(d.children[ref], path) +} + +func (d *mergeForkDriver) setParent(path, value string) { + d.mu.Lock() + defer d.mu.Unlock() + d.parent[path] = value +} + +func (d *mergeForkDriver) parentSnapshot() map[string]string { + d.mu.Lock() + defer d.mu.Unlock() + return maps.Clone(d.parent) +} + +func (d *mergeForkDriver) inspectable(ref session.EnvironmentRef) bool { + d.mu.Lock() + defer d.mu.Unlock() + _, ok := d.children[ref] + return ok +} + +func (d *mergeForkDriver) destroyed(ref session.EnvironmentRef) bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.destroyedRefs[ref] +} + +func (d *mergeForkDriver) destroyContextCancelled() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.destroyCtxCancelled +} + +func (d *mergeForkDriver) waitMergeStarted(t *testing.T) { + t.Helper() + select { + case <-d.mergeStarted: + case <-t.Context().Done(): + t.Fatal("timed out waiting for merge") + } +} + +func (d *mergeForkDriver) maxConcurrentMerges() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.maxConcurrentMergeCnt +} + +type daemonMergeRaceChildren struct { + mu sync.Mutex + entered chan struct{} + release chan struct{} + version int + active int + maxActive int +} + +func newDaemonMergeRaceChildren() *daemonMergeRaceChildren { + return &daemonMergeRaceChildren{entered: make(chan struct{}, 2), release: make(chan struct{})} +} + +func (*daemonMergeRaceChildren) Fork(context.Context, EnvironmentRecord, string) (EnvironmentRecord, error) { + return EnvironmentRecord{}, ErrInvalidFork +} + +func (c *daemonMergeRaceChildren) Merge(ctx context.Context, _, _ EnvironmentRecord) error { + c.mu.Lock() + base := 0 + c.active++ + if c.active > c.maxActive { + c.maxActive = c.active + } + c.mu.Unlock() + c.entered <- struct{}{} + select { + case <-ctx.Done(): + return context.Cause(ctx) + case <-c.release: + } + c.mu.Lock() + defer c.mu.Unlock() + c.active-- + if c.version != base { + return ErrMergeConflict + } + c.version++ + return nil +} + +func (c *daemonMergeRaceChildren) waitEntered(t *testing.T) { + t.Helper() + select { + case <-c.entered: + case <-time.After(time.Second): + t.Fatal("merge did not enter child transaction") + } +} + +func (c *daemonMergeRaceChildren) maxConcurrent() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.maxActive +} + +func TestMicroVMEnvironments_Scenario7_MergeIsConflictAwareAndPreservesChild(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("daemon serializes clients for one parent", func(t *testing.T) { + parentRecord := readyRecord("daemon-parent", 1) + firstRecord := readyRecord("daemon-child-first", 2) + firstRecord.ParentRef, firstRecord.ForkBase = parentRecord.Ref, "base" + secondRecord := readyRecord("daemon-child-second", 3) + secondRecord.ParentRef, secondRecord.ForkBase = parentRecord.Ref, "base" + registry := newLifecycleRegistry(parentRecord, firstRecord, secondRecord) + children := newDaemonMergeRaceChildren() + daemon := &Daemon{registry: registry, children: children} + request := func(child EnvironmentRecord) LifecycleRequest { + payload, err := json.Marshal(ChildMergePayload{Child: bindingForRecord(child)}) + if err != nil { + t.Fatal(err) + } + return LifecycleRequest{Binding: bindingForRecord(parentRecord), Payload: payload} + } + results := make(chan error, 2) + go func() { _, err := daemon.merge(ctx, request(firstRecord)); results <- err }() + children.waitEntered(t) + go func() { _, err := daemon.merge(ctx, request(secondRecord)); results <- err }() + select { + case <-children.entered: + t.Fatal("second client entered parent merge transaction before first apply completed") + case <-time.After(50 * time.Millisecond): + } + close(children.release) + var applied, conflicted int + for range 2 { + err := <-results + switch { + case err == nil: + applied++ + case errors.Is(err, ErrMergeConflict): + conflicted++ + default: + t.Fatalf("daemon merge error = %v", err) + } + } + if applied != 1 || conflicted != 1 || children.maxConcurrent() != 1 { + t.Fatalf("daemon merge outcomes applied=%d conflicted=%d max-concurrent=%d", applied, conflicted, children.maxConcurrent()) + } + }) + + parent := tool.MustEnvironment(session.EnvironmentRef{Kind: Kind, ID: "parent@1"}, memfs.NewWorkspace("/workspace"), memledger.New(), namespaceRunner{namespace: "parent"}) + driver := newMergeForkDriver(parent.Ref(), map[string]string{ + "replace.txt": "base replacement", + "delete.txt": "base deletion", + "stable.txt": "unchanged", + }) + forker := NewEnvironmentForker(driver, 4) + + child, cleanup, _, err := forker.Fork(ctx, parent, "merge-all-kinds") + if err != nil { + t.Fatal(err) + } + driver.setChild(child.Ref(), "added.txt", "addition") + driver.setChild(child.Ref(), "replace.txt", "replacement") + driver.deleteChild(child.Ref(), "delete.txt") + if err := forker.Merge(ctx, child, parent); err != nil { + t.Fatalf("Merge() additions/replacements/deletions: %v", err) + } + if got := driver.parentSnapshot(); !maps.Equal(got, map[string]string{ + "added.txt": "addition", + "replace.txt": "replacement", + "stable.txt": "unchanged", + }) { + t.Fatalf("merged parent = %#v", got) + } + if err := cleanup(); err != nil { + t.Fatal(err) + } + + conflicting, conflictingCleanup, _, err := forker.Fork(ctx, parent, "conflict") + if err != nil { + t.Fatal(err) + } + driver.setChild(conflicting.Ref(), "replace.txt", "child value") + driver.setChild(conflicting.Ref(), "uncertain.txt", "must not partially apply") + driver.setParent("replace.txt", "concurrent parent value") + before := driver.parentSnapshot() + err = forker.Merge(ctx, conflicting, parent) + if !errors.Is(err, ErrMergeConflict) { + t.Fatalf("Merge() error = %v, want ErrMergeConflict", err) + } + if got := driver.parentSnapshot(); !maps.Equal(got, before) { + t.Fatalf("conflicting merge partially changed parent: before=%#v after=%#v", before, got) + } + if !driver.inspectable(conflicting.Ref()) || driver.destroyed(conflicting.Ref()) { + t.Fatal("conflicting child was not preserved for inspection") + } + if err := conflictingCleanup(); err != nil { + t.Fatal(err) + } + + first, firstCleanup, _, err := forker.Fork(ctx, parent, "serialized-1") + if err != nil { + t.Fatal(err) + } + second, secondCleanup, _, err := forker.Fork(ctx, parent, "serialized-2") + if err != nil { + t.Fatal(err) + } + driver.blockMerges = make(chan struct{}) + driver.mergeStarted = make(chan struct{}, 8) + results := make(chan error, 2) + go func() { results <- forker.Merge(ctx, first, parent) }() + driver.waitMergeStarted(t) + go func() { results <- forker.Merge(ctx, second, parent) }() + select { + case <-driver.mergeStarted: + t.Fatal("second merge entered driver before the first parent merge completed") + case <-time.After(50 * time.Millisecond): + } + if got := driver.maxConcurrentMerges(); got != 1 { + t.Fatalf("concurrent merges for one parent = %d, want 1", got) + } + close(driver.blockMerges) + for range 2 { + if err := <-results; err != nil { + t.Fatalf("serialized Merge(): %v", err) + } + } + _ = firstCleanup() + _ = secondCleanup() + + // A fork base is the parent's complete dirty tree, not merely committed HEAD. + // Later parent changes cannot alter the child snapshot, and inherited dirty + // paths are not mistaken for child edits during merge. + dirtyRoot := t.TempDir() + dirtySource := initScenario6Repository(t, dirtyRoot) + if err := os.WriteFile(filepath.Join(dirtySource, "tracked.txt"), []byte("dirty parent\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dirtySource, "untracked.txt"), []byte("untracked parent\n"), 0o600); err != nil { + t.Fatal(err) + } + dirtyBase, err := captureForkBase(ctx, dirtySource) + if err != nil { + t.Fatalf("captureForkBase(): %v", err) + } + if err := os.WriteFile(filepath.Join(dirtySource, "tracked.txt"), []byte("later parent\n"), 0o600); err != nil { + t.Fatal(err) + } + dirtyChild, err := worktree.New().Prepare(ctx, worktree.Request{ + Source: dirtySource, WorktreePath: filepath.Join(dirtyRoot, "dirty-child"), MetadataPath: filepath.Join(dirtyRoot, "dirty-metadata"), Branch: "mecatl/dirty-child", BaseRevision: dirtyBase, + }) + if err != nil { + t.Fatalf("prepare dirty-base child: %v", err) + } + defer func() { _ = dirtyChild.Cleanup(context.Background()) }() + if got := string(mustRead(t, filepath.Join(dirtyChild.WorktreePath, "tracked.txt"))); got != "dirty parent\n" { + t.Fatalf("child tracked snapshot = %q, want exact fork-time state", got) + } + if got := string(mustRead(t, filepath.Join(dirtyChild.WorktreePath, "untracked.txt"))); got != "untracked parent\n" { + t.Fatalf("child untracked snapshot = %q, want exact fork-time state", got) + } + if err := os.WriteFile(filepath.Join(dirtyChild.WorktreePath, "child-only.txt"), []byte("child\n"), 0o600); err != nil { + t.Fatal(err) + } + dirtyParentRecord := readyRecord("dirty-parent", 1) + dirtyParentRecord.WorktreePath = dirtySource + dirtyChildRecord := readyRecord("dirty-child", 2) + dirtyChildRecord.WorktreePath = dirtyChild.WorktreePath + dirtyChildRecord.ParentRef = dirtyParentRecord.Ref + dirtyChildRecord.ForkBase = dirtyBase + if err := NewLifecycleChildren(nil, nil, nil, nil).Merge(ctx, dirtyParentRecord, dirtyChildRecord); err != nil { + t.Fatalf("merge exact dirty-base child: %v", err) + } + if got := string(mustRead(t, filepath.Join(dirtySource, "tracked.txt"))); got != "later parent\n" { + t.Fatalf("merge overwrote unrelated post-fork parent state: %q", got) + } + + // Exercise the production Git merge implementation, not only the seam fake. + root := t.TempDir() + source := initScenario6Repository(t, root) + baseBytes, err := gitexec.Run(ctx, source, nil, "rev-parse", "HEAD") + if err != nil { + t.Fatal(err) + } + prepared, err := worktree.New().Prepare(ctx, worktree.Request{ + Source: source, WorktreePath: filepath.Join(root, "merge-child"), MetadataPath: filepath.Join(root, "merge-metadata"), Branch: "mecatl/merge-child", + }) + if err != nil { + t.Fatal(err) + } + defer func() { _ = prepared.Cleanup(context.Background()) }() + if err := os.WriteFile(filepath.Join(prepared.WorktreePath, "addition.txt"), []byte("child\n"), 0o600); err != nil { + t.Fatal(err) + } + parentRecord := readyRecord("real-parent", 1) + parentRecord.Ref = EnvironmentRef{Kind: Kind, ID: "real-parent@1"} + parentRecord.WorktreePath = source + childRecord := readyRecord("real-child", 2) + childRecord.Ref = EnvironmentRef{Kind: Kind, ID: "real-child@2"} + childRecord.WorktreePath = prepared.WorktreePath + childRecord.ParentRef = parentRecord.Ref + childRecord.ForkBase = strings.TrimSpace(string(baseBytes)) + production := NewLifecycleChildren(nil, nil, nil, nil) + if err := production.Merge(ctx, parentRecord, childRecord); err != nil { + t.Fatalf("production Merge(): %v", err) + } + if data, err := os.ReadFile(filepath.Join(source, "addition.txt")); err != nil || string(data) != "child\n" { + t.Fatalf("production merge addition=%q err=%v", data, err) + } + if err := os.WriteFile(filepath.Join(source, "addition.txt"), []byte("parent conflict\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(prepared.WorktreePath, "addition.txt"), []byte("child conflict\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := production.Merge(ctx, parentRecord, childRecord); !errors.Is(err, ErrMergeConflict) { + t.Fatalf("production conflicting Merge() error = %v", err) + } + if data, err := os.ReadFile(filepath.Join(prepared.WorktreePath, "addition.txt")); err != nil || string(data) != "child conflict\n" { + t.Fatalf("conflict did not preserve child: data=%q err=%v", data, err) + } +} + +func TestMicroVMEnvironments_Scenario7_ChildLifecycleSurvivesTerminationPaths(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + registry, err := OpenFileRegistry(filepath.Join(dir, "registry.json")) + if err != nil { + t.Fatal(err) + } + parent := readyRecord("parent-life", 1) + child := readyRecord("child-life", 2) + child.SessionID = "parallel-parent-life-1" + child.ParentRef = parent.Ref + child.ForkBase = "immutable-base-1" + if err := registry.Save(ctx, parent); err != nil { + t.Fatal(err) + } + if err := registry.Save(ctx, child); err != nil { + t.Fatal(err) + } + + // Reopening models daemon/harness termination: exact child parentage and base + // survive, so resume cannot attach it to a sibling parent. + restarted, err := OpenFileRegistry(filepath.Join(dir, "registry.json")) + if err != nil { + t.Fatal(err) + } + got, err := restarted.Lookup(ctx, child.EnvironmentID) + if err != nil { + t.Fatal(err) + } + if got.ParentRef != parent.Ref || got.ForkBase != child.ForkBase { + t.Fatalf("restart lost child identity: %+v", got) + } + changedBase := got + changedBase.ForkBase = "rewritten-base" + if err := restarted.Save(ctx, changedBase); !errors.Is(err, ErrEnvironmentStale) { + t.Fatalf("rewriting immutable fork base error = %v, want ErrEnvironmentStale", err) + } + changedParent := got + changedParent.ParentRef = readyRecord("sibling-parent", 9).Ref + if err := restarted.Save(ctx, changedParent); !errors.Is(err, ErrEnvironmentStale) { + t.Fatalf("rewriting child parent error = %v, want ErrEnvironmentStale", err) + } + + parentEnv := tool.MustEnvironment(session.EnvironmentRef{Kind: Kind, ID: "resume-parent@1"}, memfs.NewWorkspace("/workspace"), memledger.New(), namespaceRunner{namespace: "parent"}) + resumeDriver := newMergeForkDriver(parentEnv.Ref(), map[string]string{"state": "base"}) + originalForker := NewEnvironmentForker(resumeDriver, 1) + resumedChild, resumedCleanup, _, err := originalForker.Fork(ctx, parentEnv, "resume") + if err != nil { + t.Fatal(err) + } + resumeDriver.setChild(resumedChild.Ref(), "state", "resumed") + restartedForker := NewEnvironmentForker(resumeDriver, 1) // no process-local child bookkeeping + sibling := tool.MustEnvironment(session.EnvironmentRef{Kind: Kind, ID: "sibling@9"}, memfs.NewWorkspace("/sibling"), memledger.New(), namespaceRunner{namespace: "sibling"}) + if err := restartedForker.Merge(ctx, resumedChild, sibling); !errors.Is(err, ErrInvalidFork) { + t.Fatalf("cross-parent resumed Merge() error = %v, want ErrInvalidFork", err) + } + if got := resumeDriver.parentSnapshot()["state"]; got != "base" { + t.Fatalf("cross-parent resume changed original parent to %q", got) + } + if err := restartedForker.Merge(ctx, resumedChild, parentEnv); err != nil { + t.Fatalf("exact-parent resumed Merge(): %v", err) + } + if got := resumeDriver.parentSnapshot()["state"]; got != "resumed" { + t.Fatalf("resumed merge state = %q", got) + } + if err := resumedCleanup(); err != nil { + t.Fatal(err) + } + + for _, path := range []string{"cancellation", "timeout", "background-drain"} { + t.Run(path, func(t *testing.T) { + parentEnv := tool.MustEnvironment(session.EnvironmentRef{Kind: Kind, ID: "term-parent@1"}, memfs.NewWorkspace("/workspace"), memledger.New(), namespaceRunner{namespace: "parent"}) + driver := newMergeForkDriver(parentEnv.Ref(), map[string]string{"state": "parent"}) + forker := NewEnvironmentForker(driver, 1) + cancelled, cancel := context.WithCancel(ctx) + childEnv, cleanup, _, err := forker.Fork(cancelled, parentEnv, path) + if err != nil { + t.Fatal(err) + } + cancel() + if err := cleanup(); err != nil { + t.Fatalf("cleanup after %s: %v", path, err) + } + if !driver.destroyed(childEnv.Ref()) || driver.destroyContextCancelled() { + t.Fatalf("%s leaked child or forwarded cancelled cleanup context", path) + } + }) + } + + // A cleanup-pending child is reconciled generation-exactly; its ready parent + // remains live and available for the existing resume path. + child.State = EnvironmentCleanupPending + child.Tombstone = true + child.DeleteReason = DeleteRollback + if err := restarted.Save(ctx, child); err != nil { + t.Fatal(err) + } + runtimeState := &lifecycleRuntime{live: map[string]RuntimeStatus{ + parent.EnvironmentID: exactRuntimeStatus(parent), + child.EnvironmentID: exactRuntimeStatus(child), + }} + if err := NewReconciler(restarted, runtimeState, &lifecycleWorktrees{}).Reconcile(ctx); err != nil { + t.Fatal(err) + } + gotChild, _ := restarted.Lookup(ctx, child.EnvironmentID) + gotParent, _ := restarted.Lookup(ctx, parent.EnvironmentID) + if gotChild.State != EnvironmentDestroyed || gotParent.State != EnvironmentReady || runtimeState.destroyCalls != 1 { + t.Fatalf("reconciliation crossed parent/child lifecycle: child=%+v parent=%+v destroys=%d", gotChild, gotParent, runtimeState.destroyCalls) + } +} diff --git a/environment/microvm/gitexec/gitexec.go b/environment/microvm/gitexec/gitexec.go new file mode 100644 index 000000000..d3a4ce171 --- /dev/null +++ b/environment/microvm/gitexec/gitexec.go @@ -0,0 +1,73 @@ +// Package gitexec runs the narrow set of host Git operations used by the +// microVM worktree lifecycle without ambient executable extensions. +package gitexec + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" +) + +var fixedArgs = []string{ + "--no-pager", + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=false", + "-c", "core.pager=cat", +} + +// Run invokes Git in dir with repository execution hooks and ambient config disabled. +func Run(ctx context.Context, dir string, stdin []byte, args ...string) ([]byte, error) { + return run(ctx, dir, stdin, nil, args...) +} + +// RunWithEnv invokes Git with an additional explicit, non-ambient environment. +// It is used for isolated temporary indexes; callers must supply complete KEY=value entries. +func RunWithEnv(ctx context.Context, dir string, stdin []byte, environment []string, args ...string) ([]byte, error) { + return run(ctx, dir, stdin, environment, args...) +} + +// RunInDir invokes Git with an inherited directory descriptor selected through +// /dev/fd/3 after process start. This binds cleanup to an already-open directory +// identity even if an attacker renames a logical ancestor concurrently. +func RunInDir(ctx context.Context, dir *os.File, stdin []byte, args ...string) ([]byte, error) { + return runCommand(ctx, "/", []*os.File{dir}, []string{"-C", "/dev/fd/3"}, stdin, nil, args...) +} + +func run(ctx context.Context, dir string, stdin []byte, environment []string, args ...string) ([]byte, error) { + return runCommand(ctx, dir, nil, nil, stdin, environment, args...) +} + +func runCommand(ctx context.Context, dir string, extraFiles []*os.File, prefix []string, stdin []byte, environment []string, args ...string) ([]byte, error) { + if len(args) == 0 { + return nil, errors.New("git command is required") + } + commandArgs := make([]string, 0, len(fixedArgs)+len(prefix)+len(args)+1) + commandArgs = append(commandArgs, fixedArgs...) + commandArgs = append(commandArgs, prefix...) + commandArgs = append(commandArgs, args[0]) + if args[0] == "diff" { + commandArgs = append(commandArgs, "--no-ext-diff") + } + commandArgs = append(commandArgs, args[1:]...) + cmd := exec.CommandContext(ctx, "git", commandArgs...) + cmd.Dir = dir + cmd.ExtraFiles = extraFiles + cmd.Stdin = bytes.NewReader(stdin) + cmd.Env = append([]string{ + "PATH=" + os.Getenv("PATH"), + "LC_ALL=C", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_TERMINAL_PROMPT=0", + "GIT_PAGER=cat", + "PAGER=cat", + }, environment...) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("git %s: %w: %s", args[0], err, bytes.TrimSpace(out)) + } + return out, nil +} diff --git a/environment/microvm/gitexec/gitexec_test.go b/environment/microvm/gitexec/gitexec_test.go new file mode 100644 index 000000000..e9b6aaf9e --- /dev/null +++ b/environment/microvm/gitexec/gitexec_test.go @@ -0,0 +1,77 @@ +package gitexec + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" +) + +func TestRunDisablesRepositoryAndAmbientExecutionHooks(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git unavailable") + } + root := t.TempDir() + home := filepath.Join(root, "home") + repo := filepath.Join(root, "repo") + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repo, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + rawGit(t, repo, "init", "-q") + rawGit(t, repo, "config", "user.name", "Test") + rawGit(t, repo, "config", "user.email", "test@example.invalid") + tracked := filepath.Join(repo, "tracked.txt") + if err := os.WriteFile(tracked, []byte("base\n"), 0o600); err != nil { + t.Fatal(err) + } + rawGit(t, repo, "add", "tracked.txt") + rawGit(t, repo, "commit", "-qm", "base") + + sentinel := filepath.Join(root, "executed") + executable := filepath.Join(root, "hostile") + if err := os.WriteFile(executable, []byte("#!/bin/sh\nprintf ran >>"+strconv.Quote(sentinel)+"\n"), 0o700); err != nil { + t.Fatal(err) + } + hooks := filepath.Join(root, "hooks") + if err := os.MkdirAll(hooks, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hooks, "pre-commit"), []byte("#!/bin/sh\nexec "+strconv.Quote(executable)+"\n"), 0o700); err != nil { + t.Fatal(err) + } + rawGit(t, repo, "config", "core.fsmonitor", executable) + rawGit(t, repo, "config", "core.hooksPath", hooks) + rawGit(t, repo, "config", "diff.external", executable) + if err := os.WriteFile(filepath.Join(home, ".gitconfig"), []byte("[core]\n\tpager = "+executable+"\n[diff]\n\texternal = "+executable+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tracked, []byte("changed\n"), 0o600); err != nil { + t.Fatal(err) + } + + for _, args := range [][]string{{"status", "--porcelain"}, {"diff", "--"}, {"commit", "--allow-empty", "-m", "safe"}, {"log", "-1", "--oneline"}} { + if _, err := Run(context.Background(), repo, nil, args...); err != nil { + t.Fatalf("Run(%v): %v", args, err) + } + } + if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unsafe Git extension executed: %v", err) + } +} + +func rawGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } +} diff --git a/environment/microvm/go.mod b/environment/microvm/go.mod new file mode 100644 index 000000000..a4624899d --- /dev/null +++ b/environment/microvm/go.mod @@ -0,0 +1,122 @@ +module github.com/stacklok/mecatl/environment/microvm + +go 1.26.6 + +replace github.com/stacklok/mecatl/engine => ../../engine + +require ( + github.com/bmatcuk/doublestar/v4 v4.10.0 + github.com/gofrs/flock v0.13.0 + github.com/google/go-containerregistry v0.22.0 + github.com/stacklok/go-microvm v0.0.40 + github.com/stacklok/mecatl/engine v0.10.0 + github.com/stacklok/toolhive-core v0.0.43 + golang.org/x/sys v0.47.0 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/apparentlymart/go-cidr v1.1.1 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containers/gvisor-tap-vsock v0.8.9 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect + github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c // indirect + github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/cli v29.7.2+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.26.0 // indirect + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/loads v0.25.1 // indirect + github.com/go-openapi/runtime v0.33.1 // indirect + github.com/go-openapi/runtime/server-middleware v0.33.1 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/strfmt v0.27.0 // indirect + github.com/go-openapi/swag/conv v0.29.1 // indirect + github.com/go-openapi/swag/fileutils v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/mangling v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.29.1 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect + github.com/go-openapi/validate v0.26.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect + github.com/google/gopacket v1.1.19 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect + github.com/in-toto/attestation v1.2.0 // indirect + github.com/in-toto/in-toto-golang v0.11.0 // indirect + github.com/inetaf/tcpproxy v0.0.0-20250222171855-c4b9df066048 // indirect + github.com/insomniacslk/dhcp v0.0.0-20240710054256-ddd8a41251c9 // indirect + github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/miekg/dns v1.1.72 // indirect + github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.1 // indirect + github.com/modelcontextprotocol/go-sdk v1.7.1-0.20260825151509-2732839dbadd // indirect + github.com/modelcontextprotocol/registry v1.8.1 // indirect + github.com/oklog/ulid/v2 v2.1.2 // indirect + github.com/onsi/gomega v1.41.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.1 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/sigstore/protobuf-specs v0.5.2 // indirect + github.com/sigstore/rekor v1.5.4 // indirect + github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect + github.com/sigstore/sigstore v1.10.9 // indirect + github.com/sigstore/sigstore-go v1.3.0 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect + github.com/transparency-dev/formats v0.1.1 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect + github.com/vishvananda/netlink v1.3.1 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/zalando/go-keyring v0.2.8 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/mod v0.40.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.49.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/grpc v1.83.1 // indirect + google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gvisor.dev/gvisor v0.0.0-20240916094835-a174eb65023f // indirect + k8s.io/klog/v2 v2.140.0 // indirect +) diff --git a/environment/microvm/go.sum b/environment/microvm/go.sum new file mode 100644 index 000000000..2fbb89333 --- /dev/null +++ b/environment/microvm/go.sum @@ -0,0 +1,480 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.13.0 h1:ufT3FPT5rFFXu6UtLkNoxaOaV5EuA1dsSkmemCSTo6U= +cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0= +cloud.google.com/go/kms v1.33.0 h1:pG0X78m212b2pv9N4fdMoUO69LuZGQ9kSvn8sHBOFAo= +cloud.google.com/go/kms v1.33.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1/go.mod h1:oXtinPO4OLj9d1DOTrqrL1oRwGhcqadvAmrl6wTeGlk= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/apparentlymart/go-cidr v1.1.1 h1:oEEk8CE0HP0YpHxsegk/TaOtR2FLHdWv4p3eM4ceUwg= +github.com/apparentlymart/go-cidr v1.1.1/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= +github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks= +github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/config v1.32.40 h1:lAVC9gMmKusmqDRe32dPtgKl/BWvJmMJoWELKHCAObw= +github.com/aws/aws-sdk-go-v2/config v1.32.40/go.mod h1:8xOJLbe/hOj1g4PVsfJYV7O2byq+UGET1onDdUgbwqc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.39 h1:XOg8LC3Kgnsa3WiPQjc7Bi8k5IBN92cPYfIV9XMFss0= +github.com/aws/aws-sdk-go-v2/credentials v1.19.39/go.mod h1:GonTDBQ+mTpCVNwaHjj0PagspfrYYMEqOx7FehoEP/I= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40 h1:UIXlbijuB2XK1Kr57fo8iIxCuaSHJzwZ1uo+2tbEYIk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40/go.mod h1:wcEsL6jscjZjVUinb0Q5qD/GXOG1yT3GNfmT9HuDwzU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40 h1:xLQVRDs2NddDmK9BEyh5KSlJ1Gpy5/GIJXrV6WcVGAE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40/go.mod h1:XRXnpFVFGLaEVK+olDdFIM1vNa04ETW452oFGEPUxAo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.41 h1:nv/ILuCY0yXACzMQwvtt/HbqDDjemZiI0AeDbxGQlnU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.41/go.mod h1:dzvOSpxaPqQ3j0xS6Lc1vyVuWW0RBj7s/QqYpzu3Q/0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40 h1:gr3Fw1cxZXNCdeo/lQ7isHEHzvHVM7z75qb2zW9aMjw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40/go.mod h1:8z/9CmfnQhiuXD7Ykbcg4a/whSWsniE0ODSx9uwVzfk= +github.com/aws/aws-sdk-go-v2/service/kms v1.55.0 h1:uB8ymkVosyourmGXCZHyWhJ4wuKA4xq3ii2dVMPtBZY= +github.com/aws/aws-sdk-go-v2/service/kms v1.55.0/go.mod h1:rK4RITSY/qJw3qVJ7p19fceOWuvrisqqOChFkX05n5I= +github.com/aws/aws-sdk-go-v2/service/signin v1.6.0 h1:agcr0j8YeFEzdXNo17Rg9MbbjLRjrimabwNtji4e+lU= +github.com/aws/aws-sdk-go-v2/service/signin v1.6.0/go.mod h1:qU5PxgQ4JiUOOMotzfO3+5oUda5W+8JDVKyLQqlrJik= +github.com/aws/aws-sdk-go-v2/service/sso v1.34.0 h1:FxaN8/sn61DTXNI6Gt678tFJUY8iUsCchm6Y/F/RjaA= +github.com/aws/aws-sdk-go-v2/service/sso v1.34.0/go.mod h1:vu4OY6s8LJtT8BtYG2LD6BGSZMptkYn3o5hvCPB22jc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.39.0 h1:crWKPeGYTBTuBxQ3p73kjfJvt4brUIsr+Fuypko8FxY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.39.0/go.mod h1:HjjZVhaBz0JBR/kbWKThmNDhFKS7y6EURuk493tJk9Y= +github.com/aws/aws-sdk-go-v2/service/sts v1.46.0 h1:IZ63JdogSNNjex/jsODNv7jGDcO/xJYd9FsgyfCsp1g= +github.com/aws/aws-sdk-go-v2/service/sts v1.46.0/go.mod h1:I+rwAf3spG5dITBaAo3xXRowk8kiOhtU1kYxfvCTC44= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containers/gvisor-tap-vsock v0.8.9 h1:6b7pqxFcKJ0EycBt1V4zPo3FQtgLLgs50AYkbFIb9eU= +github.com/containers/gvisor-tap-vsock v0.8.9/go.mod h1:OfqLraPkar5xMQcGbl9czDDSM6/xelt0HJpyB3es6v0= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c h1:g349iS+CtAvba7i0Ee9EP1TlTZ9w+UncBY6HSmsFZa0= +github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c/go.mod h1:mCGGmWkOQvEuLdIRfPIpXViBfpWto4AhwtJlAvo62SQ= +github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c= +github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY= +github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +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-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.26.0 h1:1xECln1iMMmQnTjgcknC1vi1hA4KISt6IHpSwnqcuwI= +github.com/go-openapi/analysis v0.26.0/go.mod h1:40gERFi/2dyXA1FaqRRLxkv1IlC6X+GPDNd1xrYAjZE= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/loads v0.25.1 h1:toKQdIDLxlqfKLLGUUmUsiTd5/X0Chzvde9EGYQP/Ac= +github.com/go-openapi/loads v0.25.1/go.mod h1:33Hen4tsKXHL45TyYojvfD5fZUFN4O1y4r/XhsRW2zc= +github.com/go-openapi/runtime v0.33.1 h1:jCvhI+wAdsn29byy+RgcPcg+j39YT6E304QOE/WqIVk= +github.com/go-openapi/runtime v0.33.1/go.mod h1:Dl5SMVRnJz+d8bX6Y1zxy0QKpqe/ysvVeUEh1nCpEZ4= +github.com/go-openapi/runtime/server-middleware v0.33.1 h1:IAeKbwWnBnpsYTpuPVS8t73ZrPpKvRZnK2iJ2KJGUV0= +github.com/go-openapi/runtime/server-middleware v0.33.1/go.mod h1:2Gej5fDxqeJxY+w38vxXYW0BgFASfgBsJ5rXwN1Fseg= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= +github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= +github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= +github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA= +github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM= +github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= +github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= +github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M= +github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4= +github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q= +github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.3 h1:OkfZgLvLDnGP2hrRGD+42WBiPWWkoHomTJ+IVI+KaDc= +github.com/go-openapi/validate v0.26.3/go.mod h1:7DOOa4raU6NRe7A8VQSKbm3VcuUIioREYHFt+er9Sk8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= +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.22.0 h1:eGbCiPeYxAH/7WLLq6zTBALP0tUIFsoyRauhxXDJ53I= +github.com/google/go-containerregistry v0.22.0/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= +github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= +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/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k= +github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= +github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= +github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/inetaf/tcpproxy v0.0.0-20250222171855-c4b9df066048 h1:jaqViOFFlZtkAwqvwZN+id37fosQqR5l3Oki9Dk4hz8= +github.com/inetaf/tcpproxy v0.0.0-20250222171855-c4b9df066048/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI= +github.com/insomniacslk/dhcp v0.0.0-20240710054256-ddd8a41251c9 h1:LZJWucZz7ztCqY6Jsu7N9g124iJ2kt/O62j3+UchZFg= +github.com/insomniacslk/dhcp v0.0.0-20240710054256-ddd8a41251c9/go.mod h1:KclMyHxX06VrVr0DJmeFSUb1ankt7xTfoOA35pCkoic= +github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 h1:FWpSWRD8FbVkKQu8M1DM9jF5oXFLyE+XpisIYfdzbic= +github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7/go.mod h1:BMxO138bOokdgt4UaxZiEfypcSHX0t6SIFimVP1oRfk= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= +github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +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/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= +github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= +github.com/modelcontextprotocol/go-sdk v1.7.1-0.20260825151509-2732839dbadd h1:PucVD/SJHtvuBZjQ+V8g3Gnv0ennqKl2i8mf3bN68JY= +github.com/modelcontextprotocol/go-sdk v1.7.1-0.20260825151509-2732839dbadd/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/modelcontextprotocol/registry v1.8.1 h1:baHVpbY9xc/lyCi/Wemcy7idVBNKROJZbSgzosCEuEo= +github.com/modelcontextprotocol/registry v1.8.1/go.mod h1:W26bRO/fiGMqCfDFNFz8DWZbFSpGj87JsqwrTbvLIWU= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= +github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +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/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic/v8 v8.2.0 h1:9/L4S4I6an/JsPNhmcTpqfiOIsLb/iJaePZD1xR+ulE= +github.com/sassoftware/relic/v8 v8.2.0/go.mod h1:pZy7hLT9WCOKPonV8G/fplvtBLOZd6/kWtKqeHR6nKc= +github.com/secure-systems-lab/go-securesystemslib v0.11.1 h1:ayahDPjfSIqKegyt5YVGEvQ7SAi72GHXTy+b2YjUd5w= +github.com/secure-systems-lab/go-securesystemslib v0.11.1/go.mod h1:UyOjhoZLi76ir63u1ptubEevVe6dlNPmNxwgVvKqFFY= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/sigstore/protobuf-specs v0.5.2 h1:RSWWUY8QrVTxbYH00jY/jg2e7YnjzrpwP+PeHTMll0E= +github.com/sigstore/protobuf-specs v0.5.2/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/rekor v1.5.4 h1:A9zITTrkRwO+8lfq7L/gBR7rDHHcBQJRI+a0JQo4xDM= +github.com/sigstore/rekor v1.5.4/go.mod h1:0bcUHhZPlG+RVW1dI/7sdBp0MiVWVzk9h0tgOH/o0Yc= +github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= +github.com/sigstore/sigstore v1.10.9 h1:7Dcpt+ibnltHQZ8XhaU0dFmhHaf/T491eJfA9WDex4Y= +github.com/sigstore/sigstore v1.10.9/go.mod h1:LYW9+qH7bK8wZmLm6lPxIC5lkHtkJDCgkqjChzTAIBs= +github.com/sigstore/sigstore-go v1.3.0 h1:hnIMHREyCNTYFtOE1o7ae3Axa9B5W5EjUSBJICP2NBE= +github.com/sigstore/sigstore-go v1.3.0/go.mod h1:AyRQXfpH89py1twjE3kEZxlRersng90GSYqQV9zGJE8= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.9 h1:2NWAU/utJRwzy1OrQurlY3wlDSynyH1rNJS5GgFchrg= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.9/go.mod h1:2shgYiPHTzeb5NNEu6okco+LOojMS5n9p/5Wdp0jhGI= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.9 h1:zcg/EG6JW/8RSx9T2g5j6zqxplaE4dymmhiFflDE6ks= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.9/go.mod h1:e7QngtHib0McuP1C/+Dt9f/7a8ic8FXvPB75OQzWgU4= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.9 h1:DG8R4orWDs3l9trp70fLRTgazedEDoAIo/cHEN7Qo0k= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.9/go.mod h1:d6oIwQ0YiYHMcFO4xqFcAx5njXwIf0dm5KfdytOar8A= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.9 h1:V+spyeSHypvLHSOvPMT+V72dxDlS0LDkDd6lwCsUOXM= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.9/go.mod h1:o7u5bQNLqpDQFaST29dpo6P6ihMV+wicwqfMmMW6H3c= +github.com/sigstore/timestamp-authority/v2 v2.1.3 h1:Fc+LjCTfik1lh3YLkaosENfkXa3R2Y1nswiUKutBdFA= +github.com/sigstore/timestamp-authority/v2 v2.1.3/go.mod h1:myoFOKJB/u5vNTFwvBBJVkG3NnOBeIJevbfjNeasLjo= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +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.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stacklok/go-microvm v0.0.40 h1:ypiv3oCOdftAFcNnoKO6XOHfNks8ixySm8Ny/L1PbSM= +github.com/stacklok/go-microvm v0.0.40/go.mod h1:MSeQCQZE65BY0LzaxQszGPqz9TIy8EyV8fnzZm+C+Vk= +github.com/stacklok/toolhive-core v0.0.43 h1:bSW+0pJI/zUD6GOfkABQwAvaTb1HPuFCFTNB+PegTYA= +github.com/stacklok/toolhive-core v0.0.43/go.mod h1:ryBVr4cNPT4dTngVidWpfFCBIAjP8cZzekzabcqmeVk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.4.0 h1:xpI5pnEQ0erFCS3JmcEl7Blluo8ZieEPNOURqy9uD70= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.4.0/go.mod h1:wDDAhjfd1t4TjeJCSsmFK7CARPUs/ITi16ZZ8kmAaFQ= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= +github.com/tink-crypto/tink-go/v2 v2.7.0 h1:k7QnUXJ1cRDpvoy/5l1FimZqMAArRff8vjUqzi5N04o= +github.com/tink-crypto/tink-go/v2 v2.7.0/go.mod h1:cWNpQ/yAT/QHzAV0kBGMOSJzzYTKofDZdJaUqOPPWCI= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +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= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.step.sm/crypto v0.87.0 h1:+u0pDz1OV9M/SswA6KtcQ3GSrYMMelbL//0W/ceJVYI= +go.step.sm/crypto v0.87.0/go.mod h1:gBr1mpMiKs5804/Yt03uAoOBiw3DFxARZnGUKEUO82M= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.290.0 h1:eMw0Xo+IfbbMlKmW7aHvpyQRv9RCXuWx/vs8AD+0x9A= +google.golang.org/api v0.290.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8= +google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY= +google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +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.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +gvisor.dev/gvisor v0.0.0-20240916094835-a174eb65023f h1:O2w2DymsOlM/nv2pLNWCMCYOldgBBMkD7H0/prN5W2k= +gvisor.dev/gvisor v0.0.0-20240916094835-a174eb65023f/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/environment/microvm/guestagent/guestagent.go b/environment/microvm/guestagent/guestagent.go new file mode 100644 index 000000000..100c1cb3d --- /dev/null +++ b/environment/microvm/guestagent/guestagent.go @@ -0,0 +1,109 @@ +// Package guestagent composes the workspace and exec services on one authenticated guest stream. +package guestagent + +import ( + "context" + "errors" + "io" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/guestexec" + "github.com/stacklok/mecatl/environment/microvm/workspace" +) + +// ServerConfig configures one generation-bound guest agent. +type ServerConfig struct { + Binding control.Binding + CapabilityKey []byte + WorkspaceRoot string + ExecLimits guestexec.Limits + Shell string + WorkloadIdentity guestexec.WorkloadIdentity + RuntimeContract guestexec.RuntimeContract +} + +// Server owns a guest-side verifier and both negotiated services. +type Server struct { + binding control.Binding + verifier *control.CapabilityVerifier + workspaceRoot string + exec *guestexec.GuestServer +} + +// NewServer constructs a guest agent without any host-shared credential registry. +func NewServer(cfg ServerConfig) (*Server, error) { + verifier, err := control.NewCapabilityVerifier(cfg.CapabilityKey) + if err != nil { + return nil, err + } + if cfg.WorkspaceRoot == "" { + return nil, errors.New("microvm guest workspace root is empty") + } + execServer, err := guestexec.NewGuestServer(guestexec.ServerConfig{ + Binding: cfg.Binding, Limits: cfg.ExecLimits, Shell: cfg.Shell, WorkspaceRoot: cfg.WorkspaceRoot, + WorkloadIdentity: cfg.WorkloadIdentity, RuntimeContract: cfg.RuntimeContract, + }) + if err != nil { + return nil, err + } + return &Server{binding: cfg.Binding, verifier: verifier, workspaceRoot: cfg.WorkspaceRoot, exec: execServer}, nil +} + +// Serve authenticates once, then multiplexes workspace and exec request IDs until disconnect. +func (s *Server) Serve(ctx context.Context, stream io.ReadWriteCloser) error { + guestWorkspace, err := workspace.NewGuest(s.workspaceRoot, s.binding) + if err != nil { + return err + } + defer func() { _ = guestWorkspace.Close() }() + return control.ServeMultiplex(ctx, stream, s.binding, s.verifier, map[control.ServiceName]control.Handler{ + control.ServiceWorkspace: guestWorkspace.Handler(), + control.ServiceExec: s.exec.Handler(), + }, control.DefaultMaxMessageBytes) +} + +// Services are the host-side adapters sharing one authenticated connection. +type Services struct { + Workspace *workspace.Workspace + Runner *guestexec.Runner + client *control.Client +} + +// Connect performs the sole guest handshake and constructs both bound adapters. +func Connect(ctx context.Context, stream io.ReadWriteCloser, binding control.Binding, capability string) (*Services, error) { + client, err := control.OpenClient(ctx, stream, binding, capability, []control.ServiceName{control.ServiceWorkspace, control.ServiceExec}, control.DefaultMaxMessageBytes) + if err != nil { + return nil, err + } + root := binding.AssignedRoot + if root == "" { + root = "/workspace" + } + ws, err := workspace.NewAt(client, binding, root) + if err != nil { + _ = client.Close() + return nil, err + } + runner, err := guestexec.NewRunner(guestexec.RunnerConfig{Binding: binding, Client: client}) + if err != nil { + _ = client.Close() + return nil, err + } + return &Services{Workspace: ws, Runner: runner, client: client}, nil +} + +// Agreement returns the capability and message-bound agreement from the sole production handshake. +func (s *Services) Agreement() control.Agreement { + if s == nil || s.client == nil { + return control.Agreement{} + } + return s.client.Agreement() +} + +// Close closes the sole workspace+exec guest connection. +func (s *Services) Close() error { + if s == nil || s.client == nil { + return nil + } + return s.client.Close() +} diff --git a/environment/microvm/guestagent/protocol_test.go b/environment/microvm/guestagent/protocol_test.go new file mode 100644 index 000000000..cf4559472 --- /dev/null +++ b/environment/microvm/guestagent/protocol_test.go @@ -0,0 +1,125 @@ +package guestagent_test + +import ( + "context" + "errors" + "io" + "net" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/guestagent" + "github.com/stacklok/mecatl/environment/microvm/guestexec" +) + +var multiplexBinding = control.Binding{ + Owner: "caller:alice", + SessionID: "session-1", + EnvironmentID: "environment-1", + Ref: "microvm:environment-1", + Generation: 7, +} + +func TestGuestProtocol_MultiplexesWorkspaceAndExecAfterOneHandshake(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "input.txt"), []byte("workspace-data"), 0o600); err != nil { + t.Fatalf("seed workspace: %v", err) + } + key := []byte("0123456789abcdef0123456789abcdef") + server, err := guestagent.NewServer(guestagent.ServerConfig{ + Binding: multiplexBinding, CapabilityKey: key, WorkspaceRoot: root, + WorkloadIdentity: guestexec.WorkloadIdentity{UID: uint32(os.Geteuid()), GID: uint32(os.Getegid())}, + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + issuer, err := control.NewCapabilityIssuer(key) + if err != nil { + t.Fatalf("NewCapabilityIssuer: %v", err) + } + capability, err := issuer.Issue(multiplexBinding) + if err != nil { + t.Fatalf("Issue: %v", err) + } + + host, guest := net.Pipe() + var handshakes atomic.Int32 + serveDone := make(chan error, 1) + go func() { + handshakes.Add(1) + serveDone <- server.Serve(context.Background(), guest) + }() + services, err := guestagent.Connect(context.Background(), host, multiplexBinding, capability) + if err != nil { + t.Fatalf("Connect: %v", err) + } + data, err := services.Workspace.Read(context.Background(), "input.txt") + if err != nil || string(data) != "workspace-data" { + t.Fatalf("workspace read = %q, %v", data, err) + } + result, err := services.Runner.Run(context.Background(), "printf exec-data") + if err != nil || result.Stdout != "exec-data" || result.Stderr != "" || result.ExitCode != 0 { + t.Fatalf("exec result = %#v, %v", result, err) + } + if handshakes.Load() != 1 { + t.Fatalf("guest connections/handshakes = %d, want 1", handshakes.Load()) + } + if err := services.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := <-serveDone; err != nil && !errors.Is(err, io.EOF) { + t.Fatalf("Serve: %v", err) + } +} + +func TestGuestProtocol_TransferableCapabilityRejectsReplayAndWrongGeneration(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + server, err := guestagent.NewServer(guestagent.ServerConfig{ + Binding: multiplexBinding, CapabilityKey: key, WorkspaceRoot: t.TempDir(), + WorkloadIdentity: guestexec.WorkloadIdentity{UID: uint32(os.Geteuid()), GID: uint32(os.Getegid())}, + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + issuer, err := control.NewCapabilityIssuer(append([]byte(nil), key...)) + if err != nil { + t.Fatalf("NewCapabilityIssuer: %v", err) + } + capability, err := issuer.Issue(multiplexBinding) + if err != nil { + t.Fatalf("Issue: %v", err) + } + + connect := func(binding control.Binding, token string) error { + host, guest := net.Pipe() + done := make(chan error, 1) + go func() { done <- server.Serve(context.Background(), guest) }() + services, connectErr := guestagent.Connect(context.Background(), host, binding, token) + if connectErr == nil { + _ = services.Close() + } + _ = host.Close() + _ = guest.Close() + <-done + return connectErr + } + if err := connect(multiplexBinding, capability); err != nil { + t.Fatalf("first capability use: %v", err) + } + if err := connect(multiplexBinding, capability); !errors.Is(err, control.ErrUnauthenticatedCapability) { + t.Fatalf("replayed capability: got %v, want ErrUnauthenticatedCapability", err) + } + + fresh, err := issuer.Issue(multiplexBinding) + if err != nil { + t.Fatalf("Issue fresh: %v", err) + } + wrongGeneration := multiplexBinding + wrongGeneration.Generation++ + if err := connect(wrongGeneration, fresh); !errors.Is(err, control.ErrUnauthenticatedCapability) { + t.Fatalf("wrong generation: got %v, want ErrUnauthenticatedCapability", err) + } +} diff --git a/environment/microvm/guestagent/repository.go b/environment/microvm/guestagent/repository.go new file mode 100644 index 000000000..c8e7ce5e8 --- /dev/null +++ b/environment/microvm/guestagent/repository.go @@ -0,0 +1,231 @@ +package guestagent + +import ( + "context" + "encoding/json" + "errors" + "io" + "path" + "sync" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/guestexec" + "github.com/stacklok/mecatl/environment/microvm/workspace" +) + +// RepositoryServerConfig binds one guest agent to one repository VM generation. +type RepositoryServerConfig struct { + Owner string + RepositoryKey string + VMID string + Endpoint string + Generation uint32 + AuthorityKey []byte + ExecLimits guestexec.Limits + Shell string + WorkloadIdentity guestexec.WorkloadIdentity + RuntimeContract guestexec.RuntimeContract + // ResolveRoot is the guest mount namespace resolver. Production leaves it nil, + // making the authenticated guest path the opened path; tests may emulate a mount. + ResolveRoot func(string) (string, error) +} + +type logicalRegistration struct { + binding control.Binding + workspace *workspace.Guest + exec *guestexec.GuestServer + mu sync.RWMutex + active bool +} + +const maxRepositoryDataConnections = 16 + +// ErrLogicalRootUnavailable is the closed guest-control classification for an +// authenticated assigned root that cannot be resolved or opened. +var ErrLogicalRootUnavailable = errors.New("repository logical root is unavailable") + +// RepositoryServer routes authenticated logical roots inside one repository VM. +type RepositoryServer struct { + owner string + repositoryKey string + vmID string + endpoint string + generation uint32 + authorityKey []byte + verifier *control.CapabilityVerifier + execLimits guestexec.Limits + shell string + identity guestexec.WorkloadIdentity + runtime guestexec.RuntimeContract + resolveRoot func(string) (string, error) + + mu sync.RWMutex + registrations map[string]*logicalRegistration + dataSlots chan struct{} +} + +// NewRepositoryServer constructs the generation-scoped guest registration table. +func NewRepositoryServer(cfg RepositoryServerConfig) (*RepositoryServer, error) { + if cfg.Owner == "" || cfg.Generation == 0 { + return nil, control.ErrBindingMismatch + } + verifier, err := control.NewCapabilityVerifier(cfg.AuthorityKey) + if err != nil { + return nil, err + } + identity := cfg.WorkloadIdentity + if identity == (guestexec.WorkloadIdentity{}) { + identity = guestexec.DefaultWorkloadIdentity() + } + runtime := cfg.RuntimeContract + if runtime.Home == "" { + runtime = guestexec.DefaultRuntimeContract() + } + resolveRoot := cfg.ResolveRoot + if resolveRoot == nil { + resolveRoot = func(root string) (string, error) { return root, nil } + } + return &RepositoryServer{ + owner: cfg.Owner, repositoryKey: cfg.RepositoryKey, vmID: cfg.VMID, endpoint: cfg.Endpoint, + generation: cfg.Generation, authorityKey: append([]byte(nil), cfg.AuthorityKey...), verifier: verifier, + execLimits: cfg.ExecLimits, shell: cfg.Shell, identity: identity, runtime: runtime, resolveRoot: resolveRoot, + registrations: make(map[string]*logicalRegistration), + dataSlots: make(chan struct{}, maxRepositoryDataConnections), + }, nil +} + +// Register authenticates and consumes registration authority before opening the +// assigned root. A ref can never be rebound to different bytes. +func (s *RepositoryServer) Register(ctx context.Context, authority string, binding control.Binding) error { + if err := ctx.Err(); err != nil { + return err + } + if s == nil || binding.ValidateLogical(s.owner, s.generation) != nil { + return control.ErrBindingMismatch + } + if err := s.verifier.Verify(authority, binding); err != nil { + return err + } + openedRoot, err := s.resolveRoot(binding.AssignedRoot) + if err != nil || openedRoot == "" { + return ErrLogicalRootUnavailable + } + guestWorkspace, err := workspace.NewGuest(openedRoot, binding) + if err != nil { + if errors.Is(err, workspace.ErrAssignedRootUnavailable) { + return ErrLogicalRootUnavailable + } + return err + } + execServer, err := guestexec.NewGuestServer(guestexec.ServerConfig{ + Binding: binding, Limits: s.execLimits, Shell: s.shell, WorkspaceRoot: openedRoot, + GitDirectory: path.Join(path.Dir(openedRoot), "metadata"), + WorkloadIdentity: s.identity, RuntimeContract: s.runtime, + }) + if err != nil { + _ = guestWorkspace.Close() + return err + } + registration := &logicalRegistration{binding: binding, workspace: guestWorkspace, exec: execServer, active: true} + s.mu.Lock() + defer s.mu.Unlock() + if previous := s.registrations[binding.Ref]; previous != nil { + _ = guestWorkspace.Close() + return control.ErrBindingMismatch + } + s.registrations[binding.Ref] = registration + return nil +} + +// Unregister authenticates the complete generation/ref/root tuple, prevents new +// dispatch, removes the registration and its replay state, then closes the root. +func (s *RepositoryServer) Unregister(authority string, binding control.Binding) error { + if s == nil || binding.ValidateLogical(s.owner, s.generation) != nil { + return control.ErrBindingMismatch + } + if err := s.verifier.Verify(authority, binding); err != nil { + return err + } + s.mu.Lock() + registration := s.registrations[binding.Ref] + if registration == nil || registration.binding != binding { + s.mu.Unlock() + return control.ErrBindingMismatch + } + registration.mu.Lock() + registration.active = false + delete(s.registrations, binding.Ref) + s.mu.Unlock() + s.verifier.Forget(binding) + err := registration.workspace.Close() + registration.mu.Unlock() + return err +} + +// Probe authenticates the complete registered tuple without dispatching work. +func (s *RepositoryServer) Probe(binding control.Binding) error { + if s == nil || binding.ValidateLogical(s.owner, s.generation) != nil { + return control.ErrBindingMismatch + } + s.mu.RLock() + registration := s.registrations[binding.Ref] + s.mu.RUnlock() + if registration == nil || registration.binding != binding { + return control.ErrBindingMismatch + } + return nil +} + +// Serve performs a binding-resolving handshake and dispatches only to the root +// registered for that exact owner/generation/ref/root tuple. +func (s *RepositoryServer) Serve(ctx context.Context, stream io.ReadWriteCloser) error { + if s == nil { + return control.ErrUnauthenticatedCapability + } + return control.ServeRegisteredMultiplex(ctx, stream, s.verifier, func(binding control.Binding) (map[control.ServiceName]control.Handler, bool) { + if s.Probe(binding) != nil { + return nil, false + } + s.mu.RLock() + registration := s.registrations[binding.Ref] + s.mu.RUnlock() + if registration == nil { + return nil, false + } + return map[control.ServiceName]control.Handler{ + control.ServiceWorkspace: registration.guard(registration.workspace.Handler()), + control.ServiceExec: registration.guard(registration.exec.Handler()), + }, true + }, control.DefaultMaxMessageBytes) +} + +func (r *logicalRegistration) guard(handler control.Handler) control.Handler { + return func(ctx context.Context, capability string, payload json.RawMessage, send func(any) error) (any, string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if !r.active { + return nil, "unauthenticated", control.ErrUnauthenticatedCapability + } + return handler(ctx, capability, payload, send) + } +} + +// Close releases every registered confined root. +func (s *RepositoryServer) Close() error { + if s == nil { + return nil + } + s.mu.Lock() + registrations := s.registrations + s.registrations = make(map[string]*logicalRegistration) + s.mu.Unlock() + var err error + for _, registration := range registrations { + registration.mu.Lock() + registration.active = false + s.verifier.Forget(registration.binding) + err = errors.Join(err, registration.workspace.Close()) + registration.mu.Unlock() + } + return err +} diff --git a/environment/microvm/guestagent/repository_capacity_test.go b/environment/microvm/guestagent/repository_capacity_test.go new file mode 100644 index 000000000..1abce1e05 --- /dev/null +++ b/environment/microvm/guestagent/repository_capacity_test.go @@ -0,0 +1,140 @@ +package guestagent + +import ( + "errors" + "io" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/guestexec" +) + +func TestAuthenticateHostRepositoryChannelTimesOutUnauthenticatedPeer(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + host, guest := net.Pipe() + defer guest.Close() + go func() { _, _ = io.Copy(io.Discard, guest) }() + + started := time.Now() + err := AuthenticateHostRepositoryChannel(t.Context(), host, key, "operator", "repository", "vm-1", 7, RepositoryChannelData) + if !errors.Is(err, ErrUnauthenticatedRepositoryChannel) { + t.Fatalf("AuthenticateHostRepositoryChannel() = %v, want ErrUnauthenticatedRepositoryChannel", err) + } + if elapsed := time.Since(started); elapsed < repositoryChannelAuthTimeout/2 || elapsed > 4*repositoryChannelAuthTimeout { + t.Fatalf("unauthenticated host exchange took %v, want timeout near %v", elapsed, repositoryChannelAuthTimeout) + } +} + +func TestRepositoryControlCapacitySurvivesFullLogicalAttachmentCapacity(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + identity := guestexec.WorkloadIdentity{UID: uint32(os.Geteuid()), GID: uint32(os.Getegid())} + runtimeContract := guestexec.DefaultRuntimeContract() + runtimeContract.Identity = identity + server, err := NewRepositoryServer(RepositoryServerConfig{ + Owner: "operator", RepositoryKey: "repository", VMID: "vm-1", Endpoint: "/guest.sock", Generation: 7, + AuthorityKey: key, WorkloadIdentity: identity, RuntimeContract: runtimeContract, + ResolveRoot: func(root string) (string, error) { return root, nil }, + }) + if err != nil { + t.Fatal(err) + } + issuer, err := control.NewCapabilityIssuer(key) + if err != nil { + t.Fatal(err) + } + + services := make([]*Services, 0, maxRepositoryDataConnections) + for i := range maxRepositoryDataConnections { + root := filepath.Join(t.TempDir(), "worktree") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + binding := control.Binding{Owner: "operator", SessionID: "session", EnvironmentID: "logical", Ref: "logical-" + string(rune('a'+i)), Generation: 7, AssignedRoot: root} + registration, issueErr := issuer.Issue(binding) + if issueErr != nil { + t.Fatal(issueErr) + } + if err := server.Register(t.Context(), registration, binding); err != nil { + t.Fatalf("register attachment %d: %v", i, err) + } + capability, issueErr := issuer.Issue(binding) + if issueErr != nil { + t.Fatal(issueErr) + } + host, guest := net.Pipe() + go func() { + _ = server.ServeAuthenticated(t.Context(), guest) + _ = guest.Close() + }() + if authErr := AuthenticateHostRepositoryChannel(t.Context(), host, key, "operator", "repository", "vm-1", 7, RepositoryChannelData); authErr != nil { + t.Fatalf("authenticate attachment %d: %v", i, authErr) + } + connected, connectErr := Connect(t.Context(), host, binding, capability) + if connectErr != nil { + t.Fatalf("connect attachment %d: %v", i, connectErr) + } + services = append(services, connected) + } + defer func() { + for _, connected := range services { + _ = connected.Close() + } + }() + + exchange := func(name string, request RepositoryControlRequest) { + t.Helper() + host, guest := net.Pipe() + done := make(chan error, 1) + go func() { + done <- server.ServeAuthenticated(t.Context(), guest) + _ = guest.Close() + }() + if err := AuthenticateHostRepositoryChannel(t.Context(), host, key, "operator", "repository", "vm-1", 7, RepositoryChannelControl); err != nil { + t.Fatalf("%s channel authentication: %v", name, err) + } + codec := control.NewCodec(control.DefaultMaxMessageBytes) + if err := codec.Write(host, request); err != nil { + t.Fatal(err) + } + var response RepositoryControlResponse + if err := codec.Read(host, &response); err != nil { + t.Fatalf("%s control starved at full logical capacity: %v", name, err) + } + if response.ErrorCode != "" { + t.Fatalf("%s control rejected at full logical capacity: %s", name, response.ErrorCode) + } + _ = host.Close() + select { + case serveErr := <-done: + if serveErr != nil && serveErr != io.EOF { + t.Fatalf("%s control exchange: %v", name, serveErr) + } + case <-time.After(time.Second): + t.Fatalf("%s control did not complete at full logical capacity", name) + } + } + exchange("health", RepositoryControlRequest{Operation: RepositoryHealth, Health: &RepositoryHealthChallenge{ + Owner: "operator", RepositoryKey: "repository", VMID: "vm-1", Generation: 7, + Status: RepositoryHealthStatus{Live: true, Generation: 7, VMID: "vm-1", PID: 42, ProcessIdentity: "boot", Endpoint: "/guest.sock"}, + }}) + + controlRoot := filepath.Join(t.TempDir(), "control-worktree") + if err := os.Mkdir(controlRoot, 0o700); err != nil { + t.Fatal(err) + } + controlBinding := control.Binding{Owner: "operator", SessionID: "control-session", EnvironmentID: "control-logical", Ref: "logical-control", Generation: 7, AssignedRoot: controlRoot} + registration, err := issuer.Issue(controlBinding) + if err != nil { + t.Fatal(err) + } + exchange("register", RepositoryControlRequest{Operation: RepositoryRegister, Binding: controlBinding, Capability: registration}) + unregistration, err := issuer.Issue(controlBinding) + if err != nil { + t.Fatal(err) + } + exchange("unregister", RepositoryControlRequest{Operation: RepositoryUnregister, Binding: controlBinding, Capability: unregistration}) +} diff --git a/environment/microvm/guestagent/repository_channel.go b/environment/microvm/guestagent/repository_channel.go new file mode 100644 index 000000000..f420bd76c --- /dev/null +++ b/environment/microvm/guestagent/repository_channel.go @@ -0,0 +1,127 @@ +package guestagent + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "io" + "time" + + "github.com/stacklok/mecatl/environment/microvm/control" +) + +// RepositoryChannelPurpose identifies the protocol that follows guest-origin +// authentication. It is covered by the generation boot authority proof. +type RepositoryChannelPurpose string + +const ( + // RepositoryChannelControl authenticates registration, health, and unregister traffic. + RepositoryChannelControl RepositoryChannelPurpose = "control" + // RepositoryChannelData authenticates one logical Workspace/exec stream. + RepositoryChannelData RepositoryChannelPurpose = "data" +) + +// ErrUnauthenticatedRepositoryChannel reports a peer that lacks the repository boot authority. +var ErrUnauthenticatedRepositoryChannel = errors.New("repository guest channel is not authenticated") + +const ( + repositoryChannelAuthVersion = 1 + repositoryChannelAuthTimeout = 250 * time.Millisecond +) + +type repositoryChannelChallenge struct { + Version uint32 `json:"version"` + Owner string `json:"owner"` + RepositoryKey string `json:"repository_key"` + VMID string `json:"vm_id"` + Generation uint32 `json:"generation"` + Purpose RepositoryChannelPurpose `json:"purpose"` + Nonce [32]byte `json:"nonce"` +} + +type repositoryChannelResponse struct { + MAC [sha256.Size]byte `json:"mac"` +} + +// AuthenticateHostRepositoryChannel challenges a newly accepted connection and +// returns only after it proves possession of the repository generation's boot +// authority. The caller must not send capabilities or protocol payloads first. +func AuthenticateHostRepositoryChannel(ctx context.Context, stream io.ReadWriteCloser, key []byte, owner, repositoryKey, vmID string, generation uint32, purpose RepositoryChannelPurpose) error { + if stream == nil || len(key) < 32 || owner == "" || repositoryKey == "" || vmID == "" || generation == 0 || !validRepositoryChannelPurpose(purpose) { + return ErrUnauthenticatedRepositoryChannel + } + challenge := repositoryChannelChallenge{ + Version: repositoryChannelAuthVersion, Owner: owner, RepositoryKey: repositoryKey, + VMID: vmID, Generation: generation, Purpose: purpose, + } + if _, err := rand.Read(challenge.Nonce[:]); err != nil { + return err + } + authCtx, cancel := context.WithTimeout(ctx, repositoryChannelAuthTimeout) + defer cancel() + stopClose := context.AfterFunc(authCtx, func() { _ = stream.Close() }) + defer stopClose() + codec := control.NewCodec(control.DefaultMaxMessageBytes) + if err := codec.Write(stream, challenge); err != nil { + return errors.Join(ErrUnauthenticatedRepositoryChannel, err) + } + var response repositoryChannelResponse + if err := codec.Read(stream, &response); err != nil { + return errors.Join(ErrUnauthenticatedRepositoryChannel, err) + } + if !hmac.Equal(response.MAC[:], repositoryChannelMAC(key, challenge)) { + return ErrUnauthenticatedRepositoryChannel + } + return nil +} + +func authenticateGuestRepositoryChannel(ctx context.Context, stream io.ReadWriteCloser, key []byte, owner, repositoryKey, vmID string, generation uint32) (RepositoryChannelPurpose, error) { + if stream == nil || len(key) < 32 { + return "", ErrUnauthenticatedRepositoryChannel + } + stopClose := context.AfterFunc(ctx, func() { _ = stream.Close() }) + defer stopClose() + codec := control.NewCodec(control.DefaultMaxMessageBytes) + var challenge repositoryChannelChallenge + if err := codec.Read(stream, &challenge); err != nil { + return "", errors.Join(ErrUnauthenticatedRepositoryChannel, err) + } + if challenge.Version != repositoryChannelAuthVersion || challenge.Owner != owner || challenge.RepositoryKey != repositoryKey || + challenge.VMID != vmID || challenge.Generation != generation || !validRepositoryChannelPurpose(challenge.Purpose) { + return "", ErrUnauthenticatedRepositoryChannel + } + response := repositoryChannelResponse{} + copy(response.MAC[:], repositoryChannelMAC(key, challenge)) + if err := codec.Write(stream, response); err != nil { + return "", errors.Join(ErrUnauthenticatedRepositoryChannel, err) + } + return challenge.Purpose, nil +} + +func validRepositoryChannelPurpose(purpose RepositoryChannelPurpose) bool { + return purpose == RepositoryChannelControl || purpose == RepositoryChannelData +} + +func repositoryChannelMAC(key []byte, challenge repositoryChannelChallenge) []byte { + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte("mecatl.repository-channel-auth.v1")) + writeChannelAuthString(mac, challenge.Owner) + writeChannelAuthString(mac, challenge.RepositoryKey) + writeChannelAuthString(mac, challenge.VMID) + var generation [4]byte + binary.BigEndian.PutUint32(generation[:], challenge.Generation) + _, _ = mac.Write(generation[:]) + writeChannelAuthString(mac, string(challenge.Purpose)) + _, _ = mac.Write(challenge.Nonce[:]) + return mac.Sum(nil) +} + +func writeChannelAuthString(writer io.Writer, value string) { + var length [8]byte + binary.BigEndian.PutUint64(length[:], uint64(len(value))) + _, _ = writer.Write(length[:]) + _, _ = writer.Write([]byte(value)) +} diff --git a/environment/microvm/guestagent/repository_close_test.go b/environment/microvm/guestagent/repository_close_test.go new file mode 100644 index 000000000..9c52b0f7d --- /dev/null +++ b/environment/microvm/guestagent/repository_close_test.go @@ -0,0 +1,50 @@ +package guestagent + +import ( + "context" + "os" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/guestexec" +) + +func TestRepositoryUnregisterClearsPerRefReplayState(t *testing.T) { + root := t.TempDir() + key := []byte("0123456789abcdef0123456789abcdef") + identity := guestexec.WorkloadIdentity{UID: uint32(os.Getuid()), GID: uint32(os.Getgid())} //nolint:gosec // test process identity + contract := guestexec.DefaultRuntimeContract() + contract.Identity = identity + server, err := NewRepositoryServer(RepositoryServerConfig{ + Owner: "operator", Generation: 7, AuthorityKey: key, WorkloadIdentity: identity, RuntimeContract: contract, + ResolveRoot: func(string) (string, error) { return root, nil }, + }) + if err != nil { + t.Fatal(err) + } + issuer, err := control.NewCapabilityIssuer(key) + if err != nil { + t.Fatal(err) + } + binding := control.Binding{Owner: "operator", SessionID: "logical", EnvironmentID: "logical", Ref: "logical-ref", Generation: 7, AssignedRoot: "/run/mecatl/repositories/logical/worktree"} + + for i := range 5000 { + registration, issueErr := issuer.Issue(binding) + if issueErr != nil { + t.Fatal(issueErr) + } + if err := server.Register(context.Background(), registration, binding); err != nil { + t.Fatalf("register iteration %d: %v", i, err) + } + unregistration, issueErr := issuer.Issue(binding) + if issueErr != nil { + t.Fatal(issueErr) + } + if err := server.Unregister(unregistration, binding); err != nil { + t.Fatalf("unregister iteration %d: %v", i, err) + } + if err := server.Probe(binding); err == nil { + t.Fatalf("iteration %d remained registered", i) + } + } +} diff --git a/environment/microvm/guestagent/repository_control.go b/environment/microvm/guestagent/repository_control.go new file mode 100644 index 000000000..39e68c09f --- /dev/null +++ b/environment/microvm/guestagent/repository_control.go @@ -0,0 +1,162 @@ +package guestagent + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "errors" + "io" + "strconv" + + "github.com/stacklok/mecatl/environment/microvm/control" +) + +// RepositoryControlOperation is a generation-scoped guest management operation. +type RepositoryControlOperation string + +const ( + // RepositoryRegister registers one authenticated logical guest root. + RepositoryRegister RepositoryControlOperation = "register" + // RepositoryUnregister revokes one authenticated logical guest root. + RepositoryUnregister RepositoryControlOperation = "unregister" + // RepositoryHealth proves possession of the repository boot authority. + RepositoryHealth RepositoryControlOperation = "health" +) + +// RepositoryHealthChallenge and RepositoryHealthStatus are wire values signed +// by the guest's generation boot authority. +type RepositoryHealthChallenge struct { + Owner, RepositoryKey, VMID string + Generation uint32 + Nonce [32]byte + Status RepositoryHealthStatus +} + +// RepositoryHealthStatus is the runtime status covered by a health response MAC. +type RepositoryHealthStatus struct { + Live bool + Generation uint32 + VMID string + PID int + ProcessIdentity string + Endpoint string +} + +// RepositoryControlRequest carries no host path. Binding.AssignedRoot is the +// path already mounted and visible inside the guest. +type RepositoryControlRequest struct { + Operation RepositoryControlOperation `json:"operation"` + Binding control.Binding `json:"binding"` + Capability string `json:"capability"` + Health *RepositoryHealthChallenge `json:"health,omitempty"` +} + +// RepositoryControlResponse is the bounded control acknowledgement. +type RepositoryControlResponse struct { + ErrorCode string `json:"error_code,omitempty"` + HealthMAC [sha256.Size]byte `json:"health_mac,omitempty"` +} + +// ServeAuthenticated proves this guest owns the repository generation before +// reading any capability or protocol payload, then dispatches only the purpose +// covered by the host challenge. +func (s *RepositoryServer) ServeAuthenticated(ctx context.Context, stream io.ReadWriteCloser) error { + if s == nil { + return ErrUnauthenticatedRepositoryChannel + } + purpose, err := authenticateGuestRepositoryChannel(ctx, stream, s.authorityKey, s.owner, s.repositoryKey, s.vmID, s.generation) + if err != nil { + return err + } + switch purpose { + case RepositoryChannelControl: + return s.ServeControl(ctx, stream) + case RepositoryChannelData: + select { + case s.dataSlots <- struct{}{}: + defer func() { <-s.dataSlots }() + default: + return errors.New("repository logical connection capacity exhausted") + } + return s.Serve(ctx, stream) + default: + return ErrUnauthenticatedRepositoryChannel + } +} + +// ServeControl authenticates one register/unregister exchange. Register opens +// only the guest-visible path from the binding; unregister atomically revokes it. +func (s *RepositoryServer) ServeControl(ctx context.Context, stream io.ReadWriteCloser) error { + if s == nil || stream == nil { + return control.ErrUnauthenticatedCapability + } + codec := control.NewCodec(control.DefaultMaxMessageBytes) + var request RepositoryControlRequest + if err := codec.Read(stream, &request); err != nil { + return err + } + var err error + response := RepositoryControlResponse{} + switch request.Operation { + case RepositoryRegister: + err = s.Register(ctx, request.Capability, request.Binding) + case RepositoryUnregister: + err = s.Unregister(request.Capability, request.Binding) + case RepositoryHealth: + if request.Health == nil { + err = control.ErrBindingMismatch + } else { + response.HealthMAC, err = s.healthMAC(*request.Health) + } + default: + err = control.ErrBindingMismatch + } + if err != nil { + if errors.Is(err, ErrLogicalRootUnavailable) { + response.ErrorCode = "logical_root_unavailable" + } else { + response.ErrorCode = "unauthenticated" + } + } + if writeErr := codec.Write(stream, response); writeErr != nil { + return writeErr + } + return err +} + +func (s *RepositoryServer) healthMAC(challenge RepositoryHealthChallenge) ([sha256.Size]byte, error) { + if challenge.Owner != s.owner || challenge.RepositoryKey != s.repositoryKey || challenge.VMID != s.vmID || challenge.Generation != s.generation || + challenge.Status.VMID != s.vmID || challenge.Status.Generation != s.generation || challenge.Status.Endpoint != s.endpoint { + return [sha256.Size]byte{}, control.ErrBindingMismatch + } + mac := hmac.New(sha256.New, s.authorityKey) + writeHealthString(mac, challenge.Owner) + writeHealthString(mac, challenge.RepositoryKey) + writeHealthString(mac, challenge.VMID) + var number [8]byte + binary.BigEndian.PutUint32(number[:4], challenge.Generation) + _, _ = mac.Write(number[:4]) + _, _ = mac.Write(challenge.Nonce[:]) + if challenge.Status.Live { + _, _ = mac.Write([]byte{1}) + } else { + _, _ = mac.Write([]byte{0}) + } + binary.BigEndian.PutUint32(number[:4], challenge.Status.Generation) + _, _ = mac.Write(number[:4]) + writeHealthString(mac, challenge.Status.VMID) + writeHealthString(mac, strconv.Itoa(challenge.Status.PID)) + writeHealthString(mac, challenge.Status.ProcessIdentity) + writeHealthString(mac, challenge.Status.Endpoint) + var out [sha256.Size]byte + copy(out[:], mac.Sum(nil)) + return out, nil +} + +func writeHealthString(writer interface{ Write([]byte) (int, error) }, value string) { + var length [8]byte + binary.BigEndian.PutUint64(length[:], uint64(len(value))) + _, _ = writer.Write(length[:]) + _, _ = writer.Write([]byte(value)) +} diff --git a/environment/microvm/guestagent/repository_error_test.go b/environment/microvm/guestagent/repository_error_test.go new file mode 100644 index 000000000..05f843c19 --- /dev/null +++ b/environment/microvm/guestagent/repository_error_test.go @@ -0,0 +1,137 @@ +package guestagent + +import ( + "context" + "errors" + "net" + "strings" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/control" +) + +func TestRepositoryRegisterClassifiesOnlyAssignedRootUnavailable(t *testing.T) { + const secret = "token=guest-secret /private/repository errno=13" + authorityKey := []byte("01234567890123456789012345678901") + binding := control.Binding{ + Owner: "owner", SessionID: "session", EnvironmentID: "logical-id", + Ref: "logical-id@1", Generation: 1, AssignedRoot: "/assigned/worktree", + } + issuer, err := control.NewCapabilityIssuer(authorityKey) + if err != nil { + t.Fatal(err) + } + capability, err := issuer.Issue(binding) + if err != nil { + t.Fatal(err) + } + + t.Run("resolved root failure is closed", func(t *testing.T) { + server, err := NewRepositoryServer(RepositoryServerConfig{ + Owner: binding.Owner, Generation: binding.Generation, AuthorityKey: authorityKey, + ResolveRoot: func(string) (string, error) { return "", errors.New(secret) }, + }) + if err != nil { + t.Fatal(err) + } + response, serveErr := exchangeRepositoryControl(t, server, RepositoryControlRequest{ + Operation: RepositoryRegister, Binding: binding, Capability: capability, + }) + if response.ErrorCode != "logical_root_unavailable" || !errors.Is(serveErr, ErrLogicalRootUnavailable) { + t.Fatalf("response=%q err=%v, want closed logical-root classification", response.ErrorCode, serveErr) + } + if strings.Contains(response.ErrorCode, secret) || strings.Contains(serveErr.Error(), secret) { + t.Fatalf("guest response leaked backend detail: response=%q err=%v", response.ErrorCode, serveErr) + } + }) + + t.Run("open failure is closed", func(t *testing.T) { + server, err := NewRepositoryServer(RepositoryServerConfig{ + Owner: binding.Owner, Generation: binding.Generation, AuthorityKey: authorityKey, + ResolveRoot: func(string) (string, error) { return "/missing/secret/repository", nil }, + }) + if err != nil { + t.Fatal(err) + } + response, _ := exchangeRepositoryControl(t, server, RepositoryControlRequest{ + Operation: RepositoryRegister, Binding: binding, Capability: capability, + }) + if response.ErrorCode != "logical_root_unavailable" { + t.Fatalf("response=%q, want logical_root_unavailable", response.ErrorCode) + } + }) + + t.Run("capability failure remains unauthenticated", func(t *testing.T) { + resolved := false + server, err := NewRepositoryServer(RepositoryServerConfig{ + Owner: binding.Owner, Generation: binding.Generation, AuthorityKey: authorityKey, + ResolveRoot: func(string) (string, error) { resolved = true; return "", errors.New(secret) }, + }) + if err != nil { + t.Fatal(err) + } + response, _ := exchangeRepositoryControl(t, server, RepositoryControlRequest{ + Operation: RepositoryRegister, Binding: binding, Capability: "invalid", + }) + if response.ErrorCode != "unauthenticated" || resolved { + t.Fatalf("response=%q resolved=%v, want unauthenticated before root resolution", response.ErrorCode, resolved) + } + }) + + t.Run("binding failure remains unauthenticated", func(t *testing.T) { + resolved := false + server, err := NewRepositoryServer(RepositoryServerConfig{ + Owner: binding.Owner, Generation: binding.Generation, AuthorityKey: authorityKey, + ResolveRoot: func(string) (string, error) { resolved = true; return "", errors.New(secret) }, + }) + if err != nil { + t.Fatal(err) + } + wrong := binding + wrong.Owner = "other" + response, _ := exchangeRepositoryControl(t, server, RepositoryControlRequest{ + Operation: RepositoryRegister, Binding: wrong, Capability: capability, + }) + if response.ErrorCode != "unauthenticated" || resolved { + t.Fatalf("response=%q resolved=%v, want unauthenticated before root resolution", response.ErrorCode, resolved) + } + }) + + t.Run("replay failure remains unauthenticated", func(t *testing.T) { + root := t.TempDir() + server, err := NewRepositoryServer(RepositoryServerConfig{ + Owner: binding.Owner, Generation: binding.Generation, AuthorityKey: authorityKey, + ResolveRoot: func(string) (string, error) { return root, nil }, + }) + if err != nil { + t.Fatal(err) + } + defer server.Close() + request := RepositoryControlRequest{Operation: RepositoryRegister, Binding: binding, Capability: capability} + first, firstErr := exchangeRepositoryControl(t, server, request) + if firstErr != nil || first.ErrorCode != "" { + t.Fatalf("first registration response=%q err=%v", first.ErrorCode, firstErr) + } + replayed, _ := exchangeRepositoryControl(t, server, request) + if replayed.ErrorCode != "unauthenticated" { + t.Fatalf("replayed response=%q, want unauthenticated", replayed.ErrorCode) + } + }) +} + +func exchangeRepositoryControl(t *testing.T, server *RepositoryServer, request RepositoryControlRequest) (RepositoryControlResponse, error) { + t.Helper() + host, guest := net.Pipe() + errCh := make(chan error, 1) + go func() { errCh <- server.ServeControl(context.Background(), guest) }() + codec := control.NewCodec(control.DefaultMaxMessageBytes) + if err := codec.Write(host, request); err != nil { + t.Fatal(err) + } + var response RepositoryControlResponse + if err := codec.Read(host, &response); err != nil { + t.Fatal(err) + } + _ = host.Close() + return response, <-errCh +} diff --git a/environment/microvm/guestagent/vsock_linux.go b/environment/microvm/guestagent/vsock_linux.go new file mode 100644 index 000000000..690aa84c3 --- /dev/null +++ b/environment/microvm/guestagent/vsock_linux.go @@ -0,0 +1,39 @@ +//go:build linux + +package guestagent + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" +) + +// DialHostVsock connects the guest agent to the host-bound go-microvm vsock port. +func DialHostVsock(ctx context.Context, port uint32) (io.ReadWriteCloser, error) { + fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) + if err != nil { + return nil, fmt.Errorf("open guest vsock: %w", err) + } + closeFD := true + defer func() { + if closeFD { + _ = unix.Close(fd) + } + }() + if err := ctx.Err(); err != nil { + return nil, err + } + if err := unix.Connect(fd, &unix.SockaddrVM{CID: unix.VMADDR_CID_HOST, Port: port}); err != nil { + return nil, fmt.Errorf("connect guest vsock: %w", err) + } + file := os.NewFile(uintptr(fd), "mecatl-guest-vsock") + if file == nil { + return nil, errors.New("wrap guest vsock file descriptor") + } + closeFD = false + return file, nil +} diff --git a/environment/microvm/guestagent/vsock_other.go b/environment/microvm/guestagent/vsock_other.go new file mode 100644 index 000000000..a03ebd4bb --- /dev/null +++ b/environment/microvm/guestagent/vsock_other.go @@ -0,0 +1,14 @@ +//go:build !linux + +package guestagent + +import ( + "context" + "errors" + "io" +) + +// DialHostVsock is available only inside the Linux execution image. +func DialHostVsock(context.Context, uint32) (io.ReadWriteCloser, error) { + return nil, errors.New("microvm guest vsock requires linux") +} diff --git a/environment/microvm/guestexec/exec.go b/environment/microvm/guestexec/exec.go new file mode 100644 index 000000000..d2912c118 --- /dev/null +++ b/environment/microvm/guestexec/exec.go @@ -0,0 +1,416 @@ +// Package guestexec implements the bounded microVM guest exec data plane. +package guestexec + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "sort" + "time" + + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +const ( + defaultMaxFrameBytes uint32 = 64 << 10 + defaultMaxOutputBytes int64 = 4 << 20 + defaultMaxConcurrent = 8 + defaultCancelGrace = time.Second +) + +var ( + // ErrUnauthenticated reports an invalid, stale, or replayed endpoint credential. + ErrUnauthenticated = errors.New("microvm exec endpoint is not authenticated") + // ErrTransport distinguishes a broken data-plane channel from a guest command exit. + ErrTransport = errors.New("microvm exec transport fault") + // ErrOutputOverflow reports that the guest command exceeded its output budget. + ErrOutputOverflow = errors.New("microvm exec output exceeds bound") + // ErrConcurrencyLimit reports that the environment's exec slots are occupied. + ErrConcurrencyLimit = errors.New("microvm exec concurrency limit reached") +) + +// CommandResult is the engine command result implemented by Runner. +type CommandResult = tool.CommandResult + +// Limits are immutable bounds applied independently by both protocol peers. +type Limits struct { + MaxFrameBytes uint32 + MaxOutputBytes int64 + MaxConcurrent int + CancelGrace time.Duration +} + +func (l Limits) normalized() Limits { + if l.MaxFrameBytes == 0 { + l.MaxFrameBytes = defaultMaxFrameBytes + } + if l.MaxOutputBytes == 0 { + l.MaxOutputBytes = defaultMaxOutputBytes + } + if l.MaxConcurrent == 0 { + l.MaxConcurrent = defaultMaxConcurrent + } + if l.CancelGrace == 0 { + l.CancelGrace = defaultCancelGrace + } + return l +} + +func (l Limits) validate() error { + if l.MaxFrameBytes < 256 || l.MaxFrameBytes > control.DefaultMaxMessageBytes || l.MaxOutputBytes <= 0 || l.MaxConcurrent <= 0 || l.CancelGrace <= 0 { + return errors.New("invalid microvm exec limits") + } + return nil +} + +func validBinding(b control.Binding) bool { + return b.Owner != "" && b.SessionID != "" && b.EnvironmentID != "" && b.Ref != "" && b.Generation != 0 +} + +// RunnerConfig binds a runner to exactly one environment generation. +type RunnerConfig struct { + Binding control.Binding + Client *control.Client + Limits Limits +} + +// Runner implements the engine's bound CommandRunner and optional CommandStreamer. +type Runner struct { + client *control.Client + limits Limits + authorized bool +} + +var ( + _ tool.CommandTemporaryScopeRunner = (*Runner)(nil) + _ tool.CommandTemporaryScopeStreamer = (*Runner)(nil) +) + +// NewRunner constructs a guest-only bound runner. No error path has a local execution fallback. +func NewRunner(cfg RunnerConfig) (*Runner, error) { + limits := cfg.Limits.normalized() + if !validBinding(cfg.Binding) || cfg.Client == nil { + return nil, errors.New("invalid microvm exec runner configuration") + } + if err := limits.validate(); err != nil { + return nil, err + } + return &Runner{client: cfg.Client, limits: limits, authorized: cfg.Client.IsBoundTo(cfg.Binding)}, nil +} + +// OutputFrame is one globally ordered guest output chunk. +type OutputFrame struct { + Channel string + Data []byte +} + +// Run captures distinct bounded stdout and stderr channels. +func (r *Runner) Run(ctx context.Context, command string) (tool.CommandResult, error) { + return r.runResult(ctx, command, "") +} + +// RunWithTemporaryScope runs with the selected guest-owned temporary storage. +func (r *Runner) RunWithTemporaryScope(ctx context.Context, command string, scope tool.TemporaryScope) (tool.CommandResult, error) { + return r.runResult(ctx, command, scope) +} + +func (r *Runner) runResult(ctx context.Context, command string, scope tool.TemporaryScope) (tool.CommandResult, error) { + var stdout, stderr limitedBuffer + stdout.remaining = r.limits.MaxOutputBytes + stderr.remaining = r.limits.MaxOutputBytes + exit, err := r.run(ctx, command, scope, func(frame OutputFrame) error { + var sink io.Writer + switch frame.Channel { + case channelStdout: + sink = &stdout + case channelStderr: + sink = &stderr + default: + return control.ErrMalformedFrame + } + _, writeErr := sink.Write(frame.Data) + return writeErr + }) + return tool.CommandResult{Stdout: string(stdout.bytes), Stderr: string(stderr.bytes), ExitCode: exit}, err +} + +// RunStreaming writes stdout and stderr frames to out in guest delivery order. +func (r *Runner) RunStreaming(ctx context.Context, command string, out io.Writer) (int, error) { + return r.run(ctx, command, "", func(frame OutputFrame) error { + n, err := out.Write(frame.Data) + if err == nil && n != len(frame.Data) { + err = io.ErrShortWrite + } + return err + }) +} + +// RunStreamingWithTemporaryScope streams with the selected guest-owned temporary storage. +func (r *Runner) RunStreamingWithTemporaryScope(ctx context.Context, command string, scope tool.TemporaryScope, out io.Writer) (int, error) { + return r.run(ctx, command, scope, func(frame OutputFrame) error { + n, err := out.Write(frame.Data) + if err == nil && n != len(frame.Data) { + err = io.ErrShortWrite + } + return err + }) +} + +// RunFrames delivers stdout and stderr chunks with their channel in global guest order. +func (r *Runner) RunFrames(ctx context.Context, command string, receive func(OutputFrame) error) (int, error) { + return r.RunFramesWithTemporaryScope(ctx, command, "", receive) +} + +// RunFramesWithTemporaryScope preserves channel framing with the selected guest-owned temporary storage. +func (r *Runner) RunFramesWithTemporaryScope(ctx context.Context, command string, scope tool.TemporaryScope, receive func(OutputFrame) error) (int, error) { + if receive == nil { + return 0, errors.New("microvm exec frame receiver is nil") + } + return r.run(ctx, command, scope, receive) +} + +func (r *Runner) run(ctx context.Context, command string, scope tool.TemporaryScope, receive func(OutputFrame) error) (int, error) { + if !r.authorized { + return 0, ErrUnauthenticated + } + if err := ctx.Err(); err != nil { + return 0, err + } + var received int64 + var final responseFrame + decodeFrame := func(payload json.RawMessage) error { + var response responseFrame + if err := json.Unmarshal(payload, &response); err != nil { + return control.ErrMalformedFrame + } + if response.Type != frameOutput { + return control.ErrMalformedFrame + } + received += int64(len(response.Data)) + if received > r.limits.MaxOutputBytes { + return ErrOutputOverflow + } + if response.Channel != channelStdout && response.Channel != channelStderr { + return control.ErrMalformedFrame + } + if err := receive(OutputFrame{Channel: response.Channel, Data: response.Data}); err != nil { + return fmt.Errorf("%w: consume guest output: %v", ErrTransport, err) + } + return nil + } + err := r.client.Stream(ctx, control.ServiceExec, "run", requestFrame{Command: command, TemporaryScope: scope}, decodeFrame, &final) + if err != nil { + var remote *control.RemoteError + if errors.As(err, &remote) { + return final.ExitCode, responseError(ctx, remote.Code) + } + if ctxErr := ctx.Err(); ctxErr != nil { + return final.ExitCode, ctxErr + } + if errors.Is(err, control.ErrFrameTooLarge) || errors.Is(err, control.ErrMalformedFrame) || errors.Is(err, ErrOutputOverflow) { + return final.ExitCode, err + } + return final.ExitCode, fmt.Errorf("%w: guest request: %v", ErrTransport, err) + } + if final.Type != frameExit { + return final.ExitCode, control.ErrMalformedFrame + } + return final.ExitCode, nil +} + +func responseError(ctx context.Context, code string) error { + switch code { + case codeUnauthenticated: + return ErrUnauthenticated + case codeOutputOverflow: + return ErrOutputOverflow + case codeConcurrency: + return ErrConcurrencyLimit + case codeCancelled: + if err := ctx.Err(); err != nil { + return err + } + return context.Canceled + default: + return fmt.Errorf("%w: guest error %q", ErrTransport, code) + } +} + +// WorkloadIdentity is the dedicated guest UID/GID used for model-controlled commands. +type WorkloadIdentity struct { + UID uint32 + GID uint32 +} + +const workloadID uint32 = 65532 + +// RuntimeContract is the explicit process environment restored when a rootfs is +// supplied directly and therefore carries no OCI configuration. +type RuntimeContract struct { + Identity WorkloadIdentity + Home string + Path string + Workdir string + Environment map[string]string +} + +// DefaultRuntimeContract returns the repository VM's fixed workload contract. +func DefaultRuntimeContract() RuntimeContract { + const home = "/home/guest" + return RuntimeContract{ + Identity: DefaultWorkloadIdentity(), + Home: home, + Path: "/usr/lib/go/bin:" + home + "/go/bin:" + home + "/.local/bin:" + home + "/.cargo/bin:/usr/local/bin:/usr/bin:/bin", + Workdir: worktree.GuestWorkspace, + Environment: map[string]string{ + "GOCACHE": home + "/.cache/go-build", + "GOMODCACHE": home + "/go/pkg/mod", + "PIP_CACHE_DIR": home + "/.cache/pip", + "npm_config_cache": home + "/.cache/node", + "CARGO_HOME": home + "/.cargo", + }, + } +} + +// CacheDirectories returns the declared writable cache roots in stable order. +func (c RuntimeContract) CacheDirectories() []string { + result := make([]string, 0, len(c.Environment)) + for _, name := range []string{"CARGO_HOME", "GOCACHE", "GOMODCACHE", "PIP_CACHE_DIR", "npm_config_cache"} { + if path := c.Environment[name]; path != "" { + result = append(result, path) + } + } + return result +} + +func (c RuntimeContract) commandEnvironment() []string { + env := []string{"HOME=" + c.Home, "PATH=" + c.Path} + names := make([]string, 0, len(c.Environment)) + for name := range c.Environment { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + env = append(env, name+"="+c.Environment[name]) + } + return env +} + +// DefaultWorkloadIdentity returns the fixed unprivileged production identity. +func DefaultWorkloadIdentity() WorkloadIdentity { + return WorkloadIdentity{UID: workloadID, GID: workloadID} +} + +// ServerConfig configures a guest endpoint. Production execution is fixed at /workspace. +type ServerConfig struct { + Binding control.Binding + Limits Limits + Shell string + WorkspaceRoot string + GitDirectory string + WorkloadIdentity WorkloadIdentity + RuntimeContract RuntimeContract +} + +// GuestServer executes bounded commands inside the authenticated guest connection. +type GuestServer struct { + limits Limits + shell string + root string + gitDir string + identity WorkloadIdentity + runtime RuntimeContract + slots chan struct{} + onStart func(string) +} + +// NewGuestServer constructs the guest exec endpoint. +func NewGuestServer(cfg ServerConfig) (*GuestServer, error) { + limits := cfg.Limits.normalized() + if !validBinding(cfg.Binding) { + return nil, errors.New("microvm exec binding is invalid") + } + if err := limits.validate(); err != nil { + return nil, err + } + shell := cfg.Shell + if shell == "" { + shell = "/bin/sh" + } + root := cfg.WorkspaceRoot + if root == "" { + root = worktree.GuestWorkspace + } + gitDir := cfg.GitDirectory + if gitDir == "" { + gitDir = worktree.GuestMetadata + } + if !filepath.IsAbs(root) || !filepath.IsAbs(gitDir) { + return nil, errors.New("microvm exec workspace and Git directory must be absolute") + } + identity := cfg.WorkloadIdentity + if identity.UID == 0 || identity.GID == 0 { + return nil, errors.New("microvm exec workload identity must be unprivileged") + } + runtimeContract := cfg.RuntimeContract + if runtimeContract.Home == "" && runtimeContract.Path == "" && runtimeContract.Workdir == "" && runtimeContract.Environment == nil { + runtimeContract = DefaultRuntimeContract() + runtimeContract.Identity = identity + } + if runtimeContract.Identity != identity || runtimeContract.Home == "" || runtimeContract.Path == "" || runtimeContract.Workdir == "" { + return nil, errors.New("microvm exec runtime contract is incomplete or has the wrong identity") + } + return &GuestServer{ + limits: limits, shell: shell, root: root, gitDir: gitDir, identity: identity, runtime: runtimeContract, + slots: make(chan struct{}, limits.MaxConcurrent), + }, nil +} + +type frameType string + +const ( + frameOutput frameType = "output" + frameExit frameType = "exit" + frameError frameType = "error" + + channelStdout = "stdout" + channelStderr = "stderr" + + codeUnauthenticated = "unauthenticated" + codeOutputOverflow = "output_overflow" + codeConcurrency = "concurrency_limit" + codeCancelled = "cancelled" + codeExecution = "execution_fault" +) + +type requestFrame struct { + Command string `json:"command"` + TemporaryScope tool.TemporaryScope `json:"temporary_scope,omitempty"` +} + +type responseFrame struct { + Type frameType `json:"type"` + Channel string `json:"channel,omitempty"` + Data []byte `json:"data,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + Code string `json:"code,omitempty"` +} + +type limitedBuffer struct { + bytes []byte + remaining int64 +} + +func (b *limitedBuffer) Write(value []byte) (int, error) { + if int64(len(value)) > b.remaining { + return 0, ErrOutputOverflow + } + b.bytes = append(b.bytes, value...) + b.remaining -= int64(len(value)) + return len(value), nil +} diff --git a/environment/microvm/guestexec/exec_test.go b/environment/microvm/guestexec/exec_test.go new file mode 100644 index 000000000..cb934e452 --- /dev/null +++ b/environment/microvm/guestexec/exec_test.go @@ -0,0 +1,438 @@ +package guestexec + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +var scenarioBinding = control.Binding{ + Owner: "caller:alice", + SessionID: "session-1", + EnvironmentID: "environment-1", + Ref: "microvm:environment-1", + Generation: 7, +} + +var capabilityKey = []byte("0123456789abcdef0123456789abcdef") + +var testWorkloadIdentity = WorkloadIdentity{UID: uint32(os.Geteuid()), GID: uint32(os.Getegid())} + +func TestGuestCommandEnvUsesBoundRepositoryPaths(t *testing.T) { + const gitDir = "/run/mecatl/repositories/logical/metadata" + const root = "/run/mecatl/repositories/logical/worktree" + env := guestCommandEnv(DefaultRuntimeContract(), gitDir, root) + if !slices.Contains(env, "GIT_DIR="+gitDir) || !slices.Contains(env, "GIT_WORK_TREE="+root) { + t.Fatalf("guest command environment does not use repository binding: %v", env) + } +} + +func TestManagedTemporaryScopeIsGuestOwnedAndCleaned(t *testing.T) { + workspaceRoot := t.TempDir() + server, err := NewGuestServer(ServerConfig{ + Binding: scenarioBinding, WorkspaceRoot: workspaceRoot, WorkloadIdentity: testWorkloadIdentity, + }) + if err != nil { + t.Fatalf("NewGuestServer: %v", err) + } + verifier, err := control.NewCapabilityVerifier(capabilityKey) + if err != nil { + t.Fatal(err) + } + issuer, err := control.NewCapabilityIssuer(capabilityKey) + if err != nil { + t.Fatal(err) + } + capability, err := issuer.Issue(scenarioBinding) + if err != nil { + t.Fatal(err) + } + client, closeServer := openExecClient(t, server, verifier, scenarioBinding, capability, defaultMaxFrameBytes) + defer closeServer() + runner := mustRunner(t, RunnerConfig{Binding: scenarioBinding, Client: client}) + + result, err := runner.RunWithTemporaryScope(context.Background(), `printf '%s\n' "$TMPDIR"; printf guest-only > "$TMPDIR/marker"; cat "$TMPDIR/marker"`, tool.TemporaryScopeManaged) + if err != nil || result.ExitCode != 0 { + t.Fatalf("managed command: result=%+v err=%v", result, err) + } + lines := strings.Split(strings.TrimSpace(result.Stdout), "\n") + if len(lines) != 2 || !strings.HasPrefix(lines[0], "/tmp/mecatl-managed-") || lines[1] != "guest-only" { + t.Fatalf("managed temporary result = %q", result.Stdout) + } + if _, err := os.Stat(lines[0]); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed temporary directory survived command: %v", err) + } + if _, err := os.Stat(filepath.Join(workspaceRoot, "marker")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed temporary file escaped into workspace: %v", err) + } +} + +func TestManagedTemporaryScopeCleansUpAfterCancellation(t *testing.T) { + runner, closeServer := scenarioRunner(t, scenarioBinding, Limits{CancelGrace: 100 * time.Millisecond}) + defer closeServer() + ctx, cancel := context.WithCancel(context.Background()) + var output temporaryPathBuffer + output.notify = make(chan struct{}, 1) + done := make(chan error, 1) + go func() { + _, err := runner.RunStreamingWithTemporaryScope(ctx, `printf '%s\n' "$TMPDIR"; sleep 60`, tool.TemporaryScopeManaged, &output) + done <- err + }() + select { + case <-output.notify: + cancel() + case <-time.After(time.Second): + t.Fatal("managed command did not publish its temporary path") + } + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("managed cancellation = %v", err) + } + tempDir := strings.TrimSpace(output.String()) + if !strings.HasPrefix(tempDir, "/tmp/mecatl-managed-") { + t.Fatalf("managed temporary path = %q", tempDir) + } + if _, err := os.Stat(tempDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed temporary directory survived cancellation: %v", err) + } +} + +func TestGuestExecPinsReconstructedGitMetadata(t *testing.T) { + t.Setenv("GIT_DIR", "/host/source/.git/worktrees/session") + t.Setenv("GIT_WORK_TREE", "/host/source") + runner, closeServer := scenarioRunner(t, scenarioBinding, Limits{}) + defer closeServer() + + result, err := runner.Run(context.Background(), `printf '%s\n%s\n' "$GIT_DIR" "$GIT_WORK_TREE"`) + if err != nil || result.ExitCode != 0 { + t.Fatalf("Run: result=%+v err=%v", result, err) + } + lines := strings.Split(strings.TrimSpace(result.Stdout), "\n") + if len(lines) != 2 || lines[0] != worktree.GuestMetadata || !filepath.IsAbs(lines[1]) || lines[1] == "/host/source" { + t.Fatalf("guest Git binding = %q, want fixed metadata plus the bound workspace root", result.Stdout) + } +} + +func TestMicroVMEnvironments_Scenario4_ExecStreamingPreservesChannelsAndExit(t *testing.T) { + t.Parallel() + runner, closeServer := scenarioRunner(t, scenarioBinding, Limits{}) + defer closeServer() + + result, err := runner.Run(context.Background(), "printf 'out-1\\nout-2\\n'; printf 'err-1\\nerr-2\\n' >&2; exit 7") + if err != nil { + t.Fatalf("Run nonzero guest command: %v", err) + } + if result.Stdout != "out-1\nout-2\n" || result.Stderr != "err-1\nerr-2\n" || result.ExitCode != 7 { + t.Fatalf("result = %#v, want distinct stdout/stderr and exit 7", result) + } + + var streamed bytes.Buffer + exit, err := runner.RunStreaming(context.Background(), "printf 'out-1\\n'; sleep 0.05; printf 'err-1\\n' >&2; sleep 0.05; printf 'out-2\\n'; exit 9", &streamed) + if err != nil { + t.Fatalf("RunStreaming nonzero guest command: %v", err) + } + if exit != 9 || streamed.String() != "out-1\nerr-1\nout-2\n" { + t.Fatalf("stream = %q exit=%d, want ordered merged output and exit 9", streamed.String(), exit) + } +} + +func TestMicroVMEnvironments_Scenario4_CancelKillsGuestProcessGroup(t *testing.T) { + runner, closeServer := scenarioRunner(t, scenarioBinding, Limits{CancelGrace: 500 * time.Millisecond}) + defer closeServer() + + ctx, cancel := context.WithCancel(context.Background()) + started := time.Now() + resultCh := make(chan struct { + result CommandResult + err error + }, 1) + go func() { + result, err := runner.Run(ctx, "printf 'partial\\n'; (trap '' TERM; while :; do :; done) & child=$!; printf '%s\\n' \"$child\"; wait") + resultCh <- struct { + result CommandResult + err error + }{result, err} + }() + + var outcome struct { + result CommandResult + err error + } + select { + case outcome = <-resultCh: + t.Fatalf("command ended before cancellation: result=%#v err=%v", outcome.result, outcome.err) + case <-time.After(100 * time.Millisecond): + cancel() + } + select { + case outcome = <-resultCh: + case <-time.After(2 * time.Second): + t.Fatal("cancelled guest process group did not terminate within bound") + } + if !errors.Is(outcome.err, context.Canceled) || errors.Is(outcome.err, ErrTransport) { + t.Fatalf("cancel error = %v, want context.Canceled distinct from transport fault", outcome.err) + } + if !strings.Contains(outcome.result.Stdout, "partial\n") { + t.Fatalf("partial stdout lost on cancellation: %q", outcome.result.Stdout) + } + lines := strings.Fields(outcome.result.Stdout) + if len(lines) < 2 { + t.Fatalf("guest child pid missing from partial output: %q", outcome.result.Stdout) + } + childPID, err := strconv.Atoi(lines[1]) + if err != nil { + t.Fatalf("parse guest child pid %q: %v", lines[1], err) + } + childGoneDeadline := time.Now().Add(500 * time.Millisecond) + for { + err = syscall.Kill(childPID, 0) + if errors.Is(err, syscall.ESRCH) { + break + } + if time.Now().After(childGoneDeadline) { + t.Fatalf("guest process-group child %d survived cancellation: %v", childPID, err) + } + time.Sleep(time.Millisecond) + } + if time.Since(started) > 2*time.Second { + t.Fatalf("process-group cancellation exceeded bound: %v", time.Since(started)) + } + + deadlineCtx, deadlineCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer deadlineCancel() + deadlineResult, deadlineErr := runner.Run(deadlineCtx, "printf 'before-deadline\\n'; while :; do :; done") + if !errors.Is(deadlineErr, context.DeadlineExceeded) || errors.Is(deadlineErr, ErrTransport) { + t.Fatalf("deadline error = %v, want context.DeadlineExceeded distinct from transport fault", deadlineErr) + } + if deadlineResult.Stdout != "before-deadline\n" { + t.Fatalf("partial deadline output = %q, want produced bytes", deadlineResult.Stdout) + } +} + +func TestMicroVMEnvironments_Scenario4_GuestProtocolBoundaryIsBounded(t *testing.T) { + limits := Limits{MaxFrameBytes: 256, MaxOutputBytes: 64, MaxConcurrent: 1, CancelGrace: 200 * time.Millisecond} + var reached atomic.Int32 + server, err := newGuestServerForTest(ServerConfig{Binding: scenarioBinding, Limits: limits, WorkspaceRoot: t.TempDir()}, func(_ string) { + reached.Add(1) + }) + if err != nil { + t.Fatalf("NewGuestServer: %v", err) + } + verifier, err := control.NewCapabilityVerifier(capabilityKey) + if err != nil { + t.Fatalf("NewCapabilityVerifier: %v", err) + } + issuer, err := control.NewCapabilityIssuer(capabilityKey) + if err != nil { + t.Fatalf("NewCapabilityIssuer: %v", err) + } + capability, err := issuer.Issue(scenarioBinding) + if err != nil { + t.Fatalf("Issue: %v", err) + } + client, closeConnection := openExecClient(t, server, verifier, scenarioBinding, capability, limits.MaxFrameBytes) + defer closeConnection() + + for name, mutate := range map[string]func(*control.Binding){ + "owner": func(b *control.Binding) { b.Owner = "caller:mallory" }, + "session": func(b *control.Binding) { b.SessionID = "session-2" }, + "ref": func(b *control.Binding) { b.Ref = "microvm:stale" }, + "generation": func(b *control.Binding) { b.Generation++ }, + } { + t.Run("wrong "+name, func(t *testing.T) { + claim := scenarioBinding + mutate(&claim) + runner := mustRunner(t, RunnerConfig{Binding: claim, Client: client, Limits: limits}) + if _, err := runner.Run(context.Background(), "echo forbidden"); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("wrong %s: got %v, want ErrUnauthenticated", name, err) + } + }) + } + if reached.Load() != 0 { + t.Fatalf("invalid bindings reached guest execution %d times", reached.Load()) + } + + if _, replayCloseErr := openExecClientError(server, verifier, scenarioBinding, capability, limits.MaxFrameBytes); !errors.Is(replayCloseErr, control.ErrUnauthenticatedCapability) { + t.Fatalf("replayed endpoint capability: got %v, want ErrUnauthenticatedCapability", replayCloseErr) + } + + runner := mustRunner(t, RunnerConfig{Binding: scenarioBinding, Client: client, Limits: limits}) + if _, err := runner.Run(context.Background(), strings.Repeat("x", 1024)); !errors.Is(err, control.ErrFrameTooLarge) { + t.Fatalf("oversized request: got %v, want ErrFrameTooLarge", err) + } + if _, err := runner.Run(context.Background(), "printf '%0100d' 1"); !errors.Is(err, ErrOutputOverflow) { + t.Fatalf("output overflow: got %v, want ErrOutputOverflow", err) + } + + blockCtx, blockCancel := context.WithCancel(context.Background()) + defer blockCancel() + firstDone := make(chan error, 1) + go func() { + _, runErr := runner.Run(blockCtx, "while :; do :; done") + firstDone <- runErr + }() + deadline := time.Now().Add(time.Second) + for reached.Load() < 2 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if _, err := runner.Run(context.Background(), "echo over-limit"); !errors.Is(err, ErrConcurrencyLimit) { + t.Fatalf("excessive concurrent exec: got %v, want ErrConcurrencyLimit", err) + } + blockCancel() + select { + case <-firstDone: + case <-time.After(time.Second): + t.Fatal("bounded concurrent command did not cancel") + } +} + +func newGuestServerForTest(cfg ServerConfig, onStart func(string)) (*GuestServer, error) { + if cfg.WorkloadIdentity == (WorkloadIdentity{}) { + cfg.WorkloadIdentity = testWorkloadIdentity + } + server, err := NewGuestServer(cfg) + if err == nil { + server.onStart = onStart + } + return server, err +} + +func scenarioRunner(t *testing.T, binding control.Binding, limits Limits) (*Runner, func()) { + t.Helper() + server, err := NewGuestServer(ServerConfig{ + Binding: scenarioBinding, Limits: limits, WorkspaceRoot: t.TempDir(), WorkloadIdentity: testWorkloadIdentity, + }) + if err != nil { + t.Fatalf("NewGuestServer: %v", err) + } + verifier, err := control.NewCapabilityVerifier(capabilityKey) + if err != nil { + t.Fatalf("NewCapabilityVerifier: %v", err) + } + issuer, err := control.NewCapabilityIssuer(capabilityKey) + if err != nil { + t.Fatalf("NewCapabilityIssuer: %v", err) + } + capability, err := issuer.Issue(binding) + if err != nil { + t.Fatalf("Issue: %v", err) + } + client, closeServer := openExecClient(t, server, verifier, binding, capability, limits.normalized().MaxFrameBytes) + return mustRunner(t, RunnerConfig{Binding: binding, Client: client, Limits: limits}), closeServer +} + +func openExecClient(t *testing.T, server *GuestServer, verifier *control.CapabilityVerifier, binding control.Binding, capability string, maxFrameBytes uint32) (*control.Client, func()) { + t.Helper() + host, guest := net.Pipe() + serveDone := make(chan error, 1) + go func() { + serveDone <- control.ServeMultiplex(context.Background(), guest, scenarioBinding, verifier, map[control.ServiceName]control.Handler{control.ServiceExec: server.Handler()}, maxFrameBytes) + }() + client, err := control.OpenClient(context.Background(), host, binding, capability, []control.ServiceName{control.ServiceExec}, maxFrameBytes) + if err != nil { + _ = host.Close() + _ = guest.Close() + <-serveDone + t.Fatalf("OpenClient: %v", err) + } + return client, func() { + _ = client.Close() + _ = guest.Close() + if err := <-serveDone; err != nil && !errors.Is(err, io.EOF) { + t.Errorf("ServeMultiplex: %v", err) + } + } +} + +func openExecClientError(server *GuestServer, verifier *control.CapabilityVerifier, binding control.Binding, capability string, maxFrameBytes uint32) (*control.Client, error) { + host, guest := net.Pipe() + done := make(chan error, 1) + go func() { + done <- control.ServeMultiplex(context.Background(), guest, scenarioBinding, verifier, map[control.ServiceName]control.Handler{control.ServiceExec: server.Handler()}, maxFrameBytes) + }() + client, err := control.OpenClient(context.Background(), host, binding, capability, []control.ServiceName{control.ServiceExec}, maxFrameBytes) + _ = host.Close() + _ = guest.Close() + <-done + return client, err +} + +func TestTransportFaultIsNotGuestExit(t *testing.T) { + issuer, err := control.NewCapabilityIssuer(capabilityKey) + if err != nil { + t.Fatalf("NewCapabilityIssuer: %v", err) + } + verifier, err := control.NewCapabilityVerifier(capabilityKey) + if err != nil { + t.Fatalf("NewCapabilityVerifier: %v", err) + } + capability, err := issuer.Issue(scenarioBinding) + if err != nil { + t.Fatalf("Issue: %v", err) + } + host, guest := net.Pipe() + serveDone := make(chan error, 1) + handler := func(context.Context, string, json.RawMessage, func(any) error) (any, string, error) { + _ = guest.Close() + return nil, "transport", io.ErrClosedPipe + } + go func() { + serveDone <- control.ServeMultiplex(context.Background(), guest, scenarioBinding, verifier, map[control.ServiceName]control.Handler{ + control.ServiceExec: handler, + }, control.DefaultMaxMessageBytes) + }() + client, err := control.OpenClient(context.Background(), host, scenarioBinding, capability, []control.ServiceName{control.ServiceExec}, control.DefaultMaxMessageBytes) + if err != nil { + t.Fatalf("OpenClient: %v", err) + } + defer func() { + _ = client.Close() + <-serveDone + }() + runner := mustRunner(t, RunnerConfig{Binding: scenarioBinding, Client: client}) + marker := t.TempDir() + "/must-not-exist" + if _, err := runner.Run(context.Background(), "touch "+marker); !errors.Is(err, ErrTransport) { + t.Fatalf("transport close = %v, want ErrTransport", err) + } + if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("transport fault fell back to host command: %v", err) + } +} + +type temporaryPathBuffer struct { + bytes.Buffer + notify chan struct{} +} + +func (b *temporaryPathBuffer) Write(p []byte) (int, error) { + n, err := b.Buffer.Write(p) + select { + case b.notify <- struct{}{}: + default: + } + return n, err +} + +func mustRunner(t *testing.T, cfg RunnerConfig) *Runner { + t.Helper() + runner, err := NewRunner(cfg) + if err != nil { + t.Fatalf("NewRunner: %v", err) + } + return runner +} diff --git a/environment/microvm/guestexec/process_linux.go b/environment/microvm/guestexec/process_linux.go new file mode 100644 index 000000000..b0766d4e6 --- /dev/null +++ b/environment/microvm/guestexec/process_linux.go @@ -0,0 +1,32 @@ +//go:build linux + +package guestexec + +import ( + "os/exec" + "syscall" + "time" +) + +func configureProcessGroup(cmd *exec.Cmd, grace time.Duration) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if err == syscall.ESRCH { + return nil + } + return err + } + cmd.WaitDelay = grace +} + +func configureWorkloadIdentity(cmd *exec.Cmd, identity WorkloadIdentity) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Credential = &syscall.Credential{Uid: identity.UID, Gid: identity.GID, NoSetGroups: true} + cmd.SysProcAttr.AmbientCaps = nil +} diff --git a/environment/microvm/guestexec/process_other.go b/environment/microvm/guestexec/process_other.go new file mode 100644 index 000000000..99c075ccb --- /dev/null +++ b/environment/microvm/guestexec/process_other.go @@ -0,0 +1,14 @@ +//go:build !unix + +package guestexec + +import ( + "os/exec" + "time" +) + +func configureProcessGroup(cmd *exec.Cmd, grace time.Duration) { + cmd.WaitDelay = grace +} + +func configureWorkloadIdentity(_ *exec.Cmd, _ WorkloadIdentity) {} diff --git a/environment/microvm/guestexec/process_unix.go b/environment/microvm/guestexec/process_unix.go new file mode 100644 index 000000000..50b732bab --- /dev/null +++ b/environment/microvm/guestexec/process_unix.go @@ -0,0 +1,31 @@ +//go:build unix && !linux + +package guestexec + +import ( + "os/exec" + "syscall" + "time" +) + +func configureProcessGroup(cmd *exec.Cmd, grace time.Duration) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if err == syscall.ESRCH { + return nil + } + return err + } + cmd.WaitDelay = grace +} + +func configureWorkloadIdentity(cmd *exec.Cmd, identity WorkloadIdentity) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Credential = &syscall.Credential{Uid: identity.UID, Gid: identity.GID, NoSetGroups: true} +} diff --git a/environment/microvm/guestexec/security_linux_test.go b/environment/microvm/guestexec/security_linux_test.go new file mode 100644 index 000000000..bc76d0ba8 --- /dev/null +++ b/environment/microvm/guestexec/security_linux_test.go @@ -0,0 +1,51 @@ +//go:build linux + +package guestexec + +import ( + "os/exec" + "syscall" + "testing" +) + +func TestInvariant_guest_workload_is_unprivileged(t *testing.T) { + identity := DefaultWorkloadIdentity() + if identity.UID == 0 || identity.GID == 0 { + t.Fatalf("production workload identity = %d:%d, must not be guest root", identity.UID, identity.GID) + } + + cmd := exec.Command("/bin/sh", "-c", "true") + configureProcessGroup(cmd, defaultCancelGrace) + configureWorkloadIdentity(cmd, identity) + attr := cmd.SysProcAttr + if attr == nil || attr.Credential == nil { + t.Fatal("guest workload has no kernel credential drop") + } + if attr.Credential.Uid != identity.UID || attr.Credential.Gid != identity.GID || !attr.Credential.NoSetGroups { + t.Fatalf("guest workload credential = %#v, want dedicated %d:%d with no supplementary groups", attr.Credential, identity.UID, identity.GID) + } + if len(attr.AmbientCaps) != 0 { + t.Fatalf("guest workload ambient capabilities = %v, want none", attr.AmbientCaps) + } + if attr.Setpgid != true { + t.Fatal("guest workload lost process-group cancellation") + } +} + +func TestGuestServerRejectsRootWorkloadIdentity(t *testing.T) { + _, err := NewGuestServer(ServerConfig{Binding: scenarioBinding, WorkspaceRoot: t.TempDir(), WorkloadIdentity: WorkloadIdentity{UID: 0, GID: 0}}) + if err == nil { + t.Fatal("guest exec accepted root workload identity") + } +} + +func TestGuestWorkloadIdentityAllowsNormalWorkspaceCommand(t *testing.T) { + identity := WorkloadIdentity{UID: uint32(syscall.Geteuid()), GID: uint32(syscall.Getegid())} + server, err := NewGuestServer(ServerConfig{Binding: scenarioBinding, WorkspaceRoot: t.TempDir(), WorkloadIdentity: identity}) + if err != nil { + t.Fatalf("NewGuestServer with unprivileged test identity: %v", err) + } + if server.identity != identity { + t.Fatalf("server identity = %#v, want %#v", server.identity, identity) + } +} diff --git a/environment/microvm/guestexec/server.go b/environment/microvm/guestexec/server.go new file mode 100644 index 000000000..538b57487 --- /dev/null +++ b/environment/microvm/guestexec/server.go @@ -0,0 +1,182 @@ +package guestexec + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/control" +) + +// Handler returns the exec service for the shared authenticated guest multiplexer. +func (s *GuestServer) Handler() control.Handler { + return func(ctx context.Context, method string, payload json.RawMessage, send func(any) error) (any, string, error) { + if method != "run" { + return nil, "unsupported_method", control.ErrMalformedFrame + } + var request requestFrame + if err := json.Unmarshal(payload, &request); err != nil { + return nil, "malformed", control.ErrMalformedFrame + } + if request.TemporaryScope != "" && request.TemporaryScope != tool.TemporaryScopeManaged && request.TemporaryScope != tool.TemporaryScopeSystem { + return nil, "malformed", control.ErrMalformedFrame + } + select { + case s.slots <- struct{}{}: + defer func() { <-s.slots }() + default: + return nil, codeConcurrency, ErrConcurrencyLimit + } + if s.onStart != nil { + s.onStart(request.Command) + } + return s.execute(ctx, send, request.Command, request.TemporaryScope) + } +} + +func guestCommandEnv(contract RuntimeContract, gitDir, worktreeRoot string) []string { + return append(contract.commandEnvironment(), + "GIT_DIR="+gitDir, + "GIT_WORK_TREE="+worktreeRoot, + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_TERMINAL_PROMPT=0", + "GIT_PAGER=cat", + "PAGER=cat", + ) +} + +func (s *GuestServer) execute(ctx context.Context, send func(any) error, command string, scope tool.TemporaryScope) (any, string, error) { //nolint:gocyclo // explicit process/output/cancel state machine + commandEnv := guestCommandEnv(s.runtime, s.gitDir, s.root) + if scope == tool.TemporaryScopeManaged { + tempDir, err := os.MkdirTemp("/tmp", "mecatl-managed-") + if err != nil { + return nil, codeExecution, err + } + defer func() { _ = os.RemoveAll(tempDir) }() + if os.Geteuid() != int(s.identity.UID) || os.Getegid() != int(s.identity.GID) { + if err := os.Chown(tempDir, int(s.identity.UID), int(s.identity.GID)); err != nil { + return nil, codeExecution, err + } + } + commandEnv = append(commandEnv, "TMPDIR="+tempDir, "GOTMPDIR="+tempDir) + } + processCtx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + cmd := exec.CommandContext(processCtx, s.shell, "-c", command) // #nosec G204 -- command is the explicit guest exec payload. + cmd.Dir = s.root + cmd.Env = commandEnv + configureProcessGroup(cmd, s.limits.CancelGrace) + configureWorkloadIdentity(cmd, s.identity) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, codeExecution, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, codeExecution, err + } + if err := cmd.Start(); err != nil { + return nil, codeExecution, err + } + + type output struct { + channel string + data []byte + err error + } + outputs := make(chan output, 4) + var readers sync.WaitGroup + readers.Add(2) + readPipe := func(channel string, src io.Reader) { + defer readers.Done() + bufferSize := min(int(s.limits.MaxFrameBytes/2), 32<<10) + buffer := make([]byte, bufferSize) + for { + n, readErr := src.Read(buffer) + if n > 0 { + chunk := append([]byte(nil), buffer[:n]...) + select { + case outputs <- output{channel: channel, data: chunk}: + case <-processCtx.Done(): + return + } + } + if readErr != nil { + if !errors.Is(readErr, io.EOF) { + select { + case outputs <- output{err: readErr}: + case <-processCtx.Done(): + } + } + return + } + } + } + go readPipe(channelStdout, stdout) + go readPipe(channelStderr, stderr) + waitDone := make(chan error, 1) + go func() { + readers.Wait() + close(outputs) + waitDone <- cmd.Wait() + }() + var outputBytes int64 + var waitErr error + waited := false + for outputs != nil || !waited { + select { + case item, ok := <-outputs: + if !ok { + outputs = nil + continue + } + if item.err != nil { + cancel(item.err) + continue + } + outputBytes += int64(len(item.data)) + if outputBytes > s.limits.MaxOutputBytes { + cancel(ErrOutputOverflow) + return nil, codeOutputOverflow, ErrOutputOverflow + } + if err := send(responseFrame{Type: frameOutput, Channel: item.channel, Data: item.data}); err != nil { + cancel(err) + return nil, codeExecution, err + } + case waitErr = <-waitDone: + waited = true + waitDone = nil + case <-processCtx.Done(): + // configureProcessGroup terminates the command tree; continue draining + // already-produced output until Wait completes. + } + } + + cause := context.Cause(processCtx) + if cause != nil { + if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) { + return responseFrame{Type: frameError, Code: codeCancelled, ExitCode: exitCode(waitErr)}, codeCancelled, cause + } + return nil, codeExecution, fmt.Errorf("guest command execution: %w", cause) + } + return responseFrame{Type: frameExit, ExitCode: exitCode(waitErr)}, "", nil +} + +func exitCode(waitErr error) int { + if waitErr == nil { + return 0 + } + var exitErr *exec.ExitError + if errors.As(waitErr, &exitErr) { + return exitErr.ExitCode() + } + return -1 +} diff --git a/environment/microvm/lifecycle.go b/environment/microvm/lifecycle.go new file mode 100644 index 000000000..c408ecf15 --- /dev/null +++ b/environment/microvm/lifecycle.go @@ -0,0 +1,666 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +// Kind is the open EnvironmentRef kind owned by the microVM adapter. +const Kind = "microvm" + +// EnvironmentState is the durable driver-side lifecycle state. +type EnvironmentState string + +const ( + // EnvironmentProvisioning has durable planned identities but is not runnable. + EnvironmentProvisioning EnvironmentState = "provisioning" + // EnvironmentReady has passed readiness and protocol negotiation. + EnvironmentReady EnvironmentState = "ready" + // EnvironmentDeleting is a durable tombstone written before destructive work. + EnvironmentDeleting EnvironmentState = "deleting" + // EnvironmentCleanupPending retains resources for lifecycle reconciliation. + EnvironmentCleanupPending EnvironmentState = "cleanup-pending" + // EnvironmentDestroyed records successful rollback or permanent destruction. + EnvironmentDestroyed EnvironmentState = "destroyed" +) + +var ( + // ErrInvalidEnvironmentRef rejects malformed or non-microVM refs. + ErrInvalidEnvironmentRef = errors.New("invalid microvm environment ref") + // ErrEnvironmentUnknown rejects refs absent from the durable registry. + ErrEnvironmentUnknown = errors.New("microvm environment is unknown") + // ErrEnvironmentForeign rejects environments owned by another caller. + ErrEnvironmentForeign = errors.New("microvm environment belongs to another owner") + // ErrEnvironmentStale rejects non-ready or generation-mismatched refs. + ErrEnvironmentStale = errors.New("microvm environment generation is stale") + // ErrEnvironmentDestroyed rejects permanently destroyed generations. + ErrEnvironmentDestroyed = errors.New("microvm environment is destroyed") + // ErrEnvironmentIncompatible rejects records without the required protocol. + ErrEnvironmentIncompatible = errors.New("microvm environment protocol is incompatible") +) + +// EnvironmentRef is the nested module's transport-neutral form of the durable +// engine ref. A thin host adapter maps it to session.EnvironmentRef. +type EnvironmentRef struct { + Kind string + ID string +} + +// EnvironmentIdentity reserves every cleanup-relevant identity before resources +// are created. Allocators must return collision-resistant, generation-fenced values. +type EnvironmentIdentity struct { + EnvironmentID string + VMID string + Endpoint string + Generation uint32 +} + +// IdentityAllocator allocates one complete provisional identity set. +type IdentityAllocator interface { + Allocate(sessionID string) (EnvironmentIdentity, error) +} + +// ArtifactVerifier admits the complete immutable artifact set under one policy revision. +type ArtifactVerifier interface { + Verify(context.Context, map[ArtifactKind]ArtifactRequest) (VerifiedArtifacts, string, error) +} + +type artifactLaunchValidator interface { + LockAndValidate(context.Context, VerifiedArtifacts) (launch VerifiedArtifacts, release func(), err error) +} + +// WorktreeLifecycle is task-05 preparation plus its idempotent rollback operation. +type WorktreeLifecycle interface { + Prepare(context.Context, worktree.Request) (*worktree.Prepared, error) + Cleanup(context.Context, *worktree.Prepared) error +} + +// GuestPrebootConfigPath is the rootfs location consumed by the guest agent before +// workload services are started. +const GuestPrebootConfigPath = "/etc/mecatl/guest-agent.json" + +// GuestPrebootConfig is immutable boot-time policy and guest-agent material. The +// concrete VMRuntime installs it into the rootfs before booting the workload. +type GuestPrebootConfig struct { + DisableIPv6 bool `json:"disable_ipv6"` + AgentEndpoint string `json:"agent_endpoint"` + Binding control.Binding `json:"binding"` + Capabilities control.Capabilities `json:"capabilities"` + MaxMessageBytes uint32 `json:"max_message_bytes"` +} + +// GuestPrebootArtifact is the explicit rootfs hook passed to VMRuntime. +type GuestPrebootArtifact struct { + RootfsPath string `json:"rootfs_path"` + Config GuestPrebootConfig `json:"config"` +} + +// VMCreateRequest binds launch inputs to identities recorded before launch. +type VMCreateRequest struct { + EnvironmentID string + VMID string + Endpoint string + Generation uint32 + Owner string + SessionID string + Profile string + Prepared *worktree.Prepared + Artifacts map[ArtifactKind]string + Verified VerifiedArtifacts + Resources ResourceUsage + Preboot GuestPrebootArtifact +} + +// VMRuntime is the narrow lifecycle seam around the go-microvm/libkrun runtime. +// Create must install VMCreateRequest.Preboot at its rootfs path before starting +// guest workload services and fail if it cannot. Destroy must be idempotent because +// Create may fail after partially provisioning. +type VMRuntime interface { + Create(context.Context, VMCreateRequest) error + WaitReady(context.Context, EnvironmentRecord) error + Inspect(context.Context, EnvironmentRecord) (RuntimeStatus, error) + Destroy(context.Context, EnvironmentRecord) error +} + +// ProtocolNegotiator performs the task-04 binding and capability handshake. +type ProtocolNegotiator interface { + Negotiate(context.Context, string, control.Binding) (control.Agreement, error) +} + +// EnvironmentRegistry durably stores the driver record. Save is latest-value wins. +type EnvironmentRegistry interface { + Save(context.Context, EnvironmentRecord) error +} + +// SessionPersister is the final commit boundary. A successful call must durably +// stamp the host session's worktree and mapped EnvironmentRef. +type SessionPersister interface { + Persist(context.Context, SessionPlacement) error +} + +// EnforcedProfileStatus is the daemon-authored, response-safe projection of +// policy actually applied to a generation. +type EnforcedProfileStatus struct { + Profile string + GuestEgress string + HostEgress string +} + +// EnvironmentRecord is the complete durable driver identity and reconciliation record. +type EnvironmentRecord struct { + State EnvironmentState + Owner string + SessionID string + Profile string + EnvironmentID string + Ref EnvironmentRef + Generation uint32 + SourceCheckout string + WorktreePath string + MetadataPath string + GuestRoot string + VMID string + Endpoint string + Artifacts map[ArtifactKind]string + ManifestDigests map[ArtifactKind]string + PolicyRevision string + ProfileStatus EnforcedProfileStatus + // AdmissionUsage is the durable retained reservation reconstructed after + // daemon restart and released only after destruction is durable. + AdmissionUsage ResourceUsage + Agreement control.Agreement + ProcessIdentity string + RunnerPID int + // ParentRef and ForkBase are both set only for child generations. They bind + // resume, merge, and reconciliation to the exact parent generation and the + // immutable pre-mutation tree captured by Fork. + ParentRef EnvironmentRef + ForkBase string + Tombstone bool + DeleteReason DeleteReason + PreserveWorktree bool + VMDeleted bool + WorktreeDeleted bool +} + +// SessionPlacement is the exact durable data committed to the host session store. +type SessionPlacement struct { + SessionID string + Owner string + Profile string + SourceCheckout string + WorktreePath string + GuestRoot string + Ref EnvironmentRef + Generation uint32 + VMID string + Endpoint string + Artifacts map[ArtifactKind]string + ManifestDigests map[ArtifactKind]string + PolicyRevision string +} + +// CreatedEnvironment is returned only after SessionPersister succeeds. +type CreatedEnvironment struct { + Ref EnvironmentRef + Generation uint32 + HostWorktree string + GuestRoot string + // Admission retains active VM, worktree, CPU, RAM, disk and inode capacity + // until the lifecycle owner destroys the environment and releases it. + Admission *AdmissionLease +} + +// CreateRequest contains caller identity plus already-resolved operator profile inputs. +type CreateRequest struct { + Owner string + SessionID string + Profile string + Worktree worktree.Request + ArtifactRequests map[ArtifactKind]ArtifactRequest + // Resources are retained for the environment lifetime. VM state, worktree, + // and boot-rate dimensions are added by Lifecycle and must be zero here. + Resources ResourceUsage + ProfileStatus EnforcedProfileStatus + // ParentRef and ForkBase bind a delegated child before its worktree or VM is + // exposed. They are either both zero or both generation-exact/non-empty. + ParentRef EnvironmentRef + ForkBase string +} + +// LifecycleDeps are the deterministic lifecycle transaction seams. +type LifecycleDeps struct { + Identities IdentityAllocator + Worktrees WorktreeLifecycle + Artifacts ArtifactVerifier + VMs VMRuntime + Protocol ProtocolNegotiator + Registry EnvironmentRegistry + Sessions SessionPersister + Admission *AdmissionController + Observer *OperationsObserver +} + +// Lifecycle coordinates one generation's failure-aware creation transaction. +type Lifecycle struct{ deps LifecycleDeps } + +// NewLifecycle constructs a lifecycle coordinator. Configuration is checked at Create. +func NewLifecycle(deps LifecycleDeps) *Lifecycle { return &Lifecycle{deps: deps} } + +// Create prepares, verifies, boots, negotiates, stamps, and persists one environment. +// Every resource identity is registered before provisioning; later failure either +// destroys the resources or leaves that durable record cleanup-pending/provisioning. +func (l *Lifecycle) Create(ctx context.Context, request CreateRequest) (CreatedEnvironment, error) { + if err := l.validateRequest(request); err != nil { + return CreatedEnvironment{}, err + } + admission, err := l.admitCreate(ctx, request) + if err != nil { + l.observeAdmissionError(err) + return CreatedEnvironment{}, err + } + started := time.Now() + committed := false + defer func() { + releaseUncommittedAdmission(admission, committed) + }() + identity, err := l.allocateIdentity(request.SessionID) + if err != nil { + return CreatedEnvironment{}, err + } + ref := EnvironmentRef{Kind: Kind, ID: identity.EnvironmentID + "@" + strconv.FormatUint(uint64(identity.Generation), 10)} + record := EnvironmentRecord{ + State: EnvironmentProvisioning, Owner: request.Owner, SessionID: request.SessionID, Profile: request.Profile, + EnvironmentID: identity.EnvironmentID, Ref: ref, Generation: identity.Generation, + SourceCheckout: request.Worktree.Source, WorktreePath: request.Worktree.WorktreePath, + MetadataPath: request.Worktree.MetadataPath, GuestRoot: worktree.GuestWorkspace, + VMID: identity.VMID, Endpoint: identity.Endpoint, Artifacts: requestDigests(request.ArtifactRequests), + ManifestDigests: requestManifestDigests(request.ArtifactRequests), + ProfileStatus: request.ProfileStatus, AdmissionUsage: bootingUsage(request.Resources), ParentRef: request.ParentRef, ForkBase: request.ForkBase, + } + if err := l.deps.Registry.Save(ctx, record); err != nil { + return CreatedEnvironment{}, fmt.Errorf("persist provisional microvm record: %w", err) + } + + var prepared *worktree.Prepared + vmAttempted := false + + prepared, err = l.deps.Worktrees.Prepare(ctx, request.Worktree) + if err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, fmt.Errorf("prepare microvm worktree: %w", err)) + } + if err := validatePreparedWorktree(prepared, record); err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, err) + } + if err := l.deps.Registry.Save(ctx, record); err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, fmt.Errorf("persist prepared microvm worktree: %w", err)) + } + + verified, policyRevision, err := l.verifyArtifacts(ctx, request) + if err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, fmt.Errorf("verify microvm artifacts: %w", err)) + } + if policyRevision == "" || !verifiedMatches(record.Artifacts, record.ManifestDigests, verified) { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, errors.New("verified microvm artifact identities do not match the provisional record")) + } + record.PolicyRevision = policyRevision + if err := l.deps.Registry.Save(ctx, record); err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, fmt.Errorf("persist verified microvm artifacts: %w", err)) + } + + vmAttempted = true + binding := control.Binding{Owner: record.Owner, SessionID: record.SessionID, EnvironmentID: record.EnvironmentID, Ref: record.Ref.ID, Generation: record.Generation} + vmRequest := VMCreateRequest{ + EnvironmentID: record.EnvironmentID, VMID: record.VMID, Endpoint: record.Endpoint, + Generation: record.Generation, Owner: record.Owner, SessionID: record.SessionID, + Profile: record.Profile, Prepared: prepared, Artifacts: cloneArtifacts(record.Artifacts), Verified: verified, + Resources: request.Resources, + Preboot: GuestPrebootArtifact{ + RootfsPath: GuestPrebootConfigPath, + Config: GuestPrebootConfig{ + DisableIPv6: true, AgentEndpoint: record.Endpoint, Binding: binding, + Capabilities: control.RequiredCapabilities(), MaxMessageBytes: control.DefaultMaxMessageBytes, + }, + }, + } + if err := l.createVMWithVerifiedArtifacts(ctx, verified, vmRequest); err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, err) + } + if err := l.deps.VMs.WaitReady(ctx, record); err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, fmt.Errorf("wait for microvm readiness: %w", err)) + } + record, err = l.inspectReadyRuntime(ctx, record) + if err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, err) + } + if err := transitionAdmission(admission, request.Resources); err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, err) + } + record.AdmissionUsage = activeUsage(request.Resources) + + agreement, err := l.deps.Protocol.Negotiate(ctx, record.Endpoint, binding) + if err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, fmt.Errorf("negotiate microvm guest protocol: %w", err)) + } + if err := validateAgreement(agreement); err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, err) + } + record.Agreement = agreement + record.State = EnvironmentReady + if err := l.deps.Registry.Save(ctx, record); err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, fmt.Errorf("persist ready microvm record: %w", err)) + } + + placement := SessionPlacement{ + SessionID: record.SessionID, Owner: record.Owner, Profile: record.Profile, + SourceCheckout: record.SourceCheckout, WorktreePath: record.WorktreePath, + GuestRoot: record.GuestRoot, Ref: record.Ref, Generation: record.Generation, + VMID: record.VMID, Endpoint: record.Endpoint, Artifacts: cloneArtifacts(record.Artifacts), + ManifestDigests: cloneArtifacts(record.ManifestDigests), PolicyRevision: record.PolicyRevision, + } + if err := l.deps.Sessions.Persist(ctx, placement); err != nil { + return CreatedEnvironment{}, l.rollback(ctx, record, prepared, vmAttempted, fmt.Errorf("persist microvm-backed session: %w", err)) + } + committed = true + l.observeBootFinished(started) + return CreatedEnvironment{Ref: record.Ref, Generation: record.Generation, HostWorktree: record.WorktreePath, GuestRoot: record.GuestRoot, Admission: admission}, nil +} + +func (l *Lifecycle) createVMWithVerifiedArtifacts(ctx context.Context, verified VerifiedArtifacts, request VMCreateRequest) error { + release := func() {} + if validator, ok := l.deps.Artifacts.(artifactLaunchValidator); ok { + var err error + verified, release, err = validator.LockAndValidate(ctx, verified) + if err != nil { + return fmt.Errorf("revalidate microvm artifacts for launch: %w", err) + } + request.Verified = verified + } + defer release() + if err := l.deps.VMs.Create(ctx, request); err != nil { + return fmt.Errorf("create microvm: %w", err) + } + return nil +} + +func (l *Lifecycle) observeBootFinished(started time.Time) { + if l.deps.Observer != nil { + l.deps.Observer.BootFinished(time.Since(started)) + } +} + +func (l *Lifecycle) verifyArtifacts(ctx context.Context, request CreateRequest) (VerifiedArtifacts, string, error) { + var lease *AdmissionLease + var err error + if l.deps.Admission != nil { + lease, err = l.deps.Admission.AcquireContext(ctx, request.Owner, ResourceUsage{Pulls: 1}) + if err != nil { + l.observeAdmissionError(err) + return VerifiedArtifacts{}, "", err + } + defer lease.Release() + } + verified, policyRevision, err := l.deps.Artifacts.Verify(ctx, cloneArtifactRequests(request.ArtifactRequests)) + return verified, policyRevision, err +} + +func (l *Lifecycle) allocateIdentity(sessionID string) (EnvironmentIdentity, error) { + identity, err := l.deps.Identities.Allocate(sessionID) + if err != nil { + return EnvironmentIdentity{}, fmt.Errorf("allocate microvm identity: %w", err) + } + if err := validateIdentity(identity); err != nil { + return EnvironmentIdentity{}, err + } + return identity, nil +} + +func (l *Lifecycle) observeAdmissionError(err error) { + if l.deps.Observer == nil { + return + } + var admissionErr *AdmissionError + if !errors.As(err, &admissionErr) { + return + } + kind, ok := quotaKindForResource(admissionErr.Resource) + if ok { + l.deps.Observer.QuotaRejected(kind) + } +} + +func quotaKindForResource(resource Resource) (QuotaKind, bool) { + switch resource { + case ResourceBootingVMs, ResourceActiveVMs: + return QuotaVMs, true + case ResourceCPU: + return QuotaVCPUs, true + case ResourceRAMBytes: + return QuotaMemory, true + case ResourceDiskBytes: + return QuotaDisk, true + case ResourceExecs: + return QuotaExecs, true + case ResourceWorktrees: + return QuotaWorktrees, true + case ResourceInodes: + return QuotaInodes, true + case ResourceForks: + return QuotaForks, true + case ResourcePulls: + return QuotaPulls, true + case ResourceBootRate: + return QuotaBootRate, true + default: + return 0, false + } +} + +func (l *Lifecycle) admitCreate(ctx context.Context, request CreateRequest) (*AdmissionLease, error) { + if l.deps.Admission == nil { + return nil, nil + } + return l.deps.Admission.AcquireContext(ctx, request.Owner, bootingUsage(request.Resources)) +} + +func releaseUncommittedAdmission(admission *AdmissionLease, committed bool) { + if admission != nil && !committed { + admission.Release() + } +} + +func transitionAdmission(admission *AdmissionLease, resources ResourceUsage) error { + if admission == nil { + return nil + } + if err := admission.Replace(activeUsage(resources)); err != nil { + return fmt.Errorf("admit active microvm resources: %w", err) + } + return nil +} + +func (l *Lifecycle) inspectReadyRuntime(ctx context.Context, record EnvironmentRecord) (EnvironmentRecord, error) { + status, err := l.deps.VMs.Inspect(ctx, record) + if err != nil { + return record, fmt.Errorf("inspect ready microvm identity: %w", err) + } + if !status.Live || status.Generation != record.Generation || status.VMID != record.VMID || status.Endpoint != record.Endpoint || status.PID <= 0 || status.ProcessIdentity == "" { + return record, ErrRuntimeIdentityMismatch + } + record.RunnerPID = status.PID + record.ProcessIdentity = status.ProcessIdentity + return record, nil +} + +func (l *Lifecycle) rollback(ctx context.Context, record EnvironmentRecord, prepared *worktree.Prepared, vmAttempted bool, cause error) error { + cleanupCtx := context.WithoutCancel(ctx) + var cleanupErr error + if vmAttempted { + cleanupErr = errors.Join(cleanupErr, l.deps.VMs.Destroy(cleanupCtx, record)) + } + if prepared != nil { + cleanupErr = errors.Join(cleanupErr, l.deps.Worktrees.Cleanup(cleanupCtx, prepared)) + } + if cleanupErr == nil { + record.State = EnvironmentDestroyed + } else { + record.State = EnvironmentCleanupPending + } + registryErr := l.deps.Registry.Save(cleanupCtx, record) + return errors.Join(cause, cleanupErr, registryErr) +} + +func (l *Lifecycle) validateRequest(request CreateRequest) error { + if !l.configured() { + return errors.New("microvm lifecycle is not fully configured") + } + if !completeCreateRequest(request) { + return errors.New("microvm lifecycle request is incomplete") + } + parentSet := request.ParentRef != (EnvironmentRef{}) + if parentSet != (request.ForkBase != "") { + return errors.New("microvm child lifecycle requires parent ref and immutable fork base together") + } + if parentSet { + if request.ParentRef.Kind != Kind { + return errors.New("microvm child parent kind is invalid") + } + if _, _, err := parseEnvironmentRef(request.ParentRef); err != nil { + return fmt.Errorf("microvm child parent ref: %w", err) + } + } + if err := validateLifecycleResources(request.Resources); err != nil { + return err + } + for _, kind := range []ArtifactKind{ArtifactRuntime, ArtifactFirmware, ArtifactExecutionImage, ArtifactGuestAgent} { + artifact, ok := request.ArtifactRequests[kind] + if !ok || artifact.Kind != kind || artifact.Digest == "" { + return fmt.Errorf("microvm lifecycle request is missing %s", kind) + } + } + return nil +} + +func (l *Lifecycle) configured() bool { + return l != nil && l.deps.Identities != nil && l.deps.Worktrees != nil && l.deps.Artifacts != nil && l.deps.VMs != nil && l.deps.Protocol != nil && l.deps.Registry != nil && l.deps.Sessions != nil +} + +func completeCreateRequest(request CreateRequest) bool { + return request.Owner != "" && request.SessionID != "" && request.Profile != "" && request.Worktree.Source != "" && request.Worktree.WorktreePath != "" && request.Worktree.MetadataPath != "" && request.Worktree.Branch != "" +} + +func validateLifecycleResources(resources ResourceUsage) error { + controlled := resources + controlled.CPU = 0 + controlled.RAMBytes = 0 + controlled.DiskBytes = 0 + controlled.Inodes = 0 + if controlled != (ResourceUsage{}) { + return errors.New("microvm lifecycle resources may specify only CPU, RAM, disk, and inodes") + } + if err := validateUsage(resources); err != nil { + return fmt.Errorf("microvm lifecycle resources: %w", err) + } + return nil +} + +func validatePreparedWorktree(prepared *worktree.Prepared, record EnvironmentRecord) error { + if prepared == nil || prepared.SourceRoot != record.SourceCheckout || prepared.WorktreePath != record.WorktreePath || prepared.MetadataPath != record.MetadataPath { + return errors.New("prepared microvm worktree identities do not match the provisional record") + } + return nil +} + +func validateIdentity(identity EnvironmentIdentity) error { + if identity.EnvironmentID == "" || strings.TrimSpace(identity.EnvironmentID) != identity.EnvironmentID || identity.VMID == "" || strings.TrimSpace(identity.VMID) != identity.VMID || identity.Generation == 0 || !filepath.IsAbs(identity.Endpoint) { + return errors.New("microvm identity allocation returned invalid values") + } + return nil +} + +func validateAgreement(agreement control.Agreement) error { + if agreement.Version != control.ProtocolVersion || agreement.MaxMessageBytes == 0 || agreement.MaxMessageBytes > control.DefaultMaxMessageBytes { + return ErrEnvironmentIncompatible + } + seen := make(map[control.Capability]bool, len(agreement.Capabilities)) + for _, capability := range agreement.Capabilities { + seen[capability] = true + } + for _, required := range control.RequiredCapabilities() { + if !seen[required] { + return ErrEnvironmentIncompatible + } + } + return nil +} + +func bootingUsage(resources ResourceUsage) ResourceUsage { + resources.BootingVMs = 1 + resources.Worktrees = 1 + resources.Boots = 1 + return resources +} + +func activeUsage(resources ResourceUsage) ResourceUsage { + resources.ActiveVMs = 1 + resources.Worktrees = 1 + return resources +} + +func requestDigests(requests map[ArtifactKind]ArtifactRequest) map[ArtifactKind]string { + result := make(map[ArtifactKind]string, len(requests)) + for kind, request := range requests { + result[kind] = request.Digest + } + return result +} + +func requestManifestDigests(requests map[ArtifactKind]ArtifactRequest) map[ArtifactKind]string { + result := make(map[ArtifactKind]string) + for kind, request := range requests { + if request.ManifestDigest != "" { + result[kind] = request.ManifestDigest + } + } + return result +} + +func verifiedMatches(want, manifests map[ArtifactKind]string, got VerifiedArtifacts) bool { + for _, artifact := range got.All() { + if want[artifact.Kind] != artifact.Digest || manifests[artifact.Kind] != artifact.ManifestDigest { + return false + } + } + return len(want) == 4 +} + +func cloneArtifacts(in map[ArtifactKind]string) map[ArtifactKind]string { + out := make(map[ArtifactKind]string, len(in)) + for kind, digest := range in { + out[kind] = digest + } + return out +} + +func cloneArtifactRequests(in map[ArtifactKind]ArtifactRequest) map[ArtifactKind]ArtifactRequest { + out := make(map[ArtifactKind]ArtifactRequest, len(in)) + for kind, request := range in { + out[kind] = request + } + return out +} + +func cloneEnvironmentRecord(record EnvironmentRecord) EnvironmentRecord { + record.Artifacts = cloneArtifacts(record.Artifacts) + record.ManifestDigests = cloneArtifacts(record.ManifestDigests) + record.Agreement.Capabilities = append(control.Capabilities(nil), record.Agreement.Capabilities...) + return record +} diff --git a/environment/microvm/lifecycle_inventory_test.go b/environment/microvm/lifecycle_inventory_test.go new file mode 100644 index 000000000..078e4c1b7 --- /dev/null +++ b/environment/microvm/lifecycle_inventory_test.go @@ -0,0 +1,275 @@ +package microvm + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "strings" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/control/controltest" +) + +func TestMicroVMLifecycleUX_InventoryIsOwnerScopedAndReportsGenerationHealth(t *testing.T) { + t.Parallel() + ctx := context.Background() + healthy := readyRecord("healthy", 1) + healthy.Owner, healthy.SessionID, healthy.WorktreePath = "owner-a", "session-healthy", "/worktrees/healthy" + stale := readyRecord("stale", 2) + stale.Owner, stale.SessionID, stale.WorktreePath = "owner-a", "session-stale", "/worktrees/stale" + broken := readyRecord("broken", 3) + broken.Owner, broken.SessionID, broken.WorktreePath = "owner-a", "session-broken", "/worktrees/broken" + foreign := readyRecord("foreign", 4) + foreign.Owner, foreign.WorktreePath = "owner-b", "/worktrees/foreign" + retained := readyRecord("retained", 5) + retained.Owner, retained.SessionID, retained.WorktreePath = "owner-a", "session-retained", "/worktrees/retained" + retained.State, retained.Tombstone, retained.PreserveWorktree = EnvironmentDestroyed, true, true + + registry := newLifecycleRegistry(healthy) + for _, record := range []EnvironmentRecord{stale, broken, foreign, retained} { + if err := registry.Save(ctx, record); err != nil { + t.Fatal(err) + } + } + runtime := inventoryRuntime{statuses: map[string]RuntimeStatus{ + healthy.EnvironmentID: exactRuntimeStatus(healthy), + stale.EnvironmentID: {}, + }, errors: map[string]error{broken.EnvironmentID: errors.New("runtime probe failed")}} + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{Control: auth, Registry: registry, Runtime: runtime, Worktrees: &lifecycleWorktrees{}}) + if err != nil { + t.Fatal(err) + } + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + response := daemon.Handle(ctx, server, LifecycleRequest{ + Version: LifecycleProtocolVersion, Operation: LifecycleInventory, Binding: control.Binding{Owner: "owner-a"}, + }) + if response.Err != nil { + t.Fatal(response.Err) + } + var page LifecycleInventoryPage + if err := json.Unmarshal(response.Payload, &page); err != nil { + t.Fatal(err) + } + entries := page.Entries + if page.Continuation != "" { + t.Fatalf("unexpected continuation for short inventory: %q", page.Continuation) + } + if len(entries) != 4 { + t.Fatalf("owner inventory length = %d, want 4: %+v", len(entries), entries) + } + want := map[string]LifecycleGenerationHealth{"healthy": GenerationHealthy, "stale": GenerationStale, "broken": GenerationError, "retained": GenerationStale} + for _, entry := range entries { + if entry.Owner != "owner-a" || entry.WorktreePath == foreign.WorktreePath { + t.Fatalf("cross-owner inventory leak: %+v", entry) + } + if got := want[entry.EnvironmentID]; got == "" || entry.Health != got || entry.Ref == "" || entry.Generation == 0 || entry.WorktreePath == "" { + t.Fatalf("inventory entry = %+v, want health %q and exact identity", entry, got) + } + if (entry.Health == GenerationHealthy) == (entry.Error != "") { + t.Fatalf("inventory health detail is not actionable: %+v", entry) + } + } + + missingOwner := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleInventory}) + if !errors.Is(missingOwner.Err, control.ErrBindingMismatch) { + t.Fatalf("ownerless inventory error = %v, want binding mismatch", missingOwner.Err) + } +} + +func TestMicroVMLifecycleUX_InventoryPaginationIsCompleteOpaqueAndOwnerBound(t *testing.T) { + t.Parallel() + ctx := context.Background() + registry := newLifecycleRegistry() + for i := 0; i < 300; i++ { + record := readyRecord(fmt.Sprintf("env-%03d", i), uint32(i+1)) + record.Owner = "owner-a" + record.SessionID = fmt.Sprintf("session-%03d", i) + record.WorktreePath = fmt.Sprintf("/worktrees/%03d", i) + if i%17 == 0 { + record.State, record.Tombstone, record.PreserveWorktree = EnvironmentDestroyed, true, true + } + if err := registry.Save(ctx, record); err != nil { + t.Fatal(err) + } + } + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{Control: auth, Registry: registry, Runtime: inventoryRuntime{}, Worktrees: &lifecycleWorktrees{}}) + if err != nil { + t.Fatal(err) + } + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + readPage := func(owner, token string) (LifecycleInventoryPage, error) { + payload, marshalErr := json.Marshal(LifecycleInventoryRequest{PageSize: 37, Continuation: token}) + if marshalErr != nil { + t.Fatal(marshalErr) + } + response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleInventory, Binding: control.Binding{Owner: owner}, Payload: payload}) + if response.Err != nil { + return LifecycleInventoryPage{}, response.Err + } + if len(response.Payload) >= int(control.DefaultMaxMessageBytes) { + t.Fatalf("inventory page bytes = %d, exceed message bound", len(response.Payload)) + } + var page LifecycleInventoryPage + if err := json.Unmarshal(response.Payload, &page); err != nil { + t.Fatal(err) + } + return page, nil + } + + var all []LifecycleInventoryEntry + var token string + for { + page, pageErr := readPage("owner-a", token) + if pageErr != nil { + t.Fatal(pageErr) + } + if len(page.Entries) == 0 || len(page.Entries) > 37 { + t.Fatalf("page length = %d, want 1..37", len(page.Entries)) + } + all = append(all, page.Entries...) + if page.Continuation == "" { + break + } + token = page.Continuation + } + if len(all) != 300 { + t.Fatalf("paginated inventory length = %d, want 300", len(all)) + } + if all[0].SessionID != "session-000" || all[len(all)-1].SessionID != "session-299" { + t.Fatalf("pagination order drifted: first=%q last=%q", all[0].SessionID, all[len(all)-1].SessionID) + } + if all[0].State != EnvironmentDestroyed || all[0].Health != GenerationStale || !strings.Contains(all[0].Error, "dirty worktree retained") { + t.Fatalf("retained destroyed generation missing from page: %+v", all[0]) + } + first, err := readPage("owner-a", "") + if err != nil { + t.Fatal(err) + } + again, err := readPage("owner-a", "") + if err != nil || first.Continuation == "" || first.Continuation != again.Continuation { + t.Fatalf("continuation is not deterministic: first=%q again=%q err=%v", first.Continuation, again.Continuation, err) + } + tamperedBytes := []byte(first.Continuation) + if tamperedBytes[len(tamperedBytes)-1] == 'A' { + tamperedBytes[len(tamperedBytes)-1] = 'B' + } else { + tamperedBytes[len(tamperedBytes)-1] = 'A' + } + if _, err := readPage("owner-a", string(tamperedBytes)); err == nil { + t.Fatal("tampered continuation was accepted") + } + if _, err := readPage("owner-b", first.Continuation); err == nil { + t.Fatal("cross-owner continuation was accepted") + } + lastRecord, err := registry.Lookup(ctx, all[len(all)-1].EnvironmentID) + if err != nil { + t.Fatal(err) + } + deleted := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleDelete, Binding: bindingForRecord(lastRecord)}) + if deleted.Err != nil { + t.Fatalf("delete generation beyond former lifetime cap: %v", deleted.Err) + } + lastRecord, err = registry.Lookup(ctx, lastRecord.EnvironmentID) + if err != nil || lastRecord.State != EnvironmentDestroyed { + t.Fatalf("last generation was not deletable: %+v, %v", lastRecord, err) + } +} + +func TestMicroVMLifecycleUX_DeleteReportsRetainedDirtyWorktree(t *testing.T) { + t.Parallel() + ctx := context.Background() + record := readyRecord("dirty", 8) + record.Owner, record.SessionID, record.WorktreePath = "owner-a", "session-dirty", "/worktrees/dirty" + registry := newLifecycleRegistry(record) + runtime := &lifecycleRuntime{live: map[string]RuntimeStatus{record.EnvironmentID: exactRuntimeStatus(record)}} + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{Control: auth, Registry: registry, Runtime: runtime, Worktrees: &lifecycleWorktrees{dirty: true}}) + if err != nil { + t.Fatal(err) + } + server, client := net.Pipe() + defer server.Close() + defer client.Close() + response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleDelete, Binding: bindingForRecord(record)}) + if response.Err != nil { + t.Fatal(response.Err) + } + var result LifecycleDeleteResult + if err := json.Unmarshal(response.Payload, &result); err != nil { + t.Fatal(err) + } + if !result.WorktreeRetained || result.WorktreePath != record.WorktreePath { + t.Fatalf("delete result = %+v", result) + } + stored, err := registry.Lookup(ctx, record.EnvironmentID) + if err != nil || !stored.PreserveWorktree || !stored.WorktreeDeleted { + t.Fatalf("dirty retention checkpoint = %+v, %v", stored, err) + } +} + +func TestMicroVMLifecycleUX_ReconcileNeverCreatesMissingGeneration(t *testing.T) { + t.Parallel() + record := readyRecord("existing", 1) + record.Owner = "owner-a" + registry := newLifecycleRegistry(record) + creator := &daemonTestCreator{registry: registry, record: readyRecord("replacement", 2)} + reconciler := &recordingReconciler{} + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{ + Control: auth, Creator: creator, Registry: registry, + Runtime: &lifecycleRuntime{live: map[string]RuntimeStatus{record.EnvironmentID: exactRuntimeStatus(record)}}, + Worktrees: &lifecycleWorktrees{}, Reconciler: reconciler, + }) + if err != nil { + t.Fatal(err) + } + server, client := net.Pipe() + defer server.Close() + defer client.Close() + response := daemon.Handle(context.Background(), server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleReconcile, Binding: control.Binding{Owner: "owner-a"}}) + if response.Err != nil || reconciler.calls != 1 || creator.calls != 0 { + t.Fatalf("reconcile response=%+v reconcile=%d create=%d", response, reconciler.calls, creator.calls) + } +} + +type recordingReconciler struct{ calls int } + +func (r *recordingReconciler) Reconcile(context.Context) error { + r.calls++ + return nil +} + +type inventoryRuntime struct { + statuses map[string]RuntimeStatus + errors map[string]error +} + +func (r inventoryRuntime) Inspect(_ context.Context, record EnvironmentRecord) (RuntimeStatus, error) { + return r.statuses[record.EnvironmentID], r.errors[record.EnvironmentID] +} +func (inventoryRuntime) Reattach(context.Context, EnvironmentRecord) error { return nil } +func (inventoryRuntime) Detach(context.Context, EnvironmentRecord) error { return nil } +func (inventoryRuntime) Destroy(context.Context, EnvironmentRecord) error { return nil } diff --git a/environment/microvm/lifecycle_reconcile_test.go b/environment/microvm/lifecycle_reconcile_test.go new file mode 100644 index 000000000..e52e65fc2 --- /dev/null +++ b/environment/microvm/lifecycle_reconcile_test.go @@ -0,0 +1,390 @@ +package microvm + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/control" +) + +func TestMicroVMEnvironments_Scenario5_HarnessRestartReattachesExactGeneration(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + worktree := filepath.Join(dir, "worktree") + if err := os.Mkdir(worktree, 0o700); err != nil { + t.Fatal(err) + } + wantState := []byte("uncommitted state") + if err := os.WriteFile(filepath.Join(worktree, "state.txt"), wantState, 0o600); err != nil { + t.Fatal(err) + } + record := readyRecord("env-restart", 7) + record.WorktreePath = worktree + registry, err := OpenFileRegistry(filepath.Join(dir, "registry.json")) + if err != nil { + t.Fatal(err) + } + if err := registry.Save(ctx, record); err != nil { + t.Fatal(err) + } + + // A new registry and resolver model a restarted harness. Reattachment must not + // invoke provisioning or any independent per-session engine factory. + restarted, err := OpenFileRegistry(filepath.Join(dir, "registry.json")) + if err != nil { + t.Fatal(err) + } + runtime := &lifecycleRuntime{live: map[string]RuntimeStatus{record.EnvironmentID: exactRuntimeStatus(record)}} + resolved, err := NewReattachingResolver(restarted, runtime).Resolve(ctx, record.Ref, record.Owner) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + gotState, err := os.ReadFile(filepath.Join(resolved.WorktreePath, "state.txt")) + if err != nil { + t.Fatal(err) + } + if string(gotState) != string(wantState) || resolved.Ref != record.Ref || runtime.createCalls != 0 { + t.Fatalf("restart changed generation/state or provisioned: ref=%+v state=%q creates=%d", resolved.Ref, gotState, runtime.createCalls) + } +} + +func TestMicroVMEnvironments_Scenario5_DaemonRestartNeverRecreatesEmptyEnvironment(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + record := readyRecord("env-daemon", 11) + record.AdmissionUsage = ResourceUsage{ActiveVMs: 1, Worktrees: 1, CPU: 2} + registry, err := OpenFileRegistry(filepath.Join(dir, "registry.json")) + if err != nil { + t.Fatal(err) + } + if err := registry.Save(ctx, record); err != nil { + t.Fatal(err) + } + + // A fresh daemon/backend cannot reopen this exact generation. Startup must + // identity-check and destroy it rather than minting an empty replacement or + // retaining quota forever. + restarted, err := OpenFileRegistry(filepath.Join(dir, "registry.json")) + if err != nil { + t.Fatal(err) + } + runtime := &lifecycleRuntime{live: make(map[string]RuntimeStatus), inspectErr: ErrEnvironmentUnavailable} + admission, err := NewAdmissionController(AdmissionLimits{Deployment: ResourceUsage{ActiveVMs: 1, Worktrees: 1, CPU: 2}}, nil) + if err != nil { + t.Fatal(err) + } + if err := admission.Reconstruct([]EnvironmentRecord{record}); err != nil { + t.Fatal(err) + } + if err := NewReconcilerWithAdmission(restarted, runtime, &lifecycleWorktrees{}, admission).Reconcile(ctx); err != nil { + t.Fatalf("startup Reconcile() error = %v", err) + } + got, err := restarted.Lookup(ctx, record.EnvironmentID) + if err != nil { + t.Fatal(err) + } + if got.State != EnvironmentDestroyed || !got.Tombstone || runtime.createCalls != 0 { + t.Fatalf("restart left unmanaged or replaced generation: record=%+v creates=%d", got, runtime.createCalls) + } + lease, err := admission.Acquire(record.Owner, record.AdmissionUsage) + if err != nil { + t.Fatalf("destroyed generation retained quota: %v", err) + } + lease.Release() + if _, err := NewReattachingResolver(restarted, runtime).Resolve(ctx, record.Ref, record.Owner); !errors.Is(err, ErrEnvironmentDestroyed) { + t.Fatalf("Resolve() error = %v, want destroyed", err) + } +} + +func TestMicroVMEnvironments_Scenario5_DetachAndDeleteAreDistinct(t *testing.T) { + t.Parallel() + ctx := context.Background() + for _, dirty := range []bool{false, true} { + t.Run(map[bool]string{false: "clean", true: "dirty"}[dirty], func(t *testing.T) { + record := readyRecord("env-delete", 3) + record.AdmissionUsage = ResourceUsage{ActiveVMs: 1, CPU: 2, RAMBytes: 4 << 30, DiskBytes: 20 << 30} + registry := newLifecycleRegistry(record) + runtime := &lifecycleRuntime{live: map[string]RuntimeStatus{record.EnvironmentID: exactRuntimeStatus(record)}} + worktrees := &lifecycleWorktrees{dirty: dirty} + observer := NewOperationsObserver(nil) + observer.ObserveRecord(record) + observed := observedRegistry{ReconcileRegistry: registry, observer: observer} + manager := NewEnvironmentManagerWithAdmission(observed, runtime, worktrees, nil, observer) + + if err := manager.Detach(ctx, record.Ref, record.Owner); err != nil { + t.Fatalf("Detach() error = %v", err) + } + got, _ := registry.Lookup(ctx, record.EnvironmentID) + if got.State != EnvironmentReady || runtime.destroyCalls != 0 || runtime.detachCalls != 1 { + t.Fatalf("detach altered durable resources: record=%+v runtime=%+v", got, runtime) + } + if snapshot := observer.Snapshot(); snapshot.ActiveVMs != 1 || snapshot.Cleanups[OutcomeSuccess] != 0 { + t.Fatalf("detach altered active or cleanup metrics: %+v", snapshot) + } + + if err := manager.Delete(ctx, record.Ref, record.Owner, DeleteExplicit); err != nil { + t.Fatalf("Delete() error = %v", err) + } + got, _ = registry.Lookup(ctx, record.EnvironmentID) + if got.State != EnvironmentDestroyed || !got.Tombstone || runtime.destroyCalls != 1 { + t.Fatalf("delete did not durably destroy generation: record=%+v destroys=%d", got, runtime.destroyCalls) + } + if snapshot := observer.Snapshot(); snapshot.ActiveVMs != 0 || snapshot.ResourceLimits != (ResourceLimits{}) || snapshot.Cleanups[OutcomeSuccess] != 1 { + t.Fatalf("delete metrics were not committed exactly once: %+v", snapshot) + } + if worktrees.cleaned == dirty { + t.Fatalf("dirty-state policy mismatch: dirty=%v cleaned=%v", dirty, worktrees.cleaned) + } + if _, err := NewReattachingResolver(registry, runtime).Resolve(ctx, record.Ref, record.Owner); !errors.Is(err, ErrEnvironmentDestroyed) { + t.Fatalf("deleted generation Resolve() error = %v", err) + } + }) + } +} + +func TestMicroVMEnvironments_Scenario5_ReconcilerConvergesAcrossCrashPoints(t *testing.T) { + t.Parallel() + ctx := context.Background() + cases := []struct { + name string + state EnvironmentState + vmDeleted bool + worktreeDone bool + wantDestroy int + }{ + {name: "provisioning", state: EnvironmentProvisioning, wantDestroy: 1}, + {name: "cleanup-pending", state: EnvironmentCleanupPending, wantDestroy: 1}, + {name: "deleting", state: EnvironmentDeleting, wantDestroy: 1}, + {name: "after-vm-delete", state: EnvironmentDeleting, vmDeleted: true}, + {name: "after-worktree-delete", state: EnvironmentDeleting, worktreeDone: true, wantDestroy: 1}, + {name: "destroyed-tombstone", state: EnvironmentDestroyed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + record := readyRecord("env-"+tc.name, 5) + record.State = tc.state + record.VMDeleted = tc.vmDeleted + record.WorktreeDeleted = tc.worktreeDone + record.Tombstone = tc.state != EnvironmentProvisioning && tc.state != EnvironmentReady + registry := newLifecycleRegistry(record) + live := make(map[string]RuntimeStatus) + if !tc.vmDeleted && tc.state != EnvironmentDestroyed { + live[record.EnvironmentID] = exactRuntimeStatus(record) + } + runtime := &lifecycleRuntime{live: live} + worktrees := &lifecycleWorktrees{} + reconciler := NewReconciler(registry, runtime, worktrees) + var wg sync.WaitGroup + for range 2 { + wg.Add(1) + go func() { defer wg.Done(); _ = reconciler.Reconcile(ctx) }() + } + wg.Wait() + got, _ := registry.Lookup(ctx, record.EnvironmentID) + if got.State != EnvironmentDestroyed || runtime.destroyCalls != tc.wantDestroy { + t.Fatalf("reconcile did not converge: record=%+v destroys=%d, want %d", got, runtime.destroyCalls, tc.wantDestroy) + } + }) + } + + t.Run("two daemons cannot replace or resurrect a generation", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "registry.json") + first, err := OpenFileRegistry(path) + if err != nil { + t.Fatal(err) + } + second, err := OpenFileRegistry(path) + if err != nil { + t.Fatal(err) + } + record := readyRecord("env-race", 8) + if err := first.Save(ctx, record); err != nil { + t.Fatal(err) + } + tombstone := record + tombstone.State = EnvironmentDeleting + tombstone.Tombstone = true + tombstone.DeleteReason = DeleteExplicit + if err := first.Save(ctx, tombstone); err != nil { + t.Fatal(err) + } + if err := second.Save(ctx, record); !errors.Is(err, ErrEnvironmentStale) { + t.Fatalf("stale daemon Save() error = %v, want generation/lifecycle fence", err) + } + other := readyRecord("env-race", 9) + if err := second.Save(ctx, other); !errors.Is(err, ErrEnvironmentStale) { + t.Fatalf("replacement generation Save() error = %v, want stale", err) + } + got, err := first.Lookup(ctx, record.EnvironmentID) + if err != nil { + t.Fatal(err) + } + if !got.Tombstone || got.Generation != record.Generation { + t.Fatalf("race replaced/resurrected durable generation: %+v", got) + } + }) + + t.Run("pid reuse and stale socket never delete another generation", func(t *testing.T) { + record := readyRecord("env-live", 9) + registry := newLifecycleRegistry(record) + runtime := &lifecycleRuntime{live: map[string]RuntimeStatus{record.EnvironmentID: { + Live: true, Generation: record.Generation + 1, VMID: "another-vm", PID: 42, + ProcessIdentity: "reused-pid", Endpoint: record.Endpoint, + }}} + if err := NewReconciler(registry, runtime, &lifecycleWorktrees{}).Reconcile(ctx); err == nil { + t.Fatal("Reconcile() error = nil, want generation-fence failure") + } + got, _ := registry.Lookup(ctx, record.EnvironmentID) + if got.State != EnvironmentCleanupPending || runtime.destroyCalls != 0 { + t.Fatalf("foreign generation was deleted: record=%+v destroys=%d", got, runtime.destroyCalls) + } + }) + + t.Run("disk full leaves durable cleanup work", func(t *testing.T) { + record := readyRecord("env-disk", 4) + record.State = EnvironmentDeleting + registry := newLifecycleRegistry(record) + registry.failSave = errors.New("disk full") + runtime := &lifecycleRuntime{live: map[string]RuntimeStatus{record.EnvironmentID: exactRuntimeStatus(record)}} + reconciler := NewReconciler(registry, runtime, &lifecycleWorktrees{}) + if err := reconciler.Reconcile(ctx); err == nil { + t.Fatal("Reconcile() error = nil, want durable-save failure") + } + registry.failSave = nil + if err := reconciler.Reconcile(ctx); err != nil { + t.Fatalf("retry Reconcile() error = %v", err) + } + got, _ := registry.Lookup(ctx, record.EnvironmentID) + if got.State != EnvironmentDestroyed { + t.Fatalf("retry did not converge: %+v", got) + } + }) +} + +func readyRecord(id string, generation uint32) EnvironmentRecord { + ref := EnvironmentRef{Kind: Kind, ID: id + "@" + strconv.FormatUint(uint64(generation), 10)} + return EnvironmentRecord{ + State: EnvironmentReady, Owner: "caller:alice", SessionID: "session-1", EnvironmentID: id, + Ref: ref, Generation: generation, WorktreePath: "/state/" + id, MetadataPath: "/state/meta-" + id, + VMID: "vm-" + id, Endpoint: "/run/mecatl/" + id + ".sock", RunnerPID: 42, ProcessIdentity: "boot-1:42", + Agreement: control.Agreement{Version: control.ProtocolVersion, Capabilities: control.RequiredCapabilities(), MaxMessageBytes: control.DefaultMaxMessageBytes}, + } +} + +type lifecycleRegistry struct { + mu sync.Mutex + records map[string]EnvironmentRecord + failSave error +} + +func newLifecycleRegistry(records ...EnvironmentRecord) *lifecycleRegistry { + r := &lifecycleRegistry{records: make(map[string]EnvironmentRecord)} + for _, record := range records { + r.records[record.EnvironmentID] = cloneEnvironmentRecord(record) + } + return r +} +func (r *lifecycleRegistry) Save(_ context.Context, record EnvironmentRecord) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.failSave != nil { + return r.failSave + } + r.records[record.EnvironmentID] = cloneEnvironmentRecord(record) + return nil +} +func (r *lifecycleRegistry) Lookup(_ context.Context, id string) (EnvironmentRecord, error) { + r.mu.Lock() + defer r.mu.Unlock() + record, ok := r.records[id] + if !ok { + return EnvironmentRecord{}, ErrEnvironmentUnknown + } + return cloneEnvironmentRecord(record), nil +} +func (r *lifecycleRegistry) List(context.Context) ([]EnvironmentRecord, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]EnvironmentRecord, 0, len(r.records)) + for _, record := range r.records { + out = append(out, cloneEnvironmentRecord(record)) + } + return out, nil +} + +type lifecycleRuntime struct { + mu sync.Mutex + live map[string]RuntimeStatus + createCalls int + detachCalls int + destroyCalls int + inspectErr error +} + +func (r *lifecycleRuntime) Reattach(_ context.Context, record EnvironmentRecord) error { + status, err := r.Inspect(context.Background(), record) + if err != nil { + return err + } + return validateRuntimeIdentity(record, status) +} +func (r *lifecycleRuntime) Inspect(_ context.Context, record EnvironmentRecord) (RuntimeStatus, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.inspectErr != nil { + return RuntimeStatus{}, r.inspectErr + } + status, ok := r.live[record.EnvironmentID] + if !ok { + return RuntimeStatus{}, nil + } + return status, nil +} +func (r *lifecycleRuntime) Detach(context.Context, EnvironmentRecord) error { + r.mu.Lock() + defer r.mu.Unlock() + r.detachCalls++ + return nil +} +func (r *lifecycleRuntime) Destroy(_ context.Context, record EnvironmentRecord) error { + r.mu.Lock() + defer r.mu.Unlock() + status, ok := r.live[record.EnvironmentID] + if !ok { + return nil + } + if err := validateRuntimeIdentity(record, status); err != nil { + return err + } + delete(r.live, record.EnvironmentID) + r.destroyCalls++ + return nil +} +func exactRuntimeStatus(record EnvironmentRecord) RuntimeStatus { + return RuntimeStatus{Live: true, Generation: record.Generation, VMID: record.VMID, PID: 42, ProcessIdentity: record.ProcessIdentity, Endpoint: record.Endpoint} +} + +type lifecycleWorktrees struct { + mu sync.Mutex + dirty, cleaned bool +} + +func (w *lifecycleWorktrees) Dirty(context.Context, EnvironmentRecord) (bool, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.dirty, nil +} +func (w *lifecycleWorktrees) Cleanup(context.Context, EnvironmentRecord) error { + w.mu.Lock() + defer w.mu.Unlock() + w.cleaned = true + return nil +} diff --git a/environment/microvm/lifecycle_test.go b/environment/microvm/lifecycle_test.go new file mode 100644 index 000000000..70089695f --- /dev/null +++ b/environment/microvm/lifecycle_test.go @@ -0,0 +1,223 @@ +package microvm + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +func TestMicroVMEnvironments_Scenario5_CreateTransactionIsAllOrReconciled(t *testing.T) { + t.Parallel() + + stages := []string{"success", "prepare", "verify", "create-vm", "ready", "negotiate", "persist"} + for _, stage := range stages { + t.Run(stage, func(t *testing.T) { + t.Parallel() + fx := newLifecycleFixture(stage) + lifecycle := NewLifecycle(LifecycleDeps{ + Identities: fx.identities, + Worktrees: fx.worktrees, + Artifacts: fx.artifacts, + VMs: fx.vms, + Protocol: fx.protocol, + Registry: fx.registry, + Sessions: fx.sessions, + }) + + created, err := lifecycle.Create(context.Background(), fx.request) + if stage != "success" { + if err == nil { + t.Fatal("Create() error = nil, want injected failure") + } + if created != (CreatedEnvironment{}) { + t.Fatalf("Create() result = %+v, want zero on failure", created) + } + last := fx.registry.last(t) + if last.State != EnvironmentDestroyed && last.State != EnvironmentCleanupPending { + t.Fatalf("registry state = %q, want destroyed or cleanup-pending", last.State) + } + if last.EnvironmentID != "env-1" || last.VMID != "vm-1" || last.Endpoint != "/run/mecatl/env-1.sock" || last.WorktreePath != "/state/worktrees/session-1" { + t.Fatalf("reconciliation record lost provisional identities: %+v", last) + } + return + } + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + persisted := fx.sessions.saved + record := fx.registry.last(t) + if record.State != EnvironmentReady { + t.Fatalf("registry state = %q, want ready", record.State) + } + if created.Ref != record.Ref || persisted.Ref != record.Ref || created.Generation != record.Generation || persisted.Generation != record.Generation { + t.Fatalf("ref/generation disagree: created=%+v persisted=%+v registry=%+v", created, persisted, record) + } + if record.Owner != fx.request.Owner || record.Profile != fx.request.Profile || persisted.Owner != record.Owner || persisted.Profile != record.Profile { + t.Fatalf("owner/profile disagree: persisted=%+v registry=%+v", persisted, record) + } + if persisted.WorktreePath != record.WorktreePath || created.HostWorktree != record.WorktreePath || created.GuestRoot != worktree.GuestWorkspace { + t.Fatalf("path roles disagree: created=%+v persisted=%+v registry=%+v", created, persisted, record) + } + if fx.vms.created.VMID != record.VMID || fx.vms.created.Endpoint != record.Endpoint || fx.protocol.endpoint != record.Endpoint { + t.Fatalf("VM/endpoint disagree: vm=%+v protocol=%q registry=%+v", fx.vms.created, fx.protocol.endpoint, record) + } + if record.ProcessIdentity == "" { + t.Fatal("ready record omitted process-start identity needed for PID-reuse fencing") + } + if !reflect.DeepEqual(persisted.Artifacts, record.Artifacts) || !reflect.DeepEqual(fx.vms.created.Artifacts, record.Artifacts) { + t.Fatalf("artifact identities disagree: persisted=%v vm=%v registry=%v", persisted.Artifacts, fx.vms.created.Artifacts, record.Artifacts) + } + if fx.protocol.binding.Owner != record.Owner || fx.protocol.binding.Generation != record.Generation || fx.protocol.binding.Ref != record.Ref.ID { + t.Fatalf("guest binding = %+v, registry = %+v", fx.protocol.binding, record) + } + }) + } + + t.Run("cleanup failure is durably queued", func(t *testing.T) { + fx := newLifecycleFixture("persist") + fx.vms.destroyErr = errors.New("destroy unavailable") + lifecycle := NewLifecycle(LifecycleDeps{Identities: fx.identities, Worktrees: fx.worktrees, Artifacts: fx.artifacts, VMs: fx.vms, Protocol: fx.protocol, Registry: fx.registry, Sessions: fx.sessions}) + if _, err := lifecycle.Create(context.Background(), fx.request); err == nil { + t.Fatal("Create() error = nil, want persistence failure") + } + if got := fx.registry.last(t).State; got != EnvironmentCleanupPending { + t.Fatalf("registry state = %q, want cleanup-pending", got) + } + }) +} + +type lifecycleFixture struct { + request CreateRequest + identities *fakeIdentities + worktrees *fakeWorktrees + artifacts *fakeArtifactVerifier + vms *fakeVMRuntime + protocol *fakeProtocol + registry *fakeRegistry + sessions *fakeSessionPersister +} + +func newLifecycleFixture(fail string) *lifecycleFixture { + failure := func(stage string) error { + if fail == stage { + return errors.New("injected " + stage + " failure") + } + return nil + } + requests := map[ArtifactKind]ArtifactRequest{ + ArtifactRuntime: {Kind: ArtifactRuntime, Reference: "runtime@sha256:runtime", Digest: "sha256:runtime"}, + ArtifactFirmware: {Kind: ArtifactFirmware, Reference: "firmware@sha256:firmware", Digest: "sha256:firmware"}, + ArtifactExecutionImage: {Kind: ArtifactExecutionImage, Reference: "image@sha256:image", Digest: "sha256:image"}, + ArtifactGuestAgent: {Kind: ArtifactGuestAgent, Reference: "guest-agent@sha256:guest-agent", Digest: "sha256:guest-agent"}, + } + verified := VerifiedArtifacts{ + Runtime: VerifiedArtifact{Kind: ArtifactRuntime, Digest: "sha256:runtime"}, + Firmware: VerifiedArtifact{Kind: ArtifactFirmware, Digest: "sha256:firmware"}, + ExecutionImage: VerifiedArtifact{Kind: ArtifactExecutionImage, Digest: "sha256:image"}, + GuestAgent: VerifiedArtifact{Kind: ArtifactGuestAgent, Digest: "sha256:guest-agent"}, + } + return &lifecycleFixture{ + request: CreateRequest{ + Owner: "caller:alice", SessionID: "session-1", Profile: "secure", + Worktree: worktree.Request{Source: "/source", WorktreePath: "/state/worktrees/session-1", MetadataPath: "/state/metadata/session-1", Branch: "mecatl/session-1"}, + ArtifactRequests: requests, + }, + identities: &fakeIdentities{identity: EnvironmentIdentity{EnvironmentID: "env-1", VMID: "vm-1", Endpoint: "/run/mecatl/env-1.sock", Generation: 7}}, + worktrees: &fakeWorktrees{err: failure("prepare")}, + artifacts: &fakeArtifactVerifier{verified: verified, policyRevision: "policy-7", err: failure("verify")}, + vms: &fakeVMRuntime{createErr: failure("create-vm"), readyErr: failure("ready")}, + protocol: &fakeProtocol{err: failure("negotiate")}, + registry: &fakeRegistry{}, + sessions: &fakeSessionPersister{err: failure("persist")}, + } +} + +type fakeIdentities struct{ identity EnvironmentIdentity } + +func (f *fakeIdentities) Allocate(string) (EnvironmentIdentity, error) { return f.identity, nil } + +type fakeWorktrees struct { + err error + cleaned bool +} + +func (f *fakeWorktrees) Prepare(_ context.Context, request worktree.Request) (*worktree.Prepared, error) { + if f.err != nil { + return nil, f.err + } + return &worktree.Prepared{SourceRoot: request.Source, WorktreePath: request.WorktreePath, MetadataPath: request.MetadataPath, Branch: request.Branch, CommonObjectStore: "/source/.git/objects"}, nil +} +func (f *fakeWorktrees) Cleanup(context.Context, *worktree.Prepared) error { + f.cleaned = true + return nil +} + +type fakeArtifactVerifier struct { + verified VerifiedArtifacts + policyRevision string + err error +} + +func (f *fakeArtifactVerifier) Verify(context.Context, map[ArtifactKind]ArtifactRequest) (VerifiedArtifacts, string, error) { + return f.verified, f.policyRevision, f.err +} + +type fakeVMRuntime struct { + created VMCreateRequest + createErr error + readyErr error + destroyErr error +} + +func (f *fakeVMRuntime) Create(_ context.Context, request VMCreateRequest) error { + f.created = request + return f.createErr +} +func (f *fakeVMRuntime) WaitReady(context.Context, EnvironmentRecord) error { return f.readyErr } +func (*fakeVMRuntime) Inspect(_ context.Context, record EnvironmentRecord) (RuntimeStatus, error) { + return RuntimeStatus{Live: true, Generation: record.Generation, VMID: record.VMID, PID: 42, ProcessIdentity: "boot-1:42", Endpoint: record.Endpoint}, nil +} +func (f *fakeVMRuntime) Destroy(context.Context, EnvironmentRecord) error { return f.destroyErr } + +type fakeProtocol struct { + endpoint string + binding control.Binding + err error +} + +func (f *fakeProtocol) Negotiate(_ context.Context, endpoint string, binding control.Binding) (control.Agreement, error) { + f.endpoint, f.binding = endpoint, binding + if f.err != nil { + return control.Agreement{}, f.err + } + return control.Agreement{Version: control.ProtocolVersion, Capabilities: control.RequiredCapabilities(), MaxMessageBytes: control.DefaultMaxMessageBytes}, nil +} + +type fakeRegistry struct{ records []EnvironmentRecord } + +func (f *fakeRegistry) Save(_ context.Context, record EnvironmentRecord) error { + f.records = append(f.records, cloneEnvironmentRecord(record)) + return nil +} +func (f *fakeRegistry) last(t *testing.T) EnvironmentRecord { + t.Helper() + if len(f.records) == 0 { + t.Fatal("registry has no durable record") + } + return f.records[len(f.records)-1] +} + +type fakeSessionPersister struct { + saved SessionPlacement + err error +} + +func (f *fakeSessionPersister) Persist(_ context.Context, placement SessionPlacement) error { + f.saved = placement + return f.err +} diff --git a/environment/microvm/microvm.go b/environment/microvm/microvm.go new file mode 100644 index 000000000..c7a7ba2b5 --- /dev/null +++ b/environment/microvm/microvm.go @@ -0,0 +1,2 @@ +// Package microvm reserves the opt-in module boundary for the future microVM environment runtime. +package microvm diff --git a/environment/microvm/multi_session_test.go b/environment/microvm/multi_session_test.go new file mode 100644 index 000000000..057a1df4e --- /dev/null +++ b/environment/microvm/multi_session_test.go @@ -0,0 +1,263 @@ +package microvm + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +func TestMicroVMEnvironments_Scenario6_SameRepoSessionsUseDistinctWorktreesAndVMs(t *testing.T) { + t.Parallel() + root := t.TempDir() + source := initScenario6Repository(t, root) + for _, dir := range []string{filepath.Join(root, "state", "worktrees"), filepath.Join(root, "state", "metadata")} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + allocator, err := NewOpaqueIdentityAllocator(filepath.Join(root, "state"), filepath.Join(root, "run"), bytes.NewReader(bytes.Repeat([]byte{0x5a}, 64))) + if err != nil { + t.Fatalf("NewOpaqueIdentityAllocator: %v", err) + } + + type result struct { + names ResourceNames + prep *worktree.Prepared + err error + } + results := make(chan result, 2) + for _, sessionID := range []string{"session-prefix-A", "session-prefix-B"} { + go func() { + names, allocErr := allocator.AllocateResources(sessionID, "same/repository") + if allocErr != nil { + results <- result{err: allocErr} + return + } + prepared, prepErr := worktree.New().Prepare(context.Background(), worktree.Request{Source: source, WorktreePath: names.WorktreePath, MetadataPath: names.MetadataPath, Branch: names.Branch}) + results <- result{names: names, prep: prepared, err: prepErr} + }() + } + first, second := <-results, <-results + for _, got := range []result{first, second} { + if got.err != nil { + t.Fatalf("concurrent create: %v", got.err) + } + t.Cleanup(func() { _ = got.prep.Cleanup(context.Background()) }) + } + if first.names.WorktreePath == second.names.WorktreePath || first.names.MetadataPath == second.names.MetadataPath || first.names.Branch == second.names.Branch || first.names.Identity.EnvironmentID == second.names.Identity.EnvironmentID || first.names.Identity.VMID == second.names.Identity.VMID || first.names.Identity.Endpoint == second.names.Identity.Endpoint || first.names.Identity.Generation == second.names.Identity.Generation { + t.Fatalf("session resources collided:\nfirst=%+v\nsecond=%+v", first.names, second.names) + } + for i, got := range []result{first, second} { + if err := os.WriteFile(filepath.Join(got.prep.WorktreePath, fmt.Sprintf("session-%d.txt", i)), []byte(got.names.Identity.EnvironmentID), 0o600); err != nil { + t.Fatalf("write session worktree: %v", err) + } + gitDir := strings.TrimSpace(runGit(t, got.prep.WorktreePath, "rev-parse", "--git-dir")) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(got.prep.WorktreePath, gitDir) + } + if samePath(gitDir, runGit(t, []result{first, second}[1-i].prep.WorktreePath, "rev-parse", "--git-dir")) { + t.Fatalf("session %d shares Git index directory", i) + } + } +} + +func TestMicroVMEnvironments_Scenario6_SiblingSessionsCannotMutateEachOther(t *testing.T) { + t.Parallel() + root := t.TempDir() + source := initScenario6Repository(t, root) + for _, dir := range []string{filepath.Join(root, "worktrees"), filepath.Join(root, "metadata")} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + prepared := make([]*worktree.Prepared, 2) + for i := range prepared { + var err error + prepared[i], err = worktree.New().Prepare(context.Background(), worktree.Request{ + Source: source, WorktreePath: filepath.Join(root, "worktrees", fmt.Sprintf("s%d", i)), MetadataPath: filepath.Join(root, "metadata", fmt.Sprintf("s%d", i)), Branch: fmt.Sprintf("mecatl/s%d", i), + }) + if err != nil { + t.Fatalf("Prepare(%d): %v", i, err) + } + t.Cleanup(func() { _ = prepared[i].Cleanup(context.Background()) }) + // Simulate the guest mount path while running Git on the host: in the VM + // /run/mecatl/git-objects resolves to this same host-enforced read-only mount. + if err := os.WriteFile(filepath.Join(prepared[i].MetadataPath, "objects", "info", "alternates"), []byte(prepared[i].CommonObjectStore+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + + a, b := prepared[0], prepared[1] + if a.CommonObjectStore != b.CommonObjectStore { + t.Fatalf("object stores differ: %q != %q", a.CommonObjectStore, b.CommonObjectStore) + } + for _, p := range prepared { + mount := p.Mounts[2] + if mount.HostPath != p.CommonObjectStore || mount.GuestPath != worktree.GuestObjectStore || !mount.ReadOnly { + t.Fatalf("object-store mount is not shared host-enforced read-only: %+v", mount) + } + if got := strings.TrimSpace(runGuestGit(t, p, "cat-file", "-p", "HEAD:tracked.txt")); got != "base" { + t.Fatalf("shared object read = %q, want base", got) + } + } + + if err := os.WriteFile(filepath.Join(a.WorktreePath, "tracked.txt"), []byte("only-a\n"), 0o600); err != nil { + t.Fatal(err) + } + runGuestGit(t, a, "add", "tracked.txt") + runGuestGit(t, a, "config", "user.session", "a") + runGuestGit(t, a, "config", "user.name", "Session A") + runGuestGit(t, a, "config", "user.email", "session-a@example.invalid") + runGuestGit(t, a, "commit", "-m", "session A") + if err := os.WriteFile(filepath.Join(a.MetadataPath, "guest-only"), []byte("a"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(a.MetadataPath, "hooks"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(a.MetadataPath, "hooks", "pre-commit"), []byte("exit 1\n"), 0o700); err != nil { + t.Fatal(err) + } + + if got := strings.TrimSpace(string(mustRead(t, filepath.Join(b.WorktreePath, "tracked.txt")))); got != "base" { + t.Fatalf("sibling working file changed to %q", got) + } + if got := runGuestGit(t, b, "diff", "--cached", "--name-only"); got != "" { + t.Fatalf("sibling index changed: %q", got) + } + if got := runGuestGit(t, b, "config", "--get", "user.session"); got != "" { + t.Fatalf("sibling config changed: %q", got) + } + for _, path := range []string{filepath.Join(b.MetadataPath, "guest-only"), filepath.Join(b.MetadataPath, "hooks", "pre-commit")} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("sibling metadata %q was altered: %v", path, err) + } + } + if runGuestGit(t, b, "rev-parse", "HEAD") != runGit(t, source, "rev-parse", "HEAD") { + t.Fatal("session A commit altered session B's branch ref") + } + if runGuestGit(t, b, "rev-parse", "HEAD") == runGuestGit(t, a, "rev-parse", "HEAD") { + t.Fatal("session A ref mutation was visible in session B") + } +} + +func TestMicroVMEnvironments_Scenario6_ConcurrentResourceNamesAreOpaqueAndConfined(t *testing.T) { + t.Parallel() + root := t.TempDir() + stateRoot, runtimeRoot := filepath.Join(root, "state"), filepath.Join(root, "run") + allocator, err := NewOpaqueIdentityAllocator(stateRoot, runtimeRoot, nil) + if err != nil { + t.Fatalf("NewOpaqueIdentityAllocator: %v", err) + } + inputs := []string{"repo", "repo-1", "../repo", "repo/../../escape", "répo\nname", strings.Repeat("x", 400)} + var wg sync.WaitGroup + got := make(chan ResourceNames, len(inputs)) + for _, input := range inputs { + wg.Add(1) + go func() { + defer wg.Done() + names, allocErr := allocator.AllocateResources("session/../"+input, input) + if allocErr != nil { + t.Errorf("AllocateResources(%q): %v", input, allocErr) + return + } + got <- names + }() + } + wg.Wait() + close(got) + + seen := map[string]bool{} + for names := range got { + for _, value := range []string{names.Identity.EnvironmentID, names.Identity.VMID, filepath.Base(names.Identity.Endpoint), filepath.Base(names.WorktreePath), filepath.Base(names.MetadataPath), strings.TrimPrefix(names.Branch, "mecatl/")} { + if seen[value] { + t.Fatalf("opaque resource name collided: %q", value) + } + seen[value] = true + if strings.ContainsAny(value, "/\\\n\r") || strings.Contains(value, "..") { + t.Fatalf("resource name is not opaque: %q", value) + } + } + for path, parent := range map[string]string{names.WorktreePath: filepath.Join(stateRoot, "worktrees"), names.MetadataPath: filepath.Join(stateRoot, "metadata"), names.Identity.Endpoint: runtimeRoot} { + if !pathWithin(parent, path) { + t.Fatalf("resource path escaped %q: %q", parent, path) + } + } + } + if len(seen) != len(inputs)*6 { + t.Fatalf("allocated %d distinct names, want %d", len(seen), len(inputs)*6) + } +} + +func initScenario6Repository(t *testing.T, root string) string { + t.Helper() + source := filepath.Join(root, "source") + if err := os.MkdirAll(source, 0o700); err != nil { + t.Fatal(err) + } + runGit(t, source, "init") + runGit(t, source, "config", "user.name", "Test") + runGit(t, source, "config", "user.email", "test@example.invalid") + if err := os.WriteFile(filepath.Join(source, "tracked.txt"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, source, "add", "tracked.txt") + runGit(t, source, "commit", "-m", "base") + return source +} + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0") + out, err := cmd.CombinedOutput() + if err != nil { + // config --get uses status 1 for an absent value. + if len(args) >= 2 && args[0] == "config" && args[1] == "--get" && len(out) == 0 { + return "" + } + t.Fatalf("git %v: %v: %s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func runGuestGit(t *testing.T, prepared *worktree.Prepared, args ...string) string { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", args...) + cmd.Dir = prepared.WorktreePath + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0", "GIT_DIR="+prepared.MetadataPath, "GIT_WORK_TREE="+prepared.WorktreePath, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+prepared.CommonObjectStore) + out, err := cmd.CombinedOutput() + if err != nil { + if len(args) >= 2 && args[0] == "config" && args[1] == "--get" && len(out) == 0 { + return "" + } + t.Fatalf("guest git %v: %v: %s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func mustRead(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return data +} +func pathWithin(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} +func samePath(a, b string) bool { + aa, _ := filepath.Abs(strings.TrimSpace(a)) + bb, _ := filepath.Abs(strings.TrimSpace(b)) + return aa == bb +} diff --git a/environment/microvm/names.go b/environment/microvm/names.go new file mode 100644 index 000000000..9f5bea15c --- /dev/null +++ b/environment/microvm/names.go @@ -0,0 +1,92 @@ +package microvm + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + "sync" +) + +// ResourceNames is one collision-free, confined set of session resource names. +type ResourceNames struct { + Identity EnvironmentIdentity + WorktreePath string + MetadataPath string + Branch string +} + +// OpaqueIdentityAllocator prevents caller-controlled session and repository names +// from becoming filesystem, socket, VM, or Git-ref names. +type OpaqueIdentityAllocator struct { + mu sync.Mutex + stateRoot string + runtimeRoot string + random io.Reader + serial uint32 +} + +// NewOpaqueIdentityAllocator constructs an allocator rooted in private daemon dirs. +func NewOpaqueIdentityAllocator(stateRoot, runtimeRoot string, random io.Reader) (*OpaqueIdentityAllocator, error) { + if !absoluteClean(stateRoot) || !absoluteClean(runtimeRoot) { + return nil, errors.New("microvm resource roots must be absolute and clean") + } + if random == nil { + random = rand.Reader + } + return &OpaqueIdentityAllocator{stateRoot: stateRoot, runtimeRoot: runtimeRoot, random: random}, nil +} + +// Allocate implements IdentityAllocator without exposing caller-controlled names. +func (a *OpaqueIdentityAllocator) Allocate(sessionID string) (EnvironmentIdentity, error) { + names, err := a.AllocateResources(sessionID, "") + return names.Identity, err +} + +// AllocateResources returns an independently-named VM, endpoint, worktree, +// metadata directory, branch, environment ref generation, and cleanup key. +func (a *OpaqueIdentityAllocator) AllocateResources(sessionID, repositoryName string) (ResourceNames, error) { + if a == nil || a.random == nil || sessionID == "" { + return ResourceNames{}, errors.New("microvm opaque identity allocation is not configured") + } + a.mu.Lock() + defer a.mu.Unlock() + if a.serial == ^uint32(0) { + return ResourceNames{}, errors.New("microvm environment generation space exhausted") + } + a.serial++ + generation := a.serial + entropy := make([]byte, 32) + if _, err := io.ReadFull(a.random, entropy); err != nil { + return ResourceNames{}, fmt.Errorf("read microvm identity entropy: %w", err) + } + hash := sha256.New() + _, _ = hash.Write(entropy) + var serial [4]byte + binary.BigEndian.PutUint32(serial[:], generation) + _, _ = hash.Write(serial[:]) + _, _ = hash.Write([]byte(sessionID)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(repositoryName)) + token := hex.EncodeToString(hash.Sum(nil)[:16]) + + identity := EnvironmentIdentity{ + EnvironmentID: "env-" + token, + VMID: "vm-" + token, + Endpoint: filepath.Join(a.runtimeRoot, "endpoint-"+token+".sock"), + Generation: generation, + } + return ResourceNames{ + Identity: identity, WorktreePath: filepath.Join(a.stateRoot, "worktrees", "wt-"+token), + MetadataPath: filepath.Join(a.stateRoot, "metadata", "meta-"+token), Branch: "mecatl/branch-" + token, + }, nil +} + +func absoluteClean(path string) bool { + return filepath.IsAbs(path) && filepath.Clean(path) == path && !strings.ContainsRune(path, '\x00') +} diff --git a/environment/microvm/network.go b/environment/microvm/network.go new file mode 100644 index 000000000..46d42a82e --- /dev/null +++ b/environment/microvm/network.go @@ -0,0 +1,286 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "net" + "path/filepath" + "strings" + + gomicrovmnet "github.com/stacklok/go-microvm/net" + "github.com/stacklok/go-microvm/net/firewall" + "github.com/stacklok/go-microvm/net/hosted" +) + +// EgressMode is the closed guest-network policy mode. +type EgressMode string + +const ( + // EgressPermissive permits unrestricted IPv4 guest egress. The guest IPv6 + // stack remains enabled, but go-microvm's hosted topology does not route + // external IPv6. It is also the zero-value and built-in profile default. + EgressPermissive EgressMode = "permissive" + // EgressDenyAll permits no guest destination. + EgressDenyAll EgressMode = "deny-all" + // EgressAllowlist permits only explicitly listed destinations. + EgressAllowlist EgressMode = "allowlist" +) + +// EgressProtocol is an IP transport protocol accepted by go-microvm's filter. +type EgressProtocol uint8 + +const ( + // ProtocolTCP permits only TCP for a destination. + ProtocolTCP EgressProtocol = 6 + // ProtocolUDP permits only UDP for a destination. + ProtocolUDP EgressProtocol = 17 +) + +// EgressDestination identifies one guest-visible hostname, port and protocol. +type EgressDestination struct { + Hostname string + Port uint16 + Protocol EgressProtocol +} + +// GuestEgressPolicy is operator-resolved policy for guest processes only. +type GuestEgressPolicy struct { + Mode EgressMode + Allow []EgressDestination +} + +// GuestNetworkConfigurator applies guest-side settings required by tightening +// modes because go-microvm v0.0.40's frame filter is IPv4-only. +type GuestNetworkConfigurator interface { + DisableIPv6(context.Context) error +} + +// NetworkProviderFactory selects the hosted provider used by an environment. +type NetworkProviderFactory func() gomicrovmnet.Provider + +// NetworkHandle is the configured provider endpoint handed to VM creation. +type NetworkHandle struct { + SocketPath string + Provider gomicrovmnet.Provider + GuestEgress string +} + +// NetworkController configures the selected provider. Permissive mode leaves +// the guest IPv6 stack enabled, although hosted external IPv6 is unrouted and +// unsupported. Selected tightening additionally closes the provider's IPv6 +// filtering gap before readiness succeeds. +type NetworkController struct { + provider NetworkProviderFactory + guest GuestNetworkConfigurator + guestEnforcedBoot bool +} + +// NewNetworkController builds a controller around an explicit provider +// selection. A nil or failed provider is an error; there is no implicit path. +func NewNetworkController(provider NetworkProviderFactory, guest GuestNetworkConfigurator) *NetworkController { + return &NetworkController{provider: provider, guest: guest} +} + +// NewHostedNetworkController selects go-microvm's in-process hosted provider. +func NewHostedNetworkController(guest GuestNetworkConfigurator) *NetworkController { + return NewNetworkController(func() gomicrovmnet.Provider { + provider := hosted.NewProvider() + return &observedHostedProvider{Provider: provider} + }, guest) +} + +// NewHostedBootNetworkController selects hosted IPv4 filtering when the guest +// image applies IPv6 policy before starting its authenticated control service. +// The owning runtime must verify that service before reporting readiness. +func NewHostedBootNetworkController() *NetworkController { + controller := NewHostedNetworkController(nil) + controller.guestEnforcedBoot = true + return controller +} + +type egressDenialSource interface{ EgressDenials() uint64 } + +type observedHostedProvider struct{ *hosted.Provider } + +func (p *observedHostedProvider) EgressDenials() uint64 { + if p == nil || p.Provider == nil || p.Relay() == nil { + return 0 + } + return p.Relay().Metrics().FramesDropped.Load() +} + +// Start validates and starts the selected provider with deny-default filtering, +// then requires either immediate guest IPv6 disablement or an explicit boot-time +// enforcement contract. Any immediate enforcement failure stops the provider. +func (c *NetworkController) Start(ctx context.Context, policy GuestEgressPolicy) (NetworkHandle, error) { + return c.start(ctx, policy, "") +} + +// StartForDoctor exercises the production provider in a caller-owned private +// runtime directory, then returns the live handle for immediate teardown. +func (c *NetworkController) StartForDoctor(ctx context.Context, policy GuestEgressPolicy, runtimeDir string) (NetworkHandle, error) { + if !filepath.IsAbs(runtimeDir) { + return NetworkHandle{}, errors.New("microvm doctor network runtime directory must be absolute") + } + return c.start(ctx, policy, runtimeDir) +} + +func (c *NetworkController) start(ctx context.Context, policy GuestEgressPolicy, logDir string) (NetworkHandle, error) { + if c == nil || c.provider == nil { + return NetworkHandle{}, errors.New("microvm network provider is not configured") + } + mode := policy.normalizedMode() + if mode != EgressPermissive && c.guest == nil && !c.guestEnforcedBoot { + return NetworkHandle{}, errors.New("microvm guest IPv6 enforcement is not configured") + } + allowed, err := policy.goMicroVMHosts() + if err != nil { + return NetworkHandle{}, err + } + provider := c.provider() + if provider == nil { + return NetworkHandle{}, errors.New("selected microvm network provider is nil") + } + cfg := gomicrovmnet.Config{LogDir: logDir, FirewallDefaultAction: firewall.Allow} + if mode != EgressPermissive { + cfg.EgressPolicy = &gomicrovmnet.EgressPolicy{AllowedHosts: allowed} + cfg.FirewallDefaultAction = firewall.Deny + } + if err := provider.Start(ctx, cfg); err != nil { + return NetworkHandle{}, fmt.Errorf("start selected microvm network provider: %w", err) + } + fail := func(err error) (NetworkHandle, error) { + provider.Stop() + return NetworkHandle{}, err + } + if strings.TrimSpace(provider.SocketPath()) == "" || !filepath.IsAbs(provider.SocketPath()) { + return fail(errors.New("selected microvm network provider returned no absolute endpoint")) + } + if mode != EgressPermissive && c.guest != nil { + if err := c.guest.DisableIPv6(ctx); err != nil { + return fail(fmt.Errorf("disable guest IPv6 for selected tightening: %w", err)) + } + } + return NetworkHandle{ + SocketPath: provider.SocketPath(), Provider: provider, + GuestEgress: policy.status(), + }, nil +} + +// Status returns the daemon-authored response-safe summary of enforced guest egress. +func (p GuestEgressPolicy) Status() string { return p.status() } + +func (p GuestEgressPolicy) normalizedMode() EgressMode { + if p.Mode == "" { + return EgressPermissive + } + return p.Mode +} + +func (p GuestEgressPolicy) tightened() bool { return p.normalizedMode() != EgressPermissive } + +func (p GuestEgressPolicy) status() string { + switch p.normalizedMode() { + case EgressPermissive: + return "permissive IPv4 (IPv6 stack enabled; external IPv6 unrouted/unsupported)" + case EgressDenyAll: + return "deny-all (IPv4 filtered; IPv6 disabled)" + default: + return fmt.Sprintf("allowlist (%d destinations; IPv4 filtered; IPv6 disabled)", len(p.Allow)) + } +} + +func (p GuestEgressPolicy) goMicroVMHosts() ([]gomicrovmnet.EgressHost, error) { + switch p.normalizedMode() { + case EgressPermissive: + if len(p.Allow) != 0 { + return nil, errors.New("permissive guest egress policy cannot contain destinations") + } + return nil, nil + case EgressDenyAll: + if len(p.Allow) != 0 { + return nil, errors.New("deny-all guest egress policy cannot contain destinations") + } + return []gomicrovmnet.EgressHost{}, nil + case EgressAllowlist: + if len(p.Allow) == 0 { + return nil, errors.New("guest egress allowlist is empty; use deny-all explicitly") + } + default: + return nil, fmt.Errorf("unknown guest egress policy mode %q", p.Mode) + } + + hosts := make([]gomicrovmnet.EgressHost, 0, len(p.Allow)) + for i, destination := range p.Allow { + hostname := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(destination.Hostname), ".")) + if !validEgressHostname(hostname) { + return nil, fmt.Errorf("guest egress destination %d has invalid hostname %q", i, destination.Hostname) + } + if destination.Port == 0 { + return nil, fmt.Errorf("guest egress destination %d has empty port", i) + } + if destination.Protocol != ProtocolTCP && destination.Protocol != ProtocolUDP { + return nil, fmt.Errorf("guest egress destination %d has unsupported protocol %d", i, destination.Protocol) + } + hosts = append(hosts, gomicrovmnet.EgressHost{ + Name: hostname, Ports: []uint16{destination.Port}, Protocol: uint8(destination.Protocol), + }) + } + return hosts, nil +} + +func validEgressHostname(host string) bool { + if host == "" || len(host) > 253 || net.ParseIP(host) != nil { + return false + } + if strings.HasPrefix(host, "*.") { + host = host[2:] + if strings.Count(host, ".") < 1 { + return false + } + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, r := range label { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } + } + } + return true +} + +// SysctlWriter is the guest-side sysctl write seam. +type SysctlWriter func(key, value string) error + +// SysctlGuestNetwork disables IPv6 on every current and future guest interface. +type SysctlGuestNetwork struct { + write SysctlWriter +} + +// NewSysctlGuestNetwork builds the guest-side IPv6 enforcer. The guest agent +// supplies its privileged sysctl implementation (for example harden.Set). +func NewSysctlGuestNetwork(write SysctlWriter) *SysctlGuestNetwork { + return &SysctlGuestNetwork{write: write} +} + +// DisableIPv6 applies all/default/interface-wide settings and fails if any +// setting is unavailable; partial disablement is not accepted. +func (g *SysctlGuestNetwork) DisableIPv6(_ context.Context) error { + if g == nil || g.write == nil { + return errors.New("guest sysctl writer is not configured") + } + for _, key := range []string{ + "net.ipv6.conf.all.disable_ipv6", + "net.ipv6.conf.default.disable_ipv6", + "net.ipv6.conf.eth0.disable_ipv6", + } { + if err := g.write(key, "1"); err != nil { + return fmt.Errorf("set %s: %w", key, err) + } + } + return nil +} diff --git a/environment/microvm/network_test.go b/environment/microvm/network_test.go new file mode 100644 index 000000000..fe0b23206 --- /dev/null +++ b/environment/microvm/network_test.go @@ -0,0 +1,249 @@ +package microvm + +import ( + "context" + "errors" + "testing" + + gomicrovmnet "github.com/stacklok/go-microvm/net" + "github.com/stacklok/go-microvm/net/firewall" +) + +func TestMicroVMMVP_Scenario6_PermissiveIPv4WithOptionalFailClosedTightening(t *testing.T) { + t.Parallel() + + t.Run("built-in default is unrestricted IPv4 with external IPv6 unsupported", func(t *testing.T) { + provider := &fakeNetworkProvider{socket: "/network.sock"} + guest := &fakeGuestNetwork{ipv6Enabled: true} + controller := NewNetworkController(func() gomicrovmnet.Provider { return provider }, guest) + handle, err := controller.Start(context.Background(), GuestEgressPolicy{}) + if err != nil { + t.Fatalf("Start(default): %v", err) + } + if handle.GuestEgress != "permissive IPv4 (IPv6 stack enabled; external IPv6 unrouted/unsupported)" { + t.Fatalf("default status = %q", handle.GuestEgress) + } + if provider.cfg.FirewallDefaultAction != firewall.Allow || provider.cfg.EgressPolicy != nil { + t.Fatalf("default provider config = %+v, want unfiltered egress", provider.cfg) + } + if guest.disableCalls != 0 || !guest.ipv6Enabled { + t.Fatalf("default changed IPv6: calls=%d enabled=%v", guest.disableCalls, guest.ipv6Enabled) + } + }) + + for _, policy := range []GuestEgressPolicy{ + {Mode: EgressDenyAll}, + {Mode: EgressAllowlist, Allow: []EgressDestination{{Hostname: "example.com", Port: 443, Protocol: ProtocolTCP}}}, + } { + t.Run(string(policy.Mode)+" covers both stacks", func(t *testing.T) { + provider := &fakeNetworkProvider{socket: "/network.sock"} + guest := &fakeGuestNetwork{ipv6Enabled: true} + controller := NewNetworkController(func() gomicrovmnet.Provider { return provider }, guest) + if _, err := controller.Start(context.Background(), policy); err != nil { + t.Fatal(err) + } + if provider.cfg.FirewallDefaultAction != firewall.Deny || provider.cfg.EgressPolicy == nil || guest.disableCalls != 1 || guest.ipv6Enabled { + t.Fatalf("tightening did not cover IPv4 and IPv6: cfg=%+v guest=%+v", provider.cfg, guest) + } + }) + } + + t.Run("selected enforcement failure aborts without permissive fallback", func(t *testing.T) { + provider := &fakeNetworkProvider{socket: "/network.sock"} + guest := &fakeGuestNetwork{ipv6Enabled: true, disableErr: errors.New("sysctl refused")} + controller := NewNetworkController(func() gomicrovmnet.Provider { return provider }, guest) + if _, err := controller.Start(context.Background(), GuestEgressPolicy{Mode: EgressDenyAll}); err == nil { + t.Fatal("Start(tightening) succeeded after enforcement failure") + } + if provider.stops != 1 { + t.Fatalf("failed tightening provider stops = %d, want 1", provider.stops) + } + }) +} + +func TestADR_0224_ExplicitNetworkProviderNeverDegrades(t *testing.T) { + t.Parallel() + + startFailure := errors.New("hosted network unavailable") + tests := []struct { + name string + provider *fakeNetworkProvider + guest *fakeGuestNetwork + wantErr bool + wantStops int + wantStarts int + }{ + {name: "provider starts with deny-all enforcement", provider: &fakeNetworkProvider{socket: "/network.sock"}, guest: &fakeGuestNetwork{}, wantStarts: 1}, + {name: "provider start failure aborts", provider: &fakeNetworkProvider{startErr: startFailure}, guest: &fakeGuestNetwork{}, wantErr: true, wantStarts: 1}, + {name: "empty provider endpoint aborts", provider: &fakeNetworkProvider{}, guest: &fakeGuestNetwork{}, wantErr: true, wantStarts: 1, wantStops: 1}, + {name: "ipv6 enforcement failure aborts", provider: &fakeNetworkProvider{socket: "/network.sock"}, guest: &fakeGuestNetwork{disableErr: errors.New("sysctl refused")}, wantErr: true, wantStarts: 1, wantStops: 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + controller := NewNetworkController(func() gomicrovmnet.Provider { return tc.provider }, tc.guest) + _, err := controller.Start(context.Background(), GuestEgressPolicy{Mode: EgressDenyAll}) + if (err != nil) != tc.wantErr { + t.Fatalf("Start() error = %v, wantErr %v", err, tc.wantErr) + } + if tc.provider.starts != tc.wantStarts || tc.provider.stops != tc.wantStops { + t.Fatalf("provider starts/stops = %d/%d, want %d/%d", tc.provider.starts, tc.provider.stops, tc.wantStarts, tc.wantStops) + } + if tc.provider.starts > 0 { + if tc.provider.cfg.EgressPolicy == nil || tc.provider.cfg.FirewallDefaultAction != firewall.Deny { + t.Fatalf("provider config = %+v, want explicit deny-default egress policy", tc.provider.cfg) + } + } + if tc.wantErr && tc.provider.startErr != nil && tc.guest.disableCalls != 0 { + t.Fatalf("guest IPv6 configuration ran after provider failure: %d calls", tc.guest.disableCalls) + } + }) + } + + t.Run("missing selected provider fails closed", func(t *testing.T) { + controller := NewNetworkController(func() gomicrovmnet.Provider { return nil }, &fakeGuestNetwork{}) + if _, err := controller.Start(context.Background(), GuestEgressPolicy{Mode: EgressDenyAll}); err == nil { + t.Fatal("Start() error = nil, want missing-provider rejection") + } + }) +} + +func TestMicroVMEnvironments_Scenario4_GuestEgressIsFailClosed(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policy GuestEgressPolicy + probes []guestProbe + }{ + { + name: "deny all", + policy: GuestEgressPolicy{Mode: EgressDenyAll}, + probes: []guestProbe{{host: "example.com", port: 443, protocol: ProtocolTCP, want: false}}, + }, + { + name: "hostname port protocol allowlist", + policy: GuestEgressPolicy{Mode: EgressAllowlist, Allow: []EgressDestination{ + {Hostname: "api.example.com", Port: 443, Protocol: ProtocolTCP}, + {Hostname: "dns.example.com", Port: 53, Protocol: ProtocolUDP}, + }}, + probes: []guestProbe{ + {host: "api.example.com", port: 443, protocol: ProtocolTCP, want: true}, + {host: "api.example.com", port: 80, protocol: ProtocolTCP, want: false}, + {host: "api.example.com", port: 443, protocol: ProtocolUDP, want: false}, + {host: "other.example.com", port: 443, protocol: ProtocolTCP, want: false}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + provider := &fakeNetworkProvider{socket: "/network.sock"} + guest := &fakeGuestNetwork{ipv6Enabled: true} + controller := NewNetworkController(func() gomicrovmnet.Provider { return provider }, guest) + handle, err := controller.Start(context.Background(), tc.policy) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + if handle.GuestEgress == "" { + t.Fatal("guest egress status is empty") + } + for _, probe := range tc.probes { + if got := fakeGuestCanDial(provider.cfg, guest, probe); got != probe.want { + t.Errorf("guest dial %s:%d/%d = %v, want %v", probe.host, probe.port, probe.protocol, got, probe.want) + } + } + if guest.disableCalls != 1 || fakeGuestCanDial(provider.cfg, guest, guestProbe{ipv6: true, host: "2001:db8::1", port: 443, protocol: ProtocolTCP}) { + t.Fatalf("IPv6 disable calls = %d; IPv6 must be unavailable", guest.disableCalls) + } + }) + } +} + +func TestSysctlGuestNetwork_DisablesAllIPv6Scopes(t *testing.T) { + t.Parallel() + got := make(map[string]string) + guest := NewSysctlGuestNetwork(func(key, value string) error { + got[key] = value + return nil + }) + if err := guest.DisableIPv6(context.Background()); err != nil { + t.Fatalf("DisableIPv6() error = %v", err) + } + for _, key := range []string{ + "net.ipv6.conf.all.disable_ipv6", + "net.ipv6.conf.default.disable_ipv6", + "net.ipv6.conf.eth0.disable_ipv6", + } { + if got[key] != "1" { + t.Errorf("sysctl %q = %q, want 1", key, got[key]) + } + } +} + +type fakeNetworkProvider struct { + cfg gomicrovmnet.Config + socket string + startErr error + startHook func() error + startCtx context.Context + starts int + stops int + denials uint64 +} + +func (p *fakeNetworkProvider) Start(ctx context.Context, cfg gomicrovmnet.Config) error { + p.starts++ + p.cfg = cfg + p.startCtx = ctx + if p.startHook != nil { + return p.startHook() + } + return p.startErr +} + +func (p *fakeNetworkProvider) SocketPath() string { return p.socket } +func (p *fakeNetworkProvider) Stop() { p.stops++ } +func (p *fakeNetworkProvider) EgressDenials() uint64 { return p.denials } + +type fakeGuestNetwork struct { + disableCalls int + disableErr error + ipv6Enabled bool +} + +func (g *fakeGuestNetwork) DisableIPv6(context.Context) error { + g.disableCalls++ + if g.disableErr == nil { + g.ipv6Enabled = false + } + return g.disableErr +} + +type guestProbe struct { + host string + port uint16 + protocol EgressProtocol + ipv6 bool + want bool +} + +func fakeGuestCanDial(cfg gomicrovmnet.Config, guest *fakeGuestNetwork, probe guestProbe) bool { + if probe.ipv6 { + return guest.ipv6Enabled + } + if cfg.EgressPolicy == nil || cfg.FirewallDefaultAction != firewall.Deny { + return false + } + for _, allowed := range cfg.EgressPolicy.AllowedHosts { + if allowed.Name != probe.host || allowed.Protocol != uint8(probe.protocol) { + continue + } + for _, port := range allowed.Ports { + if port == probe.port { + return true + } + } + } + return false +} diff --git a/environment/microvm/oci_execution_image.go b/environment/microvm/oci_execution_image.go new file mode 100644 index 000000000..0ce907b12 --- /dev/null +++ b/environment/microvm/oci_execution_image.go @@ -0,0 +1,113 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "runtime" + "sync" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/stacklok/go-microvm/extract" + gomicrovmimage "github.com/stacklok/go-microvm/image" +) + +// OCIExecutionImageResolver pulls a platform-specific digest-pinned OCI image +// through go-microvm and exposes only its extracted tree to artifact admission. +type OCIExecutionImageResolver struct { + cache *gomicrovmimage.Cache + fetcher gomicrovmimage.ImageFetcher + evidence map[string]VerificationEvidence + mu sync.Mutex +} + +const ociExtractionCacheVersion = "deterministic-modes-v1" + +// NewOCIExecutionImageResolver constructs an OCI execution-image resolver. +// cacheRoot is go-microvm's pull/extract cache; mecatl's verified cache remains +// a separate admission boundary. The versioned child leaves mode-dependent +// entries produced by older daemons unreachable rather than trusting them. +func NewOCIExecutionImageResolver(cacheRoot string, fetcher gomicrovmimage.ImageFetcher, evidence map[string]VerificationEvidence) *OCIExecutionImageResolver { + cloned := make(map[string]VerificationEvidence, len(evidence)) + for ref, item := range evidence { + cloned[ref] = item + } + return &OCIExecutionImageResolver{cache: gomicrovmimage.NewCache(filepath.Join(cacheRoot, ociExtractionCacheVersion)), fetcher: fetcher, evidence: cloned} +} + +// Resolve implements ArtifactResolver. Digest is the expected extracted-tree +// identity; ManifestDigest is the independent OCI manifest identity. +func (r *OCIExecutionImageResolver) Resolve(ctx context.Context, request ArtifactRequest) (ResolvedArtifact, error) { + if r == nil || r.cache == nil || request.Kind != ArtifactExecutionImage { + return ResolvedArtifact{}, errors.New("OCI resolver accepts execution images only") + } + manifest, err := pinnedOCIManifest(request.Reference) + if err != nil { + return ResolvedArtifact{}, err + } + if request.ManifestDigest != "" && request.ManifestDigest != manifest { + return ResolvedArtifact{}, ErrDigestMismatch + } + evidence, ok := r.evidence[request.Reference] + if !ok { + return ResolvedArtifact{}, fmt.Errorf("%w: OCI execution image has no verification evidence", ErrUnverifiedArtifact) + } + + // go-microvm's cache ref index is process-safe on disk but Pull's cold path + // is not documented as concurrently callable. Serialize it here so one + // daemon performs one complete extraction for a cold manifest. + r.mu.Lock() + rootfs, err := gomicrovmimage.PullWithFetcher(ctx, request.Reference, r.cache, manifestPlatformFetcher{next: r.fetcher, manifest: manifest}) + r.mu.Unlock() + if err != nil { + return ResolvedArtifact{}, fmt.Errorf("pull OCI execution image: %w", err) + } + treeDigest, err := digestTree(rootfs.Path) + if err != nil { + return ResolvedArtifact{}, fmt.Errorf("digest extracted OCI execution image: %w", err) + } + if request.Digest != "" && request.Digest != treeDigest { + return ResolvedArtifact{}, ErrDigestMismatch + } + return ResolvedArtifact{ + Kind: ArtifactExecutionImage, Digest: treeDigest, ManifestDigest: manifest, + Source: extract.Dir(filepath.Clean(rootfs.Path)), Evidence: evidence, + }, nil +} + +func pinnedOCIManifest(ref string) (string, error) { + parsed, err := name.NewDigest(ref, name.StrictValidation) + if err != nil || parsed.Name() != ref || !validDigest(parsed.DigestStr()) { + return "", fmt.Errorf("%w: OCI execution image must be canonical repo@sha256", ErrMutableArtifact) + } + return parsed.DigestStr(), nil +} + +type manifestPlatformFetcher struct { + next gomicrovmimage.ImageFetcher + manifest string +} + +func (f manifestPlatformFetcher) Pull(ctx context.Context, ref string) (v1.Image, error) { + if f.next == nil { + f.next = gomicrovmimage.RemoteFetcher{} + } + img, err := f.next.Pull(ctx, ref) + if err != nil { + return nil, err + } + digest, err := img.Digest() + if err != nil || digest.String() != f.manifest { + return nil, ErrDigestMismatch + } + cfg, err := img.ConfigFile() + if err != nil { + return nil, fmt.Errorf("read OCI execution-image platform: %w", err) + } + if cfg.OS != "linux" || cfg.Architecture != runtime.GOARCH { + return nil, fmt.Errorf("OCI execution-image platform is %s/%s, want linux/%s", cfg.OS, cfg.Architecture, runtime.GOARCH) + } + return img, nil +} diff --git a/environment/microvm/oci_execution_image_test.go b/environment/microvm/oci_execution_image_test.go new file mode 100644 index 000000000..288b7e76a --- /dev/null +++ b/environment/microvm/oci_execution_image_test.go @@ -0,0 +1,255 @@ +package microvm + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +func TestMicroVMEnvironments_Scenario2_OCIExecutionImageResolver(t *testing.T) { + t.Parallel() + img := testOCIImage(t, runtime.GOARCH) + manifest, err := img.Digest() + if err != nil { + t.Fatal(err) + } + ref := "ghcr.io/stacklok/brood-box/base@" + manifest.String() + fetcher := &countingImageFetcher{image: img} + resolver := NewOCIExecutionImageResolver(filepath.Join(t.TempDir(), "oci"), fetcher, map[string]VerificationEvidence{ref: {Bundle: []byte("bundle")}}) + request := ArtifactRequest{Kind: ArtifactExecutionImage, Reference: ref, ManifestDigest: manifest.String()} + + first, err := resolver.Resolve(context.Background(), request) + if err != nil { + t.Fatalf("cold Resolve() error = %v", err) + } + if first.ManifestDigest != manifest.String() || first.Digest == "" || first.Source == nil { + t.Fatalf("cold identity/source = %+v", first) + } + request.Digest = first.Digest + warm, err := resolver.Resolve(context.Background(), request) + if err != nil { + t.Fatalf("warm Resolve() error = %v", err) + } + if warm.Digest != first.Digest || fetcher.calls.Load() != 1 { + t.Fatalf("warm cache identity/calls = %q/%d, want %q/1", warm.Digest, fetcher.calls.Load(), first.Digest) + } + + const workers = 8 + var wg sync.WaitGroup + errCh := make(chan error, workers) + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + resolved, resolveErr := resolver.Resolve(context.Background(), request) + if resolveErr == nil && resolved.Digest != first.Digest { + resolveErr = errors.New("concurrent resolver returned another tree identity") + } + errCh <- resolveErr + }() + } + wg.Wait() + close(errCh) + for resolveErr := range errCh { + if resolveErr != nil { + t.Fatal(resolveErr) + } + } + if fetcher.calls.Load() != 1 { + t.Fatalf("warm/concurrent pulls = %d, want one cold pull", fetcher.calls.Load()) + } + + coldFetcher := &countingImageFetcher{image: img} + coldResolver := NewOCIExecutionImageResolver(filepath.Join(t.TempDir(), "oci-cold-concurrent"), coldFetcher, map[string]VerificationEvidence{ref: {Bundle: []byte("bundle")}}) + start := make(chan struct{}) + errCh = make(chan error, workers) + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, resolveErr := coldResolver.Resolve(context.Background(), ArtifactRequest{Kind: ArtifactExecutionImage, Reference: ref, ManifestDigest: manifest.String()}) + errCh <- resolveErr + }() + } + close(start) + wg.Wait() + close(errCh) + for resolveErr := range errCh { + if resolveErr != nil { + t.Fatal(resolveErr) + } + } + if coldFetcher.calls.Load() != 1 { + t.Fatalf("concurrent cold pulls = %d, want one", coldFetcher.calls.Load()) + } +} + +func TestOCIExecutionImageResolverInvalidatesLegacyModeDependentCacheNamespace(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "refs"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "refs", "legacy-mode-corruption"), []byte("invalid"), 0o600); err != nil { + t.Fatal(err) + } + img := testOCIImage(t, runtime.GOARCH) + manifest, err := img.Digest() + if err != nil { + t.Fatal(err) + } + ref := "ghcr.io/stacklok/brood-box/base@" + manifest.String() + fetcher := &countingImageFetcher{image: img} + resolver := NewOCIExecutionImageResolver(root, fetcher, map[string]VerificationEvidence{ref: {Bundle: []byte("bundle")}}) + if resolver.cache.BaseDir() != filepath.Join(root, ociExtractionCacheVersion) { + t.Fatalf("cache root = %q, want versioned deterministic-mode namespace", resolver.cache.BaseDir()) + } + if _, err := resolver.Resolve(t.Context(), ArtifactRequest{Kind: ArtifactExecutionImage, Reference: ref, ManifestDigest: manifest.String()}); err != nil { + t.Fatalf("Resolve() consulted legacy cache state: %v", err) + } + if fetcher.calls.Load() != 1 { + t.Fatalf("cold pulls = %d, want 1", fetcher.calls.Load()) + } +} + +func TestMicroVMEnvironments_Scenario2_OCIExecutionImageRejectsMutableMalformedAndWrongPlatform(t *testing.T) { + t.Parallel() + img := testOCIImage(t, runtime.GOARCH) + digest, err := img.Digest() + if err != nil { + t.Fatal(err) + } + good := "ghcr.io/stacklok/brood-box/base@" + digest.String() + wrongArch := "amd64" + if runtime.GOARCH == wrongArch { + wrongArch = "arm64" + } + wrongPlatformImage := testOCIImage(t, wrongArch) + wrongPlatformDigest, err := wrongPlatformImage.Digest() + if err != nil { + t.Fatal(err) + } + wrongPlatformRef := "ghcr.io/stacklok/brood-box/base@" + wrongPlatformDigest.String() + + tests := []struct { + name string + request ArtifactRequest + image v1.Image + }{ + {name: "tag", request: ArtifactRequest{Kind: ArtifactExecutionImage, Reference: "ghcr.io/stacklok/brood-box/base:v1", ManifestDigest: digest.String()}, image: img}, + {name: "latest", request: ArtifactRequest{Kind: ArtifactExecutionImage, Reference: "ghcr.io/stacklok/brood-box/base:latest", ManifestDigest: digest.String()}, image: img}, + {name: "malformed digest", request: ArtifactRequest{Kind: ArtifactExecutionImage, Reference: "ghcr.io/stacklok/brood-box/base@sha256:nope", ManifestDigest: "sha256:nope"}, image: img}, + {name: "manifest mismatch", request: ArtifactRequest{Kind: ArtifactExecutionImage, Reference: good, ManifestDigest: digestFor("another-manifest")}, image: img}, + {name: "platform mismatch", request: ArtifactRequest{Kind: ArtifactExecutionImage, Reference: wrongPlatformRef, ManifestDigest: wrongPlatformDigest.String()}, image: wrongPlatformImage}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resolver := NewOCIExecutionImageResolver(filepath.Join(t.TempDir(), "oci"), &countingImageFetcher{image: tc.image}, map[string]VerificationEvidence{tc.request.Reference: {Bundle: []byte("bundle")}}) + if _, err := resolver.Resolve(context.Background(), tc.request); err == nil { + t.Fatal("Resolve() error = nil, want fail-closed rejection") + } + }) + } +} + +func TestInvariant_oci_execution_image_retains_manifest_and_admitted_tree_identities(t *testing.T) { + t.Parallel() + img := testOCIImage(t, runtime.GOARCH) + manifest, err := img.Digest() + if err != nil { + t.Fatal(err) + } + ref := "ghcr.io/stacklok/brood-box/base@" + manifest.String() + resolver := NewOCIExecutionImageResolver(filepath.Join(t.TempDir(), "oci"), &countingImageFetcher{image: img}, map[string]VerificationEvidence{ref: {Bundle: []byte("bundle")}}) + resolved, err := resolver.Resolve(context.Background(), ArtifactRequest{Kind: ArtifactExecutionImage, Reference: ref, ManifestDigest: manifest.String()}) + if err != nil { + t.Fatal(err) + } + if resolved.ManifestDigest == resolved.Digest { + t.Fatalf("manifest identity %q unexpectedly aliases materialized-tree identity", resolved.ManifestDigest) + } + request := ArtifactRequest{Kind: ArtifactExecutionImage, Reference: ref, ManifestDigest: manifest.String(), Digest: digestFor("wrong-tree")} + if _, err := resolver.Resolve(context.Background(), request); !errors.Is(err, ErrDigestMismatch) { + t.Fatalf("tree mismatch error = %v, want ErrDigestMismatch", err) + } +} + +func TestMicroVMEnvironments_Scenario2_LiveOCIPlatformResolver(t *testing.T) { + ref := os.Getenv("MECATL_MICROVM_LIVE_OCI_REF") + if ref == "" { + t.Skip("release/live platform OCI reference is not configured") + } + at := strings.LastIndexByte(ref, '@') + if at < 0 { + t.Fatalf("live OCI reference %q is not digest-pinned", ref) + } + manifest := ref[at+1:] + resolver := NewOCIExecutionImageResolver(filepath.Join(t.TempDir(), "oci"), nil, map[string]VerificationEvidence{ref: {Bundle: []byte("release-live")}}) + first, err := resolver.Resolve(context.Background(), ArtifactRequest{Kind: ArtifactExecutionImage, Reference: ref, ManifestDigest: manifest}) + if err != nil { + t.Fatalf("live platform Resolve() error = %v", err) + } + second, err := resolver.Resolve(context.Background(), ArtifactRequest{Kind: ArtifactExecutionImage, Reference: ref, ManifestDigest: manifest, Digest: first.Digest}) + if err != nil { + t.Fatalf("live warm Resolve() error = %v", err) + } + if second.ManifestDigest != manifest || second.Digest != first.Digest || second.Source == nil { + t.Fatalf("live OCI identities/source = %+v", second) + } +} + +func testOCIImage(t *testing.T, arch string) v1.Image { + t.Helper() + var layer bytes.Buffer + writer := tar.NewWriter(&layer) + if err := writer.WriteHeader(&tar.Header{Name: "usr/bin/mecatl-tool", Mode: 0o755, Size: int64(len("tool")), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte("tool")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + img, err := mutate.AppendLayers(empty.Image, static.NewLayer(layer.Bytes(), types.OCILayer)) + if err != nil { + t.Fatal(err) + } + cfg, err := img.ConfigFile() + if err != nil { + t.Fatal(err) + } + cfg.OS = "linux" + cfg.Architecture = arch + cfg.Config.User = "65532:65532" + img, err = mutate.ConfigFile(img, cfg) + if err != nil { + t.Fatal(err) + } + return img +} + +type countingImageFetcher struct { + image v1.Image + calls atomic.Int32 +} + +func (f *countingImageFetcher) Pull(context.Context, string) (v1.Image, error) { + f.calls.Add(1) + return f.image, nil +} diff --git a/environment/microvm/operational.go b/environment/microvm/operational.go new file mode 100644 index 000000000..946381238 --- /dev/null +++ b/environment/microvm/operational.go @@ -0,0 +1,378 @@ +package microvm + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gofrs/flock" + + "github.com/stacklok/mecatl/environment/microvm/gitexec" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +// IdentitySequence allocates random generation-fenced daemon identities. +type IdentitySequence struct{ EndpointDir string } + +// Allocate implements IdentityAllocator. +func (a IdentitySequence) Allocate(sessionID string) (EnvironmentIdentity, error) { + if sessionID == "" || !filepath.IsAbs(a.EndpointDir) { + return EnvironmentIdentity{}, errors.New("microvm identity allocator is not configured") + } + if err := os.MkdirAll(a.EndpointDir, 0o700); err != nil { + return EnvironmentIdentity{}, fmt.Errorf("create microvm endpoint directory: %w", err) + } + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return EnvironmentIdentity{}, err + } + id := hex.EncodeToString(random[:]) + return EnvironmentIdentity{EnvironmentID: id, VMID: "mecatl-" + id, Endpoint: filepath.Join(a.EndpointDir, id+".sock"), Generation: 1}, nil +} + +// GitWorktrees adapts the concrete worktree preparer to lifecycle and retention. +type GitWorktrees struct{ preparer *worktree.Preparer } + +// NewGitWorktrees constructs the production Git worktree lifecycle. +func NewGitWorktrees() *GitWorktrees { return &GitWorktrees{preparer: worktree.New()} } + +// Prepare implements WorktreeLifecycle. +func (g *GitWorktrees) Prepare(ctx context.Context, request worktree.Request) (*worktree.Prepared, error) { + return g.preparer.Prepare(ctx, request) +} + +// Cleanup rolls back a prepared worktree using Git's own linked-worktree operation. +func (*GitWorktrees) Cleanup(ctx context.Context, prepared *worktree.Prepared) error { + if prepared == nil { + return nil + } + return prepared.Cleanup(ctx) +} + +// Dirty reports whether the prepared worktree has any tracked or untracked change. +func (*GitWorktrees) Dirty(ctx context.Context, record EnvironmentRecord) (bool, error) { + output, err := gitexec.Run(ctx, record.WorktreePath, nil, "status", "--porcelain=v1", "--untracked-files=all") + if err != nil { + return false, fmt.Errorf("inspect microvm worktree: %w", err) + } + return len(output) != 0, nil +} + +// CleanupRecord removes the exact clean worktree retained in the durable record. +func (*GitWorktrees) CleanupRecord(ctx context.Context, record EnvironmentRecord) error { + return cleanupGitWorktree(ctx, record.SourceCheckout, record.WorktreePath, record.MetadataPath) +} + +func cleanupGitWorktree(ctx context.Context, source, path, metadata string) error { + if !filepath.IsAbs(source) || !filepath.IsAbs(path) || !filepath.IsAbs(metadata) || source == path { + return errors.New("refusing unsafe microvm worktree cleanup") + } + head, err := os.ReadFile(filepath.Join(metadata, "HEAD")) + if err != nil { + return fmt.Errorf("read microvm guest metadata identity: %w", err) + } + const prefix = "ref: refs/heads/" + branch := strings.TrimSuffix(strings.TrimPrefix(string(head), prefix), "\n") + if !strings.HasPrefix(string(head), prefix) || branch == "" || string(head) != prefix+branch+"\n" { + return errors.New("refusing microvm worktree cleanup with invalid metadata identity") + } + if err := (&worktree.Prepared{SourceRoot: source, WorktreePath: path, MetadataPath: metadata, Branch: branch}).Cleanup(ctx); err != nil { + return fmt.Errorf("remove microvm Git worktree: %w", err) + } + return nil +} + +// RetentionAdapter exposes GitWorktrees through WorktreeRetention without +// overloading its two lifecycle Cleanup signatures. +type RetentionAdapter struct{ Worktrees *GitWorktrees } + +// Dirty implements WorktreeRetention.Dirty. +func (a RetentionAdapter) Dirty(ctx context.Context, record EnvironmentRecord) (bool, error) { + return a.Worktrees.Dirty(ctx, record) +} + +// Cleanup implements WorktreeRetention.Cleanup. +func (a RetentionAdapter) Cleanup(ctx context.Context, record EnvironmentRecord) error { + return a.Worktrees.CleanupRecord(ctx, record) +} + +var placementProcessLocks sync.Map + +// ChildRequestBuilder derives a child create request from an authoritative parent. +type ChildRequestBuilder func(context.Context, EnvironmentRecord, string) (CreateRequest, error) + +// LifecycleChildren creates child generations through the same Lifecycle that owns +// VM/worktree admission, then performs conflict-aware Git merge-back. +type LifecycleChildren struct { + creator EnvironmentCreator + registry ReconcileRegistry + admission *AdmissionController + observer *OperationsObserver + build ChildRequestBuilder +} + +// NewLifecycleChildren constructs the daemon's production delegation lifecycle. +func NewLifecycleChildren(creator EnvironmentCreator, registry ReconcileRegistry, admission *AdmissionController, build ChildRequestBuilder, observers ...*OperationsObserver) *LifecycleChildren { + var observer *OperationsObserver + if len(observers) != 0 { + observer = observers[0] + } + return &LifecycleChildren{creator: creator, registry: registry, admission: admission, observer: observer, build: build} +} + +// Fork captures the parent revision before provisioning and holds only the transient +// fork quota here; Lifecycle remains the sole owner of VM/worktree reservations. +func (c *LifecycleChildren) Fork(ctx context.Context, parent EnvironmentRecord, label string) (EnvironmentRecord, error) { + if c == nil || c.creator == nil || c.registry == nil || c.build == nil || parent.State != EnvironmentReady || label == "" { + return EnvironmentRecord{}, ErrInvalidFork + } + var forkLease *AdmissionLease + var err error + if c.admission != nil { + forkLease, err = c.admission.AcquireContext(ctx, parent.Owner, ResourceUsage{Forks: 1}) + if err != nil { + if c.observer != nil { + c.observer.QuotaRejected(QuotaForks) + } + return EnvironmentRecord{}, err + } + defer forkLease.Release() + } + base, err := captureForkBase(ctx, parent.WorktreePath) + if err != nil { + return EnvironmentRecord{}, fmt.Errorf("capture microvm fork base: %w", err) + } + request, err := c.build(ctx, parent, label) + if err != nil { + return EnvironmentRecord{}, err + } + request.Owner = parent.Owner + request.Profile = parent.Profile + request.ParentRef = parent.Ref + request.ForkBase = base + request.Worktree.BaseRevision = base + created, err := c.creator.Create(ctx, request) + if err != nil { + return EnvironmentRecord{}, err + } + environmentID, _, err := parseEnvironmentRef(created.Ref) + if err != nil { + return EnvironmentRecord{}, err + } + child, err := c.registry.Lookup(ctx, environmentID) + if err != nil { + return EnvironmentRecord{}, err + } + if child.ParentRef != parent.Ref || child.ForkBase != request.ForkBase || child.State != EnvironmentReady { + return EnvironmentRecord{}, ErrInvalidFork + } + return child, nil +} + +func captureForkBase(ctx context.Context, parent string) (string, error) { + first, err := captureForkBaseOnce(ctx, parent) + if err != nil { + return "", err + } + second, err := captureForkBaseOnce(ctx, parent) + if err != nil { + return "", err + } + if first != second { + return "", errors.New("parent worktree changed while capturing microvm fork base") + } + return first, nil +} + +func captureForkBaseOnce(ctx context.Context, parent string) (string, error) { + gitDirOut, err := gitexec.Run(ctx, parent, nil, "rev-parse", "--absolute-git-dir") + if err != nil { + return "", err + } + gitDir := strings.TrimSpace(string(gitDirOut)) + index, err := os.CreateTemp(gitDir, ".mecatl-fork-index-*") + if err != nil { + return "", err + } + indexPath := index.Name() + if err := index.Close(); err != nil { + _ = os.Remove(indexPath) + return "", err + } + if err := os.Remove(indexPath); err != nil { + return "", err + } + defer func() { _ = os.Remove(indexPath) }() + env := []string{"GIT_INDEX_FILE=" + indexPath} + if _, err := gitexec.RunWithEnv(ctx, parent, nil, env, "read-tree", "HEAD"); err != nil { + return "", err + } + if _, err := gitexec.RunWithEnv(ctx, parent, nil, env, "add", "-A", "--"); err != nil { + return "", err + } + base, err := gitexec.RunWithEnv(ctx, parent, nil, env, "write-tree") + if err != nil { + return "", err + } + value := strings.TrimSpace(string(base)) + if len(value) != 40 && len(value) != 64 { + return "", errors.New("git returned an invalid fork-base tree id") + } + return value, nil +} + +// Merge rejects every parent-side overlap before applying the complete child patch. +func (*LifecycleChildren) Merge(ctx context.Context, parent, child EnvironmentRecord) error { + if parent.State != EnvironmentReady || child.State != EnvironmentReady || child.ParentRef != parent.Ref || child.ForkBase == "" { + return ErrInvalidFork + } + if _, err := gitexec.Run(ctx, child.WorktreePath, nil, "add", "-N", "--all"); err != nil { + return fmt.Errorf("index microvm child additions: %w", err) + } + names, err := gitexec.Run(ctx, child.WorktreePath, nil, "diff", "--name-only", "-z", child.ForkBase, "--") + if err != nil { + return fmt.Errorf("enumerate microvm child changes: %w", err) + } + paths := splitNUL(names) + if len(paths) == 0 { + return nil + } + if err := checkParentMergeBase(ctx, parent.WorktreePath, child.ForkBase, paths); err != nil { + return err + } + patchArgs := append([]string{"diff", "--binary", child.ForkBase, "--"}, paths...) + patch, err := gitexec.Run(ctx, child.WorktreePath, nil, patchArgs...) + if err != nil { + return fmt.Errorf("build microvm child patch: %w", err) + } + if _, err := gitexec.Run(ctx, parent.WorktreePath, patch, "apply", "--check", "--binary", "-"); err != nil { + return fmt.Errorf("%w: %v", ErrMergeConflict, err) + } + // The daemon parent lock covers every client merge. Re-read the immutable + // base comparison after patch construction/check and directly before apply. + if err := checkParentMergeBase(ctx, parent.WorktreePath, child.ForkBase, paths); err != nil { + return err + } + if _, err := gitexec.Run(ctx, parent.WorktreePath, patch, "apply", "--binary", "-"); err != nil { + return fmt.Errorf("apply microvm child patch: %w", err) + } + return nil +} + +func checkParentMergeBase(ctx context.Context, parent, base string, paths []string) error { + quietArgs := append([]string{"diff", "--quiet", base, "--"}, paths...) + if _, err := gitexec.Run(ctx, parent, nil, quietArgs...); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return fmt.Errorf("%w: parent changed a child-modified path", ErrMergeConflict) + } + return fmt.Errorf("inspect microvm parent merge conflicts: %w", err) + } + return nil +} + +func splitNUL(data []byte) []string { + var result []string + for _, value := range strings.Split(string(data), "\x00") { + if value != "" { + result = append(result, value) + } + } + return result +} + +// FileSessionPersister writes daemon-side placement commits atomically. The host +// adapter consumes these records to stamp its own session aggregate. +type FileSessionPersister struct { + path string + lock *flock.Flock + processLock *sync.Mutex +} + +// NewFileSessionPersister constructs a placement journal projection. +func NewFileSessionPersister(path string) (*FileSessionPersister, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return nil, errors.New("microvm placement path must be absolute and clean") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + processLock, _ := placementProcessLocks.LoadOrStore(path, &sync.Mutex{}) + return &FileSessionPersister{path: path, lock: flock.New(path + ".lock"), processLock: processLock.(*sync.Mutex)}, nil +} + +// Persist atomically commits the latest placement for each session. +func (p *FileSessionPersister) Persist(ctx context.Context, placement SessionPlacement) error { + if placement.SessionID == "" || placement.Ref.Kind != Kind { + return errors.New("invalid microvm session placement") + } + p.processLock.Lock() + defer p.processLock.Unlock() + locked, err := p.lock.TryLockContext(ctx, 10*time.Millisecond) + if err != nil { + return fmt.Errorf("lock microvm placements: %w", err) + } + if !locked { + if err := context.Cause(ctx); err != nil { + return err + } + return errors.New("microvm placements lock was not acquired") + } + defer func() { _ = p.lock.Unlock() }() + placements := make(map[string]SessionPlacement) + data, err := os.ReadFile(p.path) + if err == nil { + if err := json.Unmarshal(data, &placements); err != nil { + return fmt.Errorf("decode microvm placements: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + placements[placement.SessionID] = placement + data, err = json.Marshal(placements) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(p.path), ".placements-*") + if err != nil { + return err + } + name := tmp.Name() + defer func() { _ = os.Remove(name) }() + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(append(data, '\n')); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(name, p.path); err != nil { + return err + } + directory, err := os.Open(filepath.Dir(p.path)) + if err != nil { + return err + } + if err := directory.Sync(); err != nil { + _ = directory.Close() + return err + } + return directory.Close() +} diff --git a/environment/microvm/operational_test.go b/environment/microvm/operational_test.go new file mode 100644 index 000000000..a195469c7 --- /dev/null +++ b/environment/microvm/operational_test.go @@ -0,0 +1,103 @@ +package microvm + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +func TestFileSessionPersisterSerializesIndependentWriters(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "placements.json") + first, err := NewFileSessionPersister(path) + if err != nil { + t.Fatal(err) + } + second, err := NewFileSessionPersister(path) + if err != nil { + t.Fatal(err) + } + + const entries = 64 + start := make(chan struct{}) + errs := make(chan error, entries) + var writers sync.WaitGroup + for i := range entries { + writers.Add(1) + go func() { + defer writers.Done() + <-start + persister := first + if i%2 != 0 { + persister = second + } + errs <- persister.Persist(context.Background(), SessionPlacement{ + SessionID: strconv.Itoa(i), Ref: EnvironmentRef{Kind: Kind, ID: "env-" + strconv.Itoa(i) + "@1"}, Generation: 1, + }) + }() + } + close(start) + writers.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("Persist: %v", err) + } + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var placements map[string]SessionPlacement + if err := json.Unmarshal(data, &placements); err != nil { + t.Fatal(err) + } + if len(placements) != entries { + t.Fatalf("persisted entries = %d, want %d", len(placements), entries) + } +} + +func TestGitWorktreesUseHardenedGitInvoker(t *testing.T) { + root := t.TempDir() + source := initScenario6Repository(t, root) + for _, dir := range []string{filepath.Join(root, "worktrees"), filepath.Join(root, "metadata")} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + prepared, err := worktree.New().Prepare(context.Background(), worktree.Request{ + Source: source, WorktreePath: filepath.Join(root, "worktrees", "session"), + MetadataPath: filepath.Join(root, "metadata", "session"), Branch: "mecatl/hardened-git", + }) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + t.Cleanup(func() { _ = prepared.Cleanup(context.Background()) }) + + sentinel := filepath.Join(root, "fsmonitor-ran") + monitor := filepath.Join(root, "hostile-fsmonitor") + if err := os.WriteFile(monitor, []byte("#!/bin/sh\nprintf ran >"+strconv.Quote(sentinel)+"\nprintf '\\n'\n"), 0o700); err != nil { + t.Fatal(err) + } + runGit(t, source, "config", "core.fsmonitor", monitor) + + worktrees := NewGitWorktrees() + dirty, err := worktrees.Dirty(context.Background(), EnvironmentRecord{WorktreePath: prepared.WorktreePath}) + if err != nil { + t.Fatalf("Dirty: %v", err) + } + if dirty { + t.Fatal("clean prepared worktree reported dirty") + } + if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("repository-controlled fsmonitor executed: %v", err) + } +} diff --git a/environment/microvm/operations.go b/environment/microvm/operations.go new file mode 100644 index 000000000..ca036625a --- /dev/null +++ b/environment/microvm/operations.go @@ -0,0 +1,676 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "io/fs" + "strings" + "sync" + "syscall" + "time" + + "github.com/stacklok/mecatl/engine/port" +) + +// Outcome is the closed metric outcome dimension. +type Outcome uint8 + +const ( + // OutcomeSuccess records completed operational work. + OutcomeSuccess Outcome = iota + 1 + // OutcomeFailure records failed operational work. + OutcomeFailure +) + +func (o Outcome) String() string { + if o == OutcomeSuccess { + return "success" + } + return "failure" +} + +// QuotaKind is the closed quota-rejection dimension. +type QuotaKind uint8 + +const ( + // QuotaVMs limits concurrent active and booting VMs. + QuotaVMs QuotaKind = iota + 1 + // QuotaVCPUs limits aggregate virtual CPUs. + QuotaVCPUs + // QuotaMemory limits aggregate guest memory. + QuotaMemory + // QuotaDisk limits aggregate guest disk. + QuotaDisk + // QuotaExecs limits concurrent guest execs. + QuotaExecs + // QuotaWorktrees limits prepared worktrees. + QuotaWorktrees + // QuotaInodes limits aggregate filesystem entries. + QuotaInodes + // QuotaForks limits concurrent child creation. + QuotaForks + // QuotaPulls limits concurrent artifact pulls. + QuotaPulls + // QuotaBootRate limits VM starts in the configured rolling window. + QuotaBootRate +) + +func (q QuotaKind) String() string { + switch q { + case QuotaVMs: + return "vms" + case QuotaVCPUs: + return "vcpus" + case QuotaMemory: + return "memory" + case QuotaDisk: + return "disk" + case QuotaExecs: + return "execs" + case QuotaWorktrees: + return "worktrees" + case QuotaInodes: + return "inodes" + case QuotaForks: + return "forks" + case QuotaPulls: + return "pulls" + case QuotaBootRate: + return "boot-rate" + default: + return "unknown" + } +} + +// ResourceLimits is the aggregate bounded resource gauge for live and booting VMs. +type ResourceLimits struct { + VCPUs int64 + MemoryBytes int64 + DiskBytes int64 +} + +// DurationMetric is a fixed-bucket duration summary. Buckets are cumulative at +// 100ms, 250ms, 500ms, 1s, 2.5s, 5s, 10s, and 30s; the final count includes overflow. +type DurationMetric struct { + Count uint64 + Sum time.Duration + Buckets [8]uint64 +} + +// OperationsSnapshot is a point-in-time, bounded-cardinality runtime metric set. +// Its maps are populated only for closed enum dimensions. +type OperationsSnapshot struct { + BootLatency DurationMetric + ActiveVMs int64 + BootingVMs int64 + ResourceLimits ResourceLimits + Execs uint64 + EgressDenials uint64 + ArtifactVerifications map[ArtifactKind]map[Outcome]uint64 + Cleanups map[Outcome]uint64 + Reconciliations map[Outcome]uint64 + QuotaRejections map[QuotaKind]uint64 +} + +// OperationsObserver records microVM operator facts. Command text and denied +// destinations are accepted at the producer seam only to make their deliberate +// exclusion explicit; they are never retained, labelled, or logged. +type OperationsObserver struct { + mu sync.Mutex + diag port.Diagnostics + snapshot OperationsSnapshot + records map[string]observedEnvironment + egressSources map[string]observedEgressSource +} + +type observedEgressSource struct { + source egressDenialSource + last uint64 +} + +type observedEnvironment struct { + active bool + booting bool + resources ResourceLimits +} + +// NewOperationsObserver constructs an in-memory metrics source and diagnostics emitter. +func NewOperationsObserver(diag port.Diagnostics) *OperationsObserver { + if diag == nil { + diag = port.NopDiagnostics{} + } + return &OperationsObserver{ + diag: diag, snapshot: newOperationsSnapshot(), records: make(map[string]observedEnvironment), + egressSources: make(map[string]observedEgressSource), + } +} + +func newOperationsSnapshot() OperationsSnapshot { + artifacts := make(map[ArtifactKind]map[Outcome]uint64, 4) + for _, kind := range []ArtifactKind{ArtifactRuntime, ArtifactFirmware, ArtifactExecutionImage, ArtifactGuestAgent} { + artifacts[kind] = map[Outcome]uint64{OutcomeSuccess: 0, OutcomeFailure: 0} + } + return OperationsSnapshot{ + ArtifactVerifications: artifacts, + Cleanups: map[Outcome]uint64{OutcomeSuccess: 0, OutcomeFailure: 0}, + Reconciliations: map[Outcome]uint64{OutcomeSuccess: 0, OutcomeFailure: 0}, + QuotaRejections: map[QuotaKind]uint64{ + QuotaVMs: 0, QuotaVCPUs: 0, QuotaMemory: 0, QuotaDisk: 0, QuotaExecs: 0, + QuotaWorktrees: 0, QuotaInodes: 0, QuotaForks: 0, QuotaPulls: 0, QuotaBootRate: 0, + }, + } +} + +// ObserveRecord projects one durable lifecycle generation into process gauges. +// Repeated saves of the same state are idempotent. +func (o *OperationsObserver) ObserveRecord(record EnvironmentRecord) { + if o == nil || record.Ref.ID == "" { + return + } + next := observedFromRecord(record) + o.mu.Lock() + if previous, ok := o.records[record.Ref.ID]; ok { + o.removeObserved(previous) + } + if next.active || next.booting { + o.records[record.Ref.ID] = next + o.addObserved(next) + } else { + delete(o.records, record.Ref.ID) + } + o.mu.Unlock() +} + +// Reconstruct replaces process gauges from the authoritative durable registry. +// Operational counters remain process-local and are not reconstructed. +func (o *OperationsObserver) Reconstruct(records []EnvironmentRecord) { + if o == nil { + return + } + o.mu.Lock() + o.snapshot.ActiveVMs = 0 + o.snapshot.BootingVMs = 0 + o.snapshot.ResourceLimits = ResourceLimits{} + o.records = make(map[string]observedEnvironment, len(records)) + for _, record := range records { + next := observedFromRecord(record) + if record.Ref.ID == "" || (!next.active && !next.booting) { + continue + } + o.records[record.Ref.ID] = next + o.addObserved(next) + } + o.mu.Unlock() +} + +func observedFromRecord(record EnvironmentRecord) observedEnvironment { + observed := observedEnvironment{resources: ResourceLimits{ + VCPUs: record.AdmissionUsage.CPU, MemoryBytes: record.AdmissionUsage.RAMBytes, DiskBytes: record.AdmissionUsage.DiskBytes, + }} + switch record.State { + case EnvironmentReady, EnvironmentDeleting: + observed.active = true + case EnvironmentProvisioning: + observed.booting = true + case EnvironmentCleanupPending: + observed.active = record.AdmissionUsage.ActiveVMs > 0 + observed.booting = !observed.active && record.AdmissionUsage.BootingVMs > 0 + } + return observed +} + +func (o *OperationsObserver) addObserved(observed observedEnvironment) { + if observed.active { + o.snapshot.ActiveVMs++ + } + if observed.booting { + o.snapshot.BootingVMs++ + } + o.addResources(observed.resources) +} + +func (o *OperationsObserver) removeObserved(observed observedEnvironment) { + if observed.active { + o.snapshot.ActiveVMs = max(o.snapshot.ActiveVMs-1, 0) + } + if observed.booting { + o.snapshot.BootingVMs = max(o.snapshot.BootingVMs-1, 0) + } + o.subtractResources(observed.resources) +} + +// LifecycleRequestFailed retains only closed request metadata and a classified +// cause. Lifecycle errors may contain paths, bindings, or credentials. +func (o *OperationsObserver) LifecycleRequestFailed(err error) { + if o == nil || err == nil { + return + } + operation, code := LifecycleOperation("connection"), "failed_precondition" + var serveErr *lifecycleServeError + if errors.As(err, &serveErr) { + operation, code = serveErr.operation, serveErr.code + } + args := []any{"operation", safeLifecycleOperation(operation), "code", safeLifecycleErrorCode(code), "detail", safeLifecycleErrorDetail(err)} + var logicalErr *repositoryLogicalStageError + if errors.As(err, &logicalErr) { + args = append(args, "stage", safeRepositoryLogicalStage(logicalErr.stage)) + } + o.diag.Log(context.Background(), port.LevelWarn, "microvmd lifecycle request failed", args...) +} + +func safeRepositoryLogicalStage(stage repositoryLogicalStage) string { + switch stage { + case repositoryLogicalStageEnsure, repositoryLogicalStageIdentity, repositoryLogicalStageAllocate, + repositoryLogicalStageReserve, repositoryLogicalStagePrepare, repositoryLogicalStageRegister: + return string(stage) + default: + return "invalid" + } +} + +func safeLifecycleOperation(operation LifecycleOperation) string { + switch operation { + case "connection", LifecycleInfo, LifecycleCreate, LifecycleResolve, LifecycleInspect, LifecycleDetach, + LifecycleDelete, LifecycleWorkspace, LifecycleExec, LifecycleFork, LifecycleMerge, LifecycleMetrics, + LifecycleInventory, LifecycleReconcile, LifecycleChildDelete: + return string(operation) + default: + return "unknown" + } +} + +func safeLifecycleErrorCode(code string) string { + switch code { + case "unauthenticated", "binding_mismatch", "not_found", "destroyed", "unavailable", "repository_logical_root_unavailable", "failed_precondition", "transport": + return code + default: + return "unknown" + } +} + +func safeLifecycleErrorDetail(err error) string { + switch { + case errors.Is(err, syscall.EDQUOT): + return "disk quota exceeded" + case errors.Is(err, syscall.ENOSPC): + return "insufficient disk space" + case errors.Is(err, fs.ErrPermission), errors.Is(err, syscall.EPERM): + return "permission denied" + case errors.Is(err, context.DeadlineExceeded): + return "operation timed out" + case errors.Is(err, context.Canceled): + return "operation cancelled" + } + text := strings.ToLower(err.Error()) + for phrase, detail := range map[string]string{ + "disk quota exceeded": "disk quota exceeded", + "no space left on device": "insufficient disk space", + "operation not permitted": "permission denied", + "permission denied": "permission denied", + } { + if strings.Contains(text, phrase) { + return detail + } + } + return "backend detail withheld" +} + +// VMBooting records one generation entering the bounded boot set. +func (o *OperationsObserver) VMBooting(resources ResourceLimits) { + o.mu.Lock() + defer o.mu.Unlock() + o.snapshot.BootingVMs++ + o.addResources(resources) +} + +// BootFinished observes boot latency without changing durable-state gauges. +func (o *OperationsObserver) BootFinished(latency time.Duration) { + o.mu.Lock() + o.snapshot.BootLatency.observe(latency) + o.mu.Unlock() + o.diag.Log(context.Background(), port.LevelInfo, "microvm became ready") +} + +// VMReady moves one generation from booting to active and observes boot latency. +func (o *OperationsObserver) VMReady(latency time.Duration, _ ResourceLimits) { + o.mu.Lock() + if o.snapshot.BootingVMs > 0 { + o.snapshot.BootingVMs-- + } + o.snapshot.ActiveVMs++ + o.snapshot.BootLatency.observe(latency) + o.mu.Unlock() + o.diag.Log(context.Background(), port.LevelInfo, "microvm became ready") +} + +// VMStopped removes a generation and its resources from the active gauges. +func (o *OperationsObserver) VMStopped(resources ResourceLimits) { + o.mu.Lock() + defer o.mu.Unlock() + if o.snapshot.ActiveVMs > 0 { + o.snapshot.ActiveVMs-- + } else if o.snapshot.BootingVMs > 0 { + o.snapshot.BootingVMs-- + } + o.subtractResources(resources) +} + +// ExecFinished counts a guest exec without retaining command content. +func (o *OperationsObserver) ExecFinished(outcome Outcome, _ string) { + if !validOutcome(outcome) { + return + } + o.mu.Lock() + o.snapshot.Execs++ + o.mu.Unlock() + if outcome == OutcomeFailure { + o.diag.Log(context.Background(), port.LevelWarn, "microvm exec failed", "outcome", outcome.String()) + } +} + +// trackEgressDenials adds a live cumulative denial source. Its current total is +// the baseline, so a daemon restart does not fabricate historical process metrics. +func (o *OperationsObserver) trackEgressDenials(generation string, source egressDenialSource) { + if o == nil || generation == "" || source == nil { + return + } + o.mu.Lock() + defer o.mu.Unlock() + o.sampleEgressSource(generation) + o.egressSources[generation] = observedEgressSource{source: source, last: source.EgressDenials()} +} + +// untrackEgressDenials records the final delta and removes a generation's source. +func (o *OperationsObserver) untrackEgressDenials(generation string) { + if o == nil || generation == "" { + return + } + o.mu.Lock() + defer o.mu.Unlock() + o.sampleEgressSource(generation) + delete(o.egressSources, generation) +} + +func (o *OperationsObserver) sampleEgressSource(generation string) { + tracked, ok := o.egressSources[generation] + if !ok { + return + } + current := tracked.source.EgressDenials() + if current >= tracked.last { + o.snapshot.EgressDenials += current - tracked.last + } + tracked.last = current + o.egressSources[generation] = tracked +} + +func (o *OperationsObserver) sampleEgressSources() { + for generation := range o.egressSources { + o.sampleEgressSource(generation) + } +} + +// EgressDenials counts denied guest packets without retaining destinations. +func (o *OperationsObserver) EgressDenials(count uint64) { + if o == nil || count == 0 { + return + } + o.mu.Lock() + o.snapshot.EgressDenials += count + o.mu.Unlock() + o.diag.Log(context.Background(), port.LevelWarn, "microvm guest egress denied") +} + +// EgressDenied counts a guest-network denial without retaining its destination. +func (o *OperationsObserver) EgressDenied(_ string) { + o.EgressDenials(1) +} + +// ArtifactVerification records verification by the closed artifact-kind dimension. +func (o *OperationsObserver) ArtifactVerification(kind ArtifactKind, outcome Outcome) { + if !validArtifactKind(kind) || !validOutcome(outcome) { + return + } + o.mu.Lock() + o.snapshot.ArtifactVerifications[kind][outcome]++ + o.mu.Unlock() + o.diag.Log(context.Background(), levelFor(outcome), "microvm artifact verification finished", "artifact_kind", string(kind), "outcome", outcome.String()) +} + +// DetachFinished reports process-local data-plane release; durable VM gauges remain unchanged. +func (o *OperationsObserver) DetachFinished(outcome Outcome) { + if o == nil || !validOutcome(outcome) { + return + } + o.diag.Log(context.Background(), levelFor(outcome), "microvm detach finished", "outcome", outcome.String()) +} + +// CleanupFinished records lifecycle cleanup. +func (o *OperationsObserver) CleanupFinished(outcome Outcome) { + if !validOutcome(outcome) { + return + } + o.mu.Lock() + o.snapshot.Cleanups[outcome]++ + o.mu.Unlock() + o.diag.Log(context.Background(), levelFor(outcome), "microvm cleanup finished", "outcome", outcome.String()) +} + +// ReconciliationFinished records one reconciliation pass. +func (o *OperationsObserver) ReconciliationFinished(outcome Outcome) { + if !validOutcome(outcome) { + return + } + o.mu.Lock() + o.snapshot.Reconciliations[outcome]++ + o.mu.Unlock() + o.diag.Log(context.Background(), levelFor(outcome), "microvm reconciliation finished", "outcome", outcome.String()) +} + +// QuotaRejected records a rejection by the closed resource quota dimension. +func (o *OperationsObserver) QuotaRejected(quota QuotaKind) { + if quota.String() == "unknown" { + return + } + o.mu.Lock() + o.snapshot.QuotaRejections[quota]++ + o.mu.Unlock() + o.diag.Log(context.Background(), port.LevelWarn, "microvm quota rejected request", "quota", quota.String()) +} + +// Snapshot returns a deep copy suitable for a metrics exporter. +func (o *OperationsObserver) Snapshot() OperationsSnapshot { + o.mu.Lock() + defer o.mu.Unlock() + o.sampleEgressSources() + result := o.snapshot + result.ArtifactVerifications = make(map[ArtifactKind]map[Outcome]uint64, len(o.snapshot.ArtifactVerifications)) + for kind, outcomes := range o.snapshot.ArtifactVerifications { + result.ArtifactVerifications[kind] = map[Outcome]uint64{OutcomeSuccess: outcomes[OutcomeSuccess], OutcomeFailure: outcomes[OutcomeFailure]} + } + result.Cleanups = map[Outcome]uint64{OutcomeSuccess: o.snapshot.Cleanups[OutcomeSuccess], OutcomeFailure: o.snapshot.Cleanups[OutcomeFailure]} + result.Reconciliations = map[Outcome]uint64{OutcomeSuccess: o.snapshot.Reconciliations[OutcomeSuccess], OutcomeFailure: o.snapshot.Reconciliations[OutcomeFailure]} + result.QuotaRejections = make(map[QuotaKind]uint64, len(o.snapshot.QuotaRejections)) + for quota, count := range o.snapshot.QuotaRejections { + result.QuotaRejections[quota] = count + } + return result +} + +func (o *OperationsObserver) addResources(resources ResourceLimits) { + o.snapshot.ResourceLimits.VCPUs += max(resources.VCPUs, 0) + o.snapshot.ResourceLimits.MemoryBytes += max(resources.MemoryBytes, 0) + o.snapshot.ResourceLimits.DiskBytes += max(resources.DiskBytes, 0) +} + +func (o *OperationsObserver) subtractResources(resources ResourceLimits) { + o.snapshot.ResourceLimits.VCPUs = max(o.snapshot.ResourceLimits.VCPUs-max(resources.VCPUs, 0), 0) + o.snapshot.ResourceLimits.MemoryBytes = max(o.snapshot.ResourceLimits.MemoryBytes-max(resources.MemoryBytes, 0), 0) + o.snapshot.ResourceLimits.DiskBytes = max(o.snapshot.ResourceLimits.DiskBytes-max(resources.DiskBytes, 0), 0) +} + +func (d *DurationMetric) observe(value time.Duration) { + if value < 0 { + value = 0 + } + d.Count++ + d.Sum += value + for i, bound := range [...]time.Duration{100 * time.Millisecond, 250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2500 * time.Millisecond, 5 * time.Second, 10 * time.Second, 30 * time.Second} { + if value <= bound { + d.Buckets[i]++ + } + } +} + +func validOutcome(outcome Outcome) bool { + return outcome == OutcomeSuccess || outcome == OutcomeFailure +} + +func validArtifactKind(kind ArtifactKind) bool { + return kind == ArtifactRuntime || kind == ArtifactFirmware || kind == ArtifactExecutionImage || kind == ArtifactGuestAgent +} + +func levelFor(outcome Outcome) port.Level { + if outcome == OutcomeSuccess { + return port.LevelInfo + } + return port.LevelWarn +} + +// ReadinessCheck is the closed doctor check set. +type ReadinessCheck string + +// Doctor readiness checks cover every prerequisite and stale-resource condition. +const ( + CheckHypervisor ReadinessCheck = "hypervisor" + CheckRuntime ReadinessCheck = "runtime-artifact" + CheckFirmware ReadinessCheck = "firmware-artifact" + CheckControlSocket ReadinessCheck = "control-socket" + CheckNetwork ReadinessCheck = "network-provider" + CheckProfiles ReadinessCheck = "profiles" + CheckStaleResources ReadinessCheck = "stale-resources" +) + +// ReadinessStatus is the closed doctor result status. +type ReadinessStatus string + +// Readiness result statuses are a closed operator-facing vocabulary. +const ( + ReadinessPass ReadinessStatus = "PASS" + ReadinessWarn ReadinessStatus = "WARN" + ReadinessFail ReadinessStatus = "FAIL" +) + +// ReadinessChecker supplies platform and configured-runtime probes to Doctor. +type ReadinessChecker interface { + Check(context.Context, ReadinessCheck) error + Profiles(context.Context) ([]string, error) + StaleResources(context.Context) (int, error) +} + +// ReadinessResult is one actionable doctor finding. +type ReadinessResult struct { + Check ReadinessCheck + Status ReadinessStatus + Detail string + Remediation string +} + +// ReadinessReport is the stable ordered doctor output. +type ReadinessReport struct{ Results []ReadinessResult } + +// Doctor checks whether the configured microVM runtime can safely accept work. +type Doctor struct{ checker ReadinessChecker } + +// NewDoctor constructs an operator readiness path over platform-specific probes. +func NewDoctor(checker ReadinessChecker) *Doctor { return &Doctor{checker: checker} } + +// Run executes every readiness probe; one failure never hides later findings. +func (d *Doctor) Run(ctx context.Context) ReadinessReport { + if d == nil || d.checker == nil { + return ReadinessReport{Results: []ReadinessResult{{Check: CheckHypervisor, Status: ReadinessFail, Detail: "doctor is not configured", Remediation: "configure the microVM runtime and rerun doctor"}}} + } + results := make([]ReadinessResult, 0, 7) + for _, check := range []ReadinessCheck{CheckHypervisor, CheckRuntime, CheckFirmware, CheckControlSocket, CheckNetwork} { + result := ReadinessResult{Check: check, Status: ReadinessPass, Detail: "ready", Remediation: remediation(check)} + if err := d.checker.Check(ctx, check); err != nil { + result.Status, result.Detail = ReadinessFail, oneLine(err.Error()) + } + results = append(results, result) + } + profiles, err := d.checker.Profiles(ctx) + profileResult := ReadinessResult{Check: CheckProfiles, Status: ReadinessPass, Remediation: remediation(CheckProfiles)} + if err != nil { + profileResult.Status, profileResult.Detail = ReadinessFail, oneLine(err.Error()) + } else if len(profiles) == 0 { + profileResult.Status, profileResult.Detail = ReadinessFail, "no environment profiles available" + } else { + profileResult.Detail = fmt.Sprintf("%d profiles available", len(profiles)) + } + results = append(results, profileResult) + stale, err := d.checker.StaleResources(ctx) + staleResult := ReadinessResult{Check: CheckStaleResources, Status: ReadinessPass, Detail: "no stale resources", Remediation: remediation(CheckStaleResources)} + if err != nil { + staleResult.Status, staleResult.Detail = ReadinessFail, oneLine(err.Error()) + } else if stale > 0 { + staleResult.Status, staleResult.Detail = ReadinessWarn, fmt.Sprintf("%d stale resources require reconciliation", stale) + } + results = append(results, staleResult) + return ReadinessReport{Results: results} +} + +// Result returns the named doctor result. +func (r ReadinessReport) Result(check ReadinessCheck) (ReadinessResult, bool) { + for _, result := range r.Results { + if result.Check == check { + return result, true + } + } + return ReadinessResult{}, false +} + +// Ready reports whether every mandatory check passed; stale-resource warnings do not block readiness. +func (r ReadinessReport) Ready() bool { + if len(r.Results) == 0 { + return false + } + for _, result := range r.Results { + if result.Status == ReadinessFail { + return false + } + } + return true +} + +// String renders stable line-oriented operator output. +func (r ReadinessReport) String() string { + var output strings.Builder + for _, result := range r.Results { + _, _ = fmt.Fprintf(&output, "%s %-18s %s; remediation: %s\n", result.Status, result.Check, result.Detail, result.Remediation) + } + return output.String() +} + +func remediation(check ReadinessCheck) string { + switch check { + case CheckHypervisor: + return "grant this account access to /dev/kvm on Linux or enable Hypervisor.framework on macOS, then rerun doctor" + case CheckRuntime: + return "install the pinned runtime artifact and verify its configured digest, signature, and attestation" + case CheckFirmware: + return "verify the pinned firmware digest, signature, attestation, and policy revision; replace stale cache entries" + case CheckControlSocket: + return "start mecatl-microvmd as the same local account and correct the private socket owner and mode" + case CheckNetwork: + return "install and configure the selected hosted network provider and confirm guest IPv6 can be disabled" + case CheckProfiles: + return "define at least one operator-owned environment profile with immutable artifacts, quotas, and guest egress policy" + case CheckStaleResources: + return "run reconciliation, inspect retained dirty worktrees, and retry failed deletion checkpoints" + default: + return "inspect the microVM daemon configuration and rerun doctor" + } +} + +func oneLine(value string) string { return strings.Join(strings.Fields(value), " ") } diff --git a/environment/microvm/operations_test.go b/environment/microvm/operations_test.go new file mode 100644 index 000000000..cd903bd3d --- /dev/null +++ b/environment/microvm/operations_test.go @@ -0,0 +1,267 @@ +package microvm + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/stacklok/mecatl/engine/port" +) + +func TestLifecycleRequestFailureDiagnosticIsClassifiedAndSecretFree(t *testing.T) { + t.Parallel() + diagnostics := &captureDiagnostics{} + observer := NewOperationsObserver(diagnostics) + const secret = "OPENROUTER_API_KEY=sk-secret /private/state\nforged=value" + + observer.LifecycleRequestFailed(newLifecycleServeError(LifecycleCreate, "failed_precondition", + fmt.Errorf("prepare owner: operation not permitted: %s", secret))) + observer.LifecycleRequestFailed(newLifecycleServeError(LifecycleCreate, "failed_precondition", + repositoryLogicalFailure(repositoryLogicalStagePrepare, errors.New(secret)))) + observer.LifecycleRequestFailed(newLifecycleServeError(LifecycleOperation("create\n"+secret), "code="+secret, errors.New(secret))) + + got := diagnostics.String() + for _, want := range []string{"microvmd lifecycle request failed", "create", "failed_precondition", "permission denied", "prepare", "unknown", "backend detail withheld"} { + if !strings.Contains(got, want) { + t.Fatalf("diagnostic %q does not contain %q", got, want) + } + } + for _, forbidden := range []string{secret, "OPENROUTER_API_KEY", "sk-secret", "/private/state", "forged=value"} { + if strings.Contains(got, forbidden) { + t.Fatalf("diagnostic leaked backend detail %q: %s", forbidden, got) + } + } +} + +func TestMicroVMEnvironments_Scenario8_ObservabilityIsBoundedAndSecretFree(t *testing.T) { + t.Parallel() + diagnostics := &captureDiagnostics{} + observer := NewOperationsObserver(diagnostics) + secret := "OPENROUTER_API_KEY=sk-secret command=curl https://private.invalid" + + observer.VMBooting(ResourceLimits{VCPUs: 2, MemoryBytes: 2 << 30, DiskBytes: 20 << 30}) + observer.VMReady(1750*time.Millisecond, ResourceLimits{VCPUs: 2, MemoryBytes: 2 << 30, DiskBytes: 20 << 30}) + observer.ExecFinished(OutcomeSuccess, secret) + observer.EgressDenied(secret) + observer.ArtifactVerification(ArtifactRuntime, OutcomeSuccess) + observer.ArtifactVerification(ArtifactFirmware, OutcomeFailure) + observer.CleanupFinished(OutcomeSuccess) + observer.ReconciliationFinished(OutcomeFailure) + observer.QuotaRejected(QuotaVMs) + observer.ArtifactVerification(ArtifactKind("session-secret"), OutcomeSuccess) + observer.QuotaRejected(QuotaKind(255)) + + snapshot := observer.Snapshot() + if snapshot.ActiveVMs != 1 || snapshot.BootingVMs != 0 || snapshot.Execs != 1 || snapshot.EgressDenials != 1 || snapshot.QuotaRejections[QuotaVMs] != 1 { + t.Fatalf("missing lifecycle metrics: %+v", snapshot) + } + if snapshot.ResourceLimits != (ResourceLimits{VCPUs: 2, MemoryBytes: 2 << 30, DiskBytes: 20 << 30}) { + t.Fatalf("resource gauges = %+v", snapshot.ResourceLimits) + } + if snapshot.BootLatency.Count != 1 || snapshot.BootLatency.Sum != 1750*time.Millisecond { + t.Fatalf("boot latency = %+v", snapshot.BootLatency) + } + if len(snapshot.ArtifactVerifications) != 4 || len(snapshot.QuotaRejections) != 10 || len(snapshot.BootLatency.Buckets) != 8 { + t.Fatalf("metric dimensions are not fixed: %+v", snapshot) + } + if snapshot.ArtifactVerifications[ArtifactRuntime][OutcomeSuccess] != 1 || snapshot.ArtifactVerifications[ArtifactFirmware][OutcomeFailure] != 1 || snapshot.Cleanups[OutcomeSuccess] != 1 || snapshot.Reconciliations[OutcomeFailure] != 1 { + t.Fatalf("missing bounded outcomes: %+v", snapshot) + } + + exposed := fmt.Sprintf("%+v %s", snapshot, diagnostics.String()) + if strings.Contains(exposed, secret) || strings.Contains(exposed, "sk-secret") || strings.Contains(exposed, "private.invalid") { + t.Fatalf("observability leaked command or credential: %s", exposed) + } + for _, record := range diagnostics.records { + for i := 0; i < len(record.args); i += 2 { + key, _ := record.args[i].(string) + if key != "outcome" && key != "artifact_kind" && key != "quota" { + t.Fatalf("unbounded diagnostic key %q in %+v", key, record) + } + } + } + + response := (&Daemon{observer: observer}).handleAuthenticated(context.Background(), LifecycleRequest{ + Version: LifecycleProtocolVersion, Operation: LifecycleMetrics, + }) + if response.Err != nil { + t.Fatalf("metrics operation: %v", response.Err) + } + var exported OperationsSnapshot + if err := json.Unmarshal(response.Payload, &exported); err != nil { + t.Fatalf("decode exported metrics: %v", err) + } + if exported.Execs != snapshot.Execs || exported.EgressDenials != snapshot.EgressDenials || len(exported.QuotaRejections) != len(snapshot.QuotaRejections) { + t.Fatalf("daemon metrics export drifted: exported=%+v snapshot=%+v", exported, snapshot) + } +} + +func TestInvariant_microvm_observer_transition_table_is_idempotent(t *testing.T) { + t.Parallel() + observer := NewOperationsObserver(nil) + usage := ResourceUsage{CPU: 2, RAMBytes: 2 << 30, DiskBytes: 20 << 30, BootingVMs: 1, Worktrees: 1} + record := readyRecord("observed", 1) + record.State = EnvironmentProvisioning + record.AdmissionUsage = usage + + observer.ObserveRecord(record) + observer.ObserveRecord(record) + assertOperationsGauges(t, observer.Snapshot(), 0, 1, ResourceLimits{VCPUs: 2, MemoryBytes: 2 << 30, DiskBytes: 20 << 30}) + + record.State = EnvironmentReady + record.AdmissionUsage.BootingVMs = 0 + record.AdmissionUsage.ActiveVMs = 1 + observer.ObserveRecord(record) + observer.ObserveRecord(record) + assertOperationsGauges(t, observer.Snapshot(), 1, 0, ResourceLimits{VCPUs: 2, MemoryBytes: 2 << 30, DiskBytes: 20 << 30}) + + record.State = EnvironmentDeleting + observer.ObserveRecord(record) + assertOperationsGauges(t, observer.Snapshot(), 1, 0, ResourceLimits{VCPUs: 2, MemoryBytes: 2 << 30, DiskBytes: 20 << 30}) + + record.State = EnvironmentDestroyed + observer.ObserveRecord(record) + observer.ObserveRecord(record) + assertOperationsGauges(t, observer.Snapshot(), 0, 0, ResourceLimits{}) +} + +func TestInvariant_microvm_observer_reconstructs_durable_gauges(t *testing.T) { + t.Parallel() + observer := NewOperationsObserver(nil) + ready := readyRecord("ready", 1) + ready.AdmissionUsage = ResourceUsage{CPU: 4, RAMBytes: 8 << 30, DiskBytes: 40 << 30, ActiveVMs: 1} + booting := readyRecord("booting", 1) + booting.State = EnvironmentProvisioning + booting.AdmissionUsage = ResourceUsage{CPU: 2, RAMBytes: 3 << 30, DiskBytes: 10 << 30, BootingVMs: 1} + destroyed := readyRecord("destroyed", 1) + destroyed.State = EnvironmentDestroyed + destroyed.AdmissionUsage = ResourceUsage{CPU: 99, RAMBytes: 99, DiskBytes: 99, ActiveVMs: 1} + + observer.Reconstruct([]EnvironmentRecord{ready, booting, destroyed}) + observer.Reconstruct([]EnvironmentRecord{ready, booting, destroyed}) + assertOperationsGauges(t, observer.Snapshot(), 1, 1, ResourceLimits{VCPUs: 6, MemoryBytes: 11 << 30, DiskBytes: 50 << 30}) +} + +func TestInvariant_microvm_production_egress_denials_are_counted(t *testing.T) { + t.Parallel() + observer := NewOperationsObserver(nil) + provider := &fakeNetworkProvider{denials: 3} + observer.trackEgressDenials("env@1", provider) + if got := observer.Snapshot().EgressDenials; got != 0 { + t.Fatalf("initial cumulative provider total counted as new denials: %d", got) + } + if got := observer.Snapshot().EgressDenials; got != 0 { + t.Fatalf("repeated baseline sample changed denials: %d", got) + } + + provider.denials++ + if got := observer.Snapshot().EgressDenials; got != 1 { + t.Fatalf("one additional provider denial = %d, want 1", got) + } + if got := observer.Snapshot().EgressDenials; got != 1 { + t.Fatalf("repeated live sample double-counted denial: %d", got) + } + + observer.untrackEgressDenials("env@1") + if got := observer.Snapshot().EgressDenials; got != 1 { + t.Fatalf("final sampling re-added cumulative provider total: %d", got) + } +} + +func TestInvariant_microvm_egress_denial_restart_is_truthful(t *testing.T) { + t.Parallel() + provider := &fakeNetworkProvider{denials: 7} + restarted := NewOperationsObserver(nil) + restarted.trackEgressDenials("env@1", provider) + if got := restarted.Snapshot().EgressDenials; got != 0 { + t.Fatalf("restart fabricated historical process counter = %d", got) + } + provider.denials = 9 + if got := restarted.Snapshot().EgressDenials; got != 2 { + t.Fatalf("post-restart denial delta = %d, want 2", got) + } +} + +func assertOperationsGauges(t *testing.T, snapshot OperationsSnapshot, active, booting int64, resources ResourceLimits) { + t.Helper() + if snapshot.ActiveVMs != active || snapshot.BootingVMs != booting || snapshot.ResourceLimits != resources { + t.Fatalf("gauges = active:%d booting:%d resources:%+v, want active:%d booting:%d resources:%+v", snapshot.ActiveVMs, snapshot.BootingVMs, snapshot.ResourceLimits, active, booting, resources) + } +} + +func TestMicroVMEnvironments_Scenario8_DoctorReportsActionableReadiness(t *testing.T) { + t.Parallel() + checker := readinessFixture{ + errors: map[ReadinessCheck]error{ + CheckFirmware: errors.New("firmware signature rejected"), + CheckNetwork: errors.New("hosted network unavailable"), + }, + profiles: []string{"locked-down", "build"}, + stale: 2, + } + report := NewDoctor(checker).Run(context.Background()) + + for _, name := range []ReadinessCheck{CheckHypervisor, CheckRuntime, CheckFirmware, CheckControlSocket, CheckNetwork, CheckProfiles, CheckStaleResources} { + result, ok := report.Result(name) + if !ok { + t.Fatalf("doctor omitted %s: %+v", name, report) + } + if result.Remediation == "" { + t.Fatalf("doctor result %s has no actionable remediation", name) + } + } + if report.Ready() { + t.Fatal("doctor reported ready despite failed firmware and network checks") + } + if result, _ := report.Result(CheckProfiles); result.Status != ReadinessPass || !strings.Contains(result.Detail, "2 profiles available") { + t.Fatalf("profile readiness = %+v", result) + } + if result, _ := report.Result(CheckStaleResources); result.Status != ReadinessWarn || !strings.Contains(result.Detail, "2 stale resources") { + t.Fatalf("stale-resource readiness = %+v", result) + } + if text := report.String(); !strings.Contains(text, "verify the pinned firmware digest") || !strings.Contains(text, "run reconciliation") { + t.Fatalf("doctor output is not actionable:\n%s", text) + } +} + +type readinessFixture struct { + errors map[ReadinessCheck]error + profiles []string + stale int +} + +func (f readinessFixture) Check(_ context.Context, check ReadinessCheck) error { + return f.errors[check] +} +func (f readinessFixture) Profiles(context.Context) ([]string, error) { return f.profiles, nil } +func (f readinessFixture) StaleResources(context.Context) (int, error) { return f.stale, nil } + +type diagnosticRecord struct { + level port.Level + msg string + args []any +} + +type captureDiagnostics struct { + mu sync.Mutex + records []diagnosticRecord +} + +func (d *captureDiagnostics) Log(_ context.Context, level port.Level, msg string, args ...any) { + d.mu.Lock() + defer d.mu.Unlock() + d.records = append(d.records, diagnosticRecord{level: level, msg: msg, args: append([]any(nil), args...)}) +} + +func (d *captureDiagnostics) With(...any) port.Diagnostics { return d } + +func (d *captureDiagnostics) String() string { + d.mu.Lock() + defer d.mu.Unlock() + return fmt.Sprintf("%+v", d.records) +} diff --git a/environment/microvm/platform_e2e_test.go b/environment/microvm/platform_e2e_test.go new file mode 100644 index 000000000..27e24ac96 --- /dev/null +++ b/environment/microvm/platform_e2e_test.go @@ -0,0 +1,439 @@ +//go:build microvm_e2e + +package microvm + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/stacklok/go-microvm/extract" + + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/control/controltest" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +const e2eTimeout = 4 * time.Minute + +func TestMicroVMEnvironments_Scenario8_HostSecretAndSiblingIsolation(t *testing.T) { + assertSupportedE2EPlatform(t) + artifacts := loadE2EArtifacts(t) + ctx, cancel := context.WithTimeout(context.Background(), e2eTimeout) + defer cancel() + + root := t.TempDir() + source := initE2ERepository(t, root) + for _, dir := range []string{filepath.Join(root, "worktrees"), filepath.Join(root, "metadata")} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + secretRoot := filepath.Join(root, "host-only") + if err := os.Mkdir(secretRoot, 0o700); err != nil { + t.Fatal(err) + } + secretFiles := map[string]string{ + "provider-credential": "provider-secret-e2e", "mcp-credential": "mcp-secret-e2e", + "identity-credential": "identity-secret-e2e", "registry-credential": "registry-secret-e2e", + } + for name, value := range secretFiles { + if err := os.WriteFile(filepath.Join(secretRoot, name), []byte(value), 0o600); err != nil { + t.Fatal(err) + } + } + controlSocket := filepath.Join(secretRoot, "microvmd.sock") + controlListener, err := net.ListenUnix("unix", &net.UnixAddr{Name: controlSocket, Net: "unix"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controlListener.Close() }) + t.Setenv("OPENROUTER_API_KEY", "provider-env-secret-e2e") + t.Setenv("MCP_TOKEN", "mcp-env-secret-e2e") + t.Setenv("GH_TOKEN", "identity-env-secret-e2e") + + registry, err := OpenFileRegistry(filepath.Join(root, "state", "registry.json")) + if err != nil { + t.Fatal(err) + } + placements, err := NewFileSessionPersister(filepath.Join(root, "state", "placements.json")) + if err != nil { + t.Fatal(err) + } + worktrees := NewGitWorktrees() + runtimeAdapter, err := NewGoMicroVMRuntime(GoMicroVMRuntimeConfig{ + Backend: NewLibkrunBackend(), Network: NewHostedNetworkController(e2eGuestNetwork{}), + GuestEgress: GuestEgressPolicy{Mode: EgressAllowlist, Allow: []EgressDestination{{Hostname: "example.com", Port: 80, Protocol: ProtocolTCP}}}, + UnixGuestEndpoint: true, CapabilityKey: e2eCapabilityKey, + }) + if err != nil { + t.Fatal(err) + } + endpointDir := shortE2EEndpointDir(t) + lifecycle := NewLifecycle(LifecycleDeps{ + Identities: IdentitySequence{EndpointDir: endpointDir}, + Worktrees: worktrees, Artifacts: e2eArtifactVerifier{artifacts: artifacts}, VMs: runtimeAdapter, + Protocol: runtimeAdapter, Registry: registry, Sessions: placements, + }) + + type createdResult struct { + created CreatedEnvironment + err error + } + results := make(chan createdResult, 2) + for index := range 2 { + go func() { + request := CreateRequest{ + Owner: "e2e-owner", SessionID: fmt.Sprintf("e2e-session-%d", index), Profile: "e2e", + Worktree: worktree.Request{Source: source, WorktreePath: filepath.Join(root, "worktrees", strconv.Itoa(index)), MetadataPath: filepath.Join(root, "metadata", strconv.Itoa(index)), Branch: fmt.Sprintf("mecatl/e2e-%d", index)}, + ArtifactRequests: e2eArtifactRequests(artifacts), Resources: ResourceUsage{CPU: 1, RAMBytes: 256 << 20}, + } + created, createErr := lifecycle.Create(ctx, request) + results <- createdResult{created: created, err: createErr} + }() + } + created := make([]CreatedEnvironment, 2) + for i := range created { + result := <-results + if result.err != nil { + t.Fatalf("concurrent environment %d creation: %v", i, result.err) + } + created[i] = result.created + if created[i].Ref.ID == "" { + t.Fatalf("concurrent environment %d was not created", i) + } + } + if created[0].Ref == created[1].Ref || created[0].HostWorktree == created[1].HostWorktree { + t.Fatalf("concurrent sessions collided: %+v %+v", created[0], created[1]) + } + + records := make([]EnvironmentRecord, 2) + services := make([]*e2eServices, 2) + for i, item := range created { + environmentID, _, parseErr := parseEnvironmentRef(item.Ref) + if parseErr != nil { + t.Fatal(parseErr) + } + records[i], err = registry.Lookup(ctx, environmentID) + if err != nil { + t.Fatal(err) + } + guestServices, serviceErr := runtimeAdapter.Services(item.Ref) + if serviceErr != nil { + t.Fatal(serviceErr) + } + services[i] = &e2eServices{Workspace: guestServices.Workspace, Runner: guestServices.Runner} + } + + primary, sibling := services[0], records[1] + data, version, err := primary.Workspace.ReadVersion(ctx, "tracked.txt") + if err != nil || string(data) != "base\n" { + t.Fatalf("positive workspace read = %q, %v", data, err) + } + primary.Workspace.RecordRead("tracked.txt", version) + var versionMismatch *tool.VersionMismatchError + if _, err := primary.Workspace.ReplaceFile(ctx, "tracked.txt", tool.NewFileVersion("stale"), []byte("bad\n")); !errors.As(err, &versionMismatch) { + t.Fatalf("stale Workspace CAS = %v, want version mismatch", err) + } + if _, err := primary.Workspace.ReplaceFile(ctx, "tracked.txt", version, []byte("guest-edit\n")); err != nil { + t.Fatalf("Workspace CAS replace: %v", err) + } + if got := string(mustRead(t, filepath.Join(records[0].WorktreePath, "tracked.txt"))); got != "guest-edit\n" { + t.Fatalf("virtiofs host visibility = %q", got) + } + + gitProbe, err := primary.Runner.Run(ctx, "git -C /workspace status --porcelain=v1 >/dev/null && test \"$(git -C /workspace rev-parse --git-dir)\" = /run/mecatl/git-metadata && cat /run/mecatl/git-metadata/HEAD") + if err != nil || gitProbe.ExitCode != 0 || !strings.Contains(gitProbe.Stdout, "refs/heads/mecatl/e2e-") { + t.Fatalf("guest Git did not consume /workspace-local metadata: %+v err=%v", gitProbe, err) + } + workloadProbe, err := primary.Runner.Run(ctx, "test \"$(id -u)\" -ne 0 && grep -q '^CapEff:[[:space:]]*0000000000000000$' /proc/self/status && printf workload-ok > workload-probe && cat workload-probe") + if err != nil || workloadProbe.ExitCode != 0 || workloadProbe.Stdout != "workload-ok" { + t.Fatalf("unprivileged workload positive control failed: %+v err=%v", workloadProbe, err) + } + assertGuestCommandDenied(t, ctx, primary.Runner, "guest capability key", "cat /etc/mecatl/guest-agent.json >/dev/null 2>&1") + assertGuestCommandDenied(t, ctx, primary.Runner, "guest IPv6 sysctl", "value=$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6) && printf %s \"$value\" > /proc/sys/net/ipv6/conf/all/disable_ipv6") + assertGuestCommandDenied(t, ctx, primary.Runner, "guest agent signal", "kill -0 1") + assertGuestCommandDenied(t, ctx, primary.Runner, "guest mount policy", "mkdir -p /workspace/.mount-probe && mount -t tmpfs none /workspace/.mount-probe && umount /workspace/.mount-probe") + streamer, ok := primary.Runner.(tool.CommandStreamer) + if !ok { + t.Fatal("microVM runner does not implement exec streaming") + } + var stream bytes.Buffer + exit, err := streamer.RunStreaming(ctx, "printf 'stdout-one\\n'; printf 'stderr-two\\n' >&2", &stream) + if err != nil || exit != 0 || !strings.Contains(stream.String(), "stdout-one") || !strings.Contains(stream.String(), "stderr-two") { + t.Fatalf("exec streaming = exit %d output %q err %v", exit, stream.String(), err) + } + cancelCtx, cancelExec := context.WithCancel(ctx) + cancelDone := make(chan error, 1) + go func() { + _, runErr := primary.Runner.Run(cancelCtx, "trap '' TERM; sleep 60 & wait") + cancelDone <- runErr + }() + time.AfterFunc(250*time.Millisecond, cancelExec) + select { + case cancelErr := <-cancelDone: + if !errors.Is(cancelErr, context.Canceled) { + t.Fatalf("cancelled exec = %v", cancelErr) + } + case <-time.After(5 * time.Second): + t.Fatal("cancelled exec did not terminate its guest process group") + } + + allowed, err := primary.Runner.Run(ctx, "wget -qO- http://example.com/") + if err != nil || allowed.ExitCode != 0 || !strings.Contains(allowed.Stdout, "Example Domain") { + t.Fatalf("allowed guest egress failed: %+v err=%v", allowed, err) + } + deniedCtx, deniedCancel := context.WithTimeout(ctx, 3*time.Second) + defer deniedCancel() + denied, _ := primary.Runner.Run(deniedCtx, "wget -T 2 -qO- http://example.org/") + if denied.ExitCode == 0 { + t.Fatalf("non-allowlisted guest egress succeeded: %+v", denied) + } + + siblingRead, err := primary.Runner.Run(ctx, "cat "+shellQuote(sibling.Binding.AssignedRoot+"/tracked.txt")) + if err != nil || siblingRead.ExitCode != 0 || siblingRead.Stdout != "base\n" { + t.Fatalf("same-repository Bash could not address sibling guest worktree: %+v err=%v", siblingRead, err) + } + for label, hostPath := range map[string]string{ + "provider credential": filepath.Join(secretRoot, "provider-credential"), "MCP credential": filepath.Join(secretRoot, "mcp-credential"), + "identity credential": filepath.Join(secretRoot, "identity-credential"), "registry credential": filepath.Join(secretRoot, "registry-credential"), + "unrelated host root": filepath.Join(source, "tracked.txt"), + "sibling worktree": filepath.Join(sibling.WorktreePath, "tracked.txt"), + } { + assertGuestCannotRead(t, ctx, primary.Runner, label, hostPath) + } + for label, hostPath := range map[string]string{"daemon control socket": controlSocket, "sibling guest endpoint": sibling.Endpoint} { + assertGuestCannotSee(t, ctx, primary.Runner, label, hostPath) + } + environment, err := primary.Runner.Run(ctx, "env") + if err != nil || environment.ExitCode != 0 { + t.Fatalf("guest env: %+v err=%v", environment, err) + } + for _, forbidden := range []string{"provider-env-secret-e2e", "mcp-env-secret-e2e", "identity-env-secret-e2e"} { + if strings.Contains(environment.Stdout, forbidden) { + t.Fatalf("host credential entered guest environment: %q", forbidden) + } + } + + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{Control: auth, Registry: registry, Runtime: runtimeAdapter, Worktrees: RetentionAdapter{Worktrees: worktrees}}) + if err != nil { + t.Fatal(err) + } + server, client := net.Pipe() + defer server.Close() + defer client.Close() + binding := bindingForRecord(records[0]) + if response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleDetach, Binding: binding}); response.Err != nil { + t.Fatalf("detach: %v", response.Err) + } + if response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleResolve, Binding: binding}); response.Err != nil { + t.Fatalf("reattach resolve: %v", response.Err) + } + if _, err := runtimeAdapter.Negotiate(ctx, records[0].Endpoint, binding); err != nil { + t.Fatalf("reattach negotiate: %v", err) + } + reattached, err := runtimeAdapter.Services(records[0].Ref) + if err != nil { + t.Fatal(err) + } + if result, runErr := reattached.Runner.Run(ctx, "cat tracked.txt"); runErr != nil || result.Stdout != "guest-edit\n" { + t.Fatalf("reattached exec = %+v err=%v", result, runErr) + } + + for _, record := range records { + response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleDelete, Binding: bindingForRecord(record)}) + if response.Err != nil || response.Record == nil || response.Record.State != EnvironmentDestroyed || !response.Record.Tombstone { + t.Fatalf("delete exact generation: %+v", response) + } + if _, statErr := os.Stat(record.Endpoint); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("deleted guest endpoint remains: %v", statErr) + } + } +} + +type e2eServices struct { + Workspace tool.Workspace + Runner tool.CommandRunner +} + +type e2eGuestNetwork struct{} + +func (e2eGuestNetwork) DisableIPv6(context.Context) error { return nil } + +func e2eCapabilityKey() ([]byte, error) { return bytes.Repeat([]byte{0x6d}, 32), nil } + +type e2eArtifactVerifier struct{ artifacts VerifiedArtifacts } + +func (v e2eArtifactVerifier) Verify(_ context.Context, _ map[ArtifactKind]ArtifactRequest) (VerifiedArtifacts, string, error) { + return v.artifacts, "go-microvm-v0.0.40", nil +} + +func assertGuestCommandDenied(t *testing.T, ctx context.Context, runner tool.CommandRunner, label, command string) { + t.Helper() + result, err := runner.Run(ctx, command) + if err != nil { + t.Fatalf("probe %s: %v", label, err) + } + if result.ExitCode == 0 { + t.Fatalf("model workload controls forbidden %s", label) + } +} + +func assertGuestCannotRead(t *testing.T, ctx context.Context, runner tool.CommandRunner, label, path string) { + t.Helper() + marker := "MECATL_FORBIDDEN_" + strings.ToUpper(strings.ReplaceAll(label, " ", "_")) + result, err := runner.Run(ctx, "if cat "+shellQuote(path)+" >/dev/null 2>&1; then printf "+shellQuote(marker)+"; exit 0; else exit 23; fi") + if err != nil { + t.Fatalf("probe %s: %v", label, err) + } + if result.ExitCode == 0 || strings.Contains(result.Stdout, marker) { + t.Fatalf("guest read forbidden %s at host path %q", label, path) + } +} + +func assertGuestCannotSee(t *testing.T, ctx context.Context, runner tool.CommandRunner, label, path string) { + t.Helper() + result, err := runner.Run(ctx, "test ! -e "+shellQuote(path)) + if err != nil { + t.Fatalf("probe %s: %v", label, err) + } + if result.ExitCode != 0 { + t.Fatalf("guest can resolve forbidden %s at host path %q", label, path) + } +} + +func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + +type e2eArtifactPaths struct { + runtime, firmware, rootfs, guestAgent string +} + +func loadE2EArtifacts(t *testing.T) VerifiedArtifacts { + t.Helper() + paths := e2eArtifactPaths{ + runtime: os.Getenv("MECATL_MICROVM_RUNTIME_DIR"), firmware: os.Getenv("MECATL_MICROVM_FIRMWARE_DIR"), + rootfs: os.Getenv("MECATL_MICROVM_ROOTFS_DIR"), guestAgent: os.Getenv("MECATL_MICROVM_GUEST_AGENT_DIR"), + } + for name, path := range map[string]string{"runtime": paths.runtime, "firmware": paths.firmware, "execution image": paths.rootfs, "guest agent": paths.guestAgent} { + if path == "" || !filepath.IsAbs(path) { + t.Fatalf("%s artifact is not configured by task e2e:microvm", name) + } + } + return VerifiedArtifacts{ + Runtime: VerifiedArtifact{Kind: ArtifactRuntime, Digest: "sha256:pinned-go-microvm-v0.0.40", Path: paths.runtime, Source: extract.Dir(paths.runtime)}, + Firmware: VerifiedArtifact{Kind: ArtifactFirmware, Digest: "sha256:pinned-go-microvm-v0.0.40", Path: paths.firmware, Source: extract.Dir(paths.firmware)}, + ExecutionImage: VerifiedArtifact{Kind: ArtifactExecutionImage, Digest: "sha256:pinned-alpine-3.22.1", Path: paths.rootfs, Source: extract.Dir(paths.rootfs)}, + GuestAgent: VerifiedArtifact{Kind: ArtifactGuestAgent, Digest: "sha256:independently-built-guest-agent", Path: paths.guestAgent, Source: extract.Dir(paths.guestAgent)}, + } +} + +func e2eArtifactRequests(artifacts VerifiedArtifacts) map[ArtifactKind]ArtifactRequest { + requests := make(map[ArtifactKind]ArtifactRequest, 4) + for _, artifact := range artifacts.All() { + requests[artifact.Kind] = ArtifactRequest{Kind: artifact.Kind, Reference: artifact.Path, Digest: artifact.Digest} + } + return requests +} + +func TestMicroVMEnvironments_PlatformSupportIsExplicit(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + goos, arch string + macMajor int + want bool + }{ + {goos: "linux", arch: "amd64", want: true}, + {goos: "linux", arch: "arm64", want: true}, + {goos: "darwin", arch: "arm64", macMajor: 15, want: true}, + {goos: "darwin", arch: "arm64", macMajor: 14, want: false}, + {goos: "darwin", arch: "amd64", macMajor: 15, want: false}, + {goos: "linux", arch: "386", want: false}, + } { + if got := supportedE2EPlatform(tc.goos, tc.arch, tc.macMajor); got != tc.want { + t.Errorf("supportedE2EPlatform(%s, %s, %d) = %v, want %v", tc.goos, tc.arch, tc.macMajor, got, tc.want) + } + } +} + +func supportedE2EPlatform(goos, arch string, macMajor int) bool { + return (goos == "linux" && (arch == "amd64" || arch == "arm64")) || (goos == "darwin" && arch == "arm64" && macMajor >= 15) +} + +func assertSupportedE2EPlatform(t *testing.T) { + t.Helper() + switch runtime.GOOS + "/" + runtime.GOARCH { + case "linux/amd64", "linux/arm64": + info, err := os.Stat("/dev/kvm") + if err != nil { + t.Fatalf("supported Linux E2E cell requires /dev/kvm: %v", err) + } + file, err := os.OpenFile("/dev/kvm", os.O_RDWR, 0) + if err != nil { + t.Fatalf("supported Linux E2E cell cannot access /dev/kvm: %v", err) + } + _ = file.Close() + _ = info + case "darwin/arm64": + output, err := exec.Command("sw_vers", "-productVersion").Output() + if err != nil { + t.Fatalf("read macOS version: %v", err) + } + major, err := strconv.Atoi(strings.Split(strings.TrimSpace(string(output)), ".")[0]) + if err != nil || !supportedE2EPlatform(runtime.GOOS, runtime.GOARCH, major) { + t.Fatalf("Apple Silicon microVM E2E requires macOS 15 or newer, got %q", output) + } + default: + t.Fatalf("unsupported microVM E2E platform %s/%s", runtime.GOOS, runtime.GOARCH) + } +} + +func shortE2EEndpointDir(t *testing.T) string { + t.Helper() + parent := "/dev/shm" + if runtime.GOOS == "darwin" { + parent = "/private/tmp" + } + dir, err := os.MkdirTemp(parent, "me-") + if err != nil { + t.Fatalf("create short microVM endpoint directory: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + +func initE2ERepository(t *testing.T, root string) string { + t.Helper() + source := filepath.Join(root, "source") + if err := os.MkdirAll(source, 0o700); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"init"}, {"config", "user.name", "MicroVM E2E"}, {"config", "user.email", "microvm-e2e@example.invalid"}} { + if output, err := exec.Command("git", append([]string{"-C", source}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + } + if err := os.WriteFile(filepath.Join(source, "tracked.txt"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"add", "tracked.txt"}, {"commit", "-m", "base"}} { + if output, err := exec.Command("git", append([]string{"-C", source}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + } + return source +} diff --git a/environment/microvm/process_identity_darwin.go b/environment/microvm/process_identity_darwin.go new file mode 100644 index 000000000..b1eeb706f --- /dev/null +++ b/environment/microvm/process_identity_darwin.go @@ -0,0 +1,25 @@ +//go:build darwin + +package microvm + +import ( + "context" + "fmt" + + "golang.org/x/sys/unix" +) + +func platformProcessStartIdentity(ctx context.Context, pid int) (string, error) { + if err := context.Cause(ctx); err != nil { + return "", err + } + info, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if err != nil { + return "", err + } + if info.Proc.P_pid != int32(pid) { + return "", ErrEnvironmentUnavailable + } + started := info.Proc.P_starttime + return fmt.Sprintf("%d.%06d", started.Sec, started.Usec), nil +} diff --git a/environment/microvm/process_identity_linux.go b/environment/microvm/process_identity_linux.go new file mode 100644 index 000000000..81235fd61 --- /dev/null +++ b/environment/microvm/process_identity_linux.go @@ -0,0 +1,34 @@ +//go:build linux + +package microvm + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "strings" +) + +func platformProcessStartIdentity(ctx context.Context, pid int) (string, error) { + if err := context.Cause(ctx); err != nil { + return "", err + } + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) + if err != nil { + return "", err + } + closeParen := strings.LastIndexByte(string(data), ')') + if closeParen < 0 { + return "", errors.New("malformed process stat identity") + } + fields := strings.Fields(string(data[closeParen+1:])) + if len(fields) <= 19 { + return "", errors.New("process stat identity omits start time") + } + if _, err := strconv.ParseUint(fields[19], 10, 64); err != nil { + return "", errors.New("invalid process start time") + } + return fields[19], nil +} diff --git a/environment/microvm/reconcile.go b/environment/microvm/reconcile.go new file mode 100644 index 000000000..61de1b18b --- /dev/null +++ b/environment/microvm/reconcile.go @@ -0,0 +1,398 @@ +package microvm + +import ( + "context" + "errors" + "fmt" +) + +var ( + // ErrEnvironmentUnavailable means the durable generation exists but its exact + // runtime cannot be reattached. Callers must not provision a replacement. + ErrEnvironmentUnavailable = errors.New("microvm environment generation is not live; inspect microvmd reconciliation state or delete the session environment") + // ErrRuntimeIdentityMismatch rejects PID reuse, stale endpoints, and resources + // belonging to another environment generation. + ErrRuntimeIdentityMismatch = errors.New("microvm runtime identity does not match the durable environment generation") +) + +// DeleteReason records why the durable tombstone was requested. +type DeleteReason string + +const ( + // DeleteExplicit is a caller-requested permanent deletion. + DeleteExplicit DeleteReason = "explicit" + // DeleteRetention is an automatic retention-policy destruction. + DeleteRetention DeleteReason = "retention" + // DeleteRollback cleans a generation whose creation or runtime failed. + DeleteRollback DeleteReason = "rollback" +) + +// RuntimeStatus is the generation-fenced identity observed from the runtime. +// ProcessIdentity is an OS start token in addition to PID, preventing PID reuse +// from being mistaken for the recorded runner. +type RuntimeStatus struct { + Live bool + Generation uint32 + VMID string + PID int + ProcessIdentity string + Endpoint string +} + +// RuntimeReattacher verifies and reconnects the exact live generation. It must +// never call the runtime create path. +type RuntimeReattacher interface { + Reattach(context.Context, EnvironmentRecord) error +} + +// LifecycleRuntime owns process handles, VM/endpoints, and descendant processes. +// Destroy is idempotent and must generation-check before signaling or unlinking. +type LifecycleRuntime interface { + RuntimeReattacher + Inspect(context.Context, EnvironmentRecord) (RuntimeStatus, error) + Detach(context.Context, EnvironmentRecord) error + Destroy(context.Context, EnvironmentRecord) error +} + +// ReconcileRegistry is the durable registry surface required by lifecycle repair. +type ReconcileRegistry interface { + EnvironmentRegistry + EnvironmentRegistryReader + List(context.Context) ([]EnvironmentRecord, error) +} + +// WorktreeRetention applies the dirty-state retention policy. Cleanup must be +// idempotent and confined to the exact paths in the durable record. +type WorktreeRetention interface { + Dirty(context.Context, EnvironmentRecord) (bool, error) + Cleanup(context.Context, EnvironmentRecord) error +} + +// EnvironmentManager separates process-local detach from durable destruction. +type EnvironmentManager struct { + registry ReconcileRegistry + runtime LifecycleRuntime + worktrees WorktreeRetention + admission *AdmissionController + observer *OperationsObserver +} + +// NewEnvironmentManager constructs the detach/delete lifecycle coordinator. +func NewEnvironmentManager(registry ReconcileRegistry, runtime LifecycleRuntime, worktrees WorktreeRetention) *EnvironmentManager { + return NewEnvironmentManagerWithAdmission(registry, runtime, worktrees, nil) +} + +// NewEnvironmentManagerWithAdmission additionally releases durable reservations +// after destruction commits. +func NewEnvironmentManagerWithAdmission(registry ReconcileRegistry, runtime LifecycleRuntime, worktrees WorktreeRetention, admission *AdmissionController, observers ...*OperationsObserver) *EnvironmentManager { + var observer *OperationsObserver + if len(observers) > 0 { + observer = observers[0] + } + return &EnvironmentManager{registry: registry, runtime: runtime, worktrees: worktrees, admission: admission, observer: observer} +} + +// Detach drops only process-local handles. The ready durable generation remains +// resolvable after a harness restart. +func (m *EnvironmentManager) Detach(ctx context.Context, ref EnvironmentRef, owner string) error { + record, err := m.resolveRecord(ctx, ref, owner) + if err != nil { + return err + } + if err := m.runtime.Reattach(ctx, record); err != nil { + return fmt.Errorf("reattach exact microvm generation before detach: %w", err) + } + if err := m.runtime.Detach(ctx, record); err != nil { + return fmt.Errorf("detach microvm environment handles: %w", err) + } + return nil +} + +// Delete writes a tombstone before destroying resources. Dirty worktrees are +// preserved; clean worktrees and metadata are removed. Retention uses this same path. +func (m *EnvironmentManager) Delete(ctx context.Context, ref EnvironmentRef, owner string, reason DeleteReason) error { + if reason != DeleteExplicit && reason != DeleteRetention && reason != DeleteRollback { + return errors.New("invalid microvm deletion reason") + } + record, err := m.resolveRecord(ctx, ref, owner) + if err != nil { + return err + } + dirty, err := m.worktrees.Dirty(ctx, record) + if err != nil { + return fmt.Errorf("inspect microvm worktree before deletion: %w", err) + } + record.State = EnvironmentDeleting + record.Tombstone = true + record.DeleteReason = reason + record.PreserveWorktree = dirty + if dirty { + record.WorktreeDeleted = true // lifecycle complete by policy: deliberately retained + } + if err := m.registry.Save(ctx, record); err != nil { + return fmt.Errorf("persist microvm deletion tombstone: %w", err) + } + return NewReconcilerWithAdmission(m.registry, m.runtime, m.worktrees, m.admission, m.observer).reconcileRecord(ctx, record) +} + +// DeleteChild durably destroys a delegated child worktree even when it is dirty. +// A merge conflict remains inspectable because its caller deliberately retains the +// cleanup capability; invoking cleanup is the explicit release boundary. +func (m *EnvironmentManager) DeleteChild(ctx context.Context, ref EnvironmentRef, owner string) error { + record, err := m.resolveRecord(ctx, ref, owner) + if err != nil { + return err + } + if record.ParentRef == (EnvironmentRef{}) || record.ForkBase == "" { + return ErrInvalidFork + } + record.State = EnvironmentDeleting + record.Tombstone = true + record.DeleteReason = DeleteExplicit + record.PreserveWorktree = false + if err := m.registry.Save(ctx, record); err != nil { + return fmt.Errorf("persist microvm child deletion tombstone: %w", err) + } + return NewReconcilerWithAdmission(m.registry, m.runtime, m.worktrees, m.admission, m.observer).reconcileRecord(ctx, record) +} + +func (m *EnvironmentManager) resolveRecord(ctx context.Context, ref EnvironmentRef, owner string) (EnvironmentRecord, error) { + if m == nil || m.registry == nil || m.runtime == nil || m.worktrees == nil { + return EnvironmentRecord{}, errors.New("microvm environment lifecycle is not configured") + } + environmentID, generation, err := parseEnvironmentRef(ref) + if err != nil || owner == "" { + return EnvironmentRecord{}, ErrInvalidEnvironmentRef + } + record, err := m.registry.Lookup(ctx, environmentID) + if err != nil { + return EnvironmentRecord{}, err + } + if record.Owner != owner { + return EnvironmentRecord{}, ErrEnvironmentForeign + } + if record.State == EnvironmentDestroyed || record.Tombstone { + return EnvironmentRecord{}, ErrEnvironmentDestroyed + } + if record.Ref != ref || record.Generation != generation { + return EnvironmentRecord{}, ErrEnvironmentStale + } + return record, nil +} + +// Reconciler converges durable crash states without provisioning replacements. +type Reconciler struct { + registry ReconcileRegistry + runtime LifecycleRuntime + worktrees WorktreeRetention + admission *AdmissionController + observer *OperationsObserver +} + +// NewReconciler constructs a durable lifecycle reconciler. +func NewReconciler(registry ReconcileRegistry, runtime LifecycleRuntime, worktrees WorktreeRetention) *Reconciler { + return NewReconcilerWithAdmission(registry, runtime, worktrees, nil) +} + +// NewReconcilerWithAdmission additionally restores destruction-time quota release. +func NewReconcilerWithAdmission(registry ReconcileRegistry, runtime LifecycleRuntime, worktrees WorktreeRetention, admission *AdmissionController, observers ...*OperationsObserver) *Reconciler { + var observer *OperationsObserver + if len(observers) > 0 { + observer = observers[0] + } + return &Reconciler{registry: registry, runtime: runtime, worktrees: worktrees, admission: admission, observer: observer} +} + +// Reconcile inspects every durable record. Independent record failures are joined +// so one damaged environment cannot prevent cleanup of the rest. +func (r *Reconciler) Reconcile(ctx context.Context) error { + if r == nil || r.registry == nil || r.runtime == nil || r.worktrees == nil { + return errors.New("microvm reconciler is not configured") + } + records, err := r.registry.List(ctx) + if err != nil { + return fmt.Errorf("list microvm reconciliation records: %w", err) + } + var result error + for _, record := range records { + if err := r.reconcileRecord(ctx, record); err != nil { + result = errors.Join(result, fmt.Errorf("reconcile microvm %s@%d: %w", record.EnvironmentID, record.Generation, err)) + } + } + return result +} + +func (r *Reconciler) reconcileRecord(ctx context.Context, record EnvironmentRecord) error { + cleanup := record.State == EnvironmentProvisioning || record.State == EnvironmentCleanupPending || record.State == EnvironmentDeleting + err := r.reconcileRecordRaw(ctx, record) + if !cleanup && record.State == EnvironmentReady { + if latest, lookupErr := r.registry.Lookup(context.WithoutCancel(ctx), record.EnvironmentID); lookupErr == nil { + cleanup = latest.State == EnvironmentDestroyed || (latest.State == EnvironmentCleanupPending && latest.Tombstone) + } + } + if cleanup && r.observer != nil { + outcome := OutcomeSuccess + if err != nil { + outcome = OutcomeFailure + } + r.observer.CleanupFinished(outcome) + } + return err +} + +func (r *Reconciler) reconcileRecordRaw(ctx context.Context, record EnvironmentRecord) error { + latest, err := r.registry.Lookup(ctx, record.EnvironmentID) + if err != nil { + return err + } + if latest.Generation != record.Generation || latest.Ref != record.Ref { + return ErrEnvironmentStale + } + record, cleanup, err := r.prepareForCleanup(ctx, latest) + if err != nil || !cleanup { + return err + } + record, err = r.cleanupRuntime(ctx, record) + if err != nil { + return err + } + record, err = r.cleanupWorktree(ctx, record) + if err != nil { + return err + } + record.State = EnvironmentDestroyed + record.Tombstone = true + if record.DeleteReason == "" { + record.DeleteReason = DeleteRollback + } + if err := r.registry.Save(context.WithoutCancel(ctx), record); err != nil { + return fmt.Errorf("persist destroyed microvm tombstone: %w", err) + } + if r.admission != nil { + r.admission.Release(record.Owner, record.AdmissionUsage) + } + return nil +} + +func (r *Reconciler) prepareForCleanup(ctx context.Context, record EnvironmentRecord) (EnvironmentRecord, bool, error) { + switch record.State { + case EnvironmentDestroyed: + return record, false, nil + case EnvironmentProvisioning, EnvironmentCleanupPending, EnvironmentDeleting: + return record, true, nil + case EnvironmentReady: + status, err := r.runtime.Inspect(ctx, record) + if errors.Is(err, ErrEnvironmentUnavailable) { + return r.markUnavailableForCleanup(ctx, record, err) + } + if err != nil { + return record, false, fmt.Errorf("inspect ready microvm runtime: %w", err) + } + identityErr := validateRuntimeIdentity(record, status) + if identityErr == nil { + attachErr := r.runtime.Reattach(ctx, record) + if errors.Is(attachErr, ErrEnvironmentUnavailable) { + return r.markUnavailableForCleanup(ctx, record, attachErr) + } + return record, false, attachErr + } + record.State = EnvironmentCleanupPending + record.Tombstone = true + record.DeleteReason = DeleteRollback + if saveErr := r.registry.Save(ctx, record); saveErr != nil { + return record, false, errors.Join(identityErr, saveErr) + } + if errors.Is(identityErr, ErrEnvironmentUnavailable) { + return record, true, nil + } + return record, false, identityErr + default: + return record, false, fmt.Errorf("unknown microvm lifecycle state %q", record.State) + } +} + +func (r *Reconciler) markUnavailableForCleanup(ctx context.Context, record EnvironmentRecord, unavailable error) (EnvironmentRecord, bool, error) { + if record.RunnerPID <= 0 || record.ProcessIdentity == "" || record.Endpoint == "" || record.VMID == "" { + return record, false, errors.Join(ErrRuntimeIdentityMismatch, unavailable) + } + record.State = EnvironmentCleanupPending + record.Tombstone = true + record.DeleteReason = DeleteRollback + if err := r.registry.Save(ctx, record); err != nil { + return record, false, errors.Join(unavailable, err) + } + return record, true, nil +} + +func (r *Reconciler) cleanupRuntime(ctx context.Context, record EnvironmentRecord) (EnvironmentRecord, error) { + if record.VMDeleted { + return record, nil + } + status, err := r.runtime.Inspect(ctx, record) + if errors.Is(err, ErrEnvironmentUnavailable) { + // Destroy is required to identity-check the persisted PID/start token before + // signaling or unlinking. A backend unable to prove that identity must fail. + if err := r.runtime.Destroy(context.WithoutCancel(ctx), record); err != nil { + return record, fmt.Errorf("destroy unavailable microvm by durable identity: %w", err) + } + record.VMDeleted = true + if err := r.registry.Save(context.WithoutCancel(ctx), record); err != nil { + return record, fmt.Errorf("persist microvm runtime cleanup: %w", err) + } + return record, nil + } + if err != nil { + return record, fmt.Errorf("inspect microvm before cleanup: %w", err) + } + if status.Live { + if err := validateRuntimeIdentity(record, status); err != nil { + record.State = EnvironmentCleanupPending + record.Tombstone = true + return record, errors.Join(err, r.registry.Save(ctx, record)) + } + } + cleanupCtx := context.WithoutCancel(ctx) + if err := r.runtime.Destroy(cleanupCtx, record); err != nil { + record.State = EnvironmentCleanupPending + record.Tombstone = true + return record, errors.Join(err, r.registry.Save(cleanupCtx, record)) + } + record.VMDeleted = true + if err := r.registry.Save(cleanupCtx, record); err != nil { + return record, fmt.Errorf("persist microvm runtime cleanup: %w", err) + } + return record, nil +} + +func (r *Reconciler) cleanupWorktree(ctx context.Context, record EnvironmentRecord) (EnvironmentRecord, error) { + if record.WorktreeDeleted { + return record, nil + } + dirty, err := r.worktrees.Dirty(ctx, record) + if err != nil { + return record, fmt.Errorf("inspect microvm worktree during cleanup: %w", err) + } + cleanupCtx := context.WithoutCancel(ctx) + if dirty { + record.PreserveWorktree = true + } else if err := r.worktrees.Cleanup(cleanupCtx, record); err != nil { + record.State = EnvironmentCleanupPending + record.Tombstone = true + return record, errors.Join(err, r.registry.Save(cleanupCtx, record)) + } + record.WorktreeDeleted = true + if err := r.registry.Save(cleanupCtx, record); err != nil { + return record, fmt.Errorf("persist microvm worktree cleanup: %w", err) + } + return record, nil +} + +func validateRuntimeIdentity(record EnvironmentRecord, status RuntimeStatus) error { + if !status.Live { + return ErrEnvironmentUnavailable + } + if status.Generation != record.Generation || status.VMID != record.VMID || status.Endpoint != record.Endpoint || status.PID != record.RunnerPID || status.ProcessIdentity == "" || status.ProcessIdentity != record.ProcessIdentity { + return ErrRuntimeIdentityMismatch + } + return nil +} diff --git a/environment/microvm/registry.go b/environment/microvm/registry.go new file mode 100644 index 000000000..e10a5afb4 --- /dev/null +++ b/environment/microvm/registry.go @@ -0,0 +1,233 @@ +package microvm + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "time" + + "github.com/gofrs/flock" +) + +// FileRegistry is the daemon's durable generation-fenced environment registry. +// Each operation takes an inter-process lock and reloads the file, so a restarted +// daemon and two daemons sharing the state directory observe one authoritative set. +type FileRegistry struct { + path string + lock *flock.Flock +} + +type registryDocument struct { + Version int `json:"version"` + Environments []EnvironmentRecord `json:"environments"` +} + +// OpenFileRegistry opens a registry. The document is created by the first Save; +// opening never invents or provisions an environment. +func OpenFileRegistry(path string) (*FileRegistry, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return nil, errors.New("microvm registry path must be absolute and clean") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("create microvm registry directory: %w", err) + } + return &FileRegistry{path: path, lock: flock.New(path + ".lock")}, nil +} + +// Save atomically creates or updates one exact generation. An environment ID can +// never be overwritten by a different generation, including by a racing daemon. +func (r *FileRegistry) Save(ctx context.Context, record EnvironmentRecord) error { + if err := validateRecordIdentity(record); err != nil { + return err + } + return r.withLock(ctx, func(document *registryDocument) error { + for i := range document.Environments { + if document.Environments[i].EnvironmentID != record.EnvironmentID { + continue + } + current := document.Environments[i] + if current.Generation != record.Generation || current.Ref != record.Ref || !validRecordTransition(current, record) { + return ErrEnvironmentStale + } + document.Environments[i] = cloneEnvironmentRecord(record) + return r.write(*document) + } + document.Environments = append(document.Environments, cloneEnvironmentRecord(record)) + return r.write(*document) + }) +} + +// Lookup returns one exact durable record. +func (r *FileRegistry) Lookup(ctx context.Context, environmentID string) (EnvironmentRecord, error) { + var result EnvironmentRecord + err := r.withLock(ctx, func(document *registryDocument) error { + for _, record := range document.Environments { + if record.EnvironmentID == environmentID { + result = cloneEnvironmentRecord(record) + return nil + } + } + return ErrEnvironmentUnknown + }) + return result, err +} + +// List returns a stable snapshot of every durable record, including tombstones. +func (r *FileRegistry) List(ctx context.Context) ([]EnvironmentRecord, error) { + var result []EnvironmentRecord + err := r.withLock(ctx, func(document *registryDocument) error { + result = make([]EnvironmentRecord, len(document.Environments)) + for i, record := range document.Environments { + result[i] = cloneEnvironmentRecord(record) + } + sort.Slice(result, func(i, j int) bool { return result[i].EnvironmentID < result[j].EnvironmentID }) + return nil + }) + return result, err +} + +func (r *FileRegistry) withLock(ctx context.Context, fn func(*registryDocument) error) error { + if r == nil || r.lock == nil { + return errors.New("microvm registry is not configured") + } + locked, err := r.lock.TryLockContext(ctx, 10*time.Millisecond) + if err != nil { + return fmt.Errorf("lock microvm registry: %w", err) + } + if !locked { + if err := context.Cause(ctx); err != nil { + return err + } + return errors.New("microvm registry lock was not acquired") + } + defer func() { _ = r.lock.Unlock() }() + document, err := r.read() + if err != nil { + return err + } + return fn(&document) +} + +func (r *FileRegistry) read() (registryDocument, error) { + file, err := os.Open(r.path) + if errors.Is(err, os.ErrNotExist) { + return registryDocument{Version: 1}, nil + } + if err != nil { + return registryDocument{}, fmt.Errorf("open microvm registry: %w", err) + } + var document registryDocument + decoder := json.NewDecoder(io.LimitReader(file, 16<<20)) + decoder.DisallowUnknownFields() + decodeErr := decoder.Decode(&document) + closeErr := file.Close() + if decodeErr != nil { + return registryDocument{}, fmt.Errorf("decode microvm registry: %w", decodeErr) + } + if closeErr != nil { + return registryDocument{}, fmt.Errorf("close microvm registry: %w", closeErr) + } + if document.Version != 1 { + return registryDocument{}, fmt.Errorf("unsupported microvm registry version %d", document.Version) + } + return document, nil +} + +func (r *FileRegistry) write(document registryDocument) error { + document.Version = 1 + data, err := json.Marshal(document) + if err != nil { + return fmt.Errorf("encode microvm registry: %w", err) + } + data = append(data, '\n') + dir := filepath.Dir(r.path) + tmp, err := os.CreateTemp(dir, ".registry-*") + if err != nil { + return fmt.Errorf("create microvm registry transaction: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("secure microvm registry transaction: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write microvm registry transaction: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync microvm registry transaction: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close microvm registry transaction: %w", err) + } + if err := os.Rename(tmpName, r.path); err != nil { + return fmt.Errorf("commit microvm registry transaction: %w", err) + } + directory, err := os.Open(dir) + if err != nil { + return fmt.Errorf("open microvm registry directory: %w", err) + } + if err := directory.Sync(); err != nil { + _ = directory.Close() + return fmt.Errorf("sync microvm registry directory: %w", err) + } + if err := directory.Close(); err != nil { + return fmt.Errorf("close microvm registry directory: %w", err) + } + return nil +} + +func validRecordTransition(current, next EnvironmentRecord) bool { + rank := func(state EnvironmentState) int { + switch state { + case EnvironmentProvisioning: + return 0 + case EnvironmentReady: + return 1 + case EnvironmentDeleting, EnvironmentCleanupPending: + return 2 + case EnvironmentDestroyed: + return 3 + default: + return -1 + } + } + currentRank, nextRank := rank(current.State), rank(next.State) + if currentRank < 0 || nextRank < currentRank || (current.Tombstone && !next.Tombstone) { + return false + } + if current.ParentRef != next.ParentRef || current.ForkBase != next.ForkBase { + return false + } + if current.VMDeleted && !next.VMDeleted || current.WorktreeDeleted && !next.WorktreeDeleted { + return false + } + return true +} + +func validateRecordIdentity(record EnvironmentRecord) error { + environmentID, generation, err := parseEnvironmentRef(record.Ref) + if err != nil || environmentID != record.EnvironmentID || generation != record.Generation { + return ErrInvalidEnvironmentRef + } + parentSet := record.ParentRef != (EnvironmentRef{}) + if parentSet != (record.ForkBase != "") { + return ErrInvalidEnvironmentRef + } + if parentSet { + if record.ParentRef == record.Ref { + return ErrInvalidEnvironmentRef + } + if _, _, err := parseEnvironmentRef(record.ParentRef); err != nil { + return ErrInvalidEnvironmentRef + } + } + return nil +} diff --git a/environment/microvm/repository_attachment.go b/environment/microvm/repository_attachment.go new file mode 100644 index 000000000..73ae332bc --- /dev/null +++ b/environment/microvm/repository_attachment.go @@ -0,0 +1,331 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/stacklok/mecatl/engine/adapter/memledger" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/control" +) + +// RepositoryAttachment is one session or isolated-child handle on a logical +// worktree in a repository-scoped VM. +type RepositoryAttachment struct { + Logical *LogicalEnvironment + Environment tool.Environment + + manager *RepositoryAttachmentManager + once sync.Once + err error +} + +// Close detaches only this logical environment and its process-local handles. +func (a *RepositoryAttachment) Close() error { + if a == nil { + return nil + } + a.once.Do(func() { + var record *repositoryAttachmentRecord + if a.manager != nil { + a.manager.mu.Lock() + delete(a.manager.active, a.Environment.Ref()) + record = a.manager.records[a.Environment.Ref().ID] + a.manager.mu.Unlock() + } + a.err = a.Logical.Close() + if a.manager != nil { + a.manager.mu.Lock() + if record != nil { + a.err = errors.Join(a.err, removeRepositoryAttachmentRecord(record)) + } + delete(a.manager.records, a.Environment.Ref().ID) + a.manager.mu.Unlock() + } + }) + return a.err +} + +type repositoryChildAttachment struct { + attachment *RepositoryAttachment + parent session.EnvironmentRef + forkBase string +} + +type repositoryAttachmentRecord struct { + binding control.Binding + repository RepositoryVMRecord + worktreePath string + sourceRoot string + metadataPath string + branch string + attachment *RepositoryAttachment + worktreeRetained bool + deleted bool + deleting bool +} + +// RepositoryAttachmentManager adapts repository logical worktrees to session +// attachment and the engine's existing isolated-child fork/merge seams. +type RepositoryAttachmentManager struct { + logical *RepositoryLogicalManager + mu sync.Mutex + active map[session.EnvironmentRef]*repositoryChildAttachment + records map[string]*repositoryAttachmentRecord +} + +func newRepositoryAttachmentManager(logical *RepositoryLogicalManager) (*RepositoryAttachmentManager, error) { + manager := &RepositoryAttachmentManager{ + logical: logical, + active: make(map[session.EnvironmentRef]*repositoryChildAttachment), + records: make(map[string]*repositoryAttachmentRecord), + } + if err := manager.loadRecords(); err != nil { + return nil, err + } + return manager, nil +} + +var ( + _ tool.EnvironmentForker = (*RepositoryAttachmentManager)(nil) + _ tool.EnvironmentMerger = (*RepositoryAttachmentManager)(nil) +) + +// Attach allocates a distinct logical worktree in the canonical repository VM. +func (m *RepositoryAttachmentManager) Attach(ctx context.Context, request LogicalEnvironmentRequest) (*RepositoryAttachment, error) { + if m == nil || m.logical == nil { + return nil, ErrEnvironmentUnavailable + } + logical, err := m.logical.Create(ctx, request) + if err != nil { + return nil, err + } + environment, err := tool.NewEnvironment(session.EnvironmentRef{Kind: session.EnvironmentKind(logical.Ref.Kind), ID: logical.Ref.ID}, logical.Workspace, memledger.New(), logical.Runner) + if err != nil { + _ = logical.Close() + return nil, err + } + attachment := &RepositoryAttachment{Logical: logical, Environment: environment, manager: m} + m.mu.Lock() + if _, exists := m.active[environment.Ref()]; exists { + m.mu.Unlock() + _ = logical.Close() + return nil, errors.New("repository logical environment identity collided") + } + m.active[environment.Ref()] = &repositoryChildAttachment{attachment: attachment} + m.mu.Unlock() + return attachment, nil +} + +// Reattach restores the exact persisted logical ref after authenticating the +// repository generation and retained worktree. +func (m *RepositoryAttachmentManager) Reattach(ctx context.Context, request LogicalEnvironmentRequest, ref session.EnvironmentRef) (*RepositoryAttachment, error) { + if m == nil || m.logical == nil { + return nil, ErrEnvironmentUnavailable + } + logical, err := m.logical.Reattach(ctx, request, EnvironmentRef{Kind: string(ref.Kind), ID: ref.ID}) + if err != nil { + return nil, err + } + environment, err := tool.NewEnvironment(ref, logical.Workspace, memledger.New(), logical.Runner) + if err != nil { + _ = logical.Detach() + return nil, err + } + attachment := &RepositoryAttachment{Logical: logical, Environment: environment, manager: m} + m.mu.Lock() + if _, exists := m.active[ref]; exists { + m.mu.Unlock() + _ = logical.Detach() + return nil, errors.New("repository logical environment is already attached") + } + m.active[ref] = &repositoryChildAttachment{attachment: attachment} + m.mu.Unlock() + return attachment, nil +} + +// Detach releases only process-local handles and retains the logical worktree. +func (m *RepositoryAttachmentManager) Detach(ref session.EnvironmentRef) error { + entry := m.lookupChild(ref) + if entry == nil { + return ErrEnvironmentUnavailable + } + m.mu.Lock() + delete(m.active, ref) + m.mu.Unlock() + return entry.attachment.Logical.Detach() +} + +// Fork captures the parent's exact Git tree, then attaches a distinct logical +// worktree to the same repository VM. The returned cleanup detaches only the child. +func (m *RepositoryAttachmentManager) Fork(ctx context.Context, parent tool.Environment, label string) (tool.Environment, func() error, string, error) { + if label == "" { + return tool.Environment{}, nil, "", ErrInvalidFork + } + parentAttachment := m.lookupChild(parent.Ref()) + if parentAttachment == nil || parentAttachment.attachment.Environment.Workspace() != parent.Workspace() { + return tool.Environment{}, nil, "", fmt.Errorf("%w: parent attachment is not active", ErrInvalidFork) + } + base, err := captureForkBase(ctx, parentAttachment.attachment.Logical.WorktreePath) + if err != nil { + return tool.Environment{}, nil, "", fmt.Errorf("capture repository child base: %w", err) + } + request := LogicalEnvironmentRequest{ + Owner: parentAttachment.attachment.Logical.Repository.Owner, + Checkout: parentAttachment.attachment.Logical.WorktreePath, + BaseRevision: base, + } + child, err := m.Attach(ctx, request) + if err != nil { + return tool.Environment{}, nil, "", err + } + m.mu.Lock() + entry := m.active[child.Environment.Ref()] + entry.parent = parent.Ref() + entry.forkBase = base + m.mu.Unlock() + return child.Environment, child.Close, "", nil +} + +// Merge reuses the established conflict-aware isolated-child patch path. A +// conflict never closes or removes the child attachment. +func (m *RepositoryAttachmentManager) Merge(ctx context.Context, child, parent tool.Environment) error { + parentAttachment := m.lookupChild(parent.Ref()) + childAttachment := m.lookupChild(child.Ref()) + if parentAttachment == nil || childAttachment == nil || childAttachment.parent != parent.Ref() || childAttachment.forkBase == "" { + return ErrInvalidFork + } + toRef := func(ref session.EnvironmentRef) EnvironmentRef { + return EnvironmentRef{Kind: string(ref.Kind), ID: ref.ID} + } + parentRecord := EnvironmentRecord{ + State: EnvironmentReady, Ref: toRef(parent.Ref()), + WorktreePath: parentAttachment.attachment.Logical.WorktreePath, + } + childRecord := EnvironmentRecord{ + State: EnvironmentReady, Ref: toRef(child.Ref()), ParentRef: parentRecord.Ref, + WorktreePath: childAttachment.attachment.Logical.WorktreePath, ForkBase: childAttachment.forkBase, + } + return (&LifecycleChildren{}).Merge(ctx, parentRecord, childRecord) +} + +func (m *RepositoryAttachmentManager) lookup(ref session.EnvironmentRef) *RepositoryAttachment { + entry := m.lookupChild(ref) + if entry == nil { + return nil + } + return entry.attachment +} + +func (m *RepositoryAttachmentManager) lookupChild(ref session.EnvironmentRef) *repositoryChildAttachment { + if m == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + return m.active[ref] +} + +func (m *RepositoryAttachmentManager) register(binding control.Binding, attachment *RepositoryAttachment) error { + if m == nil || attachment == nil || binding.Ref == "" || binding.Ref != attachment.Environment.Ref().ID { + return errors.New("repository logical attachment binding is invalid") + } + m.mu.Lock() + defer m.mu.Unlock() + if existing := m.records[binding.Ref]; existing != nil && existing.binding != binding { + return errors.New("repository logical attachment binding collided") + } + record := &repositoryAttachmentRecord{ + binding: binding, repository: attachment.Logical.Repository, + worktreePath: attachment.Logical.WorktreePath, sourceRoot: attachment.Logical.Repository.GitCommonDirectory, + metadataPath: attachment.Logical.MetadataPath, branch: attachment.Logical.Branch, attachment: attachment, + } + if err := persistRepositoryAttachment(record); err != nil { + return err + } + m.records[binding.Ref] = record + return nil +} + +func (m *RepositoryAttachmentManager) inventory(owner string) []repositoryAttachmentRecord { + if m == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + out := make([]repositoryAttachmentRecord, 0, len(m.records)) + for _, record := range m.records { + if record.binding.Owner == owner { + out = append(out, *record) + } + } + return out +} + +func (m *RepositoryAttachmentManager) health(ctx context.Context, record RepositoryVMRecord) error { + if m == nil || m.logical == nil || m.logical.registry == nil { + return ErrEnvironmentUnavailable + } + return m.logical.registry.inspect(ctx, record) +} + +func (m *RepositoryAttachmentManager) delete(ctx context.Context, binding control.Binding) (LifecycleDeleteResult, error) { + if m == nil { + return LifecycleDeleteResult{}, ErrEnvironmentUnavailable + } + m.mu.Lock() + record := m.records[binding.Ref] + if record == nil || record.binding != binding || record.deleted || record.deleting { + m.mu.Unlock() + return LifecycleDeleteResult{}, control.ErrBindingMismatch + } + record.deleting = true + attachment := record.attachment + if attachment == nil { + attachment = &RepositoryAttachment{Logical: &LogicalEnvironment{ + Repository: record.repository, Ref: EnvironmentRef{Kind: Kind, ID: record.binding.Ref}, + WorktreePath: record.worktreePath, SourceRoot: record.sourceRoot, + MetadataPath: record.metadataPath, Branch: record.branch, preparer: m.logical.preparer, + }} + } + m.mu.Unlock() + retained, err := attachment.Logical.DeletePreservingDirty(ctx) + if err != nil { + m.mu.Lock() + record.deleting = false + m.mu.Unlock() + return LifecycleDeleteResult{}, err + } + m.mu.Lock() + if retained { + updated := *record + updated.attachment = nil + updated.deleted = true + updated.worktreeRetained = true + updated.deleting = false + if err := persistRepositoryAttachment(&updated); err != nil { + record.deleting = false + m.mu.Unlock() + return LifecycleDeleteResult{}, err + } + if record.attachment != nil { + delete(m.active, record.attachment.Environment.Ref()) + } + *record = updated + } else { + if err := removeRepositoryAttachmentRecord(record); err != nil { + record.deleting = false + m.mu.Unlock() + return LifecycleDeleteResult{}, err + } + if record.attachment != nil { + delete(m.active, record.attachment.Environment.Ref()) + } + delete(m.records, binding.Ref) + } + m.mu.Unlock() + return LifecycleDeleteResult{WorktreePath: record.worktreePath, WorktreeRetained: retained}, nil +} diff --git a/environment/microvm/repository_attachment_store.go b/environment/microvm/repository_attachment_store.go new file mode 100644 index 000000000..6a9eec1ef --- /dev/null +++ b/environment/microvm/repository_attachment_store.go @@ -0,0 +1,239 @@ +package microvm + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" + + "github.com/stacklok/mecatl/environment/microvm/control" +) + +const repositoryAttachmentVersion = 1 + +type repositoryAttachmentDocument struct { + Version int `json:"version"` + Binding control.Binding `json:"binding"` + Repository RepositoryVMRecord `json:"repository"` + WorktreePath string `json:"worktree_path"` + SourceRoot string `json:"source_root"` + MetadataPath string `json:"metadata_path"` + Branch string `json:"branch"` + Deleted bool `json:"deleted,omitempty"` + WorktreeRetained bool `json:"worktree_retained,omitempty"` +} + +func (m *RepositoryAttachmentManager) loadRecords() error { //nolint:gocyclo // fixed hierarchy traversal keeps confinement explicit + if m == nil || m.logical == nil || m.logical.registry == nil { + return ErrEnvironmentUnavailable + } + root := m.logical.registry.stateRoot + owners, err := privateAttachmentDirectories(filepath.Join(root, "owners"), "owner") + if err != nil { + return err + } + for _, owner := range owners { + repositories, err := privateAttachmentDirectories(filepath.Join(root, "owners", owner, "repositories"), "repository") + if err != nil { + return err + } + for _, repository := range repositories { + logicalRoot := filepath.Join(root, "owners", owner, "repositories", repository, "logical") + logicalIDs, err := privateAttachmentDirectories(logicalRoot, "logical") + if err != nil { + return err + } + for _, logicalID := range logicalIDs { + filename := filepath.Join(logicalRoot, logicalID, "attachment.json") + if _, err := os.Lstat(filename); errors.Is(err, os.ErrNotExist) { + continue + } else if err != nil { + return err + } + record, err := readRepositoryAttachment(m.logical.registry, filename) + if err != nil { + return err + } + if m.records[record.binding.Ref] != nil { + return errors.New("repository attachment inventory contains duplicate ref") + } + m.records[record.binding.Ref] = record + } + } + } + return nil +} + +func privateAttachmentDirectories(root, namespace string) ([]string, error) { + entries, err := os.ReadDir(root) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + result := make([]string, 0, len(entries)) + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return nil, err + } + if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !validOpaquePathComponent(entry.Name()) || info.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("repository attachment %s namespace contains an invalid entry", namespace) + } + result = append(result, entry.Name()) + } + return result, nil +} + +func readRepositoryAttachment(registry *RepositoryVMRegistry, filename string) (*repositoryAttachmentRecord, error) { //nolint:gocyclo // fail-closed metadata validation is clearest in one path + stateRoot := registry.stateRoot + relative, err := filepath.Rel(stateRoot, filename) + if err != nil { + return nil, err + } + parts := strings.Split(relative, string(filepath.Separator)) + if len(parts) != 7 || parts[0] != "owners" || parts[2] != "repositories" || parts[4] != "logical" || parts[6] != "attachment.json" || + !validOpaquePathComponent(parts[1]) || !validOpaquePathComponent(parts[3]) { + return nil, errors.New("repository attachment metadata is outside the confined logical namespace") + } + logicalID, err := hex.DecodeString(parts[5]) + if err != nil || len(logicalID) != 16 { + return nil, errors.New("repository attachment metadata has an invalid logical identity") + } + fd, err := unix.Open(filename, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), filename) + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { + return nil, errors.New("repository attachment metadata is not a private regular file") + } + var document repositoryAttachmentDocument + decoder := json.NewDecoder(io.LimitReader(file, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&document); err != nil { + return nil, fmt.Errorf("decode repository attachment metadata: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return nil, errors.New("repository attachment metadata has trailing content") + } + if document.Version != repositoryAttachmentVersion { + return nil, fmt.Errorf("unsupported repository attachment metadata version %d", document.Version) + } + binding := document.Binding + environmentID, generation, err := parseEnvironmentRef(EnvironmentRef{Kind: Kind, ID: binding.Ref}) + logicalRoot := filepath.Dir(filename) + repositoryRoot := filepath.Dir(filepath.Dir(logicalRoot)) + identity, identityErr := newValidatedRepositoryIdentity(document.Repository.Owner, document.Repository.GitCommonDirectory, stateRoot) + if err != nil || identityErr != nil || binding.AssignedRoot != "" || binding.EnvironmentID != environmentID || generation != binding.Generation || + binding.Owner != document.Repository.Owner || binding.Generation != document.Repository.Generation || + environmentID != "logical-"+parts[5] || identity.value.StateDirectory != repositoryRoot || identity.value.Key != document.Repository.RepositoryKey || + document.WorktreePath != filepath.Join(logicalRoot, "worktree") || document.MetadataPath != filepath.Join(logicalRoot, "metadata") || + document.SourceRoot != document.Repository.GitCommonDirectory || !filepath.IsAbs(document.SourceRoot) || document.Branch != "mecatl/"+parts[5] || + document.WorktreeRetained != document.Deleted { + return nil, errors.New("repository attachment metadata is inconsistent") + } + if err := registry.validateRepositoryRecord(identity.value, document.Repository); err != nil { + return nil, errors.New("repository attachment metadata names an invalid repository generation") + } + if err := claimValidate(binding); err != nil { + return nil, control.ErrBindingMismatch + } + return &repositoryAttachmentRecord{ + binding: binding, repository: document.Repository, worktreePath: document.WorktreePath, + sourceRoot: document.SourceRoot, metadataPath: document.MetadataPath, branch: document.Branch, + deleted: document.Deleted, worktreeRetained: document.WorktreeRetained, + }, nil +} + +func persistRepositoryAttachment(record *repositoryAttachmentRecord) error { + if record == nil { + return errors.New("repository attachment record is nil") + } + document := repositoryAttachmentDocument{ + Version: repositoryAttachmentVersion, Binding: record.binding, Repository: record.repository, + WorktreePath: record.worktreePath, SourceRoot: record.sourceRoot, MetadataPath: record.metadataPath, Branch: record.branch, + Deleted: record.deleted, WorktreeRetained: record.worktreeRetained, + } + data, err := json.Marshal(document) + if err != nil { + return err + } + return writeAttachmentFile(filepath.Dir(record.worktreePath), append(data, '\n')) +} + +func writeAttachmentFile(directory string, data []byte) error { + directoryFD, err := unix.Open(directory, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return err + } + dir := os.NewFile(uintptr(directoryFD), directory) + defer func() { _ = dir.Close() }() + var name string + var temporary *os.File + for attempt := 0; attempt < 100; attempt++ { + var value [8]byte + if _, err := rand.Read(value[:]); err != nil { + return err + } + name = ".attachment-" + hex.EncodeToString(value[:]) + fd, openErr := openatOpaque(directoryFD, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600) + if openErr == nil { + temporary = os.NewFile(uintptr(fd), filepath.Join(directory, name)) + break + } + if !errors.Is(openErr, unix.EEXIST) { + return openErr + } + } + if temporary == nil { + return errors.New("allocate repository attachment metadata temporary file") + } + defer func() { _ = unix.Unlinkat(directoryFD, name, 0) }() + if _, err := temporary.Write(data); 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 := unix.Renameat(directoryFD, name, directoryFD, "attachment.json"); err != nil { + return err + } + return dir.Sync() +} + +func removeRepositoryAttachmentRecord(record *repositoryAttachmentRecord) error { + if record == nil { + return nil + } + directory := filepath.Dir(record.worktreePath) + directoryFD, err := unix.Open(directory, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if errors.Is(err, unix.ENOENT) { + return nil + } + if err != nil { + return err + } + dir := os.NewFile(uintptr(directoryFD), directory) + defer func() { _ = dir.Close() }() + if err := unix.Unlinkat(directoryFD, "attachment.json", 0); err != nil && !errors.Is(err, unix.ENOENT) { + return err + } + return dir.Sync() +} diff --git a/environment/microvm/repository_attachment_test.go b/environment/microvm/repository_attachment_test.go new file mode 100644 index 000000000..5630675e1 --- /dev/null +++ b/environment/microvm/repository_attachment_test.go @@ -0,0 +1,265 @@ +package microvm + +import ( + "errors" + "os" + "path/filepath" + "sync" + "testing" + + gomicrovmnet "github.com/stacklok/go-microvm/net" + + "github.com/stacklok/mecatl/engine/tool" +) + +func TestMicroVMMVP_Scenario2_DurableSingletonRegistryReattachesOrFailsLoudly(t *testing.T) { + t.Parallel() + root := t.TempDir() + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + stateRoot := filepath.Join(root, "state") + verified := repositoryVerifiedArtifacts(t, root) + runtime := newFakeRepositoryVMRuntime() + + const callers = 8 + results := make(chan RepositoryVMRecord, callers) + var wg sync.WaitGroup + for range callers { + wg.Add(1) + go func() { + defer wg.Done() + registry, err := OpenRepositoryVMRegistry(stateRoot, runtime) + if err != nil { + t.Errorf("open registry: %v", err) + return + } + result, err := registry.Ensure(t.Context(), RepositoryVMRequest{Owner: "operator", Checkout: repository, Verified: verified}) + if err != nil { + t.Errorf("concurrent ensure: %v", err) + return + } + results <- result.Record + }() + } + wg.Wait() + close(results) + + var admitted RepositoryVMRecord + for record := range results { + if admitted.Generation == 0 { + admitted = record + } + if record != admitted { + t.Fatalf("concurrent first use diverged: admitted=%+v got=%+v", admitted, record) + } + } + if runtime.startCount() != 1 { + t.Fatalf("repository runtime starts = %d, want 1", runtime.startCount()) + } + + restarted, err := OpenRepositoryVMRegistry(stateRoot, runtime) + if err != nil { + t.Fatal(err) + } + reattached, err := restarted.Ensure(t.Context(), RepositoryVMRequest{Owner: "operator", Checkout: repository}) + if err != nil { + t.Fatalf("reattach exact healthy generation: %v", err) + } + if !reattached.Reattached || reattached.Record != admitted || runtime.startCount() != 1 { + t.Fatalf("restart minted a replacement: result=%+v starts=%d", reattached, runtime.startCount()) + } + + runtime.mu.Lock() + delete(runtime.statuses, admitted.VMID) + runtime.mu.Unlock() + if _, err := restarted.Ensure(t.Context(), RepositoryVMRequest{Owner: "operator", Checkout: repository, Verified: verified}); err == nil { + t.Fatal("missing runtime state minted a replacement") + } + if runtime.startCount() != 1 { + t.Fatalf("failed reattach started replacement generation: starts=%d", runtime.startCount()) + } + + production := newRepositoryAttachmentFixture(t) + attached := production.attach(t) + ref, worktree := attached.Environment.Ref(), attached.Logical.WorktreePath + rootfs := attached.Logical.Repository.RootFSPath + if err := production.composition.Attachments.Detach(ref); err != nil { + t.Fatalf("detach before daemon restart: %v", err) + } + restartedComposition, err := NewRepositoryComposition(production.stateRoot, production.runtimeConfig) + if err != nil { + t.Fatal(err) + } + if _, err := restartedComposition.Attachments.Reattach(t.Context(), LogicalEnvironmentRequest{Owner: "operator", Checkout: worktree}, ref); !errors.Is(err, ErrRepositoryVMInconsistent) { + t.Fatalf("daemon restart without live network backend = %v, want inconsistent generation", err) + } + if production.backend.starts != 1 { + t.Fatalf("failed restart minted a replacement generation: starts=%d", production.backend.starts) + } + for _, retained := range []string{worktree, rootfs} { + if _, err := os.Stat(retained); err != nil { + t.Fatalf("failed restart did not preserve %s: %v", retained, err) + } + } +} + +func TestMicroVMMVP_Scenario5_SessionsAndChildrenReuseRepositoryVM(t *testing.T) { + t.Parallel() + fixture := newRepositoryAttachmentFixture(t) + first := fixture.attach(t) + second := fixture.attach(t) + defer first.Close() + defer second.Close() + + if first.Logical.Repository.VMID != second.Logical.Repository.VMID || first.Environment.Ref() == second.Environment.Ref() || first.Logical.WorktreePath == second.Logical.WorktreePath { + t.Fatalf("sessions did not share only the repository VM: first=%+v second=%+v", first.Logical, second.Logical) + } + + children := make([]tool.Environment, 0, 3) + cleanups := make([]func() error, 0, 3) + for _, label := range []string{"subagent-read-only", "parallel-branch", "team-member"} { + child, cleanup, _, err := fixture.composition.Attachments.Fork(t.Context(), first.Environment, label) + if err != nil { + t.Fatalf("fork %s: %v", label, err) + } + children, cleanups = append(children, child), append(cleanups, cleanup) + } + defer func() { + for _, cleanup := range cleanups { + _ = cleanup() + } + }() + seen := map[string]bool{first.Environment.Ref().ID: true, second.Environment.Ref().ID: true} + for _, child := range children { + attachment := fixture.composition.Attachments.lookup(child.Ref()) + if attachment == nil || attachment.Logical.Repository.VMID != first.Logical.Repository.VMID || attachment.Logical.WorktreePath == first.Logical.WorktreePath || seen[child.Ref().ID] { + t.Fatalf("isolated child did not receive a distinct logical attachment in the repository VM: ref=%+v attachment=%+v", child.Ref(), attachment) + } + seen[child.Ref().ID] = true + } +} + +func TestMicroVMMVP_Scenario5_CloseDetachesWithoutDestroyingRepositoryVM(t *testing.T) { + t.Parallel() + fixture := newRepositoryAttachmentFixture(t) + first := fixture.attach(t) + second := fixture.attach(t) + firstWorktree := first.Logical.WorktreePath + rootfs := first.Logical.Repository.RootFSPath + vmID := first.Logical.Repository.VMID + + if err := first.Close(); err != nil { + t.Fatalf("close first session: %v", err) + } + if _, err := os.Stat(firstWorktree); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("closed logical worktree remains: %v", err) + } + if _, err := os.Stat(rootfs); err != nil { + t.Fatalf("closing attachment destroyed shared rootfs: %v", err) + } + if second.Logical.Repository.VMID != vmID { + t.Fatalf("sibling moved repository VM: got %q want %q", second.Logical.Repository.VMID, vmID) + } + if got, err := second.Environment.Workspace().Read(t.Context(), "tracked.txt"); err != nil || string(got) != "source\n" { + t.Fatalf("sibling attachment stopped after close: %q, %v", got, err) + } + if fixture.backend.starts != 1 { + t.Fatalf("close restarted or destroyed repository VM: starts=%d", fixture.backend.starts) + } + if err := second.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(rootfs); err != nil { + t.Fatalf("last logical close destroyed repository rootfs: %v", err) + } +} + +func TestMicroVMMVP_Scenario5_BasicExistingMergeBehavior(t *testing.T) { + t.Parallel() + fixture := newRepositoryAttachmentFixture(t) + parent := fixture.attach(t) + defer parent.Close() + + child, cleanup, _, err := fixture.composition.Attachments.Fork(t.Context(), parent.Environment, "merge-success") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fixture.composition.Attachments.lookup(child.Ref()).Logical.WorktreePath, "child.txt"), []byte("applied\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := fixture.composition.Attachments.Merge(t.Context(), child, parent.Environment); err != nil { + t.Fatalf("merge non-conflicting child: %v", err) + } + if got, err := os.ReadFile(filepath.Join(parent.Logical.WorktreePath, "child.txt")); err != nil || string(got) != "applied\n" { + t.Fatalf("merged parent bytes = %q, %v", got, err) + } + if err := cleanup(); err != nil { + t.Fatal(err) + } + + conflict, conflictCleanup, _, err := fixture.composition.Attachments.Fork(t.Context(), parent.Environment, "merge-conflict") + if err != nil { + t.Fatal(err) + } + defer conflictCleanup() + conflictAttachment := fixture.composition.Attachments.lookup(conflict.Ref()) + if err := os.WriteFile(filepath.Join(conflictAttachment.Logical.WorktreePath, "tracked.txt"), []byte("child\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent.Logical.WorktreePath, "tracked.txt"), []byte("parent\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := fixture.composition.Attachments.Merge(t.Context(), conflict, parent.Environment); !errors.Is(err, ErrMergeConflict) { + t.Fatalf("conflicting merge = %v, want ErrMergeConflict", err) + } + if _, err := os.Stat(conflictAttachment.Logical.WorktreePath); err != nil { + t.Fatalf("conflict did not preserve child: %v", err) + } +} + +type repositoryAttachmentFixture struct { + composition *RepositoryComposition + backend *repositoryCompositionBackend + repository string + verified VerifiedArtifacts + stateRoot string + runtimeConfig RepositoryRuntimeConfig +} + +func newRepositoryAttachmentFixture(t *testing.T) *repositoryAttachmentFixture { + t.Helper() + root := t.TempDir() + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + if err := os.Chmod(filepath.Join(repository, "README.md"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "tracked.txt"), []byte("source\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := gitexecForLogicalTest(t.Context(), repository, "add", "tracked.txt"); err != nil { + t.Fatal(err) + } + if _, err := gitexecForLogicalTest(t.Context(), repository, "commit", "-m", "tracked"); err != nil { + t.Fatal(err) + } + backend := &repositoryCompositionBackend{} + network := NewNetworkController(func() gomicrovmnet.Provider { return &fakeNetworkProvider{socket: filepath.Join(root, "network.sock")} }, &fakeGuestNetwork{}) + stateRoot := filepath.Join(root, "state") + runtimeConfig := RepositoryRuntimeConfig{Backend: backend, Network: network, GuestEgress: GuestEgressPolicy{Mode: EgressDenyAll}, DialGuest: backend.dialData, DialControl: backend.dialControl} + composition, err := NewRepositoryComposition(stateRoot, runtimeConfig) + if err != nil { + t.Fatal(err) + } + return &repositoryAttachmentFixture{ + composition: composition, backend: backend, repository: repository, + verified: repositoryVerifiedArtifacts(t, root), stateRoot: stateRoot, runtimeConfig: runtimeConfig, + } +} + +func (f *repositoryAttachmentFixture) attach(t *testing.T) *RepositoryAttachment { + t.Helper() + attachment, err := f.composition.Attachments.Attach(t.Context(), LogicalEnvironmentRequest{Owner: "operator", Checkout: f.repository, Verified: f.verified}) + if err != nil { + t.Fatalf("attach repository session: %v", err) + } + return attachment +} diff --git a/environment/microvm/repository_authority.go b/environment/microvm/repository_authority.go new file mode 100644 index 000000000..d7534fe88 --- /dev/null +++ b/environment/microvm/repository_authority.go @@ -0,0 +1,119 @@ +package microvm + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "strconv" +) + +const repositoryAuthorityBytes = 32 + +// RepositoryBootAuthority is fresh secret material injected once into one +// repository VM generation. It is never written into a session preboot file. +type RepositoryBootAuthority struct { + key [repositoryAuthorityBytes]byte +} + +func newRepositoryBootAuthority() (RepositoryBootAuthority, error) { + var authority RepositoryBootAuthority + if _, err := rand.Read(authority.key[:]); err != nil { + return RepositoryBootAuthority{}, err + } + return authority, nil +} + +func repositoryBootAuthorityFromBytes(value []byte) (RepositoryBootAuthority, error) { + if len(value) != repositoryAuthorityBytes { + return RepositoryBootAuthority{}, errors.New("invalid repository VM boot authority") + } + var authority RepositoryBootAuthority + copy(authority.key[:], value) + return authority, nil +} + +func (a RepositoryBootAuthority) bytes() []byte { return append([]byte(nil), a.key[:]...) } + +func (a RepositoryBootAuthority) digest() string { + digest := sha256.Sum256(a.key[:]) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} + +// RepositoryHealthChallenge is one unpredictable host challenge bound to an exact +// repository generation. It carries no bearer proof: only the booted guest can +// produce the corresponding response MAC. +type RepositoryHealthChallenge struct { + Owner string + RepositoryKey string + VMID string + Generation uint32 + Nonce [repositoryAuthorityBytes]byte +} + +// RepositoryHealthResponse authenticates both the challenge and the runtime +// status actually returned by the guest. +type RepositoryHealthResponse struct { + Status RuntimeStatus + MAC [sha256.Size]byte +} + +func (RepositoryBootAuthority) healthChallenge(record RepositoryVMRecord) (RepositoryHealthChallenge, error) { + challenge := RepositoryHealthChallenge{Owner: record.Owner, RepositoryKey: record.RepositoryKey, VMID: record.VMID, Generation: record.Generation} + if _, err := rand.Read(challenge.Nonce[:]); err != nil { + return RepositoryHealthChallenge{}, err + } + return challenge, nil +} + +// HealthResponse signs a challenge and the guest's actual status. +func (a RepositoryBootAuthority) HealthResponse(record RepositoryVMRecord, challenge RepositoryHealthChallenge, status RuntimeStatus) (RepositoryHealthResponse, error) { + if challenge.Owner != record.Owner || challenge.RepositoryKey != record.RepositoryKey || challenge.VMID != record.VMID || challenge.Generation != record.Generation { + return RepositoryHealthResponse{}, ErrRepositoryVMInconsistent + } + response := RepositoryHealthResponse{Status: status} + copy(response.MAC[:], a.healthMAC(challenge, status)) + return response, nil +} + +// VerifyHealth accepts only a response signed by the boot authority over the +// exact challenge, tuple, and returned health fields. +func (a RepositoryBootAuthority) VerifyHealth(record RepositoryVMRecord, challenge RepositoryHealthChallenge, response RepositoryHealthResponse) error { + if challenge.Owner != record.Owner || challenge.RepositoryKey != record.RepositoryKey || challenge.VMID != record.VMID || challenge.Generation != record.Generation || + !hmac.Equal(response.MAC[:], a.healthMAC(challenge, response.Status)) { + return ErrRepositoryVMInconsistent + } + return nil +} + +func (a RepositoryBootAuthority) healthMAC(challenge RepositoryHealthChallenge, status RuntimeStatus) []byte { + mac := hmac.New(sha256.New, a.key[:]) + writeAuthorityString(mac, challenge.Owner) + writeAuthorityString(mac, challenge.RepositoryKey) + writeAuthorityString(mac, challenge.VMID) + var number [8]byte + binary.BigEndian.PutUint32(number[:4], challenge.Generation) + _, _ = mac.Write(number[:4]) + _, _ = mac.Write(challenge.Nonce[:]) + if status.Live { + _, _ = mac.Write([]byte{1}) + } else { + _, _ = mac.Write([]byte{0}) + } + binary.BigEndian.PutUint32(number[:4], status.Generation) + _, _ = mac.Write(number[:4]) + writeAuthorityString(mac, status.VMID) + writeAuthorityString(mac, strconv.Itoa(status.PID)) + writeAuthorityString(mac, status.ProcessIdentity) + writeAuthorityString(mac, status.Endpoint) + return mac.Sum(nil) +} + +func writeAuthorityString(mac interface{ Write([]byte) (int, error) }, value string) { + var length [8]byte + binary.BigEndian.PutUint64(length[:], uint64(len(value))) + _, _ = mac.Write(length[:]) + _, _ = mac.Write([]byte(value)) +} diff --git a/environment/microvm/repository_composition.go b/environment/microvm/repository_composition.go new file mode 100644 index 000000000..12ffb1f1d --- /dev/null +++ b/environment/microvm/repository_composition.go @@ -0,0 +1,61 @@ +package microvm + +import ( + "errors" + "fmt" + + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +// RepositoryComposition is the production repository-scoped microvmd slice. +// Attachments connects session and isolated-delegation callers to the shared +// authenticated runtime and logical worktree manager. +type RepositoryComposition struct { + Runtime *RepositoryRuntime + Registry *RepositoryVMRegistry + Logical *RepositoryLogicalManager + Attachments *RepositoryAttachmentManager + // RestartHealthError is set when durable repository state predates this + // composition and its in-process network backend therefore cannot be reattached. + RestartHealthError error +} + +// NewRepositoryComposition wires the singleton lifecycle and logical routing to +// the concrete hypervisor/guest adapters. +func NewRepositoryComposition(stateRoot string, cfg RepositoryRuntimeConfig) (*RepositoryComposition, error) { + runtime, err := NewRepositoryRuntime(cfg) + if err != nil { + return nil, err + } + endpointRoots := []string(nil) + if cfg.EndpointRoot != "" { + endpointRoots = append(endpointRoots, cfg.EndpointRoot) + } + registry, err := OpenRepositoryVMRegistry(stateRoot, runtime, endpointRoots...) + if err != nil { + return nil, err + } + restartHealthErr := error(nil) + hasRecords, err := registry.HasRecords() + if err != nil { + return nil, fmt.Errorf("inspect repository restart health phase: %w", err) + } + if hasRecords { + restartHealthErr = fmt.Errorf("%w: repository restart health phase: hosted network backend is not live and cannot be reconstructed safely", ErrRepositoryVMInconsistent) + } + logical, err := NewRepositoryLogicalManager(registry, worktree.New(), runtime) + if err != nil { + return nil, err + } + if logical == nil { + return nil, errors.New("repository microvmd composition is incomplete") + } + attachments, err := newRepositoryAttachmentManager(logical) + if err != nil { + return nil, fmt.Errorf("load repository attachment inventory: %w", err) + } + return &RepositoryComposition{ + Runtime: runtime, Registry: registry, Logical: logical, + Attachments: attachments, RestartHealthError: restartHealthErr, + }, nil +} diff --git a/environment/microvm/repository_composition_test.go b/environment/microvm/repository_composition_test.go new file mode 100644 index 000000000..ff76382e6 --- /dev/null +++ b/environment/microvm/repository_composition_test.go @@ -0,0 +1,861 @@ +package microvm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + gomicrovmnet "github.com/stacklok/go-microvm/net" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/control/controltest" + "github.com/stacklok/mecatl/environment/microvm/guestagent" + "github.com/stacklok/mecatl/environment/microvm/guestexec" +) + +func TestRepositoryGuestAuthenticationRejectsCompetingConnectorBeforeDisclosure(t *testing.T) { + t.Parallel() + root := t.TempDir() + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + if err := os.Chmod(filepath.Join(repository, "README.md"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "tracked.txt"), []byte("source\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := gitexecForLogicalTest(t.Context(), repository, "add", "tracked.txt"); err != nil { + t.Fatal(err) + } + if _, err := gitexecForLogicalTest(t.Context(), repository, "commit", "-m", "tracked"); err != nil { + t.Fatal(err) + } + backend := &repositoryCompositionBackend{ + attackFirst: true, + attackerPayload: make(chan []byte, 1), + attackerClosed: make(chan struct{}, 1), + } + provider := &fakeNetworkProvider{socket: filepath.Join(root, "network.sock")} + composition, err := NewRepositoryComposition(filepath.Join(root, "state"), RepositoryRuntimeConfig{ + Backend: backend, Network: NewNetworkController(func() gomicrovmnet.Provider { return provider }, &fakeGuestNetwork{}), + GuestEgress: GuestEgressPolicy{Mode: EgressDenyAll}, DialGuest: backend.dialData, DialControl: backend.dialControl, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + started := time.Now() + logical, err := composition.Logical.Create(ctx, LogicalEnvironmentRequest{Owner: "operator", Checkout: repository, Verified: repositoryVerifiedArtifacts(t, root)}) + if err != nil { + t.Fatalf("real repository guest did not establish after stalled connector: %v", err) + } + if ctx.Err() != nil { + t.Fatalf("parent operation expired before the legitimate guest authenticated: %v", ctx.Err()) + } + if elapsed := time.Since(started); elapsed >= time.Second { + t.Fatalf("legitimate guest authentication took %v after stalled connector", elapsed) + } + defer logical.Close() + select { + case <-backend.attackerClosed: + case <-time.After(time.Second): + t.Fatal("stalled unauthenticated connector was not closed") + } + payload := <-backend.attackerPayload + for _, forbidden := range []string{"capability", "binding", "operation", "assigned_root", "session_id"} { + if strings.Contains(string(payload), forbidden) { + t.Fatalf("unauthenticated connector received %q in pre-auth frame: %s", forbidden, payload) + } + } + if !strings.Contains(string(payload), `"purpose":"control"`) || !strings.Contains(string(payload), `"generation":`) || strings.Contains(string(payload), `"generation":0`) { + t.Fatalf("authentication challenge was not generation/purpose bound: %s", payload) + } +} + +func TestRepositoryProductionCompositionBootsOnceAndRoutesGuestMounts(t *testing.T) { + t.Parallel() + root := t.TempDir() + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + if err := os.Chmod(filepath.Join(repository, "README.md"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "tracked.txt"), []byte("source\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := gitexecForLogicalTest(t.Context(), repository, "add", "tracked.txt"); err != nil { + t.Fatal(err) + } + if _, err := gitexecForLogicalTest(t.Context(), repository, "commit", "-m", "tracked"); err != nil { + t.Fatal(err) + } + + backend := &repositoryCompositionBackend{} + provider := &fakeNetworkProvider{socket: filepath.Join(root, "network.sock")} + network := NewNetworkController(func() gomicrovmnet.Provider { return provider }, &fakeGuestNetwork{}) + + composition, err := NewRepositoryComposition(filepath.Join(root, "state"), RepositoryRuntimeConfig{ + Backend: backend, Network: network, GuestEgress: GuestEgressPolicy{Mode: EgressDenyAll}, + DialGuest: backend.dialData, DialControl: backend.dialControl, + }) + if err != nil { + t.Fatal(err) + } + verified := repositoryVerifiedArtifacts(t, root) + createCtx, cancelCreate := context.WithCancel(t.Context()) + first, err := composition.Logical.Create(createCtx, LogicalEnvironmentRequest{Owner: "operator", Checkout: repository, Verified: verified}) + if err != nil { + t.Fatal(err) + } + second, err := composition.Logical.Create(t.Context(), LogicalEnvironmentRequest{Owner: "operator", Checkout: repository, Verified: verified}) + if err != nil { + t.Fatal(err) + } + defer first.Close() + defer second.Close() + + if backend.starts != 1 || first.Repository.RootFSPath != second.Repository.RootFSPath { + t.Fatalf("repository composition starts=%d roots=%q/%q", backend.starts, first.Repository.RootFSPath, second.Repository.RootFSPath) + } + if backend.launch.VsockPort != control.GuestControlPort { + t.Fatalf("repository launch vsock port = %d, want %d", backend.launch.VsockPort, control.GuestControlPort) + } + if first.GuestRoot == first.WorktreePath || second.GuestRoot == second.WorktreePath || !strings.HasPrefix(first.GuestRoot, RepositoryGuestMountRoot+"/") { + t.Fatalf("host paths leaked as guest roots: first=%+v second=%+v", first, second) + } + if got, err := first.Workspace.Read(t.Context(), "tracked.txt"); err != nil || string(got) != "source\n" { + t.Fatalf("first guest mount read = %q, %v", got, err) + } + if got, err := second.Workspace.Read(t.Context(), "tracked.txt"); err != nil || string(got) != "source\n" { + t.Fatalf("second guest mount read = %q, %v", got, err) + } + if first.Binding.Ref == second.Binding.Ref || first.GuestRoot == second.GuestRoot { + t.Fatal("logical guest refs or roots were shared") + } + cancelCreate() + if err := provider.startCtx.Err(); err != nil { + t.Fatalf("repository generation network inherited create cancellation: %v", err) + } +} + +func TestRepositoryExecStreamForwardsManagedTemporaryScopeIntoGuest(t *testing.T) { + fixture := newRepositoryAttachmentFixture(t) + attachment := fixture.attach(t) + environmentID, generation, err := parseEnvironmentRef(attachment.Logical.Ref) + if err != nil { + t.Fatal(err) + } + binding := control.Binding{Owner: "operator", SessionID: "session", EnvironmentID: environmentID, Ref: attachment.Logical.Ref.ID, Generation: generation} + if err := fixture.composition.Attachments.register(binding, attachment); err != nil { + t.Fatal(err) + } + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{Control: auth, RepositoryAttachments: fixture.composition.Attachments}) + if err != nil { + t.Fatal(err) + } + daemon.repositoryBindings[binding.Ref] = repositoryDaemonBinding{binding: binding} + payload, err := json.Marshal(map[string]string{ + "command": `printf '%s\n' "$TMPDIR"; printf managed-ok > "$TMPDIR/proof"; cat "$TMPDIR/proof"`, + "temporary_scope": "managed", + }) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + response, err := daemon.repositoryExecStream(t.Context(), LifecycleRequest{Binding: binding, Payload: payload}, func(frame LifecycleExecStream) error { + _, writeErr := output.Write(frame.Data) + return writeErr + }) + if err != nil { + t.Fatalf("repository managed exec: %v", err) + } + var final struct { + ExitCode int `json:"exit_code"` + } + if err := json.Unmarshal(response.Payload, &final); err != nil || final.ExitCode != 0 { + t.Fatalf("repository managed exec response = %+v, %v", response, err) + } + lines := strings.Split(strings.TrimSpace(output.String()), "\n") + if len(lines) != 2 || !strings.HasPrefix(lines[0], "/tmp/mecatl-managed-") || lines[1] != "managed-ok" { + t.Fatalf("repository managed output = %q", output.String()) + } + if _, err := os.Stat(lines[0]); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("repository managed temporary directory survived: %v", err) + } + if _, err := os.Stat(filepath.Join(attachment.Logical.WorktreePath, "proof")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("repository managed temporary file escaped into worktree: %v", err) + } +} + +func TestRepositoryExecStreamRejectsDetachedAndUnknownLogicalBindings(t *testing.T) { + fixture := newRepositoryAttachmentFixture(t) + attachment := fixture.attach(t) + environmentID, generation, err := parseEnvironmentRef(attachment.Logical.Ref) + if err != nil { + t.Fatal(err) + } + binding := control.Binding{Owner: "operator", SessionID: "session", EnvironmentID: environmentID, Ref: attachment.Logical.Ref.ID, Generation: generation} + if err := fixture.composition.Attachments.register(binding, attachment); err != nil { + t.Fatal(err) + } + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{Control: auth, RepositoryAttachments: fixture.composition.Attachments}) + if err != nil { + t.Fatal(err) + } + daemon.repositoryBindings[binding.Ref] = repositoryDaemonBinding{binding: binding} + if response, opErr := daemon.repositoryOperation(t.Context(), LifecycleRequest{Operation: LifecycleDetach, Binding: binding}); opErr != nil || response.Binding != binding { + t.Fatalf("detach response=%+v err=%v", response, opErr) + } + + payload, err := json.Marshal(map[string]string{"command": "printf unsafe"}) + if err != nil { + t.Fatal(err) + } + unknown := binding + unknown.EnvironmentID = "logical-00000000000000000000000000000000" + unknown.Ref = unknown.EnvironmentID + "@" + fmt.Sprint(unknown.Generation) + for name, claim := range map[string]control.Binding{"detached": binding, "unknown": unknown} { + t.Run(name, func(t *testing.T) { + server, client := net.Pipe() + serveDone := make(chan error, 1) + go func() { serveDone <- daemon.ServeConn(t.Context(), server) }() + codec := control.NewCodec(control.DefaultMaxMessageBytes) + if err := codec.Write(client, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleExec, Binding: claim, Payload: payload}); err != nil { + t.Fatal(err) + } + var response LifecycleResponse + if err := codec.Read(client, &response); err != nil { + t.Fatal(err) + } + _ = client.Close() + if response.ErrorCode != "binding_mismatch" || !strings.Contains(response.ErrorText, control.ErrBindingMismatch.Error()) { + t.Fatalf("stream exec response = %+v, want binding mismatch", response) + } + if err := <-serveDone; !errors.Is(err, control.ErrBindingMismatch) { + t.Fatalf("serve error = %v, want binding mismatch", err) + } + }) + } +} + +func TestRepositoryAttachmentInventoryAndDeleteSurviveRestart(t *testing.T) { + fixture := newRepositoryAttachmentFixture(t) + attachments := make([]*RepositoryAttachment, 0, 2) + for _, sessionID := range []string{"session-a", "session-b"} { + attachment := fixture.attach(t) + environmentID, generation, err := parseEnvironmentRef(attachment.Logical.Ref) + if err != nil { + t.Fatal(err) + } + binding := control.Binding{Owner: "operator", SessionID: sessionID, EnvironmentID: environmentID, Ref: attachment.Logical.Ref.ID, Generation: generation} + if err := fixture.composition.Attachments.register(binding, attachment); err != nil { + t.Fatal(err) + } + attachments = append(attachments, attachment) + } + if err := os.WriteFile(filepath.Join(attachments[1].Logical.WorktreePath, "dirty.txt"), []byte("retain\n"), 0o600); err != nil { + t.Fatal(err) + } + + restarted, err := NewRepositoryComposition(fixture.stateRoot, fixture.runtimeConfig) + if err != nil { + t.Fatal(err) + } + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{Control: auth, RepositoryAttachments: restarted.Attachments}) + if err != nil { + t.Fatal(err) + } + page := func(continuation string) LifecycleInventoryPage { + payload, marshalErr := json.Marshal(LifecycleInventoryRequest{PageSize: 1, Continuation: continuation}) + if marshalErr != nil { + t.Fatal(marshalErr) + } + response := daemon.handleStandardRequest(t.Context(), LifecycleRequest{Operation: LifecycleInventory, Binding: control.Binding{Owner: "operator"}, Payload: payload}) + if response.Err != nil { + t.Fatal(response.Err) + } + var result LifecycleInventoryPage + if err := json.Unmarshal(response.Payload, &result); err != nil { + t.Fatal(err) + } + return result + } + first := page("") + second := page(first.Continuation) + if len(first.Entries) != 1 || len(second.Entries) != 1 || first.Continuation == "" || second.Continuation != "" || first.Entries[0].SessionID != "session-a" || second.Entries[0].SessionID != "session-b" { + t.Fatalf("restarted inventory pages = %+v / %+v", first, second) + } + clean := first.Entries[0] + cleanBinding := control.Binding{Owner: clean.Owner, SessionID: clean.SessionID, EnvironmentID: clean.EnvironmentID, Ref: clean.Ref, Generation: clean.Generation} + cleanResponse, err := daemon.repositoryOperation(t.Context(), LifecycleRequest{Operation: LifecycleDelete, Binding: cleanBinding}) + if err != nil { + t.Fatal(err) + } + var cleanResult LifecycleDeleteResult + if err := json.Unmarshal(cleanResponse.Payload, &cleanResult); err != nil || cleanResult.WorktreeRetained { + t.Fatalf("restarted clean delete = %+v, %v", cleanResult, err) + } + if _, err := os.Stat(cleanResult.WorktreePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("restarted clean worktree still exists: %v", err) + } + dirty := second.Entries[0] + binding := control.Binding{Owner: dirty.Owner, SessionID: dirty.SessionID, EnvironmentID: dirty.EnvironmentID, Ref: dirty.Ref, Generation: dirty.Generation} + response, err := daemon.repositoryOperation(t.Context(), LifecycleRequest{Operation: LifecycleDelete, Binding: binding}) + if err != nil { + t.Fatal(err) + } + var result LifecycleDeleteResult + if err := json.Unmarshal(response.Payload, &result); err != nil || !result.WorktreeRetained || result.WorktreePath != dirty.WorktreePath { + t.Fatalf("restarted dirty delete = %+v, %v", result, err) + } + retained := page("") + retained = page(retained.Continuation) + if len(retained.Entries) != 1 || retained.Entries[0].State != EnvironmentDestroyed || !strings.Contains(retained.Entries[0].Error, "dirty worktree retained") { + t.Fatalf("restarted retained inventory = %+v", retained) + } +} + +func TestRepositoryProductionInventoryPaginationAndLogicalDelete(t *testing.T) { + root := t.TempDir() + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + if err := os.Chmod(filepath.Join(repository, "README.md"), 0o644); err != nil { + t.Fatal(err) + } + backend := &repositoryCompositionBackend{} + provider := &fakeNetworkProvider{socket: filepath.Join(root, "network.sock")} + composition, err := NewRepositoryComposition(filepath.Join(root, "state"), RepositoryRuntimeConfig{ + Backend: backend, Network: NewNetworkController(func() gomicrovmnet.Provider { return provider }, &fakeGuestNetwork{}), + GuestEgress: GuestEgressPolicy{Mode: EgressDenyAll}, DialGuest: backend.dialData, DialControl: backend.dialControl, + }) + if err != nil { + t.Fatal(err) + } + verified := repositoryVerifiedArtifacts(t, root) + attachments := make([]*RepositoryAttachment, 0, 2) + for _, sessionID := range []string{"session-a", "session-b"} { + attachment, attachErr := composition.Attachments.Attach(t.Context(), LogicalEnvironmentRequest{Owner: "local", Checkout: repository, Verified: verified}) + if attachErr != nil { + t.Fatal(attachErr) + } + attachments = append(attachments, attachment) + environmentID, generation, parseErr := parseEnvironmentRef(attachment.Logical.Ref) + if parseErr != nil { + t.Fatal(parseErr) + } + binding := control.Binding{Owner: "local", SessionID: sessionID, EnvironmentID: environmentID, Ref: attachment.Logical.Ref.ID, Generation: generation} + if registerErr := composition.Attachments.register(binding, attachment); registerErr != nil { + t.Fatal(registerErr) + } + } + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + daemon, err := NewDaemon(DaemonConfig{Control: auth, RepositoryAttachments: composition.Attachments}) + if err != nil { + t.Fatal(err) + } + if daemon.manager != nil || daemon.registry != nil || daemon.reconciler != nil { + t.Fatal("repository production daemon composed a legacy lifecycle authority") + } + inventory := func(continuation string) LifecycleInventoryPage { + payload, marshalErr := json.Marshal(LifecycleInventoryRequest{PageSize: 1, Continuation: continuation}) + if marshalErr != nil { + t.Fatal(marshalErr) + } + response := daemon.handleStandardRequest(t.Context(), LifecycleRequest{Operation: LifecycleInventory, Binding: control.Binding{Owner: "local"}, Payload: payload}) + if response.Err != nil { + t.Fatal(response.Err) + } + var page LifecycleInventoryPage + if unmarshalErr := json.Unmarshal(response.Payload, &page); unmarshalErr != nil { + t.Fatal(unmarshalErr) + } + return page + } + first := inventory("") + second := inventory(first.Continuation) + if len(first.Entries) != 1 || len(second.Entries) != 1 || first.Continuation == "" || second.Continuation != "" { + t.Fatalf("repository inventory pages = %+v / %+v", first, second) + } + if first.Entries[0].SessionID != "session-a" || second.Entries[0].SessionID != "session-b" || + first.Entries[0].Generation != second.Entries[0].Generation || first.Entries[0].WorktreePath == second.Entries[0].WorktreePath { + t.Fatalf("repository logical inventory is dishonest: %+v / %+v", first.Entries[0], second.Entries[0]) + } + cleanBinding := control.Binding{Owner: first.Entries[0].Owner, SessionID: first.Entries[0].SessionID, EnvironmentID: first.Entries[0].EnvironmentID, Ref: first.Entries[0].Ref, Generation: first.Entries[0].Generation} + cleanResponse, err := daemon.repositoryOperation(t.Context(), LifecycleRequest{Operation: LifecycleDelete, Binding: cleanBinding}) + if err != nil { + t.Fatal(err) + } + var clean LifecycleDeleteResult + if err := json.Unmarshal(cleanResponse.Payload, &clean); err != nil || clean.WorktreeRetained { + t.Fatalf("clean logical delete = %+v, %v", clean, err) + } + if _, statErr := os.Stat(clean.WorktreePath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("clean logical worktree still exists: %v", statErr) + } + if err := os.WriteFile(filepath.Join(attachments[1].Logical.WorktreePath, "dirty.txt"), []byte("retain\n"), 0o600); err != nil { + t.Fatal(err) + } + dirtyEntry := second.Entries[0] + dirtyBinding := control.Binding{Owner: dirtyEntry.Owner, SessionID: dirtyEntry.SessionID, EnvironmentID: dirtyEntry.EnvironmentID, Ref: dirtyEntry.Ref, Generation: dirtyEntry.Generation} + dirtyResponse, err := daemon.repositoryOperation(t.Context(), LifecycleRequest{Operation: LifecycleDelete, Binding: dirtyBinding}) + if err != nil { + t.Fatal(err) + } + var dirty LifecycleDeleteResult + if err := json.Unmarshal(dirtyResponse.Payload, &dirty); err != nil || !dirty.WorktreeRetained { + t.Fatalf("dirty logical delete = %+v, %v", dirty, err) + } + if _, statErr := os.Stat(filepath.Join(dirty.WorktreePath, "dirty.txt")); statErr != nil { + t.Fatalf("dirty logical state was not retained: %v", statErr) + } + retained := inventory("") + if len(retained.Entries) != 1 || retained.Entries[0].Health != GenerationStale || !strings.Contains(retained.Entries[0].Error, "repository VM deletion is not supported") { + t.Fatalf("retained logical inventory = %+v", retained) + } +} + +func TestRepositoryRuntimeAbortRejectsDifferentGeneration(t *testing.T) { + root := t.TempDir() + runtime, err := NewRepositoryRuntime(RepositoryRuntimeConfig{ + Backend: &rollbackRuntimeBackend{}, + Network: NewNetworkController(func() gomicrovmnet.Provider { return &fakeNetworkProvider{socket: filepath.Join(root, "network.sock")} }, &fakeGuestNetwork{}), + DialGuest: func(context.Context, string) (io.ReadWriteCloser, error) { return nil, ErrEnvironmentUnavailable }, + DialControl: func(context.Context, string) (io.ReadWriteCloser, error) { return nil, ErrEnvironmentUnavailable }, + }) + if err != nil { + t.Fatal(err) + } + record := RepositoryVMRecord{Owner: "operator", RepositoryKey: "repo", VMID: "vm", Generation: 1, Endpoint: filepath.Join(root, "guest.sock"), RootFSPath: filepath.Join(root, "rootfs"), AuthorityDigest: "authority"} + instance := &rollbackRuntimeInstance{} + runtime.vms[record.VMID] = &repositoryRuntimeGeneration{record: record, instance: instance} + other := record + other.Generation++ + if err := runtime.Abort(t.Context(), other); !errors.Is(err, ErrRepositoryVMInconsistent) { + t.Fatalf("different generation abort = %v, want inconsistency", err) + } + if instance.stops != 0 || instance.removes != 0 { + t.Fatalf("different generation was torn down: stop=%d remove=%d", instance.stops, instance.removes) + } + if _, err := runtime.generation(record); err != nil { + t.Fatalf("healthy generation ownership was erased: %v", err) + } +} + +func TestRepositoryRuntimeStartRejectsUnsafeObjectStoreBeforeBackend(t *testing.T) { + for _, tc := range []struct { + name string + replaceLate bool + }{ + {name: "symlink"}, + {name: "replacement race", replaceLate: true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + common := filepath.Join(root, "repository.git") + objects := filepath.Join(common, "objects") + if err := os.MkdirAll(objects, 0o700); err != nil { + t.Fatal(err) + } + target := t.TempDir() + if !tc.replaceLate { + if err := os.Remove(objects); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, objects); err != nil { + t.Fatal(err) + } + } + + provider := &fakeNetworkProvider{socket: filepath.Join(root, "network.sock")} + if tc.replaceLate { + provider.startHook = func() error { + if err := os.Rename(objects, objects+".replaced"); err != nil { + return err + } + return os.Symlink(target, objects) + } + } + backend := &rollbackRuntimeBackend{instance: &rollbackRuntimeInstance{}} + runtime, err := NewRepositoryRuntime(RepositoryRuntimeConfig{ + Backend: backend, + Network: NewNetworkController(func() gomicrovmnet.Provider { return provider }, &fakeGuestNetwork{}), + DialGuest: func(context.Context, string) (io.ReadWriteCloser, error) { + return nil, ErrEnvironmentUnavailable + }, + DialControl: func(context.Context, string) (io.ReadWriteCloser, error) { + return nil, ErrEnvironmentUnavailable + }, + }) + if err != nil { + t.Fatal(err) + } + authority, err := newRepositoryBootAuthority() + if err != nil { + t.Fatal(err) + } + record := RepositoryVMRecord{ + Owner: "operator", RepositoryKey: "repo", GitCommonDirectory: common, + VMID: "vm", Generation: 1, Endpoint: filepath.Join(root, "guest.sock"), RootFSPath: filepath.Join(root, "rootfs"), + } + _, startErr := runtime.Start(t.Context(), record, VerifiedArtifacts{}, authority) + if !tc.replaceLate { + if startErr == nil { + t.Fatal("unsafe repository object store was accepted") + } + if backend.starts != 0 { + t.Fatalf("backend Start calls = %d, want 0", backend.starts) + } + return + } + if backend.starts != 1 { + t.Fatalf("backend Start calls = %d, want private snapshot launch", backend.starts) + } + mounted := backend.launch.Mounts[1].HostPath + if mounted == objects || mounted == target || !strings.HasPrefix(mounted, root+string(filepath.Separator)) { + t.Fatalf("backend received caller-controlled object path %q", mounted) + } + }) + } +} + +func TestRepositoryRuntimeStartRollsBackEveryOwnedAcquisition(t *testing.T) { + for _, tc := range []struct { + stage string + networkDirConflict bool + retainsOwnership bool + }{ + {stage: "network"}, + {stage: "network-dir", networkDirConflict: true}, + {stage: "backend"}, + {stage: "wait"}, + {stage: "status"}, + {stage: "authenticated-readiness"}, + {stage: "stop"}, + {stage: "remove", retainsOwnership: true}, + {stage: "wedged-remove", retainsOwnership: true}, + {stage: "cancelled-rollback"}, + } { + t.Run(tc.stage, func(t *testing.T) { + root := t.TempDir() + endpoint := filepath.Join(root, "guest.sock") + provider := &fakeNetworkProvider{socket: filepath.Join(root, "network.sock")} + if tc.stage == "network" { + provider.socket = "" + } + if tc.networkDirConflict { + if err := os.WriteFile(endpoint+".network", []byte("conflict"), 0o600); err != nil { + t.Fatal(err) + } + } + instance := &rollbackRuntimeInstance{stage: tc.stage} + backend := &rollbackRuntimeBackend{stage: tc.stage, instance: instance} + runtime, err := NewRepositoryRuntime(RepositoryRuntimeConfig{ + Backend: backend, Network: NewNetworkController(func() gomicrovmnet.Provider { return provider }, &fakeGuestNetwork{}), + GuestEgress: GuestEgressPolicy{Mode: EgressDenyAll}, UnixEndpoint: true, + }) + if err != nil { + t.Fatal(err) + } + if tc.stage == "wedged-remove" { + runtime.rollbackTimeout = time.Nanosecond + } + authority, err := newRepositoryBootAuthority() + if err != nil { + t.Fatal(err) + } + common := filepath.Join(root, "repository.git") + if err := os.MkdirAll(filepath.Join(common, "objects"), 0o700); err != nil { + t.Fatal(err) + } + record := RepositoryVMRecord{Owner: "operator", RepositoryKey: "repo", GitCommonDirectory: common, VMID: "vm", Generation: 1, Endpoint: endpoint, RootFSPath: filepath.Join(root, "rootfs")} + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) + instance.cancelParent = cancel + defer cancel() + _, startErr := runtime.Start(ctx, record, VerifiedArtifacts{}, authority) + if startErr == nil { + t.Fatalf("%s failure was accepted", tc.stage) + } + if tc.stage == "stop" && !strings.Contains(startErr.Error(), "injected stop failure") { + t.Fatalf("stop cleanup failure not joined: %v", startErr) + } + if tc.stage == "remove" && !strings.Contains(startErr.Error(), "injected remove failure") { + t.Fatalf("remove cleanup failure not joined: %v", startErr) + } + if tc.stage == "wedged-remove" && !errors.Is(startErr, context.DeadlineExceeded) { + t.Fatalf("wedged cleanup was not bounded: %v", startErr) + } + if tc.stage == "cancelled-rollback" && (!instance.rollbackDetached || !instance.rollbackBounded) { + t.Fatalf("rollback context detached=%t bounded=%t", instance.rollbackDetached, instance.rollbackBounded) + } + wantNetworkStops := 1 + if tc.networkDirConflict || tc.retainsOwnership { + wantNetworkStops = 0 + } + if tc.stage != "network-dir" && provider.stops != wantNetworkStops { + t.Fatalf("network stops = %d, want %d", provider.stops, wantNetworkStops) + } + if tc.stage != "backend" && tc.stage != "network" && !tc.networkDirConflict && (instance.stops != 1 || instance.removes != 1) { + t.Fatalf("instance cleanup = stop %d remove %d, want 1/1", instance.stops, instance.removes) + } + if tc.retainsOwnership { + if _, statErr := os.Lstat(endpoint); statErr != nil { + t.Fatalf("failed rollback lost owned endpoint: %v", statErr) + } + if _, statErr := os.Lstat(endpoint + ".network"); statErr != nil { + t.Fatalf("failed rollback lost owned network directory: %v", statErr) + } + if _, err := runtime.generation(record); err != nil { + t.Fatalf("failed rollback erased generation ownership: %v", err) + } + return + } + if _, statErr := os.Lstat(endpoint); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("rollback retained %s: %v", endpoint, statErr) + } + if tc.networkDirConflict { + if content, err := os.ReadFile(endpoint + ".network"); err != nil || string(content) != "conflict" { + t.Fatalf("rollback did not preserve network conflict: %q, %v", content, err) + } + } else if _, statErr := os.Lstat(endpoint + ".network"); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("rollback retained %s: %v", endpoint+".network", statErr) + } + if _, ok := runtime.listeners[endpoint]; ok { + t.Fatal("rollback retained listener ownership") + } + if _, err := runtime.generation(record); !errors.Is(err, ErrEnvironmentUnavailable) { + t.Fatalf("rollback retained generation: %v", err) + } + if snapshots, err := filepath.Glob(filepath.Join(root, ".git-objects-*")); err != nil || len(snapshots) != 0 { + t.Fatalf("rollback retained Git object snapshots %v: %v", snapshots, err) + } + }) + } +} + +type rollbackRuntimeBackend struct { + stage string + instance *rollbackRuntimeInstance + starts int + launch GoMicroVMLaunch +} + +func (b *rollbackRuntimeBackend) Start(_ context.Context, launch GoMicroVMLaunch) (GoMicroVMInstance, error) { + b.starts++ + b.launch = launch + if b.stage == "backend" { + return nil, errors.New("injected backend failure") + } + return b.instance, nil +} +func (*rollbackRuntimeBackend) Open(context.Context, EnvironmentRecord) (GoMicroVMInstance, error) { + return nil, ErrEnvironmentUnavailable +} + +type rollbackRuntimeInstance struct { + stage string + stops, removes int + cancelParent context.CancelFunc + rollbackDetached, rollbackBounded bool +} + +func (i *rollbackRuntimeInstance) WaitReady(context.Context) error { + switch i.stage { + case "wait", "stop", "remove", "wedged-remove": + return errors.New("injected wait failure") + case "cancelled-rollback": + i.cancelParent() + return errors.New("injected wait failure after cancellation") + } + return nil +} +func (i *rollbackRuntimeInstance) Status(context.Context) (RuntimeStatus, error) { + if i.stage == "status" { + return RuntimeStatus{}, errors.New("injected status failure") + } + return RuntimeStatus{Live: true, VMID: "vm", Generation: 1}, nil +} +func (i *rollbackRuntimeInstance) Stop(ctx context.Context) error { + i.stops++ + if i.stage == "cancelled-rollback" { + i.rollbackDetached = ctx.Err() == nil + _, i.rollbackBounded = ctx.Deadline() + } + if i.stage == "stop" { + return errors.New("injected stop failure") + } + return nil +} + +func (i *rollbackRuntimeInstance) Remove(ctx context.Context) error { + i.removes++ + switch i.stage { + case "remove": + return errors.New("injected remove failure") + case "wedged-remove": + <-ctx.Done() + return ctx.Err() + } + return nil +} + +func TestRepositoryProductionReadinessRequiresAuthenticatedGuestStartupAfterIPv6Disablement(t *testing.T) { + t.Parallel() + root := t.TempDir() + backend := &repositoryCompositionBackend{omitGuestServer: true} + provider := &fakeNetworkProvider{socket: filepath.Join(root, "network.sock")} + runtime, err := NewRepositoryRuntime(RepositoryRuntimeConfig{ + Backend: backend, + Network: NewNetworkController(func() gomicrovmnet.Provider { return provider }, &fakeGuestNetwork{}), + GuestEgress: GuestEgressPolicy{Mode: EgressDenyAll}, + DialGuest: backend.dialData, DialControl: backend.dialControl, + }) + if err != nil { + t.Fatal(err) + } + authority, err := newRepositoryBootAuthority() + if err != nil { + t.Fatal(err) + } + common := filepath.Join(root, "repository.git") + if err := os.MkdirAll(filepath.Join(common, "objects"), 0o700); err != nil { + t.Fatal(err) + } + record := RepositoryVMRecord{ + Owner: "operator", RepositoryKey: "repository-key", GitCommonDirectory: common, VMID: "repository-vm", Generation: 7, + Endpoint: filepath.Join(root, "guest.sock"), RootFSPath: filepath.Join(root, "rootfs"), + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if _, err := runtime.Start(ctx, record, VerifiedArtifacts{}, authority); err == nil || + !strings.Contains(err.Error(), "authenticated repository guest startup and IPv6 disablement") { + t.Fatalf("readiness without post-enforcement guest proof = %v", err) + } + if provider.stops != 1 { + t.Fatalf("failed readiness left network provider running: stops=%d", provider.stops) + } + if _, err := runtime.generation(record); !errors.Is(err, ErrEnvironmentUnavailable) { + t.Fatalf("failed readiness retained generation: %v", err) + } +} + +type repositoryCompositionBackend struct { + mu sync.Mutex + starts int + launch GoMicroVMLaunch + server *guestagent.RepositoryServer + status RuntimeStatus + omitGuestServer bool + attackFirst bool + attackerPayload chan []byte + attackerClosed chan struct{} +} + +func (b *repositoryCompositionBackend) Start(_ context.Context, launch GoMicroVMLaunch) (GoMicroVMInstance, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.starts++ + b.launch = launch + if !launch.RepositoryRootFS || len(launch.Mounts) != 2 || launch.Mounts[0].Tag != repositoryMountTag || + launch.Mounts[1].Tag != repositoryObjectMountTag || !launch.Mounts[1].ReadOnly { + return nil, errors.New("repository launch contract missing") + } + hostMount := launch.Mounts[0].HostPath + identity := guestexec.WorkloadIdentity{UID: uint32(os.Getuid()), GID: uint32(os.Getgid())} //nolint:gosec // test process identity + contract := guestexec.DefaultRuntimeContract() + contract.Identity = identity + server, err := guestagent.NewRepositoryServer(guestagent.RepositoryServerConfig{ + Owner: launch.RepositoryOwner, RepositoryKey: launch.RepositoryKey, VMID: launch.VMID, Endpoint: launch.Endpoint, + Generation: launch.Generation, AuthorityKey: launch.CapabilityKey, WorkloadIdentity: identity, RuntimeContract: contract, + ResolveRoot: func(guestRoot string) (string, error) { + rel, err := filepath.Rel(RepositoryGuestMountRoot, guestRoot) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") { + return "", errors.New("invalid guest root") + } + return filepath.Join(hostMount, filepath.FromSlash(rel)), nil + }, + }) + if err != nil { + return nil, err + } + if !b.omitGuestServer { + b.server = server + } + b.status = RuntimeStatus{Live: true, Generation: launch.Generation, VMID: launch.VMID, PID: 4242, ProcessIdentity: "fake-hypervisor-boot", Endpoint: launch.Endpoint} + return repositoryCompositionInstance{status: b.status}, nil +} + +func (*repositoryCompositionBackend) Open(context.Context, EnvironmentRecord) (GoMicroVMInstance, error) { + return nil, ErrEnvironmentUnavailable +} + +func (b *repositoryCompositionBackend) dialControl(ctx context.Context, _ string) (io.ReadWriteCloser, error) { + return b.dial(ctx) +} +func (b *repositoryCompositionBackend) dialData(ctx context.Context, _ string) (io.ReadWriteCloser, error) { + return b.dial(ctx) +} +func (b *repositoryCompositionBackend) dial(ctx context.Context) (io.ReadWriteCloser, error) { + b.mu.Lock() + server := b.server + attack := b.attackFirst + b.attackFirst = false + attackerPayload := b.attackerPayload + attackerClosed := b.attackerClosed + b.mu.Unlock() + if server == nil { + return nil, errors.New("repository guest is not booted") + } + host, guest := net.Pipe() + if attack { + go func() { + defer guest.Close() + codec := control.NewCodec(control.DefaultMaxMessageBytes) + var challenge json.RawMessage + if err := codec.Read(guest, &challenge); err == nil { + attackerPayload <- append([]byte(nil), challenge...) + var ignored json.RawMessage + if err := codec.Read(guest, &ignored); err != nil { + attackerClosed <- struct{}{} + } + } + }() + return host, nil + } + go func() { + _ = server.ServeAuthenticated(ctx, guest) + _ = guest.Close() + }() + return host, nil +} + +type repositoryCompositionInstance struct{ status RuntimeStatus } + +func (repositoryCompositionInstance) WaitReady(context.Context) error { return nil } +func (i repositoryCompositionInstance) Status(context.Context) (RuntimeStatus, error) { + return i.status, nil +} +func (repositoryCompositionInstance) Stop(context.Context) error { return nil } +func (repositoryCompositionInstance) Remove(context.Context) error { return nil } diff --git a/environment/microvm/repository_daemon.go b/environment/microvm/repository_daemon.go new file mode 100644 index 000000000..fe3f8e997 --- /dev/null +++ b/environment/microvm/repository_daemon.go @@ -0,0 +1,243 @@ +package microvm + +import ( + "context" + "encoding/json" + "strings" + + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/guestexec" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +func (d *Daemon) handleRepositoryRequest(ctx context.Context, request LifecycleRequest) (LifecycleResponse, bool) { + if d.repositoryAttachments == nil { + return LifecycleResponse{}, false + } + var response LifecycleResponse + var err error + switch { + case request.Operation == LifecycleCreate && request.Provision != nil: + response, err = d.repositoryCreate(ctx, request) + case strings.HasPrefix(request.Binding.EnvironmentID, "logical-"): + response, err = d.repositoryOperation(ctx, request) + default: + return LifecycleResponse{}, false + } + if err != nil { + return lifecycleFailure(err), true + } + return response, true +} + +func (d *Daemon) repositoryCreate(ctx context.Context, request LifecycleRequest) (LifecycleResponse, error) { + if d.repositoryProvisioner == nil || request.Provision == nil || request.Binding.Owner == "" || request.Binding.SessionID == "" || request.Provision.Owner != request.Binding.Owner || request.Provision.SessionID != request.Binding.SessionID { + return LifecycleResponse{}, control.ErrBindingMismatch + } + logicalRequest, status, err := d.repositoryProvisioner(ctx, *request.Provision) + if err != nil { + return LifecycleResponse{}, err + } + attachment, err := d.repositoryAttachments.Attach(ctx, logicalRequest) + if err != nil { + return LifecycleResponse{}, err + } + environmentID, generation, err := parseEnvironmentRef(attachment.Logical.Ref) + if err != nil || generation != attachment.Logical.Repository.Generation { + _ = attachment.Close() + return LifecycleResponse{}, ErrRepositoryVMInconsistent + } + binding := control.Binding{Owner: request.Binding.Owner, SessionID: request.Binding.SessionID, EnvironmentID: environmentID, Ref: attachment.Logical.Ref.ID, Generation: generation} + if err := d.repositoryAttachments.register(binding, attachment); err != nil { + _ = attachment.Close() + return LifecycleResponse{}, err + } + d.repositoryMu.Lock() + d.repositoryBindings[binding.Ref] = repositoryDaemonBinding{binding: binding, status: status} + d.repositoryMu.Unlock() + created := &LifecycleCreated{ + Ref: attachment.Logical.Ref, Generation: generation, HostWorktree: attachment.Logical.WorktreePath, + GuestRoot: worktree.GuestWorkspace, Profile: status.Profile, GuestEgress: status.GuestEgress, HostEgress: status.HostEgress, + } + return LifecycleResponse{Binding: binding, Created: created}, nil +} + +func (d *Daemon) repositoryOperation(ctx context.Context, request LifecycleRequest) (LifecycleResponse, error) { //nolint:gocyclo // exact attachment routing remains auditable in one switch + if err := claimValidate(request.Binding); err != nil { + return LifecycleResponse{}, control.ErrBindingMismatch + } + if request.Operation == LifecycleDelete || request.Operation == LifecycleChildDelete { + result, err := d.repositoryAttachments.delete(ctx, request.Binding) + if err != nil { + return LifecycleResponse{}, err + } + d.removeRepositoryBinding(request.Binding.Ref) + payload, err := json.Marshal(result) + return LifecycleResponse{Binding: request.Binding, Payload: payload}, err + } + attachment := d.repositoryAttachment(request.Binding) + if request.Operation == LifecycleResolve && attachment == nil { + if request.Provision == nil || request.Provision.SourceCheckout == "" || request.Provision.Owner != request.Binding.Owner || request.Provision.SessionID != request.Binding.SessionID { + return LifecycleResponse{}, control.ErrBindingMismatch + } + reattached, err := d.repositoryAttachments.Reattach(ctx, LogicalEnvironmentRequest{Owner: request.Binding.Owner, Checkout: request.Provision.SourceCheckout}, session.EnvironmentRef{Kind: session.EnvironmentKind(Kind), ID: request.Binding.Ref}) + if err != nil { + return LifecycleResponse{}, err + } + d.repositoryMu.Lock() + d.repositoryBindings[request.Binding.Ref] = repositoryDaemonBinding{binding: request.Binding} + d.repositoryMu.Unlock() + if err := d.repositoryAttachments.register(request.Binding, reattached); err != nil { + _ = reattached.Close() + d.removeRepositoryBinding(request.Binding.Ref) + return LifecycleResponse{}, err + } + attachment = reattached + } + if attachment == nil { + return LifecycleResponse{}, control.ErrBindingMismatch + } + + switch request.Operation { + case LifecycleResolve, LifecycleInspect: + return LifecycleResponse{Binding: request.Binding}, nil + case LifecycleWorkspace: + payload, err := proxyWorkspace(ctx, attachment.Environment.Workspace(), request.Payload) + if err != nil { + return LifecycleResponse{}, err + } + encoded, err := json.Marshal(payload) + return LifecycleResponse{Binding: request.Binding, Payload: encoded}, err + case LifecycleExec: + return d.repositoryExec(ctx, request, attachment) + case LifecycleDetach: + err := d.repositoryAttachments.Detach(attachment.Environment.Ref()) + d.removeRepositoryBinding(request.Binding.Ref) + return LifecycleResponse{Binding: request.Binding}, err + case LifecycleFork: + return d.repositoryFork(ctx, request, attachment) + case LifecycleMerge: + return d.repositoryMerge(ctx, request, attachment) + default: + return LifecycleResponse{}, errLifecycleProtocol + } +} + +func (d *Daemon) repositoryFork(ctx context.Context, request LifecycleRequest, parent *RepositoryAttachment) (LifecycleResponse, error) { + var payload ChildForkPayload + if json.Unmarshal(request.Payload, &payload) != nil || payload.Label == "" || len(payload.Label) > 256 { + return LifecycleResponse{}, errLifecycleProtocol + } + child, _, _, err := d.repositoryAttachments.Fork(ctx, parent.Environment, payload.Label) + if err != nil { + return LifecycleResponse{}, err + } + childAttachment := d.repositoryAttachments.lookup(child.Ref()) + environmentID, generation, err := parseEnvironmentRef(childAttachment.Logical.Ref) + if err != nil { + _ = childAttachment.Close() + return LifecycleResponse{}, err + } + binding := control.Binding{Owner: request.Binding.Owner, SessionID: request.Binding.SessionID + ":" + payload.Label, EnvironmentID: environmentID, Ref: child.Ref().ID, Generation: generation} + if err := d.repositoryAttachments.register(binding, childAttachment); err != nil { + _ = childAttachment.Close() + return LifecycleResponse{}, err + } + d.repositoryMu.Lock() + d.repositoryBindings[binding.Ref] = repositoryDaemonBinding{binding: binding} + d.repositoryMu.Unlock() + return LifecycleResponse{Binding: binding}, nil +} + +func (d *Daemon) repositoryMerge(ctx context.Context, request LifecycleRequest, parent *RepositoryAttachment) (LifecycleResponse, error) { + var payload ChildMergePayload + if json.Unmarshal(request.Payload, &payload) != nil { + return LifecycleResponse{}, errLifecycleProtocol + } + child := d.repositoryAttachment(payload.Child) + if child == nil { + return LifecycleResponse{}, control.ErrBindingMismatch + } + if err := d.repositoryAttachments.Merge(ctx, child.Environment, parent.Environment); err != nil { + return LifecycleResponse{}, err + } + return LifecycleResponse{Binding: request.Binding}, nil +} + +func (*Daemon) repositoryExec(ctx context.Context, request LifecycleRequest, attachment *RepositoryAttachment) (LifecycleResponse, error) { + var input struct { + Command string `json:"command"` + TemporaryScope tool.TemporaryScope `json:"temporary_scope,omitempty"` + } + if json.Unmarshal(request.Payload, &input) != nil || input.Command == "" { + return LifecycleResponse{}, errLifecycleProtocol + } + runner := attachment.Environment.CommandRunner() + var result tool.CommandResult + var err error + if input.TemporaryScope == "" { + result, err = runner.Run(ctx, input.Command) + } else if scoped, ok := runner.(tool.CommandTemporaryScopeRunner); ok { + result, err = scoped.RunWithTemporaryScope(ctx, input.Command, input.TemporaryScope) + } else { + return LifecycleResponse{}, ErrEnvironmentUnavailable + } + if err != nil { + return LifecycleResponse{}, err + } + payload, err := json.Marshal(struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + }{result.Stdout, result.Stderr, result.ExitCode}) + return LifecycleResponse{Binding: request.Binding, Payload: payload}, err +} + +func (d *Daemon) repositoryExecStream(ctx context.Context, request LifecycleRequest, send func(LifecycleExecStream) error) (LifecycleResponse, error) { + attachment := d.repositoryAttachment(request.Binding) + if attachment == nil { + return LifecycleResponse{}, control.ErrBindingMismatch + } + var input struct { + Command string `json:"command"` + TemporaryScope tool.TemporaryScope `json:"temporary_scope,omitempty"` + } + if json.Unmarshal(request.Payload, &input) != nil || input.Command == "" { + return LifecycleResponse{}, errLifecycleProtocol + } + runner := attachment.Logical.Runner + if runner == nil { + return LifecycleResponse{}, ErrEnvironmentUnavailable + } + exit, err := runner.RunFramesWithTemporaryScope(ctx, input.Command, input.TemporaryScope, func(frame guestexec.OutputFrame) error { + return send(LifecycleExecStream{Channel: frame.Channel, Data: frame.Data}) + }) + if err != nil { + return LifecycleResponse{}, err + } + payload, err := json.Marshal(struct { + ExitCode int `json:"exit_code"` + }{exit}) + return LifecycleResponse{Binding: request.Binding, Payload: payload}, err +} + +func (d *Daemon) repositoryAttachment(claim control.Binding) *RepositoryAttachment { + if d == nil || d.repositoryAttachments == nil || claim.Ref == "" || !strings.HasPrefix(claim.EnvironmentID, "logical-") { + return nil + } + d.repositoryMu.Lock() + known, ok := d.repositoryBindings[claim.Ref] + d.repositoryMu.Unlock() + if !ok || known.binding != claim { + return nil + } + return d.repositoryAttachments.lookup(session.EnvironmentRef{Kind: session.EnvironmentKind(Kind), ID: claim.Ref}) +} + +func (d *Daemon) removeRepositoryBinding(ref string) { + d.repositoryMu.Lock() + delete(d.repositoryBindings, ref) + d.repositoryMu.Unlock() +} diff --git a/environment/microvm/repository_lifecycle.go b/environment/microvm/repository_lifecycle.go new file mode 100644 index 000000000..e126efd66 --- /dev/null +++ b/environment/microvm/repository_lifecycle.go @@ -0,0 +1,753 @@ +package microvm + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/sys/unix" + + "github.com/stacklok/mecatl/environment/microvm/gitexec" +) + +const ( + repositoryRegistryVersion = 1 + repositoryHealthTimeout = 10 * time.Second +) + +var ( + // ErrRepositoryVMUnknown means no generation has been admitted for the repository key. + ErrRepositoryVMUnknown = errors.New("microvm repository generation is unknown") + // ErrRepositoryVMInconsistent means the durable singleton cannot be reattached exactly. + ErrRepositoryVMInconsistent = errors.New("microvm repository generation is inconsistent") + // ErrRepositoryLogicalRootUnavailable means the authenticated guest could not + // attach the assigned repository worktree. It intentionally carries no backend detail. + ErrRepositoryLogicalRootUnavailable = errors.New("microvm repository logical root is unavailable") +) + +// RepositoryIdentity is the canonical owner/repository key and its opaque, +// owner-confined state location. +type RepositoryIdentity struct { + Owner string + GitCommonDirectory string + Key string + StateDirectory string +} + +// ResolveRepositoryIdentity canonicalizes a checkout through Git and derives an +// opaque state identity. Repository-controlled path components never enter the +// state-directory suffix. +func ResolveRepositoryIdentity(ctx context.Context, owner, checkout, stateRoot string) (RepositoryIdentity, error) { + if owner == "" { + return RepositoryIdentity{}, errors.New("microvm repository owner is required") + } + canonicalStateRoot, err := canonicalStateRoot(stateRoot) + if err != nil { + return RepositoryIdentity{}, err + } + canonicalCheckout, err := filepath.EvalSymlinks(checkout) + if err != nil { + return RepositoryIdentity{}, fmt.Errorf("canonicalize microvm checkout: %w", err) + } + canonicalCheckout, err = filepath.Abs(canonicalCheckout) + if err != nil { + return RepositoryIdentity{}, fmt.Errorf("make microvm checkout absolute: %w", err) + } + output, err := gitexec.Run(ctx, canonicalCheckout, nil, "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil { + return RepositoryIdentity{}, fmt.Errorf("resolve canonical Git common directory: %w", err) + } + common := strings.TrimSpace(string(output)) + if !filepath.IsAbs(common) { + return RepositoryIdentity{}, errors.New("git common directory is not absolute") + } + common, err = filepath.EvalSymlinks(common) + if err != nil { + return RepositoryIdentity{}, fmt.Errorf("canonicalize Git common directory: %w", err) + } + common = filepath.Clean(common) + info, err := os.Stat(common) + if err != nil || !info.IsDir() { + return RepositoryIdentity{}, fmt.Errorf("validate Git common directory: %w", errors.Join(err, errors.New("not a directory"))) + } + + validated, err := newValidatedRepositoryIdentity(owner, common, canonicalStateRoot) + if err != nil { + return RepositoryIdentity{}, err + } + return validated.value, nil +} + +type validatedRepositoryIdentity struct { + value RepositoryIdentity + components [4]string +} + +func newValidatedRepositoryIdentity(owner, gitCommonDirectory, stateRoot string) (validatedRepositoryIdentity, error) { + ownerKey := framedDigest("mecatl.microvm.owner.v1", owner) + repositoryKey := framedDigest("mecatl.microvm.repository.v1", owner, gitCommonDirectory) + components := [4]string{"owners", ownerKey, "repositories", repositoryKey} + for _, component := range components { + if !validOpaquePathComponent(component) { + return validatedRepositoryIdentity{}, errors.New("derived microvm repository identity has an invalid path component") + } + } + stateDirectory := filepath.Join(stateRoot, components[0], components[1], components[2], components[3]) + if !repositoryPathWithin(stateRoot, stateDirectory) { + return validatedRepositoryIdentity{}, errors.New("derived microvm repository state path is not confined") + } + return validatedRepositoryIdentity{ + value: RepositoryIdentity{ + Owner: owner, + GitCommonDirectory: gitCommonDirectory, + Key: repositoryKey, + StateDirectory: stateDirectory, + }, + components: components, + }, nil +} + +func (r *RepositoryVMRegistry) validateIdentity(identity RepositoryIdentity) (validatedRepositoryIdentity, error) { + validated, err := newValidatedRepositoryIdentity(identity.Owner, identity.GitCommonDirectory, r.stateRoot) + if err != nil { + return validatedRepositoryIdentity{}, err + } + if identity.Key != validated.value.Key || identity.StateDirectory != validated.value.StateDirectory { + return validatedRepositoryIdentity{}, errors.New("microvm repository identity is not canonical") + } + return validated, nil +} + +func validOpaquePathComponent(component string) bool { + return component != "" && component != "." && component != ".." && !strings.ContainsRune(component, filepath.Separator) +} + +func canonicalStateRoot(stateRoot string) (string, error) { + if stateRoot == "" { + return "", errors.New("microvm repository state root is required") + } + absolute, err := filepath.Abs(stateRoot) + if err != nil { + return "", fmt.Errorf("make microvm state root absolute: %w", err) + } + if err := os.MkdirAll(absolute, 0o700); err != nil { + return "", fmt.Errorf("create microvm state root: %w", err) + } + canonical, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", fmt.Errorf("canonicalize microvm state root: %w", err) + } + info, err := os.Stat(canonical) + if err != nil || !info.IsDir() { + return "", errors.New("microvm state root is not a directory") + } + return filepath.Clean(canonical), nil +} + +func framedDigest(domain string, values ...string) string { + hash := sha256.New() + _, _ = hash.Write([]byte(domain)) + for _, value := range values { + var length [8]byte + binary.BigEndian.PutUint64(length[:], uint64(len(value))) + _, _ = hash.Write(length[:]) + _, _ = hash.Write([]byte(value)) + } + return hex.EncodeToString(hash.Sum(nil)) +} + +func repositoryPathWithin(root, candidate string) bool { + relative, err := filepath.Rel(root, candidate) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) +} + +// RepositoryVMRuntime owns the repository generation's VM process. Start is +// called only after the provisioning record and private rootfs are durable. +type RepositoryVMRuntime interface { + Start(context.Context, RepositoryVMRecord, VerifiedArtifacts, RepositoryBootAuthority) (RuntimeStatus, error) + Health(context.Context, RepositoryVMRecord, RepositoryHealthChallenge) (RepositoryHealthResponse, error) +} + +// RepositoryVMRecord is the durable singleton VM/rootfs identity for one key. +type RepositoryVMRecord struct { + State EnvironmentState `json:"state"` + Owner string `json:"owner"` + RepositoryKey string `json:"repository_key"` + GitCommonDirectory string `json:"git_common_directory"` + Generation uint32 `json:"generation"` + VMID string `json:"vm_id"` + Endpoint string `json:"endpoint"` + RootFSPath string `json:"rootfs_path"` + AuthorityDigest string `json:"authority_digest"` + RunnerPID int `json:"runner_pid"` + ProcessIdentity string `json:"process_identity"` +} + +type repositoryRegistryDocument struct { + Version int `json:"version"` + Record RepositoryVMRecord `json:"record"` +} + +// RepositoryVMRequest supplies first-use inputs. Verified artifacts are required +// only when no durable record exists; reattachment never consumes replacement bytes. +type RepositoryVMRequest struct { + Owner string + Checkout string + Verified VerifiedArtifacts +} + +// RepositoryVMResult reports the exact durable singleton selected by Ensure. +type RepositoryVMResult struct { + Record RepositoryVMRecord + Reattached bool +} + +// RepositoryVMRegistry owns durable singleton admission under one state root. +type RepositoryVMRegistry struct { + stateRoot string + endpointRoot string + runtime RepositoryVMRuntime + writeRecord func(*repositoryDirectory, RepositoryVMRecord) error +} + +// OpenRepositoryVMRegistry opens the repository lifecycle registry without +// admitting or reconciling any generation. endpointRoots optionally supplies a +// short owner-private runtime directory for Unix guest endpoints. +func OpenRepositoryVMRegistry(stateRoot string, runtime RepositoryVMRuntime, endpointRoots ...string) (*RepositoryVMRegistry, error) { + if runtime == nil { + return nil, errors.New("microvm repository runtime is required") + } + canonical, err := canonicalStateRoot(stateRoot) + if err != nil { + return nil, err + } + endpointRoot := canonical + if len(endpointRoots) > 1 { + return nil, errors.New("microvm repository endpoint root is ambiguous") + } + if len(endpointRoots) == 1 { + endpointRoot, err = canonicalStateRoot(endpointRoots[0]) + if err != nil { + return nil, fmt.Errorf("open microvm repository endpoint root: %w", err) + } + } + return &RepositoryVMRegistry{stateRoot: canonical, endpointRoot: endpointRoot, runtime: runtime, writeRecord: writeRepositoryRecord}, nil +} + +// HasRecords reports whether any durable repository generation predates this +// registry instance. It reads only the fixed owner/repository hierarchy and +// fails closed on malformed entries. +func (r *RepositoryVMRegistry) HasRecords() (bool, error) { + ownersRoot := filepath.Join(r.stateRoot, "owners") + owners, err := os.ReadDir(ownersRoot) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + for _, owner := range owners { + if !owner.IsDir() || owner.Type()&os.ModeSymlink != 0 || !validOpaquePathComponent(owner.Name()) { + return false, errors.New("repository owner registry contains an invalid entry") + } + repositoriesRoot := filepath.Join(ownersRoot, owner.Name(), "repositories") + repositories, readErr := os.ReadDir(repositoriesRoot) + if errors.Is(readErr, os.ErrNotExist) { + continue + } + if readErr != nil { + return false, readErr + } + for _, repository := range repositories { + if !repository.IsDir() || repository.Type()&os.ModeSymlink != 0 || !validOpaquePathComponent(repository.Name()) { + return false, errors.New("repository registry contains an invalid entry") + } + info, statErr := os.Lstat(filepath.Join(repositoriesRoot, repository.Name(), "registry.json")) + if statErr == nil { + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return false, errors.New("repository registry record is not a regular file") + } + return true, nil + } + if !errors.Is(statErr, os.ErrNotExist) { + return false, statErr + } + } + } + return false, nil +} + +// Ensure admits one generation on first use or reattaches only the exact healthy +// ready generation. Any partial, missing, or mismatched state fails without +// replacement or destructive reconciliation. +func (r *RepositoryVMRegistry) Ensure(ctx context.Context, request RepositoryVMRequest) (RepositoryVMResult, error) { + identity, err := ResolveRepositoryIdentity(ctx, request.Owner, request.Checkout, r.stateRoot) + if err != nil { + return RepositoryVMResult{}, err + } + validatedIdentity, err := r.validateIdentity(identity) + if err != nil { + return RepositoryVMResult{}, err + } + directory, err := r.openIdentityDirectory(validatedIdentity, true) + if err != nil { + return RepositoryVMResult{}, err + } + defer func() { _ = directory.Close() }() + + var result RepositoryVMResult + err = r.withIdentityLock(ctx, directory, func() error { + record, readErr := readRepositoryRecord(directory) + switch { + case readErr == nil: + if err := r.reattach(ctx, directory, identity, record); err != nil { + return err + } + result = RepositoryVMResult{Record: record, Reattached: true} + return nil + case !errors.Is(readErr, os.ErrNotExist): + return readErr + } + + if err := validateRepositoryArtifacts(request.Verified); err != nil { + return err + } + generation, err := randomGeneration() + if err != nil { + return err + } + authority, err := newRepositoryBootAuthority() + if err != nil { + return fmt.Errorf("create repository boot authority: %w", err) + } + record = RepositoryVMRecord{ + State: EnvironmentProvisioning, Owner: identity.Owner, RepositoryKey: identity.Key, + GitCommonDirectory: identity.GitCommonDirectory, Generation: generation, + VMID: "repository-" + identity.Key[:16] + fmt.Sprintf("-%08x", generation), + Endpoint: r.repositoryEndpoint(identity, generation), + RootFSPath: filepath.Join(identity.StateDirectory, "rootfs"), AuthorityDigest: authority.digest(), + } + if err := r.writeRecord(directory, record); err != nil { + return fmt.Errorf("admit repository generation: %w", err) + } + if err := writeRepositoryBootAuthority(directory, authority); err != nil { + return fmt.Errorf("persist repository boot authority: %w", err) + } + if info, err := directory.Lstat("rootfs"); err == nil || !errors.Is(err, os.ErrNotExist) { + _ = info + return fmt.Errorf("%w: private repository rootfs already exists", ErrRepositoryVMInconsistent) + } + materializer := newRepositoryRootFSMaterializer() + if err := materializer.Materialize(request.Verified.ExecutionImage.Path, record.RootFSPath, request.Verified.GuestAgent.Path); err != nil { + return fmt.Errorf("materialize repository rootfs: %w", err) + } + if err := unix.Mkdirat(int(directory.file.Fd()), "logical", 0o700); err != nil && !errors.Is(err, unix.EEXIST) { + return fmt.Errorf("create repository guest mount namespace: %w", err) + } + status, err := r.runtime.Start(ctx, record, request.Verified, authority) + if err != nil { + return fmt.Errorf("start admitted repository generation: %w", err) + } + if err := validateProvisionalRuntime(record, status); err != nil { + return errors.Join(err, r.abortStartedRuntime(ctx, record)) + } + record.State = EnvironmentReady + record.RunnerPID = status.PID + record.ProcessIdentity = status.ProcessIdentity + if err := r.writeRecord(directory, record); err != nil { + return errors.Join(fmt.Errorf("commit ready repository generation: %w", err), r.abortStartedRuntime(ctx, record)) + } + result = RepositoryVMResult{Record: record} + return nil + }) + return result, err +} + +func (r *RepositoryVMRegistry) abortStartedRuntime(ctx context.Context, record RepositoryVMRecord) error { + aborter, ok := r.runtime.(repositoryRuntimeAborter) + if !ok { + return errors.New("started repository runtime cannot be rolled back") + } + if err := aborter.Abort(ctx, record); err != nil { + return fmt.Errorf("abort unpublished repository generation: %w", err) + } + return nil +} + +func (r *RepositoryVMRegistry) reattach(ctx context.Context, directory *repositoryDirectory, identity RepositoryIdentity, record RepositoryVMRecord) error { + if err := r.validateRepositoryRecord(identity, record); err != nil { + return err + } + if err := validateRepositoryRootFS(directory); err != nil { + return fmt.Errorf("%w: %v", ErrRepositoryVMInconsistent, err) + } + authority, err := readRepositoryBootAuthority(directory) + if err != nil || record.AuthorityDigest != authority.digest() { + return fmt.Errorf("%w: repository boot authority is missing or inconsistent", ErrRepositoryVMInconsistent) + } + challenge, err := authority.healthChallenge(record) + if err != nil { + return fmt.Errorf("create repository health challenge: %w", err) + } + healthCtx, cancelHealth := context.WithTimeoutCause(ctx, repositoryHealthTimeout, errors.New("repository restart health phase timed out")) + response, err := r.runtime.Health(healthCtx, record, challenge) + cancelHealth() + if err != nil { + return fmt.Errorf("%w: repository restart health phase: %v", ErrRepositoryVMInconsistent, err) + } + if err := authority.VerifyHealth(record, challenge, response); err != nil { + return fmt.Errorf("%w: repository health response is not authoritative", ErrRepositoryVMInconsistent) + } + if attacher, ok := r.runtime.(repositoryRuntimeAttacher); ok { + if err := attacher.AttachRepository(record, authority); err != nil { + return fmt.Errorf("%w: restore repository runtime authority: %v", ErrRepositoryVMInconsistent, err) + } + } + return validateRepositoryRuntime(record, response.Status) +} + +// Lookup returns the exact durable record without inspecting or changing runtime state. +func (r *RepositoryVMRegistry) Lookup(ctx context.Context, identity RepositoryIdentity) (RepositoryVMRecord, error) { + validatedIdentity, err := r.validateIdentity(identity) + if err != nil { + return RepositoryVMRecord{}, err + } + directory, err := r.openIdentityDirectory(validatedIdentity, false) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return RepositoryVMRecord{}, ErrRepositoryVMUnknown + } + return RepositoryVMRecord{}, err + } + defer func() { _ = directory.Close() }() + var record RepositoryVMRecord + err = r.withIdentityLock(ctx, directory, func() error { + var err error + record, err = readRepositoryRecord(directory) + if errors.Is(err, os.ErrNotExist) { + return ErrRepositoryVMUnknown + } + if err != nil { + return err + } + return r.validateRepositoryRecord(identity, record) + }) + return record, err +} + +// inspect authenticates the live runtime for an exact durable repository record +// without attaching handles or creating replacement state. +func (r *RepositoryVMRegistry) inspect(ctx context.Context, record RepositoryVMRecord) error { + identity, err := newValidatedRepositoryIdentity(record.Owner, record.GitCommonDirectory, r.stateRoot) + if err != nil || identity.value.Key != record.RepositoryKey { + return ErrRepositoryVMInconsistent + } + directory, err := r.openIdentityDirectory(identity, false) + if err != nil { + return fmt.Errorf("%w: open repository generation: %v", ErrRepositoryVMInconsistent, err) + } + defer func() { _ = directory.Close() }() + if err := r.validateRepositoryRecord(identity.value, record); err != nil { + return err + } + authority, err := readRepositoryBootAuthority(directory) + if err != nil || record.AuthorityDigest != authority.digest() { + return fmt.Errorf("%w: repository boot authority is missing or inconsistent", ErrRepositoryVMInconsistent) + } + challenge, err := authority.healthChallenge(record) + if err != nil { + return err + } + healthCtx, cancelHealth := context.WithTimeoutCause(ctx, repositoryHealthTimeout, errors.New("repository inventory health phase timed out")) + response, err := r.runtime.Health(healthCtx, record, challenge) + cancelHealth() + if err != nil { + return err + } + if err := authority.VerifyHealth(record, challenge, response); err != nil { + return fmt.Errorf("%w: repository health response is not authoritative", ErrRepositoryVMInconsistent) + } + return validateRepositoryRuntime(record, response.Status) +} + +type repositoryDirectory struct { + file *os.File + path string +} + +func (d *repositoryDirectory) Close() error { return d.file.Close() } + +func openatOpaque(directoryFD int, component string, flags int, mode uint32) (int, error) { + if !validOpaquePathComponent(component) { + return -1, fmt.Errorf("openat path component %q is not opaque", component) + } + return unix.Openat(directoryFD, component, flags, mode) +} + +func (d *repositoryDirectory) Lstat(name string) (os.FileInfo, error) { + fd, err := openatOpaque(int(d.file.Fd()), name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: name, Err: err} + } + file := os.NewFile(uintptr(fd), filepath.Join(d.path, name)) + defer func() { _ = file.Close() }() + return file.Stat() +} + +func (r *RepositoryVMRegistry) openIdentityDirectory(identity validatedRepositoryIdentity, create bool) (*repositoryDirectory, error) { + rootFD, err := unix.Open(r.stateRoot, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, fmt.Errorf("open repository registry root without symlinks: %w", err) + } + current := os.NewFile(uintptr(rootFD), r.stateRoot) + currentPath := r.stateRoot + for _, component := range identity.components { + if create { + if err := unix.Mkdirat(int(current.Fd()), component, 0o700); err != nil && !errors.Is(err, unix.EEXIST) { + _ = current.Close() + return nil, fmt.Errorf("create repository registry namespace %q: %w", component, err) + } + } + nextFD, err := openatOpaque(int(current.Fd()), component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + _ = current.Close() + return nil, fmt.Errorf("open repository registry namespace %q without symlinks: %w", component, err) + } + next := os.NewFile(uintptr(nextFD), currentPath) + info, statErr := next.Stat() + if statErr != nil || !info.IsDir() || info.Mode().Perm()&0o077 != 0 { + _ = next.Close() + _ = current.Close() + return nil, fmt.Errorf("repository registry namespace %q is not a private directory", component) + } + _ = current.Close() + currentPath = filepath.Join(currentPath, component) + current = next + } + return &repositoryDirectory{file: current, path: currentPath}, nil +} + +func (*RepositoryVMRegistry) withIdentityLock(ctx context.Context, directory *repositoryDirectory, fn func() error) error { + fd, err := openatOpaque(int(directory.file.Fd()), "registry.lock", unix.O_RDWR|unix.O_CREAT|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600) + if err != nil { + return fmt.Errorf("open repository generation lock without symlinks: %w", err) + } + lock := os.NewFile(uintptr(fd), filepath.Join(directory.path, "registry.lock")) + defer func() { _ = lock.Close() }() + for { + err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + break + } + if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) { + return fmt.Errorf("lock repository generation: %w", err) + } + select { + case <-ctx.Done(): + return context.Cause(ctx) + case <-time.After(10 * time.Millisecond): + } + } + defer func() { _ = unix.Flock(fd, unix.LOCK_UN) }() + return fn() +} + +func (r *RepositoryVMRegistry) repositoryEndpoint(identity RepositoryIdentity, generation uint32) string { + return filepath.Join(r.endpointRoot, "repository-"+identity.Key[:16]+fmt.Sprintf("-%08x.sock", generation)) +} + +func (r *RepositoryVMRegistry) validateRepositoryRecord(identity RepositoryIdentity, record RepositoryVMRecord) error { + expectedRootFS := filepath.Join(identity.StateDirectory, "rootfs") + expectedEndpoint := r.repositoryEndpoint(identity, record.Generation) + if record.State != EnvironmentReady || record.Owner != identity.Owner || record.RepositoryKey != identity.Key || + record.GitCommonDirectory != identity.GitCommonDirectory || record.Generation == 0 || record.VMID == "" || + record.RootFSPath != expectedRootFS || record.Endpoint != expectedEndpoint || record.AuthorityDigest == "" || record.RunnerPID <= 0 || record.ProcessIdentity == "" { + return ErrRepositoryVMInconsistent + } + return nil +} + +func validateRepositoryArtifacts(verified VerifiedArtifacts) error { + if verified.ExecutionImage.Kind != ArtifactExecutionImage || verified.GuestAgent.Kind != ArtifactGuestAgent || + verified.ExecutionImage.Digest == "" || verified.GuestAgent.Digest == "" || + !filepath.IsAbs(verified.ExecutionImage.Path) || !filepath.IsAbs(verified.GuestAgent.Path) { + return errors.New("verified Brood and guest-agent artifacts are required for first use") + } + return nil +} + +func validateRepositoryRootFS(directory *repositoryDirectory) error { + rootFD, err := openatOpaque(int(directory.file.Fd()), "rootfs", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return errors.New("private repository rootfs is missing or invalid") + } + root := os.NewFile(uintptr(rootFD), filepath.Join(directory.path, "rootfs")) + defer func() { _ = root.Close() }() + current := root + components := strings.Split(guestAgentInstallPath, string(filepath.Separator)) + for index, component := range components { + flags := unix.O_RDONLY | unix.O_CLOEXEC | unix.O_NOFOLLOW + if index < len(components)-1 { + flags |= unix.O_DIRECTORY + } + fd, err := openatOpaque(int(current.Fd()), component, flags, 0) + if err != nil { + if current != root { + _ = current.Close() + } + return errors.New("private repository rootfs has no executable guest agent") + } + next := os.NewFile(uintptr(fd), filepath.Join(directory.path, "rootfs", filepath.Join(components[:index+1]...))) + if current != root { + _ = current.Close() + } + current = next + } + defer func() { _ = current.Close() }() + info, err := current.Stat() + if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return errors.New("private repository rootfs has no executable guest agent") + } + return nil +} + +func validateProvisionalRuntime(record RepositoryVMRecord, status RuntimeStatus) error { + if !status.Live || status.Generation != record.Generation || status.VMID != record.VMID || status.Endpoint != record.Endpoint || status.PID <= 0 || status.ProcessIdentity == "" { + return ErrRepositoryVMInconsistent + } + return nil +} + +func validateRepositoryRuntime(record RepositoryVMRecord, status RuntimeStatus) error { + if err := validateProvisionalRuntime(record, status); err != nil || status.PID != record.RunnerPID || status.ProcessIdentity != record.ProcessIdentity { + return ErrRepositoryVMInconsistent + } + return nil +} + +func randomGeneration() (uint32, error) { + var bytes [4]byte + for { + if _, err := rand.Read(bytes[:]); err != nil { + return 0, fmt.Errorf("allocate repository generation: %w", err) + } + if generation := binary.BigEndian.Uint32(bytes[:]); generation != 0 { + return generation, nil + } + } +} + +func readRepositoryBootAuthority(directory *repositoryDirectory) (RepositoryBootAuthority, error) { + fd, err := openatOpaque(int(directory.file.Fd()), "authority.key", unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return RepositoryBootAuthority{}, err + } + file := os.NewFile(uintptr(fd), filepath.Join(directory.path, "authority.key")) + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 || info.Size() != repositoryAuthorityBytes { + return RepositoryBootAuthority{}, errors.New("repository boot authority is not a private regular file") + } + value, err := io.ReadAll(io.LimitReader(file, repositoryAuthorityBytes+1)) + if err != nil { + return RepositoryBootAuthority{}, err + } + return repositoryBootAuthorityFromBytes(value) +} + +func writeRepositoryBootAuthority(directory *repositoryDirectory, authority RepositoryBootAuthority) error { + fd, err := openatOpaque(int(directory.file.Fd()), "authority.key", unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), filepath.Join(directory.path, "authority.key")) + if _, err := file.Write(authority.bytes()); err != nil { + _ = file.Close() + return err + } + if err := file.Sync(); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return directory.file.Sync() +} + +func readRepositoryRecord(directory *repositoryDirectory) (RepositoryVMRecord, error) { + fd, err := openatOpaque(int(directory.file.Fd()), "registry.json", unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return RepositoryVMRecord{}, &os.PathError{Op: "open", Path: "registry.json", Err: err} + } + file := os.NewFile(uintptr(fd), filepath.Join(directory.path, "registry.json")) + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + return RepositoryVMRecord{}, fmt.Errorf("repository generation registry is not a regular file: %w", err) + } + var document repositoryRegistryDocument + decoder := json.NewDecoder(io.LimitReader(file, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&document); err != nil { + return RepositoryVMRecord{}, fmt.Errorf("decode repository generation registry: %w", err) + } + if document.Version != repositoryRegistryVersion { + return RepositoryVMRecord{}, fmt.Errorf("unsupported repository generation registry version %d", document.Version) + } + return document.Record, nil +} + +func writeRepositoryRecord(directory *repositoryDirectory, record RepositoryVMRecord) error { + data, err := json.Marshal(repositoryRegistryDocument{Version: repositoryRegistryVersion, Record: record}) + if err != nil { + return err + } + data = append(data, '\n') + var name string + var temporary *os.File + for attempt := 0; attempt < 100; attempt++ { + var random [8]byte + if _, err := rand.Read(random[:]); err != nil { + return err + } + name = ".registry-" + hex.EncodeToString(random[:]) + fd, openErr := openatOpaque(int(directory.file.Fd()), name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600) + if openErr == nil { + temporary = os.NewFile(uintptr(fd), filepath.Join(directory.path, name)) + break + } + if !errors.Is(openErr, unix.EEXIST) { + return openErr + } + } + if temporary == nil { + return errors.New("allocate repository registry temporary file") + } + defer func() { _ = unix.Unlinkat(int(directory.file.Fd()), name, 0) }() + if _, err := temporary.Write(data); 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 := unix.Renameat(int(directory.file.Fd()), name, int(directory.file.Fd()), "registry.json"); err != nil { + return err + } + return directory.file.Sync() +} diff --git a/environment/microvm/repository_lifecycle_test.go b/environment/microvm/repository_lifecycle_test.go new file mode 100644 index 000000000..48909e7d6 --- /dev/null +++ b/environment/microvm/repository_lifecycle_test.go @@ -0,0 +1,525 @@ +package microvm + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/gitexec" +) + +func TestMicroVMMVP_Scenario2_CanonicalRepositoryIdentitySelectsSingletonVM(t *testing.T) { + t.Parallel() + ctx := t.Context() + root := t.TempDir() + stateRoot := filepath.Join(root, "state") + repository, linked, symlink := repositoryIdentityFixtureWithLinkedWorktree(t, root, "repository") + other, _, _ := repositoryIdentityFixture(t, root, "other", "repository") + + canonical, err := ResolveRepositoryIdentity(ctx, "operator-a", repository, stateRoot) + if err != nil { + t.Fatal(err) + } + for _, checkout := range []string{linked, symlink} { + got, err := ResolveRepositoryIdentity(ctx, "operator-a", checkout, stateRoot) + if err != nil { + t.Fatalf("resolve equivalent checkout %q: %v", checkout, err) + } + if got != canonical { + t.Fatalf("equivalent checkout identity = %+v, want %+v", got, canonical) + } + } + otherRepository, err := ResolveRepositoryIdentity(ctx, "operator-a", other, stateRoot) + if err != nil { + t.Fatal(err) + } + otherOperator, err := ResolveRepositoryIdentity(ctx, "operator-b", repository, stateRoot) + if err != nil { + t.Fatal(err) + } + if otherRepository.Key == canonical.Key || otherOperator.Key == canonical.Key { + t.Fatalf("different repository/operator collided: canonical=%+v other-repository=%+v other-operator=%+v", canonical, otherRepository, otherOperator) + } + + verified := repositoryVerifiedArtifacts(t, root) + runtime := newFakeRepositoryVMRuntime() + registry, err := OpenRepositoryVMRegistry(stateRoot, runtime) + if err != nil { + t.Fatal(err) + } + first, err := registry.Ensure(ctx, RepositoryVMRequest{Owner: "operator-a", Checkout: repository, Verified: verified}) + if err != nil { + t.Fatalf("ensure repository VM: %v", err) + } + second, err := registry.Ensure(ctx, RepositoryVMRequest{Owner: "operator-a", Checkout: linked}) + if err != nil { + t.Fatalf("ensure from linked worktree: %v", err) + } + if got := runtime.startCount(); first.Record.Generation != second.Record.Generation || first.Record.VMID != second.Record.VMID || got != 1 { + t.Fatalf("equivalent checkouts did not select singleton VM: first=%+v second=%+v starts=%d", first.Record, second.Record, got) + } +} + +func TestRepositoryHealthRejectsForeignExactStatusWithoutBootAuthority(t *testing.T) { + t.Parallel() + root := t.TempDir() + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + runtime := newFakeRepositoryVMRuntime() + registry, err := OpenRepositoryVMRegistry(filepath.Join(root, "state"), runtime) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Ensure(t.Context(), RepositoryVMRequest{Owner: "operator", Checkout: repository, Verified: repositoryVerifiedArtifacts(t, root)}); err != nil { + t.Fatal(err) + } + runtime.mu.Lock() + runtime.forgeHealth = true + runtime.mu.Unlock() + if _, err := registry.Ensure(t.Context(), RepositoryVMRequest{Owner: "operator", Checkout: repository}); !errors.Is(err, ErrRepositoryVMInconsistent) { + t.Fatalf("foreign exact-status endpoint accepted: %v", err) + } +} + +func TestRepositoryVMRegistryReadyCommitFailureAbortsUnpublishedRuntime(t *testing.T) { + root := t.TempDir() + stateRoot := filepath.Join(root, "state") + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + runtime := newFakeRepositoryVMRuntime() + registry, err := OpenRepositoryVMRegistry(stateRoot, runtime) + if err != nil { + t.Fatal(err) + } + persistErr := errors.New("injected ready-record persistence failure") + writeRecord := registry.writeRecord + registry.writeRecord = func(directory *repositoryDirectory, record RepositoryVMRecord) error { + if record.State == EnvironmentReady { + return persistErr + } + return writeRecord(directory, record) + } + _, err = registry.Ensure(t.Context(), RepositoryVMRequest{Owner: "operator", Checkout: repository, Verified: repositoryVerifiedArtifacts(t, root)}) + if !errors.Is(err, persistErr) { + t.Fatalf("ready commit failure = %v, want injected persistence error", err) + } + if runtime.aborts != 1 || len(runtime.statuses) != 0 { + t.Fatalf("unpublished runtime remained live: aborts=%d statuses=%v", runtime.aborts, runtime.statuses) + } + identity, err := ResolveRepositoryIdentity(t.Context(), "operator", repository, stateRoot) + if err != nil { + t.Fatal(err) + } + validated, err := registry.validateIdentity(identity) + if err != nil { + t.Fatal(err) + } + directory, err := registry.openIdentityDirectory(validated, false) + if err != nil { + t.Fatal(err) + } + defer directory.Close() + persisted, err := readRepositoryRecord(directory) + if err != nil { + t.Fatal(err) + } + if persisted.State != EnvironmentProvisioning { + t.Fatalf("failed ready commit replaced provisioning record: %+v", persisted) + } + if _, err := registry.Ensure(t.Context(), RepositoryVMRequest{Owner: "operator", Checkout: repository}); !errors.Is(err, ErrRepositoryVMInconsistent) { + t.Fatalf("provisioning generation was silently replaced: %v", err) + } + if runtime.startCount() != 1 { + t.Fatalf("provisioning retry started a replacement: starts=%d", runtime.startCount()) + } +} + +func TestRepositoryVMRegistryConcurrentEnsureConvergesAndPersistsRecord(t *testing.T) { + t.Parallel() + ctx := t.Context() + root := t.TempDir() + stateRoot := filepath.Join(root, "state") + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + verified := repositoryVerifiedArtifacts(t, root) + runtime := newFakeRepositoryVMRuntime() + + const callers = 12 + results := make(chan RepositoryVMRecord, callers) + errorsCh := make(chan error, callers) + var wait sync.WaitGroup + for i := 0; i < callers; i++ { + wait.Add(1) + go func() { + defer wait.Done() + registry, err := OpenRepositoryVMRegistry(stateRoot, runtime) + if err != nil { + errorsCh <- err + return + } + result, err := registry.Ensure(ctx, RepositoryVMRequest{Owner: "operator", Checkout: repository, Verified: verified}) + if err != nil { + errorsCh <- err + return + } + results <- result.Record + }() + } + wait.Wait() + close(results) + close(errorsCh) + for err := range errorsCh { + t.Fatalf("concurrent ensure: %v", err) + } + var admitted RepositoryVMRecord + for record := range results { + if admitted.Generation == 0 { + admitted = record + } + if record != admitted { + t.Fatalf("concurrent first use diverged: first=%+v got=%+v", admitted, record) + } + } + if got := runtime.startCount(); got != 1 { + t.Fatalf("runtime starts = %d, want 1", got) + } + + identity, err := ResolveRepositoryIdentity(ctx, "operator", repository, stateRoot) + if err != nil { + t.Fatal(err) + } + reopened, err := OpenRepositoryVMRegistry(stateRoot, runtime) + if err != nil { + t.Fatal(err) + } + persisted, err := reopened.Lookup(ctx, identity) + if err != nil { + t.Fatalf("lookup persisted record: %v", err) + } + if persisted != admitted { + t.Fatalf("persisted record = %+v, want %+v", persisted, admitted) + } +} + +func TestRepositoryVMRegistryLookupRejectsForgedKeyTraversalBeforeLockCreation(t *testing.T) { + t.Parallel() + root := t.TempDir() + stateRoot := filepath.Join(root, "state") + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + identity, err := ResolveRepositoryIdentity(t.Context(), "operator", repository, stateRoot) + if err != nil { + t.Fatal(err) + } + outside := filepath.Join(root, "outside") + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatal(err) + } + identity.Key = filepath.Join("..", "..", "..", "..", "outside") + + registry, err := OpenRepositoryVMRegistry(stateRoot, newFakeRepositoryVMRuntime()) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Lookup(t.Context(), identity); err == nil { + t.Fatal("Lookup accepted a forged repository key") + } + if _, err := os.Stat(filepath.Join(outside, "registry.lock")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("forged key created an outside lock: %v", err) + } +} + +func TestRepositoryVMRegistryLookupRejectsMismatchedStateDirectoryBeforeLockCreation(t *testing.T) { + t.Parallel() + root := t.TempDir() + stateRoot := filepath.Join(root, "state") + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + identity, err := ResolveRepositoryIdentity(t.Context(), "operator", repository, stateRoot) + if err != nil { + t.Fatal(err) + } + canonicalDirectory := identity.StateDirectory + if err := os.MkdirAll(canonicalDirectory, 0o700); err != nil { + t.Fatal(err) + } + identity.StateDirectory = filepath.Join(stateRoot, "forged-state") + if err := os.Mkdir(identity.StateDirectory, 0o700); err != nil { + t.Fatal(err) + } + + registry, err := OpenRepositoryVMRegistry(stateRoot, newFakeRepositoryVMRuntime()) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Lookup(t.Context(), identity); err == nil { + t.Fatal("Lookup accepted a mismatched state directory") + } + for _, directory := range []string{canonicalDirectory, identity.StateDirectory} { + if _, err := os.Stat(filepath.Join(directory, "registry.lock")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("mismatched state directory created lock under %q: %v", directory, err) + } + } +} + +func TestMicroVMMVP_Scenario2_RepositoryIdentityIsCanonicalAndConfined(t *testing.T) { + t.Parallel() + ctx := t.Context() + root := t.TempDir() + stateRoot := filepath.Join(root, "owner state") + repository, linked, symlink := repositoryIdentityFixtureWithLinkedWorktree(t, root, "hostile", "..", "same-name") + other, _, _ := repositoryIdentityFixture(t, root, "other-parent", "same-name") + + identities := make([]RepositoryIdentity, 0, 3) + for _, checkout := range []string{repository, linked, symlink} { + identity, err := ResolveRepositoryIdentity(ctx, "../../operator\nwith/slashes", checkout, stateRoot) + if err != nil { + t.Fatalf("resolve hostile identity input: %v", err) + } + identities = append(identities, identity) + } + if identities[0] != identities[1] || identities[0] != identities[2] { + t.Fatalf("linked/symlink identities diverged: %+v", identities) + } + otherIdentity, err := ResolveRepositoryIdentity(ctx, "../../operator\nwith/slashes", other, stateRoot) + if err != nil { + t.Fatal(err) + } + if otherIdentity.Key == identities[0].Key { + t.Fatalf("same repository display name collided across common directories: %+v %+v", identities[0], otherIdentity) + } + + canonicalState, err := filepath.EvalSymlinks(stateRoot) + if err != nil { + t.Fatal(err) + } + for _, identity := range append(identities, otherIdentity) { + relative, err := filepath.Rel(canonicalState, identity.StateDirectory) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + t.Fatalf("identity escaped owner state root: state=%q identity=%+v rel=%q err=%v", canonicalState, identity, relative, err) + } + for _, component := range strings.Split(relative, string(filepath.Separator)) { + if component == "" || component == "." || component == ".." || strings.ContainsAny(component, "/\\\n") { + t.Fatalf("unsafe identity path component %q in %q", component, relative) + } + } + } +} + +func TestRepositoryVMRegistryRejectsSymlinksAtEveryStateComponent(t *testing.T) { + t.Parallel() + for _, target := range []string{"owners", "owner", "repositories", "repository", "registry.lock", "registry.json"} { + t.Run(target, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + stateRoot := filepath.Join(root, "state") + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + identity, err := ResolveRepositoryIdentity(t.Context(), "operator", repository, stateRoot) + if err != nil { + t.Fatal(err) + } + ownerKey := framedDigest("mecatl.microvm.owner.v1", identity.Owner) + components := []string{"owners", ownerKey, "repositories", identity.Key} + index := map[string]int{"owners": 0, "owner": 1, "repositories": 2, "repository": 3} + if componentIndex, ok := index[target]; ok { + parent := stateRoot + if componentIndex > 0 { + parent = filepath.Join(append([]string{stateRoot}, components[:componentIndex]...)...) + if err := os.MkdirAll(parent, 0o700); err != nil { + t.Fatal(err) + } + } + external := filepath.Join(root, "external-directory") + if err := os.Mkdir(external, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(parent, components[componentIndex])); err != nil { + t.Fatal(err) + } + } else { + if err := os.MkdirAll(identity.StateDirectory, 0o700); err != nil { + t.Fatal(err) + } + external := filepath.Join(root, "external-file") + if err := os.WriteFile(external, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(identity.StateDirectory, target)); err != nil { + t.Fatal(err) + } + } + registry, err := OpenRepositoryVMRegistry(stateRoot, newFakeRepositoryVMRuntime()) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Ensure(t.Context(), RepositoryVMRequest{Owner: identity.Owner, Checkout: repository, Verified: repositoryVerifiedArtifacts(t, root)}); err == nil { + t.Fatalf("Ensure followed %s symlink", target) + } + if target == "registry.lock" || target == "registry.json" { + data, err := os.ReadFile(filepath.Join(root, "external-file")) + if err != nil || string(data) != "unchanged" { + t.Fatalf("external target changed: %q, %v", data, err) + } + } + }) + } +} + +func TestRepositoryVMRegistryMaterializesRootFSOnce(t *testing.T) { + t.Parallel() + ctx := t.Context() + root := t.TempDir() + stateRoot := filepath.Join(root, "state") + repository, linked, symlink := repositoryIdentityFixtureWithLinkedWorktree(t, root, "repository") + verified := repositoryVerifiedArtifacts(t, root) + runtime := newFakeRepositoryVMRuntime() + registry, err := OpenRepositoryVMRegistry(stateRoot, runtime) + if err != nil { + t.Fatal(err) + } + + first, err := registry.Ensure(ctx, RepositoryVMRequest{Owner: "operator", Checkout: repository, Verified: verified}) + if err != nil { + t.Fatal(err) + } + injected, err := os.ReadFile(filepath.Join(first.Record.RootFSPath, guestAgentInstallPath)) + if err != nil || string(injected) != "guest-agent-complete" { + t.Fatalf("injected independently verified guest agent = %q, %v", injected, err) + } + sharedMarker := filepath.Join(first.Record.RootFSPath, "home", "guest", "shared-package-marker") + if err := os.WriteFile(sharedMarker, []byte("installed once"), 0o600); err != nil { + t.Fatal(err) + } + for _, checkout := range []string{repository, linked, symlink, repository} { + result, err := registry.Ensure(ctx, RepositoryVMRequest{Owner: "operator", Checkout: checkout}) + if err != nil { + t.Fatalf("reuse repository generation: %v", err) + } + if result.Record.Generation != first.Record.Generation || result.Record.RootFSPath != first.Record.RootFSPath { + t.Fatalf("reuse selected another rootfs: first=%+v got=%+v", first.Record, result.Record) + } + } + marker, err := os.ReadFile(sharedMarker) + if err != nil || string(marker) != "installed once" { + t.Fatalf("subsequent ensure recopied rootfs: marker=%q err=%v", marker, err) + } + if got := runtime.startCount(); got != 1 { + t.Fatalf("fake runtime starts = %d, want one", got) + } +} + +type fakeRepositoryVMRuntime struct { + mu sync.Mutex + starts int + aborts int + healthCalls int + statuses map[string]RuntimeStatus + authorities map[string]RepositoryBootAuthority + forgeHealth bool +} + +func newFakeRepositoryVMRuntime() *fakeRepositoryVMRuntime { + return &fakeRepositoryVMRuntime{statuses: make(map[string]RuntimeStatus), authorities: make(map[string]RepositoryBootAuthority)} +} + +func (r *fakeRepositoryVMRuntime) startCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.starts +} + +func (r *fakeRepositoryVMRuntime) Start(_ context.Context, record RepositoryVMRecord, _ VerifiedArtifacts, authority RepositoryBootAuthority) (RuntimeStatus, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.starts++ + status := RuntimeStatus{Live: true, Generation: record.Generation, VMID: record.VMID, PID: 4000 + r.starts, ProcessIdentity: "boot-identity-" + record.VMID, Endpoint: record.Endpoint} + r.statuses[record.VMID] = status + r.authorities[record.VMID] = authority + return status, nil +} + +func (r *fakeRepositoryVMRuntime) Abort(_ context.Context, record RepositoryVMRecord) error { + r.mu.Lock() + defer r.mu.Unlock() + r.aborts++ + delete(r.statuses, record.VMID) + delete(r.authorities, record.VMID) + return nil +} + +func (r *fakeRepositoryVMRuntime) Health(_ context.Context, record RepositoryVMRecord, challenge RepositoryHealthChallenge) (RepositoryHealthResponse, error) { + r.mu.Lock() + defer r.mu.Unlock() + status, ok := r.statuses[record.VMID] + if !ok { + return RepositoryHealthResponse{}, ErrEnvironmentUnavailable + } + if r.forgeHealth { + return RepositoryHealthResponse{Status: status}, nil + } + response, err := r.authorities[record.VMID].HealthResponse(record, challenge, status) + if err != nil { + return RepositoryHealthResponse{}, err + } + r.healthCalls++ + return response, nil +} + +func repositoryIdentityFixture(t *testing.T, root string, components ...string) (string, string, string) { + t.Helper() + repository := filepath.Join(append([]string{root}, components...)...) + if err := os.MkdirAll(repository, 0o700); err != nil { + t.Fatal(err) + } + ctx := t.Context() + if _, err := gitexec.Run(ctx, repository, nil, "init"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("fixture\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := gitexec.Run(ctx, repository, nil, "add", "README.md"); err != nil { + t.Fatal(err) + } + identityEnv := []string{ + "GIT_AUTHOR_NAME=mecatl test", "GIT_AUTHOR_EMAIL=test@example.invalid", "GIT_AUTHOR_DATE=2000-01-01T00:00:00Z", + "GIT_COMMITTER_NAME=mecatl test", "GIT_COMMITTER_EMAIL=test@example.invalid", "GIT_COMMITTER_DATE=2000-01-01T00:00:00Z", + } + if _, err := gitexec.RunWithEnv(ctx, repository, nil, identityEnv, "commit", "-m", "fixture"); err != nil { + t.Fatal(err) + } + return repository, "", "" +} + +func repositoryIdentityFixtureWithLinkedWorktree(t *testing.T, root string, components ...string) (string, string, string) { + t.Helper() + repository, _, _ := repositoryIdentityFixture(t, root, components...) + linked := repository + "-linked" + if _, err := gitexec.Run(t.Context(), repository, nil, "worktree", "add", "--detach", linked); err != nil { + t.Fatal(err) + } + symlink := repository + "-symlink" + if err := os.Symlink(linked, symlink); err != nil { + t.Fatal(err) + } + return repository, linked, symlink +} + +func repositoryVerifiedArtifacts(t *testing.T, root string) VerifiedArtifacts { + t.Helper() + artifactSources := filepath.Join(root, "artifact-sources") + if err := os.Mkdir(artifactSources, 0o700); err != nil { + t.Fatal(err) + } + requests, resolver := testArtifactSet(t, artifactSources, nil, "") + verified, _, err := NewProvisioner( + NewVerifiedCache(filepath.Join(root, "verified-cache")), + resolver, + testPolicy("repository-policy-v1", nil), + nil, + nil, + ).Verify(t.Context(), requests) + if err != nil { + t.Fatalf("independently verify repository artifacts: %v", err) + } + return verified +} diff --git a/environment/microvm/repository_logical.go b/environment/microvm/repository_logical.go new file mode 100644 index 000000000..61611ffc5 --- /dev/null +++ b/environment/microvm/repository_logical.go @@ -0,0 +1,317 @@ +package microvm + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strings" + "sync" + + "golang.org/x/sys/unix" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/gitexec" + "github.com/stacklok/mecatl/environment/microvm/guestagent" + "github.com/stacklok/mecatl/environment/microvm/guestexec" + "github.com/stacklok/mecatl/environment/microvm/workspace" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +// RepositoryGuestMount describes the host export and the distinct path visible +// to the guest. Only GuestPath may enter an authenticated Binding. +type RepositoryGuestMount struct { + HostPath string + GuestPath string +} + +// RepositoryGuestRegistrar authenticates and attaches one logical root to an +// already-running repository guest. It must not provide a host fallback. +type RepositoryGuestRegistrar interface { + Register(context.Context, RepositoryVMRecord, control.Binding, RepositoryGuestMount) (*guestagent.Services, error) + Unregister(context.Context, RepositoryVMRecord, control.Binding) error +} + +// LogicalEnvironmentRequest selects a repository singleton and asks for one +// distinct logical Git worktree in it. +type LogicalEnvironmentRequest struct { + Owner string + Checkout string + Verified VerifiedArtifacts + BaseRevision string +} + +// LogicalEnvironment is one authenticated Workspace/runner pair in a shared repository VM. +type LogicalEnvironment struct { + Repository RepositoryVMRecord + Binding control.Binding + Ref EnvironmentRef + WorktreePath string + SourceRoot string + GuestRoot string + MetadataPath string + IndexPath string + Branch string + Workspace *workspace.Workspace + Runner *guestexec.Runner + + services *guestagent.Services + guest RepositoryGuestRegistrar + prepared *worktree.Prepared + preparer *worktree.Preparer + detachOnce sync.Once + detachErr error + closeOnce sync.Once + closeErr error +} + +// Detach closes this process's guest registration and data-plane handles while +// preserving the logical worktree for exact session reattachment. +func (e *LogicalEnvironment) Detach() error { + if e == nil { + return nil + } + e.detachOnce.Do(func() { + if e.guest != nil { + e.detachErr = e.guest.Unregister(context.Background(), e.Repository, e.Binding) + } + if e.services != nil { + e.detachErr = errors.Join(e.detachErr, e.services.Close()) + } + }) + return e.detachErr +} + +// DeletePreservingDirty detaches the logical environment and removes only a clean +// worktree. Dirty state remains at the reported worktree path for operator recovery. +func (e *LogicalEnvironment) DeletePreservingDirty(ctx context.Context) (bool, error) { + if e == nil { + return false, nil + } + if err := e.Detach(); err != nil { + return false, err + } + status, err := gitexec.Run(ctx, e.WorktreePath, nil, "status", "--porcelain", "--untracked-files=all") + if err != nil { + return false, fmt.Errorf("inspect repository logical worktree before delete: %w", err) + } + if len(status) != 0 { + return true, nil + } + prepared := e.prepared + if prepared == nil { + prepared = &worktree.Prepared{ + SourceRoot: e.SourceRoot, WorktreePath: e.WorktreePath, + MetadataPath: e.MetadataPath, Branch: e.Branch, + } + } + if err := e.preparer.Cleanup(ctx, prepared); err != nil { + return false, err + } + return false, nil +} + +// Close detaches process-local protocol handles and removes only this logical +// worktree. It never stops the repository VM or removes its rootfs. +func (e *LogicalEnvironment) Close() error { + if e == nil { + return nil + } + e.closeOnce.Do(func() { + e.closeErr = e.Detach() + if e.preparer != nil && e.prepared != nil { + e.closeErr = errors.Join(e.closeErr, e.preparer.Cleanup(context.Background(), e.prepared)) + } + }) + return e.closeErr +} + +type repositoryLogicalStage string + +const ( + repositoryLogicalStageEnsure repositoryLogicalStage = "ensure" + repositoryLogicalStageIdentity repositoryLogicalStage = "identity" + repositoryLogicalStageAllocate repositoryLogicalStage = "allocate" + repositoryLogicalStageReserve repositoryLogicalStage = "reserve" + repositoryLogicalStagePrepare repositoryLogicalStage = "prepare" + repositoryLogicalStageRegister repositoryLogicalStage = "register" +) + +type repositoryLogicalStageError struct { + stage repositoryLogicalStage + cause error +} + +func (e *repositoryLogicalStageError) Error() string { + return "microvm repository logical environment failed during " + string(e.stage) +} + +func (e *repositoryLogicalStageError) Unwrap() error { return e.cause } + +func repositoryLogicalFailure(stage repositoryLogicalStage, err error) error { + return &repositoryLogicalStageError{stage: stage, cause: err} +} + +// RepositoryLogicalManager creates logical worktrees over the task-62 singleton registry. +type RepositoryLogicalManager struct { + registry *RepositoryVMRegistry + preparer *worktree.Preparer + guest RepositoryGuestRegistrar +} + +// NewRepositoryLogicalManager constructs the daemon-side logical routing use case. +func NewRepositoryLogicalManager(registry *RepositoryVMRegistry, preparer *worktree.Preparer, guest RepositoryGuestRegistrar) (*RepositoryLogicalManager, error) { + if registry == nil || preparer == nil || guest == nil { + return nil, errors.New("microvm repository logical manager is not fully configured") + } + return &RepositoryLogicalManager{registry: registry, preparer: preparer, guest: guest}, nil +} + +// Create reuses one healthy repository VM while allocating a fresh ref, branch, +// index, worktree, and assigned guest root. +func (m *RepositoryLogicalManager) Create(ctx context.Context, request LogicalEnvironmentRequest) (_ *LogicalEnvironment, retErr error) { + result, err := m.registry.Ensure(ctx, RepositoryVMRequest{ + Owner: request.Owner, Checkout: request.Checkout, Verified: request.Verified, + }) + if err != nil { + return nil, repositoryLogicalFailure(repositoryLogicalStageEnsure, err) + } + identity, err := ResolveRepositoryIdentity(ctx, request.Owner, request.Checkout, m.registry.stateRoot) + if err != nil { + return nil, repositoryLogicalFailure(repositoryLogicalStageIdentity, err) + } + logicalID, err := randomLogicalID() + if err != nil { + return nil, repositoryLogicalFailure(repositoryLogicalStageAllocate, err) + } + logicalRoot, err := m.createLogicalRoot(identity, logicalID) + if err != nil { + return nil, repositoryLogicalFailure(repositoryLogicalStageReserve, err) + } + prepared, err := m.preparer.Prepare(ctx, worktree.Request{ + Source: request.Checkout, WorktreePath: filepath.Join(logicalRoot, "worktree"), + MetadataPath: filepath.Join(logicalRoot, "metadata"), Branch: "mecatl/" + logicalID, + BaseRevision: request.BaseRevision, + }) + if err != nil { + return nil, repositoryLogicalFailure(repositoryLogicalStagePrepare, err) + } + defer func() { + if retErr != nil { + _ = m.preparer.Cleanup(context.Background(), prepared) + } + }() + ref := EnvironmentRef{Kind: Kind, ID: "logical-" + logicalID + "@" + fmt.Sprint(result.Record.Generation)} + guestRoot := path.Join("/run/mecatl/repositories", logicalID, "worktree") + binding := control.Binding{ + Owner: request.Owner, SessionID: logicalID, EnvironmentID: logicalID, + Ref: ref.ID, Generation: result.Record.Generation, AssignedRoot: guestRoot, + } + services, err := m.guest.Register(ctx, result.Record, binding, RepositoryGuestMount{HostPath: prepared.WorktreePath, GuestPath: guestRoot}) + if err != nil { + return nil, repositoryLogicalFailure(repositoryLogicalStageRegister, ErrRepositoryLogicalRootUnavailable) + } + return &LogicalEnvironment{ + Repository: result.Record, Binding: binding, Ref: ref, + WorktreePath: prepared.WorktreePath, SourceRoot: prepared.SourceRoot, GuestRoot: binding.AssignedRoot, + MetadataPath: prepared.MetadataPath, IndexPath: filepath.Join(prepared.MetadataPath, "index"), Branch: prepared.Branch, + Workspace: services.Workspace, Runner: services.Runner, + services: services, guest: m.guest, prepared: prepared, preparer: m.preparer, + }, nil +} + +// Reattach restores process-local handles for one exact retained logical ref. +// It first authenticates the recorded repository generation through Ensure and +// never creates a replacement when that health check fails. +func (m *RepositoryLogicalManager) Reattach(ctx context.Context, request LogicalEnvironmentRequest, ref EnvironmentRef) (*LogicalEnvironment, error) { + result, err := m.registry.Ensure(ctx, RepositoryVMRequest{Owner: request.Owner, Checkout: request.Checkout}) + if err != nil { + return nil, err + } + environmentID, generation, err := parseEnvironmentRef(ref) + if err != nil || generation != result.Record.Generation || !strings.HasPrefix(environmentID, "logical-") { + return nil, ErrRepositoryVMInconsistent + } + logicalID := strings.TrimPrefix(environmentID, "logical-") + decoded, err := hex.DecodeString(logicalID) + if err != nil || len(decoded) != 16 { + return nil, ErrRepositoryVMInconsistent + } + identity, err := ResolveRepositoryIdentity(ctx, request.Owner, request.Checkout, m.registry.stateRoot) + if err != nil { + return nil, err + } + logicalRoot := filepath.Join(identity.StateDirectory, "logical", logicalID) + worktreePath := filepath.Join(logicalRoot, "worktree") + info, err := os.Lstat(worktreePath) + if err != nil || !info.IsDir() { + return nil, fmt.Errorf("%w: retained logical worktree is unavailable", ErrRepositoryVMInconsistent) + } + guestRoot := path.Join(RepositoryGuestMountRoot, logicalID, "worktree") + binding := control.Binding{ + Owner: request.Owner, SessionID: logicalID, EnvironmentID: logicalID, + Ref: ref.ID, Generation: generation, AssignedRoot: guestRoot, + } + services, err := m.guest.Register(ctx, result.Record, binding, RepositoryGuestMount{HostPath: worktreePath, GuestPath: guestRoot}) + if err != nil { + return nil, fmt.Errorf("reattach repository logical root: %w", ErrRepositoryLogicalRootUnavailable) + } + return &LogicalEnvironment{ + Repository: result.Record, Binding: binding, Ref: ref, + WorktreePath: worktreePath, SourceRoot: request.Checkout, GuestRoot: guestRoot, + MetadataPath: filepath.Join(logicalRoot, "metadata"), IndexPath: filepath.Join(logicalRoot, "metadata", "index"), Branch: "mecatl/" + logicalID, + Workspace: services.Workspace, Runner: services.Runner, + services: services, guest: m.guest, preparer: m.preparer, + }, nil +} + +func (m *RepositoryLogicalManager) createLogicalRoot(identity RepositoryIdentity, logicalID string) (string, error) { + validated, err := m.registry.validateIdentity(identity) + if err != nil { + return "", err + } + repository, err := m.registry.openIdentityDirectory(validated, false) + if err != nil { + return "", err + } + defer func() { _ = repository.Close() }() + if err := unix.Mkdirat(int(repository.file.Fd()), "logical", 0o700); err != nil && !errors.Is(err, unix.EEXIST) { + return "", fmt.Errorf("create repository logical namespace: %w", err) + } + logicalFD, err := openatOpaque(int(repository.file.Fd()), "logical", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return "", fmt.Errorf("open repository logical namespace: %w", err) + } + logical := os.NewFile(uintptr(logicalFD), filepath.Join(identity.StateDirectory, "logical")) + defer func() { _ = logical.Close() }() + info, err := logical.Stat() + if err != nil || !info.IsDir() || info.Mode().Perm()&0o077 != 0 { + return "", errors.New("repository logical namespace is not private") + } + if err := unix.Mkdirat(logicalFD, logicalID, 0o700); err != nil { + return "", fmt.Errorf("reserve repository logical identity: %w", err) + } + rootFD, err := openatOpaque(logicalFD, logicalID, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return "", fmt.Errorf("open repository logical identity: %w", err) + } + root := os.NewFile(uintptr(rootFD), filepath.Join(identity.StateDirectory, "logical", logicalID)) + defer func() { _ = root.Close() }() + info, err = root.Stat() + if err != nil || !info.IsDir() || info.Mode().Perm()&0o077 != 0 { + return "", errors.New("repository logical identity is not private") + } + return filepath.Join(identity.StateDirectory, "logical", logicalID), nil +} + +func randomLogicalID() (string, error) { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return "", fmt.Errorf("allocate repository logical environment: %w", err) + } + return hex.EncodeToString(value[:]), nil +} diff --git a/environment/microvm/repository_logical_test.go b/environment/microvm/repository_logical_test.go new file mode 100644 index 000000000..fa6120479 --- /dev/null +++ b/environment/microvm/repository_logical_test.go @@ -0,0 +1,475 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/gitexec" + "github.com/stacklok/mecatl/environment/microvm/guestagent" + "github.com/stacklok/mecatl/environment/microvm/guestexec" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +func TestRepositoryLogicalStageFailureIsClosedAndSecretFree(t *testing.T) { + const secret = "token=repository-secret /private/worktree errno=13" + err := repositoryLogicalFailure(repositoryLogicalStagePrepare, errors.New(secret)) + response := lifecycleFailure(err) + if response.ErrorCode != "failed_precondition" || response.ErrorText != "microvm repository logical environment failed during prepare" { + t.Fatalf("response = %+v", response) + } + if !errors.Is(response.Err, errors.Unwrap(err)) || strings.Contains(response.ErrorText, secret) || strings.Contains(response.ErrorText, "/private/worktree") { + t.Fatalf("lifecycle response leaked backend detail or lost cause: %+v", response) + } +} + +func TestRepositoryLogicalRootUnavailableLifecycleFailureIsClosed(t *testing.T) { + const secret = "token=repository-secret /private/worktree errno=13" + response := lifecycleFailure(fmt.Errorf("%w: %s", ErrRepositoryLogicalRootUnavailable, secret)) + if response.ErrorCode != "repository_logical_root_unavailable" || response.ErrorText != ErrRepositoryLogicalRootUnavailable.Error() || + !errors.Is(response.Err, ErrRepositoryLogicalRootUnavailable) { + t.Fatalf("response = %+v", response) + } + if strings.Contains(response.ErrorText, secret) || strings.Contains(response.ErrorText, "/private/worktree") { + t.Fatalf("lifecycle response leaked backend detail: %+v", response) + } +} + +func TestRepositoryLogicalManagerRegisterFailureIsClosedAndClassified(t *testing.T) { + fixture := newLogicalRepositoryFixture(t) + const secret = "control transport failed: token=repository-secret /private/worktree" + manager, err := NewRepositoryLogicalManager(fixture.manager.registry, fixture.manager.preparer, failingRepositoryGuest{err: errors.New(secret)}) + if err != nil { + t.Fatal(err) + } + request := LogicalEnvironmentRequest{Owner: "operator", Checkout: fixture.repo, Verified: fixture.verified} + + _, err = manager.Create(t.Context(), request) + assertLogicalRootUnavailable(t, err, secret) + response := lifecycleFailure(err) + if response.ErrorCode != "repository_logical_root_unavailable" || response.ErrorText != ErrRepositoryLogicalRootUnavailable.Error() { + t.Fatalf("daemon lifecycle response = %+v", response) + } + + environment := fixture.create(t) + defer environment.Close() + _, err = manager.Reattach(t.Context(), request, environment.Ref) + assertLogicalRootUnavailable(t, err, secret) + response = lifecycleFailure(err) + if response.ErrorCode != "repository_logical_root_unavailable" || response.ErrorText != ErrRepositoryLogicalRootUnavailable.Error() { + t.Fatalf("daemon reattach lifecycle response = %+v", response) + } +} + +func assertLogicalRootUnavailable(t *testing.T, err error, secret string) { + t.Helper() + if !errors.Is(err, ErrRepositoryLogicalRootUnavailable) { + t.Fatalf("error = %v, want logical root unavailable", err) + } + if unwrapped := errors.Unwrap(err); unwrapped != ErrRepositoryLogicalRootUnavailable || strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), "/private/worktree") { + t.Fatalf("error exposed guest registration detail or lost canonical cause: %v", err) + } +} + +func TestMicroVMMVP_Scenario3_LogicalEnvironmentsShareVMNotWorktree(t *testing.T) { + t.Parallel() + fixture := newLogicalRepositoryFixture(t) + + first := fixture.create(t) + second := fixture.create(t) + defer first.Close() + defer second.Close() + + if first.Repository.Generation != second.Repository.Generation || first.Repository.VMID != second.Repository.VMID { + t.Fatalf("logical environments did not share repository VM: first=%+v second=%+v", first.Repository, second.Repository) + } + if first.Binding.Ref == second.Binding.Ref || first.Binding.AssignedRoot == second.Binding.AssignedRoot || + first.WorktreePath == second.WorktreePath || first.Branch == second.Branch || first.IndexPath == second.IndexPath { + t.Fatalf("logical environments shared identity or Git state: first=%+v second=%+v", first, second) + } + for _, environment := range []*LogicalEnvironment{first, second} { + if environment.Binding.Generation != environment.Repository.Generation || environment.Binding.Owner != "operator" { + t.Fatalf("logical binding drifted from repository VM: %+v", environment) + } + if _, err := os.Stat(environment.IndexPath); err != nil { + t.Fatalf("logical environment has no distinct Git index %q: %v", environment.IndexPath, err) + } + } + if got := fixture.runtime.startCount(); got != 1 { + t.Fatalf("repository VM starts = %d, want 1", got) + } +} + +func TestMicroVMMVP_Scenario3_EnvironmentRefAuthenticatesAssignedRoot(t *testing.T) { + t.Parallel() + fixture := newLogicalRepositoryFixture(t) + environment := fixture.create(t) + defer environment.Close() + + if got, err := environment.Workspace.Read(t.Context(), "tracked.txt"); err != nil || string(got) != "source\n" { + t.Fatalf("authenticated workspace read = %q, %v", got, err) + } + result, err := environment.Runner.Run(t.Context(), "pwd; cat tracked.txt") + if err != nil || result.ExitCode != 0 || !strings.Contains(result.Stdout, environment.WorktreePath+"\nsource\n") { + t.Fatalf("authenticated exec = %+v, %v", result, err) + } + + mutations := []func(control.Binding) control.Binding{ + func(binding control.Binding) control.Binding { binding.Owner = "other"; return binding }, + func(binding control.Binding) control.Binding { binding.Generation++; return binding }, + func(binding control.Binding) control.Binding { + binding.Ref = "microvm-local:logical-sibling" + return binding + }, + func(binding control.Binding) control.Binding { binding.AssignedRoot += "-sibling"; return binding }, + } + for _, mutate := range mutations { + claim := mutate(environment.Binding) + if err := fixture.guest.Probe(t.Context(), environment.Repository, claim); !errors.Is(err, control.ErrBindingMismatch) { + t.Fatalf("forged binding %+v probe = %v, want binding mismatch", claim, err) + } + } + + fixture.guest.mu.Lock() + server := fixture.guest.servers[environment.Repository.VMID] + issuer := fixture.guest.issuers[environment.Repository.VMID] + fixture.guest.mu.Unlock() + wrongRegistration := environment.Binding + wrongRegistration.AssignedRoot += "-sibling" + registration, err := issuer.Issue(wrongRegistration) + if err != nil { + t.Fatal(err) + } + capability, err := issuer.Issue(environment.Binding) + if err != nil { + t.Fatal(err) + } + if err := server.Register(t.Context(), registration, wrongRegistration); !errors.Is(err, guestagent.ErrLogicalRootUnavailable) { + t.Fatalf("registration with unavailable assigned root = %v, want logical root unavailable", err) + } + wrongRoot := environment.Binding + wrongRoot.AssignedRoot += "-sibling" + services, err := connectLogicalGuest(t.Context(), server, wrongRoot, capability) + if services != nil { + _ = services.Close() + } + if !errors.Is(err, control.ErrUnauthenticatedCapability) { + t.Fatalf("handshake accepted wrong assigned root: %v", err) + } +} + +func TestInvariant_microvm_logical_environment_is_confined_and_affined(t *testing.T) { + t.Parallel() + fixture := newLogicalRepositoryFixture(t) + first := fixture.create(t) + second := fixture.create(t) + defer first.Close() + defer second.Close() + + if _, err := first.Workspace.Read(t.Context(), "../"+filepath.Base(second.WorktreePath)+"/tracked.txt"); err == nil { + t.Fatal("workspace path escape reached sibling worktree") + } + if _, err := first.Workspace.Read(t.Context(), second.WorktreePath+"/tracked.txt"); err == nil { + t.Fatal("absolute cross-worktree read succeeded") + } + if _, err := second.Workspace.CreateFile(t.Context(), "only-second", []byte("sibling")); err != nil { + t.Fatal(err) + } + result, err := first.Runner.Run(t.Context(), "test \"$PWD\" = "+shellQuote(first.WorktreePath)+" && cat "+shellQuote(second.WorktreePath+"/only-second")) + if err != nil || result.ExitCode != 0 || result.Stdout != "sibling" { + t.Fatalf("same-repository Bash could not address sibling guest path: %+v, %v", result, err) + } + if _, err := first.Workspace.Read(t.Context(), "only-second"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("first workspace observed sibling bytes: %v", err) + } + + _, version, err := first.Workspace.ReadVersion(t.Context(), "tracked.txt") + if err != nil { + t.Fatal(err) + } + if _, err := first.Workspace.ReplaceFile(t.Context(), "tracked.txt", version, []byte("first\n")); err != nil { + t.Fatal(err) + } + if _, err := first.Workspace.ReplaceFile(t.Context(), "tracked.txt", version, []byte("replay\n")); err == nil { + t.Fatal("replayed file version overwrote newer assigned-root bytes") + } + got, err := os.ReadFile(filepath.Join(second.WorktreePath, "tracked.txt")) + if err != nil || string(got) != "source\n" { + t.Fatalf("first mutation crossed into sibling worktree: %q, %v", got, err) + } + + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + if _, err := first.Runner.Run(ctx, "sleep 5"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("guest cancellation = %v, want deadline exceeded", err) + } + + stale := first.Binding + stale.Generation++ + if err := fixture.guest.Probe(t.Context(), first.Repository, stale); !errors.Is(err, control.ErrBindingMismatch) { + t.Fatalf("stale request = %v, want fail closed", err) + } + if err := fixture.guest.Replay(t.Context(), first.Repository, first.Binding); !errors.Is(err, control.ErrUnauthenticatedCapability) { + t.Fatalf("replayed capability = %v, want unauthenticated", err) + } +} + +func TestLogicalEnvironmentCloseRevokesGuestRegistrationBeforeCleanup(t *testing.T) { + t.Parallel() + fixture := newLogicalRepositoryFixture(t) + environment := fixture.create(t) + binding := environment.Binding + repository := environment.Repository + worktreePath := environment.WorktreePath + + if err := environment.Close(); err != nil { + t.Fatalf("close logical environment: %v", err) + } + if err := fixture.guest.Probe(t.Context(), repository, binding); !errors.Is(err, control.ErrBindingMismatch) { + t.Fatalf("closed binding probe = %v, want binding mismatch", err) + } + fixture.guest.mu.Lock() + server, issuer := fixture.guest.servers[repository.VMID], fixture.guest.issuers[repository.VMID] + fixture.guest.mu.Unlock() + freshCapability, err := issuer.Issue(binding) + if err != nil { + t.Fatal(err) + } + if services, err := connectLogicalGuest(t.Context(), server, binding, freshCapability); !errors.Is(err, control.ErrUnauthenticatedCapability) { + if services != nil { + _ = services.Close() + } + t.Fatalf("fresh capability reopened closed ref: %v", err) + } + if _, err := environment.Workspace.Read(t.Context(), "tracked.txt"); err == nil { + t.Fatal("closed workspace still dispatched") + } + if _, err := environment.Runner.Run(t.Context(), "true"); err == nil { + t.Fatal("closed runner still dispatched") + } + if _, err := os.Stat(worktreePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("closed logical worktree still exists: %v", err) + } + if err := environment.Close(); err != nil { + t.Fatalf("second close: %v", err) + } + + sibling := fixture.create(t) + defer sibling.Close() + if _, err := sibling.Workspace.Read(t.Context(), "tracked.txt"); err != nil { + t.Fatalf("closing one ref harmed shared VM sibling: %v", err) + } +} + +func TestMicroVMMVP_Scenario4_RepositoryGenerationOwnsSingleRootFS(t *testing.T) { + t.Parallel() + fixture := newLogicalRepositoryFixture(t) + first := fixture.create(t) + second := fixture.create(t) + defer first.Close() + defer second.Close() + + if first.Repository.RootFSPath != second.Repository.RootFSPath { + t.Fatalf("logical environments got different rootfs paths: %q %q", first.Repository.RootFSPath, second.Repository.RootFSPath) + } + if got := fixture.runtime.startCount(); got != 1 { + t.Fatalf("repository generation materializations/starts = %d, want 1", got) + } + fixture.runtime.mu.Lock() + healthCalls := fixture.runtime.healthCalls + fixture.runtime.mu.Unlock() + if healthCalls == 0 { + t.Fatal("logical environment creation did not authenticate repository-generation health") + } + guestBytes, err := os.ReadFile(filepath.Join(first.Repository.RootFSPath, guestAgentInstallPath)) + if err != nil || string(guestBytes) != "guest-agent-complete" { + t.Fatalf("generation rootfs guest agent = %q, %v", guestBytes, err) + } + if _, err := os.Stat(filepath.Join(first.Repository.RootFSPath, strings.TrimPrefix(GuestPrebootConfigPath, "/"))); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("repository rootfs contains session-bound preboot config: %v", err) + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(first.Repository.RootFSPath), "rootfs*")); err != nil || len(matches) != 1 { + t.Fatalf("session creation cloned repository rootfs: matches=%v err=%v", matches, err) + } +} + +type logicalRepositoryFixture struct { + t *testing.T + repo string + verified VerifiedArtifacts + runtime *fakeRepositoryVMRuntime + guest *testRepositoryGuest + manager *RepositoryLogicalManager +} + +type failingRepositoryGuest struct{ err error } + +func (g failingRepositoryGuest) Register(context.Context, RepositoryVMRecord, control.Binding, RepositoryGuestMount) (*guestagent.Services, error) { + return nil, g.err +} + +func (failingRepositoryGuest) Unregister(context.Context, RepositoryVMRecord, control.Binding) error { + return nil +} + +func newLogicalRepositoryFixture(t *testing.T) *logicalRepositoryFixture { + t.Helper() + root := t.TempDir() + repository, _, _ := repositoryIdentityFixture(t, root, "repository") + if err := os.Chmod(filepath.Join(repository, "README.md"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "tracked.txt"), []byte("source\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := gitexecForLogicalTest(t.Context(), repository, "add", "tracked.txt"); err != nil { + t.Fatal(err) + } + if _, err := gitexecForLogicalTest(t.Context(), repository, "commit", "-m", "fixture"); err != nil { + t.Fatal(err) + } + runtime := newFakeRepositoryVMRuntime() + registry, err := OpenRepositoryVMRegistry(filepath.Join(root, "state"), runtime) + if err != nil { + t.Fatal(err) + } + guest := newTestRepositoryGuest(t) + manager, err := NewRepositoryLogicalManager(registry, worktree.New(), guest) + if err != nil { + t.Fatal(err) + } + return &logicalRepositoryFixture{t: t, repo: repository, verified: repositoryVerifiedArtifacts(t, root), runtime: runtime, guest: guest, manager: manager} +} + +func (f *logicalRepositoryFixture) create(t *testing.T) *LogicalEnvironment { + t.Helper() + environment, err := f.manager.Create(t.Context(), LogicalEnvironmentRequest{Owner: "operator", Checkout: f.repo, Verified: f.verified}) + if err != nil { + t.Fatalf("create logical environment: %v", err) + } + return environment +} + +type testRepositoryGuest struct { + mu sync.Mutex + servers map[string]*guestagent.RepositoryServer + issuers map[string]*control.CapabilityIssuer + tokens map[string]string + mounts map[string]string +} + +func newTestRepositoryGuest(*testing.T) *testRepositoryGuest { + return &testRepositoryGuest{servers: make(map[string]*guestagent.RepositoryServer), issuers: make(map[string]*control.CapabilityIssuer), tokens: make(map[string]string), mounts: make(map[string]string)} +} + +func (g *testRepositoryGuest) Register(ctx context.Context, record RepositoryVMRecord, binding control.Binding, mount RepositoryGuestMount) (*guestagent.Services, error) { + g.mu.Lock() + g.mounts[mount.GuestPath] = mount.HostPath + server := g.servers[record.VMID] + if server == nil { + key := []byte("0123456789abcdef0123456789abcdef") + identity := guestexec.WorkloadIdentity{UID: uint32(os.Getuid()), GID: uint32(os.Getgid())} //nolint:gosec // test process identity + contract := guestexec.DefaultRuntimeContract() + contract.Identity = identity + var err error + server, err = guestagent.NewRepositoryServer(guestagent.RepositoryServerConfig{ + Owner: record.Owner, Generation: record.Generation, AuthorityKey: key, + WorkloadIdentity: identity, RuntimeContract: contract, + ResolveRoot: func(guestRoot string) (string, error) { + root := g.mounts[guestRoot] + if root == "" { + return "", errors.New("guest mount is absent") + } + return root, nil + }, + }) + if err != nil { + g.mu.Unlock() + return nil, err + } + issuer, err := control.NewCapabilityIssuer(key) + if err != nil { + g.mu.Unlock() + return nil, err + } + g.servers[record.VMID] = server + g.issuers[record.VMID] = issuer + } + issuer := g.issuers[record.VMID] + registration, err := issuer.Issue(binding) + var capability string + if err == nil { + capability, err = issuer.Issue(binding) + } + if err == nil { + err = server.Register(ctx, registration, binding) + } + g.tokens[binding.Ref] = capability + g.mu.Unlock() + if err != nil { + return nil, err + } + return connectLogicalGuest(ctx, server, binding, capability) +} + +func (g *testRepositoryGuest) Unregister(_ context.Context, record RepositoryVMRecord, binding control.Binding) error { + g.mu.Lock() + server, issuer := g.servers[record.VMID], g.issuers[record.VMID] + if server == nil || issuer == nil { + g.mu.Unlock() + return errors.New("guest not started") + } + authority, err := issuer.Issue(binding) + if err == nil { + err = server.Unregister(authority, binding) + } + delete(g.tokens, binding.Ref) + delete(g.mounts, binding.AssignedRoot) + g.mu.Unlock() + return err +} + +func (g *testRepositoryGuest) Probe(_ context.Context, record RepositoryVMRecord, binding control.Binding) error { + g.mu.Lock() + server := g.servers[record.VMID] + g.mu.Unlock() + if server == nil { + return errors.New("guest not started") + } + return server.Probe(binding) +} + +func (g *testRepositoryGuest) Replay(ctx context.Context, record RepositoryVMRecord, binding control.Binding) error { + g.mu.Lock() + server, capability := g.servers[record.VMID], g.tokens[binding.Ref] + g.mu.Unlock() + services, err := connectLogicalGuest(ctx, server, binding, capability) + if services != nil { + _ = services.Close() + } + return err +} + +func connectLogicalGuest(ctx context.Context, server *guestagent.RepositoryServer, binding control.Binding, capability string) (*guestagent.Services, error) { + host, guest := net.Pipe() + go func() { _ = server.Serve(ctx, guest) }() + services, err := guestagent.Connect(ctx, host, binding, capability) + if err != nil { + _ = host.Close() + } + return services, err +} + +func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + +func gitexecForLogicalTest(ctx context.Context, root string, args ...string) ([]byte, error) { + return gitexec.RunWithEnv(ctx, root, nil, []string{"GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.invalid", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.invalid"}, args...) +} diff --git a/environment/microvm/repository_object_snapshot.go b/environment/microvm/repository_object_snapshot.go new file mode 100644 index 000000000..569119310 --- /dev/null +++ b/environment/microvm/repository_object_snapshot.go @@ -0,0 +1,214 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +const ( + defaultRepositoryObjectSnapshotBytes int64 = 20 << 30 + defaultRepositoryObjectSnapshotEntries int64 = 1_000_000 +) + +type objectSnapshotLimit struct { + bytes int64 + entries int64 +} + +func snapshotRepositoryObjects(ctx context.Context, common, destinationParent string) (_ string, retErr error) { + commonDir, err := openAbsoluteDirectoryNoSymlinks(common) + if err != nil { + return "", errors.New("repository Git common directory is not a real directory") + } + defer func() { _ = commonDir.Close() }() + + objectsFD, err := openatOpaque(int(commonDir.Fd()), "objects", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return "", errors.New("repository Git object store is not a real directory") + } + objects := os.NewFile(uintptr(objectsFD), "objects") + defer func() { _ = objects.Close() }() + + snapshot, err := os.MkdirTemp(destinationParent, ".git-objects-") + if err != nil { + return "", fmt.Errorf("create private Git object snapshot: %w", err) + } + defer func() { + if retErr != nil { + retErr = errors.Join(retErr, removeObjectSnapshot(snapshot)) + } + }() + // The snapshot must remain traversable while it is populated; its parent is + // already owner-only and makeObjectSnapshotReadOnly removes write access. + if err := os.Chmod(snapshot, 0o700); err != nil { //nolint:gosec // G302: owner-only directory, not a file + return "", err + } + destination, err := os.OpenRoot(snapshot) + if err != nil { + return "", err + } + defer func() { _ = destination.Close() }() + + limit := objectSnapshotLimit{bytes: defaultRepositoryObjectSnapshotBytes, entries: defaultRepositoryObjectSnapshotEntries} + if err := copyObjectDirectory(ctx, objects, destination, ".", &limit); err != nil { + return "", fmt.Errorf("snapshot repository Git objects: %w", err) + } + if err := makeObjectSnapshotReadOnly(snapshot); err != nil { + return "", err + } + return snapshot, nil +} + +func openAbsoluteDirectoryNoSymlinks(name string) (*os.File, error) { + if !filepath.IsAbs(name) || filepath.Clean(name) != name { + return nil, errors.New("path is not clean and absolute") + } + fd, err := unix.Open(string(filepath.Separator), unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + current := os.NewFile(uintptr(fd), string(filepath.Separator)) + for _, component := range strings.Split(strings.TrimPrefix(name, string(filepath.Separator)), string(filepath.Separator)) { + if component == "" { + continue + } + nextFD, openErr := openatOpaque(int(current.Fd()), component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if openErr != nil { + _ = current.Close() + return nil, openErr + } + _ = current.Close() + current = os.NewFile(uintptr(nextFD), component) + } + return current, nil +} + +func copyObjectDirectory(ctx context.Context, source *os.File, destination *os.Root, relative string, limit *objectSnapshotLimit) error { + if strings.Count(relative, string(filepath.Separator)) >= 16 { + return errors.New("git object snapshot directory depth exceeded") + } + dupFD, err := unix.Dup(int(source.Fd())) + if err != nil { + return err + } + reader := os.NewFile(uintptr(dupFD), source.Name()) + entries, err := reader.ReadDir(-1) + _ = reader.Close() + if err != nil { + return err + } + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return err + } + if err := copyObjectEntry(ctx, source, destination, relative, entry, limit); err != nil { + return err + } + } + return nil +} + +func copyObjectEntry(ctx context.Context, source *os.File, destination *os.Root, relative string, entry os.DirEntry, limit *objectSnapshotLimit) (retErr error) { + name := entry.Name() + fd, err := openatOpaque(int(source.Fd()), name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + return fmt.Errorf("open object entry %q: %w", name, err) + } + input := os.NewFile(uintptr(fd), name) + defer func() { retErr = errors.Join(retErr, input.Close()) }() + + info, err := input.Stat() + if err != nil { + return err + } + limit.entries-- + if limit.entries < 0 { + return errors.New("git object snapshot entry limit exceeded") + } + + target := filepath.Join(relative, name) + switch { + case info.IsDir(): + if err := destination.Mkdir(target, 0o700); err != nil { + return err + } + return copyObjectDirectory(ctx, input, destination, target, limit) + case info.Mode().IsRegular(): + return copyObjectFile(ctx, input, destination, target, info.Size(), limit) + default: + return fmt.Errorf("git object entry %q is not a private regular file or directory", target) + } +} + +func copyObjectFile(ctx context.Context, input *os.File, destination *os.Root, target string, size int64, limit *objectSnapshotLimit) (retErr error) { + if target == filepath.Join("info", "alternates") || target == filepath.Join("info", "http-alternates") { + return errors.New("git object alternates are not permitted") + } + if size < 0 || size > limit.bytes { + return errors.New("git object snapshot byte limit exceeded") + } + limit.bytes -= size + + output, err := destination.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o400) + if err != nil { + return err + } + defer func() { retErr = errors.Join(retErr, output.Close()) }() + copied, err := io.Copy(output, io.LimitReader(contextReader{ctx: ctx, reader: input}, size+1)) + if err != nil { + return err + } + if copied != size { + return errors.New("git object changed while snapshotting") + } + return nil +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r contextReader) Read(buffer []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(buffer) +} + +func removeObjectSnapshot(root string) error { + _ = chmodObjectSnapshot(root, 0o700, 0o600) + return os.RemoveAll(root) +} + +func makeObjectSnapshotReadOnly(root string) error { + // Directories stay owner-writable so lifecycle cleanup can remove immutable + // object files; every copied object itself is read-only. + return chmodObjectSnapshot(root, 0o700, 0o400) //nolint:gosec // G302: owner-only directory, not a file +} + +func chmodObjectSnapshot(name string, directoryMode, fileMode os.FileMode) error { + root, err := os.OpenRoot(name) + if err != nil { + return err + } + defer func() { _ = root.Close() }() + return fs.WalkDir(root.FS(), ".", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + mode := fileMode + if entry.IsDir() { + mode = directoryMode + } + return root.Chmod(path, mode) + }) +} diff --git a/environment/microvm/repository_object_snapshot_test.go b/environment/microvm/repository_object_snapshot_test.go new file mode 100644 index 000000000..6b6a764be --- /dev/null +++ b/environment/microvm/repository_object_snapshot_test.go @@ -0,0 +1,86 @@ +package microvm + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/gitexec" +) + +func TestRepositoryObjectSnapshotRetainsPackedGitObjects(t *testing.T) { + t.Parallel() + root := t.TempDir() + repository := filepath.Join(root, "repository") + if err := os.Mkdir(repository, 0o700); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.name", "MicroVM Test"}, + {"config", "user.email", "microvm@example.invalid"}, + } { + if _, err := gitexec.Run(t.Context(), repository, nil, args...); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(repository, "tracked.txt"), []byte("snapshot content\n"), 0o600); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"add", "tracked.txt"}, {"commit", "-qm", "snapshot fixture"}, {"gc", "--prune=now"}} { + if _, err := gitexec.Run(t.Context(), repository, nil, args...); err != nil { + t.Fatal(err) + } + } + + common := filepath.Join(repository, ".git") + snapshot, err := snapshotRepositoryObjects(t.Context(), common, root) + if err != nil { + t.Fatal(err) + } + defer removeObjectSnapshot(snapshot) + packs, err := filepath.Glob(filepath.Join(snapshot, "pack", "*.pack")) + if err != nil || len(packs) == 0 { + t.Fatalf("packed objects missing from snapshot: packs=%v err=%v", packs, err) + } + indexes, err := filepath.Glob(filepath.Join(snapshot, "pack", "*.idx")) + if err != nil || len(indexes) == 0 { + t.Fatalf("pack indexes missing from snapshot: indexes=%v err=%v", indexes, err) + } + + original := filepath.Join(common, "objects") + if err := os.Rename(original, original+".host"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(original, 0o700); err != nil { + t.Fatal(err) + } + environment := []string{"GIT_OBJECT_DIRECTORY=" + snapshot} + if _, err := gitexec.RunWithEnv(t.Context(), repository, nil, environment, "cat-file", "-e", "HEAD^{tree}"); err != nil { + t.Fatalf("packed snapshot cannot serve Git object reads: %v", err) + } + status, err := gitexec.RunWithEnv(t.Context(), repository, nil, environment, "status", "--porcelain") + if err != nil || strings.TrimSpace(string(status)) != "" { + t.Fatalf("logical worktree status over snapshot = %q, %v", status, err) + } +} + +func TestRepositoryObjectSnapshotRejectsNestedSymlink(t *testing.T) { + t.Parallel() + root := t.TempDir() + common := filepath.Join(root, "repository.git") + if err := os.MkdirAll(filepath.Join(common, "objects", "pack"), 0o700); err != nil { + t.Fatal(err) + } + external := filepath.Join(root, "external") + if err := os.WriteFile(external, []byte("do not copy"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(common, "objects", "pack", "host.pack")); err != nil { + t.Fatal(err) + } + if _, err := snapshotRepositoryObjects(t.Context(), common, root); err == nil { + t.Fatal("nested object-store symlink was copied") + } +} diff --git a/environment/microvm/repository_rootfs.go b/environment/microvm/repository_rootfs.go new file mode 100644 index 000000000..e1d5a7e24 --- /dev/null +++ b/environment/microvm/repository_rootfs.go @@ -0,0 +1,117 @@ +package microvm + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/stacklok/mecatl/environment/microvm/guestexec" +) + +const ( + guestAgentArtifactName = "mecatl-guest-agent" + guestAgentInstallPath = "usr/local/bin/mecatl-guest-agent" +) + +var errRepositoryRootFSMaterialized = errors.New("repository rootfs is already materialized") + +type repositoryRootFSMaterializer struct { + mu sync.Mutex + materialized bool +} + +func newRepositoryRootFSMaterializer() *repositoryRootFSMaterializer { + return &repositoryRootFSMaterializer{} +} + +// Materialize clones the admitted Brood tree once, injects the independently +// admitted guest agent, and establishes the static guest runtime contract. +// The repository-VM generation owner must retain one materializer for the +// generation lifetime; per-boot configuration belongs to the later launch step. +func (m *repositoryRootFSMaterializer) Materialize(broodRoot, destination, guestArtifact string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.materialized { + return errRepositoryRootFSMaterialized + } + if err := cloneRootFS(broodRoot, destination); err != nil { + return err + } + defer func() { + if !m.materialized { + _ = os.RemoveAll(destination) + } + }() + if err := injectGuestAgent(destination, guestArtifact); err != nil { + return err + } + if err := establishGuestRuntimeContract(destination, guestexec.DefaultRuntimeContract()); err != nil { + return err + } + m.materialized = true + return nil +} + +func injectGuestAgent(rootfs, artifactRoot string) (retErr error) { + artifact, err := os.OpenRoot(artifactRoot) + if err != nil { + return fmt.Errorf("open verified guest-agent artifact: %w", err) + } + defer func() { retErr = errors.Join(retErr, artifact.Close()) }() + source, err := artifact.Open(guestAgentArtifactName) + if err != nil { + return fmt.Errorf("open verified guest agent: %w", err) + } + defer func() { retErr = errors.Join(retErr, source.Close()) }() + info, err := source.Stat() + if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return errors.New("verified guest agent is not an executable regular file") + } + + destination, err := os.OpenRoot(rootfs) + if err != nil { + return err + } + defer func() { retErr = errors.Join(retErr, destination.Close()) }() + parent := filepath.Dir(guestAgentInstallPath) + if err := destination.MkdirAll(parent, 0o755); err != nil { + return fmt.Errorf("create guest-agent install directory: %w", err) + } + output, err := destination.OpenFile(guestAgentInstallPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o755) + if err != nil { + return fmt.Errorf("create injected guest agent: %w", err) + } + _, copyErr := io.Copy(output, source) + return errors.Join(copyErr, output.Close(), destination.Chmod(guestAgentInstallPath, 0o755)) +} + +func establishGuestRuntimeContract(rootfs string, contract guestexec.RuntimeContract) (retErr error) { + identity := guestexec.DefaultWorkloadIdentity() + if contract.Identity != identity || identity.UID != 65532 || identity.GID != 65532 || + contract.Home == "" || contract.Workdir != "/workspace" || contract.Path == "" { + return errors.New("guest runtime contract is incomplete") + } + root, err := os.OpenRoot(rootfs) + if err != nil { + return err + } + defer func() { retErr = errors.Join(retErr, root.Close()) }() + paths := append([]string{contract.Home, contract.Workdir}, contract.CacheDirectories()...) + for _, guestPath := range paths { + relative := filepath.FromSlash(strings.TrimPrefix(guestPath, "/")) + if relative == "." || relative == "" || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("guest runtime directory is not confined") + } + if err := root.MkdirAll(relative, 0o700); err != nil { + return fmt.Errorf("create guest runtime directory %s: %w", guestPath, err) + } + if err := root.Chmod(relative, 0o700); err != nil { + return fmt.Errorf("make guest runtime directory writable %s: %w", guestPath, err) + } + } + return nil +} diff --git a/environment/microvm/repository_rootfs_test.go b/environment/microvm/repository_rootfs_test.go new file mode 100644 index 000000000..fbf958ed2 --- /dev/null +++ b/environment/microvm/repository_rootfs_test.go @@ -0,0 +1,105 @@ +package microvm + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/guestexec" +) + +func TestRepositoryRootFSMaterializer_ClonesStaticRootFSAndInjectsGuestAgentOnce(t *testing.T) { + t.Parallel() + root := t.TempDir() + brood := filepath.Join(root, "admitted-brood") + if err := os.MkdirAll(filepath.Join(brood, "etc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(brood, "etc", "brood-release"), []byte("direct admitted bytes\n"), 0o644); err != nil { + t.Fatal(err) + } + artifactSources := filepath.Join(root, "artifact-sources") + if err := os.Mkdir(artifactSources, 0o700); err != nil { + t.Fatal(err) + } + requests, resolver := testArtifactSet(t, artifactSources, nil, "") + verified, _, err := NewProvisioner(NewVerifiedCache(filepath.Join(root, "verified-cache")), resolver, testPolicy("guest-policy-v1", nil), nil, nil).Verify(t.Context(), requests) + if err != nil { + t.Fatalf("independently verify guest-agent release artifact: %v", err) + } + guestBytes := []byte("guest-agent-complete") + + materializer := newRepositoryRootFSMaterializer() + destination := filepath.Join(root, "repository-rootfs") + if err := materializer.Materialize(brood, destination, verified.GuestAgent.Path); err != nil { + t.Fatalf("materialize repository rootfs: %v", err) + } + got, err := os.ReadFile(filepath.Join(destination, guestAgentInstallPath)) + if err != nil || string(got) != string(guestBytes) { + t.Fatalf("injected guest agent = %q, %v", got, err) + } + if _, err := os.Stat(filepath.Join(brood, guestAgentInstallPath)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("admitted Brood rootfs was mutated: %v", err) + } + if _, err := os.Stat(filepath.Join(destination, "etc", "mecatl", "guest-agent.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("static repository rootfs contains per-boot guest configuration: %v", err) + } + if err := materializer.Materialize(brood, filepath.Join(root, "second-repository-rootfs"), verified.GuestAgent.Path); !errors.Is(err, errRepositoryRootFSMaterialized) { + t.Fatalf("second primitive materialization = %v, want errRepositoryRootFSMaterialized", err) + } + if _, err := os.Stat(filepath.Join(root, "second-repository-rootfs")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("second materialization created rootfs bytes: %v", err) + } +} + +func TestMicroVMRedesign_Scenario4_GuestRuntimeContractIsExplicit(t *testing.T) { + t.Parallel() + contract := guestexec.DefaultRuntimeContract() + identity := guestexec.DefaultWorkloadIdentity() + if contract.Identity != identity || identity.UID != 65532 || identity.GID != 65532 { + t.Fatalf("workload identity = %+v contract=%+v, want 65532:65532", identity, contract.Identity) + } + if contract.Home != "/home/guest" || contract.Workdir != "/workspace" { + t.Fatalf("HOME/workdir = %q/%q", contract.Home, contract.Workdir) + } + for _, required := range []string{"/usr/lib/go/bin", "/home/guest/go/bin", "/home/guest/.cargo/bin", "/home/guest/.local/bin"} { + if !strings.Contains(":"+contract.Path+":", ":"+required+":") { + t.Errorf("PATH %q omits Brood/toolchain path %q", contract.Path, required) + } + } + wantCaches := map[string]string{ + "GOCACHE": "/home/guest/.cache/go-build", + "GOMODCACHE": "/home/guest/go/pkg/mod", + "PIP_CACHE_DIR": "/home/guest/.cache/pip", + "npm_config_cache": "/home/guest/.cache/node", + "CARGO_HOME": "/home/guest/.cargo", + } + for name, path := range wantCaches { + if contract.Environment[name] != path { + t.Errorf("%s = %q, want %q", name, contract.Environment[name], path) + } + } + + rootfs := filepath.Join(t.TempDir(), "rootfs") + if err := os.Mkdir(rootfs, 0o700); err != nil { + t.Fatal(err) + } + if err := establishGuestRuntimeContract(rootfs, contract); err != nil { + t.Fatalf("establish guest runtime contract: %v", err) + } + for _, guestPath := range append([]string{contract.Home, contract.Workdir}, contract.CacheDirectories()...) { + info, err := os.Stat(filepath.Join(rootfs, filepath.FromSlash(strings.TrimPrefix(guestPath, "/")))) + if err != nil || !info.IsDir() || info.Mode().Perm()&0o200 == 0 { + t.Errorf("writable runtime directory %q = mode %v, %v", guestPath, infoMode(info), err) + } + } +} + +func infoMode(info os.FileInfo) os.FileMode { + if info == nil { + return 0 + } + return info.Mode().Perm() +} diff --git a/environment/microvm/repository_runtime.go b/environment/microvm/repository_runtime.go new file mode 100644 index 000000000..170b719a8 --- /dev/null +++ b/environment/microvm/repository_runtime.go @@ -0,0 +1,498 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync" + "time" + + gomicrovm "github.com/stacklok/go-microvm" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/guestagent" +) + +const ( + repositoryMountTag = "mecatl-repository-logical" + repositoryObjectMountTag = "mecatl-git-objects" + repositoryRollbackTimeout = 5 * time.Second + // RepositoryGuestMountRoot is the only guest namespace containing logical worktrees. + RepositoryGuestMountRoot = "/run/mecatl/repositories" +) + +// RepositoryRuntimeConfig wires the repository-scoped production adapters to +// the lowest hypervisor and guest-transport seams. +type RepositoryRuntimeConfig struct { + Backend GoMicroVMBackend + DialGuest GuestDialer + DialControl GuestDialer + UnixEndpoint bool + EndpointRoot string + Network *NetworkController + GuestEgress GuestEgressPolicy +} + +type repositoryRuntimeGeneration struct { + record RepositoryVMRecord + instance GoMicroVMInstance + issuer *control.CapabilityIssuer + authorityKey []byte + network NetworkHandle + objectSnapshot string +} + +type repositoryRuntimeAttacher interface { + AttachRepository(RepositoryVMRecord, RepositoryBootAuthority) error +} + +type repositoryRuntimeAborter interface { + Abort(context.Context, RepositoryVMRecord) error +} + +// RepositoryRuntime is both the concrete singleton VM runtime and logical guest +// registrar used by production microvmd composition. +type RepositoryRuntime struct { + backend GoMicroVMBackend + dial GuestDialer + controlDial GuestDialer + unixEndpoint bool + network *NetworkController + guestEgress GuestEgressPolicy + rollbackTimeout time.Duration + mu sync.Mutex + vms map[string]*repositoryRuntimeGeneration + listeners map[string]*net.UnixListener +} + +// NewRepositoryRuntime constructs the production repository adapters. There is +// deliberately no host filesystem or command-runner fallback. +func NewRepositoryRuntime(cfg RepositoryRuntimeConfig) (*RepositoryRuntime, error) { + if cfg.Backend == nil || cfg.Network == nil || (cfg.UnixEndpoint && (cfg.DialGuest != nil || cfg.DialControl != nil)) || (!cfg.UnixEndpoint && (cfg.DialGuest == nil || cfg.DialControl == nil)) { + return nil, errors.New("repository microvm runtime is not fully configured") + } + runtime := &RepositoryRuntime{backend: cfg.Backend, dial: cfg.DialGuest, controlDial: cfg.DialControl, unixEndpoint: cfg.UnixEndpoint, network: cfg.Network, guestEgress: cfg.GuestEgress, rollbackTimeout: repositoryRollbackTimeout, vms: make(map[string]*repositoryRuntimeGeneration), listeners: make(map[string]*net.UnixListener)} + if cfg.UnixEndpoint { + runtime.dial = runtime.acceptGuest + runtime.controlDial = runtime.acceptGuest + } + return runtime, nil +} + +// Start boots exactly the already-materialized repository rootfs and exports the +// repository logical namespace once. Individual worktrees are addressed only by +// guest-visible paths below RepositoryGuestMountRoot. +func (r *RepositoryRuntime) Start(ctx context.Context, record RepositoryVMRecord, verified VerifiedArtifacts, authority RepositoryBootAuthority) (_ RuntimeStatus, retErr error) { //nolint:gocyclo // explicit ownership transaction + listenerOwned := false + if r.unixEndpoint { + if err := r.listenGuest(record.Endpoint); err != nil { + return RuntimeStatus{}, err + } + listenerOwned = true + } + logicalRoot := filepath.Join(filepath.Dir(record.RootFSPath), "logical") + networkDir := record.Endpoint + ".network" + networkDirOwned := false + var network NetworkHandle + var instance GoMicroVMInstance + owned := &repositoryRuntimeGeneration{record: record, authorityKey: authority.bytes()} + committed := false + defer func() { + if committed { + return + } + retErr = errors.Join(retErr, r.rollback(ctx, record, owned, networkDir, networkDirOwned, listenerOwned)) + }() + objectSnapshot, err := snapshotRepositoryObjects(ctx, record.GitCommonDirectory, filepath.Dir(record.RootFSPath)) + if err != nil { + return RuntimeStatus{}, err + } + owned.objectSnapshot = objectSnapshot + if err := os.Mkdir(networkDir, 0o700); err != nil { + return RuntimeStatus{}, err + } + networkDirOwned = true + // The provider belongs to the repository generation, not the create RPC. + // Abort owns its cancellation after the request context is gone. + network, err = r.network.start(context.WithoutCancel(ctx), r.guestEgress, networkDir) + if err != nil { + return RuntimeStatus{}, err + } + owned.network = network + launch := GoMicroVMLaunch{ + EnvironmentID: record.VMID, VMID: record.VMID, Endpoint: record.Endpoint, Generation: record.Generation, + RepositoryOwner: record.Owner, RepositoryKey: record.RepositoryKey, + RuntimePath: verified.Runtime.Path, FirmwarePath: verified.Firmware.Path, ImagePath: record.RootFSPath, + Network: network.Provider, NetworkSocket: network.SocketPath, + VsockPort: control.GuestControlPort, + Mounts: []gomicrovm.VirtioFSMount{ + {Tag: repositoryMountTag, HostPath: logicalRoot}, + {Tag: repositoryObjectMountTag, HostPath: objectSnapshot, ReadOnly: true}, + }, + CapabilityKey: authority.bytes(), Verified: true, HostReadOnly: true, + DisableIPv6: r.guestEgress.tightened(), RepositoryRootFS: true, + } + instance, err = r.backend.Start(ctx, launch) + if err != nil { + return RuntimeStatus{}, err + } + owned.instance = instance + if err := instance.WaitReady(ctx); err != nil { + return RuntimeStatus{}, err + } + status, err := instance.Status(ctx) + if err != nil { + return RuntimeStatus{}, err + } + issuer, err := control.NewCapabilityIssuer(authority.bytes()) + if err != nil { + return RuntimeStatus{}, err + } + owned.issuer = issuer + r.mu.Lock() + if r.vms[record.VMID] != nil { + r.mu.Unlock() + return RuntimeStatus{}, ErrRepositoryVMInconsistent + } + r.vms[record.VMID] = owned + r.mu.Unlock() + + // The guest serves authenticated control only after all privileged startup + // work, including requested IPv6 disablement, has succeeded. Prove that point + // before this generation can be committed ready. + verifiedStatus := status + challenge, err := authority.healthChallenge(record) + if err == nil { + var response RepositoryHealthResponse + response, err = r.Health(ctx, record, challenge) + if err == nil { + err = authority.VerifyHealth(record, challenge, response) + } + if err == nil { + err = validateProvisionalRuntime(record, response.Status) + verifiedStatus = response.Status + } + } + if err != nil { + if r.guestEgress.tightened() { + return RuntimeStatus{}, fmt.Errorf("verify authenticated repository guest startup and IPv6 disablement: %w", err) + } + return RuntimeStatus{}, fmt.Errorf("verify authenticated repository guest startup: %w", err) + } + committed = true + return verifiedStatus, nil +} + +func (r *RepositoryRuntime) rollback(parent context.Context, record RepositoryVMRecord, owned *repositoryRuntimeGeneration, networkDir string, networkDirOwned, listenerOwned bool) error { + timeout := r.rollbackTimeout + if timeout <= 0 { + timeout = repositoryRollbackTimeout + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), timeout) + defer cancel() + + var stopErr, removeErr error + if owned.instance != nil { + if err := owned.instance.Stop(ctx); err != nil { + stopErr = fmt.Errorf("stop repository VM during rollback: %w", err) + } + if err := owned.instance.Remove(ctx); err != nil { + removeErr = fmt.Errorf("remove repository VM during rollback: %w", err) + } + } + if removeErr != nil { + r.mu.Lock() + existing := r.vms[record.VMID] + if existing == nil { + r.vms[record.VMID] = owned + } else if existing != owned { + removeErr = errors.Join(removeErr, ErrRepositoryVMInconsistent) + } + r.mu.Unlock() + return errors.Join(stopErr, removeErr) + } + + r.mu.Lock() + if r.vms[record.VMID] == owned { + delete(r.vms, record.VMID) + } + r.mu.Unlock() + if owned.network.Provider != nil { + owned.network.Provider.Stop() + } + var cleanupErr error + if owned.objectSnapshot != "" { + if err := removeObjectSnapshot(owned.objectSnapshot); err != nil { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("remove repository Git object snapshot during rollback: %w", err)) + } + } + if networkDirOwned { + if err := os.RemoveAll(networkDir); err != nil { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("remove repository network directory during rollback: %w", err)) + } + } + if listenerOwned { + r.closeGuestListener(record.Endpoint) + } + return errors.Join(stopErr, cleanupErr) +} + +// Abort discards a started generation only after VM teardown is confirmed. +func (r *RepositoryRuntime) Abort(ctx context.Context, record RepositoryVMRecord) error { + r.mu.Lock() + owned := r.vms[record.VMID] + r.mu.Unlock() + if owned == nil { + return ErrEnvironmentUnavailable + } + if owned.record.Owner != record.Owner || owned.record.RepositoryKey != record.RepositoryKey || owned.record.Generation != record.Generation || owned.record.VMID != record.VMID || owned.record.Endpoint != record.Endpoint || owned.record.RootFSPath != record.RootFSPath || owned.record.AuthorityDigest != record.AuthorityDigest { + return ErrRepositoryVMInconsistent + } + return r.rollback(ctx, record, owned, record.Endpoint+".network", true, r.unixEndpoint) +} + +// Health performs guest-origin proof of the host's unpredictable challenge. +func (r *RepositoryRuntime) Health(ctx context.Context, record RepositoryVMRecord, challenge RepositoryHealthChallenge) (RepositoryHealthResponse, error) { + if r.unixEndpoint { + r.mu.Lock() + listener := r.listeners[record.Endpoint] + r.mu.Unlock() + if listener == nil { + if err := removeIfExists(record.Endpoint); err != nil { + return RepositoryHealthResponse{}, err + } + if err := r.listenGuest(record.Endpoint); err != nil { + return RepositoryHealthResponse{}, err + } + } + } + r.mu.Lock() + generation := r.vms[record.VMID] + r.mu.Unlock() + if generation == nil || generation.instance == nil || generation.network.Provider == nil || generation.network.SocketPath == "" { + return RepositoryHealthResponse{}, errors.New("repository network backend is not live in this daemon process") + } + if generation.network.Provider.SocketPath() != generation.network.SocketPath { + return RepositoryHealthResponse{}, errors.New("repository network backend endpoint changed") + } + status, err := generation.instance.Status(ctx) + if err != nil { + return RepositoryHealthResponse{}, err + } + wireStatus := guestagent.RepositoryHealthStatus{Live: status.Live, Generation: status.Generation, VMID: status.VMID, PID: status.PID, ProcessIdentity: status.ProcessIdentity, Endpoint: status.Endpoint} + wireChallenge := guestagent.RepositoryHealthChallenge{Owner: challenge.Owner, RepositoryKey: challenge.RepositoryKey, VMID: challenge.VMID, Generation: challenge.Generation, Nonce: challenge.Nonce, Status: wireStatus} + response, err := r.control(ctx, record, guestagent.RepositoryControlRequest{Operation: guestagent.RepositoryHealth, Health: &wireChallenge}) + if err != nil { + return RepositoryHealthResponse{}, err + } + return RepositoryHealthResponse{Status: status, MAC: response.HealthMAC}, nil +} + +// AttachRepository restores capability issuance only for a generation whose VM +// and network backend are still owned by this daemon process. A daemon restart +// cannot safely reconstruct go-microvm's in-process hosted network provider. +func (r *RepositoryRuntime) AttachRepository(record RepositoryVMRecord, authority RepositoryBootAuthority) error { + issuer, err := control.NewCapabilityIssuer(authority.bytes()) + if err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + existing := r.vms[record.VMID] + if existing == nil || existing.instance == nil || existing.network.Provider == nil || existing.network.SocketPath == "" { + return ErrEnvironmentUnavailable + } + existing.issuer = issuer + existing.authorityKey = authority.bytes() + return nil +} + +// Register authenticates one logical binding, sends only its guest-visible root +// to the guest, then opens the separately authenticated data-plane connection. +func (r *RepositoryRuntime) Register(ctx context.Context, record RepositoryVMRecord, binding control.Binding, mount RepositoryGuestMount) (*guestagent.Services, error) { + expectedHostRoot := filepath.Join(filepath.Dir(record.RootFSPath), "logical", filepath.FromSlash(strings.TrimPrefix(binding.AssignedRoot, RepositoryGuestMountRoot+"/"))) + if mount.HostPath == "" || mount.GuestPath != binding.AssignedRoot || !filepath.IsAbs(mount.HostPath) || filepath.Clean(mount.HostPath) != expectedHostRoot || !guestLogicalRoot(binding.AssignedRoot) { + return nil, control.ErrBindingMismatch + } + generation, err := r.generation(record) + if err != nil { + return nil, err + } + registration, err := generation.issuer.Issue(binding) + if err != nil { + return nil, err + } + if _, err := r.control(ctx, record, guestagent.RepositoryControlRequest{Operation: guestagent.RepositoryRegister, Binding: binding, Capability: registration}); err != nil { + return nil, err + } + capability, err := generation.issuer.Issue(binding) + if err != nil { + return nil, err + } + stream, err := r.authenticatedGuest(ctx, record, guestagent.RepositoryChannelData) + if err != nil { + return nil, err + } + services, err := guestagent.Connect(ctx, stream, binding, capability) + if err != nil { + _ = stream.Close() + } + return services, err +} + +// Unregister generation/ref/root-authenticates removal in the guest. +func (r *RepositoryRuntime) Unregister(ctx context.Context, record RepositoryVMRecord, binding control.Binding) error { + generation, err := r.generation(record) + if err != nil { + return err + } + capability, err := generation.issuer.Issue(binding) + if err != nil { + return err + } + _, err = r.control(ctx, record, guestagent.RepositoryControlRequest{Operation: guestagent.RepositoryUnregister, Binding: binding, Capability: capability}) + return err +} + +func (r *RepositoryRuntime) generation(record RepositoryVMRecord) (*repositoryRuntimeGeneration, error) { + r.mu.Lock() + generation := r.vms[record.VMID] + r.mu.Unlock() + if generation == nil { + return nil, ErrEnvironmentUnavailable + } + return generation, nil +} + +func (r *RepositoryRuntime) control(ctx context.Context, record RepositoryVMRecord, request guestagent.RepositoryControlRequest) (guestagent.RepositoryControlResponse, error) { + stream, err := r.authenticatedGuest(ctx, record, guestagent.RepositoryChannelControl) + if err != nil { + return guestagent.RepositoryControlResponse{}, err + } + defer func() { _ = stream.Close() }() + codec := control.NewCodec(control.DefaultMaxMessageBytes) + if err := codec.Write(stream, request); err != nil { + return guestagent.RepositoryControlResponse{}, err + } + var response guestagent.RepositoryControlResponse + if err := codec.Read(stream, &response); err != nil { + return guestagent.RepositoryControlResponse{}, err + } + if response.ErrorCode != "" { + if response.ErrorCode == "logical_root_unavailable" { + return guestagent.RepositoryControlResponse{}, ErrRepositoryLogicalRootUnavailable + } + return guestagent.RepositoryControlResponse{}, fmt.Errorf("repository guest control rejected: %s", response.ErrorCode) + } + return response, nil +} + +const maxRejectedRepositoryGuestConnections = 16 + +func (r *RepositoryRuntime) authenticatedGuest(ctx context.Context, record RepositoryVMRecord, purpose guestagent.RepositoryChannelPurpose) (io.ReadWriteCloser, error) { + r.mu.Lock() + generation := r.vms[record.VMID] + var authorityKey []byte + if generation != nil { + authorityKey = append(authorityKey, generation.authorityKey...) + } + r.mu.Unlock() + if generation == nil { + return nil, ErrEnvironmentUnavailable + } + for range maxRejectedRepositoryGuestConnections { + stream, dialErr := r.dialForPurpose(ctx, record.Endpoint, purpose) + if dialErr != nil { + return nil, dialErr + } + authErr := guestagent.AuthenticateHostRepositoryChannel(ctx, stream, authorityKey, record.Owner, record.RepositoryKey, record.VMID, record.Generation, purpose) + if authErr == nil { + return stream, nil + } + _ = stream.Close() + if ctx.Err() != nil { + return nil, ctx.Err() + } + } + return nil, guestagent.ErrUnauthenticatedRepositoryChannel +} + +func (r *RepositoryRuntime) dialForPurpose(ctx context.Context, endpoint string, purpose guestagent.RepositoryChannelPurpose) (io.ReadWriteCloser, error) { + if purpose == guestagent.RepositoryChannelControl { + return r.controlDial(ctx, endpoint) + } + return r.dial(ctx, endpoint) +} + +func (r *RepositoryRuntime) listenGuest(endpoint string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.listeners[endpoint] != nil { + return ErrRepositoryVMInconsistent + } + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: endpoint, Net: "unix"}) + if err != nil { + return fmt.Errorf("listen for repository guest vsock: %w", err) + } + if err := os.Chmod(endpoint, 0o600); err != nil { + _ = listener.Close() + _ = os.Remove(endpoint) + return err + } + r.listeners[endpoint] = listener + return nil +} + +func (r *RepositoryRuntime) closeGuestListener(endpoint string) { + r.mu.Lock() + listener := r.listeners[endpoint] + delete(r.listeners, endpoint) + r.mu.Unlock() + if listener != nil { + _ = listener.Close() + } + _ = os.Remove(endpoint) +} + +func (r *RepositoryRuntime) acceptGuest(ctx context.Context, endpoint string) (io.ReadWriteCloser, error) { + r.mu.Lock() + listener := r.listeners[endpoint] + r.mu.Unlock() + if listener == nil { + return nil, ErrEnvironmentUnavailable + } + for { + if err := listener.SetDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + return nil, err + } + conn, err := listener.AcceptUnix() + if err == nil { + return conn, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + return nil, err + } + } +} + +func guestLogicalRoot(root string) bool { + rel, err := filepath.Rel(RepositoryGuestMountRoot, root) + if err != nil || filepath.IsAbs(rel) { + return false + } + parts := strings.Split(filepath.ToSlash(rel), "/") + return len(parts) == 2 && validOpaquePathComponent(parts[0]) && parts[1] == "worktree" +} + +var _ RepositoryVMRuntime = (*RepositoryRuntime)(nil) +var _ RepositoryGuestRegistrar = (*RepositoryRuntime)(nil) +var _ io.Closer = (*guestagent.Services)(nil) diff --git a/environment/microvm/resolver.go b/environment/microvm/resolver.go new file mode 100644 index 000000000..f3eb3d104 --- /dev/null +++ b/environment/microvm/resolver.go @@ -0,0 +1,85 @@ +package microvm + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" +) + +// EnvironmentRegistryReader is the read half used by the host reattachment adapter. +type EnvironmentRegistryReader interface { + Lookup(context.Context, string) (EnvironmentRecord, error) +} + +// Resolver validates the exact durable generation before a host adapter constructs +// any live Workspace or CommandRunner. It never provisions a replacement. +type Resolver struct { + registry EnvironmentRegistryReader + reattacher RuntimeReattacher +} + +// NewResolver constructs the fail-closed driver-side resolution seam. +func NewResolver(registry EnvironmentRegistryReader) *Resolver { return &Resolver{registry: registry} } + +// NewReattachingResolver additionally verifies that the exact daemon generation +// is live. The reattacher is verification-only and must never provision. +func NewReattachingResolver(registry EnvironmentRegistryReader, reattacher RuntimeReattacher) *Resolver { + return &Resolver{registry: registry, reattacher: reattacher} +} + +// Resolve returns the exact ready record for ref and owner. +func (r *Resolver) Resolve(ctx context.Context, ref EnvironmentRef, owner string) (EnvironmentRecord, error) { + environmentID, generation, err := parseEnvironmentRef(ref) + if err != nil || owner == "" || r == nil || r.registry == nil { + return EnvironmentRecord{}, ErrInvalidEnvironmentRef + } + record, err := r.registry.Lookup(ctx, environmentID) + if err != nil { + if errors.Is(err, ErrEnvironmentUnknown) { + return EnvironmentRecord{}, ErrEnvironmentUnknown + } + return EnvironmentRecord{}, fmt.Errorf("lookup microvm environment: %w", err) + } + if record.Owner != owner { + return EnvironmentRecord{}, ErrEnvironmentForeign + } + if record.State == EnvironmentDestroyed { + return EnvironmentRecord{}, ErrEnvironmentDestroyed + } + if record.State != EnvironmentReady || record.EnvironmentID != environmentID || record.Ref != ref || record.Generation != generation { + return EnvironmentRecord{}, ErrEnvironmentStale + } + if err := validateAgreement(record.Agreement); err != nil { + return EnvironmentRecord{}, ErrEnvironmentIncompatible + } + if r.reattacher != nil { + if err := r.reattacher.Reattach(ctx, record); err != nil { + if errors.Is(err, ErrEnvironmentUnavailable) || errors.Is(err, ErrRuntimeIdentityMismatch) { + return EnvironmentRecord{}, err + } + return EnvironmentRecord{}, fmt.Errorf("reattach exact microvm generation: %w", err) + } + } + return cloneEnvironmentRecord(record), nil +} + +func parseEnvironmentRef(ref EnvironmentRef) (string, uint32, error) { + if ref.Kind != Kind || strings.TrimSpace(ref.ID) != ref.ID { + return "", 0, ErrInvalidEnvironmentRef + } + separator := strings.LastIndexByte(ref.ID, '@') + if separator <= 0 || separator == len(ref.ID)-1 { + return "", 0, ErrInvalidEnvironmentRef + } + environmentID := ref.ID[:separator] + if strings.TrimSpace(environmentID) != environmentID { + return "", 0, ErrInvalidEnvironmentRef + } + generation, err := strconv.ParseUint(ref.ID[separator+1:], 10, 32) + if err != nil || generation == 0 { + return "", 0, ErrInvalidEnvironmentRef + } + return environmentID, uint32(generation), nil +} diff --git a/environment/microvm/resolver_test.go b/environment/microvm/resolver_test.go new file mode 100644 index 000000000..136a8c345 --- /dev/null +++ b/environment/microvm/resolver_test.go @@ -0,0 +1,53 @@ +package microvm + +import ( + "context" + "errors" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/control" +) + +func TestLifecycleResolverRejectsInvalidForeignStaleDestroyedAndIncompatibleRefs(t *testing.T) { + t.Parallel() + base := EnvironmentRecord{ + State: EnvironmentReady, Owner: "caller:alice", EnvironmentID: "env-1", + Ref: EnvironmentRef{Kind: Kind, ID: "env-1@7"}, Generation: 7, + Agreement: control.Agreement{Version: control.ProtocolVersion, Capabilities: control.RequiredCapabilities(), MaxMessageBytes: control.DefaultMaxMessageBytes}, + } + tests := []struct { + name string + ref EnvironmentRef + owner string + mutate func(*EnvironmentRecord) + want error + }{ + {name: "invalid", ref: EnvironmentRef{Kind: Kind, ID: "not-generation-fenced"}, owner: base.Owner, want: ErrInvalidEnvironmentRef}, + {name: "unknown", ref: base.Ref, owner: base.Owner, mutate: func(record *EnvironmentRecord) { *record = EnvironmentRecord{} }, want: ErrEnvironmentUnknown}, + {name: "foreign", ref: base.Ref, owner: "caller:bob", want: ErrEnvironmentForeign}, + {name: "generation mismatch", ref: base.Ref, owner: base.Owner, mutate: func(record *EnvironmentRecord) { record.Generation++ }, want: ErrEnvironmentStale}, + {name: "destroyed", ref: base.Ref, owner: base.Owner, mutate: func(record *EnvironmentRecord) { record.State = EnvironmentDestroyed }, want: ErrEnvironmentDestroyed}, + {name: "incompatible", ref: base.Ref, owner: base.Owner, mutate: func(record *EnvironmentRecord) { record.Agreement.Version++ }, want: ErrEnvironmentIncompatible}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + record := cloneEnvironmentRecord(base) + if tc.mutate != nil { + tc.mutate(&record) + } + resolver := NewResolver(fakeRegistryReader{record: record}) + if _, err := resolver.Resolve(context.Background(), tc.ref, tc.owner); !errors.Is(err, tc.want) { + t.Fatalf("Resolve() error = %v, want %v", err, tc.want) + } + }) + } +} + +type fakeRegistryReader struct{ record EnvironmentRecord } + +func (f fakeRegistryReader) Lookup(context.Context, string) (EnvironmentRecord, error) { + if f.record.EnvironmentID == "" { + return EnvironmentRecord{}, ErrEnvironmentUnknown + } + return f.record, nil +} diff --git a/environment/microvm/runtime.go b/environment/microvm/runtime.go new file mode 100644 index 000000000..22ae09278 --- /dev/null +++ b/environment/microvm/runtime.go @@ -0,0 +1,877 @@ +package microvm + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + gomicrovm "github.com/stacklok/go-microvm" + "github.com/stacklok/go-microvm/extract" + gomicrovmlibkrun "github.com/stacklok/go-microvm/hypervisor/libkrun" + gomicrovmimage "github.com/stacklok/go-microvm/image" + gomicrovmnet "github.com/stacklok/go-microvm/net" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/guestagent" + "github.com/stacklok/mecatl/environment/microvm/guestexec" + "github.com/stacklok/mecatl/environment/microvm/virtiofs" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +// GuestDialer opens the one vsock-backed host endpoint for a guest generation. +type GuestDialer func(context.Context, string) (io.ReadWriteCloser, error) + +// CapabilityKeySource returns fresh per-boot guest capability key material. +type CapabilityKeySource func() ([]byte, error) + +// GoMicroVMLaunch is the fully resolved, verified launch description handed to +// the hypervisor backend. Artifact paths are cache-admitted immutable payloads. +type GoMicroVMLaunch struct { + EnvironmentID string + VMID string + Endpoint string + Generation uint32 + RepositoryOwner string + RepositoryKey string + Prepared *worktree.Prepared + RuntimePath string + FirmwarePath string + ImagePath string + NetworkSocket string + Network gomicrovmnet.Provider + VsockPort uint32 + Mounts []gomicrovm.VirtioFSMount + Preboot GuestPrebootArtifact + CapabilityKey []byte + CPUs uint32 + MemoryMiB uint32 + Verified bool + HostReadOnly bool + DisableIPv6 bool + // RepositoryRootFS tells the production backend to boot the already-private, + // already-materialized singleton rootfs without cloning it per logical session. + RepositoryRootFS bool +} + +// GoMicroVMInstance is the concrete runtime handle retained by microvmd. +type GoMicroVMInstance interface { + WaitReady(context.Context) error + Status(context.Context) (RuntimeStatus, error) + Stop(context.Context) error + Remove(context.Context) error +} + +// GoMicroVMBackend is the hypervisor seam. The production implementation below +// calls go-microvm; deterministic tests replace only this lowest-level seam. +type GoMicroVMBackend interface { + Start(context.Context, GoMicroVMLaunch) (GoMicroVMInstance, error) + Open(context.Context, EnvironmentRecord) (GoMicroVMInstance, error) +} + +// GoMicroVMRuntimeConfig configures the concrete VMRuntime adapter. +type GoMicroVMRuntimeConfig struct { + Backend GoMicroVMBackend + Network *NetworkController + GuestEgress GuestEgressPolicy + DialGuest GuestDialer + UnixGuestEndpoint bool + CapabilityKey CapabilityKeySource + Observer *OperationsObserver +} + +type runtimeGeneration struct { + instance GoMicroVMInstance + network gomicrovmnet.Provider + networkDir string + issuer *control.CapabilityIssuer + services *guestagent.Services + binding control.Binding + endpoint string +} + +// GoMicroVMRuntime composes verified artifacts, virtio-fs, hosted networking, +// vsock, transferable capabilities, and the unified guest Workspace/exec stream. +type GoMicroVMRuntime struct { + backend GoMicroVMBackend + network *NetworkController + guestEgress GuestEgressPolicy + dialGuest GuestDialer + unixEndpoint bool + capabilityKey CapabilityKeySource + observer *OperationsObserver + + mu sync.Mutex + generations map[string]*runtimeGeneration + listeners map[string]*net.UnixListener +} + +// NewGoMicroVMRuntime constructs a fail-closed concrete runtime. No host +// Workspace or command-runner fallback is installed. +func NewGoMicroVMRuntime(cfg GoMicroVMRuntimeConfig) (*GoMicroVMRuntime, error) { + if cfg.Backend == nil || cfg.Network == nil || cfg.CapabilityKey == nil || (cfg.DialGuest == nil) == !cfg.UnixGuestEndpoint { + return nil, errors.New("go-microvm runtime is not fully configured") + } + runtime := &GoMicroVMRuntime{ + backend: cfg.Backend, network: cfg.Network, guestEgress: cfg.GuestEgress, + dialGuest: cfg.DialGuest, unixEndpoint: cfg.UnixGuestEndpoint, capabilityKey: cfg.CapabilityKey, observer: cfg.Observer, + generations: make(map[string]*runtimeGeneration), listeners: make(map[string]*net.UnixListener), + } + if cfg.UnixGuestEndpoint { + runtime.dialGuest = runtime.acceptGuest + } + return runtime, nil +} + +// Create implements VMRuntime.Create. +func (r *GoMicroVMRuntime) Create(ctx context.Context, request VMCreateRequest) error { + if err := validateConcreteCreate(request); err != nil { + return err + } + if r.unixEndpoint { + if err := r.listenGuest(request.Endpoint); err != nil { + return err + } + defer func() { + if _, err := r.generation(EnvironmentRef{Kind: Kind, ID: request.Preboot.Config.Binding.Ref}); err != nil { + r.closeGuestListener(request.Endpoint) + } + }() + } + plan, err := virtiofs.Plan(request.Prepared, nil) + if err != nil { + return fmt.Errorf("plan go-microvm virtio-fs: %w", err) + } + if err := plan.PrepareWorkloadAccess(); err != nil { + return fmt.Errorf("prepare go-microvm workload access: %w", err) + } + mounts := plan.GoMicroVMMounts() + hostReadOnly := false + for _, mount := range mounts { + hostReadOnly = hostReadOnly || mount.ReadOnly + } + if !hostReadOnly { + return errors.New("go-microvm launch has no host-enforced read-only mount") + } + networkDir := request.Endpoint + ".network" + if err := os.Mkdir(networkDir, 0o700); err != nil { + return fmt.Errorf("create microvm network directory: %w", err) + } + // The provider belongs to the environment generation, not the create RPC. + // Stop/Remove own its cancellation after the request context is gone. + network, err := r.network.start(context.WithoutCancel(ctx), r.guestEgress, networkDir) + if err != nil { + _ = os.RemoveAll(networkDir) + return err + } + key, err := r.capabilityKey() + if err != nil { + network.Provider.Stop() + _ = os.RemoveAll(networkDir) + return fmt.Errorf("generate microvm guest capability key: %w", err) + } + issuer, err := control.NewCapabilityIssuer(key) + if err != nil { + network.Provider.Stop() + _ = os.RemoveAll(networkDir) + return err + } + preboot := request.Preboot + preboot.Config.DisableIPv6 = r.guestEgress.tightened() + launch := GoMicroVMLaunch{ + EnvironmentID: request.EnvironmentID, VMID: request.VMID, Endpoint: request.Endpoint, + Generation: request.Generation, Prepared: request.Prepared, + RuntimePath: request.Verified.Runtime.Path, FirmwarePath: request.Verified.Firmware.Path, + ImagePath: request.Verified.ExecutionImage.Path, NetworkSocket: network.SocketPath, + Network: network.Provider, VsockPort: control.GuestControlPort, Mounts: mounts, + Preboot: preboot, CapabilityKey: append([]byte(nil), key...), + CPUs: positiveUint32(request.Resources.CPU), MemoryMiB: bytesToMiB(request.Resources.RAMBytes), + Verified: true, HostReadOnly: true, DisableIPv6: r.guestEgress.tightened(), + } + instance, err := r.backend.Start(ctx, launch) + if err != nil { + network.Provider.Stop() + _ = os.RemoveAll(networkDir) + return fmt.Errorf("start verified go-microvm instance: %w", err) + } + binding := request.Preboot.Config.Binding + r.mu.Lock() + if _, exists := r.generations[binding.Ref]; exists { + r.mu.Unlock() + _ = instance.Stop(context.WithoutCancel(ctx)) + _ = instance.Remove(context.WithoutCancel(ctx)) + network.Provider.Stop() + _ = os.RemoveAll(networkDir) + return ErrEnvironmentStale + } + r.generations[binding.Ref] = &runtimeGeneration{instance: instance, network: network.Provider, networkDir: networkDir, issuer: issuer, binding: binding, endpoint: request.Endpoint} + r.mu.Unlock() + if source, ok := network.Provider.(egressDenialSource); ok && r.observer != nil { + r.observer.trackEgressDenials(binding.Ref, source) + } + return nil +} + +func validateConcreteCreate(request VMCreateRequest) error { + if request.Prepared == nil || request.Endpoint == "" || !filepath.IsAbs(request.Endpoint) || request.Preboot.Config.Binding.Ref == "" { + return errors.New("go-microvm create request is incomplete") + } + for _, artifact := range request.Verified.All() { + if artifact.Path == "" || !filepath.IsAbs(artifact.Path) { + return fmt.Errorf("verified %s artifact has no absolute execution path", artifact.Kind) + } + if _, err := os.Stat(artifact.Path); err != nil { + return fmt.Errorf("open verified %s artifact: %w", artifact.Kind, err) + } + } + return nil +} + +func positiveUint32(value int64) uint32 { + if value <= 0 { + return 0 + } + if value > int64(^uint32(0)) { + return ^uint32(0) + } + return uint32(value) +} + +func bytesToMiB(bytes int64) uint32 { + const mib = int64(1 << 20) + if bytes <= 0 { + return 0 + } + value := (bytes + mib - 1) / mib + if value > int64(^uint32(0)) { + return ^uint32(0) + } + return uint32(value) +} + +// WaitReady waits for the exact process-local generation and its guest vsock +// endpoint. A live runner alone is not ready for protocol negotiation. +func (r *GoMicroVMRuntime) WaitReady(ctx context.Context, record EnvironmentRecord) error { + generation, err := r.generation(record.Ref) + if err != nil { + return err + } + if !r.unixEndpoint { + return generation.instance.WaitReady(ctx) + } + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + if err := generation.instance.WaitReady(ctx); err != nil { + return err + } + if info, statErr := os.Lstat(generation.endpoint); statErr == nil && info.Mode()&os.ModeSocket != 0 { + return nil + } else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("inspect microvm guest endpoint: %w", statErr) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +// Inspect returns the generation-fenced runtime identity. +func (r *GoMicroVMRuntime) Inspect(ctx context.Context, record EnvironmentRecord) (RuntimeStatus, error) { + generation, err := r.ensureGeneration(ctx, record) + if err != nil { + return RuntimeStatus{}, err + } + return generation.instance.Status(ctx) +} + +// Negotiate opens the one authenticated multiplexed guest stream. +func (r *GoMicroVMRuntime) Negotiate(ctx context.Context, endpoint string, binding control.Binding) (control.Agreement, error) { + generation, err := r.generation(EnvironmentRef{Kind: Kind, ID: binding.Ref}) + if err != nil || generation.binding != binding || endpoint != generation.endpoint { + return control.Agreement{}, control.ErrBindingMismatch + } + capability, err := generation.issuer.Issue(binding) + if err != nil { + return control.Agreement{}, err + } + stream, err := r.dialGuest(ctx, endpoint) + if err != nil { + return control.Agreement{}, fmt.Errorf("dial microvm guest vsock endpoint: %w", err) + } + services, err := guestagent.Connect(ctx, stream, binding, capability) + if err != nil { + _ = stream.Close() + return control.Agreement{}, err + } + r.mu.Lock() + generation.services = services + r.mu.Unlock() + return services.Agreement(), nil +} + +// Services returns the already-negotiated complete guest Workspace/exec pair. +func (r *GoMicroVMRuntime) Services(ref EnvironmentRef) (*guestagent.Services, error) { + generation, err := r.generation(ref) + if err != nil { + return nil, err + } + if generation.services == nil { + return nil, ErrEnvironmentUnavailable + } + return generation.services, nil +} + +// Reattach resolves the exact generation through the backend without creating it. +func (r *GoMicroVMRuntime) Reattach(ctx context.Context, record EnvironmentRecord) error { + generation, err := r.ensureGeneration(ctx, record) + if err != nil { + return err + } + if generation.services != nil { + return nil + } + if generation.issuer == nil || generation.endpoint != record.Endpoint { + return ErrEnvironmentUnavailable + } + _, err = r.Negotiate(ctx, record.Endpoint, bindingForRecord(record)) + return err +} + +// Detach drops only the process-local data-plane handle. +func (r *GoMicroVMRuntime) Detach(_ context.Context, record EnvironmentRecord) error { + generation, err := r.generation(record.Ref) + if err != nil { + return err + } + if generation.services != nil { + err = generation.services.Close() + generation.services = nil + } + return err +} + +// Destroy stops and removes the exact generation and its explicit network provider. +func (r *GoMicroVMRuntime) Destroy(ctx context.Context, record EnvironmentRecord) error { + generation, err := r.ensureGeneration(ctx, record) + if err != nil { + if errors.Is(err, ErrEnvironmentUnavailable) { + return nil + } + return err + } + if generation.services != nil { + _ = generation.services.Close() + } + err = errors.Join(generation.instance.Stop(ctx), generation.instance.Remove(ctx)) + if generation.network != nil { + if r.observer != nil { + r.observer.untrackEgressDenials(record.Ref.ID) + } + generation.network.Stop() + } + if generation.networkDir != "" { + err = errors.Join(err, os.RemoveAll(generation.networkDir)) + } + r.closeGuestListener(record.Endpoint) + r.mu.Lock() + delete(r.generations, record.Ref.ID) + r.mu.Unlock() + return err +} + +func (r *GoMicroVMRuntime) generation(ref EnvironmentRef) (*runtimeGeneration, error) { + if ref.Kind != Kind || ref.ID == "" { + return nil, ErrInvalidEnvironmentRef + } + r.mu.Lock() + defer r.mu.Unlock() + generation := r.generations[ref.ID] + if generation == nil { + return nil, ErrEnvironmentUnavailable + } + return generation, nil +} + +func (r *GoMicroVMRuntime) ensureGeneration(ctx context.Context, record EnvironmentRecord) (*runtimeGeneration, error) { + generation, err := r.generation(record.Ref) + if err == nil { + if record.ProcessIdentity != "" || record.RunnerPID != 0 { + status, statusErr := generation.instance.Status(ctx) + if statusErr != nil { + return nil, statusErr + } + if identityErr := validateRuntimeIdentity(record, status); identityErr != nil { + return nil, identityErr + } + } + return generation, nil + } + instance, openErr := r.backend.Open(ctx, record) + if openErr != nil { + return nil, errors.Join(ErrEnvironmentUnavailable, openErr) + } + status, statusErr := instance.Status(ctx) + if statusErr != nil { + return nil, errors.Join(ErrEnvironmentUnavailable, statusErr) + } + if identityErr := validateRuntimeIdentity(record, status); identityErr != nil { + return nil, identityErr + } + generation = &runtimeGeneration{instance: instance, binding: bindingForRecord(record), endpoint: record.Endpoint} + r.mu.Lock() + r.generations[record.Ref.ID] = generation + r.mu.Unlock() + return generation, nil +} + +// LibkrunBackend is the production go-microvm backend. Runtime and firmware +// artifacts are supplied as explicit environment paths consumed by the pinned +// runner; the execution image is an already-verified rootfs. +type LibkrunBackend struct { + mu sync.Mutex + instances map[string]*libkrunInstance + ownedArtifactDir string + userNamespaceUID int + userNamespaceGID int +} + +// NewLibkrunBackend constructs the production backend. ownedArtifactDir is an +// executable daemon-owned filesystem used to retain generation runtime libraries. +func NewLibkrunBackend(ownedArtifactDir ...string) *LibkrunBackend { + workload := guestexec.DefaultWorkloadIdentity() + backend := &LibkrunBackend{ + instances: make(map[string]*libkrunInstance), + userNamespaceUID: int(workload.UID), userNamespaceGID: int(workload.GID), + } + if len(ownedArtifactDir) > 0 { + backend.ownedArtifactDir = ownedArtifactDir[0] + } + return backend +} + +// Start launches go-microvm with explicit rootfs, network, vsock, and v0.0.40 +// host-enforced ReadOnly virtio-fs options. +func (b *LibkrunBackend) Start(ctx context.Context, launch GoMicroVMLaunch) (GoMicroVMInstance, error) { + if !launch.Verified || launch.Network == nil || !launch.HostReadOnly { + return nil, errors.New("refusing incomplete go-microvm launch") + } + endpointParent := filepath.Dir(launch.Endpoint) + ownedArtifactParent := b.ownedArtifactDir + if ownedArtifactParent == "" { + ownedArtifactParent = endpointParent + } + runtimePath, firmwarePath, ownedArtifactsRoot, err := prepareOwnedRuntimeArtifacts( + launch.RuntimePath, launch.FirmwarePath, ownedArtifactParent, launch.EnvironmentID, + ) + if err != nil { + return nil, fmt.Errorf("prepare generation-owned microvm artifacts: %w", err) + } + rootfsPath := filepath.Join(endpointParent, "rootfs-"+launch.EnvironmentID) + if launch.RepositoryRootFS { + rootfsPath = launch.ImagePath + if err := writeRepositoryGuestBootConfig(rootfsPath, launch); err != nil { + _ = os.RemoveAll(ownedArtifactsRoot) + return nil, fmt.Errorf("prepare repository guest boot config: %w", err) + } + } else if err := prepareExecutionRootFS(launch.ImagePath, rootfsPath, launch.Preboot, launch.CapabilityKey); err != nil { + _ = os.RemoveAll(ownedArtifactsRoot) + return nil, fmt.Errorf("prepare private microvm execution rootfs: %w", err) + } + backend := gomicrovmlibkrun.NewBackend( + gomicrovmlibkrun.WithRuntime(extract.Dir(runtimePath)), + gomicrovmlibkrun.WithFirmware(extract.Dir(firmwarePath)), + gomicrovmlibkrun.WithCacheDir(filepath.Join(endpointParent, "runtime-cache")), + // Linux maps the fixed guest workload IDs to the daemon's real IDs. The + // runner gains only namespace-local SETUID/SETGID, which lets virtio-fs + // create files as workload 65532 without host CAP_CHOWN or sudo. + gomicrovmlibkrun.WithUserNamespaceUID(uint32(b.userNamespaceUID), uint32(b.userNamespaceGID)), // #nosec G115 -- supported OS user IDs are uint32 values. + ) + opts := []gomicrovm.Option{ + gomicrovm.WithName(launch.VMID), gomicrovm.WithRootFSPath(rootfsPath), + gomicrovm.WithDataDir(filepath.Join(endpointParent, "data-"+launch.EnvironmentID)), + gomicrovm.WithBackend(backend), + gomicrovm.WithInitOverride("/usr/local/bin/mecatl-guest-agent"), + gomicrovm.WithNetProvider(alreadyStartedProvider{Provider: launch.Network}), + gomicrovm.WithVirtioFS(launch.Mounts...), gomicrovm.WithVsock(launch.VsockPort, launch.Endpoint), + gomicrovm.WithoutSSH(), + } + if launch.CPUs > 0 { + opts = append(opts, gomicrovm.WithCPUs(launch.CPUs)) + } + if launch.MemoryMiB > 0 { + opts = append(opts, gomicrovm.WithMemory(launch.MemoryMiB)) + } + vm, err := gomicrovm.Run(ctx, "", opts...) + if err != nil { + if !launch.RepositoryRootFS { + _ = os.RemoveAll(rootfsPath) + } + _ = os.RemoveAll(ownedArtifactsRoot) + return nil, err + } + instance := &libkrunInstance{vm: vm, launch: launch, rootfsPath: rootfsPath, ownedArtifactsRoot: ownedArtifactsRoot} + b.mu.Lock() + b.instances[launch.EnvironmentID] = instance + b.mu.Unlock() + return instance, nil +} + +func prepareOwnedRuntimeArtifacts(runtimeSource, firmwareSource, parent, environmentID string) (runtimePath, firmwarePath, ownedRoot string, retErr error) { + ownedRoot = filepath.Join(parent, "artifacts-"+environmentID) + if err := os.Mkdir(ownedRoot, 0o700); err != nil { + return "", "", "", err + } + defer func() { + if retErr != nil { + _ = os.RemoveAll(ownedRoot) + } + }() + runtimePath = filepath.Join(ownedRoot, "runtime") + if err := cloneRootFS(runtimeSource, runtimePath); err != nil { + return "", "", "", err + } + firmwarePath = filepath.Join(ownedRoot, "firmware") + if err := cloneRootFS(firmwareSource, firmwarePath); err != nil { + return "", "", "", err + } + return runtimePath, firmwarePath, ownedRoot, nil +} + +// Open resolves a process-local handle or reconstructs a destruction-only handle +// after daemon restart. It never provisions a replacement. +func (b *LibkrunBackend) Open(ctx context.Context, record EnvironmentRecord) (GoMicroVMInstance, error) { + b.mu.Lock() + instance := b.instances[record.EnvironmentID] + b.mu.Unlock() + if instance != nil { + if instance.launch.Generation != record.Generation || instance.launch.VMID != record.VMID { + return nil, ErrEnvironmentUnavailable + } + return instance, nil + } + if record.RunnerPID <= 0 || record.ProcessIdentity == "" || record.Endpoint == "" || record.EnvironmentID == "" || record.VMID == "" { + return nil, ErrEnvironmentUnavailable + } + identity, err := ProcessStartIdentity(ctx, record.RunnerPID) + if err != nil { + return nil, errors.Join(ErrEnvironmentUnavailable, err) + } + if identity != record.ProcessIdentity { + return nil, ErrRuntimeIdentityMismatch + } + endpoint, err := os.Lstat(record.Endpoint) + if err != nil || endpoint.Mode()&os.ModeSocket == 0 || endpoint.Mode()&os.ModeSymlink != 0 { + return nil, errors.Join(ErrRuntimeIdentityMismatch, err) + } + ownedArtifactParent := b.ownedArtifactDir + if ownedArtifactParent == "" { + ownedArtifactParent = filepath.Dir(record.Endpoint) + } + return &orphanLibkrunInstance{ + record: record, rootfsPath: filepath.Join(filepath.Dir(record.Endpoint), "rootfs-"+record.EnvironmentID), + dataPath: filepath.Join(filepath.Dir(record.Endpoint), "data-"+record.EnvironmentID), networkPath: record.Endpoint + ".network", + ownedArtifactsRoot: filepath.Join(ownedArtifactParent, "artifacts-"+record.EnvironmentID), + }, nil +} + +type alreadyStartedProvider struct{ gomicrovmnet.Provider } + +func (alreadyStartedProvider) Start(context.Context, gomicrovmnet.Config) error { return nil } + +type repositoryGuestBootConfig struct { + Owner string `json:"Owner"` + RepositoryKey string `json:"RepositoryKey"` + VMID string `json:"VMID"` + Endpoint string `json:"Endpoint"` + Generation uint32 `json:"Generation"` +} + +type guestBootConfig struct { + GuestPrebootConfig + CapabilityKey string `json:"capability_key"` + Repository *repositoryGuestBootConfig `json:"repository,omitempty"` +} + +func writeRepositoryGuestBootConfig(rootfs string, launch GoMicroVMLaunch) error { + if launch.RepositoryOwner == "" || launch.RepositoryKey == "" || launch.VMID == "" || launch.Generation == 0 || len(launch.CapabilityKey) < 32 { + return errors.New("repository guest boot config is incomplete") + } + if err := secureRootFSMkdir(rootfs, filepath.Join("etc", "mecatl")); err != nil { + return err + } + payload, err := json.Marshal(guestBootConfig{ + GuestPrebootConfig: GuestPrebootConfig{DisableIPv6: launch.DisableIPv6, AgentEndpoint: launch.Endpoint, Capabilities: control.RequiredCapabilities(), MaxMessageBytes: control.DefaultMaxMessageBytes}, + CapabilityKey: base64.RawStdEncoding.EncodeToString(launch.CapabilityKey), + Repository: &repositoryGuestBootConfig{Owner: launch.RepositoryOwner, RepositoryKey: launch.RepositoryKey, VMID: launch.VMID, Endpoint: launch.Endpoint, Generation: launch.Generation}, + }) + if err != nil { + return err + } + target := filepath.Join(rootfs, strings.TrimPrefix(GuestPrebootConfigPath, "/")) + file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) // #nosec G304 -- target is confined beneath the validated private rootfs. + if err != nil { + return err + } + _, writeErr := file.Write(append(payload, '\n')) + return errors.Join(writeErr, file.Close()) +} + +func prepareExecutionRootFS(source, destination string, preboot GuestPrebootArtifact, key []byte) (retErr error) { + if !filepath.IsAbs(source) || !filepath.IsAbs(destination) || source == destination { + return errors.New("execution rootfs paths must be distinct absolute paths") + } + if err := cloneRootFS(source, destination); err != nil { + return err + } + defer func() { + if retErr != nil { + _ = os.RemoveAll(destination) + } + }() + return prebootRootFSHook(preboot, key)(destination, nil) +} + +func cloneRootFS(source, destination string) error { + return copyTree(source, destination) +} + +func secureRootFSMkdir(root, relative string) error { + rootInfo, err := os.Lstat(root) + if err != nil || !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { + return errors.New("private execution rootfs is not a directory") + } + current := root + for _, component := range []string{"etc", "mecatl"} { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(current, 0o755); err != nil { + return err + } + continue + } + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("unsafe execution rootfs parent %s", relative) + } + } + return os.Chmod(current, 0o700) // #nosec G302 -- directory is deliberately owner-only; secret file remains 0600. +} + +func prebootRootFSHook(preboot GuestPrebootArtifact, key []byte) gomicrovm.RootFSHook { + return func(rootfsPath string, _ *gomicrovmimage.OCIConfig) error { + if preboot.RootfsPath != GuestPrebootConfigPath { + return errors.New("invalid guest preboot config path") + } + target := filepath.Join(rootfsPath, "etc", "mecatl", "guest-agent.json") + if err := secureRootFSMkdir(rootfsPath, filepath.Join("etc", "mecatl")); err != nil { + return err + } + payload, err := json.Marshal(guestBootConfig{GuestPrebootConfig: preboot.Config, CapabilityKey: base64.RawStdEncoding.EncodeToString(key)}) + if err != nil { + return err + } + file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + _, writeErr := file.Write(append(payload, '\n')) + return errors.Join(writeErr, file.Close()) + } +} + +type libkrunInstance struct { + vm *gomicrovm.VM + launch GoMicroVMLaunch + rootfsPath string + ownedArtifactsRoot string +} + +func (i *libkrunInstance) WaitReady(ctx context.Context) error { + status, err := i.vm.Status(ctx) + if err != nil { + return err + } + if !status.Active { + return ErrEnvironmentUnavailable + } + return nil +} +func (i *libkrunInstance) Status(ctx context.Context) (RuntimeStatus, error) { + status, err := i.vm.Status(ctx) + if err != nil { + return RuntimeStatus{}, err + } + identity, err := ProcessStartIdentity(ctx, i.vm.RunnerPID()) + if err != nil { + return RuntimeStatus{}, err + } + return RuntimeStatus{Live: status.Active, Generation: i.launch.Generation, VMID: i.launch.VMID, PID: i.vm.RunnerPID(), ProcessIdentity: identity, Endpoint: i.launch.Endpoint}, nil +} +func (i *libkrunInstance) Stop(ctx context.Context) error { return i.vm.Stop(ctx) } +func (i *libkrunInstance) Remove(ctx context.Context) error { + var rootfsErr error + if !i.launch.RepositoryRootFS { + rootfsErr = os.RemoveAll(i.rootfsPath) + } + return errors.Join(i.vm.Remove(ctx), rootfsErr, os.RemoveAll(i.ownedArtifactsRoot)) +} + +type orphanLibkrunInstance struct { + record EnvironmentRecord + rootfsPath string + dataPath string + networkPath string + ownedArtifactsRoot string +} + +func (*orphanLibkrunInstance) WaitReady(context.Context) error { + return ErrEnvironmentUnavailable +} + +func (i *orphanLibkrunInstance) Status(ctx context.Context) (RuntimeStatus, error) { + identity, err := ProcessStartIdentity(ctx, i.record.RunnerPID) + if err != nil { + return RuntimeStatus{}, ErrEnvironmentUnavailable + } + if identity != i.record.ProcessIdentity { + return RuntimeStatus{}, ErrRuntimeIdentityMismatch + } + return RuntimeStatus{Live: true, Generation: i.record.Generation, VMID: i.record.VMID, PID: i.record.RunnerPID, ProcessIdentity: identity, Endpoint: i.record.Endpoint}, nil +} + +func (i *orphanLibkrunInstance) Stop(ctx context.Context) error { + identity, err := ProcessStartIdentity(ctx, i.record.RunnerPID) + if err != nil { + return nil // already gone + } + if identity != i.record.ProcessIdentity { + return ErrRuntimeIdentityMismatch + } + process, err := os.FindProcess(i.record.RunnerPID) + if err != nil { + return err + } + if err := process.Signal(syscall.SIGTERM); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + identity, err = ProcessStartIdentity(ctx, i.record.RunnerPID) + if err != nil || identity != i.record.ProcessIdentity { + return nil + } + select { + case <-ctx.Done(): + return context.Cause(ctx) + case <-ticker.C: + } + } +} + +func (i *orphanLibkrunInstance) Remove(context.Context) error { + return errors.Join( + removeIfExists(i.record.Endpoint), + os.RemoveAll(i.networkPath), + os.RemoveAll(i.dataPath), + os.RemoveAll(i.rootfsPath), + os.RemoveAll(i.ownedArtifactsRoot), + ) +} + +func removeIfExists(path string) error { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// ProcessStartIdentity returns the platform process-start token used by runtime, +// reconciliation, and doctor checks to distinguish a live generation from PID reuse. +func ProcessStartIdentity(ctx context.Context, pid int) (string, error) { + return runnerProcessIdentity(ctx, pid) +} + +func runnerProcessIdentity(ctx context.Context, pid int) (string, error) { + if pid <= 0 { + return "", ErrEnvironmentUnavailable + } + start, err := platformProcessStartIdentity(ctx, pid) + if err != nil || start == "" { + return "", ErrEnvironmentUnavailable + } + return fmt.Sprintf("process-start:%d:%s", pid, start), nil +} + +func (r *GoMicroVMRuntime) listenGuest(endpoint string) error { + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: endpoint, Net: "unix"}) + if err != nil { + return fmt.Errorf("listen for microvm guest vsock: %w", err) + } + if err := os.Chmod(endpoint, 0o600); err != nil { + _ = listener.Close() + _ = os.Remove(endpoint) + return err + } + r.mu.Lock() + r.listeners[endpoint] = listener + r.mu.Unlock() + return nil +} + +func (r *GoMicroVMRuntime) acceptGuest(ctx context.Context, endpoint string) (io.ReadWriteCloser, error) { + r.mu.Lock() + listener := r.listeners[endpoint] + r.mu.Unlock() + if listener == nil { + return nil, ErrEnvironmentUnavailable + } + for { + if err := listener.SetDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + return nil, err + } + conn, err := listener.AcceptUnix() + if err == nil { + return conn, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + return nil, err + } + } +} + +func (r *GoMicroVMRuntime) closeGuestListener(endpoint string) { + r.mu.Lock() + listener := r.listeners[endpoint] + delete(r.listeners, endpoint) + r.mu.Unlock() + if listener != nil { + _ = listener.Close() + } + _ = os.Remove(endpoint) +} + +// UnixGuestDialer opens a pre-existing host endpoint. Production runtimes use +// GoMicroVMRuntimeConfig.UnixGuestEndpoint so the listener exists before boot; +// this helper remains available to clients attaching to an external endpoint. +func UnixGuestDialer(ctx context.Context, endpoint string) (io.ReadWriteCloser, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", endpoint) +} diff --git a/environment/microvm/runtime_test.go b/environment/microvm/runtime_test.go new file mode 100644 index 000000000..05ccef7db --- /dev/null +++ b/environment/microvm/runtime_test.go @@ -0,0 +1,478 @@ +package microvm + +import ( + "context" + "errors" + "io" + "net" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "testing" + "time" + + gomicrovmnet "github.com/stacklok/go-microvm/net" + + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/control/controltest" + "github.com/stacklok/mecatl/environment/microvm/guestagent" + "github.com/stacklok/mecatl/environment/microvm/guestexec" + "github.com/stacklok/mecatl/environment/microvm/virtiofs" + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +func TestMicroVMMVP_Scenario6_LinuxUserNamespaceAvoidsWorldModeWidening(t *testing.T) { + if goruntime.GOOS != "linux" || goruntime.GOARCH != "amd64" { + t.Skip("Linux amd64 user-namespace contract") + } + + backend := NewLibkrunBackend() + workload := guestexec.DefaultWorkloadIdentity() + if workload.UID != 65532 || workload.GID != 65532 || backend.userNamespaceUID != int(workload.UID) || backend.userNamespaceGID != int(workload.GID) { + t.Fatalf("libkrun workload user namespace = %d:%d for workload %d:%d, want 65532:65532", backend.userNamespaceUID, backend.userNamespaceGID, workload.UID, workload.GID) + } + if workload.UID == 0 || workload.GID == 0 { + t.Fatal("model workload identity is privileged") + } + + prepared := preparedRuntimeWorktree(t, t.TempDir()) + privateFile := filepath.Join(prepared.WorktreePath, "private") + if err := os.WriteFile(privateFile, []byte("private"), 0o600); err != nil { + t.Fatal(err) + } + plan, err := virtiofs.Plan(prepared, nil) + if err != nil { + t.Fatal(err) + } + if err := plan.PrepareWorkloadAccess(); err != nil { + t.Fatal(err) + } + for path, want := range map[string]os.FileMode{ + prepared.WorktreePath: 0o700, + prepared.MetadataPath: 0o700, + privateFile: 0o600, + } { + info, statErr := os.Stat(path) + if statErr != nil { + t.Fatal(statErr) + } + if got := info.Mode().Perm(); got != want || got&0o007 != 0 { + t.Errorf("mode %s = %#o, want private %#o", path, got, want) + } + } +} + +func TestLibkrunGenerationOwnsRuntimeArtifacts(t *testing.T) { + root := t.TempDir() + runtimeSource := filepath.Join(root, "verified-runtime") + firmwareSource := filepath.Join(root, "verified-firmware") + for path, content := range map[string]string{runtimeSource: "runner", firmwareSource: "firmware"} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "payload"), []byte(content), 0o700); err != nil { + t.Fatal(err) + } + } + + runtimePath, firmwarePath, ownedRoot, err := prepareOwnedRuntimeArtifacts(runtimeSource, firmwareSource, root, "generation") + if err != nil { + t.Fatalf("prepare owned runtime artifacts: %v", err) + } + defer func() { _ = os.RemoveAll(ownedRoot) }() + if err := os.RemoveAll(runtimeSource); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(firmwareSource); err != nil { + t.Fatal(err) + } + for path, want := range map[string]string{runtimePath: "runner", firmwarePath: "firmware"} { + got, readErr := os.ReadFile(filepath.Join(path, "payload")) + if readErr != nil || string(got) != want { + t.Fatalf("owned artifact %s after launch snapshot release = %q, %v", path, got, readErr) + } + } +} + +func TestMicroVMRuntime_FreshInstanceVerifiesExactRuntimeIdentity(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + prepared := preparedRuntimeWorktree(t, root) + binding := control.Binding{Owner: "caller:alice", SessionID: "session-runtime", EnvironmentID: "env-runtime", Ref: "env-runtime@3", Generation: 3} + backend := newRuntimeTestBackend(t) + runtime, err := NewGoMicroVMRuntime(GoMicroVMRuntimeConfig{ + Backend: backend, Network: NewNetworkController(func() gomicrovmnet.Provider { return &runtimeTestNetwork{socket: filepath.Join(root, "network.sock")} }, prebootIPv6Only{}), + GuestEgress: GuestEgressPolicy{Mode: EgressDenyAll}, DialGuest: backend.DialGuest, + CapabilityKey: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, + }) + if err != nil { + t.Fatal(err) + } + createRequest := runtimeCreateRequest(prepared, binding) + verified, _, err := runtimeArtifactVerifier(root).Verify(ctx, createRequest.ArtifactRequests) + if err != nil { + t.Fatal(err) + } + request := VMCreateRequest{EnvironmentID: binding.EnvironmentID, VMID: "vm-runtime", Endpoint: filepath.Join(root, "guest.sock"), Generation: binding.Generation, Owner: binding.Owner, SessionID: binding.SessionID, Prepared: prepared, Verified: verified, Preboot: GuestPrebootArtifact{RootfsPath: GuestPrebootConfigPath, Config: GuestPrebootConfig{DisableIPv6: true, AgentEndpoint: filepath.Join(root, "guest.sock"), Binding: binding, Capabilities: control.RequiredCapabilities(), MaxMessageBytes: control.DefaultMaxMessageBytes}}} + if err := runtime.Create(ctx, request); err != nil { + t.Fatalf("Create: %v", err) + } + status, err := runtime.Inspect(ctx, EnvironmentRecord{Ref: EnvironmentRef{Kind: Kind, ID: binding.Ref}}) + if err != nil { + t.Fatalf("Inspect: %v", err) + } + record := EnvironmentRecord{State: EnvironmentReady, Owner: binding.Owner, SessionID: binding.SessionID, EnvironmentID: binding.EnvironmentID, Ref: EnvironmentRef{Kind: Kind, ID: binding.Ref}, Generation: binding.Generation, VMID: status.VMID, RunnerPID: status.PID, ProcessIdentity: status.ProcessIdentity, Endpoint: status.Endpoint, Agreement: control.Agreement{Version: control.ProtocolVersion, Capabilities: control.RequiredCapabilities(), MaxMessageBytes: control.DefaultMaxMessageBytes}} + + fresh, err := NewGoMicroVMRuntime(GoMicroVMRuntimeConfig{Backend: backend, Network: runtime.network, GuestEgress: runtime.guestEgress, DialGuest: backend.DialGuest, CapabilityKey: runtime.capabilityKey}) + if err != nil { + t.Fatal(err) + } + wrong := record + wrong.Endpoint += ".stale" + if err := fresh.Reattach(ctx, wrong); !errors.Is(err, ErrRuntimeIdentityMismatch) { + t.Fatalf("Reattach(stale endpoint) = %v, want identity mismatch", err) + } +} + +func TestLibkrunBackend_FreshBackendOpensDestructionOnlyExactProcess(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := exec.Command("sleep", "30") + if err := command.Start(); err != nil { + t.Skipf("sleep helper unavailable: %v", err) + } + waited := make(chan error, 1) + go func() { waited <- command.Wait() }() + defer func() { + _ = command.Process.Kill() + select { + case <-waited: + case <-time.After(time.Second): + } + }() + + root := t.TempDir() + endpoint := filepath.Join(root, "guest.sock") + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: endpoint, Net: "unix"}) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + identity, err := runnerProcessIdentity(ctx, command.Process.Pid) + if err != nil { + t.Fatal(err) + } + record := readyRecord("fresh-backend", 9) + record.RunnerPID = command.Process.Pid + record.ProcessIdentity = identity + record.Endpoint = endpoint + + instance, err := NewLibkrunBackend().Open(ctx, record) + if err != nil { + t.Fatalf("fresh backend Open(): %v", err) + } + status, err := instance.Status(ctx) + if err != nil || status.ProcessIdentity != identity || status.Generation != record.Generation { + t.Fatalf("fresh backend status = %+v, %v", status, err) + } + if err := instance.Stop(ctx); err != nil { + t.Fatalf("identity-checked orphan Stop(): %v", err) + } + if err := instance.Remove(ctx); err != nil { + t.Fatalf("orphan Remove(): %v", err) + } + select { + case <-waited: + case <-ctx.Done(): + t.Fatal("orphan runner remained live after destruction") + } +} + +func TestMicroVMRuntime_ComposesVerifiedEnvironmentEndToEnd(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + root := t.TempDir() + prepared := preparedRuntimeWorktree(t, root) + endpoint := filepath.Join(root, "guest.sock") + identity := EnvironmentIdentity{EnvironmentID: "env-runtime", VMID: "vm-runtime", Endpoint: endpoint, Generation: 3} + binding := control.Binding{Owner: "caller:alice", SessionID: "session-runtime", EnvironmentID: identity.EnvironmentID, Ref: "env-runtime@3", Generation: identity.Generation} + + provider := &runtimeTestNetwork{socket: filepath.Join(root, "network.sock")} + observer := NewOperationsObserver(nil) + backend := newRuntimeTestBackend(t) + registry := newLifecycleRegistry() + auth, err := control.NewService(control.ServiceConfig{AccountUID: 1000, PeerAuthenticator: controltest.StaticPeerAuthenticator{UID: 1000}}) + if err != nil { + t.Fatal(err) + } + rootComposition, err := NewRuntimeDaemon(RuntimeDaemonConfig{ + Control: auth, Backend: backend, + Network: NewNetworkController(func() gomicrovmnet.Provider { return provider }, prebootIPv6Only{}), + GuestEgress: GuestEgressPolicy{Mode: EgressDenyAll}, DialGuest: backend.DialGuest, + CapabilityKey: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, + Identities: staticRuntimeIdentity{identity}, Worktrees: staticRuntimeWorktree{prepared: prepared}, + Artifacts: runtimeArtifactVerifier(root), Registry: registry, Sessions: &fakeSessionPersister{}, + Observer: observer, Retention: runtimeTestRetention{}, + }) + if err != nil { + t.Fatalf("NewRuntimeDaemon: %v", err) + } + runtime, daemon := rootComposition.Runtime, rootComposition.Daemon + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + request := runtimeCreateRequest(prepared, binding) + created := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleCreate, Binding: control.Binding{Owner: binding.Owner, SessionID: binding.SessionID}, Create: &request}) + if created.Err != nil || created.Record == nil || created.Record.State != EnvironmentReady { + t.Fatalf("create through daemon = %+v", created) + } + if !backend.launch.Verified || !backend.launch.HostReadOnly || backend.launch.NetworkSocket != provider.socket || backend.launch.VsockPort != control.GuestControlPort { + t.Fatalf("concrete launch omitted verified/read-only/network/vsock wiring: %+v", backend.launch) + } + provider.denials = 2 + if got := observer.Snapshot().EgressDenials; got != 2 { + t.Fatalf("active runtime egress denials before destruction = %d, want 2", got) + } + for _, mount := range backend.launch.Mounts { + if mount.OverrideUID != 0 || mount.OverrideGID != 0 { + t.Fatalf("mount %q relies on unsupported ownership override %d:%d", mount.Tag, mount.OverrideUID, mount.OverrideGID) + } + } + worktreeInfo, err := os.Stat(prepared.WorktreePath) + if err != nil || worktreeInfo.Mode().Perm() != 0o700 { + t.Fatalf("private worktree mode = %v, %v", worktreeInfo.Mode().Perm(), err) + } + + services, err := runtime.Services(created.Record.Ref) + if err != nil { + t.Fatalf("resolve runtime services: %v", err) + } + if _, err := services.Workspace.CreateFile(ctx, "runtime.txt", []byte("guest workspace\n")); err != nil { + t.Fatalf("guest Workspace create: %v", err) + } + contents, err := os.ReadFile(filepath.Join(prepared.WorktreePath, "runtime.txt")) + if err != nil || string(contents) != "guest workspace\n" { + t.Fatalf("host-visible guest write = %q, %v", contents, err) + } + result, err := services.Runner.Run(ctx, "printf concrete-runtime") + if err != nil || result.Stdout != "concrete-runtime" || result.ExitCode != 0 { + t.Fatalf("guest exec = %#v, %v", result, err) + } + + if response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleDetach, Binding: created.Binding}); response.Err != nil { + t.Fatalf("detach through daemon: %v", response.Err) + } + if response := daemon.Handle(ctx, server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleResolve, Binding: created.Binding}); response.Err != nil { + t.Fatalf("reattach through daemon: %v", response.Err) + } + if _, err := runtime.Services(created.Record.Ref); err != nil { + t.Fatalf("reattach did not restore exact guest services: %v", err) + } + cancel() + if err := provider.startCtx.Err(); err != nil { + t.Fatalf("generation network inherited create cancellation: %v", err) + } + if response := daemon.Handle(context.Background(), server, LifecycleRequest{Version: LifecycleProtocolVersion, Operation: LifecycleDelete, Binding: created.Binding}); response.Err != nil { + t.Fatalf("delete through daemon: %v", response.Err) + } + if backend.hostFallback || backend.stopCalls != 1 || backend.removeCalls != 1 || !provider.stopped { + t.Fatalf("lifecycle/fallback drift: backend=%+v provider=%+v", backend, provider) + } +} + +type staticRuntimeIdentity struct{ identity EnvironmentIdentity } + +func (s staticRuntimeIdentity) Allocate(string) (EnvironmentIdentity, error) { return s.identity, nil } + +type staticRuntimeWorktree struct{ prepared *worktree.Prepared } + +func (s staticRuntimeWorktree) Prepare(context.Context, worktree.Request) (*worktree.Prepared, error) { + return s.prepared, nil +} +func (staticRuntimeWorktree) Cleanup(context.Context, *worktree.Prepared) error { return nil } + +type prebootIPv6Only struct{} + +func (prebootIPv6Only) DisableIPv6(context.Context) error { return nil } + +type runtimeTestRetention struct{} + +func (runtimeTestRetention) Dirty(context.Context, EnvironmentRecord) (bool, error) { + return false, nil +} +func (runtimeTestRetention) Cleanup(context.Context, EnvironmentRecord) error { return nil } + +type runtimeTestNetwork struct { + socket string + stopped bool + denials uint64 + startCtx context.Context +} + +func (n *runtimeTestNetwork) Start(ctx context.Context, _ gomicrovmnet.Config) error { + n.startCtx = ctx + return nil +} +func (n *runtimeTestNetwork) SocketPath() string { return n.socket } +func (n *runtimeTestNetwork) Stop() { n.stopped = true } +func (n *runtimeTestNetwork) EgressDenials() uint64 { return n.denials } + +type runtimeTestBackend struct { + t *testing.T + launch GoMicroVMLaunch + host net.Conn + guest net.Conn + serveErr chan error + stopCalls int + removeCalls int + hostFallback bool +} + +func newRuntimeTestBackend(t *testing.T) *runtimeTestBackend { + return &runtimeTestBackend{t: t, serveErr: make(chan error, 1)} +} + +func (b *runtimeTestBackend) Start(ctx context.Context, launch GoMicroVMLaunch) (GoMicroVMInstance, error) { + b.launch = launch + b.hostFallback = !launch.Verified || !launch.HostReadOnly || launch.Network == nil || launch.VsockPort == 0 + if err := b.startGuest(ctx); err != nil { + return nil, err + } + return &runtimeTestInstance{backend: b, launch: launch}, nil +} + +func (b *runtimeTestBackend) startGuest(ctx context.Context) error { + b.host, b.guest = net.Pipe() + server, err := guestagent.NewServer(guestagent.ServerConfig{ + Binding: b.launch.Preboot.Config.Binding, CapabilityKey: b.launch.CapabilityKey, + WorkspaceRoot: b.launch.Prepared.WorktreePath, + WorkloadIdentity: guestexec.WorkloadIdentity{UID: uint32(os.Geteuid()), GID: uint32(os.Getegid())}, + }) + if err != nil { + return err + } + go func() { b.serveErr <- server.Serve(ctx, b.guest) }() + return nil +} + +func (b *runtimeTestBackend) Open(context.Context, EnvironmentRecord) (GoMicroVMInstance, error) { + return &runtimeTestInstance{backend: b, launch: b.launch}, nil +} +func (b *runtimeTestBackend) DialGuest(ctx context.Context, _ string) (io.ReadWriteCloser, error) { + if b.host == nil { + if err := b.startGuest(ctx); err != nil { + return nil, err + } + } + host := b.host + b.host = nil + return host, nil +} + +type runtimeTestInstance struct { + backend *runtimeTestBackend + launch GoMicroVMLaunch +} + +func (*runtimeTestInstance) WaitReady(context.Context) error { return nil } +func (i *runtimeTestInstance) Status(context.Context) (RuntimeStatus, error) { + return RuntimeStatus{Live: true, Generation: i.launch.Generation, VMID: i.launch.VMID, PID: 4242, ProcessIdentity: "boot:4242", Endpoint: i.launch.Endpoint}, nil +} +func (i *runtimeTestInstance) Stop(context.Context) error { + i.backend.stopCalls++ + if i.backend.guest != nil { + _ = i.backend.guest.Close() + } + return nil +} +func (i *runtimeTestInstance) Remove(context.Context) error { i.backend.removeCalls++; return nil } + +func preparedRuntimeWorktree(t *testing.T, root string) *worktree.Prepared { + t.Helper() + prepared := &worktree.Prepared{ + SourceRoot: filepath.Join(root, "source"), WorktreePath: filepath.Join(root, "worktree"), + MetadataPath: filepath.Join(root, "metadata"), CommonObjectStore: filepath.Join(root, "objects"), Branch: "mecatl/runtime", + } + for _, dir := range []string{prepared.SourceRoot, prepared.WorktreePath, prepared.MetadataPath, prepared.CommonObjectStore} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + return prepared +} + +func runtimeArtifactVerifier(root string) ArtifactVerifier { + artifact := func(kind ArtifactKind) VerifiedArtifact { + path := filepath.Join(root, string(kind)) + _ = os.WriteFile(path, []byte(kind), 0o600) + return VerifiedArtifact{Kind: kind, Digest: "sha256:" + string(kind), Path: path} + } + return &fakeArtifactVerifier{verified: VerifiedArtifacts{ + Runtime: artifact(ArtifactRuntime), Firmware: artifact(ArtifactFirmware), + ExecutionImage: artifact(ArtifactExecutionImage), GuestAgent: artifact(ArtifactGuestAgent), + }, policyRevision: "policy-runtime"} +} + +func TestGoMicroVMRuntime_PrebootDoesNotMutateVerifiedImageOrFollowSymlinks(t *testing.T) { + t.Parallel() + preboot := GuestPrebootArtifact{RootfsPath: GuestPrebootConfigPath, Config: GuestPrebootConfig{Binding: control.Binding{Owner: "owner", SessionID: "session", EnvironmentID: "env", Ref: "env@1", Generation: 1}}} + key := []byte("0123456789abcdef0123456789abcdef") + + t.Run("immutable source", func(t *testing.T) { + source, destination := t.TempDir(), filepath.Join(t.TempDir(), "rootfs") + if err := os.MkdirAll(filepath.Join(source, "etc"), 0o755); err != nil { + t.Fatal(err) + } + if err := prepareExecutionRootFS(source, destination, preboot, key); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(filepath.Join(source, "etc", "mecatl", "guest-agent.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("verified source image was mutated: %v", err) + } + configPath := filepath.Join(destination, "etc", "mecatl", "guest-agent.json") + info, err := os.Stat(configPath) + if err != nil { + t.Fatalf("private rootfs has no preboot config: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("preboot capability material mode = %#o, want 0600", info.Mode().Perm()) + } + parentInfo, err := os.Stat(filepath.Dir(configPath)) + if err != nil { + t.Fatalf("stat preboot capability directory: %v", err) + } + if parentInfo.Mode().Perm() != 0o700 { + t.Fatalf("preboot capability directory mode = %#o, want 0700", parentInfo.Mode().Perm()) + } + }) + + t.Run("symlink parent", func(t *testing.T) { + source, outside := t.TempDir(), t.TempDir() + if err := os.MkdirAll(filepath.Join(source, "etc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(source, "etc", "mecatl")); err != nil { + t.Fatal(err) + } + if err := prepareExecutionRootFS(source, filepath.Join(t.TempDir(), "rootfs"), preboot, key); err == nil { + t.Fatal("preboot installation followed an image-controlled parent symlink") + } + if _, err := os.Stat(filepath.Join(outside, "guest-agent.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("preboot escaped private rootfs: %v", err) + } + }) +} + +func runtimeCreateRequest(prepared *worktree.Prepared, binding control.Binding) CreateRequest { + requests := make(map[ArtifactKind]ArtifactRequest) + for _, kind := range []ArtifactKind{ArtifactRuntime, ArtifactFirmware, ArtifactExecutionImage, ArtifactGuestAgent} { + requests[kind] = ArtifactRequest{Kind: kind, Reference: string(kind) + "@sha256:" + string(kind), Digest: "sha256:" + string(kind)} + } + return CreateRequest{ + Owner: binding.Owner, SessionID: binding.SessionID, Profile: "secure", + Worktree: worktree.Request{Source: prepared.SourceRoot, WorktreePath: prepared.WorktreePath, MetadataPath: prepared.MetadataPath, Branch: prepared.Branch}, + ArtifactRequests: requests, + } +} diff --git a/environment/microvm/testdata/sigstore/bundle.json b/environment/microvm/testdata/sigstore/bundle.json new file mode 100644 index 000000000..950ae965b --- /dev/null +++ b/environment/microvm/testdata/sigstore/bundle.json @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json", "verificationMaterial":{"publicKey":{"hint":"1RU/aC3hJbaa2fuX2JSlNUec/eEcNgsMQyQClfwnepo="}, "tlogEntries":[{"logIndex":"2514017326", "logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion":{"kind":"hashedrekord", "version":"0.0.1"}, "integratedTime":"1787123184", "inclusionPromise":{"signedEntryTimestamp":"MEYCIQDrwNwCSyZ+Y1p1bnFRT2vS1JRFhO8hbd1Oa3K8w691bQIhAL3tTHkGSlx1hWPXagoIwnQjdTpHEHdhPloeQenlQaRG"}, "inclusionProof":{"logIndex":"2392113064", "rootHash":"JC2EPEP7dIQxh9cUVHxwp814gJwz7t0amDJwoEI1cl0=", "treeSize":"2392113382", "hashes":["w43En+LOaRwPU+UPu1xJmncp7ywZEEq8YQbNU5VOH6w=", "1+RKbax2o0bk+SoJcGK5bCEcwRz0ZsugDP8sifVN6O4=", "M3ovGqQhXdfa2MNR0qGH5HjHzmXepDMKPuorXpe3qI8=", "J8v5M7tAyn5Kfp5WSlWcbItXjFwfjp/OJs1/sE5ecAM=", "50h3V57XZe1/70jynrVL46Z4UoXS5hi0+0xhIo+J/3E=", "WHDXg0eziXMnRAKEM3p8aece+x+CR7mxR37gdcyUbIg=", "pIXDU2IrciRzj5/+l4fU5KQcpQXocPZbGueQeUHhBBQ=", "H+REVv4hJrgk4++la8i10hY28K2IO3gkxqu5EweBT9o=", "Zgdzcq5hpkQ9o9AUYINx/akJPL41vqXapYTxDVb9efg=", "7ecnfIA1+6v75TCHA7UuAH+naadu0qjv7AGKsgXNXmA=", "5yCv90nbz8P6pfoCgZMXEL3OTiXlfZwRixJMtr6cE7g=", "IeRXSlbmLU2d9YaeM/B/ObUTmBA1MKKoumWEBF31uDA=", "ivG0+UtTESg617QDnb6K16zTy0c059+VWHi3Lwz2/Ro=", "yfcxQaSTMEOLumr8GpdPsYLbBTgs3MvIXNsTvPqc2tA=", "LSFRCfwUYKOMYDph7zbOMZvEESaaNHOtPFMtod2m2HI=", "pMlnrkaKBKapZhgJ8Je8wys6g+NjlSKy9cTHnGibU68=", "w37D4YEDL6kb06tYHg+MeSQpxBJI262cvE4MHrZBesQ=", "+HjgnV+iYbi45d/Y46UzAnClGPWeOUcvfD16n7f5duQ=", "4lHbp/s0wu3uMpYmUP1DaI4YuEabcY6ZCrug4U6vWSQ=", "oAG1JFnuk+DeIPoZLAcUfEl0HwF95M/Xk3z7w9LjSF8=", "1OEC0KMekWr1AbpIDYXYCOXzf6aK8hYAK01U3ypm0is=", "ygTG08vB2EEmJQPXRvF5H06Tlac72AkODENK7PhCPkw=", "xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="], "checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2392113382\nJC2EPEP7dIQxh9cUVHxwp814gJwz7t0amDJwoEI1cl0=\n\n— rekor.sigstore.dev wNI9ajBEAiB4CHunyi2MMm8mhViyKFv+gJePCVGpWz497p90lqEgAQIgAjc4sSFao4Amtr77YQG23YB6w+Nq2+8B6fKjItlKPWM=\n"}}, "canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI5YTFiMWZmMjhkMWVmMTIzYTk1NWMyZjRjYzJjZDIzNWQyMGU0NTk1NmY2M2ZkNDU2MjcyNmU5ZGYyMmQ5NzdjIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJUUM3T1Vac0gwL3dMcmFCdWtPWE4vVEkyaS94Q3dlWTlFUkFqTmVJdXRKY2t3SWdXMWNUaVhyZzNZUGpBTFB0MVNkR0ZQdlZVc1ZzSW94WWJEbVpKa2hoUEpBPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCUVZVSk1TVU1nUzBWWkxTMHRMUzBLVFVacmQwVjNXVWhMYjFwSmVtb3dRMEZSV1VsTGIxcEplbW93UkVGUlkwUlJaMEZGU2k5VWRrMDBaV1pvWVc4eGFtaEtMMVlyTmtZMVpTOHZiMVJaVWdwRVdFY3JMM0JUU3pCU01rczBlRVJ0YlROSWMzcE1Tak5KZVVacGQwOTZhVEV4ZERKcFVXTkxNbnBIVWpkVE56WlhTa1k1WkhFMGJtcG5QVDBLTFMwdExTMUZUa1FnVUZWQ1RFbERJRXRGV1MwdExTMHRDZz09In19fX0="}], "timestampVerificationData":{"rfc3161Timestamps":[{"signedTimestamp":"MIICyjADAgEAMIICwQYJKoZIhvcNAQcCoIICsjCCAq4CAQMxDTALBglghkgBZQMEAgEwgbgGCyqGSIb3DQEJEAEEoIGoBIGlMIGiAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgOB0sETVacp25sw2Fxt1Lz4Zhj8MEXkCOksjxWGdy9OACFQDupVi9XnLhXFgNGP7osUnLD7cl2xgPMjAyNjA4MTkwNzA2MjNaMAMCAQGgMqQwMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhoAAxggHbMIIB1wIBATBRMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFDoTVC8MkGHuvMFDL8uKjosqI4sMMAsGCWCGSAFlAwQCAaCB/DAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkFMQ8XDTI2MDgxOTA3MDYyM1owLwYJKoZIhvcNAQkEMSIEIBmuyKWT8DPe96+OJXmrXDQ9MNiV/7xUZDPtRzYkOtjTMIGOBgsqhkiG9w0BCRACLzF/MH0wezB5BCCF+Se8B6tiysO0Q1bBDvyBssaIP9p6uebYcNnROs0FtzBVMD2kOzA5MRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxIDAeBgNVBAMTF3NpZ3N0b3JlLXRzYS1zZWxmc2lnbmVkAhQ6E1QvDJBh7rzBQy/Lio6LKiOLDDAKBggqhkjOPQQDAgRnMGUCMF76f6igglPiDrND+gYIAUiGWqMIjhM3SlnoBN/vS7/qKE5JtB3lMJI++FaipS1AkQIxAMMJZXBbIWmz4CpRnoCsnha7pbSsMTLasTsMLBfj9oCXo0MMPMBShX68npMDJzg3XQ=="}]}}, "messageSignature":{"messageDigest":{"algorithm":"SHA2_256", "digest":"mhsf8o0e8SOpVcL0zCzSNdIORZVvY/1FYnJunfItl3w="}, "signature":"MEUCIQC7OUZsH0/wLraBukOXN/TI2i/xCweY9ERAjNeIutJckwIgW1cTiXrg3YPjALPt1SdGFPvVUsVsIoxYbDmZJkhhPJA="}} \ No newline at end of file diff --git a/environment/microvm/testdata/sigstore/public.pem b/environment/microvm/testdata/sigstore/public.pem new file mode 100644 index 000000000..6ff48c09a --- /dev/null +++ b/environment/microvm/testdata/sigstore/public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJ/TvM4efhao1jhJ/V+6F5e//oTYR +DXG+/pSK0R2K4xDmm3HszLJ3IyFiwOzi11t2iQcK2zGR7S76WJF9dq4njg== +-----END PUBLIC KEY----- diff --git a/environment/microvm/testdata/sigstore/statement.json b/environment/microvm/testdata/sigstore/statement.json new file mode 100644 index 000000000..0b3408b7c --- /dev/null +++ b/environment/microvm/testdata/sigstore/statement.json @@ -0,0 +1 @@ +{"_type":"https://in-toto.io/Statement/v1","subject":[{"digest":{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}],"predicateType":"https://slsa.dev/provenance/v1"} diff --git a/environment/microvm/tools.go b/environment/microvm/tools.go new file mode 100644 index 000000000..4c8e3ad24 --- /dev/null +++ b/environment/microvm/tools.go @@ -0,0 +1,5 @@ +//go:build tools + +package microvm + +import _ "github.com/stacklok/go-microvm" diff --git a/environment/microvm/virtiofs/virtiofs.go b/environment/microvm/virtiofs/virtiofs.go new file mode 100644 index 000000000..10424e86c --- /dev/null +++ b/environment/microvm/virtiofs/virtiofs.go @@ -0,0 +1,274 @@ +// Package virtiofs builds the explicit host/guest mount plan for a microVM. +package virtiofs + +import ( + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "sort" + "strings" + + gomicrovm "github.com/stacklok/go-microvm" + + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +const ( + // GuestSkillAssets is the only guest namespace used for materialized skill payloads. + GuestSkillAssets = "/run/mecatl/skill-assets" + + workspaceTag = "mecatl-workspace" + metadataTag = "mecatl-git-metadata" + objectsTag = "mecatl-git-objects" + assetsTag = "mecatl-skill-assets" +) + +var ( + // ErrUnsafeMount rejects an absent, symlinked, overlapping, or otherwise + // implicit host mount input. + ErrUnsafeMount = errors.New("unsafe virtio-fs mount input") +) + +// Asset names one payload file to copy into a session-owned materialization. +// Name is its logical slash-separated guest name; SourcePath is never mounted. +type Asset struct { + Name string + SourcePath string +} + +// GoMicroVMRuntime is the legacy test seam for the mount plan. Production uses +// GoMicroVMMounts and go-microvm v0.0.40's host-enforced ReadOnly field directly. +type GoMicroVMRuntime interface { + SetGuestVirtioFSMount(tag, guestPath string) error + AddVirtioFS(tag, hostPath string) error + AddVirtioFS3(tag, hostPath string, daxWindowSize uint64, readOnly bool) error +} + +type access uint8 + +const ( + readWrite access = iota + hostReadOnly +) + +type mount struct { + tag, hostPath, guestPath string + access access +} + +// MountPlan is an ordered, validated set of virtio-fs devices. Reconstructed +// metadata is mounted separately and guest exec pins GIT_DIR to that path, so +// the linked worktree's host-path .git file is never consumed. +type MountPlan struct { + mounts []mount +} + +// MaterializedAssets is a session-owned copy of explicitly named skill files. +// Its host path is intentionally private so callers cannot turn an arbitrary +// directory (for example, a user home) into the skill-assets capability. +type MaterializedAssets struct { + root string +} + +// Plan builds the fixed worktree/Git mount set and, when non-nil, one +// session-owned materialized skill-assets mount. +func Plan(prepared *worktree.Prepared, assets *MaterializedAssets) (MountPlan, error) { + if prepared == nil { + return MountPlan{}, fmt.Errorf("%w: nil prepared worktree", ErrUnsafeMount) + } + inputs := []mount{ + {tag: workspaceTag, hostPath: prepared.WorktreePath, guestPath: worktree.GuestWorkspace, access: readWrite}, + {tag: metadataTag, hostPath: prepared.MetadataPath, guestPath: worktree.GuestMetadata, access: readWrite}, + {tag: objectsTag, hostPath: prepared.CommonObjectStore, guestPath: worktree.GuestObjectStore, access: hostReadOnly}, + } + if assets != nil { + inputs = append(inputs, mount{tag: assetsTag, hostPath: assets.root, guestPath: GuestSkillAssets, access: hostReadOnly}) + } + + seenHost := make(map[string]string, len(inputs)) + seenGuest := make(map[string]struct{}, len(inputs)) + for i := range inputs { + canonical, err := canonicalRealDir(inputs[i].hostPath) + if err != nil { + return MountPlan{}, fmt.Errorf("%w: %s: %v", ErrUnsafeMount, inputs[i].tag, err) + } + inputs[i].hostPath = canonical + if other, exists := seenHost[canonical]; exists { + return MountPlan{}, fmt.Errorf("%w: %s and %s share a host path", ErrUnsafeMount, other, inputs[i].tag) + } + seenHost[canonical] = inputs[i].tag + if _, exists := seenGuest[inputs[i].guestPath]; exists { + return MountPlan{}, fmt.Errorf("%w: duplicate guest path %s", ErrUnsafeMount, inputs[i].guestPath) + } + seenGuest[inputs[i].guestPath] = struct{}{} + } + return MountPlan{mounts: inputs}, nil +} + +// PrepareWorkloadAccess preserves host ownership and modes. The Linux libkrun +// backend maps guest workload UID/GID 65532 to the daemon user through its +// unprivileged user namespace, so making host trees world-accessible is neither +// necessary nor permitted. +func (MountPlan) PrepareWorkloadAccess() error { return nil } + +// GoMicroVMMounts projects the validated plan onto go-microvm v0.0.40's +// host-enforced ReadOnly mount contract. +func (p MountPlan) GoMicroVMMounts() []gomicrovm.VirtioFSMount { + mounts := make([]gomicrovm.VirtioFSMount, 0, len(p.mounts)) + for _, mount := range p.mounts { + mounts = append(mounts, gomicrovm.VirtioFSMount{ + Tag: mount.tag, HostPath: mount.hostPath, ReadOnly: mount.access == hostReadOnly, + }) + } + return mounts +} + +// Configure registers every guest target and creates its libkrun device. A +// read-only device always uses krun_add_virtiofs3 with read_only=true; it never +// falls back to go-microvm's guest-only ReadOnly flag. +func (p MountPlan) Configure(runtime GoMicroVMRuntime) error { + if runtime == nil { + return errors.New("virtio-fs runtime is nil") + } + for _, m := range p.mounts { + if err := runtime.SetGuestVirtioFSMount(m.tag, m.guestPath); err != nil { + return fmt.Errorf("register guest virtio-fs mount %s: %w", m.tag, err) + } + var err error + if m.access == hostReadOnly { + err = runtime.AddVirtioFS3(m.tag, m.hostPath, 0, true) + } else { + err = runtime.AddVirtioFS(m.tag, m.hostPath) + } + if err != nil { + return fmt.Errorf("add virtio-fs device %s: %w", m.tag, err) + } + } + return nil +} + +// MaterializeAssets copies only the explicitly named regular files into a new +// session-owned directory. The source tree (and therefore a user's home or +// config directory) is never itself a mount input. +func MaterializeAssets(destination string, assets []Asset) (_ *MaterializedAssets, retErr error) { + if len(assets) == 0 { + return nil, errors.New("materialize skill assets: no assets") + } + root, err := newDirectoryPath(destination) + if err != nil { + return nil, fmt.Errorf("materialize skill assets: %w", err) + } + if err := os.Mkdir(root, 0o700); err != nil { + return nil, fmt.Errorf("materialize skill assets: %w", err) + } + defer func() { + if retErr != nil { + _ = os.RemoveAll(root) + } + }() + + ordered := append([]Asset(nil), assets...) + sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name }) + seen := make(map[string]struct{}, len(ordered)) + for _, asset := range ordered { + if err := validAssetName(asset.Name); err != nil { + return nil, fmt.Errorf("materialize skill asset: %w", err) + } + if _, exists := seen[asset.Name]; exists { + return nil, fmt.Errorf("materialize skill asset: duplicate name %q", asset.Name) + } + seen[asset.Name] = struct{}{} + if err := copyRegularFile(asset.SourcePath, filepath.Join(root, filepath.FromSlash(asset.Name))); err != nil { + return nil, fmt.Errorf("materialize skill asset %q: %w", asset.Name, err) + } + } + return &MaterializedAssets{root: root}, nil +} + +func copyRegularFile(source, destination string) (retErr error) { + info, err := os.Lstat(source) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return errors.New("source must be a regular file, not a symlink or directory") + } + in, err := os.Open(source) // #nosec G304 -- explicit operator-admitted asset path; identity is checked below. + if err != nil { + return err + } + defer func() { retErr = errors.Join(retErr, in.Close()) }() + opened, err := in.Stat() + if err != nil { + return err + } + if !opened.Mode().IsRegular() || !os.SameFile(info, opened) { + return errors.New("source changed while materializing") + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return err + } + out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o400) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + return errors.Join(copyErr, closeErr) +} + +func validAssetName(name string) error { + if name == "" || strings.Contains(name, "\\") || path.IsAbs(name) || path.Clean(name) != name || name == "." || name == ".." || strings.HasPrefix(name, "../") { + return fmt.Errorf("unsafe logical name %q", name) + } + return nil +} + +func newDirectoryPath(name string) (string, error) { + if name == "" { + return "", errors.New("destination is required") + } + absolute, err := filepath.Abs(name) + if err != nil { + return "", err + } + if _, err := os.Lstat(absolute); !errors.Is(err, os.ErrNotExist) { + if err == nil { + return "", errors.New("destination already exists") + } + return "", err + } + parent, err := canonicalRealDir(filepath.Dir(absolute)) + if err != nil { + return "", fmt.Errorf("destination parent: %w", err) + } + return filepath.Join(parent, filepath.Base(absolute)), nil +} + +func canonicalRealDir(name string) (string, error) { + if name == "" { + return "", errors.New("path is required") + } + absolute, err := filepath.Abs(name) + if err != nil { + return "", err + } + info, err := os.Lstat(absolute) + if err != nil { + return "", err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", errors.New("path is not a real directory") + } + canonical, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", err + } + if filepath.Clean(absolute) != canonical { + return "", errors.New("path traverses a symlink") + } + return canonical, nil +} diff --git a/environment/microvm/virtiofs/virtiofs_test.go b/environment/microvm/virtiofs/virtiofs_test.go new file mode 100644 index 000000000..98448a287 --- /dev/null +++ b/environment/microvm/virtiofs/virtiofs_test.go @@ -0,0 +1,220 @@ +package virtiofs + +import ( + "errors" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stacklok/mecatl/environment/microvm/worktree" +) + +var errFakeReadOnlyMount = errors.New("fake guest: host-read-only mount") + +func TestMicroVMEnvironments_Scenario3_WorktreeIsBidirectionallyVisible(t *testing.T) { + worktreeRoot := t.TempDir() + metadataRoot := t.TempDir() + objectsRoot := t.TempDir() + for _, root := range []string{worktreeRoot, metadataRoot} { + if err := os.Chmod(root, 0o700); err != nil { + t.Fatal(err) + } + } + prepared := &worktree.Prepared{ + WorktreePath: worktreeRoot, + MetadataPath: metadataRoot, + CommonObjectStore: objectsRoot, + } + + plan, err := Plan(prepared, nil) + if err != nil { + t.Fatalf("Plan: %v", err) + } + guest := newFakeGuest() + if err := plan.Configure(guest); err != nil { + t.Fatalf("Configure: %v", err) + } + if !slices.ContainsFunc(guest.calls, func(c apiCall) bool { + return c.method == "krun_add_virtiofs" && c.guestPath == worktree.GuestWorkspace && !c.readOnly + }) { + t.Fatalf("calls = %#v, want read-write /workspace device", guest.calls) + } + if !slices.ContainsFunc(guest.calls, func(c apiCall) bool { + return c.method == "krun_add_virtiofs3" && c.guestPath == worktree.GuestObjectStore && c.readOnly + }) { + t.Fatalf("calls = %#v, want host-read-only Git object device", guest.calls) + } + + if err := plan.PrepareWorkloadAccess(); err != nil { + t.Fatalf("PrepareWorkloadAccess: %v", err) + } + for _, root := range []string{worktreeRoot, metadataRoot} { + info, err := os.Stat(root) + if err != nil || info.Mode().Perm()&0o007 != 0 { + t.Fatalf("read-write mount root %s was widened: mode = %v, %v", root, info.Mode().Perm(), err) + } + } + mounts := plan.GoMicroVMMounts() + for _, mount := range mounts { + if mount.OverrideUID != 0 || mount.OverrideGID != 0 { + t.Fatalf("mount %s uses host-specific ownership overrides: %d:%d", mount.Tag, mount.OverrideUID, mount.OverrideGID) + } + } + + if err := guest.WriteFile("/workspace/from-guest.txt", []byte("guest\n")); err != nil { + t.Fatalf("guest write: %v", err) + } + got, err := os.ReadFile(filepath.Join(worktreeRoot, "from-guest.txt")) + if err != nil || string(got) != "guest\n" { + t.Fatalf("host sees guest write = %q, %v", got, err) + } + + if err := os.WriteFile(filepath.Join(worktreeRoot, "from-host.txt"), []byte("host\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err = guest.ReadFile("/workspace/from-host.txt") + if err != nil || string(got) != "host\n" { + t.Fatalf("guest sees host write = %q, %v", got, err) + } +} + +func TestMicroVMEnvironments_Scenario3_SkillAssetsAreExplicitAndReadOnly(t *testing.T) { + home := t.TempDir() + secret := filepath.Join(home, ".config", "credentials") + writeFile(t, secret, []byte("do-not-mount\n")) + assetSource := filepath.Join(home, ".claude", "skills", "review", "references", "guide.txt") + writeFile(t, assetSource, []byte("review guide\n")) + symlinkAsset := filepath.Join(home, "asset-link") + if err := os.Symlink(secret, symlinkAsset); err != nil { + t.Fatal(err) + } + if _, err := MaterializeAssets(filepath.Join(t.TempDir(), "linked"), []Asset{{Name: "secret", SourcePath: symlinkAsset}}); err == nil { + t.Fatal("MaterializeAssets accepted a symlink source") + } + + materialized, err := MaterializeAssets(filepath.Join(t.TempDir(), "materialized"), []Asset{ + {Name: "review/references/guide.txt", SourcePath: assetSource}, + }) + if err != nil { + t.Fatalf("MaterializeAssets: %v", err) + } + prepared := &worktree.Prepared{ + WorktreePath: t.TempDir(), + MetadataPath: t.TempDir(), + CommonObjectStore: t.TempDir(), + } + plan, err := Plan(prepared, materialized) + if err != nil { + t.Fatalf("Plan: %v", err) + } + guest := newFakeGuest() + if err := plan.Configure(guest); err != nil { + t.Fatalf("Configure: %v", err) + } + + got, err := guest.ReadFile("/run/mecatl/skill-assets/review/references/guide.txt") + if err != nil || string(got) != "review guide\n" { + t.Fatalf("guest asset = %q, %v", got, err) + } + if err := guest.WriteFile("/run/mecatl/skill-assets/review/references/guide.txt", []byte("changed")); !errors.Is(err, errFakeReadOnlyMount) { + t.Fatalf("asset write error = %v, want a read-only-mount error", err) + } + if _, err := guest.ReadFile("/run/mecatl/skill-assets/.config/credentials"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("arbitrary home config is discoverable: %v", err) + } + if slices.ContainsFunc(guest.mounts, func(m configuredMount) bool { + return m.hostPath == home || m.hostPath == filepath.Dir(home) + }) { + t.Fatalf("mounts expose arbitrary home: %#v", guest.mounts) + } + if !slices.ContainsFunc(guest.calls, func(c apiCall) bool { + return c.method == "krun_add_virtiofs3" && c.guestPath == GuestSkillAssets && c.readOnly + }) { + t.Fatalf("calls = %#v, want libkrun host-enforced read-only API", guest.calls) + } +} + +func writeFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +type apiCall struct { + method, tag, hostPath, guestPath string + readOnly bool +} + +type configuredMount struct { + hostPath, guestPath string + readOnly bool +} + +type fakeGuest struct { + calls []apiCall + mounts []configuredMount + targets map[string]string +} + +func newFakeGuest() *fakeGuest { return &fakeGuest{targets: make(map[string]string)} } + +func (g *fakeGuest) SetGuestVirtioFSMount(tag, guestPath string) error { + g.targets[tag] = guestPath + return nil +} + +func (g *fakeGuest) AddVirtioFS(tag, hostPath string) error { + guestPath := g.targets[tag] + g.calls = append(g.calls, apiCall{method: "krun_add_virtiofs", tag: tag, hostPath: hostPath, guestPath: guestPath}) + g.mounts = append(g.mounts, configuredMount{hostPath: hostPath, guestPath: guestPath}) + return nil +} + +func (g *fakeGuest) AddVirtioFS3(tag, hostPath string, _ uint64, readOnly bool) error { + guestPath := g.targets[tag] + g.calls = append(g.calls, apiCall{method: "krun_add_virtiofs3", tag: tag, hostPath: hostPath, guestPath: guestPath, readOnly: readOnly}) + g.mounts = append(g.mounts, configuredMount{hostPath: hostPath, guestPath: guestPath, readOnly: readOnly}) + return nil +} + +func (g *fakeGuest) ReadFile(path string) ([]byte, error) { + mount, hostPath, ok := g.resolve(path) + if !ok { + return nil, os.ErrNotExist + } + _ = mount + return os.ReadFile(hostPath) +} + +func (g *fakeGuest) WriteFile(path string, data []byte) error { + mount, hostPath, ok := g.resolve(path) + if !ok { + return os.ErrNotExist + } + if mount.readOnly { + return errFakeReadOnlyMount + } + if err := os.MkdirAll(filepath.Dir(hostPath), 0o700); err != nil { + return err + } + return os.WriteFile(hostPath, data, 0o600) +} + +func (g *fakeGuest) resolve(path string) (configuredMount, string, bool) { + for _, mount := range slices.Backward(g.mounts) { + rel, err := filepath.Rel(mount.guestPath, path) + if err == nil && rel != ".." && !filepath.IsAbs(rel) && rel != "." && !startsWithDotDot(rel) { + return mount, filepath.Join(mount.hostPath, rel), true + } + } + return configuredMount{}, "", false +} + +func startsWithDotDot(path string) bool { + return len(path) > 3 && path[:3] == ".."+string(filepath.Separator) +} diff --git a/environment/microvm/workspace/guest.go b/environment/microvm/workspace/guest.go new file mode 100644 index 000000000..fa58a4b4b --- /dev/null +++ b/environment/microvm/workspace/guest.go @@ -0,0 +1,320 @@ +package workspace + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + + "github.com/bmatcuk/doublestar/v4" + + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/control" +) + +var ( + errPathEscape = errors.New("microvm workspace path escapes guest root") + errResultBound = errors.New("microvm workspace result exceeds bound") +) + +// Guest serves the confined filesystem side of the authenticated guest protocol. +type Guest struct { + root *osRoot + mu sync.Mutex +} + +// osRoot is the subset of os.Root used by the guest service. +type osRoot struct { + rootFS *os.Root +} + +var ( + // ErrAssignedRootUnavailable means the authenticated guest root could not be opened. + // It deliberately carries no backend detail because callers project it across trust boundaries. + ErrAssignedRootUnavailable = errors.New("microvm assigned root is unavailable") +) + +// NewGuest opens root as the guest's /workspace mount. +func NewGuest(root string, binding control.Binding) (*Guest, error) { + if root == "" || binding.Owner == "" || binding.SessionID == "" || binding.EnvironmentID == "" || binding.Ref == "" || binding.Generation == 0 { + return nil, fmt.Errorf("microvm workspace root or binding is invalid") + } + r, err := os.OpenRoot(root) + if err != nil { + return nil, ErrAssignedRootUnavailable + } + return &Guest{root: &osRoot{rootFS: r}}, nil +} + +// Handler returns the workspace service for the shared guest multiplexer. +func (g *Guest) Handler() control.Handler { + return func(ctx context.Context, method string, payload json.RawMessage, _ func(any) error) (any, string, error) { + if err := ctx.Err(); err != nil { + return nil, "cancelled", err + } + if method != "call" { + return nil, "unsupported_method", errors.New("unsupported microvm workspace method") + } + var req request + if err := json.Unmarshal(payload, &req); err != nil { + return nil, "malformed", control.ErrMalformedFrame + } + resp := g.handle(req) + return resp, "", nil + } +} + +// Close releases the guest's confined workspace root. +func (g *Guest) Close() error { return g.root.rootFS.Close() } + +func (g *Guest) handle(req request) response { + var resp response + var err error + switch req.Operation { + case opRead: + resp.Data, resp.Version, err = g.readVersion(req.Path) + resp.VersionValid = err == nil + case opStat: + var info wireFileInfo + info, err = g.stat(req.Path) + resp.Info = &info + case opCreate: + resp.Version, err = g.create(req.Path, req.Data) + resp.VersionValid = err == nil + case opReplace: + resp.Version, err = g.replace(req.Path, req.Version, req.VersionValid, req.Data) + resp.VersionValid = err == nil + case opGlob: + resp.Paths, err = g.glob(req.Pattern) + case opGrep: + resp.Matches, err = g.grep(req.Pattern, req.PathGlob) + default: + err = errors.New("unsupported microvm workspace operation") + } + if err != nil { + return errorResponse(err) + } + return resp +} + +func confined(path string) (string, error) { + if path == "" || !filepath.IsLocal(filepath.FromSlash(path)) { + return "", errPathEscape + } + return filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))), nil +} + +func (g *Guest) read(path string) ([]byte, error) { + rel, err := confined(path) + if err != nil { + return nil, err + } + file, err := g.root.rootFS.Open(rel) + if err != nil { + return nil, err + } + data, readErr := io.ReadAll(io.LimitReader(file, maxContentSize+1)) + closeErr := file.Close() + if readErr != nil { + return nil, readErr + } + if closeErr != nil { + return nil, closeErr + } + if len(data) > maxContentSize { + return nil, errResultBound + } + return data, nil +} + +func (g *Guest) readVersion(path string) ([]byte, string, error) { + data, err := g.read(path) + if err != nil { + return nil, "", err + } + return data, version(data), nil +} + +func version(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func (g *Guest) stat(path string) (wireFileInfo, error) { + rel, err := confined(path) + if err != nil { + return wireFileInfo{}, err + } + info, err := g.root.rootFS.Stat(rel) + if err != nil { + return wireFileInfo{}, err + } + return wireFileInfo{Name: info.Name(), Size: info.Size(), Mode: info.Mode(), ModTime: info.ModTime(), IsDir: info.IsDir()}, nil +} + +func (g *Guest) create(path string, data []byte) (string, error) { + if len(data) > maxContentSize { + return "", errResultBound + } + rel, err := confined(path) + if err != nil { + return "", err + } + g.mu.Lock() + defer g.mu.Unlock() + if dir := filepath.Dir(rel); dir != "." { + if err := g.root.rootFS.MkdirAll(dir, 0o755); err != nil { + return "", err + } + } + file, err := g.root.rootFS.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return "", err + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + _ = g.root.rootFS.Remove(rel) + return "", err + } + if err := file.Close(); err != nil { + _ = g.root.rootFS.Remove(rel) + return "", err + } + return version(data), nil +} + +func (g *Guest) replace(path, old string, oldValid bool, data []byte) (string, error) { + if len(data) > maxContentSize { + return "", errResultBound + } + rel, err := confined(path) + if err != nil { + return "", err + } + g.mu.Lock() + defer g.mu.Unlock() + current, err := g.read(rel) + if err != nil { + return "", err + } + if !oldValid || version(current) != old { + return "", &tool.VersionMismatchError{Path: path} + } + + temp, file, err := g.createReplaceTemp(rel) + if err != nil { + return "", err + } + cleanup := func() { _ = g.root.rootFS.Remove(temp) } + if _, err := file.Write(data); err != nil { + _ = file.Close() + cleanup() + return "", err + } + if err := file.Close(); err != nil { + cleanup() + return "", err + } + latest, err := g.read(rel) + if err != nil { + cleanup() + return "", err + } + if version(latest) != old { + cleanup() + return "", &tool.VersionMismatchError{Path: path} + } + if err := g.root.rootFS.Rename(temp, rel); err != nil { + cleanup() + return "", err + } + return version(data), nil +} + +func (g *Guest) createReplaceTemp(path string) (string, *os.File, error) { + for range 8 { + var suffix [8]byte + if _, err := rand.Read(suffix[:]); err != nil { + return "", nil, err + } + temp := path + ".mecatl-replace-" + hex.EncodeToString(suffix[:]) + file, err := g.root.rootFS.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err == nil { + return temp, file, nil + } + if !errors.Is(err, fs.ErrExist) { + return "", nil, err + } + } + return "", nil, errors.New("microvm workspace could not allocate replacement file") +} + +func (g *Guest) glob(pattern string) ([]string, error) { + pattern, err := confined(pattern) + if err != nil { + return nil, err + } + paths, err := doublestar.Glob(g.root.rootFS.FS(), pattern, doublestar.WithFilesOnly()) + if err != nil { + return nil, err + } + sort.Strings(paths) + if len(paths) > maxResults { + return nil, errResultBound + } + return paths, nil +} + +func (g *Guest) grep(pattern, pathGlob string) ([]tool.GrepMatch, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + if pathGlob != "" { + pathGlob, err = confined(pathGlob) + if err != nil { + return nil, err + } + } + var matches []tool.GrepMatch + err = fs.WalkDir(g.root.rootFS.FS(), ".", func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || entry.Type()&fs.ModeSymlink != 0 { + return nil + } + if pathGlob != "" { + matched, matchErr := doublestar.Match(pathGlob, path) + if matchErr != nil || !matched { + return matchErr + } + } + data, readErr := g.read(path) + if readErr != nil || bytes.IndexByte(data, 0) >= 0 { + return nil + } + for i, line := range strings.Split(string(data), "\n") { + if re.MatchString(line) { + matches = append(matches, tool.GrepMatch{Path: path, Line: i + 1, Text: line}) + if len(matches) > maxResults { + return errResultBound + } + } + } + return nil + }) + return matches, err +} diff --git a/environment/microvm/workspace/protocol.go b/environment/microvm/workspace/protocol.go new file mode 100644 index 000000000..587ee0e98 --- /dev/null +++ b/environment/microvm/workspace/protocol.go @@ -0,0 +1,72 @@ +package workspace + +import ( + "errors" + "io/fs" + "time" + + "github.com/stacklok/mecatl/engine/tool" +) + +const ( + guestRoot = "/workspace" + maxContentSize = 512 << 10 + maxResults = 200 +) + +type operation string + +const ( + opRead operation = "read" + opStat operation = "stat" + opCreate operation = "create" + opReplace operation = "replace" + opGlob operation = "glob" + opGrep operation = "grep" +) + +type request struct { + Operation operation `json:"operation"` + Path string `json:"path,omitempty"` + Pattern string `json:"pattern,omitempty"` + PathGlob string `json:"path_glob,omitempty"` + Data []byte `json:"data,omitempty"` + Version string `json:"version,omitempty"` + VersionValid bool `json:"version_valid,omitempty"` +} + +type response struct { + Data []byte `json:"data,omitempty"` + Version string `json:"version,omitempty"` + VersionValid bool `json:"version_valid,omitempty"` + Info *wireFileInfo `json:"info,omitempty"` + Paths []string `json:"paths,omitempty"` + Matches []tool.GrepMatch `json:"matches,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +type wireFileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + Mode fs.FileMode `json:"mode"` + ModTime time.Time `json:"mod_time"` + IsDir bool `json:"is_dir"` +} + +func errorResponse(err error) response { + code := "internal" + var mismatch *tool.VersionMismatchError + switch { + case errors.As(err, &mismatch): + code = "version_mismatch" + case errors.Is(err, fs.ErrExist): + code = "exists" + case errors.Is(err, fs.ErrNotExist): + code = "not_found" + case errors.Is(err, errPathEscape): + code = "path_escape" + case errors.Is(err, errResultBound): + code = "result_bound" + } + return response{ErrorCode: code} +} diff --git a/environment/microvm/workspace/workspace.go b/environment/microvm/workspace/workspace.go new file mode 100644 index 000000000..0eaf5858b --- /dev/null +++ b/environment/microvm/workspace/workspace.go @@ -0,0 +1,169 @@ +// Package workspace implements the authenticated host/guest Workspace RPC adapter. +package workspace + +import ( + "context" + "errors" + "fmt" + "io/fs" + "sync" + + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/control" +) + +// Workspace is the host-side mecatl adapter for one authenticated guest mount. +type Workspace struct { + client *control.Client + root string + ledgerMu sync.Mutex + ledger map[string]tool.FileVersion +} + +var _ tool.Workspace = (*Workspace)(nil) + +// New constructs a Workspace over an already-authenticated multiplexed guest connection. +func New(client *control.Client, binding control.Binding) (*Workspace, error) { + return NewAt(client, binding, guestRoot) +} + +// NewAt constructs a Workspace affined to one authenticated logical guest root. +func NewAt(client *control.Client, binding control.Binding, root string) (*Workspace, error) { + if client == nil || !client.IsBoundTo(binding) || root == "" { + return nil, errors.New("invalid multiplexed microvm workspace configuration") + } + return &Workspace{client: client, root: root, ledger: make(map[string]tool.FileVersion)}, nil +} + +// Root returns the authenticated guest execution root shared with the bound runner. +func (w *Workspace) Root() string { return w.root } + +// Read reads bounded bytes from the guest workspace. +func (w *Workspace) Read(ctx context.Context, path string) ([]byte, error) { + resp, err := w.call(ctx, request{Operation: opRead, Path: path}) + if err != nil { + return nil, err + } + return resp.Data, nil +} + +// ReadVersion returns guest bytes and their opaque authoritative version. +func (w *Workspace) ReadVersion(ctx context.Context, path string) ([]byte, tool.FileVersion, error) { + resp, err := w.call(ctx, request{Operation: opRead, Path: path}) + if err != nil { + return nil, tool.FileVersion{}, err + } + if !resp.VersionValid { + return nil, tool.FileVersion{}, errors.New("microvm workspace omitted file version") + } + return resp.Data, tool.NewFileVersion(resp.Version), nil +} + +// Stat returns guest file metadata without exposing OS-specific values. +func (w *Workspace) Stat(ctx context.Context, path string) (tool.FileInfo, error) { + resp, err := w.call(ctx, request{Operation: opStat, Path: path}) + if err != nil { + return tool.FileInfo{}, err + } + if resp.Info == nil { + return tool.FileInfo{}, errors.New("microvm workspace omitted file metadata") + } + return tool.FileInfo{ + Name: resp.Info.Name, Size: resp.Info.Size, Mode: resp.Info.Mode, + ModTime: resp.Info.ModTime, IsDir: resp.Info.IsDir, + }, nil +} + +// CreateFile performs an atomic create-only guest mutation. +func (w *Workspace) CreateFile(ctx context.Context, path string, data []byte) (tool.FileVersion, error) { + resp, err := w.call(ctx, request{Operation: opCreate, Path: path, Data: data}) + if err != nil { + return tool.FileVersion{}, err + } + if !resp.VersionValid { + return tool.FileVersion{}, errors.New("microvm workspace omitted created file version") + } + return tool.NewFileVersion(resp.Version), nil +} + +// ReplaceFile conditionally replaces a guest file using its opaque version. +func (w *Workspace) ReplaceFile(ctx context.Context, path string, old tool.FileVersion, data []byte) (tool.FileVersion, error) { + token, err := tool.EncodeFileVersion(old) + resp, err := w.call(ctx, request{ + Operation: opReplace, Path: path, Data: data, + Version: token, VersionValid: err == nil, + }) + if err != nil { + return tool.FileVersion{}, err + } + if !resp.VersionValid { + return tool.FileVersion{}, errors.New("microvm workspace omitted replacement file version") + } + return tool.NewFileVersion(resp.Version), nil +} + +// Glob returns bounded, deterministic guest-relative matches. +func (w *Workspace) Glob(ctx context.Context, pattern string) ([]string, error) { + resp, err := w.call(ctx, request{Operation: opGlob, Pattern: pattern}) + return resp.Paths, err +} + +// Grep returns bounded guest-relative regular-expression matches. +func (w *Workspace) Grep(ctx context.Context, pattern, pathGlob string) ([]tool.GrepMatch, error) { + resp, err := w.call(ctx, request{Operation: opGrep, Pattern: pattern, PathGlob: pathGlob}) + return resp.Matches, err +} + +// RecordRead records the exact supplied version without guest I/O. +func (w *Workspace) RecordRead(path string, version tool.FileVersion) { + key := tool.LedgerKey(w.root, path) + w.ledgerMu.Lock() + w.ledger[key] = version + w.ledgerMu.Unlock() +} + +// RecordedVersion performs an I/O-free lookup in the host-side live ledger. +func (w *Workspace) RecordedVersion(path string) (tool.FileVersion, bool) { + key := tool.LedgerKey(w.root, path) + w.ledgerMu.Lock() + version, ok := w.ledger[key] + w.ledgerMu.Unlock() + return version, ok +} + +func (w *Workspace) call(ctx context.Context, req request) (response, error) { + if err := ctx.Err(); err != nil { + return response{}, err + } + var resp response + if err := w.client.Call(ctx, control.ServiceWorkspace, "call", req, &resp); err != nil { + var remote *control.RemoteError + if errors.As(err, &remote) { + return response{}, remoteError(remote.Code, req.Path) + } + return response{}, fmt.Errorf("call microvm workspace service: %w", err) + } + if resp.ErrorCode != "" { + return response{}, remoteError(resp.ErrorCode, req.Path) + } + return resp, nil +} + +func remoteError(code, path string) error { + switch code { + case "version_mismatch": + return &tool.VersionMismatchError{Path: path} + case "exists": + return &fs.PathError{Op: "create", Path: path, Err: fs.ErrExist} + case "not_found": + return &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist} + case "path_escape": + return errPathEscape + case "result_bound": + return errResultBound + case "binding_mismatch": + return control.ErrBindingMismatch + default: + return errors.New("microvm workspace operation failed") + } +} diff --git a/environment/microvm/workspace/workspace_test.go b/environment/microvm/workspace/workspace_test.go new file mode 100644 index 000000000..d219a4bf5 --- /dev/null +++ b/environment/microvm/workspace/workspace_test.go @@ -0,0 +1,112 @@ +package workspace_test + +import ( + "bytes" + "context" + "io" + "net" + "os" + "path/filepath" + "testing" + + "github.com/stacklok/mecatl/engine/adapter/fsconformance" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/environment/microvm/control" + "github.com/stacklok/mecatl/environment/microvm/workspace" +) + +func TestInvariant_environment_workspace_runner_affinity(t *testing.T) { + ws, _ := newRemoteFixture(t) + if ws.Root() != "/workspace" { + t.Fatalf("Workspace root = %q, want bound runner cwd /workspace", ws.Root()) + } + fsconformance.RunMutationAffinity(t, newRemoteFixture) +} + +func TestMicroVMEnvironments_Scenario3_RemoteWorkspaceConformance(t *testing.T) { + fsconformance.Run(t, func(t *testing.T) tool.Workspace { + ws, _ := newRemoteFixture(t) + return ws + }) + t.Run("external mutation conflicts", func(t *testing.T) { + fsconformance.RunExternalMutationConflict(t, newRemoteFixture) + }) + t.Run("bounded read", func(t *testing.T) { + ws, external := newRemoteFixture(t) + if err := external.Write("oversized.txt", bytes.Repeat([]byte("x"), (512<<10)+1)); err != nil { + t.Fatalf("seed oversized file: %v", err) + } + if _, err := ws.Read(context.Background(), "oversized.txt"); err == nil { + t.Fatal("Read oversized result succeeded, want bounded-result error") + } + }) +} + +func newRemoteFixture(t *testing.T) (tool.Workspace, fsconformance.ExternalAccess) { + t.Helper() + root := t.TempDir() + binding := control.Binding{ + Owner: "owner-1", + SessionID: "session-1", + EnvironmentID: "environment-1", + Ref: "microvm:environment-1:1", + Generation: 1, + } + host, guestConn := net.Pipe() + server, err := workspace.NewGuest(root, binding) + if err != nil { + t.Fatalf("NewGuest: %v", err) + } + key := []byte("0123456789abcdef0123456789abcdef") + verifier, err := control.NewCapabilityVerifier(key) + if err != nil { + t.Fatalf("NewCapabilityVerifier: %v", err) + } + issuer, err := control.NewCapabilityIssuer(key) + if err != nil { + t.Fatalf("NewCapabilityIssuer: %v", err) + } + capability, err := issuer.Issue(binding) + if err != nil { + t.Fatalf("Issue: %v", err) + } + serveErr := make(chan error, 1) + go func() { + serveErr <- control.ServeMultiplex(context.Background(), guestConn, binding, verifier, map[control.ServiceName]control.Handler{ + control.ServiceWorkspace: server.Handler(), + }, control.DefaultMaxMessageBytes) + }() + client, err := control.OpenClient(context.Background(), host, binding, capability, []control.ServiceName{control.ServiceWorkspace}, control.DefaultMaxMessageBytes) + if err != nil { + _ = host.Close() + _ = guestConn.Close() + t.Fatalf("OpenClient: %v", err) + } + ws, err := workspace.New(client, binding) + if err != nil { + _ = client.Close() + _ = guestConn.Close() + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { + _ = client.Close() + _ = guestConn.Close() + _ = server.Close() + if err := <-serveErr; err != nil && err != io.EOF { + t.Errorf("guest ServeMultiplex: %v", err) + } + }) + + return ws, fsconformance.ExternalAccess{ + Read: func(path string) ([]byte, error) { + return os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + }, + Write: func(path string, data []byte) error { + full := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return err + } + return os.WriteFile(full, data, 0o644) + }, + } +} diff --git a/environment/microvm/worktree/worktree.go b/environment/microvm/worktree/worktree.go new file mode 100644 index 000000000..a24907289 --- /dev/null +++ b/environment/microvm/worktree/worktree.go @@ -0,0 +1,1032 @@ +// Package worktree prepares session-owned Git worktrees and guest-local metadata. +package worktree + +import ( + "archive/tar" + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/stacklok/mecatl/environment/microvm/gitexec" +) + +// Fixed guest paths used by the ordered virtio-fs mount plan. +const ( + GuestWorkspace = "/workspace" + GuestMetadata = "/run/mecatl/git-metadata" + GuestObjectStore = "/run/mecatl/git-objects" +) + +// Request names the validated source and the new session-owned paths and branch. +type Request struct { + Source string + WorktreePath string + MetadataPath string + Branch string + // BaseRevision is an optional immutable tree object used for delegated forks. + // When set, preparation materializes exactly this tree instead of rereading the + // source checkout's potentially changing dirty state. + BaseRevision string +} + +// Mount describes one host path exposed at a fixed guest path. +type Mount struct { + HostPath string + GuestPath string + ReadOnly bool +} + +// Prepared is an exact source capture plus the mount plan needed by VM creation. +type Prepared struct { + SourceRoot string + WorktreePath string + MetadataPath string + Branch string + CommonObjectStore string + Mounts []Mount + beforeCleanupRemoval func() +} + +// Preparer creates exact, confined Git-backed session worktrees. +type Preparer struct { + afterCapture func() error + afterWorktree func(string) error +} + +// New returns a worktree preparer using hardened Git subprocesses. +func New() *Preparer { return &Preparer{} } + +// Prepare captures one source state, creates its branch/worktree, validates linked +// metadata, and reconstructs the guest-local Git directory and mount plan. +func (p *Preparer) Prepare(ctx context.Context, req Request) (_ *Prepared, retErr error) { //nolint:gocyclo // explicit acquisition/rollback transaction + source, common, err := validateSource(ctx, req.Source) + if err != nil { + return nil, err + } + worktree, worktreeParent, worktreeName, err := newPath(req.WorktreePath, "worktree") + if err != nil { + return nil, err + } + defer func() { _ = worktreeParent.Close() }() + metadata, metadataParent, metadataName, err := newPath(req.MetadataPath, "metadata") + if err != nil { + return nil, err + } + defer func() { _ = metadataParent.Close() }() + if req.Branch == "" { + return nil, errors.New("worktree: branch is required") + } + if _, err := git(ctx, source, nil, "check-ref-format", "--branch", req.Branch); err != nil { + return nil, fmt.Errorf("worktree: invalid branch: %w", err) + } + + var capture sourceCapture + if req.BaseRevision == "" { + capture, err = p.captureSource(ctx, source) + } else { + capture, err = captureRevision(ctx, source, req.BaseRevision) + } + if err != nil { + return nil, err + } + + created := false + var sourceDir, worktreeDir *os.File + var gitAdminParent *os.Root + var gitAdminName string + defer func() { + if retErr != nil && created { + retErr = errors.Join(retErr, + wrapRollbackError("remove worktree", worktreeParent.RemoveAll(worktreeName)), + wrapRollbackError("remove Git metadata", removeRootEntry(gitAdminParent, gitAdminName)), + wrapRollbackError("remove branch", removeWorktreeBound(context.Background(), sourceDir, req.Branch)), + wrapRollbackError("remove guest metadata", metadataParent.RemoveAll(metadataName)), + ) + } + if sourceDir != nil { + _ = sourceDir.Close() + } + if worktreeDir != nil { + _ = worktreeDir.Close() + } + if gitAdminParent != nil { + _ = gitAdminParent.Close() + } + }() + if err := createCapturedWorktree(ctx, source, worktree, req.Branch, capture); err != nil { + return nil, err + } + created = true + sourceDir, err = os.Open(source) + if err != nil { + return nil, fmt.Errorf("worktree: bind source for rollback: %w", err) + } + worktreeDir, err = worktreeParent.Open(worktreeName) + if err != nil { + return nil, fmt.Errorf("worktree: bind target for rollback: %w", err) + } + gitAdminParent, gitAdminName, err = bindWorktreeAdmin(worktreeDir, common) + if err != nil { + return nil, fmt.Errorf("worktree: bind Git metadata for rollback: %w", err) + } + if p.afterWorktree != nil { + if err := p.afterWorktree(worktree); err != nil { + return nil, fmt.Errorf("worktree: validation hook: %w", err) + } + } + + gitDir, objectStore, err := validateLinkedMetadata(worktree, common) + if err != nil { + return nil, err + } + if err := verifyPreparedCapture(ctx, source, worktree, req.BaseRevision, capture); err != nil { + return nil, err + } + if err := reconstructMetadata(ctx, worktree, gitDir, metadata, req.Branch, objectStore); err != nil { + return nil, fmt.Errorf("worktree: reconstruct guest metadata: %w", err) + } + + return &Prepared{ + SourceRoot: source, WorktreePath: worktree, MetadataPath: metadata, + Branch: req.Branch, CommonObjectStore: objectStore, + Mounts: []Mount{ + {HostPath: worktree, GuestPath: GuestWorkspace}, + {HostPath: metadata, GuestPath: GuestMetadata}, + {HostPath: objectStore, GuestPath: GuestObjectStore, ReadOnly: true}, + }, + }, nil +} + +func wrapRollbackError(action string, err error) error { + if err == nil { + return nil + } + return fmt.Errorf("worktree: rollback %s: %w", action, err) +} + +func removeRootEntry(root *os.Root, name string) error { + if root == nil { + return nil + } + return root.RemoveAll(name) +} + +func verifyPreparedCapture(ctx context.Context, source, prepared, revision string, capture sourceCapture) error { + if revision != "" { + if _, err := git(ctx, prepared, nil, "diff", "--quiet", revision, "--"); err != nil { + return fmt.Errorf("worktree: prepared state does not match immutable base: %w", err) + } + return nil + } + after, err := captureState(ctx, source) + if err != nil { + return fmt.Errorf("worktree: recheck source state: %w", err) + } + if !bytes.Equal(capture.state.digest, after.digest) { + return errors.New("worktree: source changed during capture") + } + got, err := captureState(ctx, prepared) + if err != nil { + return fmt.Errorf("worktree: verify prepared state: %w", err) + } + if !bytes.Equal(capture.state.digest, got.digest) { + return errors.New("worktree: prepared state does not exactly match captured source") + } + return nil +} + +type sourceCapture struct { + state state + staged, unstaged []byte + committedTreeArchive []byte + baseRevision string +} + +func captureRevision(ctx context.Context, source, revision string) (sourceCapture, error) { + if !validObjectID(revision) { + return sourceCapture{}, errors.New("worktree: immutable base revision must be a full object id") + } + if _, err := git(ctx, source, nil, "cat-file", "-e", revision+"^{tree}"); err != nil { + return sourceCapture{}, fmt.Errorf("worktree: resolve immutable base revision: %w", err) + } + archive, err := git(ctx, source, nil, "archive", "--format=tar", revision) + if err != nil { + return sourceCapture{}, fmt.Errorf("worktree: capture immutable base revision: %w", err) + } + return sourceCapture{committedTreeArchive: archive, baseRevision: revision}, nil +} + +func validObjectID(value string) bool { + if len(value) != 40 && len(value) != 64 { + return false + } + for _, char := range value { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') { + return false + } + } + return true +} + +func (p *Preparer) captureSource(ctx context.Context, source string) (sourceCapture, error) { + captured, err := captureState(ctx, source) + if err != nil { + return sourceCapture{}, fmt.Errorf("worktree: capture source state: %w", err) + } + staged, err := git(ctx, source, nil, "diff", "--cached", "--binary", "--full-index", "--no-renames", "HEAD", "--") + if err != nil { + return sourceCapture{}, fmt.Errorf("worktree: capture staged changes: %w", err) + } + unstaged, err := git(ctx, source, nil, "diff", "--binary", "--full-index", "--no-renames", "--") + if err != nil { + return sourceCapture{}, fmt.Errorf("worktree: capture unstaged changes: %w", err) + } + archive, err := git(ctx, source, nil, "archive", "--format=tar", "HEAD") + if err != nil { + return sourceCapture{}, fmt.Errorf("worktree: capture committed tree: %w", err) + } + if p.afterCapture != nil { + if err := p.afterCapture(); err != nil { + return sourceCapture{}, fmt.Errorf("worktree: capture hook: %w", err) + } + } + return sourceCapture{state: captured, staged: staged, unstaged: unstaged, committedTreeArchive: archive}, nil +} + +func createCapturedWorktree(ctx context.Context, source, worktree, branch string, capture sourceCapture) (retErr error) { + if _, err := git(ctx, source, nil, "worktree", "add", "--no-checkout", "-b", branch, worktree, "HEAD"); err != nil { + return fmt.Errorf("worktree: create linked worktree: %w", err) + } + if err := os.Chmod(worktree, 0o700); err != nil { // #nosec G302 -- the worktree root is deliberately owner-only. + return fmt.Errorf("worktree: make linked worktree private: %w", err) + } + defer func() { + if retErr != nil { + _ = removeWorktree(context.Background(), source, worktree, branch) + } + }() + if err := extractArchive(capture.committedTreeArchive, worktree); err != nil { + return fmt.Errorf("worktree: extract committed tree: %w", err) + } + revision := capture.baseRevision + if revision == "" { + revision = "HEAD" + } + if _, err := git(ctx, worktree, nil, "read-tree", revision); err != nil { + return fmt.Errorf("worktree: initialize index: %w", err) + } + if len(capture.staged) != 0 { + if _, err := git(ctx, worktree, capture.staged, "apply", "--index", "--binary", "--whitespace=nowarn", "-"); err != nil { + return fmt.Errorf("worktree: apply staged changes: %w", err) + } + } + if len(capture.unstaged) != 0 { + if _, err := git(ctx, worktree, capture.unstaged, "apply", "--binary", "--whitespace=nowarn", "-"); err != nil { + return fmt.Errorf("worktree: apply unstaged changes: %w", err) + } + } + if err := materializeUntracked(worktree, capture.state.untracked); err != nil { + return fmt.Errorf("worktree: materialize untracked files: %w", err) + } + return nil +} + +// Cleanup removes a successfully prepared worktree through the Preparer seam. +func (*Preparer) Cleanup(ctx context.Context, prepared *Prepared) error { + if prepared == nil { + return nil + } + return prepared.Cleanup(ctx) +} + +// Cleanup removes the linked worktree, its session branch, and guest metadata. +func (p *Prepared) Cleanup(ctx context.Context) error { + bound, err := bindCleanupTarget(ctx, p) + if err != nil { + return err + } + defer bound.close() + if p.beforeCleanupRemoval != nil { + p.beforeCleanupRemoval() + } + if err := bound.worktreeParent.RemoveAll(bound.worktreeName); err != nil { + return err + } + if err := bound.gitAdminParent.RemoveAll(bound.gitAdminName); err != nil { + return err + } + if err := removeWorktreeBound(ctx, bound.source, p.Branch); err != nil { + return err + } + return bound.metadataParent.RemoveAll(bound.metadataName) +} + +type cleanupTarget struct { + source, worktree *os.File + worktreeParent, metadataParent, gitAdminParent *os.Root + worktreeName, metadataName, gitAdminName string +} + +func (b *cleanupTarget) close() { + _ = b.source.Close() + _ = b.worktree.Close() + _ = b.worktreeParent.Close() + _ = b.metadataParent.Close() + _ = b.gitAdminParent.Close() +} + +func bindCleanupTarget(ctx context.Context, prepared *Prepared) (*cleanupTarget, error) { //nolint:gocyclo // fail-closed descriptor and identity checks stay explicit + if prepared == nil { + return nil, errors.New("worktree: cleanup target is nil") + } + common, err := cleanupCommonDirectory(ctx, prepared.SourceRoot) + if err != nil { + return nil, errors.New("worktree: cleanup source path was replaced or traverses a symlink") + } + worktreeParent, err := os.OpenRoot(filepath.Dir(prepared.WorktreePath)) + if err != nil { + return nil, err + } + metadataParent, err := os.OpenRoot(filepath.Dir(prepared.MetadataPath)) + if err != nil { + _ = worktreeParent.Close() + return nil, err + } + bound := &cleanupTarget{worktreeParent: worktreeParent, metadataParent: metadataParent, worktreeName: filepath.Base(prepared.WorktreePath), metadataName: filepath.Base(prepared.MetadataPath)} + fail := func(err error) (*cleanupTarget, error) { bound.closePartial(); return nil, err } + bound.source, err = os.Open(prepared.SourceRoot) + if err != nil { + return fail(err) + } + bound.worktree, err = worktreeParent.Open(bound.worktreeName) + if err != nil { + return fail(err) + } + metadataRoot, err := metadataParent.OpenRoot(bound.metadataName) + if err != nil { + return fail(err) + } + defer func() { _ = metadataRoot.Close() }() + if err := sameOpenDirectory(bound.source, prepared.SourceRoot); err != nil { + return fail(errors.New("worktree: cleanup source path changed while binding")) + } + if err := sameOpenDirectory(bound.worktree, prepared.WorktreePath); err != nil { + return fail(errors.New("worktree: cleanup target changed while binding")) + } + if err := validateRemovalTarget(ctx, prepared.SourceRoot, prepared.WorktreePath, prepared.Branch); err != nil { + return fail(err) + } + metadata, err := existingCanonicalDirNoSymlink(prepared.MetadataPath) + if err != nil || metadata != prepared.MetadataPath { + return fail(errors.New("worktree: cleanup metadata path was replaced or traverses a symlink")) + } + if err := sameOpenRoot(metadataRoot, prepared.MetadataPath); err != nil { + return fail(errors.New("worktree: cleanup metadata changed while binding")) + } + bound.gitAdminParent, bound.gitAdminName, err = bindWorktreeAdmin(bound.worktree, common) + if err != nil { + return fail(fmt.Errorf("worktree: bind cleanup Git metadata: %w", err)) + } + headInfo, err := metadataRoot.Lstat("HEAD") + if err != nil || !headInfo.Mode().IsRegular() { + return fail(errors.New("worktree: cleanup metadata HEAD is not a regular file")) + } + head, err := metadataRoot.ReadFile("HEAD") + if err != nil || string(head) != "ref: refs/heads/"+prepared.Branch+"\n" { + return fail(errors.New("worktree: cleanup metadata does not belong to the prepared branch")) + } + branchOut, err := gitexec.RunInDir(ctx, bound.worktree, nil, "symbolic-ref", "--quiet", "--short", "HEAD") + if err != nil { + return fail(fmt.Errorf("worktree: cleanup target branch changed while binding: %w", err)) + } + if strings.TrimSpace(string(branchOut)) != prepared.Branch { + return fail(errors.New("worktree: cleanup target branch changed while binding")) + } + return bound, nil +} + +func (b *cleanupTarget) closePartial() { + if b.source != nil { + _ = b.source.Close() + } + if b.worktree != nil { + _ = b.worktree.Close() + } + if b.worktreeParent != nil { + _ = b.worktreeParent.Close() + } + if b.metadataParent != nil { + _ = b.metadataParent.Close() + } + if b.gitAdminParent != nil { + _ = b.gitAdminParent.Close() + } +} + +func sameOpenDirectory(opened *os.File, path string) error { + boundInfo, err := opened.Stat() + if err != nil { + return err + } + pathInfo, err := os.Lstat(path) + if err != nil || !pathInfo.IsDir() || !os.SameFile(boundInfo, pathInfo) { + return errors.New("directory identity changed") + } + return nil +} + +func sameOpenRoot(opened *os.Root, path string) error { + boundInfo, err := opened.Stat(".") + if err != nil { + return err + } + pathInfo, err := os.Lstat(path) + if err != nil || !pathInfo.IsDir() || !os.SameFile(boundInfo, pathInfo) { + return errors.New("directory identity changed") + } + return nil +} + +func bindWorktreeAdmin(worktree *os.File, common string) (*os.Root, string, error) { + root, err := os.OpenRoot(fmt.Sprintf("/dev/fd/%d", worktree.Fd())) + if err != nil { + return nil, "", err + } + data, err := root.ReadFile(".git") + _ = root.Close() + if err != nil { + return nil, "", err + } + const prefix = "gitdir: " + gitDir := strings.TrimSpace(strings.TrimPrefix(string(data), prefix)) + adminParent := filepath.Join(common, "worktrees") + if !strings.HasPrefix(string(data), prefix) || filepath.Dir(gitDir) != adminParent || filepath.Base(gitDir) == "." { + return nil, "", errors.New("linked-worktree administrative path escaped the common Git directory") + } + parent, err := os.OpenRoot(adminParent) + if err != nil { + return nil, "", err + } + return parent, filepath.Base(gitDir), nil +} + +func removeWorktreeBound(ctx context.Context, source *os.File, branch string) error { + if source == nil { + return errors.New("worktree: cleanup descriptors are unavailable") + } + _, pruneErr := gitexec.RunInDir(ctx, source, nil, "worktree", "prune") + _, branchErr := gitexec.RunInDir(ctx, source, nil, "branch", "-D", branch) + return errors.Join(pruneErr, branchErr) +} + +func removeWorktree(ctx context.Context, source, worktree, branch string) error { + if err := validateRemovalTarget(ctx, source, worktree, branch); err != nil { + return err + } + if _, err := git(ctx, source, nil, "worktree", "remove", "--force", worktree); err != nil { + return err + } + _, pruneErr := git(ctx, source, nil, "worktree", "prune") + _, branchErr := git(ctx, source, nil, "branch", "-D", branch) + return errors.Join(pruneErr, branchErr) +} + +func validateRemovalTarget(ctx context.Context, source, worktree, branch string) error { + common, err := cleanupCommonDirectory(ctx, source) + if err != nil { + return errors.New("worktree: cleanup source path was replaced or traverses a symlink") + } + validatedWorktree, err := existingCanonicalDirNoSymlink(worktree) + if err != nil || validatedWorktree != worktree { + return errors.New("worktree: cleanup target was replaced or traverses a symlink") + } + if _, _, err := validateLinkedMetadata(validatedWorktree, common); err != nil { + return fmt.Errorf("worktree: cleanup target ownership: %w", err) + } + branchOut, err := git(ctx, validatedWorktree, nil, "symbolic-ref", "--quiet", "--short", "HEAD") + if err != nil || strings.TrimSpace(string(branchOut)) != branch { + return errors.New("worktree: cleanup target does not belong to the prepared branch") + } + return nil +} + +func cleanupCommonDirectory(ctx context.Context, source string) (string, error) { + validatedSource, err := existingCanonicalDirNoSymlink(source) + if err != nil || validatedSource != source { + return "", errors.New("cleanup source is not an unchanged real directory") + } + if root, common, err := validateSource(ctx, source); err == nil && root == source { + return common, nil + } + commonOut, err := git(ctx, source, nil, "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil { + return "", err + } + common, err := existingCanonicalDirNoSymlink(strings.TrimSpace(string(commonOut))) + if err != nil || common != source { + return "", errors.New("cleanup source is neither a worktree root nor its common Git directory") + } + objects, err := existingCanonicalDirNoSymlink(filepath.Join(common, "objects")) + if err != nil || !within(common, objects) { + return "", errors.New("cleanup source has no confined object store") + } + if err := rejectExternalObjectAlternates(objects); err != nil { + return "", err + } + return common, nil +} + +type capturedFile struct { + path string + mode fs.FileMode + data []byte +} + +type state struct { + digest []byte + untracked []capturedFile +} + +func captureState(ctx context.Context, root string) (state, error) { + staged, err := git(ctx, root, nil, "ls-files", "--stage", "-z") + if err != nil { + return state{}, err + } + untrackedOut, err := git(ctx, root, nil, "ls-files", "--others", "--exclude-standard", "-z") + if err != nil { + return state{}, err + } + h := sha256.New() + paths := make([]string, 0) + for _, record := range splitZero(staged) { + tab := bytes.IndexByte(record, '\t') + if tab < 0 { + return state{}, errors.New("malformed staged entry") + } + fields := strings.Fields(string(record[:tab])) + if len(fields) != 3 || fields[2] != "0" { + return state{}, fmt.Errorf("unsupported unmerged index entry %q", record) + } + if fields[0] == "160000" { + return state{}, errors.New("submodules are unsupported for exact source capture") + } + if strings.Trim(fields[1], "0") == "" { + return state{}, errors.New("intent-to-add entries are unsupported for exact source capture") + } + path := string(record[tab+1:]) + if err := validRelative(path); err != nil { + return state{}, err + } + writeDigest(h, record) + paths = append(paths, path) + } + sort.Strings(paths) + for _, path := range paths { + file, err := readCaptured(root, path, true) + if err != nil { + return state{}, err + } + writeCapturedDigest(h, file) + } + + var untracked []capturedFile + for _, raw := range splitZero(untrackedOut) { + path := string(raw) + if err := validRelative(path); err != nil { + return state{}, err + } + file, err := readCaptured(root, path, false) + if err != nil { + return state{}, err + } + untracked = append(untracked, file) + } + sort.Slice(untracked, func(i, j int) bool { return untracked[i].path < untracked[j].path }) + for _, file := range untracked { + writeCapturedDigest(h, file) + } + return state{digest: h.Sum(nil), untracked: untracked}, nil +} + +func readCaptured(root, path string, tracked bool) (capturedFile, error) { + full := filepath.Join(root, filepath.FromSlash(path)) + info, err := os.Lstat(full) + if errors.Is(err, os.ErrNotExist) && tracked { + return capturedFile{path: path}, nil + } + if err != nil { + return capturedFile{}, fmt.Errorf("inspect %q: %w", path, err) + } + file := capturedFile{path: path, mode: info.Mode()} + switch { + case info.Mode().IsRegular(): + file.data, err = os.ReadFile(full) + case info.Mode()&os.ModeSymlink != 0: + var target string + target, err = os.Readlink(full) + file.data = []byte(target) + default: + return capturedFile{}, fmt.Errorf("unsupported file type at %q", path) + } + if err != nil { + return capturedFile{}, fmt.Errorf("read %q: %w", path, err) + } + return file, nil +} + +func materializeUntracked(root string, files []capturedFile) error { + for _, file := range files { + path := filepath.Join(root, filepath.FromSlash(file.path)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if file.mode&os.ModeSymlink != 0 { + if err := os.Symlink(string(file.data), path); err != nil { + return err + } + continue + } + if err := os.WriteFile(path, file.data, file.mode.Perm()); err != nil { + return err + } + } + return nil +} + +func validateSource(ctx context.Context, source string) (string, string, error) { + root, err := existingCanonicalDir(source) + if err != nil { + return "", "", fmt.Errorf("worktree: source: %w", err) + } + topOut, err := git(ctx, root, nil, "rev-parse", "--path-format=absolute", "--show-toplevel") + if err != nil { + return "", "", fmt.Errorf("worktree: source is not a Git worktree: %w", err) + } + top, err := existingCanonicalDir(strings.TrimSpace(string(topOut))) + if err != nil || top != root { + return "", "", errors.New("worktree: source must be the registered worktree root") + } + list, err := git(ctx, root, nil, "worktree", "list", "--porcelain", "-z") + if err != nil || !listedWorktree(list, root) { + return "", "", errors.New("worktree: source is not registered in its repository") + } + commonOut, err := git(ctx, root, nil, "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil { + return "", "", fmt.Errorf("worktree: resolve common Git directory: %w", err) + } + common, err := existingCanonicalDirNoSymlink(strings.TrimSpace(string(commonOut))) + if err != nil { + return "", "", fmt.Errorf("worktree: common Git directory: %w", err) + } + objects, err := existingCanonicalDirNoSymlink(filepath.Join(common, "objects")) + if err != nil || !within(common, objects) { + return "", "", errors.New("worktree: common object store is not a confined directory") + } + if err := rejectExternalObjectAlternates(objects); err != nil { + return "", "", err + } + return root, common, nil +} + +func listedWorktree(out []byte, root string) bool { + for _, field := range splitZero(out) { + line := string(field) + if !strings.HasPrefix(line, "worktree ") { + continue + } + candidate, err := existingCanonicalDir(strings.TrimPrefix(line, "worktree ")) + if err == nil && candidate == root { + return true + } + } + return false +} + +func validateLinkedMetadata(worktree, expectedCommon string) (string, string, error) { + dotGit := filepath.Join(worktree, ".git") + info, err := os.Lstat(dotGit) + if err != nil || !info.Mode().IsRegular() { + return "", "", errors.New("worktree: linked .git must be a regular file") + } + content, err := os.ReadFile(dotGit) + if err != nil { + return "", "", fmt.Errorf("worktree: read linked .git: %w", err) + } + line := strings.TrimSpace(string(content)) + if !strings.HasPrefix(line, "gitdir: ") || strings.Contains(strings.TrimSpace(strings.TrimPrefix(line, "gitdir: ")), "\n") { + return "", "", errors.New("worktree: malformed linked .git file") + } + gitDirName := strings.TrimSpace(strings.TrimPrefix(line, "gitdir: ")) + if !filepath.IsAbs(gitDirName) { + gitDirName = filepath.Join(worktree, gitDirName) + } + gitDir, err := existingCanonicalDirNoSymlink(gitDirName) + if err != nil || filepath.Dir(gitDir) != filepath.Join(expectedCommon, "worktrees") { + return "", "", errors.New("worktree: linked Git directory escapes the common worktrees directory") + } + commondirPath := filepath.Join(gitDir, "commondir") + // #nosec G703 -- gitDir is canonical and confined to expectedCommon/worktrees above. + commondirInfo, err := os.Lstat(commondirPath) + if err != nil || !commondirInfo.Mode().IsRegular() { + return "", "", errors.New("worktree: commondir must be a regular file") + } + // #nosec G703 -- the same confined regular file was checked without following links. + commondirBytes, err := os.ReadFile(commondirPath) + if err != nil { + return "", "", err + } + commondirName := strings.TrimSpace(string(commondirBytes)) + if commondirName == "" || filepath.IsAbs(commondirName) { + return "", "", errors.New("worktree: commondir must be relative") + } + resolvedCommon, err := existingCanonicalDirNoSymlink(filepath.Join(gitDir, commondirName)) + if err != nil || resolvedCommon != expectedCommon { + return "", "", errors.New("worktree: commondir escapes the validated common Git directory") + } + objects, err := existingCanonicalDirNoSymlink(filepath.Join(resolvedCommon, "objects")) + if err != nil || !within(resolvedCommon, objects) { + return "", "", errors.New("worktree: object store escapes the validated common Git directory") + } + if err := rejectExternalObjectAlternates(objects); err != nil { + return "", "", err + } + return gitDir, objects, nil +} + +func rejectExternalObjectAlternates(objects string) error { + for _, name := range []string{"alternates", "http-alternates"} { + path := filepath.Join(objects, "info", name) + // #nosec G703 -- objects is canonical and confined by both callers. + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("worktree: object-store %s is not a confined regular file", name) + } + // #nosec G703 -- the confined file was Lstat-checked without following links. + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("worktree: read object-store %s: %w", name, err) + } + if len(bytes.TrimSpace(content)) != 0 { + return fmt.Errorf("worktree: external object-store %s is unsupported", name) + } + } + return nil +} + +func reconstructMetadata(ctx context.Context, worktree, gitDir, metadata, branch, objectStore string) error { + if err := os.Mkdir(metadata, 0o700); err != nil { + return err + } + formatOut, err := git(ctx, worktree, nil, "rev-parse", "--show-object-format") + if err != nil { + return err + } + format := strings.TrimSpace(string(formatOut)) + if format != "sha1" && format != "sha256" { + return fmt.Errorf("unsupported object format %q", format) + } + headOut, err := git(ctx, worktree, nil, "rev-parse", "HEAD") + if err != nil { + return err + } + head := strings.TrimSpace(string(headOut)) + refPath := filepath.Join(metadata, filepath.FromSlash("refs/heads/"+branch)) + if !within(metadata, refPath) { + return errors.New("branch ref escapes guest metadata") + } + if err := os.MkdirAll(filepath.Dir(refPath), 0o700); err != nil { + return err + } + config := "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = false\n\tworktree = " + GuestWorkspace + "\n" + if format == "sha256" { + config += "[extensions]\n\tobjectformat = sha256\n" + } + if err := os.WriteFile(filepath.Join(metadata, "config"), []byte(config), 0o600); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(metadata, "HEAD"), []byte("ref: refs/heads/"+branch+"\n"), 0o600); err != nil { + return err + } + if err := os.WriteFile(refPath, []byte(head+"\n"), 0o600); err != nil { + return err + } + index, err := os.ReadFile(filepath.Join(gitDir, "index")) + if err != nil { + return err + } + // #nosec G703 -- metadata is a canonical new path under a prevalidated parent. + if err := os.WriteFile(filepath.Join(metadata, "index"), index, 0o600); err != nil { + return err + } + infoDir := filepath.Join(metadata, "objects", "info") + if err := os.MkdirAll(infoDir, 0o700); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(infoDir, "alternates"), []byte(GuestObjectStore+"\n"), 0o600); err != nil { + return err + } + if _, err := existingCanonicalDirNoSymlink(objectStore); err != nil { + return err + } + return makeGuestMetadataReadable(metadata) +} + +func makeGuestMetadataReadable(metadata string) error { + // The metadata root is below an owner-only daemon state directory on the host, + // but it must be readable by the fixed unprivileged guest Git capability. + metadataRoot, err := os.OpenRoot(metadata) + if err != nil { + return err + } + defer func() { _ = metadataRoot.Close() }() + return filepath.WalkDir(metadata, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(metadata, path) + if err != nil { + return err + } + if entry.IsDir() { + return metadataRoot.Chmod(rel, 0o755) + } + return metadataRoot.Chmod(rel, 0o644) + }) +} + +func extractArchive(data []byte, root string) error { + tr := tar.NewReader(bytes.NewReader(data)) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + name := filepath.ToSlash(hdr.Name) + if hdr.Typeflag == tar.TypeDir { + name = strings.TrimSuffix(name, "/") + } + if err := validRelative(name); err != nil { + return err + } + mode, err := safeArchiveMode(hdr.Mode) + if err != nil { + return fmt.Errorf("archive entry %q: %w", name, err) + } + path := filepath.Join(root, filepath.FromSlash(name)) + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(path, mode); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + content, err := io.ReadAll(tr) + if err != nil { + return err + } + if err := os.WriteFile(path, content, mode); err != nil { + return err + } + case tar.TypeSymlink: + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.Symlink(hdr.Linkname, path); err != nil { + return err + } + case tar.TypeXGlobalHeader, tar.TypeXHeader: + // Metadata-only PAX records emitted by Git's built-in tar writer. + continue + default: + return fmt.Errorf("unsupported archive entry type for %q", name) + } + } +} + +func safeArchiveMode(raw int64) (fs.FileMode, error) { + if raw < 0 || raw > 0o777 { + return 0, errors.New("invalid permission mode") + } + // #nosec G115 -- the explicit bound above makes this conversion lossless. + return fs.FileMode(uint32(raw)), nil +} + +func git(ctx context.Context, dir string, stdin []byte, args ...string) ([]byte, error) { + return gitexec.Run(ctx, dir, stdin, args...) +} + +func newPath(path, kind string) (string, *os.Root, string, error) { + if path == "" { + return "", nil, "", fmt.Errorf("worktree: %s path is required", kind) + } + abs, err := filepath.Abs(path) + if err != nil { + return "", nil, "", err + } + if _, err := os.Lstat(abs); !errors.Is(err, os.ErrNotExist) { + if err == nil { + return "", nil, "", fmt.Errorf("worktree: %s path already exists", kind) + } + return "", nil, "", err + } + parent, err := existingCanonicalDir(filepath.Dir(abs)) + if err != nil { + return "", nil, "", fmt.Errorf("worktree: %s parent: %w", kind, err) + } + root, err := os.OpenRoot(parent) + if err != nil { + return "", nil, "", err + } + name := filepath.Base(abs) + return filepath.Join(parent, name), root, name, nil +} + +func existingCanonicalDir(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + canonical, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", err + } + info, err := os.Stat(canonical) + if err != nil || !info.IsDir() { + return "", errors.New("not a directory") + } + return filepath.Clean(canonical), nil +} + +func existingCanonicalDirNoSymlink(path string) (string, error) { + // #nosec G703 -- Lstat deliberately inspects the untrusted final component + // without following it; EvalSymlinks below then rejects any parent symlink. + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", errors.New("not a real directory") + } + canonical, err := filepath.EvalSymlinks(path) + if err != nil { + return "", err + } + if filepath.Clean(path) != canonical { + return "", errors.New("directory path traverses a symlink") + } + return canonical, nil +} + +func validRelative(path string) error { + if path == "" || filepath.IsAbs(path) || path == "." || path == ".git" || strings.HasPrefix(path, ".git/") { + return fmt.Errorf("unsafe repository path %q", path) + } + clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) + if clean != path || clean == ".." || strings.HasPrefix(clean, "../") { + return fmt.Errorf("unsafe repository path %q", path) + } + return nil +} + +func within(root, path string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func splitZero(data []byte) [][]byte { + parts := bytes.Split(data, []byte{0}) + if len(parts) > 0 && len(parts[len(parts)-1]) == 0 { + parts = parts[:len(parts)-1] + } + return parts +} + +func writeDigest(w io.Writer, data []byte) { + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(data))) + _, _ = w.Write(size[:]) + _, _ = w.Write(data) +} + +func writeCapturedDigest(w io.Writer, file capturedFile) { + writeDigest(w, []byte(file.path)) + writeDigest(w, []byte(file.mode.String())) + writeDigest(w, file.data) +} diff --git a/environment/microvm/worktree/worktree_test.go b/environment/microvm/worktree/worktree_test.go new file mode 100644 index 000000000..83d1448a5 --- /dev/null +++ b/environment/microvm/worktree/worktree_test.go @@ -0,0 +1,424 @@ +package worktree + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestPrepareExtractsCommittedDirectories(t *testing.T) { + requireGit(t) + source := newRepository(t) + writeTestFile(t, filepath.Join(source, "nested", "fact.txt"), []byte("nested\n"), 0o644) + gitTest(t, source, nil, "add", "nested/fact.txt") + gitTest(t, source, nil, "commit", "-qm", "add nested file") + root := t.TempDir() + prepared, err := New().Prepare(context.Background(), Request{ + Source: source, WorktreePath: filepath.Join(root, "worktree"), + MetadataPath: filepath.Join(root, "metadata"), Branch: "mecatl/nested", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = prepared.Cleanup(context.Background()) }) + got, err := os.ReadFile(filepath.Join(prepared.WorktreePath, "nested", "fact.txt")) + if err != nil || string(got) != "nested\n" { + t.Fatalf("nested committed file = %q, %v", got, err) + } +} + +func TestCleanupRejectsLogicalAncestorSymlinkSubstitution(t *testing.T) { + requireGit(t) + attackerSource := newRepository(t) + victimSource := newRepository(t) + root := t.TempDir() + attackerRoot := filepath.Join(root, "attacker") + victimRoot := filepath.Join(root, "victim") + if err := os.MkdirAll(attackerRoot, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(victimRoot, 0o700); err != nil { + t.Fatal(err) + } + attacker, err := New().Prepare(context.Background(), Request{ + Source: attackerSource, WorktreePath: filepath.Join(attackerRoot, "worktree"), + MetadataPath: filepath.Join(attackerRoot, "metadata"), Branch: "mecatl/attacker", + }) + if err != nil { + t.Fatalf("prepare attacker: %v", err) + } + victim, err := New().Prepare(context.Background(), Request{ + Source: victimSource, WorktreePath: filepath.Join(victimRoot, "worktree"), + MetadataPath: filepath.Join(victimRoot, "metadata"), Branch: "mecatl/victim", + }) + if err != nil { + t.Fatalf("prepare victim: %v", err) + } + t.Cleanup(func() { _ = victim.Cleanup(context.Background()) }) + + attackerOriginal := attackerRoot + ".original" + if err := os.Rename(attackerRoot, attackerOriginal); err != nil { + t.Fatal(err) + } + if err := os.Symlink(victimRoot, attackerRoot); err != nil { + t.Fatal(err) + } + + if err := attacker.Cleanup(context.Background()); err == nil { + t.Fatal("Cleanup accepted a symlink-substituted logical ancestor") + } + for _, path := range []string{victim.WorktreePath, victim.MetadataPath} { + if info, statErr := os.Lstat(path); statErr != nil || !info.IsDir() { + t.Fatalf("victim path %q was removed: %v", path, statErr) + } + } + if got := gitOutput(t, victim.WorktreePath, "status", "--porcelain"); got != "" { + t.Fatalf("victim worktree became dirty: %q", got) + } +} + +func TestCleanupBindsRemovalBeforeAncestorSwap(t *testing.T) { + requireGit(t) + source := newRepository(t) + root := t.TempDir() + owned := filepath.Join(root, "owned") + victim := filepath.Join(root, "victim") + if err := os.MkdirAll(owned, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(victim, 0o700); err != nil { + t.Fatal(err) + } + marker := filepath.Join(victim, "survives") + writeTestFile(t, marker, []byte("victim\n"), 0o600) + prepared, err := New().Prepare(context.Background(), Request{ + Source: source, WorktreePath: filepath.Join(owned, "worktree"), + MetadataPath: filepath.Join(owned, "metadata"), Branch: "mecatl/anchored-cleanup", + }) + if err != nil { + t.Fatal(err) + } + original := owned + ".original" + prepared.beforeCleanupRemoval = func() { + if err := os.Rename(owned, original); err != nil { + t.Fatal(err) + } + if err := os.Symlink(victim, owned); err != nil { + t.Fatal(err) + } + } + if err := prepared.Cleanup(context.Background()); err != nil { + t.Fatalf("descriptor-bound cleanup: %v", err) + } + if got, err := os.ReadFile(marker); err != nil || string(got) != "victim\n" { + t.Fatalf("victim changed after ancestor swap: %q, %v", got, err) + } +} + +func TestPrepareRollbackUsesBoundRemovalAfterAncestorSwap(t *testing.T) { + requireGit(t) + source := newRepository(t) + root := t.TempDir() + owned := filepath.Join(root, "owned") + victim := filepath.Join(root, "victim") + if err := os.MkdirAll(owned, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(victim, 0o700); err != nil { + t.Fatal(err) + } + marker := filepath.Join(victim, "survives") + writeTestFile(t, marker, []byte("victim\n"), 0o600) + p := New() + p.afterWorktree = func(string) error { + if err := os.Rename(owned, owned+".original"); err != nil { + return err + } + if err := os.Symlink(victim, owned); err != nil { + return err + } + return errors.New("injected preparation failure") + } + _, err := p.Prepare(context.Background(), Request{ + Source: source, WorktreePath: filepath.Join(owned, "worktree"), + MetadataPath: filepath.Join(owned, "metadata"), Branch: "mecatl/anchored-rollback", + }) + if err == nil { + t.Fatal("Prepare accepted injected failure") + } + if got, readErr := os.ReadFile(marker); readErr != nil || string(got) != "victim\n" { + t.Fatalf("rollback changed victim: %q, %v", got, readErr) + } + for _, path := range []string{filepath.Join(owned+".original", "worktree"), filepath.Join(owned+".original", "metadata")} { + if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("rollback retained owned path %q: %v", path, statErr) + } + } + entries, readErr := os.ReadDir(filepath.Join(source, ".git", "worktrees")) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + t.Fatalf("read linked-worktree administration: %v", readErr) + } + if len(entries) != 0 { + t.Fatalf("rollback retained linked-worktree administration: %v", entries) + } + command := exec.CommandContext(t.Context(), "git", "show-ref", "--verify", "--quiet", "refs/heads/mecatl/anchored-rollback") + command.Dir = source + if branchErr := command.Run(); branchErr == nil { + t.Fatal("rollback retained temporary branch") + } +} + +func TestMicroVMEnvironments_Scenario3_SourceStateCaptureIsExactOrFails(t *testing.T) { + requireGit(t) + source := newRepository(t) + writeTestFile(t, filepath.Join(source, "both.txt"), []byte("staged\n"), 0o644) + writeTestFile(t, filepath.Join(source, "staged.txt"), []byte("staged only\n"), 0o755) + gitTest(t, source, nil, "add", "both.txt", "staged.txt") + writeTestFile(t, filepath.Join(source, "both.txt"), []byte("unstaged after staged\n"), 0o644) + writeTestFile(t, filepath.Join(source, "untracked.bin"), []byte{0, 1, 0xff, 2}, 0o600) + + prepared, err := New().Prepare(context.Background(), Request{ + Source: source, + WorktreePath: filepath.Join(t.TempDir(), "session-worktree"), + MetadataPath: filepath.Join(t.TempDir(), "guest-git"), + Branch: "mecatl/session-test", + }) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + t.Cleanup(func() { _ = prepared.Cleanup(context.Background()) }) + + sourceState, err := captureState(context.Background(), source) + if err != nil { + t.Fatalf("capture source: %v", err) + } + preparedState, err := captureState(context.Background(), prepared.WorktreePath) + if err != nil { + t.Fatalf("capture prepared worktree: %v", err) + } + if !bytes.Equal(sourceState.digest, preparedState.digest) { + t.Fatalf("prepared state differs from source\nsource: %x\nprepared: %x", sourceState.digest, preparedState.digest) + } + if got := gitOutput(t, prepared.WorktreePath, "diff", "--cached", "--", "both.txt"); !strings.Contains(got, "+staged") { + t.Fatalf("staged state was not retained: %q", got) + } + if got := gitOutput(t, prepared.WorktreePath, "diff", "--", "both.txt"); !strings.Contains(got, "+unstaged after staged") { + t.Fatalf("unstaged state was not retained: %q", got) + } + + racy := newRepository(t) + p := New() + p.afterCapture = func() error { + return os.WriteFile(filepath.Join(racy, "tracked.txt"), []byte("changed during capture\n"), 0o644) + } + racyWorktree := filepath.Join(t.TempDir(), "racy-worktree") + racyMetadata := filepath.Join(t.TempDir(), "racy-git") + _, err = p.Prepare(context.Background(), Request{ + Source: racy, + WorktreePath: racyWorktree, + MetadataPath: racyMetadata, + Branch: "mecatl/racy", + }) + if err == nil || !strings.Contains(err.Error(), "source changed during capture") { + t.Fatalf("racing source error = %v, want exact-capture failure", err) + } + for _, path := range []string{racyWorktree, racyMetadata} { + if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("failed capture left provisional path %q: %v", path, statErr) + } + } +} + +func TestMicroVMEnvironments_Scenario3_WorktreeGitMetadataIsConfined(t *testing.T) { + requireGit(t) + t.Run("reconstructed metadata and read-only alternates mount", func(t *testing.T) { + source := newRepository(t) + hookSentinel := filepath.Join(t.TempDir(), "hook-fired") + hooks := filepath.Join(source, ".git", "hooks") + if err := os.WriteFile(filepath.Join(hooks, "post-checkout"), []byte("#!/bin/sh\ntouch \""+hookSentinel+"\"\n"), 0o755); err != nil { + t.Fatalf("write hostile hook: %v", err) + } + gitTest(t, source, nil, "config", "core.pager", "false") + prepared, err := New().Prepare(context.Background(), Request{ + Source: source, + WorktreePath: filepath.Join(t.TempDir(), "session-worktree"), + MetadataPath: filepath.Join(t.TempDir(), "guest-git"), + Branch: "mecatl/confined", + }) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + t.Cleanup(func() { _ = prepared.Cleanup(context.Background()) }) + if _, statErr := os.Stat(hookSentinel); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("repository-controlled post-checkout hook ran: %v", statErr) + } + guestConfig, err := os.ReadFile(filepath.Join(prepared.MetadataPath, "config")) + if err != nil { + t.Fatalf("read reconstructed config: %v", err) + } + if bytes.Contains(guestConfig, []byte("pager")) { + t.Fatalf("repository-controlled config leaked into guest metadata: %s", guestConfig) + } + + wantMount := Mount{HostPath: prepared.CommonObjectStore, GuestPath: GuestObjectStore, ReadOnly: true} + if !slices.Contains(prepared.Mounts, wantMount) { + t.Fatalf("mounts = %#v, want host-enforced read-only object-store mount %#v", prepared.Mounts, wantMount) + } + alternates, err := os.ReadFile(filepath.Join(prepared.MetadataPath, "objects", "info", "alternates")) + if err != nil { + t.Fatalf("read alternates: %v", err) + } + if string(alternates) != GuestObjectStore+"\n" { + t.Fatalf("alternates = %q, want guest-only path", alternates) + } + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = prepared.WorktreePath + cmd.Env = append(os.Environ(), + "GIT_DIR="+prepared.MetadataPath, + "GIT_WORK_TREE="+prepared.WorktreePath, + "GIT_ALTERNATE_OBJECT_DIRECTORIES="+prepared.CommonObjectStore, + ) + if out, runErr := cmd.CombinedOutput(); runErr != nil { + t.Fatalf("git with reconstructed metadata: %v: %s", runErr, out) + } + }) + + t.Run("source object-store alternates cannot name a host path", func(t *testing.T) { + source := newRepository(t) + alternates := filepath.Join(source, ".git", "objects", "info", "alternates") + if err := os.MkdirAll(filepath.Dir(alternates), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(alternates, []byte("/etc\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := New().Prepare(context.Background(), Request{ + Source: source, + WorktreePath: filepath.Join(t.TempDir(), "session-worktree"), + MetadataPath: filepath.Join(t.TempDir(), "guest-git"), + Branch: "mecatl/external-alternate", + }) + if err == nil || !strings.Contains(err.Error(), "external object-store alternates") { + t.Fatalf("external alternates error = %v, want confined failure", err) + } + }) + + for _, tc := range []struct { + name string + tamper func(string) error + }{ + { + name: "dot-git symlink", + tamper: func(worktree string) error { + if err := os.Remove(filepath.Join(worktree, ".git")); err != nil { + return err + } + return os.Symlink("/etc", filepath.Join(worktree, ".git")) + }, + }, + { + name: "commondir escape", + tamper: func(worktree string) error { + gitFile, err := os.ReadFile(filepath.Join(worktree, ".git")) + if err != nil { + return err + } + gitDir := strings.TrimSpace(strings.TrimPrefix(string(gitFile), "gitdir:")) + return os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../../../../../../etc\n"), 0o644) + }, + }, + { + name: "object-store symlink", + tamper: func(worktree string) error { + gitFile, err := os.ReadFile(filepath.Join(worktree, ".git")) + if err != nil { + return err + } + gitDir := strings.TrimSpace(strings.TrimPrefix(string(gitFile), "gitdir:")) + commondir, err := os.ReadFile(filepath.Join(gitDir, "commondir")) + if err != nil { + return err + } + objects := filepath.Join(gitDir, strings.TrimSpace(string(commondir)), "objects") + if err := os.Rename(objects, objects+".real"); err != nil { + return err + } + return os.Symlink("/etc", objects) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + source := newRepository(t) + p := New() + p.afterWorktree = tc.tamper + prepared, err := p.Prepare(context.Background(), Request{ + Source: source, + WorktreePath: filepath.Join(t.TempDir(), "session-worktree"), + MetadataPath: filepath.Join(t.TempDir(), "guest-git"), + Branch: "mecatl/hostile-" + strings.ReplaceAll(tc.name, " ", "-"), + }) + if prepared != nil { + _ = prepared.Cleanup(context.Background()) + } + if err == nil { + t.Fatal("Prepare accepted hostile linked-worktree metadata") + } + }) + } +} + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git unavailable") + } +} + +func newRepository(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitTest(t, dir, nil, "init", "-q") + gitTest(t, dir, nil, "config", "user.email", "test@example.invalid") + gitTest(t, dir, nil, "config", "user.name", "Test") + writeTestFile(t, filepath.Join(dir, "tracked.txt"), []byte("committed\n"), 0o644) + gitTest(t, dir, nil, "add", "tracked.txt") + gitTest(t, dir, nil, "commit", "-qm", "initial") + return dir +} + +func writeTestFile(t *testing.T, path string, content []byte, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, mode); err != nil { + t.Fatal(err) + } +} + +func gitTest(t *testing.T, dir string, stdin []byte, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Stdin = bytes.NewReader(stdin) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } +} + +func gitOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return string(out) +} diff --git a/go.mod b/go.mod index e2b167d82..a1c438476 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( golang.org/x/net v0.58.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.23.0 + golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 golang.org/x/tools v0.49.0 @@ -82,6 +83,7 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-git/go-git/v5 v5.19.2 // indirect + github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect @@ -304,7 +306,6 @@ require ( golang.org/x/exp/event v0.0.0-20260611194520-c48552f49976 // indirect golang.org/x/exp/jsonrpc2 v0.0.0-20260709172345-9ea1abe57597 // indirect golang.org/x/mod v0.40.0 // indirect - golang.org/x/sys v0.47.0 golang.org/x/text v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect @@ -315,10 +316,10 @@ require ( k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect - modernc.org/libc v1.72.1 // indirect + modernc.org/libc v1.74.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.49.1 // indirect + modernc.org/sqlite v1.54.0 // indirect oras.land/oras-go/v2 v2.6.2 // indirect sigs.k8s.io/controller-runtime v0.23.3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/go.sum b/go.sum index b330d9540..437fe3ecb 100644 --- a/go.sum +++ b/go.sum @@ -289,8 +289,7 @@ github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= -github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= -github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE= github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q= github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= @@ -1036,20 +1035,17 @@ k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk1 k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc= -modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54= -modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= -modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= 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= @@ -1058,8 +1054,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/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.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= -modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/go.work b/go.work index 33f2b0512..5686fa477 100644 --- a/go.work +++ b/go.work @@ -6,6 +6,8 @@ use ./engine use ./authn/oidc +use ./environment/microvm + use ./provider/anthropic use ./provider/openai diff --git a/go.work.sum b/go.work.sum index e7cbaa561..0a01db3a3 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,23 +1,34 @@ +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms= cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= -cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= -cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/accessapproval v1.8.8/go.mod h1:RFwPY9JDKseP4gJrX1BlAVsP5O6kI8NdGlTmaeDefmk= +cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI= cloud.google.com/go/accesscontextmanager v1.9.7/go.mod h1:i6e0nd5CPcrh7+YwGq4bKvju5YB9sgoAip+mXU73aMM= +cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k= cloud.google.com/go/aiplatform v1.120.0/go.mod h1:6mDthfmy0oS1EQhVFdijoxkVdI2+HIZkpuGTBpedeCg= +cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI= cloud.google.com/go/analytics v0.30.1/go.mod h1:V/FnINU5kMOsttZnKPnXfKi6clJUHTEXUKQjHxcNK8A= +cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc= cloud.google.com/go/apigateway v1.7.7/go.mod h1:j1bCmrUK1BzVHpiIyTApxB7cRyhivKzltqLmp6j6i7U= +cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk= cloud.google.com/go/apigeeconnect v1.7.7/go.mod h1:ftGK3nca0JePiVLl0A6alaMjKdOc5C+sAkFMyH2RH8U= +cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q= cloud.google.com/go/apigeeregistry v0.10.0/go.mod h1:SAlF5OhKvyLDuwWAaFAIVJjrEqKRrGTPkJs+TWNnSqg= +cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0= cloud.google.com/go/appengine v1.9.7/go.mod h1:y1XpGVeAhbsNzHida79cHbr3pFRsym0ob8xnC8yphbo= +cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM= cloud.google.com/go/area120 v0.10.0/go.mod h1:Xg3fKl4xU3UVai9wsI1FXwNU8wSCDYT7dFZfwJKViAM= +cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y= cloud.google.com/go/artifactregistry v1.20.0/go.mod h1:0G9wdbGyDFkvrYH+2AlQs9MuTJdbY8Vg45M8VjlI8rc= +cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8= cloud.google.com/go/asset v1.22.1/go.mod h1:NlvWwmca7CX6BIBEdRNxOocH6DowmBghAAHucOHuHng= +cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M= cloud.google.com/go/assuredworkloads v1.13.0/go.mod h1:o/oHEOnUlribR+uJWTKQo8A5RhSl9K9FNeMOew4TJ3M= +cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss= cloud.google.com/go/auth v0.3.0/go.mod h1:lBv6NKTWp8E3LPzmO1TbiiRKc4drLOfHsgmlH9ogv5w= cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs= cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= @@ -27,142 +38,245 @@ cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UG cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= -cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY= cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I= -cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= -cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/automl v1.15.0/go.mod h1:U9zOtQb8zVrFNGTuW3BfxeqmLyeleLgT9B12EaXfODg= +cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI= cloud.google.com/go/baremetalsolution v1.4.0/go.mod h1:K6C6g4aS8LW95I0fEHZiBsBlh0UxwDLGf+S/vyfXbvg= +cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg= cloud.google.com/go/batch v1.14.0/go.mod h1:oeQveyG6NDS/ks2ilOP4LzKRmuIaI7GLe0CkR7WF6pk= +cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw= cloud.google.com/go/beyondcorp v1.2.0/go.mod h1:sszcgxpPPBEfLzbI0aYCTg6tT1tyt3CmKav3NZIUcvI= +cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY= cloud.google.com/go/bigquery v1.74.0/go.mod h1:iViO7Cx3A/cRKcHNRsHB3yqGAMInFBswrE9Pxazsc90= +cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM= cloud.google.com/go/bigtable v1.42.0/go.mod h1:oZ30nofVB6/UYGg7lBwGLWSea7NZUvw/WvBBgLY07xU= +cloud.google.com/go/bigtable v1.50.0/go.mod h1:RTannV5mvoJM8KscLTfRYMPo84u9/j+C3PSyYJGf5Ic= cloud.google.com/go/billing v1.21.0/go.mod h1:ZGairB3EVnb3i09E2SxFxo50p5unPaMTuo1jh6jW9js= +cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk= cloud.google.com/go/binaryauthorization v1.10.0/go.mod h1:WOuiaQkI4PU/okwrcREjSAr2AUtjQgVe+PlrXKOmKKw= +cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss= cloud.google.com/go/certificatemanager v1.9.6/go.mod h1:vWogV874jKZkSRDFCMM3r7wqybv8WXs3XhyNff6o/Zo= +cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8= cloud.google.com/go/channel v1.21.0/go.mod h1:8v3TwHtgLmFxTpL2U+e10CLFOQN8u/Vr9RhYcJUS3y8= +cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY= cloud.google.com/go/cloudbuild v1.25.0/go.mod h1:lCu+T6IPkobPo2Nw+vCE7wuaAl9HbXLzdPx/tcF+oWo= +cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA= cloud.google.com/go/clouddms v1.8.8/go.mod h1:QtCyw+a73dlkDb2q20aTAPvfaTZCepDDi6Gb1AKq0a4= +cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk= cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4= +cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE= cloud.google.com/go/compute v1.54.0/go.mod h1:RfBj0L1x/pIM84BrzNX2V21oEv16EKRPBiTcBRRH1Ww= +cloud.google.com/go/compute v1.64.0 h1:7MmuzeAxlG5MOG5PQD2NLtyYR6bWjkvGljRu7pByoRU= +cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= -cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= -cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/contactcenterinsights v1.17.4/go.mod h1:kZe6yOnKDfpPz2GphDHynxk/Spx+53UX/pGf+SmWAKM= +cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ= cloud.google.com/go/container v1.46.0/go.mod h1:A7gMqdQduTk46+zssWDTKbGS2z46UsJNXfKqvMI1ZO4= +cloud.google.com/go/container v1.53.0/go.mod h1:SBOylKhlKYCBFs/8kz2yqRdUW5ctVNHs82JKOTjrB9s= cloud.google.com/go/containeranalysis v0.14.2/go.mod h1:FjppROiUtP9cyMegdWdY/TsBSGc6kqh1GjA2NOJXXL8= +cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ= cloud.google.com/go/datacatalog v1.26.1/go.mod h1:2Qcq8vsHNxMDgjgadRFmFG47Y+uuIVsyEGUrlrKEdrg= +cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM= cloud.google.com/go/dataflow v0.11.1/go.mod h1:3s6y/h5Qz7uuxTmKJKBifkYZ3zs63jS+6VGtSu8Cf7Y= +cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ= cloud.google.com/go/dataform v0.13.0/go.mod h1:U3fqrPY5jAcFh1a8rQb4a+PQ7zKlc5qfgotFZ+luKPo= +cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk= cloud.google.com/go/datafusion v1.8.7/go.mod h1:4dkFb1la41qCEXh1AzYtFwl842bu2ikTUXyKhjvFCb0= +cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc= cloud.google.com/go/datalabeling v0.9.7/go.mod h1:EEUVn+wNn3jl19P2S13FqE1s9LsKzRsPuuMRq2CMsOk= +cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q= cloud.google.com/go/dataplex v1.28.0/go.mod h1:VB+xlYJiJ5kreonXsa2cHPj0A3CfPh/mgiHG4JFhbUA= +cloud.google.com/go/dataplex v1.35.0/go.mod h1:B7AFwXU1u3sp7FVQ3IFYnQguGTycJS2mF1voE0lLe1o= cloud.google.com/go/dataproc/v2 v2.16.0/go.mod h1:HlzFg8k1SK+bJN3Zsy2z5g6OZS1D4DYiDUgJtF0gJnE= +cloud.google.com/go/dataproc/v2 v2.23.0/go.mod h1:dOzSynzBm7TBf9nIxmJxKAQt5EpdNPcNJomYfbpPhm4= cloud.google.com/go/dataqna v0.9.8/go.mod h1:2lHKmGPOqzzuqCc5NI0+Xrd5om4ulxGwPpLB4AnFgpA= +cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs= cloud.google.com/go/datastore v1.22.0/go.mod h1:aopSX+Whx0lHspWWBj+AjWt68/zjYsPfDe3LjWtqZg8= +cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So= cloud.google.com/go/datastream v1.15.1/go.mod h1:aV1Grr9LFon0YvqryE5/gF1XAhcau2uxN2OvQJPpqRw= +cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ= cloud.google.com/go/deploy v1.27.3/go.mod h1:7LFIYYTSSdljYRqY3n+JSmIFdD4lv6aMD5xg0crB5iw= +cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U= cloud.google.com/go/dialogflow v1.76.0/go.mod h1:mdLkMmSCghfcP85X9dFBlirC1OssS65KE5hrrSz2GXY= +cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw= cloud.google.com/go/dlp v1.28.0/go.mod h1:C3od1fIK8lf7Kr62aU1Uh0z4OL5Z8s3do3znAiEupAw= +cloud.google.com/go/dlp v1.36.0/go.mod h1:UW92dBhxvqkSKLct+Ril7Y9B4CanS5VLuDwlTGVA9VQ= cloud.google.com/go/documentai v1.42.0/go.mod h1:CABOUzRNOuvb/QwJS2LS80Hpqbu3UW2afyRKTYuW7bo= +cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c= cloud.google.com/go/domains v0.10.7/go.mod h1:T3WG/QUAO/52z4tUPooKS8AY7yXaFxPYn1V3F0/JbNQ= +cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk= cloud.google.com/go/edgecontainer v1.4.4/go.mod h1:yyNVHsCKtsX/0mqFdbljQw0Uo660q2dlMPaiqYiC2Tg= +cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE= cloud.google.com/go/errorreporting v0.4.0/go.mod h1:dZGEhqzdHZSRxxWLVjC3Ue5CVaROzvP58D9rU6zbBfw= +cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84= cloud.google.com/go/essentialcontacts v1.7.7/go.mod h1:ytycWAEn/aKUMRKQPMVgMrAtphEMgjbzL8vFwM3tqXs= +cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A= cloud.google.com/go/eventarc v1.18.0/go.mod h1:/6SDoqh5+9QNUqCX4/oQcJVK16fG/snHBSXu7lrJtO8= +cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU= cloud.google.com/go/filestore v1.10.3/go.mod h1:94ZGyLTx9j+aWKozPQ6Wbq1DuImie/L/HIdGMshtwac= +cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM= cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= +cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= cloud.google.com/go/functions v1.19.7/go.mod h1:xbcKfS7GoIcaXr2FSwmtn9NXal1JR4TV6iYZlgXffwA= +cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0= cloud.google.com/go/gkebackup v1.8.1/go.mod h1:GAaAl+O5D9uISH5MnClUop2esQW4pDa2qe/95A4l7YQ= +cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY= cloud.google.com/go/gkeconnect v0.12.5/go.mod h1:wMD2RXcsAWlkREZWJDVeDV70PYka1iEb9stFmgpw+5o= +cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ= cloud.google.com/go/gkehub v0.16.0/go.mod h1:ADp27Ucor8v81wY+x/5pOxTorxkPj/xswH3AUpN62GU= +cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0= cloud.google.com/go/gkemulticloud v1.6.0/go.mod h1:bGpd4o/Z5Z/XFlaojkgdVisHRwb+fLJvUPzsmV0I9ok= +cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM= cloud.google.com/go/gsuiteaddons v1.7.8/go.mod h1:DBKNHH4YXAdd/rd6zVvtOGAJNGo0ekOh+nIjTUDEJ5U= +cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= -cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0= cloud.google.com/go/iap v1.11.3/go.mod h1:+gXO0ClH62k2LVlfhHzrpiHQNyINlEVmGAE3+DB4ShU= +cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0= cloud.google.com/go/ids v1.5.7/go.mod h1:N3ZQOIgIBwwOu2tzyhmh3JDT+kt8PcoKkn2BRT9Qe4A= +cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ= cloud.google.com/go/iot v1.8.7/go.mod h1:HvVcypV8LPv1yTXSLCNK+YCtqGHhq+p0F3BXETfpN+U= +cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s= cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= -cloud.google.com/go/kms v1.33.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA= cloud.google.com/go/language v1.14.6/go.mod h1:7y3J9OexQsfkWNGCxhT+7lb64pa60e12ZCoWDOHxJ1M= +cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04= cloud.google.com/go/lifesciences v0.10.7/go.mod h1:v3AbTki9iWttEls/Wf4ag3EqeLRHofploOcpsLnu7iY= +cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U= cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= +cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA= cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= -cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= cloud.google.com/go/managedidentities v1.7.7/go.mod h1:nwNlMxtBo2YJMvsKXRtAD1bL41qiCI9npS7cbqrsJUs= +cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA= cloud.google.com/go/maps v1.29.0/go.mod h1:FNATcM5ziB2TDE2IVWH4f/yeXc+SbUk1X+bmKjR8HEA= +cloud.google.com/go/maps v1.36.0/go.mod h1:Ly0sd/0G1MgKuWpGc2vCBjNZ+fc8iRHzcBWJqrw7Xao= cloud.google.com/go/mediatranslation v0.9.7/go.mod h1:mz3v6PR7+Fd/1bYrRxNFGnd+p4wqdc/fyutqC5QHctw= +cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ= cloud.google.com/go/memcache v1.11.7/go.mod h1:AU1jYlUqCihxapcJ1GGMtlMWDVhzjbfUWBXqsXa4rBg= +cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg= cloud.google.com/go/metastore v1.14.8/go.mod h1:h1XI2LpD4ohJhQYn9TwXqKb5sVt6KSo47ft96SiFF1s= +cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= cloud.google.com/go/networkconnectivity v1.21.0/go.mod h1:XC1UJ+tqBsLWz73dqrMc7kUvdTv0FIxtDGv6YntTBO0= +cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg= cloud.google.com/go/networkmanagement v1.23.0/go.mod h1:QTYCWp5UxUnU280SqF7AX/mf6NhsqKblmLeCALQmx5c= +cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU= cloud.google.com/go/networksecurity v0.11.0/go.mod h1:JLgDsg4tOyJ3eMO8lypjqMftbfd60SJ+P7T+DUmWBsM= +cloud.google.com/go/networksecurity v0.17.0/go.mod h1:NlMistWENBCFt1v748gUn4v9Rk5AVCTDTFs1VSE3JUg= cloud.google.com/go/notebooks v1.12.7/go.mod h1:uR9pxAkKmlNloibMr9Q1t8WhIu4P2JeqJs7c064/0Mo= +cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM= cloud.google.com/go/optimization v1.7.7/go.mod h1:OY2IAlX23o52qwMAZ0w65wibKuV12a4x6IHDTCq6kcU= +cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8= cloud.google.com/go/orchestration v1.11.10/go.mod h1:tz7m1s4wNEvhNNIM3JOMH0lYxBssu9+7si5MCPw/4/0= +cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ= cloud.google.com/go/orgpolicy v1.15.1/go.mod h1:bpvi9YIyU7wCW9WiXL/ZKT7pd2Ovegyr2xENIeRX5q0= +cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ= cloud.google.com/go/osconfig v1.16.0/go.mod h1:PRmLgZ1loD1hGaqnTBww1nETbqcqAvmTQOLYiIZ7Nvk= +cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk= cloud.google.com/go/oslogin v1.14.7/go.mod h1:NB6NqBHfDMwznePdBVX+ILllc1oPCdNSGp5u/WIyndY= +cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc= cloud.google.com/go/phishingprotection v0.9.7/go.mod h1:JTI4HNGyAbWolBoNOoCyCF0e3cqPNrYnlievHU49EwE= +cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k= cloud.google.com/go/policytroubleshooter v1.11.7/go.mod h1:JP/aQ+bUkt4Gz6lQXBi/+A/6nyNRZ0Pvxui5Xl9ieyk= +cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4= cloud.google.com/go/privatecatalog v0.10.8/go.mod h1:BkLHi+rtAGYBt5DocXLytHhF0n6F03Tegxgty40Y7aA= +cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8= +cloud.google.com/go/profiler v0.6.0/go.mod h1:cJV7Qfj0o9PAC7q/xQTkM6qn2FO9So3TFk4P5O5yLis= cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= +cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w= +cloud.google.com/go/pubsub v1.51.0/go.mod h1:NERXf11sd82UV3VnflcUj8POIyQUXT/QwrKlxD8di/I= cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= +cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA= +cloud.google.com/go/pubsub/v2 v2.6.0/go.mod h1:4anqvV/w8Pcgu2tO0qr2XgsF3GXHowzryfQ5gOnVmWY= +cloud.google.com/go/pubsub/v2 v2.6.1/go.mod h1:1y2lZnKfUFPZz0PU4YmXyk4lA11+xmYA42zbC32RkxQ= cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI= cloud.google.com/go/recaptchaenterprise/v2 v2.21.0/go.mod h1:HxQYqZC2/zl2CvKN7jJEv71vEdDi1GMGNUiZxnpiuVI= +cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs= cloud.google.com/go/recommendationengine v0.9.7/go.mod h1:snZ/FL147u86Jqpv1j95R+CyU5NvL/UzYiyDo6UByTM= +cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k= cloud.google.com/go/recommender v1.13.6/go.mod h1:y5/5womtdOaIM3xx+76vbsiA+8EBTIVfWnxHDFHBGJM= +cloud.google.com/go/recommender v1.19.0/go.mod h1:LRh+1HJjLx2kDE3S65AIlG/lvwA0llEFWYPD/QtgoaU= cloud.google.com/go/redis v1.18.3/go.mod h1:x8HtXZbvMBDNT6hMHaQ022Pos5d7SP7YsUH8fCJ2Wm4= +cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw= cloud.google.com/go/resourcemanager v1.10.7/go.mod h1:rScGkr6j2eFwxAjctvOP/8sqnEpDbQ9r5CKwKfomqjs= +cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE= cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw= cloud.google.com/go/retail v1.26.0/go.mod h1:gMfh6s174Mvy1rK4g50J9TH5sRim8px+Krml25kdrqo= +cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk= cloud.google.com/go/run v1.15.0/go.mod h1:rgFHMdAopLl++57vzeqA+a1o2x0/ILZnEacRD6nC0EA= +cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU= cloud.google.com/go/scheduler v1.11.8/go.mod h1:bNKU7/f04eoM6iKQpwVLvFNBgGyJNS87RiFN73mIPik= +cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA= cloud.google.com/go/secretmanager v1.16.0/go.mod h1://C/e4I8D26SDTz1f3TQcddhcmiC3rMEl0S1Cakvs3Q= +cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU= cloud.google.com/go/security v1.19.2/go.mod h1:KXmf64mnOsLVKe8mk/bZpU1Rsvxqc0Ej0A6tgCeN93w= +cloud.google.com/go/security v1.25.0/go.mod h1:xKPO7XBfUtgjfzPJeznEhI0gp/ZRJt/ZbWtuMYMeUDk= cloud.google.com/go/securitycenter v1.38.1/go.mod h1:Ge2D/SlG2lP1FrQD7wXHy8qyeloRenvKXeB4e7zO6z0= +cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U= cloud.google.com/go/servicedirectory v1.12.7/go.mod h1:gOtN+qbuCMH6tj2dqlDY3qQL7w3V0+nkWaZElnJK8Ps= +cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk= cloud.google.com/go/shell v1.8.7/go.mod h1:OTke7qc3laNEW5Jr5OV9VR3IwU5x5VqGOE6705zFex4= +cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY= cloud.google.com/go/spanner v1.88.0/go.mod h1:MzulBwuuYwQUVdkZXBBFapmXee3N+sQrj2T/yup6uEE= +cloud.google.com/go/spanner v1.92.0/go.mod h1:rCDPfWXNX0h+t484r+crCEaaMKbJfoWkHRDKU3H3+oY= cloud.google.com/go/speech v1.30.0/go.mod h1:F2+NJujR8uzDLd6bwy5kgtVycxvEq06nzvzz5eQ/gMo= +cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8= cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/storage v1.62.0/go.mod h1:T5hz3qzcpnxZ5LdKc7y8Tw7lh4v9zeeVyrD/cLJAzZU= +cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= cloud.google.com/go/storagetransfer v1.13.1/go.mod h1:S858w5l383ffkdqAqrAA+BC7KlhCqeNieK3sFf5Bj4Y= +cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4= cloud.google.com/go/talent v1.8.4/go.mod h1:3yukBXUTVFNyKcJpUExW/k5gqEy8qW6OCNj7WdN0MWo= +cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y= cloud.google.com/go/texttospeech v1.16.0/go.mod h1:AeSkoH3ziPvapsuyI07TWY4oGxluAjntX+pF4PJ2jy0= +cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk= cloud.google.com/go/tpu v1.8.4/go.mod h1:ul0cyWSHr6jHGZYElZe6HvQn35VY93RAlwpDiSBRnPA= +cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs= cloud.google.com/go/translate v1.12.7/go.mod h1:wwJp14NZyWvcrFANhIXutXj0pOBkYciBHwSlUOykcjI= +cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU= cloud.google.com/go/video v1.27.1/go.mod h1:xzfAC77B4vtnbi/TT3UUxEjCa/+Ehy5EA8w470ytOig= +cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk= cloud.google.com/go/videointelligence v1.12.7/go.mod h1:XAk5hCMY+GihxJ55jNoMdwdXSNZnCl3wGs2+94gK7MA= +cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4= cloud.google.com/go/vision/v2 v2.9.6/go.mod h1:lJC+vP15D5znJvHQYjEoTKnpToX1L93BUlvBmzM0gyg= +cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ= cloud.google.com/go/vmmigration v1.10.0/go.mod h1:LDztCWEb+RwS1bPg4Xzt0fcJS9kVrFxa3ejhH7OW9vg= +cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY= cloud.google.com/go/vmwareengine v1.3.6/go.mod h1:ps0rb+Skgpt9ppHYC0o5DqtJ5ld2FyS8sAqtbHH8t9s= +cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0= cloud.google.com/go/vpcaccess v1.8.7/go.mod h1:9RYw5bVvk4Z51Rc8vwXT63yjEiMD/l7XyEaDyrNHgmk= +cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8= cloud.google.com/go/webrisk v1.11.2/go.mod h1:yH44GeXz5iz4HFsIlGeoVvnjwnmfbni7Lwj1SelV4f0= +cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc= cloud.google.com/go/websecurityscanner v1.7.7/go.mod h1:ng/PzARaus3Bj4Os4LpUnyYHsbtJky1HbBDmz148v1o= +cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8= cloud.google.com/go/workflows v1.14.3/go.mod h1:CC9+YdVI2Kvp0L58WajHpEfKJxhrtRh3uQ0SYWcmAk4= +cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g= code.dny.dev/ssrf v0.2.0 h1:wCBP990rQQ1CYfRpW+YK1+8xhwUjv189AQ3WMo1jQaI= code.dny.dev/ssrf v0.2.0/go.mod h1:B+91l25OnyaLIeCx0WRJN5qfJ/4/ZTZxRXgm0lj/2w8= cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= @@ -172,34 +286,34 @@ git.sr.ht/~sbinet/gg v0.3.1 h1:LNhjNn8DerC8f9DHLz6lS0YYul/b602DUxDgGkd/Aik= git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/IBM/sarama v1.40.1 h1:lL01NNg/iBeigUbT+wpPysuTYW6roHo6kc1QrffRf0k= github.com/IBM/sarama v1.40.1/go.mod h1:+5OFwA5Du9I6QrznhaMHsuwWdWZNMjaBSIxEWEgKOYE= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -210,62 +324,88 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEV github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/auth0/go-jwt-middleware/v2 v2.2.1 h1:pqxEIwlCztD0T9ZygGfOrw4NK/F9iotnCnPJVADKbkE= github.com/auth0/go-jwt-middleware/v2 v2.2.1/go.mod h1:CSi0tuu0QrALbWdiQZwqFL8SbBhj4e2MJzkvNfjY0Us= github.com/avast/retry-go/v4 v4.3.0 h1:cqI48aXx0BExKoM7XPklDpoHAg7/srPPLAfWG5z62jo= github.com/avast/retry-go/v4 v4.3.0/go.mod h1:bqOlT4nxk4phk9buiQFaghzjpqdchOSwPgjdfdQBtdg= github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= +github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= github.com/aws/aws-sdk-go-v2 v1.43.2/go.mod h1:WEzLKBh/mEjXvx1FtQMWgSxMSTVqxQzjkRtk5fa3wkg= +github.com/aws/aws-sdk-go-v2 v1.44.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg= +github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs= github.com/aws/aws-sdk-go-v2/config v1.32.33/go.mod h1:fGj1iQj2QpIZzp7jE4aQQ+71TE8cd4z9K4+xCd6EqmE= github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= github.com/aws/aws-sdk-go-v2/credentials v1.19.32/go.mod h1:yYJu+6tqKUYZuJSYcpSGjz/6sV/SUaAaKIufnWKx2OU= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33/go.mod h1:xu02847OdZfNr/jAfZpHtyRk0b3v4d0kaoxNHxZGG/w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.40/go.mod h1:vOD3CnPxAdkL6MWZeROkZsTlskklMFfgVFkHzx/oZpY= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.29 h1:1Hbcvm9a/7hBCAM5y5SAvSKyFUsULrMgmS+XBx32u68= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.29/go.mod h1:qIWWBwh4Wp7HU4E8AkMtu9pqHJ6DxmqLFX7zV8ZX4lM= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.31/go.mod h1:e68nsSeAI5H2Yq3v4GiGxljrU+0gUfn1olY7W2Lu9jU= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.36/go.mod h1:bgDHl7/zlHq1A4Uxqb782balW63IF1s8bpSe3QLDZYc= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.38/go.mod h1:fVuMvXXuUvjoc32pcntZUJkxMs3ABu2uuH6mzoHxYTw= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.40/go.mod h1:Dju0WU9SXXHC2MdA8Dj6WVjffDlgTUBLPhFxQRNujtY= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33/go.mod h1:mJk5fmqnF+WUlMdPG37pR2Fh3oh6r8F6ZGUgPKvzu0c= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33/go.mod h1:UZqj4WIdTH+ga8Y/DgpAuy/8cGjM3h7gDCliJYGg2SE= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.34/go.mod h1:tGzj56niKYZBbDIRhwPGDqrULzmWv5b6uBQGqyNaFZw= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14/go.mod h1:Du3llKcwbQvHsTXSLzTOGQz0DTDBMEzdg7DAGu7inrY= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.33/go.mod h1:eZ5jdEpvaaOU8nWWE4cTAJETSEA5FZoWxvNRao4piHY= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM= +github.com/aws/aws-sdk-go-v2/service/kms v1.55.0/go.mod h1:rK4RITSY/qJw3qVJ7p19fceOWuvrisqqOChFkX05n5I= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs= github.com/aws/aws-sdk-go-v2/service/signin v1.5.2/go.mod h1:vN3eb5H8MEAZ4dx0F5Wc9LT8eb3eW7bZZ5BjGJdbw9k= github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg= github.com/aws/aws-sdk-go-v2/service/sso v1.33.2/go.mod h1:0LoIZSUKjdo2BleHfT1hv/jlD33LQS00IrBlzoUsoUQ= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.2/go.mod h1:DThweuz22kiLc7lGHop5vQ9c3bx5W6Azs/YqSHa2fu8= github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= github.com/aws/aws-sdk-go-v2/service/sts v1.45.2/go.mod h1:OgpPvKzsO2Ranjpli/20djMkg6UrV5mw4W3pZpq1Mqo= github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.27.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bmatcuk/doublestar v1.1.1 h1:YroD6BJCZBYx06yYFEWvUuKVWQn3vLLQAVmDmvTSaiQ= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmatcuk/doublestar/v2 v2.0.4 h1:6I6oUiT/sU27eE2OFcWqBhL1SwjyvQuOssxT4a1yidI= github.com/bmatcuk/doublestar/v2 v2.0.4/go.mod h1:QMmcs3H2AUQICWhfzLXz+IYln8lRQmTZRptLie8RgRw= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= @@ -273,6 +413,7 @@ github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1l github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= @@ -286,10 +427,12 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= @@ -297,11 +440,13 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/cockroach-go/v2 v2.3.5 h1:Khtm8K6fTTz/ZCWPzU9Ne3aOW9VyAnj4qIPCJgKtwK0= github.com/cockroachdb/cockroach-go/v2 v2.3.5/go.mod h1:1wNJ45eSXW9AnOc3skntW9ZUZz6gxrQK3cOj3rK+BC8= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= @@ -318,20 +463,15 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= -github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/danielgtaylor/huma/v2 v2.37.3 h1:6Av0Vj45Vk5lDxRVfoO2iPlEdvCvwLc7pl5nbqGOkYM= github.com/danielgtaylor/huma/v2 v2.37.3/go.mod h1:OeHHtCEAaNiuVbAVdYu4IQ0UOmnb4x3yMUOShNlZ53g= github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo= github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= -github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= -github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= -github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c/go.mod h1:mCGGmWkOQvEuLdIRfPIpXViBfpWto4AhwtJlAvo62SQ= -github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= -github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/dlclark/regexp2cg v0.7.2 h1:6UA/sRxbUIYsTVIuR6ZTsmkplgff5W2YZBLMRQiMsZU= github.com/dlclark/regexp2cg v0.7.2/go.mod h1:Gtyx3xhJ8ZXoXJVZ0Cxm0qcnyjZvwUltpXOuz7WkLFw= @@ -353,6 +493,7 @@ github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwB github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= @@ -392,42 +533,40 @@ github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= -github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc= github.com/go-openapi/analysis v0.26.0/go.mod h1:40gERFi/2dyXA1FaqRRLxkv1IlC6X+GPDNd1xrYAjZE= github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= -github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4= -github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8= github.com/go-openapi/loads v0.25.1/go.mod h1:33Hen4tsKXHL45TyYojvfD5fZUFN4O1y4r/XhsRW2zc= github.com/go-openapi/runtime v0.32.3 h1:J7Ycy5DJmhhP1By3NifhRUjnkXTrk21qbeqSULjwX8U= github.com/go-openapi/runtime v0.32.3/go.mod h1:/WTQi0fa5DiGnnCXQKsTkSm15OzJp8Uz3H2t+67TBr4= github.com/go-openapi/runtime v0.32.4/go.mod h1:Bz6keOZw1NX4T6f+m42OoT1MBPDt6Re13dbccHyGH/4= -github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw= +github.com/go-openapi/runtime v0.32.5/go.mod h1:eNPUCk8LAYV803GHJP3xDSNllJQfv+pBlXpFfWqpPO8= github.com/go-openapi/runtime v0.33.1/go.mod h1:Dl5SMVRnJz+d8bX6Y1zxy0QKpqe/ysvVeUEh1nCpEZ4= -github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= -github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= github.com/go-openapi/runtime/server-middleware v0.33.1/go.mod h1:2Gej5fDxqeJxY+w38vxXYW0BgFASfgBsJ5rXwN1Fseg= github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo= github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= -github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= github.com/go-openapi/strfmt v0.26.4/go.mod h1:hNJi6nb5ETD6i7A1yRo03M9S6ZoTPPoWff1iUexmfUc= -github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= +github.com/go-openapi/swag/cmdutils v0.26.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/netutils v0.26.1/go.mod h1:y02vByhZhQPAVwOX+0KipXFZ/hUbk6G/Enhf5rGaOkQ= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4= github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA= -github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= github.com/go-openapi/validate v0.26.3/go.mod h1:7DOOa4raU6NRe7A8VQSKbm3VcuUIioREYHFt+er9Sk8= github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= +github.com/go-redis/redismock/v9 v9.2.0/go.mod h1:18KHfGDK4Y6c2R0H38EUGWAdc7ZQS9gfYxc94k7rWT0= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= @@ -448,7 +587,9 @@ github.com/gobuffalo/packd v1.0.2 h1:Yg523YqnOxGIWCp69W12yYBKsoChwI7mtu6ceM9Bwfw github.com/gobuffalo/plush/v4 v4.1.18 h1:bnPjdMTEUQHqj9TNX2Ck3mxEXYZa+0nrFMNM07kpX9g= github.com/gobuffalo/tags/v3 v3.1.4 h1:X/ydLLPhgXV4h04Hp2xlbI2oc5MDaa7eub6zw8oHjsM= github.com/gobuffalo/validate/v3 v3.3.3 h1:o7wkIGSvZBYBd6ChQoLxkz2y1pfmhbI4jNJYh6PuNJ4= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -470,11 +611,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= -github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= -github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -483,6 +620,7 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= github.com/google/go-jsonnet v0.20.0 h1:WG4TTSARuV7bSm4PMB4ohjxe33IHT5WVTrJSU33uT4g= github.com/google/go-jsonnet v0.20.0/go.mod h1:VbgWF9JX7ztlv770x/TolZNGGFfiHEVx9G6ca2eUmeA= github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= @@ -490,13 +628,15 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= +github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= -github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/enterprise-certificate-proxy v0.3.5/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= @@ -504,7 +644,9 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7 github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= @@ -513,7 +655,6 @@ github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOID github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= -github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -522,21 +663,17 @@ github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpS github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0 h1:FbSCl+KggFl+Ocym490i/EyXF4lPgLoUtcSWquBM0Rs= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= -github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= -github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= -github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= +github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= github.com/itchyny/go-yaml v0.0.0-20251001235044-fca9a0999f15 h1:m4jKsIK0QS9ihQzOxUN2zJcPdrACwqIWCwvdzv9skMQ= @@ -572,25 +709,27 @@ github.com/jcmturner/gokrb5/v8 v8.4.3 h1:iTonLeSJOn7MVUtyMT+arAn5AKAPrkilzhGw8wE github.com/jcmturner/gokrb5/v8 v8.4.3/go.mod h1:dqRwJGXznQrzw6cWmyo6kH+E7jksEQG/CyVWsJEsJO0= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7/go.mod h1:BMxO138bOokdgt4UaxZiEfypcSHX0t6SIFimVP1oRfk= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= -github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18= github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w= @@ -618,15 +757,16 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno= github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= -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/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= @@ -641,10 +781,9 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= -github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= @@ -662,15 +801,15 @@ github.com/ory/dockertest/v3 v3.10.1-0.20240704115616-d229e74b748d h1:By96ZSVuH5 github.com/ory/dockertest/v3 v3.10.1-0.20240704115616-d229e74b748d/go.mod h1:F2FIjwwAk6CsNAs//B8+aPFQF0t84pbM8oliyNXwQrk= github.com/parnurzeal/gorequest v0.2.15 h1:oPjDCsF5IkD4gUk6vIgsxYNaSgvAnIh1EJeROn3HdJU= github.com/parnurzeal/gorequest v0.2.15/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE= -github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= -github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= -github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= @@ -695,45 +834,39 @@ github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+ github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= -github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI= github.com/sassoftware/relic/v8 v8.2.0/go.mod h1:pZy7hLT9WCOKPonV8G/fplvtBLOZd6/kWtKqeHR6nKc= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= -github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= -github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= github.com/secure-systems-lab/go-securesystemslib v0.11.1/go.mod h1:UyOjhoZLi76ir63u1ptubEevVe6dlNPmNxwgVvKqFFY= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/backo-go v1.0.1 h1:68RQccglxZeyURy93ASB/2kc9QudzgIDexJ927N++y4= github.com/segmentio/backo-go v1.0.1/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= -github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= -github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= -github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= -github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= github.com/sigstore/protobuf-specs v0.5.2/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= github.com/sigstore/rekor v1.5.2 h1:k6pX4o1zFAzAvDbXiVIp5IHj1b0wcDaxsbsbNpuRO8o= github.com/sigstore/rekor v1.5.2/go.mod h1:WkMnITBccOFauPkT6yte74tF5gC83pefKRGZvNOsbjI= -github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= github.com/sigstore/rekor v1.5.4/go.mod h1:0bcUHhZPlG+RVW1dI/7sdBp0MiVWVzk9h0tgOH/o0Yc= github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 h1:/CO8F6m3Bo/f59bZo5dv1sTIfUnQqVnepIdDV24KoDw= github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443/go.mod h1:w1h8wF8vq9lHjmtRdwJiEaoVxhP+WHIMpj4M39pkzp0= -github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= -github.com/sigstore/sigstore v1.10.9/go.mod h1:LYW9+qH7bK8wZmLm6lPxIC5lkHtkJDCgkqjChzTAIBs= github.com/sigstore/sigstore-go v1.2.1 h1:YWP/rDbBaEBvtbkj6xtwsSj38ZCFEhTVVadNOXjVe3A= github.com/sigstore/sigstore-go v1.2.1/go.mod h1:I8BqVwAb/SaQJ5pBu5IDFY+ksq8O/1/kCag8XUgrsko= github.com/sigstore/sigstore-go v1.2.2/go.mod h1:MIFwBxAHJD+/lKgZzt9n/4Zhq/3T2+EuGX8iGrIsZgU= -github.com/sigstore/sigstore-go v1.3.0/go.mod h1:AyRQXfpH89py1twjE3kEZxlRersng90GSYqQV9zGJE8= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.9/go.mod h1:2shgYiPHTzeb5NNEu6okco+LOojMS5n9p/5Wdp0jhGI= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.9/go.mod h1:e7QngtHib0McuP1C/+Dt9f/7a8ic8FXvPB75OQzWgU4= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.9/go.mod h1:d6oIwQ0YiYHMcFO4xqFcAx5njXwIf0dm5KfdytOar8A= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.9/go.mod h1:o7u5bQNLqpDQFaST29dpo6P6ihMV+wicwqfMmMW6H3c= github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= -github.com/sigstore/timestamp-authority/v2 v2.1.3/go.mod h1:myoFOKJB/u5vNTFwvBBJVkG3NnOBeIJevbfjNeasLjo= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= @@ -749,39 +882,41 @@ github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GB github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stacklok/toolhive-catalog v0.20260623.0 h1:x8RqKYJPjPbW/gFyFf7HRhcdC1qPwbBEmP5wybSurng= github.com/stacklok/toolhive-catalog v0.20260623.0/go.mod h1:byRwPueB1c9E6UWyZfQUyHmfZjzR5IKnsmVmcZQZyPs= github.com/stacklok/toolhive-catalog v0.20260716.0/go.mod h1:G33nbS4PplpBl7lk/hPiIYpsmBl5OATvpohpXJujSfg= github.com/stacklok/toolhive-catalog v0.20260824.0/go.mod h1:7l8t1m10oDUtsGroq88TvSJvyBhADvTEF2n5IBedydA= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4= github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE= github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk= github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI= -github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef h1:jJac5InhEfD0Z46/d5RayZjoavf/se7bPZpOgg8GLrM= github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef/go.mod h1:cLUSJ2cgR194lNWfp+TJT4P8PX7qGleCXdudqlCMtOE= -github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.4.0/go.mod h1:wDDAhjfd1t4TjeJCSsmFK7CARPUs/ITi16ZZ8kmAaFQ= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= -github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= -github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= -github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= -github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zenazn/goji v0.9.0 h1:RSQQAbXGArQ0dIDEq+PI6WqN6if+5KHu6x2Cx/GXLTQ= +go.einride.tech/aip v0.83.0/go.mod h1:E8+wdTApA70odnpFzJgsGogHozC2JCIhFJBKPr8bVig= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/etcd/api/v3 v3.6.5 h1:pMMc42276sgR1j1raO/Qv3QI9Af/AuyQUW6CBAWuntA= @@ -803,6 +938,7 @@ go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= @@ -810,7 +946,8 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.4 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= @@ -818,14 +955,18 @@ go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= @@ -845,15 +986,16 @@ go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.starlark.net v0.0.0-20260522144826-ec58d4b459e2 h1:3cIeOhZXLdLHnBoLyKdJu4SEAEuM/av/VFzB5twLo8k= go.starlark.net v0.0.0-20260522144826-ec58d4b459e2/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= -go.starlark.net v0.0.0-20260630144053-529d8e869a14 h1:w4krPU6GwY87DdwH30fr/GygfD3ROuoBq3YWilzFe6A= -go.starlark.net v0.0.0-20260630144053-529d8e869a14/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.step.sm/crypto v0.87.0/go.mod h1:gBr1mpMiKs5804/Yt03uAoOBiw3DFxARZnGUKEUO82M= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= @@ -876,7 +1018,7 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -915,11 +1057,16 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -958,7 +1105,6 @@ golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -975,7 +1121,9 @@ google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqc google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/api v0.287.1/go.mod h1:lM2kYRzYUCBY91P9h6VF1PYmvhxii3O5hji37qRvIcY= +google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/api v0.285.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= +google.golang.org/api v0.290.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= @@ -983,11 +1131,14 @@ google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:O9kGHb51iE/nOGvQaDUuadVYqovW56s5emA88lQnj6Y= google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc= google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:RRHjglSYABVCWpQ7USCpdfhcd9t4PkajvVwyynZizTc= +google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY= +google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA= +google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= google.golang.org/genproto/googleapis/api v0.0.0-20240429193739-8cf5692501f6/go.mod h1:10yRODfgim2/T8csjQsMPgZOMvtytXKTDRzH6HRGzRw= google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I= google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= @@ -998,10 +1149,15 @@ google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go. google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= google.golang.org/genproto/googleapis/api v0.0.0-20260316172706-e463d84ca32d/go.mod h1:X2gu9Qwng7Nn009s/r3RUxqkzQNqOrAy79bluY7ojIg= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= +google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= +google.golang.org/genproto/googleapis/api v0.0.0-20260727163830-6c54dddc4772/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= google.golang.org/genproto/googleapis/bytestream v0.0.0-20260630182238-925bb5da69e7/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20260706201446-f0a921348800/go.mod h1:MS5Wgu4uvm+nhCBwXzKEpYKPawaaNNUT7WfgpH44YVQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= @@ -1016,10 +1172,16 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401001100-f93e5f3e9f0f/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260818201246-1b0934165a6f/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= @@ -1035,6 +1197,7 @@ google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpW google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= @@ -1058,6 +1221,7 @@ gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec h1:RlWgLqCMMIYYEVcAR5MDsuHlVkaIPDAF+5Dehzg8L5A= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -1074,5 +1238,14 @@ k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYo k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= k8s.io/kms v0.35.0 h1:/x87FED2kDSo66csKtcYCEHsxF/DBlNl7LfJ1fVQs1o= k8s.io/kms v0.35.0/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/release-utils v0.12.4/go.mod h1:Tc3iM9DVM3W9oJu/6rEI+LnREuhy8lZ7wInQhRBtUoo= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/adapter/forker/router.go b/internal/adapter/forker/router.go new file mode 100644 index 000000000..660b9d31a --- /dev/null +++ b/internal/adapter/forker/router.go @@ -0,0 +1,87 @@ +package forker + +import ( + "context" + "errors" + "fmt" + + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" +) + +// ErrUnsupportedEnvironmentKind prevents an external/remote Environment from +// silently falling through to the host-local forker. +var ErrUnsupportedEnvironmentKind = errors.New("unsupported environment kind for child fork") + +// KindRouter selects the child-environment backend from the parent's durable +// EnvironmentRef kind. The local fallback is deliberately restricted to the +// in-tree local and memory kinds; an unregistered external kind fails closed. +type KindRouter struct { + local tool.EnvironmentForker + byKind map[session.EnvironmentKind]tool.EnvironmentForker +} + +// KindMergerRouter selects merge backend from the parent environment kind. +type KindMergerRouter struct { + local tool.EnvironmentMerger + byKind map[session.EnvironmentKind]tool.EnvironmentMerger +} + +// NewKindMergerRouter constructs a fail-closed kind-aware merger. +func NewKindMergerRouter(local tool.EnvironmentMerger, byKind map[session.EnvironmentKind]tool.EnvironmentMerger) *KindMergerRouter { + registered := make(map[session.EnvironmentKind]tool.EnvironmentMerger, len(byKind)) + for kind, merger := range byKind { + if kind != "" && merger != nil { + registered[kind] = merger + } + } + return &KindMergerRouter{local: local, byKind: registered} +} + +var _ tool.EnvironmentMerger = (*KindMergerRouter)(nil) + +// Merge routes by parent kind and requires child and parent to share that kind. +func (r *KindMergerRouter) Merge(ctx context.Context, child, parent tool.Environment) error { + kind := parent.Ref().Kind + if child.Ref().Kind != kind { + return fmt.Errorf("%w: child %q parent %q", ErrUnsupportedEnvironmentKind, child.Ref().Kind, kind) + } + if r != nil { + if selected := r.byKind[kind]; selected != nil { + return selected.Merge(ctx, child, parent) + } + if (kind == session.EnvKindLocal || kind == session.EnvKindMem) && r.local != nil { + return r.local.Merge(ctx, child, parent) + } + } + return fmt.Errorf("%w: %q", ErrUnsupportedEnvironmentKind, kind) +} + +// NewKindRouter constructs a kind-aware delegation forker. The registrations +// are copied so caller mutation cannot change routing while children are live. +func NewKindRouter(local tool.EnvironmentForker, byKind map[session.EnvironmentKind]tool.EnvironmentForker) *KindRouter { + registered := make(map[session.EnvironmentKind]tool.EnvironmentForker, len(byKind)) + for kind, childForker := range byKind { + if kind != "" && childForker != nil { + registered[kind] = childForker + } + } + return &KindRouter{local: local, byKind: registered} +} + +var _ tool.EnvironmentForker = (*KindRouter)(nil) + +// Fork routes from base.Ref().Kind. Unknown remote kinds never use the host +// filesystem fallback, which would break Workspace/runner namespace affinity. +func (r *KindRouter) Fork(ctx context.Context, base tool.Environment, label string) (tool.Environment, func() error, string, error) { + kind := base.Ref().Kind + if r != nil { + if selected := r.byKind[kind]; selected != nil { + return selected.Fork(ctx, base, label) + } + if (kind == session.EnvKindLocal || kind == session.EnvKindMem) && r.local != nil { + return r.local.Fork(ctx, base, label) + } + } + return tool.Environment{}, nil, "", fmt.Errorf("%w: %q", ErrUnsupportedEnvironmentKind, kind) +} diff --git a/internal/adapter/forker/router_test.go b/internal/adapter/forker/router_test.go new file mode 100644 index 000000000..1ac5b38e5 --- /dev/null +++ b/internal/adapter/forker/router_test.go @@ -0,0 +1,81 @@ +package forker_test + +import ( + "context" + "errors" + "testing" + + "github.com/stacklok/mecatl/engine/adapter/memfs" + "github.com/stacklok/mecatl/engine/adapter/memledger" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/internal/adapter/forker" +) + +func TestKindMergerRouterRoutesByParentEnvironmentRefKind(t *testing.T) { + t.Parallel() + local := &routingMerger{} + microVM := &routingMerger{} + router := forker.NewKindMergerRouter(local, map[session.EnvironmentKind]tool.EnvironmentMerger{"microvm": microVM}) + for _, tc := range []struct { + kind session.EnvironmentKind + want *routingMerger + }{{session.EnvKindLocal, local}, {"microvm", microVM}} { + parent := tool.MustEnvironment(session.EnvironmentRef{Kind: tc.kind, ID: "parent"}, memfs.NewWorkspace("/parent"), memledger.New(), nil) + child := tool.MustEnvironment(session.EnvironmentRef{Kind: tc.kind, ID: "child"}, memfs.NewWorkspace("/child"), memledger.New(), nil) + if err := router.Merge(context.Background(), child, parent); err != nil { + t.Fatalf("Merge(%q): %v", tc.kind, err) + } + if tc.want.calls != 1 { + t.Fatalf("Merge(%q) calls = %d", tc.kind, tc.want.calls) + } + } +} + +type routingMerger struct{ calls int } + +func (m *routingMerger) Merge(context.Context, tool.Environment, tool.Environment) error { + m.calls++ + return nil +} + +func TestKindRouterRoutesDelegationByEnvironmentRefKind(t *testing.T) { + t.Parallel() + local := &routingForker{id: "local"} + microVM := &routingForker{id: "microvm"} + router := forker.NewKindRouter(local, map[session.EnvironmentKind]tool.EnvironmentForker{ + "microvm": microVM, + }) + + for _, tc := range []struct { + kind session.EnvironmentKind + want string + }{ + {kind: session.EnvKindLocal, want: "local"}, + {kind: "microvm", want: "microvm"}, + } { + base := tool.MustEnvironment(session.EnvironmentRef{Kind: tc.kind, ID: "parent"}, memfs.NewWorkspace("/parent"), memledger.New(), nil) + child, cleanup, _, err := router.Fork(context.Background(), base, "child") + if err != nil { + t.Fatalf("Fork(%q): %v", tc.kind, err) + } + if child.Ref().ID != tc.want { + t.Fatalf("Fork(%q) child ref = %q, want route %q", tc.kind, child.Ref().ID, tc.want) + } + if err := cleanup(); err != nil { + t.Fatalf("cleanup: %v", err) + } + } + + unknown := tool.MustEnvironment(session.EnvironmentRef{Kind: "unknown-remote", ID: "parent"}, memfs.NewWorkspace("/parent"), memledger.New(), nil) + if _, _, _, err := router.Fork(context.Background(), unknown, "child"); !errors.Is(err, forker.ErrUnsupportedEnvironmentKind) { + t.Fatalf("unknown remote kind error = %v, want ErrUnsupportedEnvironmentKind", err) + } +} + +type routingForker struct{ id string } + +func (f *routingForker) Fork(_ context.Context, base tool.Environment, _ string) (tool.Environment, func() error, string, error) { + child := tool.MustEnvironment(session.EnvironmentRef{Kind: base.Ref().Kind, ID: f.id}, memfs.NewWorkspace("/"+f.id), memledger.New(), nil) + return child, func() error { return nil }, "", nil +} diff --git a/internal/adapter/microvm/client.go b/internal/adapter/microvm/client.go new file mode 100644 index 000000000..41e8aeff1 --- /dev/null +++ b/internal/adapter/microvm/client.go @@ -0,0 +1,851 @@ +// Package microvm provides the root module's thin UDS adapter for microvmd. +// It deliberately duplicates only the versioned JSON wire shapes and does not +// import the nested runtime module. +package microvm + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/stacklok/mecatl/engine/adapter/memledger" + "github.com/stacklok/mecatl/engine/adapter/nofs" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/internal/adapter/server" +) + +const ( + // DaemonProtocolVersion is the exact local management protocol spoken by this client. + DaemonProtocolVersion uint16 = 3 + protocolVersion = DaemonProtocolVersion + maxMessageBytes = 1 << 20 + maxInventoryPageSize = 64 + defaultInventoryPageSize = 50 + maxInventoryTokenBytes = 1024 + resolvePhaseTimeout = 15 * time.Second + kindMicroVM = session.EnvironmentKind("microvm") + publicGuestRoot = "/workspace" + repositoryLogicalRootUnavailableCategory = "repository_logical_root_unavailable" +) + +type repositoryLogicalRootUnavailableError struct{} + +func (repositoryLogicalRootUnavailableError) Error() string { + return "microvmd: repository logical root is unavailable" +} +func (repositoryLogicalRootUnavailableError) EnvironmentLifecycleCategory() string { + return repositoryLogicalRootUnavailableCategory +} + +// ErrRepositoryLogicalRootUnavailable is returned when an authenticated guest +// cannot attach the prepared repository worktree. It carries no daemon detail. +var ErrRepositoryLogicalRootUnavailable error = repositoryLogicalRootUnavailableError{} + +type binding struct { + Owner string `json:"owner"` + SessionID string `json:"session_id"` + EnvironmentID string `json:"environment_id"` + Ref string `json:"ref"` + Generation uint32 `json:"generation"` +} + +type provisionRequest struct { + Owner string `json:"owner"` + SessionID string `json:"session_id"` + Profile string `json:"profile"` + SourceCheckout string `json:"source_checkout"` +} + +type lifecycleRequest struct { + Version uint16 `json:"version"` + Operation string `json:"operation"` + Binding binding `json:"binding"` + Provision *provisionRequest `json:"provision,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +type environmentRef struct { + Kind string + ID string +} +type created struct { + Ref environmentRef `json:"ref"` + Generation uint32 `json:"generation"` + HostWorktree string `json:"host_worktree"` + GuestRoot string `json:"guest_root"` + Profile string `json:"profile"` + GuestEgress string `json:"guest_egress"` + HostEgress string `json:"host_egress"` +} +type execStreamFrame struct { + Channel string `json:"channel"` + Data []byte `json:"data"` +} + +type lifecycleResponse struct { + Binding binding `json:"binding,omitempty"` + Record json.RawMessage `json:"record,omitempty"` + Created *created `json:"created,omitempty"` + Stream *execStreamFrame `json:"stream,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorText string `json:"error,omitempty"` +} + +// Client speaks the authenticated local management protocol. Authentication is +// performed by microvmd from kernel peer credentials before it decodes a frame. +type Client struct { + endpoint string + sourceCheckout string + profile string + scope server.PlacementScope + readiness func(context.Context) error +} + +// New validates endpoint and constructs a thin lifecycle client. +func New(endpoint string) (*Client, error) { + u, err := url.Parse(endpoint) + if err != nil || u.Scheme != "unix" || u.Host != "" || u.Path == "" { + return nil, errors.New("microvmd endpoint must be an absolute unix:// path") + } + return &Client{endpoint: u.Path}, nil +} + +// NewPlacementProvider constructs a deployment-owned microVM default placement. +func NewPlacementProvider(endpoint, sourceCheckout, profile string, scope server.PlacementScope, readiness func(context.Context) error) (*Client, error) { + client, err := New(endpoint) + if err != nil { + return nil, err + } + if sourceCheckout == "" || profile == "" || scope == "" { + return nil, errors.New("microvm placement requires source checkout, profile, and scope") + } + client.sourceCheckout = sourceCheckout + client.profile = profile + client.scope = scope + client.readiness = readiness + return client, nil +} + +// Bind provisions either the trusted deployment default or explicit no-FS attenuation. +func (c *Client) Bind(ctx context.Context, request server.PlacementBindRequest) (server.PlacementBinding, error) { + if request.Scope != c.scope { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + if request.Selector.IsNoFS() { + return noFSBinding() + } + if !request.Selector.IsDefault() { + return server.PlacementBinding{}, server.ErrInvalidPlacementSelection + } + if c.readiness != nil { + if err := c.readiness(ctx); err != nil { + return server.PlacementBinding{}, fmt.Errorf("microvm readiness: %w", err) + } + } + return c.provisionPlacement(ctx, request.Principal) +} + +// Reattach restores only the exact persisted microVM generation. +func (c *Client) Reattach(ctx context.Context, request server.PlacementReattachRequest) (server.PlacementBinding, error) { + if request.Scope != c.scope { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + if request.Ref.Kind == session.EnvKindNoFS { + binding, err := noFSBinding() + if err != nil || binding.Ref != request.Ref { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + return binding, nil + } + claim, err := bindingForRef(request.Ref, request.Principal) + if err != nil { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + resolveCtx, cancelResolve := context.WithTimeoutCause(ctx, resolvePhaseTimeout, errors.New("microvmd resolve phase timed out")) + defer cancelResolve() + response, err := c.call(resolveCtx, lifecycleRequest{ + Version: protocolVersion, Operation: "resolve", Binding: claim, + Provision: &provisionRequest{Owner: claim.Owner, SessionID: claim.SessionID, Profile: c.profile, SourceCheckout: c.sourceCheckout}, + }) + if err != nil { + return server.PlacementBinding{}, fmt.Errorf("microvmd resolve phase failed: %w", err) + } + if response.Binding != claim { + return server.PlacementBinding{}, errors.New("microvmd resolved a different environment generation") + } + return c.placementBinding(request.Ref, claim, nil), nil +} + +func noFSBinding() (server.PlacementBinding, error) { + ref := session.EnvironmentRef{Kind: session.EnvKindNoFS, ID: "no-fs", Revision: "nofs-v1"} + env, err := tool.NewEnvironment(ref, nofs.New(), memledger.New(), nil) + if err != nil { + return server.PlacementBinding{}, err + } + return server.PlacementBinding{Ref: ref, Environment: env, Metadata: server.PlacementMetadata{Label: "No filesystem"}}, nil +} + +func (c *Client) provisionPlacement(ctx context.Context, principal *session.Principal) (server.PlacementBinding, error) { + var entropy [16]byte + if _, err := rand.Read(entropy[:]); err != nil { + return server.PlacementBinding{}, fmt.Errorf("mint microvm placement identity: %w", err) + } + placementID := hex.EncodeToString(entropy[:]) + owner := ownerName(principal) + response, err := c.call(ctx, lifecycleRequest{ + Version: protocolVersion, Operation: "create", Binding: binding{Owner: owner, SessionID: placementID}, + Provision: &provisionRequest{Owner: owner, SessionID: placementID, Profile: c.profile, SourceCheckout: c.sourceCheckout}, + }) + if err != nil { + return server.PlacementBinding{}, err + } + if response.Created == nil || response.Created.Profile != c.profile || response.Created.GuestEgress == "" || response.Created.HostEgress == "" { + return server.PlacementBinding{}, errors.New("microvmd create returned incomplete placement metadata") + } + if response.Binding.Owner != owner || response.Binding.SessionID != placementID || response.Binding.EnvironmentID == "" || response.Binding.Generation == 0 { + return server.PlacementBinding{}, errors.New("microvmd create binding mismatch") + } + ref := refForBinding(response.Binding) + closePlacement := func() error { return c.operate(context.WithoutCancel(ctx), "detach", response.Binding) } + return c.placementBinding(ref, response.Binding, closePlacement), nil +} + +func (c *Client) placementBinding(ref session.EnvironmentRef, claim binding, closePlacement func() error) server.PlacementBinding { + return server.PlacementBinding{ + Ref: ref, Environment: c.environment(ref, claim), Close: closePlacement, + Metadata: server.PlacementMetadata{Kind: string(kindMicroVM), Label: "Local microVM", Revision: ref.Revision}, + } +} + +func refForBinding(claim binding) session.EnvironmentRef { + return session.EnvironmentRef{Kind: kindMicroVM, ID: claim.SessionID + "." + claim.EnvironmentID, Revision: strconv.FormatUint(uint64(claim.Generation), 10)} +} + +func bindingForRef(ref session.EnvironmentRef, principal *session.Principal) (binding, error) { + if ref.Kind != kindMicroVM || ref.ID == "" || ref.Revision == "" { + return binding{}, errors.New("invalid microvm environment ref") + } + dot := strings.IndexByte(ref.ID, '.') + if dot <= 0 || dot == len(ref.ID)-1 { + return binding{}, errors.New("invalid microvm environment identity") + } + generation, err := strconv.ParseUint(ref.Revision, 10, 32) + if err != nil || generation == 0 { + return binding{}, errors.New("invalid microvm environment generation") + } + sessionID, environmentID := ref.ID[:dot], ref.ID[dot+1:] + return binding{Owner: ownerName(principal), SessionID: sessionID, EnvironmentID: environmentID, Ref: environmentID + "@" + ref.Revision, Generation: uint32(generation)}, nil +} + +// DaemonInfo is the authenticated identity of the process serving the configured socket. +type DaemonInfo struct { + ProtocolVersion uint16 `json:"protocol_version"` + ReleaseIdentity string `json:"release_identity"` + BinaryIdentity string `json:"binary_identity"` + ConfigDigest string `json:"config_digest"` + PolicyRevision string `json:"policy_revision"` + Profiles []string `json:"profiles"` + Socket string `json:"socket"` +} + +// Equal reports an exact daemon compatibility match. +func (d DaemonInfo) Equal(other DaemonInfo) bool { + if d.ProtocolVersion != other.ProtocolVersion || d.ReleaseIdentity != other.ReleaseIdentity || d.BinaryIdentity != other.BinaryIdentity || d.ConfigDigest != other.ConfigDigest || d.PolicyRevision != other.PolicyRevision || d.Socket != other.Socket || len(d.Profiles) != len(other.Profiles) { + return false + } + for i := range d.Profiles { + if d.Profiles[i] != other.Profiles[i] { + return false + } + } + return true +} + +// DaemonInfo queries the serving process over the kernel-authenticated Unix socket. +func (c *Client) DaemonInfo(ctx context.Context) (DaemonInfo, error) { + response, err := c.call(ctx, lifecycleRequest{Version: protocolVersion, Operation: "info"}) + if err != nil { + return DaemonInfo{}, err + } + var info DaemonInfo + if len(response.Payload) == 0 { + return info, errors.New("microvmd info response omitted payload") + } + if err := json.Unmarshal(response.Payload, &info); err != nil { + return info, fmt.Errorf("decode microvmd info: %w", err) + } + if info.ProtocolVersion != protocolVersion || info.ReleaseIdentity == "" || info.BinaryIdentity == "" || info.ConfigDigest == "" || info.PolicyRevision == "" || len(info.Profiles) == 0 || info.Socket != c.endpoint { + return DaemonInfo{}, errors.New("microvmd returned incomplete or mismatched daemon identity") + } + return info, nil +} + +// LifecycleMetrics is the bounded operator snapshot exposed by microvmd. The +// root adapter projects only counters needed by host-side operational checks. +type LifecycleMetrics struct { + EgressDenials uint64 +} + +// GenerationHealth is microvmd's bounded operator health classification. +type GenerationHealth string + +const ( + // GenerationHealthy means the daemon verified the exact live runtime identity. + GenerationHealthy GenerationHealth = "healthy" + // GenerationStale means the durable generation is not currently reattachable. + GenerationStale GenerationHealth = "stale" + // GenerationError means the runtime health probe itself failed. + GenerationError GenerationHealth = "error" +) + +// InventoryEntry is one owner-filtered exact daemon generation. +type InventoryEntry struct { + Owner string `json:"owner"` + SessionID string `json:"session_id"` + EnvironmentID string `json:"environment_id"` + Ref string `json:"ref"` + WorktreePath string `json:"worktree_path"` + Generation uint32 `json:"generation"` + State string `json:"state"` + Health GenerationHealth `json:"health"` + Error string `json:"error,omitempty"` +} + +// GenerationBinding binds a destructive operation to one exact owner generation. +type GenerationBinding struct { + Owner string `json:"owner"` + SessionID string `json:"session_id"` + EnvironmentID string `json:"environment_id"` + Ref string `json:"ref"` + Generation uint32 `json:"generation"` +} + +// DeleteResult reports clean removal or the retained dirty worktree. +type DeleteResult struct { + WorktreePath string `json:"worktree_path"` + WorktreeRetained bool `json:"worktree_retained"` +} + +// InventoryRequest asks for one bounded owner-scoped page. +type InventoryRequest struct { + PageSize int `json:"page_size,omitempty"` + Continuation string `json:"continuation,omitempty"` +} + +// InventoryPage carries one bounded inventory page and its opaque continuation. +type InventoryPage struct { + Entries []InventoryEntry `json:"entries"` + Continuation string `json:"continuation,omitempty"` +} + +// Inventory retrieves one bounded inventory page visible to owner. +func (c *Client) Inventory(ctx context.Context, owner string, requests ...InventoryRequest) (InventoryPage, error) { + if owner == "" { + return InventoryPage{}, errors.New("microvmd inventory owner is required") + } + request, err := normalizeInventoryRequest(requests) + if err != nil { + return InventoryPage{}, err + } + payload, err := json.Marshal(request) + if err != nil { + return InventoryPage{}, err + } + response, err := c.call(ctx, lifecycleRequest{Version: protocolVersion, Operation: "inventory", Binding: binding{Owner: owner}, Payload: payload}) + if err != nil { + return InventoryPage{}, err + } + var page InventoryPage + if len(response.Payload) == 0 { + return page, errors.New("microvmd inventory response omitted payload") + } + if err := json.Unmarshal(response.Payload, &page); err != nil { + return page, fmt.Errorf("decode microvmd inventory: %w", err) + } + if err := validateInventoryPage(page, request, owner); err != nil { + return InventoryPage{}, err + } + return page, nil +} + +func normalizeInventoryRequest(requests []InventoryRequest) (InventoryRequest, error) { + if len(requests) > 1 { + return InventoryRequest{}, errors.New("microvmd inventory accepts one page request") + } + request := InventoryRequest{PageSize: defaultInventoryPageSize} + if len(requests) == 1 { + request = requests[0] + } + if request.PageSize <= 0 { + request.PageSize = defaultInventoryPageSize + } + if request.PageSize > maxInventoryPageSize { + request.PageSize = maxInventoryPageSize + } + if len(request.Continuation) > maxInventoryTokenBytes { + return InventoryRequest{}, errors.New("microvmd inventory continuation exceeds client bound") + } + return request, nil +} + +func validateInventoryPage(page InventoryPage, request InventoryRequest, owner string) error { + if len(page.Entries) > request.PageSize || len(page.Entries) > maxInventoryPageSize || len(page.Continuation) > maxInventoryTokenBytes || + (len(page.Entries) == 0 && page.Continuation != "") || (page.Continuation == request.Continuation && page.Continuation != "") { + return errors.New("microvmd inventory returned an invalid bounded page") + } + for _, entry := range page.Entries { + if entry.Owner != owner || entry.SessionID == "" || entry.EnvironmentID == "" || entry.Ref == "" || entry.Generation == 0 || entry.WorktreePath == "" || + (entry.Health != GenerationHealthy && entry.Health != GenerationStale && entry.Health != GenerationError) { + return errors.New("microvmd inventory returned an invalid or cross-owner generation") + } + } + return nil +} + +// Reconcile repairs durable daemon state without creating a generation. +func (c *Client) Reconcile(ctx context.Context, owner string) error { + if owner == "" { + return errors.New("microvmd reconcile owner is required") + } + _, err := c.call(ctx, lifecycleRequest{Version: protocolVersion, Operation: "reconcile", Binding: binding{Owner: owner}}) + return err +} + +// DeleteGeneration permanently deletes one exact owner/session/ref/generation binding. +func (c *Client) DeleteGeneration(ctx context.Context, claim GenerationBinding) (DeleteResult, error) { + if claim.Owner == "" || claim.SessionID == "" || claim.EnvironmentID == "" || claim.Ref == "" || claim.Generation == 0 { + return DeleteResult{}, errors.New("microvmd delete requires a complete generation binding") + } + response, err := c.call(ctx, lifecycleRequest{Version: protocolVersion, Operation: "delete", Binding: binding(claim)}) + if err != nil { + return DeleteResult{}, err + } + var result DeleteResult + if len(response.Payload) == 0 { + return result, errors.New("microvmd delete response omitted cleanup result") + } + if err := json.Unmarshal(response.Payload, &result); err != nil { + return result, fmt.Errorf("decode microvmd delete result: %w", err) + } + return result, nil +} + +// LifecycleMetrics retrieves the current daemon lifecycle counters without binding the +// request to one environment generation. +func (c *Client) LifecycleMetrics(ctx context.Context) (LifecycleMetrics, error) { + response, err := c.call(ctx, lifecycleRequest{Version: protocolVersion, Operation: "metrics"}) + if err != nil { + return LifecycleMetrics{}, err + } + var metrics LifecycleMetrics + if len(response.Payload) == 0 { + return LifecycleMetrics{}, errors.New("microvmd metrics response omitted payload") + } + if err := json.Unmarshal(response.Payload, &metrics); err != nil { + return LifecycleMetrics{}, fmt.Errorf("decode microvmd lifecycle metrics: %w", err) + } + return metrics, nil +} + +// Detach drops daemon process-local handles while retaining the generation. +func (c *Client) Detach(ctx context.Context, sess *session.Session) error { + return c.sessionOperation(ctx, "detach", sess) +} + +// Delete permanently destroys the exact persisted generation. +func (c *Client) Delete(ctx context.Context, sess *session.Session) error { + return c.sessionOperation(ctx, "delete", sess) +} + +var ( + _ server.PlacementProvider = (*Client)(nil) + _ server.PlacementReattacher = (*Client)(nil) + _ tool.EnvironmentForker = (*Client)(nil) + _ tool.EnvironmentMerger = (*Client)(nil) +) + +// Fork asks microvmd to create a complete isolated child generation. +func (c *Client) Fork(ctx context.Context, base tool.Environment, label string) (tool.Environment, func() error, string, error) { + claim, err := bindingFromEnvironment(c, base) + if err != nil { + return tool.Environment{}, nil, "", err + } + payload, err := json.Marshal(struct { + Label string `json:"label"` + }{Label: label}) + if err != nil { + return tool.Environment{}, nil, "", err + } + response, err := c.call(ctx, lifecycleRequest{Version: protocolVersion, Operation: "fork", Binding: claim, Payload: payload}) + if err != nil { + return tool.Environment{}, nil, "", err + } + childRef := refForBinding(response.Binding) + if response.Binding.Owner != claim.Owner || response.Binding.EnvironmentID == "" || response.Binding.Generation == 0 { + return tool.Environment{}, nil, "", errors.New("microvmd fork returned an invalid child binding") + } + child := c.environment(childRef, response.Binding) + cleanup := func() error { + return c.operate(context.WithoutCancel(ctx), "child-delete", response.Binding) + } + return child, cleanup, "", nil +} + +// Merge asks microvmd to conflict-check and atomically apply child changes. +func (c *Client) Merge(ctx context.Context, child, parent tool.Environment) error { + parentClaim, err := bindingFromEnvironment(c, parent) + if err != nil { + return err + } + childClaim, err := bindingFromEnvironment(c, child) + if err != nil { + return err + } + payload, err := json.Marshal(struct { + Child binding `json:"child"` + }{Child: childClaim}) + if err != nil { + return err + } + response, err := c.call(ctx, lifecycleRequest{Version: protocolVersion, Operation: "merge", Binding: parentClaim, Payload: payload}) + if err != nil { + return err + } + if response.Binding != parentClaim { + return errors.New("microvmd merge response changed parent generation") + } + return nil +} + +func bindingFromEnvironment(client *Client, env tool.Environment) (binding, error) { + workspace, ok := env.Workspace().(*workspace) + if !ok || workspace.client != client || env.Ref().Kind != kindMicroVM || refForBinding(workspace.binding) != env.Ref() { + return binding{}, errors.New("environment is not owned by this microvmd client") + } + return workspace.binding, nil +} + +func (c *Client) sessionOperation(ctx context.Context, operation string, sess *session.Session) error { + claim, err := bindingForSession(sess) + if err != nil { + return err + } + return c.operate(ctx, operation, claim) +} +func (c *Client) operate(ctx context.Context, operation string, claim binding) error { + response, err := c.call(ctx, lifecycleRequest{Version: protocolVersion, Operation: operation, Binding: claim}) + if err != nil { + return err + } + if response.Binding != claim && operation != "delete" { + return errors.New("microvmd lifecycle response changed generation") + } + return nil +} + +func (c *Client) environment(ref session.EnvironmentRef, claim binding) tool.Environment { + ws := &workspace{client: c, binding: claim, ledger: make(map[string]tool.FileVersion)} + return tool.MustEnvironment(ref, ws, memledger.New(), &runner{client: c, binding: claim}) +} + +func (c *Client) call(ctx context.Context, request lifecycleRequest) (lifecycleResponse, error) { + return c.stream(ctx, request, func(execStreamFrame) error { + return errors.New("microvmd sent a stream frame for a unary request") + }) +} + +func (c *Client) stream(ctx context.Context, request lifecycleRequest, receive func(execStreamFrame) error) (lifecycleResponse, error) { + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "unix", c.endpoint) + if err != nil { + return lifecycleResponse{}, fmt.Errorf("dial microvmd: %w", err) + } + defer func() { _ = conn.Close() }() + stopCancel := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stopCancel() + if err := writeFrame(conn, request); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return lifecycleResponse{}, ctxErr + } + return lifecycleResponse{}, err + } + for { + var response lifecycleResponse + if err := readFrame(conn, &response); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return lifecycleResponse{}, ctxErr + } + return lifecycleResponse{}, err + } + if response.Stream != nil { + if receive == nil { + return lifecycleResponse{}, errors.New("microvmd sent an unexpected stream frame") + } + if err := receive(*response.Stream); err != nil { + return lifecycleResponse{}, err + } + continue + } + if response.ErrorCode != "" { + if response.ErrorCode == repositoryLogicalRootUnavailableCategory { + return lifecycleResponse{}, ErrRepositoryLogicalRootUnavailable + } + return lifecycleResponse{}, fmt.Errorf("microvmd %s: %s", response.ErrorCode, response.ErrorText) + } + return response, nil + } +} + +func writeFrame(w io.Writer, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + if len(payload) == 0 || len(payload) > maxMessageBytes { + return errors.New("microvmd frame exceeds bound") + } + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(payload))) // #nosec G115 -- maxMessageBytes is below uint32 + _, err = w.Write(append(header[:], payload...)) + return err +} +func readFrame(r io.Reader, value any) error { + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return err + } + size := binary.BigEndian.Uint32(header[:]) + if size == 0 || size > maxMessageBytes { + return errors.New("invalid microvmd frame") + } + payload := make([]byte, size) + if _, err := io.ReadFull(r, payload); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + return decoder.Decode(value) +} + +func ownerName(p *session.Principal) string { + if p == nil { + return "local" + } + payload, _ := json.Marshal([2]string{p.Issuer, p.Subject}) + return string(payload) +} +func bindingForSession(sess *session.Session) (binding, error) { + if sess == nil { + return binding{}, errors.New("session is not microvm-backed") + } + return bindingForRef(sess.EnvironmentRef, sess.Owner) +} + +type workspace struct { + client *Client + binding binding + mu sync.Mutex + ledger map[string]tool.FileVersion +} + +func (*workspace) Root() string { return "/workspace" } + +type workspaceRequest struct { + Operation string `json:"operation"` + Path string `json:"path,omitempty"` + Pattern string `json:"pattern,omitempty"` + PathGlob string `json:"path_glob,omitempty"` + Data []byte `json:"data,omitempty"` + Version string `json:"version,omitempty"` + VersionValid bool `json:"version_valid,omitempty"` +} +type workspaceResponse struct { + Data []byte `json:"data,omitempty"` + Version string `json:"version,omitempty"` + VersionValid bool `json:"version_valid,omitempty"` + Info *tool.FileInfo `json:"info,omitempty"` + Paths []string `json:"paths,omitempty"` + Matches []tool.GrepMatch `json:"matches,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +func (w *workspace) rpc(ctx context.Context, req workspaceRequest) (workspaceResponse, error) { + payload, _ := json.Marshal(req) + response, err := w.client.call(ctx, lifecycleRequest{Version: protocolVersion, Operation: "workspace", Binding: w.binding, Payload: payload}) + if err != nil { + return workspaceResponse{}, err + } + var out workspaceResponse + if err := json.Unmarshal(response.Payload, &out); err != nil { + return out, err + } + if out.ErrorCode != "" { + if out.ErrorCode == "not_found" { + return out, fs.ErrNotExist + } + if out.ErrorCode == "exists" { + return out, fs.ErrExist + } + if out.ErrorCode == "version_mismatch" { + return out, &tool.VersionMismatchError{Path: req.Path} + } + return out, errors.New("microvm workspace operation failed") + } + return out, nil +} +func (w *workspace) Read(ctx context.Context, path string) ([]byte, error) { + r, e := w.rpc(ctx, workspaceRequest{Operation: "read", Path: path}) + return r.Data, e +} +func (w *workspace) ReadVersion(ctx context.Context, path string) ([]byte, tool.FileVersion, error) { + r, e := w.rpc(ctx, workspaceRequest{Operation: "read", Path: path}) + if e != nil { + return nil, tool.FileVersion{}, e + } + if !r.VersionValid { + return nil, tool.FileVersion{}, errors.New("microvm workspace omitted version") + } + return r.Data, tool.NewFileVersion(r.Version), nil +} +func (w *workspace) Stat(ctx context.Context, path string) (tool.FileInfo, error) { + r, e := w.rpc(ctx, workspaceRequest{Operation: "stat", Path: path}) + if e != nil || r.Info == nil { + return tool.FileInfo{}, e + } + return *r.Info, nil +} +func (w *workspace) CreateFile(ctx context.Context, path string, data []byte) (tool.FileVersion, error) { + r, e := w.rpc(ctx, workspaceRequest{Operation: "create", Path: path, Data: data}) + if e != nil { + return tool.FileVersion{}, e + } + return tool.NewFileVersion(r.Version), nil +} +func (w *workspace) ReplaceFile(ctx context.Context, path string, old tool.FileVersion, data []byte) (tool.FileVersion, error) { + token, encodeErr := tool.EncodeFileVersion(old) + r, e := w.rpc(ctx, workspaceRequest{Operation: "replace", Path: path, Data: data, Version: token, VersionValid: encodeErr == nil}) + if e != nil { + return tool.FileVersion{}, e + } + return tool.NewFileVersion(r.Version), nil +} +func (w *workspace) Glob(ctx context.Context, p string) ([]string, error) { + r, e := w.rpc(ctx, workspaceRequest{Operation: "glob", Pattern: p}) + return r.Paths, e +} +func (w *workspace) Grep(ctx context.Context, p, g string) ([]tool.GrepMatch, error) { + r, e := w.rpc(ctx, workspaceRequest{Operation: "grep", Pattern: p, PathGlob: g}) + return r.Matches, e +} +func (w *workspace) RecordRead(path string, v tool.FileVersion) { + w.mu.Lock() + w.ledger[tool.LedgerKey("/workspace", path)] = v + w.mu.Unlock() +} +func (w *workspace) RecordedVersion(path string) (tool.FileVersion, bool) { + w.mu.Lock() + defer w.mu.Unlock() + v, ok := w.ledger[tool.LedgerKey("/workspace", path)] + return v, ok +} + +type runner struct { + client *Client + binding binding +} + +func (*runner) BoundWorkspaceRoot() string { return publicGuestRoot } + +type execResponse struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` +} + +func (r *runner) Run(ctx context.Context, command string) (tool.CommandResult, error) { + return r.runResult(ctx, command, "") +} + +func (r *runner) RunWithTemporaryScope(ctx context.Context, command string, scope tool.TemporaryScope) (tool.CommandResult, error) { + return r.runResult(ctx, command, scope) +} + +func (r *runner) runResult(ctx context.Context, command string, scope tool.TemporaryScope) (tool.CommandResult, error) { + var stdout, stderr bytes.Buffer + exit, err := r.exec(ctx, command, scope, func(frame execStreamFrame) error { + var target io.Writer + switch frame.Channel { + case "stdout": + target = &stdout + case "stderr": + target = &stderr + default: + return errors.New("microvmd exec stream has an invalid channel") + } + _, writeErr := target.Write(frame.Data) + return writeErr + }) + return tool.CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exit}, err +} + +func (r *runner) RunStreaming(ctx context.Context, command string, out io.Writer) (int, error) { + return r.runStreaming(ctx, command, "", out) +} + +func (r *runner) RunStreamingWithTemporaryScope(ctx context.Context, command string, scope tool.TemporaryScope, out io.Writer) (int, error) { + return r.runStreaming(ctx, command, scope, out) +} + +func (r *runner) runStreaming(ctx context.Context, command string, scope tool.TemporaryScope, out io.Writer) (int, error) { + if out == nil { + return 0, errors.New("microvmd exec stream writer is nil") + } + return r.exec(ctx, command, scope, func(frame execStreamFrame) error { + if frame.Channel != "stdout" && frame.Channel != "stderr" { + return errors.New("microvmd exec stream has an invalid channel") + } + n, err := out.Write(frame.Data) + if err == nil && n != len(frame.Data) { + err = io.ErrShortWrite + } + return err + }) +} + +func (r *runner) exec(ctx context.Context, command string, scope tool.TemporaryScope, receive func(execStreamFrame) error) (int, error) { + payload, err := json.Marshal(struct { + Command string `json:"command"` + TemporaryScope tool.TemporaryScope `json:"temporary_scope,omitempty"` + }{Command: command, TemporaryScope: scope}) + if err != nil { + return 0, err + } + response, err := r.client.stream(ctx, lifecycleRequest{Version: protocolVersion, Operation: "exec", Binding: r.binding, Payload: payload}, receive) + if err != nil { + return 0, err + } + var final execResponse + if err := json.Unmarshal(response.Payload, &final); err != nil { + return 0, err + } + return final.ExitCode, nil +} + +var _ tool.Workspace = (*workspace)(nil) +var _ tool.CommandTemporaryScopeRunner = (*runner)(nil) +var _ tool.CommandTemporaryScopeStreamer = (*runner)(nil) diff --git a/internal/adapter/microvm/client_error_test.go b/internal/adapter/microvm/client_error_test.go new file mode 100644 index 000000000..fd8bf8aa3 --- /dev/null +++ b/internal/adapter/microvm/client_error_test.go @@ -0,0 +1,57 @@ +package microvm + +import ( + "context" + "errors" + "net" + "strings" + "testing" +) + +func TestClientClassifiesRepositoryLogicalRootUnavailableWithoutDaemonDetail(t *testing.T) { + const secret = "token=daemon-secret /private/repository errno=13" + endpoint := testUnixSocketPath(t) + listener, err := net.Listen("unix", endpoint) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + serveErr := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + serveErr <- acceptErr + return + } + defer conn.Close() + var request lifecycleRequest + if readErr := readFrame(conn, &request); readErr != nil { + serveErr <- readErr + return + } + serveErr <- writeFrame(conn, lifecycleResponse{ + ErrorCode: repositoryLogicalRootUnavailableCategory, + ErrorText: secret, + }) + }() + + client, err := New("unix://" + endpoint) + if err != nil { + t.Fatal(err) + } + _, err = client.call(context.Background(), lifecycleRequest{Version: protocolVersion, Operation: "create"}) + if !errors.Is(err, ErrRepositoryLogicalRootUnavailable) { + t.Fatalf("error=%v, want ErrRepositoryLogicalRootUnavailable", err) + } + if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), "/private/repository") { + t.Fatalf("client error leaked daemon detail: %v", err) + } + var categorized interface{ EnvironmentLifecycleCategory() string } + if !errors.As(err, &categorized) || categorized.EnvironmentLifecycleCategory() != repositoryLogicalRootUnavailableCategory { + t.Fatalf("category=%v, want %q", categorized, repositoryLogicalRootUnavailableCategory) + } + if err := <-serveErr; err != nil { + t.Fatal(err) + } +} diff --git a/internal/adapter/microvm/client_profile_test.go b/internal/adapter/microvm/client_profile_test.go new file mode 100644 index 000000000..1ce99c07d --- /dev/null +++ b/internal/adapter/microvm/client_profile_test.go @@ -0,0 +1,66 @@ +package microvm + +import ( + "context" + "encoding/json" + "net" + "strings" + "testing" + + "github.com/stacklok/mecatl/internal/adapter/server" +) + +func TestPlacementProviderUsesOpaqueDaemonProfileAndExactRef(t *testing.T) { + socket := testUnixSocketPath(t) + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + + requestSeen := make(chan lifecycleRequest, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer func() { _ = conn.Close() }() + var request lifecycleRequest + if readFrame(conn, &request) != nil { + return + } + requestSeen <- request + _ = writeFrame(conn, lifecycleResponse{ + Binding: binding{Owner: "local", SessionID: request.Provision.SessionID, EnvironmentID: "env-1", Ref: "env-1@7", Generation: 7}, + Created: &created{Ref: environmentRef{Kind: "microvm", ID: "env-1@7"}, Generation: 7, HostWorktree: "/private/worktree", GuestRoot: "/workspace", Profile: "microvm-local", GuestEgress: "deny-all", HostEgress: "not constrained"}, + }) + }() + + client, err := NewPlacementProvider("unix://"+socket, "/source", "microvm-local", "deployment", nil) + if err != nil { + t.Fatal(err) + } + placed, err := client.Bind(context.Background(), server.PlacementBindRequest{Selector: server.DefaultPlacement(), Scope: "deployment", Operation: server.PlacementOperationCreate}) + if err != nil { + t.Fatalf("Bind: %v", err) + } + request := <-requestSeen + if request.Provision == nil || request.Provision.Profile != "microvm-local" || request.Provision.SourceCheckout != "/source" || request.Provision.SessionID == "" { + t.Fatalf("opaque provision request = %+v", request.Provision) + } + if placed.Ref.Kind != "microvm" || placed.Ref.Revision != "7" || !strings.HasSuffix(placed.Ref.ID, ".env-1") { + t.Fatalf("exact placement ref = %+v", placed.Ref) + } + if placed.Metadata.Label != "Local microVM" || strings.Contains(placed.Metadata.Label, "/private/") { + t.Fatalf("public placement metadata = %+v", placed.Metadata) + } +} + +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/internal/adapter/microvm/client_stream_test.go b/internal/adapter/microvm/client_stream_test.go new file mode 100644 index 000000000..cb6d0d577 --- /dev/null +++ b/internal/adapter/microvm/client_stream_test.go @@ -0,0 +1,193 @@ +package microvm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net" + "testing" + "time" + + "github.com/stacklok/mecatl/engine/tool" +) + +const wireTestTimeout = 5 * time.Second + +func TestRunnerManagedTemporaryScopeCrossesOnlyAsClosedCapability(t *testing.T) { + client, claim, stop := startExecWireServer(t, func(conn net.Conn) { + var request lifecycleRequest + if err := readFrame(conn, &request); err != nil { + t.Errorf("read request: %v", err) + return + } + var input struct { + Command string `json:"command"` + TemporaryScope tool.TemporaryScope `json:"temporary_scope"` + } + if err := json.Unmarshal(request.Payload, &input); err != nil { + t.Errorf("decode exec payload: %v", err) + return + } + if input.Command != "pwd" || input.TemporaryScope != tool.TemporaryScopeManaged { + t.Errorf("exec payload = %+v", input) + return + } + _ = writeFrame(conn, lifecycleResponse{Payload: mustJSON(t, execResponse{ExitCode: 0})}) + }) + defer stop() + + result, err := (&runner{client: client, binding: claim}).RunWithTemporaryScope(context.Background(), "pwd", tool.TemporaryScopeManaged) + if err != nil || result.ExitCode != 0 { + t.Fatalf("RunWithTemporaryScope = %+v, %v", result, err) + } +} + +func TestRunnerStreamingPreservesDaemonFrameOrderAndNonzeroExit(t *testing.T) { + client, claim, stop := startExecWireServer(t, func(conn net.Conn) { + var request lifecycleRequest + if err := readFrame(conn, &request); err != nil { + t.Errorf("read request: %v", err) + return + } + for _, frame := range []lifecycleResponse{ + {Stream: &execStreamFrame{Channel: "stdout", Data: []byte("out-1\n")}}, + {Stream: &execStreamFrame{Channel: "stderr", Data: []byte("err-1\n")}}, + {Stream: &execStreamFrame{Channel: "stdout", Data: []byte("out-2\n")}}, + {Payload: mustJSON(t, execResponse{ExitCode: 9})}, + } { + if err := writeFrame(conn, frame); err != nil { + t.Errorf("write response: %v", err) + return + } + } + }) + defer stop() + + var output bytes.Buffer + exit, err := (&runner{client: client, binding: claim}).RunStreaming(context.Background(), "ignored", &output) + if err != nil || exit != 9 || output.String() != "out-1\nerr-1\nout-2\n" { + t.Fatalf("RunStreaming = output %q, exit %d, err %v", output.String(), exit, err) + } +} + +func TestRunnerCancellationWithoutDeadlineClosesRequestAndPreservesOutput(t *testing.T) { + peerClosed := make(chan struct{}) + client, claim, stop := startExecWireServer(t, func(conn net.Conn) { + var request lifecycleRequest + if err := readFrame(conn, &request); err != nil { + t.Errorf("read request: %v", err) + return + } + if err := writeFrame(conn, lifecycleResponse{Stream: &execStreamFrame{Channel: "stdout", Data: []byte("partial\n")}}); err != nil { + t.Errorf("write partial output: %v", err) + return + } + var one [1]byte + _, _ = conn.Read(one[:]) + close(peerClosed) + }) + defer stop() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + outputSeen := make(chan struct{}) + resultCh := make(chan struct { + result string + err error + }, 1) + go func() { + var output notifyingBuffer + output.notify = outputSeen + _, err := (&runner{client: client, binding: claim}).RunStreaming(ctx, "ignored", &output) + resultCh <- struct { + result string + err error + }{output.String(), err} + }() + select { + case <-outputSeen: + cancel() + case <-time.After(wireTestTimeout): + t.Fatal("partial output was not delivered") + } + select { + case got := <-resultCh: + if !errors.Is(got.err, context.Canceled) || got.result != "partial\n" { + t.Fatalf("Run cancellation = output %q, err %v", got.result, got.err) + } + case <-time.After(wireTestTimeout): + t.Fatal("request cancellation did not unblock a deadline-free read") + } + select { + case <-peerClosed: + case <-time.After(wireTestTimeout): + t.Fatal("client cancellation did not close the peer request") + } +} + +func TestRunnerTransportFailureIsDistinctAndPreservesPriorOutput(t *testing.T) { + client, claim, stop := startExecWireServer(t, func(conn net.Conn) { + var request lifecycleRequest + if err := readFrame(conn, &request); err != nil { + t.Errorf("read request: %v", err) + return + } + _ = writeFrame(conn, lifecycleResponse{Stream: &execStreamFrame{Channel: "stderr", Data: []byte("before-disconnect\n")}}) + }) + defer stop() + + result, err := (&runner{client: client, binding: claim}).Run(context.Background(), "ignored") + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("transport error = %v, want non-context failure", err) + } + if result.Stderr != "before-disconnect\n" { + t.Fatalf("prior stderr = %q", result.Stderr) + } +} + +type notifyingBuffer struct { + bytes.Buffer + notify chan struct{} +} + +func (b *notifyingBuffer) Write(p []byte) (int, error) { + n, err := b.Buffer.Write(p) + select { + case b.notify <- struct{}{}: + default: + } + return n, err +} + +func startExecWireServer(t *testing.T, serve func(net.Conn)) (*Client, binding, func()) { + t.Helper() + socket := testUnixSocketPath(t) + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { + defer close(done) + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + serve(conn) + }() + client, err := New("unix://" + socket) + if err != nil { + t.Fatal(err) + } + claim := binding{Owner: "local", SessionID: "session-1", EnvironmentID: "env-1", Ref: "env-1@1", Generation: 1} + return client, claim, func() { + _ = listener.Close() + select { + case <-done: + case <-time.After(wireTestTimeout): + t.Error("wire server did not stop") + } + } +} diff --git a/internal/adapter/microvm/lifecycle_inventory_test.go b/internal/adapter/microvm/lifecycle_inventory_test.go new file mode 100644 index 000000000..32b1f3d00 --- /dev/null +++ b/internal/adapter/microvm/lifecycle_inventory_test.go @@ -0,0 +1,83 @@ +package microvm + +import ( + "context" + "encoding/json" + "net" + "testing" +) + +func TestMicroVMLifecycleUX_ClientInventoryAndDeleteStayGenerationBound(t *testing.T) { + t.Parallel() + socket := testUnixSocketPath(t) + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + requests := make(chan lifecycleRequest, 4) + go func() { + for i := 0; i < 4; i++ { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + var request lifecycleRequest + if readFrame(conn, &request) == nil { + requests <- request + switch request.Operation { + case "inventory": + var inventoryRequest InventoryRequest + _ = json.Unmarshal(request.Payload, &inventoryRequest) + entry := InventoryEntry{Owner: "local", SessionID: "s1", EnvironmentID: "env-1", Ref: "env-1@7", Generation: 7, WorktreePath: "/worktrees/s1", Health: GenerationHealthy} + page := InventoryPage{Entries: []InventoryEntry{entry}} + if inventoryRequest.Continuation == "" { + page.Continuation = "opaque-next" + } + payload, _ := json.Marshal(page) + _ = writeFrame(conn, lifecycleResponse{Payload: payload}) + case "reconcile": + _ = writeFrame(conn, lifecycleResponse{}) + case "delete": + payload, _ := json.Marshal(DeleteResult{WorktreePath: "/worktrees/s1", WorktreeRetained: true}) + _ = writeFrame(conn, lifecycleResponse{Payload: payload}) + } + } + _ = conn.Close() + } + }() + + client, err := New("unix://" + socket) + if err != nil { + t.Fatal(err) + } + page, err := client.Inventory(context.Background(), "local") + if err != nil || len(page.Entries) != 1 || page.Entries[0].Generation != 7 || page.Continuation != "opaque-next" { + t.Fatalf("Inventory() = %+v, %v", page, err) + } + lastPage, err := client.Inventory(context.Background(), "local", InventoryRequest{PageSize: 1, Continuation: page.Continuation}) + if err != nil || len(lastPage.Entries) != 1 || lastPage.Continuation != "" { + t.Fatalf("Inventory(next) = %+v, %v", lastPage, err) + } + if err := client.Reconcile(context.Background(), "local"); err != nil { + t.Fatal(err) + } + claim := GenerationBinding{Owner: "local", SessionID: "s1", EnvironmentID: "env-1", Ref: "env-1@7", Generation: 7} + deleted, err := client.DeleteGeneration(context.Background(), claim) + if err != nil || !deleted.WorktreeRetained || deleted.WorktreePath != "/worktrees/s1" { + t.Fatalf("DeleteGeneration() = %+v, %v", deleted, err) + } + + firstInventoryRequest, nextInventoryRequest, reconcileRequest, deleteRequest := <-requests, <-requests, <-requests, <-requests + if firstInventoryRequest.Binding != (binding{Owner: "local"}) || nextInventoryRequest.Binding != (binding{Owner: "local"}) || reconcileRequest.Binding != (binding{Owner: "local"}) { + t.Fatalf("owner scope drifted: inventory=%+v next=%+v reconcile=%+v", firstInventoryRequest.Binding, nextInventoryRequest.Binding, reconcileRequest.Binding) + } + var nextPageRequest InventoryRequest + if err := json.Unmarshal(nextInventoryRequest.Payload, &nextPageRequest); err != nil || nextPageRequest.Continuation != "opaque-next" || nextPageRequest.PageSize != 1 { + t.Fatalf("next inventory request = %+v, %v", nextPageRequest, err) + } + if deleteRequest.Binding != (binding(claim)) { + t.Fatalf("delete binding = %+v, want %+v", deleteRequest.Binding, claim) + } +} diff --git a/internal/adapter/microvm/production_e2e_test.go b/internal/adapter/microvm/production_e2e_test.go new file mode 100644 index 000000000..9cbb71f5c --- /dev/null +++ b/internal/adapter/microvm/production_e2e_test.go @@ -0,0 +1,345 @@ +//go:build microvm_e2e + +package microvm_test + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "testing" + "time" + + "github.com/stacklok/mecatl/engine/adapter/mockllm" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" + "github.com/stacklok/mecatl/internal/adapter/server" + "github.com/stacklok/mecatl/internal/app" +) + +const microVME2EPolicy = "microvm-production-e2e-v1" + +func TestMicroVMDefaultPlacementDailyHarnessJourney(t *testing.T) { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Fatalf("Linux amd64 KVM is the only live microVM target, got %s/%s", runtime.GOOS, runtime.GOARCH) + } + assertKVMAccess(t) + + rootParent := requiredAbsoluteEnv(t, "MECATL_MICROVM_E2E_ROOT") + root, err := os.MkdirTemp(rootParent, "daily-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if t.Failed() { + t.Logf("retained failed production E2E state at %s", root) + return + } + _ = os.RemoveAll(root) + }) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + t.Cleanup(cancel) + + source := filepath.Join(root, "source") + initE2ERepository(t, source) + ready, paths := prepareManagedRelease(t, ctx, root) + manager, endpoint, err := microvmmanager.DefaultLocal() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { stopManagedDaemon(t, paths) }) + if _, err := manager.EnsureReady(ctx, ready); err != nil { + t.Fatalf("prepare microVM deployment default: %v", err) + } + + const scope server.PlacementScope = "deployment" + placement, err := microvmadapter.NewPlacementProvider(endpoint, source, microvmmanager.Alias, scope, nil) + if err != nil { + t.Fatal(err) + } + provider := mockllm.New( + mockllm.ToolCallTurn(session.NewToolCall("daily-bash", "Bash", json.RawMessage(`{"command":"cat tracked.txt && printf harness-change > journey.txt"}`))), + mockllm.TextTurn("done"), + ) + built, err := app.Build(ctx, app.Config{ + Workspace: source, StoreDir: filepath.Join(root, "store"), MockProvider: provider, + Shell: "/bin/sh", AllowAllTools: true, + PlacementProvider: placement, PlacementScope: scope, + EnvironmentForkers: map[session.EnvironmentKind]tool.EnvironmentForker{"microvm": placement}, + EnvironmentMergers: map[session.EnvironmentKind]tool.EnvironmentMerger{"microvm": placement}, + }) + if err != nil { + t.Fatalf("build harness with microVM deployment default: %v", err) + } + defer built.Close() + + // This is deliberately the ordinary no-selector API. The deployment, not the + // client, chooses microvm-local as the default placement. + sess, err := built.Service.CreateSession(ctx, session.ModeDefault, session.Limits{}) + if err != nil { + t.Fatalf("normal CreateSession: %v", err) + } + defer func() { + if err := placement.Delete(context.WithoutCancel(ctx), sess); err != nil { + t.Errorf("delete exact microVM generation: %v", err) + } + }() + if sess.EnvironmentRef.Kind != "microvm" || sess.EnvironmentRef.ID == "" || sess.EnvironmentRef.Revision == "" { + t.Fatalf("CreateSession did not persist an exact microVM placement: %+v", sess.EnvironmentRef) + } + + run, err := built.Service.StartRunContent(ctx, sess.ID, "perform the daily filesystem operation", nil) + if err != nil { + t.Fatalf("start harness run: %v", err) + } + var result *session.ResultPayload + var toolSucceeded bool + for event := range run.Events() { + if event.ToolResult != nil && event.ToolResult.CallID == "daily-bash" && !event.ToolResult.IsError { + toolSucceeded = strings.Contains(event.ToolResult.Content, "base") + } + if event.Result != nil { + result = event.Result + } + } + if result == nil || result.Stop == session.StopError || !toolSucceeded { + t.Fatalf("guest-backed harness operation did not complete: result=%+v tool_succeeded=%v", result, toolSucceeded) + } + + binding, err := placement.Reattach(ctx, server.PlacementReattachRequest{Ref: sess.EnvironmentRef, Principal: sess.Owner, Scope: scope}) + if err != nil { + t.Fatalf("reattach exact guest generation: %v", err) + } + data, err := binding.Environment.Workspace().Read(ctx, "journey.txt") + if err != nil || string(data) != "harness-change" { + t.Fatalf("guest workspace did not retain harness change: %q, %v", data, err) + } + if _, err := os.Stat(filepath.Join(source, "journey.txt")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("guest change escaped its isolated worktree into the source checkout: %v", err) + } + pwd := mustGuestRun(t, ctx, binding.Environment.CommandRunner(), "pwd").Stdout + if !strings.HasPrefix(strings.TrimSpace(pwd), "/run/mecatl/repositories/") { + t.Fatalf("guest command ran outside its isolated repository worktree: %q", pwd) + } +} + +func prepareManagedRelease(t *testing.T, ctx context.Context, root string) (microvmmanager.ReadyRequest, microvmmanager.Paths) { + t.Helper() + packageDir := requiredAbsoluteEnv(t, "MECATL_MICROVM_RELEASE_ASSETS") + bundleDir := filepath.Join(root, "release") + if err := copyE2ETree(packageDir, bundleDir); err != nil { + t.Fatalf("copy packaged release: %v", err) + } + platform := runtime.GOOS + "-" + runtime.GOARCH + if err := copyE2EFile(requiredAbsoluteEnv(t, "MECATL_MICROVMD_BIN"), filepath.Join(bundleDir, "mecatl-microvmd-"+platform), 0o755); err != nil { + t.Fatalf("project production daemon into release: %v", err) + } + bundle := filepath.Join(root, "microvm-release.tar.gz") + if err := archiveE2ETree(bundleDir, bundle); err != nil { + t.Fatalf("archive production release: %v", err) + } + bundleData, err := os.ReadFile(bundle) + if err != nil { + t.Fatal(err) + } + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write(bundleData) + })) + t.Cleanup(server.Close) + certificate := filepath.Join(root, "release-ca.pem") + if err := os.WriteFile(certificate, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600); err != nil { + t.Fatal(err) + } + + defaults, err := json.Marshal(map[string]any{platform: map[string]string{ + "version": "e2e", "platform": platform, "url": server.URL + "/microvm-release.tar.gz", + "sha256": fmt.Sprintf("%x", sha256.Sum256(bundleData)), "policy_revision": microVME2EPolicy, + "certificate_identity": "https://github.com/stacklok/mecatl/.github/workflows/release.yml@refs/tags/e2e", + "oidc_issuer": "https://token.actions.githubusercontent.com", + }}) + if err != nil { + t.Fatal(err) + } + ready, err := microvmmanager.ReadyRequestFromDefaults(base64.StdEncoding.EncodeToString(defaults), "e2e") + if err != nil { + t.Fatal(err) + } + publisher := requiredAbsoluteEnv(t, "MECATL_MICROVM_RELEASE_PUBLIC_KEY") + publisherData, err := os.ReadFile(publisher) + if err != nil { + t.Fatal(err) + } + identity := sha256.Sum256(publisherData) + ready.Policy.CertificateIdentity, ready.Policy.OIDCIssuer = "", "" + ready.Policy.PublicKey = publisher + ready.Policy.PublicKeyIdentity = fmt.Sprintf("sha256:%x", identity) + + home := filepath.Join(root, "home") + for key, value := range map[string]string{ + "HOME": home, "XDG_CONFIG_HOME": filepath.Join(home, ".config"), + "XDG_DATA_HOME": filepath.Join(home, ".local", "share"), "XDG_STATE_HOME": filepath.Join(home, ".local", "state"), + "XDG_RUNTIME_DIR": filepath.Join(requiredAbsoluteEnv(t, "MECATL_MICROVM_E2E_SOCKET_ROOT"), "daily-"+fmt.Sprint(os.Getpid())), + "SSL_CERT_FILE": certificate, + } { + t.Setenv(key, value) + } + paths, err := microvmmanager.DefaultPaths(microvmmanager.HostPaths{Home: home, UID: os.Getuid(), GOOS: runtime.GOOS}) + if err != nil { + t.Fatal(err) + } + _ = ctx // keeps preparation explicitly tied to the caller's bounded lifecycle + return ready, paths +} + +func requiredAbsoluteEnv(t *testing.T, name string) string { + t.Helper() + value := os.Getenv(name) + if value == "" || !filepath.IsAbs(value) { + t.Fatalf("%s must be an absolute path", name) + } + return value +} + +func assertKVMAccess(t *testing.T) { + t.Helper() + file, err := os.OpenFile("/dev/kvm", os.O_RDWR|syscall.O_CLOEXEC, 0) + if err != nil { + t.Fatalf("KVM-authorized runner cannot open /dev/kvm read-write: %v", err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func stopManagedDaemon(t *testing.T, paths microvmmanager.Paths) { + t.Helper() + if _, err := os.Lstat(paths.Socket); errors.Is(err, fs.ErrNotExist) { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := (µvmmanager.DefaultOperations{}).Stop(ctx, paths); err != nil { + t.Errorf("stop managed microvmd: %v", err) + } +} + +func initE2ERepository(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"init"}, {"config", "user.name", "MicroVM E2E"}, {"config", "user.email", "microvm-e2e@example.invalid"}} { + if output, err := exec.Command("git", append([]string{"-C", path}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + } + if err := os.WriteFile(filepath.Join(path, "tracked.txt"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"add", "tracked.txt"}, {"commit", "-m", "base"}} { + if output, err := exec.Command("git", append([]string{"-C", path}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + } +} + +func copyE2ETree(source, destination string) error { + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, rel) + if entry.IsDir() { + return os.MkdirAll(target, 0o700) + } + info, err := entry.Info() + if err != nil { + return err + } + return copyE2EFile(path, target, info.Mode().Perm()) + }) +} + +func copyE2EFile(source, destination string, mode fs.FileMode) error { + data, err := os.ReadFile(source) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return err + } + return os.WriteFile(destination, data, mode) +} + +func archiveE2ETree(source, destination string) error { + file, err := os.Create(destination) + if err != nil { + return err + } + zw := gzip.NewWriter(file) + tw := tar.NewWriter(zw) + walkErr := filepath.Walk(source, func(path string, info fs.FileInfo, walkErr error) error { + if walkErr != nil || path == source { + return walkErr + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = filepath.ToSlash(rel) + if err := tw.WriteHeader(header); err != nil || !info.Mode().IsRegular() { + return err + } + input, err := os.Open(path) + if err != nil { + return err + } + _, copyErr := io.Copy(tw, input) + closeErr := input.Close() + if copyErr != nil { + return copyErr + } + return closeErr + }) + for _, closer := range []io.Closer{tw, zw, file} { + if err := closer.Close(); walkErr == nil { + walkErr = err + } + } + return walkErr +} + +func mustGuestRun(t *testing.T, ctx context.Context, runner tool.CommandRunner, command string) tool.CommandResult { + t.Helper() + result, err := runner.Run(ctx, command) + if err != nil || result.ExitCode != 0 { + t.Fatalf("guest command %q = %+v, %v", command, result, err) + } + return result +} diff --git a/internal/adapter/microvm/socket_test.go b/internal/adapter/microvm/socket_test.go new file mode 100644 index 000000000..0b9b3c460 --- /dev/null +++ b/internal/adapter/microvm/socket_test.go @@ -0,0 +1,53 @@ +package microvm + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func testUnixSocketPath(t *testing.T) string { + t.Helper() + return testUnixSocketPathForOS(t, runtime.GOOS) +} + +func testUnixSocketPathForOS(t *testing.T, _ string) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "mecatl-microvm-") + if err != nil { + t.Fatalf("create short private Darwin socket directory: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(dir); err != nil { + t.Errorf("remove short private Darwin socket directory: %v", err) + } + }) + return filepath.Join(dir, "microvmd.sock") +} + +func TestInvariant_microvm_darwin_test_socket_path_is_short_and_private(t *testing.T) { + path := testUnixSocketPathForOS(t, "darwin") + if len(path) >= 104 { + t.Fatalf("Darwin test socket path is %d bytes, want < 104: %q", len(path), path) + } + if !strings.HasPrefix(path, filepath.Clean("/tmp")+string(filepath.Separator)) { + t.Fatalf("Darwin test socket escaped private short base: %q", path) + } + info, err := os.Stat(filepath.Dir(path)) + if err != nil { + t.Fatalf("stat Darwin test socket directory: %v", err) + } + if info.Mode().Perm()&0o077 != 0 { + t.Fatalf("Darwin test socket directory mode = %o, want owner-only", info.Mode().Perm()) + } + resolved, err := filepath.EvalSymlinks(filepath.Dir(path)) + if err != nil { + t.Fatalf("resolve Darwin test socket directory: %v", err) + } + if !strings.HasPrefix(resolved, filepath.Clean("/private/tmp")+string(filepath.Separator)) && + !strings.HasPrefix(resolved, filepath.Clean("/tmp")+string(filepath.Separator)) { + t.Fatalf("Darwin test socket resolved outside short temporary base: %q", resolved) + } +} diff --git a/internal/adapter/microvmmanager/bootstrap.go b/internal/adapter/microvmmanager/bootstrap.go new file mode 100644 index 000000000..daaf05aba --- /dev/null +++ b/internal/adapter/microvmmanager/bootstrap.go @@ -0,0 +1,109 @@ +package microvmmanager + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "runtime" + "strings" + + microvmclient "github.com/stacklok/mecatl/internal/adapter/microvm" +) + +// ReleaseDefaults is the release-generated projection embedded in production +// composition roots. +type ReleaseDefaults struct { + Version string `json:"version"` + Platform string `json:"platform"` + URL string `json:"url"` + SHA256 string `json:"sha256"` + PolicyRevision string `json:"policy_revision"` + CertificateIdentity string `json:"certificate_identity"` + OIDCIssuer string `json:"oidc_issuer"` +} + +// ReadyRequestFromDefaults validates one release-generated defaults projection and +// overlays an optional, already operator-selected guest-egress policy. +func ReadyRequestFromDefaults(encoded, version string, egress ...GuestEgressSelection) (ReadyRequest, error) { + if encoded == "" { + return ReadyRequest{}, errors.New("this source-built binary has no authenticated microVM release defaults; install a signed, release-stamped Linux-amd64 mecatl host binary") + } + data, err := base64.StdEncoding.Strict().DecodeString(encoded) + if err != nil { + return ReadyRequest{}, errors.New("invalid built-in microVM release defaults encoding") + } + var byPlatform map[string]ReleaseDefaults + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&byPlatform); err != nil { + return ReadyRequest{}, fmt.Errorf("decode built-in microVM release defaults: %w", err) + } + platform := runtime.GOOS + "-" + runtime.GOARCH + defaults := byPlatform[platform] + if defaults.Version != version || defaults.Platform != platform || defaults.URL == "" || defaults.SHA256 == "" || defaults.PolicyRevision == "" || defaults.CertificateIdentity == "" || defaults.OIDCIssuer == "" { + return ReadyRequest{}, fmt.Errorf("built-in microVM release defaults do not exactly match version %s on %s", version, platform) + } + request := ReadyRequest{ + Release: Release{URL: defaults.URL, SHA256: defaults.SHA256}, + Policy: Policy{ + PolicyRevision: defaults.PolicyRevision, + CertificateIdentity: defaults.CertificateIdentity, OIDCIssuer: defaults.OIDCIssuer, + RequiredAttestations: requiredMicroVMAttestations(), + GuestEgressMode: GuestEgressPermissive, Resources: defaultMicroVMResources(), + }, + PreserveExistingGuestEgress: len(egress) == 0, + } + if err := applyGuestEgress(&request, egress); err != nil { + return ReadyRequest{}, err + } + return request, nil +} + +func requiredMicroVMAttestations() map[string]string { + return map[string]string{"runtime": "https://slsa.dev/provenance/v1", "firmware": "https://slsa.dev/provenance/v1", "execution-image": "https://slsa.dev/provenance/v1", "guest-agent": "https://slsa.dev/provenance/v1"} +} + +func defaultMicroVMResources() map[string]string { + return map[string]string{"cpus": "2", "memory": "4GiB"} +} + +func applyGuestEgress(request *ReadyRequest, egress []GuestEgressSelection) error { + if len(egress) > 1 { + return errors.New("multiple guest egress selections supplied") + } + if len(egress) == 1 { + selection := egress[0] + if selection.Mode == "" && len(selection.Allow) == 0 { + selection = NewGuestEgressSelection() + } + if err := selection.Validate(); err != nil { + return err + } + request.Policy.GuestEgressMode = selection.Mode + request.Policy.GuestAllow = append([]EgressRule(nil), selection.Allow...) + } + return nil +} + +// DefaultLocal builds the user-local manager without changing host state. +func DefaultLocal() (*Manager, string, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, "", fmt.Errorf("resolve home for microVM manager: %w", err) + } + paths, err := DefaultPaths(HostPaths{ + Home: home, XDGConfigHome: os.Getenv("XDG_CONFIG_HOME"), XDGDataHome: os.Getenv("XDG_DATA_HOME"), + XDGStateHome: os.Getenv("XDG_STATE_HOME"), XDGRuntimeDir: os.Getenv("XDG_RUNTIME_DIR"), UID: os.Getuid(), GOOS: runtime.GOOS, + }) + if err != nil { + return nil, "", err + } + endpoint := "unix://" + paths.Socket + lifecycle, err := microvmclient.New(endpoint) + if err != nil { + return nil, "", err + } + return New(paths, &DefaultOperations{}, lifecycle), endpoint, nil +} diff --git a/internal/adapter/microvmmanager/default_operations.go b/internal/adapter/microvmmanager/default_operations.go new file mode 100644 index 000000000..fd9245fd1 --- /dev/null +++ b/internal/adapter/microvmmanager/default_operations.go @@ -0,0 +1,712 @@ +package microvmmanager + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + microvmclient "github.com/stacklok/mecatl/internal/adapter/microvm" +) + +const maxReleaseBundleBytes = 2 << 30 + +const managedProcessSchema = "mecatl-microvmd-process/v1" + +type managedProcessRecord struct { + Schema string `json:"schema"` + PID int `json:"pid"` + ProcessIdentity string `json:"process_identity"` + BinaryIdentity string `json:"binary_identity"` + Args []string `json:"args"` + Socket string `json:"socket"` +} + +// DefaultOperations implements the real local OS, release, installer, and +// daemon boundary. +type DefaultOperations struct { + HTTPClient *http.Client + GOOS, GOARCH string + usernsProbe func(context.Context) error +} + +func (o *DefaultOperations) platform() (string, string) { + goos, goarch := o.GOOS, o.GOARCH + if goos == "" { + goos = runtime.GOOS + } + if goarch == "" { + goarch = runtime.GOARCH + } + return goos, goarch +} + +// Preflight checks the current user's platform, required host tools, hypervisor +// access, and ability to create the unprivileged user namespace used at launch. +func (o *DefaultOperations) Preflight(ctx context.Context, _ Paths) error { + goos, goarch := o.platform() + if !supportedPlatform(goos, goarch) { + return fmt.Errorf("unsupported microVM platform %s/%s", goos, goarch) + } + if _, err := exec.LookPath("git"); err != nil { + return errors.New("git is required for microVM worktrees") + } + if _, err := exec.LookPath("python3"); err != nil { + return errors.New("python3 is required by the verified microVM release installer; install Python 3 and retry") + } + if goos == "linux" { + probe := o.usernsProbe + if probe == nil { + probe = checkLinuxUserNamespaces + } + if err := probe(ctx); err != nil { + return err + } + file, err := os.OpenFile("/dev/kvm", os.O_RDWR|syscall.O_CLOEXEC, 0) + if err != nil { + return fmt.Errorf("open /dev/kvm read-write as the current user: %w (fix host access explicitly; the manager will not run sudo or change groups/ACLs)", err) + } + return file.Close() + } + version, err := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output() + if err != nil || darwinMajor(strings.TrimSpace(string(version))) < 15 { + return errors.New("microVMs require Apple Silicon macOS 15 or newer") + } + output, err := exec.CommandContext(ctx, "sysctl", "-n", "kern.hv_support").Output() + if err != nil || strings.TrimSpace(string(output)) != "1" { + return errors.New("hypervisor.framework is unavailable to the current user") + } + return nil +} + +func supportedPlatform(goos, goarch string) bool { + return goos == "linux" && goarch == "amd64" +} + +func darwinMajor(version string) int { + major, _ := strconv.Atoi(strings.SplitN(version, ".", 2)[0]) + return major +} + +// Download obtains, digest-verifies, and safely extracts one release bundle. +func (o *DefaultOperations) Download(ctx context.Context, release Release, destination string) (string, error) { //nolint:gocyclo // explicit fail-closed download transaction + if release.bundlePath == "" { + u, err := url.Parse(release.URL) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" { + return "", errors.New("release URL must be an absolute HTTPS URL without userinfo or fragment") + } + } + if err := secureMkdirAll(destination); err != nil { + return "", err + } + archivePath := filepath.Join(destination, "release.tar.gz") + if err := refuseSymlink(archivePath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return "", err + } + var source io.ReadCloser + if release.bundlePath != "" { + if release.bundleFile != nil { + info, err := release.bundleFile.Stat() + if err != nil { + return "", err + } + source = io.NopCloser(io.NewSectionReader(release.bundleFile, 0, info.Size())) + } else { + file, err := os.Open(release.bundlePath) // #nosec G304 -- compatibility path for package-internal tests; development descriptors provide bundleFile. + if err != nil { + return "", err + } + source = file + } + } else { + baseClient := o.HTTPClient + if baseClient == nil { + baseClient = &http.Client{Timeout: 10 * time.Minute} + } + client := *baseClient + previousRedirect := client.CheckRedirect + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" || req.URL.Host == "" || req.URL.User != nil || req.URL.Fragment != "" { + return errors.New("release redirect must remain an absolute HTTPS URL") + } + if previousRedirect != nil { + return previousRedirect(req, via) + } + if len(via) >= 10 { + return errors.New("too many release redirects") + } + return nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, release.URL, nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return "", fmt.Errorf("release download returned HTTP %d", resp.StatusCode) + } + source = resp.Body + } + defer func() { _ = source.Close() }() + tmp, err := os.OpenFile(archivePath+".tmp", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return "", err + } + hash := sha256.New() + written, copyErr := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(source, maxReleaseBundleBytes+1)) + closeErr := tmp.Close() + if copyErr != nil { + _ = os.Remove(tmp.Name()) + return "", copyErr + } + if closeErr != nil { + _ = os.Remove(tmp.Name()) + return "", closeErr + } + if written > maxReleaseBundleBytes { + _ = os.Remove(tmp.Name()) + return "", errors.New("release bundle exceeds size limit") + } + if hex.EncodeToString(hash.Sum(nil)) != release.SHA256 { + _ = os.Remove(tmp.Name()) + return "", errors.New("release bundle SHA-256 mismatch") + } + if err := os.Rename(tmp.Name(), archivePath); err != nil { + _ = os.Remove(tmp.Name()) + return "", err + } + unpacked := filepath.Join(destination, "unpacked") + if err := os.RemoveAll(unpacked); err != nil { + return "", err + } + if err := secureMkdirAll(unpacked); err != nil { + return "", err + } + if err := extractReleaseBundle(archivePath, unpacked); err != nil { + _ = os.RemoveAll(unpacked) + return "", err + } + goos, goarch := o.platform() + manifest := filepath.Join(unpacked, "microvm-release-"+goos+"-"+goarch+".json") + if !regularFile(manifest) { + _ = os.RemoveAll(unpacked) + return "", errors.New("release bundle omitted the platform manifest") + } + return manifest, nil +} + +func extractReleaseBundle(archivePath, destination string) error { //nolint:gocyclo // archive safety checks remain explicit + file, err := os.Open(archivePath) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + zr, err := gzip.NewReader(file) + if err != nil { + return err + } + defer func() { _ = zr.Close() }() + tr := tar.NewReader(zr) + count := 0 + for { + header, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + count++ + if count > 10000 { + return errors.New("release bundle has too many entries") + } + name := filepath.Clean(filepath.FromSlash(header.Name)) + if name == "." || filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(filepath.Separator)) { + return fmt.Errorf("unsafe release bundle path %q", header.Name) + } + target := filepath.Join(destination, name) + if !strings.HasPrefix(target, destination+string(filepath.Separator)) { + return fmt.Errorf("unsafe release bundle path %q", header.Name) + } + switch header.Typeflag { + case tar.TypeDir: + if err := secureMkdirAll(target); err != nil { + return err + } + case tar.TypeReg: + if header.Mode < 0 || header.Mode > 0o777 { + return fmt.Errorf("invalid release bundle mode for %s", header.Name) + } + if err := secureMkdirAll(filepath.Dir(target)); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, os.FileMode(header.Mode)&0o700) // #nosec G115 -- bounded to Unix permission bits above. + if err != nil { + return err + } + _, copyErr := io.CopyN(out, tr, header.Size) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + case tar.TypeSymlink, tar.TypeLink: + return fmt.Errorf("release bundle symlink or hard link is forbidden: %s", header.Name) + default: + return fmt.Errorf("unsupported release bundle entry type for %s", header.Name) + } + } +} + +// Verify rechecks that the extracted manifest still belongs to the verified bundle. +func (*DefaultOperations) Verify(_ context.Context, release Release, manifest string) error { + archive := filepath.Join(filepath.Dir(filepath.Dir(manifest)), "release.tar.gz") + file, err := os.Open(archive) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + hash := sha256.New() + if _, err := io.Copy(hash, io.LimitReader(file, maxReleaseBundleBytes+1)); err != nil { + return err + } + if hex.EncodeToString(hash.Sum(nil)) != release.SHA256 { + return errors.New("downloaded release changed after bootstrap verification") + } + return nil +} + +// Install executes the installer from the verified bundle. +func (o *DefaultOperations) Install(ctx context.Context, manifest, installRoot string) (InstalledArtifacts, error) { + installerData, err := readBundleInstaller(manifest) + if err != nil { + return InstalledArtifacts{}, err + } + installer := filepath.Join(filepath.Dir(manifest), ".bundle-installer") + if err := atomicWriteMode(installer, installerData, 0o700); err != nil { + return InstalledArtifacts{}, fmt.Errorf("materialize verified installer: %w", err) + } + cmd := exec.CommandContext(ctx, installer, manifest, installRoot) // #nosec G204 -- executable and each argument are distinct verified paths. + if output, err := cmd.CombinedOutput(); err != nil { + return InstalledArtifacts{}, fmt.Errorf("release installer: %w: %s", err, strings.TrimSpace(string(output))) + } + data, err := os.ReadFile(filepath.Join(installRoot, "microvmd-artifacts.json")) + if err != nil { + return InstalledArtifacts{}, err + } + var installed InstalledArtifacts + if err := json.Unmarshal(data, &installed); err != nil { + return InstalledArtifacts{}, err + } + if installed.Schema != "mecatl-microvmd-artifacts/v1" { + return InstalledArtifacts{}, errors.New("release installer returned unsupported artifact schema") + } + goos, goarch := o.platform() + source := filepath.Join(filepath.Dir(manifest), "mecatl-microvmd-"+goos+"-"+goarch) + target := filepath.Join(filepath.Dir(installRoot), "bin", "mecatl-microvmd") + if err := copyVerifiedExecutable(source, target); err != nil { + return InstalledArtifacts{}, fmt.Errorf("install verified microvmd binary: %w", err) + } + return installed, nil +} + +func readBundleInstaller(manifest string) ([]byte, error) { + archivePath := filepath.Join(filepath.Dir(filepath.Dir(manifest)), "release.tar.gz") + file, err := os.Open(archivePath) + if err != nil { + return nil, fmt.Errorf("open verified release bundle: %w", err) + } + defer func() { _ = file.Close() }() + zr, err := gzip.NewReader(file) + if err != nil { + return nil, err + } + defer func() { _ = zr.Close() }() + tr := tar.NewReader(zr) + for count := 0; count < 10000; count++ { + header, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + if filepath.Clean(filepath.FromSlash(header.Name)) != "install-microvm-release.sh" { + continue + } + if header.Typeflag != tar.TypeReg || header.Size < 0 || header.Size > 16<<20 { + return nil, errors.New("verified release bundle installer is not a bounded regular file") + } + data, err := io.ReadAll(io.LimitReader(tr, header.Size+1)) + if err != nil { + return nil, err + } + if int64(len(data)) != header.Size { + return nil, errors.New("verified release bundle installer is truncated") + } + return data, nil + } + return nil, errors.New("verified release bundle omitted its installer") +} + +func copyVerifiedExecutable(source, target string) error { + info, err := os.Lstat(source) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > 256<<20 { + return errors.New("verified microvmd binary is not a bounded regular file") + } + if err := secureMkdirAll(filepath.Dir(target)); err != nil { + return err + } + in, err := os.Open(source) + if err != nil { + return err + } + defer func() { _ = in.Close() }() + tmp, err := os.OpenFile(target+".tmp", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700) // #nosec G302 -- installed executable is owner-only. + if err != nil { + return err + } + defer func() { _ = os.Remove(tmp.Name()) }() + written, copyErr := io.Copy(tmp, io.LimitReader(in, 256<<20+1)) + closeErr := tmp.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if written > 256<<20 { + return errors.New("verified microvmd binary exceeds size limit") + } + return os.Rename(tmp.Name(), target) +} + +// Running reports whether an owner-only socket accepts a local connection. +func (*DefaultOperations) Running(ctx context.Context, paths Paths) (bool, error) { + if err := validateOwnerSocket(paths.Socket); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + dialer := net.Dialer{Timeout: time.Second} + conn, err := dialer.DialContext(ctx, "unix", paths.Socket) + if err != nil { + return false, nil + } + _ = conn.Close() + return true, nil +} + +// DaemonInfo queries the process serving the owner-only socket. +func (*DefaultOperations) DaemonInfo(ctx context.Context, paths Paths) (DaemonInfo, error) { + if err := validateOwnerSocket(paths.Socket); err != nil { + return DaemonInfo{}, err + } + client, err := microvmclient.New("unix://" + paths.Socket) + if err != nil { + return DaemonInfo{}, err + } + return client.DaemonInfo(ctx) +} + +// Start launches the installed daemon with absolute manager paths. +func (*DefaultOperations) Start(_ context.Context, paths Paths) error { + if !regularFile(paths.DaemonBinary) { + return errors.New("verified mecatl-microvmd binary is missing") + } + log, err := os.OpenFile(filepath.Join(paths.StateDir, "microvmd.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + args := []string{"--state-dir", paths.StateDir, "--socket", paths.Socket, "--config", paths.ConfigFile} + cmd := exec.Command(paths.DaemonBinary, args...) // #nosec G204 -- absolute manager-owned executable and separate fixed arguments. + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + cmd.Stdout, cmd.Stderr = log, log + if err := cmd.Start(); err != nil { + _ = log.Close() + return err + } + pid := []byte(strconv.Itoa(cmd.Process.Pid) + "\n") + if err := atomicWrite(filepath.Join(paths.StateDir, "microvmd.pid"), pid); err != nil { + _ = cmd.Process.Kill() + _ = log.Close() + return err + } + startIdentity, err := processStartIdentity(cmd.Process.Pid) + if err != nil { + _ = cmd.Process.Kill() + _ = log.Close() + return fmt.Errorf("record microvmd process-start identity: %w", err) + } + binaryIdentity, err := fileSHA256(paths.DaemonBinary) + if err != nil { + _ = cmd.Process.Kill() + _ = log.Close() + return err + } + record, err := json.Marshal(managedProcessRecord{Schema: managedProcessSchema, PID: cmd.Process.Pid, ProcessIdentity: startIdentity, BinaryIdentity: binaryIdentity, Args: append([]string{paths.DaemonBinary}, args...), Socket: paths.Socket}) + if err != nil { + _ = cmd.Process.Kill() + _ = log.Close() + return err + } + if err := atomicWrite(filepath.Join(paths.StateDir, "microvmd.process.json"), append(record, '\n')); err != nil { + _ = cmd.Process.Kill() + _ = log.Close() + return err + } + _ = cmd.Process.Release() + return log.Close() +} + +// WaitSocket waits until the daemon publishes an owner-only Unix socket. +func (*DefaultOperations) WaitSocket(ctx context.Context, socket string) error { + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 30*time.Second) + defer cancel() + } + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + dialer := net.Dialer{Timeout: 250 * time.Millisecond} + for { + if err := validateOwnerSocket(socket); err == nil { + conn, dialErr := dialer.DialContext(ctx, "unix", socket) + if dialErr == nil { + _ = conn.Close() + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +// Doctor verifies the identity of the daemon actually serving the configured socket +// and returns its structured, line-oriented readiness report. +func (o *DefaultOperations) Doctor(ctx context.Context, paths Paths) (string, error) { + expected, err := expectedDaemonInfo(paths) + if err != nil { + return "", fmt.Errorf("compute expected microvmd identity: %w", err) + } + serving, err := o.DaemonInfo(ctx, paths) + if err != nil { + return "", fmt.Errorf("query serving microvmd identity: %w", err) + } + if !serving.Equal(expected) { + return "", errors.New("serving microvmd identity does not match installed release, binary, policy, profiles, config, and socket") + } + cmd := exec.CommandContext(ctx, paths.DaemonBinary, "--doctor", "--state-dir", paths.StateDir, "--socket", paths.Socket, "--config", paths.ConfigFile) // #nosec G204 -- exact identity was authenticated above; arguments are fixed manager paths. + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("microvmd readiness checks: %w: %s", err, strings.TrimSpace(string(output))) + } + return string(output), nil +} + +// Stop signals only the manager-recorded daemon process. +func (*DefaultOperations) Stop(ctx context.Context, paths Paths) error { + data, err := os.ReadFile(filepath.Join(paths.StateDir, "microvmd.pid")) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || pid <= 1 { + return errors.New("invalid managed microvmd pid") + } + if err := validateOwnerSocket(paths.Socket); err != nil { + return fmt.Errorf("refusing to signal recorded daemon without its owner-only socket: %w", err) + } + recordData, err := os.ReadFile(filepath.Join(paths.StateDir, "microvmd.process.json")) + if err != nil { + return fmt.Errorf("managed microvmd process identity is unavailable; use the user service manager and do not signal the recorded PID: %w", err) + } + var record managedProcessRecord + if err := json.Unmarshal(recordData, &record); err != nil || record.Schema != managedProcessSchema || record.PID != pid { + return errors.New("managed microvmd process identity is invalid; use the user service manager and do not signal the recorded PID") + } + handle, err := openManagedProcess(pid) + if err != nil { + return fmt.Errorf("open exact managed microvmd process: %w", err) + } + defer func() { _ = handle.close() }() + if err := validateManagedProcess(record, paths); err != nil { + return err + } + if err := handle.signal(); err != nil { + return err + } + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 10*time.Second) + defer cancel() + } + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := os.Lstat(paths.Socket); errors.Is(err, fs.ErrNotExist) { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("wait for microvmd shutdown: %w", ctx.Err()) + case <-ticker.C: + } + } +} + +func validateManagedProcess(record managedProcessRecord, paths Paths) error { //nolint:gocyclo // fail-closed cross-platform identity validation is intentionally explicit + if record.Socket != paths.Socket || len(record.Args) != 7 || record.Args[0] != paths.DaemonBinary || record.Args[1] != "--state-dir" || record.Args[2] != paths.StateDir || record.Args[3] != "--socket" || record.Args[4] != paths.Socket || record.Args[5] != "--config" || record.Args[6] != paths.ConfigFile { + return errors.New("recorded microvmd launch arguments or socket do not match this manager") + } + identity, err := processStartIdentity(record.PID) + if err != nil { + return fmt.Errorf("read managed daemon process-start identity: %w", err) + } + if identity != record.ProcessIdentity { + return errors.New("recorded microvmd PID was reused by a different process start") + } + if runtime.GOOS == "linux" { + runningPath := filepath.Join("/proc", strconv.Itoa(record.PID), "exe") + running, err := os.Readlink(runningPath) + if err != nil { + return fmt.Errorf("read managed daemon process identity: %w", err) + } + expected, err := filepath.EvalSymlinks(paths.DaemonBinary) + if err != nil { + return fmt.Errorf("resolve installed daemon identity: %w", err) + } + if strings.TrimSuffix(running, " (deleted)") != expected { + return errors.New("recorded microvmd PID belongs to a different executable") + } + binaryIdentity, err := fileSHA256(runningPath) + if err != nil || binaryIdentity != record.BinaryIdentity { + return errors.New("recorded microvmd process has a different binary identity") + } + args, err := processArgs(record.PID) + if err != nil || !equalStrings(args, record.Args) { + return errors.New("recorded microvmd process has different launch arguments") + } + return nil + } + if runtime.GOOS == "darwin" { + output, err := exec.Command("ps", "-p", strconv.Itoa(record.PID), "-o", "command=").Output() + if err != nil { + return fmt.Errorf("read managed daemon process identity: %w", err) + } + command := strings.Fields(string(output)) + if !equalStrings(command, record.Args) { + return errors.New("recorded microvmd process has different launch arguments") + } + return nil + } + return errors.New("managed daemon stop is unsupported on this platform") +} + +func processStartIdentity(pid int) (string, error) { + if runtime.GOOS == "linux" { + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) + if err != nil { + return "", err + } + closeParen := strings.LastIndexByte(string(data), ')') + if closeParen < 0 { + return "", errors.New("malformed process stat") + } + fields := strings.Fields(string(data[closeParen+1:])) + if len(fields) <= 19 { + return "", errors.New("process stat omits start time") + } + bootID, err := os.ReadFile("/proc/sys/kernel/random/boot_id") + if err != nil { + return "", err + } + return strings.TrimSpace(string(bootID)) + ":" + fields[19], nil + } + if runtime.GOOS == "darwin" { + output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "lstart=").Output() + if err != nil { + return "", err + } + identity := strings.Join(strings.Fields(string(output)), " ") + if identity == "" { + return "", errors.New("process start identity is empty") + } + return identity, nil + } + return "", errors.New("process start identity is unsupported on this platform") +} + +func processArgs(pid int) ([]string, error) { + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline")) + if err != nil { + return nil, err + } + parts := strings.Split(strings.TrimSuffix(string(data), "\x00"), "\x00") + if len(parts) == 1 && parts[0] == "" { + return nil, errors.New("process arguments are empty") + } + return parts, nil +} + +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 +} + +func validateOwnerSocket(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || info.Mode()&os.ModeSocket == 0 { + return errors.New("microvmd endpoint is not a Unix socket") + } + if info.Mode().Perm()&0o077 != 0 { + return errors.New("microvmd socket is not owner-only") + } + if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != os.Getuid() { + return errors.New("microvmd socket is not owned by the current user") + } + return nil +} diff --git a/internal/adapter/microvmmanager/default_operations_test.go b/internal/adapter/microvmmanager/default_operations_test.go new file mode 100644 index 000000000..c4aec2d72 --- /dev/null +++ b/internal/adapter/microvmmanager/default_operations_test.go @@ -0,0 +1,276 @@ +package microvmmanager + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestDefaultOperationsPreflightInvokesAndPropagatesUserNamespaceProbe(t *testing.T) { + probeErr := errors.New("injected production userns probe failure") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + called := false + o := &DefaultOperations{GOOS: "linux", GOARCH: "amd64", usernsProbe: func(got context.Context) error { + called = true + if got != ctx { + t.Fatal("Preflight did not propagate its context to the userns probe") + } + return probeErr + }} + if err := o.Preflight(ctx, Paths{}); !errors.Is(err, probeErr) { + t.Fatalf("Preflight error = %v, want userns probe error", err) + } + if !called { + t.Fatal("Preflight skipped the production userns probe") + } +} + +func TestMicroVMLivePlatformBoundary(t *testing.T) { + if !supportedPlatform("linux", "amd64") { + t.Fatal("Linux amd64 live platform was rejected") + } + for _, platform := range [][2]string{{"linux", "arm64"}, {"darwin", "arm64"}, {"windows", "amd64"}} { + if supportedPlatform(platform[0], platform[1]) { + t.Fatalf("unsupported live platform accepted: %s/%s", platform[0], platform[1]) + } + } +} + +func TestMicroVMUserBootstrap_Scenario14_PIDReuseWithSameBinaryIsRejected(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("process-start identity is Unix-only") + } + paths := testPaths(t.TempDir()) + record := managedProcessRecord{ + Schema: managedProcessSchema, PID: os.Getpid(), ProcessIdentity: "stale-start-token", BinaryIdentity: "same-binary", + Args: []string{paths.DaemonBinary, "--state-dir", paths.StateDir, "--socket", paths.Socket, "--config", paths.ConfigFile}, Socket: paths.Socket, + } + if err := validateManagedProcess(record, paths); err == nil || !strings.Contains(err.Error(), "reused") { + t.Fatalf("same-binary PID reuse error = %v, want process-start rejection", err) + } +} + +func TestDefaultOperationsWaitSocketRejectsStaleSocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "microvmd.sock") + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) + if err != nil { + t.Fatal(err) + } + listener.SetUnlinkOnClose(false) + if err := os.Chmod(path, 0o600); err != nil { + t.Fatal(err) + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- (&DefaultOperations{}).WaitSocket(ctx, path) }() + select { + case err := <-done: + t.Fatalf("WaitSocket accepted stale socket: %v", err) + case <-time.After(100 * time.Millisecond): + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + live, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + defer live.Close() + if err := os.Chmod(path, 0o600); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatalf("WaitSocket rejected live socket: %v", err) + } + case <-ctx.Done(): + t.Fatal("WaitSocket did not observe live daemon socket") + } +} + +func TestMicroVMUserBootstrap_Scenario7_DownloadVerifiesBeforeExtraction(t *testing.T) { + bundle := releaseBundle(t, "microvm-release-linux-amd64.json", []byte(`{"schema":"mecatl-microvm-release/v2"}`)) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(bundle) })) + defer srv.Close() + digest := fmt.Sprintf("%x", sha256.Sum256(bundle)) + ops := &DefaultOperations{HTTPClient: srv.Client(), GOOS: "linux", GOARCH: "amd64"} + dest := filepath.Join(t.TempDir(), "download") + manifest, err := ops.Download(context.Background(), Release{URL: srv.URL, SHA256: digest}, dest) + if err != nil { + t.Fatal(err) + } + if err := ops.Verify(context.Background(), Release{URL: srv.URL, SHA256: digest}, manifest); err != nil { + t.Fatal(err) + } + if !regularFile(manifest) { + t.Fatalf("manifest was not safely extracted: %s", manifest) + } + + badDest := filepath.Join(t.TempDir(), "bad") + if _, err := ops.Download(context.Background(), Release{URL: srv.URL, SHA256: strings.Repeat("0", 64)}, badDest); err == nil { + t.Fatal("digest mismatch was accepted") + } + if _, err := os.Stat(filepath.Join(badDest, "unpacked")); !os.IsNotExist(err) { + t.Fatalf("unverified bundle was extracted: %v", err) + } +} + +func TestMicroVMFirstRunRepair_InstallerComesFromVerifiedBundle(t *testing.T) { + root := t.TempDir() + bundle := filepath.Join(root, "unpacked") + if err := os.MkdirAll(bundle, 0o700); err != nil { + t.Fatal(err) + } + manifest := filepath.Join(bundle, "microvm-release-linux-amd64.json") + if err := os.WriteFile(manifest, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + installer := filepath.Join(bundle, "install-microvm-release.sh") + script := "#!/bin/sh\nset -eu\nprintf '%s' '{\"schema\":\"mecatl-microvmd-artifacts/v1\",\"artifacts\":[]}' > \"$2/microvmd-artifacts.json\"\n" + if err := os.WriteFile(installer, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + writeInstallerBundle(t, filepath.Join(root, "release.tar.gz"), installer) + if err := os.WriteFile(filepath.Join(bundle, "mecatl-microvmd-"+runtime.GOOS+"-"+runtime.GOARCH), []byte("daemon"), 0o700); err != nil { + t.Fatal(err) + } + installRoot := filepath.Join(root, "installed") + if err := os.MkdirAll(installRoot, 0o700); err != nil { + t.Fatal(err) + } + installed, err := (&DefaultOperations{}).Install(context.Background(), manifest, installRoot) + if err != nil { + t.Fatalf("Install with verified bundled installer: %v", err) + } + if installed.Artifacts == nil { + t.Fatal("bundled installer result was not decoded") + } +} + +func TestMicroVMUserBootstrap_Scenario8_BundleSymlinkIsRejected(t *testing.T) { + var raw bytes.Buffer + zw := gzip.NewWriter(&raw) + tw := tar.NewWriter(zw) + if err := tw.WriteHeader(&tar.Header{Name: "microvm-release-linux-amd64.json", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd", Mode: 0o777}); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + bundle := raw.Bytes() + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(bundle) })) + defer srv.Close() + ops := &DefaultOperations{HTTPClient: srv.Client(), GOOS: "linux", GOARCH: "amd64"} + _, err := ops.Download(context.Background(), Release{URL: srv.URL, SHA256: fmt.Sprintf("%x", sha256.Sum256(bundle))}, filepath.Join(t.TempDir(), "download")) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("error = %v, want symlink refusal", err) + } +} + +func TestMicroVMUsabilityRepair_Scenario2_InstallerComesFromVerifiedBundle(t *testing.T) { + root := t.TempDir() + assets := filepath.Join(root, "unpacked") + if err := os.MkdirAll(assets, 0o700); err != nil { + t.Fatal(err) + } + manifest := filepath.Join(assets, "microvm-release-linux-amd64.json") + if err := os.WriteFile(manifest, []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(assets, "mecatl-microvmd-linux-amd64"), []byte("daemon"), 0o700); err != nil { + t.Fatal(err) + } + packagedMarker := filepath.Join(root, "packaged-ran") + packagedInstaller := filepath.Join(assets, "install-microvm-release.sh") + writeInstallerFixture(t, packagedInstaller, packagedMarker) + writeInstallerBundle(t, filepath.Join(root, "release.tar.gz"), packagedInstaller) + + installRoot := filepath.Join(root, "install", "artifacts") + ops := &DefaultOperations{GOOS: "linux", GOARCH: "amd64"} + if _, err := ops.Install(context.Background(), manifest, installRoot); err != nil { + t.Fatalf("install with packaged bundle member: %v", err) + } + if _, err := os.Stat(packagedMarker); err != nil { + t.Fatalf("packaged installer did not execute: %v", err) + } +} + +func writeInstallerFixture(t *testing.T, path, marker string) { + t.Helper() + script := "#!/bin/sh\nset -eu\ntouch '" + marker + "'\nmkdir -p \"$2\"\nprintf '%s' '{\"schema\":\"mecatl-microvmd-artifacts/v1\",\"artifacts\":[]}' > \"$2/microvmd-artifacts.json\"\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } +} + +func writeInstallerBundle(t *testing.T, path, installer string) { + t.Helper() + data, err := os.ReadFile(installer) + if err != nil { + t.Fatal(err) + } + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + zw := gzip.NewWriter(file) + tw := tar.NewWriter(zw) + if err := tw.WriteHeader(&tar.Header{Name: "./install-microvm-release.sh", Typeflag: tar.TypeReg, Size: int64(len(data)), Mode: 0o755}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(data); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func releaseBundle(t *testing.T, name string, data []byte) []byte { + t.Helper() + var raw bytes.Buffer + zw := gzip.NewWriter(&raw) + tw := tar.NewWriter(zw) + if err := tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeReg, Size: int64(len(data)), Mode: 0o600}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(data); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return raw.Bytes() +} diff --git a/internal/adapter/microvmmanager/development_release_microvm_dev.go b/internal/adapter/microvmmanager/development_release_microvm_dev.go new file mode 100644 index 000000000..8bcb590ad --- /dev/null +++ b/internal/adapter/microvmmanager/development_release_microvm_dev.go @@ -0,0 +1,168 @@ +//go:build microvm_dev + +package microvmmanager + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "golang.org/x/sys/unix" +) + +const DevelopmentReleaseSchema = "mecatl-microvm-development-release/v1" + +// DevelopmentReleaseDescriptor is the deliberately narrow local equivalent of +// release-stamped bootstrap defaults. It is available only in microvm_dev builds. +type DevelopmentReleaseDescriptor struct { + Schema string `json:"schema"` + Platform string `json:"platform"` + SourceBuildIdentity string `json:"source_build_identity"` + BundlePath string `json:"bundle_path"` + BundleSHA256 string `json:"bundle_sha256"` + PublicKeyPath string `json:"public_key_path"` + PublicKeyIdentity string `json:"public_key_identity"` + PolicyRevision string `json:"policy_revision"` +} + +// ReadyRequestFromDevelopmentDescriptor validates local bootstrap inputs before +// returning the same readiness request consumed by the production manager. +func ReadyRequestFromDevelopmentDescriptor(path, sourceBuildIdentity string, egress ...GuestEgressSelection) (ReadyRequest, error) { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + return ReadyRequest{}, errors.New("microVM development releases require linux-amd64") + } + data, err := readOwnerOnlyRegular(path, 1<<20) + if err != nil { + return ReadyRequest{}, fmt.Errorf("read microVM development release descriptor: %w", err) + } + var descriptor DevelopmentReleaseDescriptor + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&descriptor); err != nil { + return ReadyRequest{}, fmt.Errorf("decode microVM development release descriptor: %w", err) + } + if decoder.Decode(&struct{}{}) != io.EOF { + return ReadyRequest{}, errors.New("microVM development release descriptor must contain exactly one JSON object") + } + if descriptor.Schema != DevelopmentReleaseSchema || descriptor.Platform != "linux-amd64" || descriptor.SourceBuildIdentity == "" || descriptor.SourceBuildIdentity != sourceBuildIdentity || descriptor.PolicyRevision == "" { + return ReadyRequest{}, errors.New("microVM development release descriptor identity does not match this source build") + } + if !lowerSHA256(descriptor.BundleSHA256) || !strings.HasPrefix(descriptor.PublicKeyIdentity, "sha256:") || !lowerSHA256(strings.TrimPrefix(descriptor.PublicKeyIdentity, "sha256:")) { + return ReadyRequest{}, errors.New("microVM development release descriptor contains an invalid digest identity") + } + bundle, err := openOwnerOnlyRegular(descriptor.BundlePath, maxReleaseBundleBytes) + if err != nil { + return ReadyRequest{}, fmt.Errorf("validate local microVM release bundle: %w", err) + } + keyFile, err := openOwnerOnlyRegular(descriptor.PublicKeyPath, 1<<20) + if err != nil { + _ = bundle.Close() + return ReadyRequest{}, fmt.Errorf("validate local microVM release public key: %w", err) + } + key, err := readBoundedFile(keyFile, 1<<20) + _ = keyFile.Close() + if err != nil { + _ = bundle.Close() + return ReadyRequest{}, fmt.Errorf("read local microVM release public key: %w", err) + } + if "sha256:"+bytesSHA256Hex(key) != descriptor.PublicKeyIdentity { + _ = bundle.Close() + return ReadyRequest{}, errors.New("local microVM release public-key identity mismatch") + } + bundleDigest, err := openedFileSHA256(bundle, maxReleaseBundleBytes) + if err != nil { + _ = bundle.Close() + return ReadyRequest{}, fmt.Errorf("identify local microVM release bundle: %w", err) + } + if bundleDigest != descriptor.BundleSHA256 { + _ = bundle.Close() + return ReadyRequest{}, errors.New("local microVM release bundle SHA-256 mismatch") + } + request := ReadyRequest{ + Release: Release{bundlePath: filepath.Clean(descriptor.BundlePath), bundleFile: bundle, SHA256: descriptor.BundleSHA256}, + Policy: Policy{ + PolicyRevision: descriptor.PolicyRevision, PublicKeyIdentity: descriptor.PublicKeyIdentity, publicKey: key, + RequiredAttestations: requiredMicroVMAttestations(), GuestEgressMode: GuestEgressPermissive, Resources: defaultMicroVMResources(), + }, + PreserveExistingGuestEgress: len(egress) == 0, + } + if err := applyGuestEgress(&request, egress); err != nil { + _ = bundle.Close() + return ReadyRequest{}, err + } + return request, nil +} + +func readOwnerOnlyRegular(path string, maxBytes int64) ([]byte, error) { + file, err := openOwnerOnlyRegular(path, maxBytes) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + return readBoundedFile(file, maxBytes) +} + +func openOwnerOnlyRegular(path string, maxBytes int64) (*os.File, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return nil, errors.New("path must be absolute and clean") + } + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) // #nosec G304 -- local development input is opened without following the final symlink. + if err != nil { + return nil, err + } + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + _ = unix.Close(fd) + return nil, err + } + file := os.NewFile(uintptr(fd), path) + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + if stat.Uid != uint32(os.Getuid()) || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 || info.Size() < 0 || info.Size() > maxBytes { + _ = file.Close() + return nil, errors.New("path must be an owner-only regular non-symlink file owned by the current user") + } + return file, nil +} + +func readBoundedFile(file *os.File, maxBytes int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > maxBytes { + return nil, errors.New("file exceeds size limit") + } + return data, nil +} + +func openedFileSHA256(file *os.File, maxBytes int64) (string, error) { + hash := sha256.New() + written, err := io.Copy(hash, io.NewSectionReader(file, 0, maxBytes+1)) + if err != nil { + return "", err + } + if written > maxBytes { + return "", errors.New("file exceeds size limit") + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func lowerSHA256(value string) bool { + return len(value) == 64 && strings.Trim(value, "0123456789abcdef") == "" +} + +func bytesSHA256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/adapter/microvmmanager/development_release_microvm_dev_test.go b/internal/adapter/microvmmanager/development_release_microvm_dev_test.go new file mode 100644 index 000000000..e6e3f3bbe --- /dev/null +++ b/internal/adapter/microvmmanager/development_release_microvm_dev_test.go @@ -0,0 +1,348 @@ +//go:build microvm_dev + +package microvmmanager + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +type rejectHTTPTransport struct{ called bool } + +func (r *rejectHTTPTransport) RoundTrip(*http.Request) (*http.Response, error) { + r.called = true + return nil, errors.New("HTTP must not be used") +} + +func TestDevelopmentReleaseDescriptorIsStrictAndLocal(t *testing.T) { + root := t.TempDir() + bundle := filepath.Join(root, "release.tar.gz") + writeDevelopmentBundle(t, bundle) + key := filepath.Join(root, "publisher.pub") + writeOwnerOnly(t, key, []byte("development public key")) + descriptor := filepath.Join(root, "release.json") + body := validDevelopmentDescriptor(t, bundle, key) + writeOwnerOnly(t, descriptor, body) + + request, err := ReadyRequestFromDevelopmentDescriptor(descriptor, "source-test") + if err != nil { + t.Fatal(err) + } + if request.Release.URL != "" || request.Release.bundlePath != bundle || request.Release.bundleFile == nil || request.Policy.PublicKey != "" || string(request.Policy.publicKey) != "development public key" || request.Policy.PolicyRevision != "development-test" { + t.Fatalf("development request = %#v", request) + } + + bundleLink := filepath.Join(root, "release-link.tar.gz") + if err := os.Symlink(bundle, bundleLink); err != nil { + t.Fatal(err) + } + keyLink := filepath.Join(root, "publisher-link.pub") + if err := os.Symlink(key, keyLink); err != nil { + t.Fatal(err) + } + unsafe := []struct { + name string + edit func(map[string]any) + }{ + {"unknown field", func(value map[string]any) { value["unexpected"] = true }}, + {"wrong schema", func(value map[string]any) { value["schema"] = "other" }}, + {"wrong platform", func(value map[string]any) { value["platform"] = "linux-arm64" }}, + {"wrong build", func(value map[string]any) { value["source_build_identity"] = "other" }}, + {"wrong bundle digest", func(value map[string]any) { value["bundle_sha256"] = strings.Repeat("0", 64) }}, + {"wrong key identity", func(value map[string]any) { value["public_key_identity"] = "sha256:" + strings.Repeat("0", 64) }}, + {"symlink bundle", func(value map[string]any) { value["bundle_path"] = bundleLink }}, + {"symlink key", func(value map[string]any) { value["public_key_path"] = keyLink }}, + } + for _, tc := range unsafe { + t.Run(tc.name, func(t *testing.T) { + var value map[string]any + if err := json.Unmarshal(body, &value); err != nil { + t.Fatal(err) + } + tc.edit(value) + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "descriptor.json") + writeOwnerOnly(t, path, data) + if _, err := ReadyRequestFromDevelopmentDescriptor(path, "source-test"); err == nil { + t.Fatal("unsafe descriptor was accepted") + } + }) + } + + if err := os.Chmod(key, 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadyRequestFromDevelopmentDescriptor(descriptor, "source-test"); err == nil || !strings.Contains(err.Error(), "owner-only") { + t.Fatalf("world-readable key error = %v", err) + } +} + +func TestDevelopmentReleaseInputsRemainBoundAfterPathReplacement(t *testing.T) { + root := t.TempDir() + bundle := filepath.Join(root, "release.tar.gz") + writeDevelopmentBundle(t, bundle) + key := filepath.Join(root, "publisher.pub") + originalKey := []byte("development public key") + writeOwnerOnly(t, key, originalKey) + descriptor := filepath.Join(root, "release.json") + writeOwnerOnly(t, descriptor, validDevelopmentDescriptor(t, bundle, key)) + + request, err := ReadyRequestFromDevelopmentDescriptor(descriptor, "source-test") + if err != nil { + t.Fatal(err) + } + if err := os.Rename(bundle, bundle+".original"); err != nil { + t.Fatal(err) + } + writeOwnerOnly(t, bundle, []byte("replacement bundle")) + writeOwnerOnly(t, key, []byte("replacement key")) + + ops := &DefaultOperations{GOOS: "linux", GOARCH: "amd64"} + manifest, err := ops.Download(context.Background(), request.Release, filepath.Join(root, "download")) + if err != nil { + t.Fatalf("download bound bundle: %v", err) + } + if filepath.Base(manifest) != "microvm-release-linux-amd64.json" { + t.Fatalf("manifest = %q", manifest) + } + + paths := testPaths(filepath.Join(root, "manager")) + if _, err := New(paths, &fakeOps{}).EnsureReady(context.Background(), request); err != nil { + t.Fatalf("EnsureReady: %v", err) + } + configData, err := os.ReadFile(paths.ConfigFile) + if err != nil { + t.Fatal(err) + } + var config struct { + PublicKey string `json:"public_key"` + } + if err := json.Unmarshal(configData, &config); err != nil { + t.Fatal(err) + } + managedKey := filepath.Join(paths.DataDir, "trust", "development-"+strings.TrimPrefix(request.Policy.PublicKeyIdentity, "sha256:")+".pub") + if config.PublicKey != managedKey { + t.Fatalf("configured public key = %q, want %q", config.PublicKey, managedKey) + } + got, err := os.ReadFile(managedKey) + if err != nil { + t.Fatal(err) + } + if string(got) != string(originalKey) { + t.Fatalf("managed public key = %q, want %q", got, originalKey) + } +} + +func TestDevelopmentReleaseDescriptorSymlinkIsRejected(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "release.json") + writeOwnerOnly(t, target, []byte(`{}`)) + link := filepath.Join(root, "release-link.json") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := ReadyRequestFromDevelopmentDescriptor(link, "source-test"); err == nil { + t.Fatal("symlink descriptor was accepted") + } +} + +func TestDevelopmentReleaseDownloadNeverUsesHTTP(t *testing.T) { + root := t.TempDir() + bundle := filepath.Join(root, "release.tar.gz") + writeDevelopmentBundle(t, bundle) + transport := &rejectHTTPTransport{} + ops := &DefaultOperations{HTTPClient: &http.Client{Transport: transport}, GOOS: "linux", GOARCH: "amd64"} + release := Release{bundlePath: bundle, SHA256: fileDigest(t, bundle)} + manifest, err := ops.Download(context.Background(), release, filepath.Join(root, "download")) + if err != nil { + t.Fatal(err) + } + if transport.called || filepath.Base(manifest) != "microvm-release-linux-amd64.json" { + t.Fatalf("local import used HTTP=%v manifest=%q", transport.called, manifest) + } +} + +func TestPreparedDevelopmentReleaseBundleIsImportable(t *testing.T) { + descriptorPath := os.Getenv("MECATL_MICROVM_DEV_RELEASE_DESCRIPTOR") + if descriptorPath == "" { + t.Skip("set MECATL_MICROVM_DEV_RELEASE_DESCRIPTOR after task microvm:dev:prepare") + } + data, err := os.ReadFile(descriptorPath) + if err != nil { + t.Fatal(err) + } + var descriptor DevelopmentReleaseDescriptor + if err := json.Unmarshal(data, &descriptor); err != nil { + t.Fatal(err) + } + request, err := ReadyRequestFromDevelopmentDescriptor(descriptorPath, descriptor.SourceBuildIdentity) + if err != nil { + t.Fatal(err) + } + defer func() { _ = request.Release.bundleFile.Close() }() + + bundle, err := os.Open(descriptor.BundlePath) + if err != nil { + t.Fatal(err) + } + defer func() { _ = bundle.Close() }() + zr, err := gzip.NewReader(bundle) + if err != nil { + t.Fatal(err) + } + defer func() { _ = zr.Close() }() + tr := tar.NewReader(zr) + entries := map[string]bool{} + packageRoot := filepath.Join(filepath.Dir(filepath.Dir(filepath.Dir(descriptor.BundlePath))), "microvm-e2e", descriptor.Platform, "package") + for { + header, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatal(err) + } + name := filepath.Clean(filepath.FromSlash(header.Name)) + if name == "." { + t.Fatalf("development bundle contains extractor-rejected root entry %q", header.Name) + } + entries[name] = true + source := filepath.Join(packageRoot, name) + info, err := os.Lstat(source) + if err != nil { + t.Fatalf("stat production package member %q: %v", name, err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("production package member %q has no Unix metadata", name) + } + if header.FileInfo().Mode() != info.Mode() || header.Uid != int(stat.Uid) || header.Gid != int(stat.Gid) || header.ModTime.Unix() != info.ModTime().Unix() { + t.Fatalf("development bundle changed production package metadata for %q", name) + } + if info.Mode().IsRegular() { + got, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("read development bundle member %q: %v", name, err) + } + want, err := os.ReadFile(source) + if err != nil { + t.Fatalf("read production package member %q: %v", name, err) + } + if !bytes.Equal(got, want) { + t.Fatalf("development bundle changed production package bytes for %q", name) + } + } + } + for _, name := range []string{"microvm-release-linux-amd64.json", "install-microvm-release.sh"} { + if !entries[name] { + t.Fatalf("development bundle omitted %q", name) + } + } + + ops := &DefaultOperations{GOOS: "linux", GOARCH: "amd64"} + root := t.TempDir() + manifest, err := ops.Download(context.Background(), request.Release, filepath.Join(root, "download")) + if err != nil { + t.Fatalf("import prepared development bundle: %v", err) + } + if !regularFile(manifest) || !regularFile(filepath.Join(filepath.Dir(manifest), "install-microvm-release.sh")) { + t.Fatal("prepared development bundle did not extract expected regular files") + } + if err := ops.Verify(context.Background(), request.Release, manifest); err != nil { + t.Fatalf("verify prepared development bundle: %v", err) + } + installed, err := ops.Install(context.Background(), manifest, filepath.Join(root, "installed", "verified")) + if err != nil { + t.Fatalf("install prepared development bundle: %v", err) + } + if len(installed.Artifacts) != 4 { + t.Fatalf("installed admission artifacts = %d, want 4", len(installed.Artifacts)) + } + for _, artifact := range installed.Artifacts { + if artifact.Kind == "execution-image" { + continue + } + if !regularFile(filepath.Join(artifact.Path, "mecatl-guest-agent")) && artifact.Kind == "guest-agent" { + t.Fatalf("installed guest-agent artifact missing payload: %#v", artifact) + } + if artifact.Path == "" { + t.Fatalf("installed %s artifact has no path", artifact.Kind) + } + } +} + +func validDevelopmentDescriptor(t *testing.T, bundle, key string) []byte { + t.Helper() + value := DevelopmentReleaseDescriptor{ + Schema: DevelopmentReleaseSchema, Platform: "linux-amd64", SourceBuildIdentity: "source-test", + BundlePath: bundle, BundleSHA256: fileDigest(t, bundle), PublicKeyPath: key, + PublicKeyIdentity: "sha256:" + fileDigest(t, key), PolicyRevision: "development-test", + } + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func writeDevelopmentBundle(t *testing.T, path string) { + t.Helper() + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(file) + tarWriter := tar.NewWriter(gz) + body := []byte(`{"schema":"mecatl-microvm-release/v2"}`) + if err := tarWriter.WriteHeader(&tar.Header{Name: "microvm-release-linux-amd64.json", Mode: 0o600, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write(body); err != nil { + t.Fatal(err) + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func writeOwnerOnly(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o600); err != nil { + t.Fatal(err) + } +} + +func fileDigest(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/adapter/microvmmanager/egress.go b/internal/adapter/microvmmanager/egress.go new file mode 100644 index 000000000..3f6f6e707 --- /dev/null +++ b/internal/adapter/microvmmanager/egress.go @@ -0,0 +1,244 @@ +package microvmmanager + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "net" + "os" + "strconv" + "strings" + "syscall" + "unicode" +) + +const ( + // GuestEgressPermissive allows unrestricted guest IPv4 egress. + GuestEgressPermissive = "permissive" + // GuestEgressDenyAll blocks all guest network destinations. + GuestEgressDenyAll = "deny-all" + // GuestEgressAllowlist permits only explicitly selected destinations. + GuestEgressAllowlist = "allowlist" +) + +// GuestEgressSelection is the host-operator CLI projection of guest egress. +// It is intentionally not part of any API or project configuration surface. +type GuestEgressSelection struct { + Mode string + Allow []EgressRule +} + +// NewGuestEgressSelection returns the byte-compatible default policy selection. +func NewGuestEgressSelection() GuestEgressSelection { + return GuestEgressSelection{Mode: GuestEgressPermissive} +} + +// ModeValue returns a flag.Value for --microvm-guest-egress. +func (s *GuestEgressSelection) ModeValue() flag.Value { return guestEgressModeValue{s} } + +// AllowValue returns a repeatable flag.Value for --microvm-guest-allow. +func (s *GuestEgressSelection) AllowValue() flag.Value { return guestEgressAllowValue{s} } + +// Validate rejects incomplete or contradictory selections. +func (s GuestEgressSelection) Validate() error { + switch s.Mode { + case GuestEgressPermissive, GuestEgressDenyAll: + if len(s.Allow) != 0 { + return fmt.Errorf("--microvm-guest-allow is valid only with --microvm-guest-egress=%s", GuestEgressAllowlist) + } + case GuestEgressAllowlist: + if len(s.Allow) == 0 { + return errors.New("--microvm-guest-egress=allowlist requires at least one --microvm-guest-allow rule") + } + default: + return fmt.Errorf("invalid --microvm-guest-egress %q (want permissive|deny-all|allowlist)", s.Mode) + } + seen := make(map[EgressRule]struct{}, len(s.Allow)) + for _, rule := range s.Allow { + if err := validateEgressRule(rule); err != nil { + return err + } + if _, ok := seen[rule]; ok { + return fmt.Errorf("duplicate --microvm-guest-allow rule %s", formatEgressRule(rule)) + } + seen[rule] = struct{}{} + } + return nil +} + +type guestEgressModeValue struct{ selection *GuestEgressSelection } + +func (v guestEgressModeValue) String() string { + if v.selection == nil { + return GuestEgressPermissive + } + return v.selection.Mode +} + +func (v guestEgressModeValue) Set(value string) error { + if v.selection == nil { + return errors.New("guest egress selection is nil") + } + if value != GuestEgressPermissive && value != GuestEgressDenyAll && value != GuestEgressAllowlist { + return fmt.Errorf("invalid guest egress mode %q (want permissive|deny-all|allowlist)", value) + } + v.selection.Mode = value + return nil +} + +type guestEgressAllowValue struct{ selection *GuestEgressSelection } + +func (guestEgressAllowValue) String() string { return "" } + +func (v guestEgressAllowValue) Set(value string) error { + if v.selection == nil { + return errors.New("guest egress selection is nil") + } + rule, err := parseEgressRule(value) + if err != nil { + return err + } + for _, existing := range v.selection.Allow { + if existing == rule { + return fmt.Errorf("duplicate guest egress rule %q", value) + } + } + v.selection.Allow = append(v.selection.Allow, rule) + return nil +} + +func parseEgressRule(value string) (EgressRule, error) { + if value == "" || strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.IndexFunc(value, unicode.IsControl) >= 0 { + return EgressRule{}, fmt.Errorf("invalid guest egress rule %q: whitespace and control characters are not allowed", value) + } + authority, protocol, ok := strings.Cut(value, "/") + if !ok || strings.Contains(protocol, "/") { + return EgressRule{}, fmt.Errorf("invalid guest egress rule %q (want HOST:PORT/tcp|udp)", value) + } + host, portText, err := net.SplitHostPort(authority) + if err != nil { + return EgressRule{}, fmt.Errorf("invalid guest egress rule %q (want HOST:PORT/tcp|udp): %w", value, err) + } + host = strings.ToLower(strings.TrimSuffix(host, ".")) + port, err := strconv.ParseUint(portText, 10, 16) + if err != nil || port == 0 { + return EgressRule{}, fmt.Errorf("invalid guest egress port %q: want 1..65535", portText) + } + var protocolNumber uint8 + switch protocol { + case "tcp": + protocolNumber = 6 + case "udp": + protocolNumber = 17 + default: + return EgressRule{}, fmt.Errorf("unsupported guest egress protocol %q (want tcp or udp)", protocol) + } + rule := EgressRule{Hostname: host, Port: uint16(port), Protocol: protocolNumber} + if err := validateEgressRule(rule); err != nil { + return EgressRule{}, err + } + return rule, nil +} + +func validateEgressRule(rule EgressRule) error { + if !validEgressHostname(rule.Hostname) || rule.Port == 0 || (rule.Protocol != 6 && rule.Protocol != 17) { + return fmt.Errorf("invalid guest egress destination %q", formatEgressRule(rule)) + } + return nil +} + +func validEgressHostname(host string) bool { + if host == "" || len(host) > 253 || net.ParseIP(host) != nil || strings.HasPrefix(host, "*.") { + return false + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, r := range label { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } + } + } + return true +} + +func formatEgressRule(rule EgressRule) string { + protocol := strconv.Itoa(int(rule.Protocol)) + switch rule.Protocol { + case 6: + protocol = "tcp" + case 17: + protocol = "udp" + } + return fmt.Sprintf("%s:%d/%s", rule.Hostname, rule.Port, protocol) +} + +// GuestEgressSummary returns the safe operator-facing configured policy summary. +func GuestEgressSummary(selection GuestEgressSelection) string { + switch selection.Mode { + case GuestEgressAllowlist: + return fmt.Sprintf("allowlist (%d destinations)", len(selection.Allow)) + case GuestEgressDenyAll: + return GuestEgressDenyAll + default: + return GuestEgressPermissive + } +} + +func preserveGuestEgressPolicy(path string, policy *Policy) error { + selection, err := readGuestEgressPolicy(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("refusing to replace existing microvmd guest egress policy: %w", err) + } + policy.GuestEgressMode = selection.Mode + policy.GuestAllow = append([]EgressRule(nil), selection.Allow...) + return nil +} + +func readGuestEgressPolicy(path string) (GuestEgressSelection, error) { + info, err := os.Lstat(path) + if err != nil { + return GuestEgressSelection{}, err + } + stat, ownerOK := info.Sys().(*syscall.Stat_t) + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 || !ownerOK || int(stat.Uid) != os.Getuid() { + return GuestEgressSelection{}, errors.New("existing microvmd config is not an owner-only regular file") + } + data, err := os.ReadFile(path) // #nosec G304 -- manager-owned validated absolute config path. + if err != nil { + return GuestEgressSelection{}, err + } + var top map[string]json.RawMessage + if err := json.Unmarshal(data, &top); err != nil { + return GuestEgressSelection{}, errors.New("existing microvmd config is corrupt") + } + raw, ok := top["guest_egress"] + if !ok { + return GuestEgressSelection{}, errors.New("existing microvmd config omits guest egress policy") + } + var stored struct { + Mode string + Allow []EgressRule + } + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&stored); err != nil { + return GuestEgressSelection{}, errors.New("existing microvmd guest egress policy is corrupt") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return GuestEgressSelection{}, errors.New("existing microvmd guest egress policy is corrupt") + } + selection := GuestEgressSelection{Mode: stored.Mode, Allow: stored.Allow} + if err := selection.Validate(); err != nil { + return GuestEgressSelection{}, errors.New("existing microvmd guest egress policy is unsafe") + } + return selection, nil +} diff --git a/internal/adapter/microvmmanager/egress_test.go b/internal/adapter/microvmmanager/egress_test.go new file mode 100644 index 000000000..876a8900f --- /dev/null +++ b/internal/adapter/microvmmanager/egress_test.go @@ -0,0 +1,103 @@ +package microvmmanager + +import ( + "encoding/base64" + "encoding/json" + "runtime" + "strings" + "testing" +) + +func TestGuestEgressSelectionParsesAndNormalizes(t *testing.T) { + selection := NewGuestEgressSelection() + if err := selection.ModeValue().Set("allowlist"); err != nil { + t.Fatal(err) + } + for _, value := range []string{"API.Example.COM.:443/tcp", "dns.example.com:53/udp"} { + if err := selection.AllowValue().Set(value); err != nil { + t.Fatalf("Set(%q): %v", value, err) + } + } + if err := selection.Validate(); err != nil { + t.Fatal(err) + } + want := []EgressRule{{Hostname: "api.example.com", Port: 443, Protocol: 6}, {Hostname: "dns.example.com", Port: 53, Protocol: 17}} + if len(selection.Allow) != len(want) { + t.Fatalf("rules = %#v", selection.Allow) + } + for i := range want { + if selection.Allow[i] != want[i] { + t.Fatalf("rule %d = %#v, want %#v", i, selection.Allow[i], want[i]) + } + } +} + +func TestGuestEgressSelectionRejectsInvalidRules(t *testing.T) { + invalid := []string{ + "example.com:0/tcp", "example.com:65536/tcp", "example.com:443/sctp", + "127.0.0.1:443/tcp", "[2001:db8::1]:443/tcp", "*.example.com:443/tcp", + "bad host:443/tcp", "example.com:443/TCP", "example.com/tcp", "example.com:443/tcp\n", + } + for _, value := range invalid { + t.Run(strings.ReplaceAll(value, "/", "_"), func(t *testing.T) { + selection := NewGuestEgressSelection() + if err := selection.AllowValue().Set(value); err == nil { + t.Fatalf("Set(%q) succeeded", value) + } + }) + } +} + +func TestGuestEgressSelectionRejectsCombinationsAndDuplicates(t *testing.T) { + selection := NewGuestEgressSelection() + if err := selection.AllowValue().Set("example.com:443/tcp"); err != nil { + t.Fatal(err) + } + if err := selection.Validate(); err == nil { + t.Fatal("permissive mode accepted an allow rule") + } + if err := selection.ModeValue().Set("allowlist"); err != nil { + t.Fatal(err) + } + if err := selection.AllowValue().Set("EXAMPLE.COM.:443/tcp"); err == nil { + t.Fatal("normalized duplicate accepted") + } + empty := NewGuestEgressSelection() + if err := empty.ModeValue().Set("allowlist"); err != nil { + t.Fatal(err) + } + if err := empty.Validate(); err == nil { + t.Fatal("empty allowlist accepted") + } +} + +func TestReadyRequestFromDefaultsOverlaysOnlyGuestEgress(t *testing.T) { + platform := runtime.GOOS + "-" + runtime.GOARCH + defaults := map[string]ReleaseDefaults{platform: { + Version: "v1", Platform: platform, URL: "https://example.com/release", SHA256: strings.Repeat("a", 64), + PolicyRevision: "release-policy", CertificateIdentity: "release-identity", OIDCIssuer: "release-issuer", + }} + data, err := json.Marshal(defaults) + if err != nil { + t.Fatal(err) + } + selection := GuestEgressSelection{Mode: GuestEgressAllowlist, Allow: []EgressRule{{Hostname: "api.example.com", Port: 443, Protocol: 6}}} + request, err := ReadyRequestFromDefaults(base64.StdEncoding.EncodeToString(data), "v1", selection) + if err != nil { + t.Fatal(err) + } + if request.Policy.GuestEgressMode != GuestEgressAllowlist || len(request.Policy.GuestAllow) != 1 { + t.Fatalf("egress overlay = %#v", request.Policy) + } + if request.Policy.PolicyRevision != "release-policy" || request.Policy.CertificateIdentity != "release-identity" || request.Policy.Resources["memory"] != "4GiB" || len(request.Policy.RequiredAttestations) != 4 { + t.Fatalf("release defaults changed = %#v", request.Policy) + } + + defaultRequest, err := ReadyRequestFromDefaults(base64.StdEncoding.EncodeToString(data), "v1") + if err != nil { + t.Fatal(err) + } + if defaultRequest.Policy.GuestEgressMode != GuestEgressPermissive || len(defaultRequest.Policy.GuestAllow) != 0 { + t.Fatalf("default egress = %#v", defaultRequest.Policy) + } +} diff --git a/internal/adapter/microvmmanager/ensure_ready_test.go b/internal/adapter/microvmmanager/ensure_ready_test.go new file mode 100644 index 000000000..27f58c9b2 --- /dev/null +++ b/internal/adapter/microvmmanager/ensure_ready_test.go @@ -0,0 +1,155 @@ +package microvmmanager + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestMicroVMRedesign_Scenario1_EnsureReadyConvergesUnderManagerLock(t *testing.T) { + root := t.TempDir() + paths := testPaths(root) + entered := make(chan struct{}, 2) + release := make(chan struct{}) + var running atomic.Bool + var starts atomic.Int32 + ops1 := &lockingReadyOps{fakeOps: fakeOps{}, entered: entered, release: release, running: &running, starts: &starts} + ops2 := &lockingReadyOps{fakeOps: fakeOps{}, entered: entered, running: &running, starts: &starts} + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} + + errCh := make(chan error, 2) + go func() { _, err := New(paths, ops1).EnsureReady(context.Background(), request); errCh <- err }() + <-entered + secondStarted := make(chan struct{}) + go func() { + close(secondStarted) + _, err := New(paths, ops2).EnsureReady(context.Background(), request) + errCh <- err + }() + <-secondStarted + + select { + case <-entered: + t.Fatal("second readiness attempt entered operations before the manager lock was released") + case <-time.After(50 * time.Millisecond): + } + close(release) + for range 2 { + if err := <-errCh; err != nil { + t.Fatal(err) + } + } + if got := starts.Load(); got != 1 { + t.Fatalf("daemon starts = %d, want one compatible daemon", got) + } +} + +func TestEnsureReadyRefreshesCacheOnlyWhenReleaseAdmissionChanges(t *testing.T) { + root := t.TempDir() + paths := testPaths(root) + if err := preparePaths(paths); err != nil { + t.Fatal(err) + } + oldConfig := []byte(`{"release_identity":"sha256:old","policy_revision":"old"}`) + if err := os.WriteFile(paths.ConfigFile, oldConfig, 0o600); err != nil { + t.Fatal(err) + } + cacheMarker := filepath.Join(paths.DataDir, "cache", "old-policy-entry") + if err := os.MkdirAll(filepath.Dir(cacheMarker), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cacheMarker, []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("c", 64)}, Policy: testPolicy(root)} + manager := New(paths, &fakeOps{}) + if _, err := manager.EnsureReady(context.Background(), request); err != nil { + t.Fatalf("converge changed release admission: %v", err) + } + if _, err := os.Lstat(cacheMarker); !os.IsNotExist(err) { + t.Fatalf("stale cache marker survived admission change: %v", err) + } + + steadyMarker := filepath.Join(paths.DataDir, "cache", "current-policy-entry") + if err := os.MkdirAll(filepath.Dir(steadyMarker), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(steadyMarker, []byte("current"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := manager.EnsureReady(context.Background(), request); err != nil { + t.Fatalf("reuse current release admission: %v", err) + } + if _, err := os.Stat(steadyMarker); err != nil { + t.Fatalf("current-policy cache was unnecessarily removed: %v", err) + } +} + +func TestMicroVMRedesign_Scenario1_ReadinessNeverRewritesDesiredConfig(t *testing.T) { + root := t.TempDir() + paths := testPaths(root) + if err := os.MkdirAll(filepath.Dir(paths.UserSettings), 0o700); err != nil { + t.Fatal(err) + } + const desired = "posture: strict\n" + if err := os.WriteFile(paths.UserSettings, []byte(desired), 0o600); err != nil { + t.Fatal(err) + } + ops := &fakeOps{failAt: "doctor"} + manager := New(paths, ops) + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("b", 64)}, Policy: testPolicy(root)} + + if _, err := manager.EnsureReady(context.Background(), request); err == nil || !strings.Contains(err.Error(), "retry ordinary use") { + t.Fatalf("readiness error = %v, want actionable ordinary-use retry", err) + } + got, err := os.ReadFile(paths.UserSettings) + if err != nil { + t.Fatal(err) + } + if string(got) != desired { + t.Fatalf("desired configuration was rewritten on failure:\n%s", got) + } + + ops.failAt = "" + if _, err := manager.EnsureReady(context.Background(), request); err != nil { + t.Fatalf("ordinary-use retry failed: %v", err) + } +} + +type lockingReadyOps struct { + fakeOps + entered chan<- struct{} + release <-chan struct{} + running *atomic.Bool + starts *atomic.Int32 +} + +func (o *lockingReadyOps) Preflight(ctx context.Context, paths Paths) error { + if o.entered != nil { + o.entered <- struct{}{} + } + if o.release != nil { + select { + case <-o.release: + case <-ctx.Done(): + return ctx.Err() + } + } + return o.fakeOps.Preflight(ctx, paths) +} + +func (o *lockingReadyOps) Running(context.Context, Paths) (bool, error) { + return o.running.Load(), nil +} + +func (o *lockingReadyOps) Start(context.Context, Paths) error { + o.starts.Add(1) + o.running.Store(true) + return nil +} + +var _ Operations = (*lockingReadyOps)(nil) diff --git a/internal/adapter/microvmmanager/manager.go b/internal/adapter/microvmmanager/manager.go new file mode 100644 index 000000000..83fa9affb --- /dev/null +++ b/internal/adapter/microvmmanager/manager.go @@ -0,0 +1,921 @@ +// Package microvmmanager owns idempotent readiness and explicit diagnostics for +// the user-local microvmd lifecycle. It supplies safe paths and process operations +// without weakening microvmd's absolute-path or operator-policy validation. +package microvmmanager + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + + microvmclient "github.com/stacklok/mecatl/internal/adapter/microvm" +) + +const ( + // Alias is the built-in local profile selected through ordinary configuration. + Alias = "microvm-local" + // DarwinSocketPathLimit is kept below Darwin's sockaddr_un.sun_path bound. + DarwinSocketPathLimit = 104 + // longestRuntimeSocketSuffix is the longest socket path microvmd derives below + // RuntimeDir: the hosted network socket for a repository generation. + longestRuntimeSocketSuffix = "generations/repository-0000000000000000-00000000.sock.network/hosted-net.sock" +) + +// ReadinessStage is one bounded, secret-free phase of EnsureReady. +type ReadinessStage string + +// Readiness stages are emitted in transaction order. +const ( + StagePrepare ReadinessStage = "prepare" + StagePreflight ReadinessStage = "preflight" + StageDownload ReadinessStage = "download" + StageVerify ReadinessStage = "verify" + StageInstall ReadinessStage = "install" + StageDaemon ReadinessStage = "daemon" + StageSocket ReadinessStage = "socket" + StageReconcile ReadinessStage = "reconcile" + StageHealth ReadinessStage = "health" + StageReady ReadinessStage = "ready" +) + +// ReadinessObserver receives only closed stage identifiers and constant display +// text. Release metadata, paths, credentials, and process output are never sent. +type ReadinessObserver func(ReadinessStage, string) + +type readinessObserverKey struct{} + +func readinessText(stage ReadinessStage) (string, bool) { + switch stage { + case StagePrepare: + return "Preparing local microVM readiness", true + case StagePreflight: + return "Checking host prerequisites", true + case StageDownload: + return "Downloading microVM components (up to about 2 GiB)", true + case StageVerify: + return "Verifying downloaded components", true + case StageInstall: + return "Installing and configuring the local microVM", true + case StageDaemon: + return "Starting or reusing the microVM daemon", true + case StageSocket: + return "Waiting for the microVM daemon socket", true + case StageReconcile: + return "Reconciling existing microVM state", true + case StageHealth: + return "Running microVM health checks", true + case StageReady: + return "Local microVM is ready", true + default: + return "", false + } +} + +// WithReadinessObserver returns a context that observes EnsureReady stages. +func WithReadinessObserver(ctx context.Context, observer ReadinessObserver) context.Context { + if observer == nil { + return ctx + } + return context.WithValue(ctx, readinessObserverKey{}, observer) +} + +// ReportReadinessStage reports one stage to an observer carried by ctx. It is +// exported so command-root test doubles can preserve the observer contract. +func ReportReadinessStage(ctx context.Context, stage ReadinessStage) { + observer, _ := ctx.Value(readinessObserverKey{}).(ReadinessObserver) + text, ok := readinessText(stage) + if observer != nil && ok { + observer(stage, text) + } +} + +// HostPaths supplies environment-independent inputs for DefaultPaths. +type HostPaths struct { + Home, XDGConfigHome, XDGDataHome, XDGStateHome, XDGRuntimeDir string + UID int + GOOS string +} + +// Paths are the absolute, user-owned manager paths. +type Paths struct { + StateDir, RuntimeDir, Socket, DataDir, ConfigFile, UserSettings string + DaemonBinary string +} + +// DefaultPaths resolves XDG paths without requiring any XDG variable. The +// runtime directory uses a short /tmp path when any derived daemon socket would +// exceed Darwin's Unix-socket bound. +func DefaultPaths(host HostPaths) (Paths, error) { + if host.Home == "" || !filepath.IsAbs(host.Home) || host.UID < 0 { + return Paths{}, errors.New("microVM manager requires an absolute home and valid uid") + } + config := xdgBase(host.XDGConfigHome, filepath.Join(host.Home, ".config")) + data := xdgBase(host.XDGDataHome, filepath.Join(host.Home, ".local", "share")) + state := xdgBase(host.XDGStateHome, filepath.Join(host.Home, ".local", "state")) + if config == "" || data == "" || state == "" { + return Paths{}, errors.New("XDG paths must be absolute") + } + dataDir := filepath.Join(data, "mecatl", "microvm") + runtimeDir := "" + if filepath.IsAbs(host.XDGRuntimeDir) { + candidate := filepath.Join(host.XDGRuntimeDir, "mecatl-microvm") + if len(filepath.Join(candidate, longestRuntimeSocketSuffix)) < DarwinSocketPathLimit { + runtimeDir = candidate + } + } + if runtimeDir == "" { + runtimeDir = filepath.Join(string(filepath.Separator)+"tmp", "mv-"+strconv.Itoa(host.UID)) + } + paths := Paths{ + StateDir: filepath.Join(state, "mecatl", "microvm"), RuntimeDir: runtimeDir, + Socket: filepath.Join(runtimeDir, "microvmd.sock"), DataDir: dataDir, + ConfigFile: filepath.Join(config, "mecatl", "microvmd.json"), + UserSettings: filepath.Join(config, "mecatl", "settings.yaml"), + DaemonBinary: filepath.Join(dataDir, "bin", "mecatl-microvmd"), + } + if err := validatePaths(paths); err != nil { + return Paths{}, err + } + return paths, nil +} + +func xdgBase(value, fallback string) string { + if value == "" { + return fallback + } + if !filepath.IsAbs(value) { + return "" + } + return filepath.Clean(value) +} + +// Release identifies a bundle by an immutable, out-of-band SHA-256 digest. +type Release struct { + URL, SHA256 string + bundlePath string + bundleFile *os.File +} + +// Policy is the daemon-enforced local profile policy. Bootstrap writes it only +// to microvmd's strict config; the host alias contains only enabled+endpoint. +type Policy struct { + PolicyRevision, CertificateIdentity, OIDCIssuer string + PublicKey, PublicKeyIdentity string + publicKey []byte + RequiredAttestations map[string]string + GuestEgressMode string + GuestAllow []EgressRule + Admission map[string]int64 + Resources map[string]string +} + +// EgressRule is one daemon-enforced guest destination allowance. +type EgressRule struct { + Hostname string + Port uint16 + Protocol uint8 +} + +// ReadyRequest identifies the manager-owned release and daemon policy that +// ordinary use must converge before provisioning a microVM environment. +type ReadyRequest struct { + Release Release + Policy Policy + // PreserveExistingGuestEgress keeps an existing validated owner-only daemon + // policy when the operator omitted both guest-egress flags. With no existing + // config, Policy's permissive default applies. + PreserveExistingGuestEgress bool +} + +// Artifact is one installer-projected verified admission artifact. +type Artifact struct { + Kind string `json:"kind"` + Reference string `json:"reference"` + Digest string `json:"digest"` + ManifestDigest string `json:"manifest_digest,omitempty"` + DiscoveryReference string `json:"discovery_reference,omitempty"` + ResolutionEvidence string `json:"resolution_evidence,omitempty"` + Platform string `json:"platform,omitempty"` + Path string `json:"path"` + Provenance string `json:"provenance"` + SigstoreBundle string `json:"sigstore_bundle"` +} + +// InstalledArtifacts is the existing release installer's strict projection. +type InstalledArtifacts struct { + Schema string `json:"schema,omitempty"` + Artifacts []Artifact `json:"artifacts"` +} + +// DaemonInfo is the authenticated serving-daemon identity used for exact reuse. +type DaemonInfo = microvmclient.DaemonInfo + +// Operations is the OS/network/process boundary. DefaultOperations is the real +// implementation; tests can keep the complete manager journey offline. +type Operations interface { + Preflight(context.Context, Paths) error + Download(context.Context, Release, string) (string, error) + Verify(context.Context, Release, string) error + Install(context.Context, string, string) (InstalledArtifacts, error) + Running(context.Context, Paths) (bool, error) + DaemonInfo(context.Context, Paths) (DaemonInfo, error) + Start(context.Context, Paths) error + WaitSocket(context.Context, string) error + Doctor(context.Context, Paths) (string, error) + Stop(context.Context, Paths) error +} + +type lifecycleClient interface { + Inventory(context.Context, string, ...microvmclient.InventoryRequest) (microvmclient.InventoryPage, error) + Reconcile(context.Context, string) error + DeleteGeneration(context.Context, microvmclient.GenerationBinding) (microvmclient.DeleteResult, error) +} + +// Manager coordinates readiness and daemon lifecycle operations. +type Manager struct { + paths Paths + ops Operations + lifecycle lifecycleClient + mu sync.Mutex +} + +// New constructs a manager over explicit paths and operations. +func New(paths Paths, ops Operations, lifecycle ...lifecycleClient) *Manager { + manager := &Manager{paths: paths, ops: ops} + if len(lifecycle) > 0 { + manager.lifecycle = lifecycle[0] + } + return manager +} + +// EnsureReady idempotently installs or validates the configured release, starts +// or reuses the exact compatible daemon, and reconciles its durable state. The +// inter-process manager lock serializes the complete transaction. Readiness is +// observed state: this method never edits the operator settings file. +func (m *Manager) EnsureReady(ctx context.Context, request ReadyRequest) (string, error) { //nolint:gocyclo // ordered readiness transaction + ReportReadinessStage(ctx, StagePrepare) + m.mu.Lock() + defer m.mu.Unlock() + if request.Release.bundleFile != nil { + defer func() { _ = request.Release.bundleFile.Close() }() + } + if m.ops == nil { + return "", readinessError(StagePrepare, errors.New("microVM manager operations are not configured")) + } + if err := preparePaths(m.paths); err != nil { + return "", readinessError(StagePrepare, err) + } + unlock, err := lockManager(m.paths.StateDir) + if err != nil { + return "", readinessError(StagePrepare, err) + } + defer unlock() + if len(request.Policy.publicKey) != 0 { + keyPath := filepath.Join(m.paths.DataDir, "trust", "development-"+strings.TrimPrefix(request.Policy.PublicKeyIdentity, "sha256:")+".pub") + if bytesSHA256(request.Policy.publicKey) != request.Policy.PublicKeyIdentity { + return "", readinessError(StagePrepare, errors.New("local microVM release public-key identity mismatch")) + } + if err := atomicWrite(keyPath, request.Policy.publicKey); err != nil { + return "", readinessError(StagePrepare, fmt.Errorf("materialize local microVM release public key: %w", err)) + } + request.Policy.PublicKey = keyPath + } + if err := validateReleasePolicy(request.Release, request.Policy); err != nil { + return "", readinessError(StagePrepare, err) + } + if request.PreserveExistingGuestEgress { + if err := preserveGuestEgressPolicy(m.paths.ConfigFile, &request.Policy); err != nil { + return "", readinessError(StagePrepare, err) + } + } + ReportReadinessStage(ctx, StagePreflight) + if err := m.ops.Preflight(ctx, m.paths); err != nil { + return "", readinessError(StagePreflight, fmt.Errorf("microVM preflight: %w", err)) + } + ReportReadinessStage(ctx, StageDownload) + manifest, err := m.ops.Download(ctx, request.Release, filepath.Join(m.paths.DataDir, "download")) + if err != nil { + return "", readinessError(StageDownload, fmt.Errorf("download release bundle: %w", err)) + } + ReportReadinessStage(ctx, StageVerify) + if err := m.ops.Verify(ctx, request.Release, manifest); err != nil { + return "", readinessError(StageVerify, fmt.Errorf("verify release bundle: %w", err)) + } + ReportReadinessStage(ctx, StageInstall) + installed, err := m.ops.Install(ctx, manifest, filepath.Join(m.paths.DataDir, "verified")) + if err != nil { + return "", readinessError(StageInstall, fmt.Errorf("install verified release bundle: %w", err)) + } + if err := validateInstalled(installed, m.paths.DataDir); err != nil { + return "", readinessError(StageInstall, err) + } + cacheRefresh, err := verifiedArtifactCacheNeedsRefresh(m.paths.ConfigFile, request.Release, request.Policy) + if err != nil { + return "", readinessError(StageInstall, err) + } + if err := writeDaemonConfig(m.paths.ConfigFile, m.paths, request.Release, request.Policy, installed); err != nil { + return "", readinessError(StageInstall, err) + } + ReportReadinessStage(ctx, StageDaemon) + running, err := m.ops.Running(ctx, m.paths) + if err != nil { + return "", readinessError(StageDaemon, err) + } + if running { + expected, expectedErr := expectedDaemonInfo(m.paths) + if expectedErr != nil { + return "", readinessError(StageDaemon, expectedErr) + } + serving, infoErr := m.ops.DaemonInfo(ctx, m.paths) + if infoErr != nil || !serving.Equal(expected) { + if stopErr := m.ops.Stop(ctx, m.paths); stopErr != nil { + return "", readinessError(StageDaemon, fmt.Errorf("serving microvmd is incompatible and safe managed restart failed; stop the exact user service with your service manager, then retry: %w", stopErr)) + } + running = false + } + } + if cacheRefresh { + if running { + return "", readinessError(StageDaemon, errors.New("serving microvmd reported current identity while its prior artifact admission differed")) + } + if err := resetVerifiedArtifactCache(m.paths.DataDir); err != nil { + return "", readinessError(StageInstall, err) + } + } + if !running { + if err := m.ops.Start(ctx, m.paths); err != nil { + return "", readinessError(StageDaemon, fmt.Errorf("start microvmd: %w", err)) + } + } + ReportReadinessStage(ctx, StageSocket) + if err := m.ops.WaitSocket(ctx, m.paths.Socket); err != nil { + return "", readinessError(StageSocket, fmt.Errorf("wait for owner-only microvmd socket: %w", err)) + } + if m.lifecycle != nil { + ReportReadinessStage(ctx, StageReconcile) + if err := m.lifecycle.Reconcile(ctx, "local"); err != nil { + return "", readinessError(StageReconcile, fmt.Errorf("reconcile existing microVM state: %w", err)) + } + } + ReportReadinessStage(ctx, StageHealth) + if _, err := m.ops.Doctor(ctx, m.paths); err != nil { + return "", readinessError(StageHealth, fmt.Errorf("microvmd doctor: %w", err)) + } + ReportReadinessStage(ctx, StageReady) + return "unix://" + m.paths.Socket, nil +} + +func readinessError(stage ReadinessStage, err error) error { + return fmt.Errorf("microvm-local readiness failed during %s: %w; correct the reported problem and retry ordinary use", stage, err) +} + +// Generation is one exact owner-scoped daemon generation. +type Generation struct { + SessionID, EnvironmentID, Ref, WorktreePath, State, Error string + Generation uint32 + Health microvmclient.GenerationHealth +} + +// StatusRequest selects one bounded daemon inventory page. +type StatusRequest struct { + PageSize int + Continuation string +} + +// Status reports manager state without starting or recreating anything. +type Status struct { + Configured, Running bool + Socket string + GuestEgress string + Generations []Generation + Continuation string +} + +// Status inspects daemon configuration, health, and one owner inventory page without mutation. +func (m *Manager) Status(ctx context.Context, requests ...StatusRequest) (Status, error) { + if len(requests) > 1 { + return Status{}, errors.New("microVM status accepts one page request") + } + request := StatusRequest{PageSize: 50} + if len(requests) == 1 { + request = requests[0] + } + if request.PageSize <= 0 { + request.PageSize = 50 + } + if request.PageSize > 64 { + request.PageSize = 64 + } + configured := regularFile(m.paths.ConfigFile) + running, err := m.ops.Running(ctx, m.paths) + status := Status{Configured: configured, Running: running, Socket: m.paths.Socket} + if configured { + selection, policyErr := readGuestEgressPolicy(m.paths.ConfigFile) + if policyErr != nil { + return Status{}, policyErr + } + status.GuestEgress = GuestEgressSummary(selection) + } + if err != nil || !running || m.lifecycle == nil { + return status, err + } + page, err := m.lifecycle.Inventory(ctx, "local", microvmclient.InventoryRequest{PageSize: request.PageSize, Continuation: request.Continuation}) + if err != nil { + return Status{}, err + } + status.Continuation = page.Continuation + status.Generations = make([]Generation, len(page.Entries)) + for i, entry := range page.Entries { + status.Generations[i] = Generation{SessionID: entry.SessionID, EnvironmentID: entry.EnvironmentID, Ref: entry.Ref, Generation: entry.Generation, WorktreePath: entry.WorktreePath, State: entry.State, Health: entry.Health, Error: entry.Error} + } + return status, nil +} + +// Doctor reports host preflight and backend health without downloading, +// installing, starting, or changing configuration. +func (m *Manager) Doctor(ctx context.Context) (string, error) { + if m.ops == nil { + return "host preflight: failed: manager operations are not configured\nbackend: not checked\n", errors.New("microVM manager operations are not configured") + } + + var report strings.Builder + var failures []error + preflightErr := m.ops.Preflight(ctx, m.paths) + if preflightErr != nil { + _, _ = fmt.Fprintf(&report, "host preflight: failed: %v\n", preflightErr) + failures = append(failures, fmt.Errorf("microVM preflight: %w", preflightErr)) + } else { + _, _ = fmt.Fprintln(&report, "host preflight: passed") + } + + configured := regularFile(m.paths.ConfigFile) + running, runningErr := m.ops.Running(ctx, m.paths) + switch { + case !configured: + _, _ = fmt.Fprintln(&report, "backend: not configured") + failures = append(failures, errors.New("microVM backend is not configured")) + case runningErr != nil: + _, _ = fmt.Fprintf(&report, "backend: unhealthy: inspect daemon state: %v\n", runningErr) + failures = append(failures, fmt.Errorf("inspect microVM daemon state: %w", runningErr)) + case !running: + _, _ = fmt.Fprintln(&report, "backend: configured; daemon not running") + failures = append(failures, errors.New("microVM daemon is not running")) + default: + daemonReport, err := m.ops.Doctor(ctx, m.paths) + if err != nil { + _, _ = fmt.Fprintf(&report, "backend: unhealthy: %v\n", err) + failures = append(failures, fmt.Errorf("microVM backend is unhealthy: %w", err)) + } else { + _, _ = fmt.Fprintln(&report, "backend: healthy") + } + if daemonReport != "" { + _, _ = io.WriteString(&report, daemonReport) + if !strings.HasSuffix(daemonReport, "\n") { + _, _ = fmt.Fprintln(&report) + } + } + } + + if preflightErr != nil { + _, _ = fmt.Fprintln(&report, "next: fix the failed host prerequisite, then select microvm-local to install/start the backend automatically:") + } else if len(failures) > 0 { + _, _ = fmt.Fprintln(&report, "next: host prerequisites passed; select microvm-local to configure or start the backend automatically:") + } else { + _, _ = fmt.Fprintln(&report, "next: select microvm-local as the deployment default:") + } + _, _ = fmt.Fprintln(&report, " mecatui --default-placement microvm-local") + _, _ = fmt.Fprintln(&report, ` mecated serve --headless --default-placement microvm-local; then POST /v1/sessions with {}`) + _, _ = fmt.Fprintln(&report, "doctor is read-only; it never downloads, installs, or starts microvmd") + return report.String(), errors.Join(failures...) +} + +// DeleteRequest identifies one exact local-owner generation for permanent deletion. +type DeleteRequest struct { + SessionID string + Ref string + Generation uint32 +} + +// DeleteResult reports clean worktree removal or dirty retention. +type DeleteResult struct { + WorktreePath string + WorktreeRetained bool +} + +// ValidateDeleteRequest checks the complete generation selector before any +// destructive confirmation is shown. +func ValidateDeleteRequest(request DeleteRequest) error { + at := strings.LastIndexByte(request.Ref, '@') + if request.SessionID == "" || at <= 0 || request.Generation == 0 || request.Ref[at+1:] != strconv.FormatUint(uint64(request.Generation), 10) { + return errors.New("microVM delete requires matching --session, --ref, and --generation") + } + return nil +} + +// Delete permanently destroys one exact generation. It never stops the daemon or +// removes verified installation state shared by other sessions. +func (m *Manager) Delete(ctx context.Context, request DeleteRequest) (DeleteResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.lifecycle == nil { + return DeleteResult{}, errors.New("microVM lifecycle client is not configured") + } + if err := ValidateDeleteRequest(request); err != nil { + return DeleteResult{}, err + } + at := strings.LastIndexByte(request.Ref, '@') + claim := microvmclient.GenerationBinding{ + Owner: "local", SessionID: request.SessionID, EnvironmentID: request.Ref[:at], Ref: request.Ref, Generation: request.Generation, + } + result, err := m.lifecycle.DeleteGeneration(ctx, claim) + if err != nil { + return DeleteResult{}, err + } + return DeleteResult{WorktreePath: result.WorktreePath, WorktreeRetained: result.WorktreeRetained}, nil +} + +// HostFallbackEnabled is deliberately false: manager failures never redirect a +// selected microVM session to host filesystem or shell execution. +func (*Manager) HostFallbackEnabled() bool { return false } + +func validateReleasePolicy(release Release, policy Policy) error { //nolint:gocyclo // explicit closed policy validation + remote := strings.HasPrefix(release.URL, "https://") && release.bundlePath == "" + localDevelopment := release.URL == "" && filepath.IsAbs(release.bundlePath) + if (!remote && !localDevelopment) || len(release.SHA256) != 64 || strings.Trim(release.SHA256, "0123456789abcdef") != "" { + return errors.New("release requires exactly one authenticated source and a lowercase SHA-256 digest") + } + if policy.PolicyRevision == "" { + return errors.New("policy requires a revision") + } + if policy.GuestEgressMode != "permissive" && policy.GuestEgressMode != "deny-all" && policy.GuestEgressMode != "allowlist" { + return errors.New("policy requires permissive, deny-all, or allowlist guest egress") + } + if ((policy.GuestEgressMode == "permissive" || policy.GuestEgressMode == "deny-all") && len(policy.GuestAllow) != 0) || (policy.GuestEgressMode == "allowlist" && len(policy.GuestAllow) == 0) { + return errors.New("guest egress mode and allowlist disagree") + } + seenRules := make(map[EgressRule]struct{}, len(policy.GuestAllow)) + for _, rule := range policy.GuestAllow { + if err := validateEgressRule(rule); err != nil { + return errors.New("guest egress allowlist contains an invalid destination") + } + if rule.Hostname != strings.ToLower(strings.TrimSuffix(rule.Hostname, ".")) { + return errors.New("guest egress allowlist contains a non-canonical hostname") + } + if _, ok := seenRules[rule]; ok { + return errors.New("guest egress allowlist contains a duplicate destination") + } + seenRules[rule] = struct{}{} + } + for _, kind := range []string{"runtime", "firmware", "execution-image", "guest-agent"} { + if policy.RequiredAttestations[kind] == "" { + return fmt.Errorf("policy omitted required %s attestation", kind) + } + } + keyless := policy.CertificateIdentity != "" || policy.OIDCIssuer != "" + keyed := policy.PublicKey != "" || policy.PublicKeyIdentity != "" + if keyless == keyed || (keyless && (policy.CertificateIdentity == "" || policy.OIDCIssuer == "")) || (keyed && (!filepath.IsAbs(policy.PublicKey) || !strings.HasPrefix(policy.PublicKeyIdentity, "sha256:"))) { + return errors.New("policy requires exactly one complete Sigstore trust mode") + } + return nil +} + +func validateInstalled(installed InstalledArtifacts, installRoot string) error { + seen := map[string]bool{} + for _, artifact := range installed.Artifacts { + if seen[artifact.Kind] { + return errors.New("installer returned duplicate artifact kind") + } + if err := validateInstalledArtifact(artifact, installRoot); err != nil { + return err + } + seen[artifact.Kind] = true + } + for _, kind := range []string{"runtime", "firmware", "execution-image", "guest-agent"} { + if !seen[kind] { + return fmt.Errorf("installer omitted %s artifact", kind) + } + } + return nil +} + +func validateInstalledArtifact(artifact Artifact, installRoot string) error { //nolint:gocyclo // one fail-closed validation chain keeps artifact and OCI-lineage checks atomic + isOCI := artifact.Kind == "execution-image" && artifact.ManifestDigest != "" + if (!isOCI && !pathWithin(installRoot, artifact.Path)) || (isOCI && artifact.Path != "") { + return errors.New("installer returned invalid or out-of-root artifact paths") + } + if artifact.Provenance == "" || !pathWithin(installRoot, artifact.Provenance) || artifact.SigstoreBundle == "" || !pathWithin(installRoot, artifact.SigstoreBundle) { + return errors.New("installer returned out-of-root artifact evidence") + } + if !validSHA256(artifact.Digest) { + return errors.New("installer returned incomplete immutable artifact evidence") + } + if isOCI { + if !validSHA256(artifact.ManifestDigest) || !strings.HasSuffix(artifact.Reference, "@"+artifact.ManifestDigest) || !canonicalOCIReference(artifact.Reference) || + artifact.DiscoveryReference != "ghcr.io/stacklok/brood-box/base:latest" || !validSHA256(artifact.ResolutionEvidence) || + (artifact.Platform != "linux/amd64" && artifact.Platform != "linux/arm64") { + return errors.New("installer returned mutable or inconsistent Brood execution image resolution") + } + return nil + } + if artifact.ManifestDigest != "" || artifact.DiscoveryReference != "" || artifact.ResolutionEvidence != "" || artifact.Platform != "" || !strings.HasSuffix(artifact.Reference, "@"+artifact.Digest) { + return errors.New("installer returned incomplete immutable artifact evidence") + } + return nil +} + +func validSHA256(value string) bool { + digestHex := strings.TrimPrefix(value, "sha256:") + return len(digestHex) == 64 && strings.Trim(digestHex, "0123456789abcdef") == "" && value == "sha256:"+digestHex +} + +func canonicalOCIReference(value string) bool { + at := strings.LastIndexByte(value, '@') + if at <= 0 || strings.Count(value, "@") != 1 || strings.ToLower(value[:at]) != value[:at] { + return false + } + repo := value[:at] + lastSlash := strings.LastIndexByte(repo, '/') + return strings.Contains(repo, "/") && !strings.ContainsAny(repo, " \t\r\n") && !strings.Contains(repo[lastSlash+1:], ":") +} + +func pathWithin(root, path string) bool { + if !filepath.IsAbs(root) || !filepath.IsAbs(path) { + return false + } + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func verifiedArtifactCacheNeedsRefresh(configPath string, release Release, policy Policy) (bool, error) { + data, err := os.ReadFile(configPath) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read existing microvmd config before artifact-cache reconciliation: %w", err) + } + var current struct { + ReleaseIdentity string `json:"release_identity"` + PolicyRevision string `json:"policy_revision"` + } + if err := json.Unmarshal(data, ¤t); err != nil { + return false, fmt.Errorf("decode existing microvmd config before artifact-cache reconciliation: %w", err) + } + return current.ReleaseIdentity != "sha256:"+release.SHA256 || current.PolicyRevision != policy.PolicyRevision, nil +} + +func resetVerifiedArtifactCache(dataDir string) error { + cache := filepath.Join(dataDir, "cache") + info, err := os.Lstat(cache) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect stale verified artifact cache: %w", err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("refusing to replace stale verified artifact cache with an unsafe file type") + } + if err := os.RemoveAll(cache); err != nil { + return fmt.Errorf("replace stale verified artifact cache: %w", err) + } + return nil +} + +func writeDaemonConfig(path string, paths Paths, release Release, policy Policy, installed InstalledArtifacts) error { + binaryIdentity, err := fileSHA256(paths.DaemonBinary) + if err != nil { + return fmt.Errorf("identify installed microvmd binary: %w", err) + } + artifacts := make([]map[string]string, 0, len(installed.Artifacts)) + for _, a := range installed.Artifacts { + artifacts = append(artifacts, map[string]string{ + "kind": a.Kind, "reference": a.Reference, "digest": a.Digest, "manifest_digest": a.ManifestDigest, + "discovery_reference": a.DiscoveryReference, "resolution_evidence": a.ResolutionEvidence, "platform": a.Platform, + "path": a.Path, "provenance": a.Provenance, "sigstore_bundle": a.SigstoreBundle, + }) + } + cfg := map[string]any{ + "release_identity": "sha256:" + release.SHA256, "binary_identity": binaryIdentity, + "policy_revision": policy.PolicyRevision, "artifact_cache": filepath.Join(paths.DataDir, "cache"), "runtime_dir": filepath.Join(paths.RuntimeDir, "generations"), + "required_attestations": policy.RequiredAttestations, "artifacts": artifacts, + "guest_egress": map[string]any{"Mode": policy.GuestEgressMode, "Allow": policy.GuestAllow}, "admission": policy.Admission, + "profiles": map[string]any{Alias: map[string]any{"resources": policy.Resources}}, + } + if policy.CertificateIdentity != "" { + cfg["certificate_identity"], cfg["oidc_issuer"] = policy.CertificateIdentity, policy.OIDCIssuer + } else { + cfg["public_key"], cfg["public_key_identity"] = policy.PublicKey, policy.PublicKeyIdentity + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return atomicWrite(path, append(data, '\n')) +} + +func expectedDaemonInfo(paths Paths) (DaemonInfo, error) { + data, err := os.ReadFile(paths.ConfigFile) + if err != nil { + return DaemonInfo{}, err + } + var cfg struct { + ReleaseIdentity string `json:"release_identity"` + BinaryIdentity string `json:"binary_identity"` + PolicyRevision string `json:"policy_revision"` + Profiles map[string]json.RawMessage `json:"profiles"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return DaemonInfo{}, fmt.Errorf("read expected microvmd identity: %w", err) + } + profiles := make([]string, 0, len(cfg.Profiles)) + for profile := range cfg.Profiles { + profiles = append(profiles, profile) + } + sort.Strings(profiles) + binaryIdentity, err := fileSHA256(paths.DaemonBinary) + if err != nil { + return DaemonInfo{}, err + } + if cfg.BinaryIdentity != binaryIdentity { + return DaemonInfo{}, errors.New("installed microvmd binary changed after config was written") + } + return DaemonInfo{ProtocolVersion: microvmclient.DaemonProtocolVersion, ReleaseIdentity: cfg.ReleaseIdentity, BinaryIdentity: binaryIdentity, ConfigDigest: bytesSHA256(data), PolicyRevision: cfg.PolicyRevision, Profiles: profiles, Socket: paths.Socket}, nil +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) // #nosec G304 -- manager-owned absolute executable path. + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil +} + +func bytesSHA256(data []byte) string { + digest := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func preparePaths(paths Paths) error { + if err := validatePaths(paths); err != nil { + return err + } + for _, dir := range []string{paths.StateDir, paths.RuntimeDir, paths.DataDir, filepath.Dir(paths.ConfigFile), filepath.Dir(paths.DaemonBinary)} { + if err := secureMkdirAll(dir); err != nil { + return err + } + } + for _, file := range []string{paths.ConfigFile, paths.UserSettings} { + if err := refuseSymlink(file); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + return nil +} + +func validatePaths(paths Paths) error { + for _, path := range []string{paths.StateDir, paths.RuntimeDir, paths.Socket, paths.DataDir, paths.ConfigFile, paths.UserSettings, paths.DaemonBinary} { + if !filepath.IsAbs(path) { + return errors.New("all microVM manager paths must be absolute") + } + } + if len(paths.Socket) >= DarwinSocketPathLimit { + return errors.New("microvmd socket path exceeds Darwin-safe bound") + } + return nil +} + +func lockManager(stateDir string) (func(), error) { + info, err := os.Lstat(stateDir) + if err != nil { + return nil, err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("microVM manager state root is not a directory") + } + lock, err := os.OpenFile(filepath.Join(stateDir, "manager.lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil { + _ = lock.Close() + return nil, err + } + return func() { + _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + _ = lock.Close() + }, nil +} + +func secureMkdirAll(path string) error { + if err := refuseSymlinkAncestors(path); err != nil { + return err + } + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + if err := os.Chmod(path, 0o700); err != nil { // #nosec G302 -- this is an owner-only directory, not a file. + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing non-directory or symlink path %s", path) + } + if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != os.Getuid() { + return fmt.Errorf("refusing directory not owned by current user: %s", path) + } + return nil +} + +func refuseSymlinkAncestors(path string) error { + clean := filepath.Clean(path) + for current := clean; current != filepath.Dir(current); current = filepath.Dir(current) { + info, err := os.Lstat(current) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing symlink path %s", current) + } + } + return nil +} + +func refuseSymlink(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing symlink path %s", path) + } + return nil +} + +func atomicWrite(path string, data []byte) error { + return atomicWriteMode(path, data, 0o600) +} + +func atomicWriteMode(path string, data []byte, mode fs.FileMode) error { + if err := refuseSymlinkAncestors(path); err != nil { + return err + } + if err := secureMkdirAll(filepath.Dir(path)); err != nil { + return err + } + if err := refuseSymlink(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".microvm-manager-*") + if err != nil { + return err + } + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return err + } + name := tmp.Name() + defer func() { _ = os.Remove(name) }() + if _, err = tmp.Write(data); err == nil { + err = tmp.Sync() + } + if closeErr := tmp.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + if err := os.Rename(name, path); err != nil { + return err + } + return os.Chmod(path, mode) +} + +func regularFile(path string) bool { + info, err := os.Lstat(path) + return err == nil && info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 +} diff --git a/internal/adapter/microvmmanager/manager_test.go b/internal/adapter/microvmmanager/manager_test.go new file mode 100644 index 000000000..b6f177883 --- /dev/null +++ b/internal/adapter/microvmmanager/manager_test.go @@ -0,0 +1,466 @@ +package microvmmanager + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + microvmclient "github.com/stacklok/mecatl/internal/adapter/microvm" +) + +func TestMicroVMUserBootstrap_Scenario1_SafeXDGDefaults(t *testing.T) { + home := t.TempDir() + paths, err := DefaultPaths(HostPaths{Home: home, UID: 1234, GOOS: "linux"}) + if err != nil { + t.Fatal(err) + } + if paths.StateDir != filepath.Join(home, ".local", "state", "mecatl", "microvm") || paths.DataDir != filepath.Join(home, ".local", "share", "mecatl", "microvm") { + t.Fatalf("unexpected manager paths: %+v", paths) + } + if !filepath.IsAbs(paths.Socket) || len(paths.Socket) >= DarwinSocketPathLimit { + t.Fatalf("socket is not absolute and Darwin-safe: %q", paths.Socket) + } +} + +func TestDefaultPathsBoundsDerivedRepositoryNetworkSocket(t *testing.T) { + paths, err := DefaultPaths(HostPaths{ + Home: t.TempDir(), XDGRuntimeDir: "/dev/shm/daily-1234567", UID: 1234, GOOS: "linux", + }) + if err != nil { + t.Fatal(err) + } + derived := filepath.Join(paths.RuntimeDir, longestRuntimeSocketSuffix) + if len(derived) >= DarwinSocketPathLimit { + t.Fatalf("derived repository network socket is too long: %q (%d bytes)", derived, len(derived)) + } + if paths.RuntimeDir == filepath.Join("/dev/shm/daily-1234567", "mecatl-microvm") { + t.Fatalf("overlong XDG runtime directory was accepted: %q", paths.RuntimeDir) + } +} + +func TestEnsureReadyPreservesOmittedGuestEgressAndExplicitPermissiveResets(t *testing.T) { + root := t.TempDir() + paths := testPaths(root) + manager := New(paths, &fakeOps{}) + request := ReadyRequest{ + Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, + Policy: testPolicy(root), + } + request.Policy.GuestEgressMode = GuestEgressDenyAll + if _, err := manager.EnsureReady(t.Context(), request); err != nil { + t.Fatal(err) + } + + omitted := request + omitted.Policy.GuestEgressMode = GuestEgressPermissive + omitted.PreserveExistingGuestEgress = true + if _, err := manager.EnsureReady(t.Context(), omitted); err != nil { + t.Fatal(err) + } + selection, err := readGuestEgressPolicy(paths.ConfigFile) + if err != nil || selection.Mode != GuestEgressDenyAll { + t.Fatalf("preserved policy = %#v, err=%v", selection, err) + } + status, err := manager.Status(t.Context()) + if err != nil || status.GuestEgress != GuestEgressDenyAll { + t.Fatalf("status policy = %q, err=%v", status.GuestEgress, err) + } + + explicit := omitted + explicit.PreserveExistingGuestEgress = false + if _, err := manager.EnsureReady(t.Context(), explicit); err != nil { + t.Fatal(err) + } + selection, err = readGuestEgressPolicy(paths.ConfigFile) + if err != nil || selection.Mode != GuestEgressPermissive { + t.Fatalf("reset policy = %#v, err=%v", selection, err) + } +} + +func TestEnsureReadyRejectsUnsafeExistingPolicyWhenFlagsOmitted(t *testing.T) { + root := t.TempDir() + paths := testPaths(root) + if err := os.MkdirAll(filepath.Dir(paths.ConfigFile), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.ConfigFile, []byte(`{"guest_egress":{"Mode":"allowlist","Allow":[]}}`), 0o600); err != nil { + t.Fatal(err) + } + request := ReadyRequest{ + Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, + Policy: testPolicy(root), PreserveExistingGuestEgress: true, + } + ops := &fakeOps{} + _, err := New(paths, ops).EnsureReady(t.Context(), request) + if err == nil || !strings.Contains(err.Error(), "refusing to replace existing microvmd guest egress policy") { + t.Fatalf("error = %v", err) + } + if len(ops.calls) != 0 { + t.Fatalf("unsafe policy reached operations: %v", ops.calls) + } +} + +func TestEnsureReadyReportsBoundedSecretFreeStagesInOrder(t *testing.T) { + root := t.TempDir() + secret := "super-secret-api-key" + request := ReadyRequest{ + Release: Release{URL: "https://" + secret + "@example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, + Policy: testPolicy(root), + } + var stages []ReadinessStage + var messages []string + ctx := WithReadinessObserver(t.Context(), func(stage ReadinessStage, message string) { + stages = append(stages, stage) + messages = append(messages, message) + }) + if _, err := New(testPaths(root), &fakeOps{}, &fakeLifecycleClient{}).EnsureReady(ctx, request); err != nil { + t.Fatal(err) + } + want := []ReadinessStage{StagePrepare, StagePreflight, StageDownload, StageVerify, StageInstall, StageDaemon, StageSocket, StageReconcile, StageHealth, StageReady} + if !reflect.DeepEqual(stages, want) { + t.Fatalf("stages = %v, want %v", stages, want) + } + joined := strings.Join(messages, "\n") + for _, forbidden := range []string{request.Release.URL, secret, request.Policy.PublicKey, testPaths(root).DataDir} { + if forbidden != "" && strings.Contains(joined, forbidden) { + t.Fatalf("stage text leaked %q:\n%s", forbidden, joined) + } + } +} + +func TestEnsureReadyFailureStopsAtExactReportedStage(t *testing.T) { + tests := []struct { + name string + failAt string + nilOps bool + lifecycle *fakeLifecycleClient + wantStage ReadinessStage + }{ + {name: "prepare", nilOps: true, wantStage: StagePrepare}, + {name: "preflight", failAt: "preflight", wantStage: StagePreflight}, + {name: "download", failAt: "download", wantStage: StageDownload}, + {name: "verify", failAt: "verify", wantStage: StageVerify}, + {name: "install", failAt: "install", wantStage: StageInstall}, + {name: "daemon", failAt: "start", wantStage: StageDaemon}, + {name: "socket", failAt: "wait", wantStage: StageSocket}, + {name: "reconcile", lifecycle: &fakeLifecycleClient{reconcileErr: errors.New("reconcile failed")}, wantStage: StageReconcile}, + {name: "health", failAt: "doctor", wantStage: StageHealth}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} + var stages []ReadinessStage + ctx := WithReadinessObserver(t.Context(), func(stage ReadinessStage, _ string) { stages = append(stages, stage) }) + var ops Operations = &fakeOps{failAt: tc.failAt} + if tc.nilOps { + ops = nil + } + manager := New(testPaths(root), ops) + if tc.lifecycle != nil { + manager = New(testPaths(root), ops, tc.lifecycle) + } + _, err := manager.EnsureReady(ctx, request) + if err == nil || !strings.HasPrefix(err.Error(), "microvm-local readiness failed during "+string(tc.wantStage)+":") || !strings.HasSuffix(err.Error(), "correct the reported problem and retry ordinary use") { + t.Fatalf("error = %v, want exact stage prefix %s", err, tc.wantStage) + } + if len(stages) == 0 || stages[len(stages)-1] != tc.wantStage { + t.Fatalf("stages = %v, want failure stage %s last", stages, tc.wantStage) + } + for _, stage := range stages { + if stage == StageReady { + t.Fatalf("ready stage followed failure: %v", stages) + } + } + }) + } +} + +func TestReadinessStageProjectionIsClosedAndUnknownIsSilent(t *testing.T) { + want := map[ReadinessStage]string{ + StagePrepare: "Preparing local microVM readiness", StagePreflight: "Checking host prerequisites", + StageDownload: "Downloading microVM components (up to about 2 GiB)", StageVerify: "Verifying downloaded components", + StageInstall: "Installing and configuring the local microVM", StageDaemon: "Starting or reusing the microVM daemon", + StageSocket: "Waiting for the microVM daemon socket", StageReconcile: "Reconciling existing microVM state", + StageHealth: "Running microVM health checks", StageReady: "Local microVM is ready", + } + for stage, text := range want { + if got, ok := readinessText(stage); !ok || got != text { + t.Fatalf("readinessText(%q) = %q, %t; want %q, true", stage, got, ok, text) + } + } + calls := 0 + ctx := WithReadinessObserver(t.Context(), func(ReadinessStage, string) { calls++ }) + ReportReadinessStage(ctx, ReadinessStage("future-stage")) + if calls != 0 { + t.Fatalf("unknown readiness stage emitted %d observer calls", calls) + } +} + +func TestMicroVMRedesign_EnsureReadyRestartsOnlyIncompatibleDaemon(t *testing.T) { + for _, mismatch := range []string{"", "binary", "policy"} { + t.Run(mismatch, func(t *testing.T) { + root := t.TempDir() + ops := &fakeOps{running: true, identityMismatch: mismatch} + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} + if _, err := New(testPaths(root), ops).EnsureReady(context.Background(), request); err != nil { + t.Fatal(err) + } + wantRestart := mismatch != "" + if got := contains(ops.calls, "stop") && contains(ops.calls, "start"); got != wantRestart { + t.Fatalf("calls = %v, restart=%t want %t", ops.calls, got, wantRestart) + } + }) + } +} + +func TestDoctorReportsFreshAndFailureStatesWithoutMutation(t *testing.T) { + tests := []struct { + name string + configured bool + ops *fakeOps + want []string + }{ + {name: "fresh home", ops: &fakeOps{}, want: []string{"host preflight: passed", "backend: not configured", "host prerequisites passed; select microvm-local", "mecatui --default-placement microvm-local", "doctor is read-only"}}, + {name: "daemon stopped", configured: true, ops: &fakeOps{}, want: []string{"host preflight: passed", "backend: configured; daemon not running", "host prerequisites passed; select microvm-local"}}, + {name: "healthy", configured: true, ops: &fakeOps{running: true}, want: []string{"host preflight: passed", "backend: healthy", "PASS hypervisor ready"}}, + {name: "daemon unhealthy", configured: true, ops: &fakeOps{running: true, doctorErr: errors.New("guest transport unavailable")}, want: []string{"backend: unhealthy", "guest transport unavailable"}}, + {name: "host preflight failed", ops: &fakeOps{failAt: "preflight"}, want: []string{"host preflight: failed", "backend: not configured"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + paths := testPaths(root) + if tc.configured { + if err := os.MkdirAll(filepath.Dir(paths.ConfigFile), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.ConfigFile, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } + report, err := New(paths, tc.ops).Doctor(t.Context()) + if tc.name == "healthy" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("Doctor succeeded for an unready state") + } + for _, want := range tc.want { + if !strings.Contains(report, want) { + t.Fatalf("report omitted %q:\n%s", want, report) + } + } + if tc.ops.failAt != "preflight" && strings.Contains(report, "failed host prerequisite") { + t.Fatalf("passed preflight received failed-prerequisite guidance:\n%s", report) + } + for _, forbidden := range []string{"download", "verify", "install", "start", "stop", "wait"} { + if contains(tc.ops.calls, forbidden) { + t.Fatalf("read-only Doctor called %q: %v", forbidden, tc.ops.calls) + } + } + }) + } +} + +func TestMicroVMLifecycleUX_ManagerStatusAndDeleteRemainExact(t *testing.T) { + root := t.TempDir() + paths := testPaths(root) + ops := &fakeOps{running: true} + daemon := &fakeLifecycleClient{entries: []microvmclient.InventoryEntry{{ + Owner: "local", SessionID: "s1", EnvironmentID: "env-1", Ref: "env-1@7", Generation: 7, + WorktreePath: "/worktrees/s1", Health: microvmclient.GenerationStale, + }}} + manager := New(paths, ops, daemon) + status, err := manager.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(status.Generations) != 1 || status.Generations[0].Ref != "env-1@7" { + t.Fatalf("status = %+v", status) + } + result, err := manager.Delete(context.Background(), DeleteRequest{SessionID: "s1", Ref: "env-1@7", Generation: 7}) + if err != nil { + t.Fatal(err) + } + if !result.WorktreeRetained || daemon.deleted.Ref != "env-1@7" { + t.Fatalf("delete result=%+v binding=%+v", result, daemon.deleted) + } +} + +func TestRepositoryManagerInventoryPagesAndExactLogicalDelete(t *testing.T) { + root := t.TempDir() + daemon := &fakeLifecycleClient{entries: []microvmclient.InventoryEntry{{ + Owner: "local", SessionID: "session-a", EnvironmentID: "logical-a", Ref: "logical-a@9", Generation: 9, + WorktreePath: "/worktrees/a", State: "ready", Health: microvmclient.GenerationHealthy, + }}, continuation: "next"} + manager := New(testPaths(root), &fakeOps{running: true}, daemon) + first, err := manager.Status(t.Context(), StatusRequest{PageSize: 1}) + if err != nil { + t.Fatal(err) + } + daemon.entries = []microvmclient.InventoryEntry{{ + Owner: "local", SessionID: "session-b", EnvironmentID: "logical-b", Ref: "logical-b@9", Generation: 9, + WorktreePath: "/worktrees/b", State: "ready", Health: microvmclient.GenerationHealthy, + }} + daemon.continuation = "" + second, err := manager.Status(t.Context(), StatusRequest{PageSize: 1, Continuation: first.Continuation}) + if err != nil { + t.Fatal(err) + } + if len(first.Generations) != 1 || len(second.Generations) != 1 || first.Generations[0].Generation != second.Generations[0].Generation || + first.Generations[0].WorktreePath == second.Generations[0].WorktreePath || daemon.inventoryRequest.Continuation != "next" { + t.Fatalf("repository manager pages = %+v / %+v, request=%+v", first, second, daemon.inventoryRequest) + } + result, err := manager.Delete(t.Context(), DeleteRequest{SessionID: "session-b", Ref: "logical-b@9", Generation: 9}) + if err != nil { + t.Fatal(err) + } + if !result.WorktreeRetained || daemon.deleted.EnvironmentID != "logical-b" || daemon.reconcileCalls != 0 { + t.Fatalf("logical delete=%+v binding=%+v legacy reconcile calls=%d", result, daemon.deleted, daemon.reconcileCalls) + } +} + +type fakeLifecycleClient struct { + entries []microvmclient.InventoryEntry + continuation string + inventoryRequest microvmclient.InventoryRequest + deleted microvmclient.GenerationBinding + reconcileCalls int + reconcileErr error +} + +func (f *fakeLifecycleClient) Inventory(_ context.Context, _ string, requests ...microvmclient.InventoryRequest) (microvmclient.InventoryPage, error) { + if len(requests) > 0 { + f.inventoryRequest = requests[0] + } + return microvmclient.InventoryPage{Entries: f.entries, Continuation: f.continuation}, nil +} +func (f *fakeLifecycleClient) Reconcile(context.Context, string) error { + f.reconcileCalls++ + return f.reconcileErr +} +func (f *fakeLifecycleClient) DeleteGeneration(_ context.Context, binding microvmclient.GenerationBinding) (microvmclient.DeleteResult, error) { + f.deleted = binding + return microvmclient.DeleteResult{WorktreePath: "/worktrees/s1", WorktreeRetained: true}, nil +} + +type fakeOps struct { + calls []string + running bool + doctorErr error + failAt string + identityMismatch string +} + +func (f *fakeOps) stageError(stage string) error { + if f.failAt == stage { + return errors.New(stage + " failed") + } + return nil +} +func (f *fakeOps) Preflight(context.Context, Paths) error { + f.calls = append(f.calls, "preflight") + return f.stageError("preflight") +} +func (f *fakeOps) Download(context.Context, Release, string) (string, error) { + f.calls = append(f.calls, "download") + return "/bundle/manifest.json", f.stageError("download") +} +func (f *fakeOps) Verify(context.Context, Release, string) error { + f.calls = append(f.calls, "verify") + return f.stageError("verify") +} +func (f *fakeOps) Install(_ context.Context, _ string, installRoot string) (InstalledArtifacts, error) { + f.calls = append(f.calls, "install") + if err := f.stageError("install"); err != nil { + return InstalledArtifacts{}, err + } + binary := filepath.Join(filepath.Dir(installRoot), "bin", "mecatl-microvmd") + if err := os.MkdirAll(filepath.Dir(binary), 0o700); err != nil { + return InstalledArtifacts{}, err + } + if err := os.WriteFile(binary, []byte("test-microvmd"), 0o700); err != nil { + return InstalledArtifacts{}, err + } + digest := "sha256:" + strings.Repeat("1", 64) + artifacts := make([]Artifact, 0, 4) + for _, kind := range []string{"runtime", "firmware", "execution-image", "guest-agent"} { + artifact := Artifact{Kind: kind, Reference: "oci.example/" + kind + "@" + digest, Digest: digest, Path: filepath.Join(installRoot, "artifacts", kind), Provenance: filepath.Join(filepath.Dir(installRoot), "download", kind+".provenance.json"), SigstoreBundle: filepath.Join(filepath.Dir(installRoot), "download", kind+".sigstore.json")} + if kind == "execution-image" { + artifact.Reference = "ghcr.io/stacklok/brood-box/base@" + digest + artifact.ManifestDigest = digest + artifact.DiscoveryReference = "ghcr.io/stacklok/brood-box/base:latest" + artifact.ResolutionEvidence = digest + artifact.Platform = "linux/amd64" + artifact.Path = "" + } + artifacts = append(artifacts, artifact) + } + return InstalledArtifacts{Artifacts: artifacts}, nil +} +func (f *fakeOps) Running(context.Context, Paths) (bool, error) { return f.running, nil } +func (f *fakeOps) DaemonInfo(_ context.Context, paths Paths) (DaemonInfo, error) { + f.calls = append(f.calls, "info") + info, err := expectedDaemonInfo(paths) + if err != nil { + return DaemonInfo{}, err + } + if f.identityMismatch == "binary" { + info.BinaryIdentity = "sha256:" + strings.Repeat("0", 64) + } + if f.identityMismatch == "policy" { + info.PolicyRevision = "stale-policy" + } + return info, nil +} +func (f *fakeOps) Start(context.Context, Paths) error { + f.calls = append(f.calls, "start") + if err := f.stageError("start"); err != nil { + return err + } + f.running = true + return nil +} +func (f *fakeOps) WaitSocket(context.Context, string) error { + f.calls = append(f.calls, "wait") + return f.stageError("wait") +} +func (f *fakeOps) Doctor(context.Context, Paths) (string, error) { + f.calls = append(f.calls, "doctor") + if f.doctorErr != nil { + return "", f.doctorErr + } + return "PASS hypervisor ready; remediation: none\n", f.stageError("doctor") +} +func (f *fakeOps) Stop(context.Context, Paths) error { + f.calls = append(f.calls, "stop") + if err := f.stageError("stop"); err != nil { + return fmt.Errorf("safe managed restart failed: %w", err) + } + f.running = false + return nil +} + +func testPaths(root string) Paths { + return Paths{StateDir: filepath.Join(root, "state"), RuntimeDir: filepath.Join(root, "run"), Socket: filepath.Join(root, "run", "d.sock"), DataDir: filepath.Join(root, "data"), ConfigFile: filepath.Join(root, "config", "microvmd.json"), UserSettings: filepath.Join(root, "config", "settings.yaml"), DaemonBinary: filepath.Join(root, "data", "bin", "mecatl-microvmd")} +} + +func testPolicy(_ string) Policy { + return Policy{PolicyRevision: "release-v1", CertificateIdentity: "https://github.com/stacklok/mecatl/.github/workflows/release.yml@refs/tags/v1", OIDCIssuer: "https://token.actions.githubusercontent.com", RequiredAttestations: map[string]string{"runtime": "https://slsa.dev/provenance/v1", "firmware": "https://slsa.dev/provenance/v1", "execution-image": "https://slsa.dev/provenance/v1", "guest-agent": "https://slsa.dev/provenance/v1"}, GuestEgressMode: "deny-all"} +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/internal/adapter/microvmmanager/process_linux.go b/internal/adapter/microvmmanager/process_linux.go new file mode 100644 index 000000000..16f8aa292 --- /dev/null +++ b/internal/adapter/microvmmanager/process_linux.go @@ -0,0 +1,36 @@ +//go:build linux + +package microvmmanager + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +type managedProcessHandle struct { + fd int +} + +func openManagedProcess(pid int) (managedProcessHandle, error) { + fd, err := unix.PidfdOpen(pid, 0) + if err != nil { + return managedProcessHandle{}, err + } + return managedProcessHandle{fd: fd}, nil +} + +func (h managedProcessHandle) signal() error { + if err := unix.PidfdSendSignal(h.fd, unix.SIGTERM, nil, 0); err != nil && !errors.Is(err, unix.ESRCH) { + return err + } + return nil +} + +func (h managedProcessHandle) close() error { + if h.fd < 0 { + return os.ErrInvalid + } + return unix.Close(h.fd) +} diff --git a/internal/adapter/microvmmanager/process_other.go b/internal/adapter/microvmmanager/process_other.go new file mode 100644 index 000000000..f76f2d34f --- /dev/null +++ b/internal/adapter/microvmmanager/process_other.go @@ -0,0 +1,27 @@ +//go:build !linux + +package microvmmanager + +import ( + "errors" + "os" + "syscall" +) + +type managedProcessHandle struct { + process *os.Process +} + +func openManagedProcess(pid int) (managedProcessHandle, error) { + process, err := os.FindProcess(pid) + return managedProcessHandle{process: process}, err +} + +func (h managedProcessHandle) signal() error { + if err := h.process.Signal(syscall.SIGTERM); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + return nil +} + +func (managedProcessHandle) close() error { return nil } diff --git a/internal/adapter/microvmmanager/userns_linux.go b/internal/adapter/microvmmanager/userns_linux.go new file mode 100644 index 000000000..437fa0cbd --- /dev/null +++ b/internal/adapter/microvmmanager/userns_linux.go @@ -0,0 +1,56 @@ +//go:build linux + +package microvmmanager + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strconv" + "strings" + "syscall" +) + +func checkLinuxUserNamespaces(ctx context.Context) error { + return checkLinuxUserNamespaceControls(os.ReadFile, func() error { + cmd := exec.CommandContext(ctx, "true") + cmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWUSER} + return cmd.Run() + }) +} + +func checkLinuxUserNamespaceControls(readFile func(string) ([]byte, error), probe func() error) error { + maximum, err := readPositiveControl(readFile, "/proc/sys/user/max_user_namespaces") + if err != nil { + return fmt.Errorf("unprivileged user namespaces are unavailable: %w; enable them and provide free per-user namespace quota, then retry", err) + } + if maximum == 0 { + return errors.New("unprivileged user namespaces are disabled by user.max_user_namespaces=0; set a positive host limit and retry") + } + clone, err := readPositiveControl(readFile, "/proc/sys/kernel/unprivileged_userns_clone") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read kernel.unprivileged_userns_clone: %w", err) + } + if err == nil && clone == 0 { + return errors.New("unprivileged user namespaces are disabled by kernel.unprivileged_userns_clone=0; enable the control and retry") + } + if err := probe(); err != nil { + return fmt.Errorf("create an unprivileged user namespace: %w (the host policy may disable user namespaces or the current user may have exhausted its namespace quota; free quota or enable the capability, then retry)", err) + } + return nil +} + +func readPositiveControl(readFile func(string) ([]byte, error), path string) (uint64, error) { + data, err := readFile(path) + if err != nil { + return 0, err + } + value, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64) + if err != nil { + return 0, fmt.Errorf("parse %s: %w", path, err) + } + return value, nil +} diff --git a/internal/adapter/microvmmanager/userns_linux_test.go b/internal/adapter/microvmmanager/userns_linux_test.go new file mode 100644 index 000000000..8e6a47c12 --- /dev/null +++ b/internal/adapter/microvmmanager/userns_linux_test.go @@ -0,0 +1,47 @@ +//go:build linux + +package microvmmanager + +import ( + "errors" + "io/fs" + "strings" + "testing" +) + +func TestLinuxUserNamespaceControls(t *testing.T) { + tests := []struct { + name, max, clone string + probe error + want string + }{ + {name: "enabled", max: "1024\n", clone: "1\n"}, + {name: "disabled by quota control", max: "0\n", clone: "1\n", want: "max_user_namespaces=0"}, + {name: "disabled by clone control", max: "1024\n", clone: "0\n", want: "unprivileged_userns_clone=0"}, + {name: "quota exhausted", max: "1024\n", clone: "1\n", probe: errors.New("resource temporarily unavailable"), want: "exhausted its namespace quota"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + read := func(path string) ([]byte, error) { + switch path { + case "/proc/sys/user/max_user_namespaces": + return []byte(test.max), nil + case "/proc/sys/kernel/unprivileged_userns_clone": + return []byte(test.clone), nil + default: + return nil, fs.ErrNotExist + } + } + err := checkLinuxUserNamespaceControls(read, func() error { return test.probe }) + if test.want == "" { + if err != nil { + t.Fatalf("enabled controls failed: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} diff --git a/internal/adapter/microvmmanager/userns_other.go b/internal/adapter/microvmmanager/userns_other.go new file mode 100644 index 000000000..c24a60315 --- /dev/null +++ b/internal/adapter/microvmmanager/userns_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package microvmmanager + +import "context" + +func checkLinuxUserNamespaces(context.Context) error { return nil } diff --git a/internal/adapter/server/errorcodes.go b/internal/adapter/server/errorcodes.go index 008380f84..3cbf99209 100644 --- a/internal/adapter/server/errorcodes.go +++ b/internal/adapter/server/errorcodes.go @@ -108,6 +108,7 @@ var errorRegistry = []errorCodeEntry{ {Sentinel: ErrDreamDeadline, Code: "dream_deadline", GRPC: codes.DeadlineExceeded, HTTPStatus: http.StatusGatewayTimeout, Title: "Dream plan deadline exceeded"}, {Sentinel: ErrDreamRequestFailed, Code: "dream_request_failed", GRPC: codes.Internal, HTTPStatus: http.StatusInternalServerError, Title: "Dream request failed"}, {Sentinel: ErrFailedStepRetryIneligible, Code: "failed_step_retry_ineligible", GRPC: codes.FailedPrecondition, HTTPStatus: http.StatusConflict, Title: "Failed-step retry is not eligible"}, + {Sentinel: ErrEnvironmentLogicalRootUnavailable, Code: "environment_logical_root_unavailable", GRPC: codes.FailedPrecondition, HTTPStatus: http.StatusPreconditionFailed, Title: "Repository worktree attachment unavailable"}, {Sentinel: ErrFailedPrecondition, Code: "failed_precondition", GRPC: codes.FailedPrecondition, HTTPStatus: http.StatusPreconditionFailed, Title: "Failed precondition"}, {Sentinel: ErrNoActiveRun, Code: "no_active_run", GRPC: codes.FailedPrecondition, HTTPStatus: http.StatusConflict, Title: "No active run for session"}, {Sentinel: ErrNotAwaitingPlan, Code: "not_awaiting_plan", GRPC: codes.FailedPrecondition, HTTPStatus: http.StatusConflict, Title: "Session is not awaiting a plan approval"}, diff --git a/internal/adapter/server/errors.go b/internal/adapter/server/errors.go index 97d19fd0c..08d9edac5 100644 --- a/internal/adapter/server/errors.go +++ b/internal/adapter/server/errors.go @@ -79,6 +79,10 @@ var ( // the read-only-share invariant). Adapters map it to FailedPrecondition / // HTTP 412, distinguishing it from a bad request (ErrInvalidArgument). ErrFailedPrecondition = errors.New("server: failed precondition") + // ErrEnvironmentLogicalRootUnavailable reports that the authenticated guest + // could not attach the prepared repository worktree. The fixed wording is safe + // for client transports and deliberately excludes daemon/backend detail. + ErrEnvironmentLogicalRootUnavailable = fmt.Errorf("%w: Guest could not attach the prepared repository worktree", ErrFailedPrecondition) // ErrLearningUnavailable means reflection/proposal persistence is not wired. ErrLearningUnavailable = errors.New("server: learning proposals are not configured") // ErrAttemptVersionConflict reports an opaque expected-version CAS mismatch. diff --git a/internal/adapter/server/export_test.go b/internal/adapter/server/export_test.go index b46cf3204..994dcfd78 100644 --- a/internal/adapter/server/export_test.go +++ b/internal/adapter/server/export_test.go @@ -35,10 +35,8 @@ func (a *Authenticator) RejectedTrackedClientCountForTest() int { return len(a.rejectedLimiters.clients) } -// SetEngineCloseTimeoutForTest overrides the package-level engineCloseTimeout var -// for the duration of one test; it returns a restore func (defer it). The override -// lets a test shrink the timeout so a bounded-engine-close assertion runs in -// milliseconds, not seconds. +// SetEngineCloseTimeoutForTest overrides the aggregate shutdown phase timeout +// for the duration of one test; it returns a restore func (defer it). func SetEngineCloseTimeoutForTest(d time.Duration) (restore func()) { prev := engineCloseTimeout engineCloseTimeout = d diff --git a/internal/adapter/server/microvm_egress_api_test.go b/internal/adapter/server/microvm_egress_api_test.go new file mode 100644 index 000000000..4ed3c1a8f --- /dev/null +++ b/internal/adapter/server/microvm_egress_api_test.go @@ -0,0 +1,18 @@ +package server_test + +import ( + "testing" + + "google.golang.org/protobuf/reflect/protoreflect" + + mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" +) + +func TestCreateSessionAPIHasNoMicroVMGuestEgressPolicy(t *testing.T) { + fields := (&mecatlv1.CreateSessionRequest{}).ProtoReflect().Descriptor().Fields() + for _, name := range []protoreflect.Name{"guest_egress", "guest_egress_mode", "guest_allow", "microvm_guest_egress", "microvm_guest_allow"} { + if fields.ByName(name) != nil { + t.Errorf("public CreateSessionRequest exposes host-operator policy field %q", name) + } + } +} diff --git a/authn/oidc/fixture_test.go b/internal/adapter/server/oidc_fixture_test.go similarity index 98% rename from authn/oidc/fixture_test.go rename to internal/adapter/server/oidc_fixture_test.go index 34674f49c..a6b0e9f7f 100644 --- a/authn/oidc/fixture_test.go +++ b/internal/adapter/server/oidc_fixture_test.go @@ -1,4 +1,4 @@ -package oidc_test +package server_test import ( "context" diff --git a/internal/adapter/server/sdk_typescript_scenario11_test.go b/internal/adapter/server/sdk_typescript_scenario11_test.go index ce4b9b1b7..2c2961de4 100644 --- a/internal/adapter/server/sdk_typescript_scenario11_test.go +++ b/internal/adapter/server/sdk_typescript_scenario11_test.go @@ -37,7 +37,7 @@ type sdkScenario11Job struct { Env map[string]string `yaml:"env"` Environment string `yaml:"environment"` If string `yaml:"if"` - Needs string `yaml:"needs"` + Needs any `yaml:"needs"` Outputs map[string]string `yaml:"outputs"` Permissions map[string]string `yaml:"permissions"` RunsOn string `yaml:"runs-on"` diff --git a/internal/adapter/server/service.go b/internal/adapter/server/service.go index a5655e97b..0665b87b8 100644 --- a/internal/adapter/server/service.go +++ b/internal/adapter/server/service.go @@ -811,11 +811,9 @@ const recoverNoticeText = "this session's last turn failed on a permanent provid // ErrConfig is returned by NewService when a required dependency is missing. var ErrConfig = errors.New("server: invalid config") -// engineCloseTimeout bounds how long Close waits for all per-session engine close -// calls to complete before proceeding. It is a `var` (not a const) so a test can -// shrink it to assert Close is bounded; production keeps the conservative default. -// A wedged engine close that ignores its deadline is abandoned (best-effort), never -// allowed to stall shutdown unboundedly. +// engineCloseTimeout bounds aggregate shutdown work that may call external +// adapters. It is a `var` so tests can shrink it; one deadline covers every +// concurrent detachment rather than multiplying by the attachment count. var engineCloseTimeout = 10 * time.Second // Service is the surface-agnostic application service shared by the gRPC and diff --git a/internal/apicheck/microvm_module_contract_test.go b/internal/apicheck/microvm_module_contract_test.go new file mode 100644 index 000000000..566f46177 --- /dev/null +++ b/internal/apicheck/microvm_module_contract_test.go @@ -0,0 +1,125 @@ +package apicheck + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestADR_0224_MicroVMDependenciesStayOutOfEngineAndRoot keeps the libkrun-backed +// runtime in its opt-in nested module. The production engine and default binaries +// must remain usable without resolving or linking the microVM runtime. +func TestADR_0224_MicroVMDependenciesStayOutOfEngineAndRoot(t *testing.T) { + repo := repoRoot(t) + microvmMod := filepath.Join(repo, "environment", "microvm", "go.mod") + mustContain(t, microvmMod, "module github.com/stacklok/mecatl/environment/microvm") + mustContain(t, microvmMod, "github.com/stacklok/go-microvm v0.0.40") + mustContain(t, filepath.Join(repo, "go.work"), "use ./environment/microvm") + + for _, path := range []string{ + filepath.Join(repo, "go.mod"), + filepath.Join(repo, "engine", "go.mod"), + } { + mustNotContainAny(t, path, "go-microvm", "libkrun") + } + + for _, dir := range []string{ + filepath.Join(repo, "engine"), + repo, + } { + assertProductionImportsExclude(t, dir, filepath.Join(repo, "environment", "microvm")) + } + + assertGoListExcludes(t, repo, "./cmd/mecated", "./cmd/mecademo", "./cmd/mecatequi", "./cmd/mecak8s", "./cmd/mecatui") + assertGoListExcludes(t, filepath.Join(repo, "engine"), "./...") +} + +func TestMicroVMRuntime_StandardBinariesDoNotLinkLibkrun(t *testing.T) { + repo := repoRoot(t) + microvm := filepath.Join(repo, "environment", "microvm") + for _, target := range []string{"./cmd/mecatl-microvmd", "./cmd/mecatl-guest-agent"} { + outputPath := filepath.Join(t.TempDir(), filepath.Base(target)) + cmd := exec.Command("go", "build", "-o", outputPath, target) + cmd.Dir = microvm + cmd.Env = append(os.Environ(), "GOWORK=off") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build microVM target %s: %v\n%s", target, err, output) + } + } + assertGoListExcludes(t, repo, "./cmd/mecated", "./cmd/mecademo", "./cmd/mecatequi", "./cmd/mecak8s", "./cmd/mecatui") + assertGoListExcludes(t, filepath.Join(repo, "engine"), "./...") +} + +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + return filepath.Clean(filepath.Join(wd, "..", "..")) +} + +func mustContain(t *testing.T, path, want string) { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if !bytes.Contains(contents, []byte(want)) { + t.Fatalf("%s does not contain %q", path, want) + } +} + +func mustNotContainAny(t *testing.T, path string, forbidden ...string) { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + for _, value := range forbidden { + if bytes.Contains(contents, []byte(value)) { + t.Fatalf("%s unexpectedly contains %q", path, value) + } + } +} + +func assertProductionImportsExclude(t *testing.T, root, excluded string) { + t.Helper() + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + if path == excluded || (root != filepath.Join(repoRoot(t), "engine") && path == filepath.Join(root, "engine")) { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + mustNotContainAny(t, path, `"github.com/stacklok/go-microvm`, "libkrun") + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } +} + +func assertGoListExcludes(t *testing.T, dir string, patterns ...string) { + t.Helper() + cmd := exec.Command("go", append([]string{"list", "-deps"}, patterns...)...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go list -deps %s: %v\n%s", strings.Join(patterns, " "), err, output) + } + for _, forbidden := range []string{"go-microvm", "libkrun"} { + if bytes.Contains(output, []byte(forbidden)) { + t.Fatalf("go list -deps %s unexpectedly includes %q", strings.Join(patterns, " "), forbidden) + } + } +} diff --git a/internal/app/build.go b/internal/app/build.go index dba1dc85b..3ae085d4c 100644 --- a/internal/app/build.go +++ b/internal/app/build.go @@ -150,9 +150,18 @@ type Config struct { // deployment policy the cmd/ main decides from its listener topology and Build passes through verbatim; the zero value fails // closed, so a composition root that never sets it refuses the field. ClientMCPOnCreate bool - Model string - UseOpenAI bool - OpenAIKey string + // EnvironmentForkers routes isolated delegation children by the parent + // EnvironmentRef kind. External backends (for example microVM) register their + // complete child-environment forker here; local/memory parents retain the + // ordinary host forker. An unregistered external kind fails closed rather than + // creating a host-local child under a remote parent. + EnvironmentForkers map[session.EnvironmentKind]tool.EnvironmentForker + // EnvironmentMergers routes merge-back through the same backend kind as the + // parent; external children must never fall through to the host Git merger. + EnvironmentMergers map[session.EnvironmentKind]tool.EnvironmentMerger + Model string + UseOpenAI bool + OpenAIKey string // OpenAIBearerTokenFile is a rotating credential source for only the OpenAI // registry entry. The adapter reads it for every request. OpenAIBearerTokenFile string @@ -1921,6 +1930,7 @@ func Build(ctx context.Context, cfg Config) (*Built, error) { // The local capability is shared by Service lease ownership, delegation-child // liveness, and the engine's persistence/audit adapters. mutationCapability := server.NewSessionMutationCapability(sessionLease != nil) + // One process-wide liveness registry bridges engine-owned delegation children // to Service/retention without introducing an engine→server dependency. When // leasing is configured it owns distributed child holds as well. @@ -5492,7 +5502,8 @@ func buildCatalog(ctx context.Context, cfg Config, reg *providerRegistry, provid // consumer. var autoMerger tool.EnvironmentMerger if cfg.EnableParallel { - autoMerger = forker.NewSerializingMerger(forker.NewMerger()) + localMerger := forker.NewMerger() + autoMerger = forker.NewSerializingMerger(forker.NewKindMergerRouter(localMerger, cfg.EnvironmentMergers)) } assets := catalogAssets{ @@ -7133,13 +7144,13 @@ func buildSubagentTool(ctx context.Context, cfg Config, provReg *providerRegistr // buildSandboxedCommandRunner does (sandboxedRunner != nil already proves the // gate passed at build time; the per-child builder re-checks it so a future // per-session trust change cannot hand a shell to an untrusted child). - taskForker := forker.New(newForkWorkspace(), forker.WithDirtyOverlay(), + taskForker := routeChildForker(cfg, forker.New(newForkWorkspace(), forker.WithDirtyOverlay(), forker.WithRunner(func(childRoot string) tool.CommandRunner { if !sandboxedShellAvailable(cfg) { return nil } return newHardenedRunnerForRoot(cfg, childRoot) - })) + }))) opts = append(opts, agent.WithChildForker(taskForker)) } // Base-sharing children retain the parent content backend but receive a @@ -7459,6 +7470,10 @@ func buildAgentWritableEngineFactory(ctx context.Context, cfg Config, provReg *p return ordinary } +func routeChildForker(cfg Config, local tool.EnvironmentForker) tool.EnvironmentForker { + return forker.NewKindRouter(local, cfg.EnvironmentForkers) +} + // buildTeamWiring constructs the agent-team dependencies — the unified per-member // engine factory, the TWO workspace forkers (force-copy for mutating members, // worktree for read-only-isolated members), and the shared team hooks runner — that @@ -7558,11 +7573,11 @@ func buildTeamWiring(_ context.Context, cfg Config, provReg *providerRegistry, p } return newHardenedRunnerForRoot(cfg, childRoot) } - fk := forker.New(newForkWorkspace(), forker.WithForceCopy(), forker.WithRunner(fkRunnerBuilder)) - roFk := forker.New(newForkWorkspace(), forker.WithDirtyOverlay(), forker.WithRunner(roRunnerBuilder)) + fk := routeChildForker(cfg, forker.New(newForkWorkspace(), forker.WithForceCopy(), forker.WithRunner(fkRunnerBuilder))) + roFk := routeChildForker(cfg, forker.New(newForkWorkspace(), forker.WithDirtyOverlay(), forker.WithRunner(roRunnerBuilder))) memberRunner := buildSandboxedCommandRunner(cfg) mutatingRunner := buildForceCopyRunner(cfg) - roIsolationAvailable := memberRunner != nil && roFk != nil + roIsolationAvailable := memberRunner != nil factory := buildMemberEngine(cfg, provReg, provider, parentProviderID, parentModel, teamHooks, agentReg, skillIdx, memberRunner, mutatingRunner, roIsolationAvailable, mainMgr, a, false) return factory, fk, roFk, childWorkspaceView, teamHooks } diff --git a/internal/app/catalog.go b/internal/app/catalog.go index e0601f12b..546445918 100644 --- a/internal/app/catalog.go +++ b/internal/app/catalog.go @@ -407,13 +407,13 @@ func registerParallelTool(ctx context.Context, cfg Config, cat *tool.Catalog, re // parent base. The builder applies the SAME trust-UNGATED hardening // buildForceCopyRunner does (force-copy forks do no fork-time git, so the // trust gate does not apply — see the comment above). - fk := forker.New(newForkWorkspace(), forker.WithForceCopy(), + fk := routeChildForker(cfg, forker.New(newForkWorkspace(), forker.WithForceCopy(), forker.WithRunner(func(childRoot string) tool.CommandRunner { if !forceCopyShellAvailable(cfg) { return nil } return newHardenedRunnerForRoot(cfg, childRoot) - })) + }))) // Parallel branches run Bash through the HARDENED, trust-UNGATED runner (issue // #40) — the same construction as Mutating team members (buildForceCopyRunner). // Ungated because a force-copy fork is created by a pure FS copy, with NO git diff --git a/internal/app/driverstore.go b/internal/app/driverstore.go index b65aec09e..19691ba50 100644 --- a/internal/app/driverstore.go +++ b/internal/app/driverstore.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "os" "path/filepath" "sync" @@ -103,24 +104,25 @@ func canonicalConfiguredDir(path string) (string, error) { if err != nil { return "", err } - if resolved, resolveErr := filepath.EvalSymlinks(abs); resolveErr == nil { - return filepath.Clean(resolved), nil - } - // A not-yet-created leaf (and possibly several missing ancestors, e.g. a - // completely fresh install) cannot be symlink-aliased; walk up until an - // existing ancestor resolves, then rejoin the missing suffix onto it. - missing := filepath.Base(abs) - dir := filepath.Dir(abs) + current := abs + var missing []string for { - if resolved, resolveErr := filepath.EvalSymlinks(dir); resolveErr == nil { - return filepath.Clean(filepath.Join(resolved, missing)), nil + resolved, resolveErr := filepath.EvalSymlinks(current) + if resolveErr == nil { + for i := len(missing) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, missing[i]) + } + return filepath.Clean(resolved), nil + } + if _, lstatErr := os.Lstat(current); lstatErr == nil || !os.IsNotExist(lstatErr) { + return "", resolveErr } - parent := filepath.Dir(dir) - if parent == dir { - return "", err + parent := filepath.Dir(current) + if parent == current { + return "", resolveErr } - missing = filepath.Join(filepath.Base(dir), missing) - dir = parent + missing = append(missing, filepath.Base(current)) + current = parent } } diff --git a/internal/app/driverstore_test.go b/internal/app/driverstore_test.go index a8ec958e5..92540608d 100644 --- a/internal/app/driverstore_test.go +++ b/internal/app/driverstore_test.go @@ -29,6 +29,18 @@ import ( "github.com/stacklok/mecatl/internal/adapter/store/jsonlstore" ) +func TestCanonicalConfiguredDir_AllowsMissingDefaultParents(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, ".local", "share", "mecatui", "memory", "workspace") + got, err := canonicalConfiguredDir(path) + if err != nil { + t.Fatalf("canonicalConfiguredDir(first-run path): %v", err) + } + if got != path { + t.Fatalf("canonical path = %q, want %q", got, path) + } +} + // TestValidateDriverConfigExclusivity pins the mutual-exclusion rule: a local // dir and a remote driver URL for the SAME store is a fatal config error; // every other combination passes. diff --git a/internal/app/microvm_lifecycle_e2e_test.go b/internal/app/microvm_lifecycle_e2e_test.go new file mode 100644 index 000000000..724801ad9 --- /dev/null +++ b/internal/app/microvm_lifecycle_e2e_test.go @@ -0,0 +1,160 @@ +package app + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "io" + "net" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" + "github.com/stacklok/mecatl/internal/adapter/server" +) + +type placementTestDaemon struct { + listener net.Listener + mu sync.Mutex + ops []string +} + +func startPlacementTestDaemon(t *testing.T) *placementTestDaemon { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "mecatl-microvm-") + if err != nil { + t.Fatalf("create short private socket directory: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(dir); err != nil { + t.Errorf("remove short private socket directory: %v", err) + } + }) + listener, err := net.Listen("unix", filepath.Join(dir, "microvmd.sock")) + if err != nil { + t.Fatal(err) + } + d := &placementTestDaemon{listener: listener} + t.Cleanup(func() { _ = listener.Close() }) + go d.serve() + return d +} + +func (d *placementTestDaemon) endpoint() string { return "unix://" + d.listener.Addr().String() } + +func (d *placementTestDaemon) serve() { + for { + conn, err := d.listener.Accept() + if err != nil { + return + } + go func() { + defer func() { _ = conn.Close() }() + var request map[string]any + if readPlacementFrame(conn, &request) != nil { + return + } + op, _ := request["operation"].(string) + d.mu.Lock() + d.ops = append(d.ops, op) + d.mu.Unlock() + binding, _ := request["binding"].(map[string]any) + response := map[string]any{"binding": binding} + if op == "create" { + provision, _ := request["provision"].(map[string]any) + sessionID, _ := provision["session_id"].(string) + binding = map[string]any{"owner": "local", "session_id": sessionID, "environment_id": "env-fixed", "ref": "env-fixed@7", "generation": 7} + response["binding"] = binding + response["created"] = map[string]any{ + "ref": map[string]any{"Kind": "microvm", "ID": "env-fixed@7"}, "generation": 7, + "host_worktree": "/private/host/path", "guest_root": "/workspace", "profile": "microvm-local", + "guest_egress": "deny-all", "host_egress": "not constrained", + } + } + _ = writePlacementFrame(conn, response) + }() + } +} + +func (d *placementTestDaemon) operations() []string { + d.mu.Lock() + defer d.mu.Unlock() + return append([]string(nil), d.ops...) +} + +func TestMicroVMDefaultPlacementUsesNormalCreateSessionAndExactReattach(t *testing.T) { + ctx := context.Background() + daemon := startPlacementTestDaemon(t) + const scope server.PlacementScope = "deployment" + provider, err := microvmadapter.NewPlacementProvider(daemon.endpoint(), t.TempDir(), "microvm-local", scope, nil) + if err != nil { + t.Fatal(err) + } + built, err := Build(ctx, Config{ + Workspace: t.TempDir(), StoreDir: t.TempDir(), UseMock: true, + PlacementProvider: provider, PlacementScope: scope, + EnvironmentForkers: map[session.EnvironmentKind]tool.EnvironmentForker{session.EnvironmentKind("microvm"): provider}, + EnvironmentMergers: map[session.EnvironmentKind]tool.EnvironmentMerger{session.EnvironmentKind("microvm"): provider}, + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer built.Close() + + sess, err := built.Service.CreateSession(ctx, session.ModeDefault, session.Limits{}) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if sess.EnvironmentRef.Kind != "microvm" || sess.EnvironmentRef.ID == "" || sess.EnvironmentRef.Revision != "7" { + t.Fatalf("exact environment ref = %+v", sess.EnvironmentRef) + } + if sess.Placement.Kind != "microvm" || sess.Placement.Label != "Local microVM" || sess.Placement.Revision != "7" { + t.Fatalf("public placement metadata = %+v", sess.Placement) + } + + run, err := built.Service.StartRunContent(ctx, sess.ID, "continue", nil) + if err != nil { + t.Fatalf("StartRunContent: %v", err) + } + for range run.Events() { + } + ops := daemon.operations() + foundResolve := false + for _, op := range ops { + foundResolve = foundResolve || op == "resolve" + } + if !foundResolve { + t.Fatalf("daemon operations = %v; exact reattach was not exercised", ops) + } +} + +func readPlacementFrame(r io.Reader, value any) error { + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return err + } + payload := make([]byte, binary.BigEndian.Uint32(header[:])) + if _, err := io.ReadFull(r, payload); err != nil { + return err + } + return json.Unmarshal(payload, value) +} + +func writePlacementFrame(w io.Writer, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + var frame bytes.Buffer + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(payload))) + frame.Write(header[:]) + frame.Write(payload) + _, err = w.Write(frame.Bytes()) + return err +} diff --git a/internal/app/usermodelreview_test.go b/internal/app/usermodelreview_test.go index 77a4a02f8..8c00f3c03 100644 --- a/internal/app/usermodelreview_test.go +++ b/internal/app/usermodelreview_test.go @@ -74,6 +74,7 @@ func TestLearningAdmissionIsGlobalAcrossConcurrentProviderObservers(t *testing.T func learningResolverConfig(t *testing.T, operator, workspace string) Config { t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) cfg := Config{Workspace: workspace, PermissionsConventional: workspace != ""} if operator != "" { path := filepath.Join(t.TempDir(), "settings.yaml") diff --git a/internal/microvmcmd/command.go b/internal/microvmcmd/command.go new file mode 100644 index 000000000..154cb6f23 --- /dev/null +++ b/internal/microvmcmd/command.go @@ -0,0 +1,392 @@ +// Package microvmcmd implements the binary-neutral, local microVM lifecycle +// administration command shared by mecated and mecatui. +package microvmcmd + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "strings" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" +) + +// Manager is the narrow lifecycle-administration surface used by the command. +type Manager interface { + Doctor(context.Context) (string, error) + Status(context.Context, ...microvmmanager.StatusRequest) (microvmmanager.Status, error) + Delete(context.Context, microvmmanager.DeleteRequest) (microvmmanager.DeleteResult, error) +} + +type outputFormat string + +// Frontend identifies the installed binary that invoked the shared command. +type Frontend string + +const ( + // FrontendMecated identifies the mecated command frontend. + FrontendMecated Frontend = "mecated" + // FrontendMecatui identifies the mecatui command frontend. + FrontendMecatui Frontend = "mecatui" + + outputText outputFormat = "text" + outputJSON outputFormat = "json" +) + +// Run parses and executes one local microVM administration command. +func Run(ctx context.Context, frontend Frontend, args []string, in io.Reader, out io.Writer, manager Manager, interactive bool) error { + if frontend != FrontendMecated && frontend != FrontendMecatui { + return fmt.Errorf("unsupported microvm command frontend %q", frontend) + } + if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "--help-all" || args[0] == "-h" { + WriteHelp(out) + return nil + } + switch args[0] { + case "doctor": + return runDoctor(ctx, args[1:], out, manager) + case "status": + return runStatus(ctx, frontend, args[1:], out, manager) + case "delete": + return runDelete(ctx, args[1:], in, out, manager, interactive) + default: + return fmt.Errorf("unknown microvm command %q; run 'mecated microvm --help' (or 'mecatui microvm --help') for doctor, status, and delete usage", args[0]) + } +} + +func outputFlag(fs *flag.FlagSet, value *string) { + fs.StringVar(value, "output", string(outputText), "output format: text or json") +} + +func parseOutput(value string) (outputFormat, error) { + format := outputFormat(value) + if format != outputText && format != outputJSON { + return "", fmt.Errorf("unknown output format %q; use text or json", value) + } + return format, nil +} + +func writeJSON(out io.Writer, value any) error { + encoder := json.NewEncoder(out) + encoder.SetEscapeHTML(false) + return encoder.Encode(value) +} + +func runDoctor(ctx context.Context, args []string, out io.Writer, manager Manager) error { + fs := flag.NewFlagSet("microvm doctor", flag.ContinueOnError) + fs.SetOutput(out) + fs.Usage = func() { writeDoctorHelp(out) } + var output string + outputFlag(fs, &output) + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + if fs.NArg() != 0 { + return errors.New("usage: mecated microvm doctor [--output text|json]") + } + format, err := parseOutput(output) + if err != nil { + return err + } + report, doctorErr := manager.Doctor(ctx) + if format == outputJSON { + result := struct { + Success bool `json:"success"` + Report string `json:"report"` + Error string `json:"error"` + }{Success: doctorErr == nil, Report: report} + if doctorErr != nil { + result.Error = doctorErr.Error() + } + if err := writeJSON(out, result); err != nil { + return err + } + } else if report != "" { + _, _ = io.WriteString(out, report) + if !strings.HasSuffix(report, "\n") { + _, _ = fmt.Fprintln(out) + } + } + if doctorErr != nil { + return fmt.Errorf("microVM doctor found problems: %w; follow the next action in the report, then rerun 'mecated microvm doctor' (or 'mecatui microvm doctor')", doctorErr) + } + return nil +} + +func runStatus(ctx context.Context, frontend Frontend, args []string, out io.Writer, manager Manager) error { + fs := flag.NewFlagSet("microvm status", flag.ContinueOnError) + fs.SetOutput(out) + fs.Usage = func() { writeStatusHelp(out) } + var pageSize int + var continuation, output string + fs.IntVar(&pageSize, "page-size", 50, "generation rows per page (maximum 64)") + fs.StringVar(&continuation, "continuation", "", "opaque continuation from the previous status page") + outputFlag(fs, &output) + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + if fs.NArg() != 0 || pageSize <= 0 || pageSize > 64 || len(continuation) > 1024 { + return errors.New("usage: mecated microvm status [--page-size 1..64] [--continuation TOKEN] [--output text|json]") + } + format, err := parseOutput(output) + if err != nil { + return err + } + status, err := manager.Status(ctx, microvmmanager.StatusRequest{PageSize: pageSize, Continuation: continuation}) + if err != nil { + return fmt.Errorf("inspect microVM status: %w", err) + } + if format == outputJSON { + return writeJSON(out, statusJSON{ + Profile: microvmmanager.Alias, Configured: status.Configured, Running: status.Running, + Socket: status.Socket, GuestEgress: status.GuestEgress, Generations: generationJSONs(status.Generations), Continuation: status.Continuation, + }) + } + writeStatusText(out, frontend, status) + return nil +} + +type generationJSON struct { + SessionID string `json:"session_id"` + EnvironmentID string `json:"environment_id"` + Ref string `json:"ref"` + Generation uint32 `json:"generation"` + WorktreePath string `json:"worktree_path"` + State string `json:"state"` + Health string `json:"health"` + Error string `json:"error"` +} + +type statusJSON struct { + Profile string `json:"profile"` + Configured bool `json:"configured"` + Running bool `json:"running"` + Socket string `json:"socket"` + GuestEgress string `json:"guest_egress"` + Generations []generationJSON `json:"generations"` + Continuation string `json:"continuation"` +} + +func generationJSONs(generations []microvmmanager.Generation) []generationJSON { + result := make([]generationJSON, len(generations)) + for i, generation := range generations { + result[i] = generationJSON{ + SessionID: generation.SessionID, EnvironmentID: generation.EnvironmentID, Ref: generation.Ref, + Generation: generation.Generation, WorktreePath: generation.WorktreePath, State: generation.State, + Health: string(generation.Health), Error: generation.Error, + } + } + return result +} + +func writeStatusText(out io.Writer, frontend Frontend, status microvmmanager.Status) { + _, _ = fmt.Fprintf(out, "profile: %s\nconfigured: %t\ndaemon running: %t\nsocket: %s\nguest egress: %s\n", microvmmanager.Alias, status.Configured, status.Running, status.Socket, configuredValue(status.GuestEgress)) + for _, generation := range status.Generations { + _, _ = fmt.Fprintf(out, "logical worktree: session=%s ref=%s repository-generation=%d health=%s state=%s worktree=%s", generation.SessionID, generation.Ref, generation.Generation, generation.Health, generation.State, generation.WorktreePath) + if generation.Error != "" { + _, _ = fmt.Fprintf(out, " error=%q", generation.Error) + } + _, _ = fmt.Fprintln(out) + } + if status.Continuation != "" { + _, _ = fmt.Fprintf(out, "next page: %s microvm status --continuation %q\n", frontend, status.Continuation) + } + _, _ = fmt.Fprintln(out, "status is read-only; it never installs, starts, stops, or deletes microVM state") + if !status.Configured { + _, _ = fmt.Fprintln(out, "backend state: not configured") + writeSelectionExamples(out) + } else if !status.Running { + _, _ = fmt.Fprintln(out, "backend state: configured; daemon not running") + writeSelectionExamples(out) + } +} + +func configuredValue(value string) string { + if value == "" { + return "not configured" + } + return value +} + +func writeSelectionExamples(out io.Writer) { + _, _ = fmt.Fprintln(out, "next (mecatui): mecatui --default-placement microvm-local") + _, _ = fmt.Fprintln(out, `next (HTTP API): run 'mecated serve --headless --default-placement microvm-local', then POST /v1/sessions with {}`) +} + +func runDelete(ctx context.Context, args []string, in io.Reader, out io.Writer, manager Manager, interactive bool) error { + fs := flag.NewFlagSet("microvm delete", flag.ContinueOnError) + fs.SetOutput(out) + fs.Usage = func() { writeDeleteHelp(out) } + var yes bool + var sessionID, ref, output string + var generation uint + fs.BoolVar(&yes, "yes", false, "confirm destructive removal") + fs.StringVar(&sessionID, "session", "", "exact session id") + fs.StringVar(&ref, "ref", "", "exact microVM environment ref") + fs.UintVar(&generation, "generation", 0, "exact microVM generation") + outputFlag(fs, &output) + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("microvm delete: unexpected argument %q", fs.Arg(0)) + } + format, err := parseOutput(output) + if err != nil { + return err + } + if uint64(generation) > uint64(^uint32(0)) { + return errors.New("microvm delete: generation exceeds uint32") + } + request := microvmmanager.DeleteRequest{SessionID: sessionID, Ref: ref, Generation: uint32(generation)} // #nosec G115 -- range checked above. + if err := microvmmanager.ValidateDeleteRequest(request); err != nil { + return err + } + found, err := statusHasGeneration(ctx, manager, request) + if err != nil { + return fmt.Errorf("validate microVM delete target: %w", err) + } + if !found { + return errors.New("microVM delete target is not present in microvm status; refresh status and copy all three values from one row") + } + if format == outputJSON && !yes { + return errors.New("microVM delete with --output json requires explicit confirmation with --yes") + } + if format == outputText { + _, _ = fmt.Fprintf(out, "Permanently delete logical worktree session=%s ref=%s repository-generation=%d. The shared repository VM is not deleted. Closing a host client normally only detaches and preserves this worktree. Dirty worktrees are retained.\n", sessionID, ref, generation) + if interactive && !yes { + _, _ = fmt.Fprint(out, "Permanently delete this exact microVM generation? [y/N] ") + answer, _ := bufio.NewReader(in).ReadString('\n') + yes = strings.EqualFold(strings.TrimSpace(answer), "y") || strings.EqualFold(strings.TrimSpace(answer), "yes") + } + } + if !yes { + return errors.New("microVM delete requires confirmation (--yes for noninteractive use)") + } + result, err := manager.Delete(ctx, request) + if err != nil { + return fmt.Errorf("delete exact microVM generation: %w", err) + } + if format == outputJSON { + return writeJSON(out, struct { + Selector deleteSelectorJSON `json:"selector"` + Result deleteResultJSON `json:"result"` + DirtyRetained bool `json:"dirty_retained"` + }{ + Selector: deleteSelectorJSON{SessionID: request.SessionID, Ref: request.Ref, Generation: request.Generation}, + Result: deleteResultJSON{WorktreePath: result.WorktreePath, WorktreeRemoved: !result.WorktreeRetained, RepositoryVMRetained: true}, + DirtyRetained: result.WorktreeRetained, + }) + } + if result.WorktreeRetained { + _, _ = fmt.Fprintf(out, "logical attachment deleted; dirty worktree retained: %s\n", result.WorktreePath) + } else { + _, _ = fmt.Fprintf(out, "logical attachment and clean worktree removed; repository VM retained: %s\n", result.WorktreePath) + } + return nil +} + +type deleteSelectorJSON struct { + SessionID string `json:"session_id"` + Ref string `json:"ref"` + Generation uint32 `json:"generation"` +} + +type deleteResultJSON struct { + WorktreePath string `json:"worktree_path"` + WorktreeRemoved bool `json:"worktree_removed"` + RepositoryVMRetained bool `json:"repository_vm_retained"` +} + +func statusHasGeneration(ctx context.Context, manager Manager, request microvmmanager.DeleteRequest) (bool, error) { + continuation := "" + seen := make(map[string]struct{}) + for range 1024 { + status, err := manager.Status(ctx, microvmmanager.StatusRequest{PageSize: 64, Continuation: continuation}) + if err != nil { + return false, err + } + for _, generation := range status.Generations { + if generation.SessionID == request.SessionID && generation.Ref == request.Ref && generation.Generation == request.Generation { + return true, nil + } + } + if status.Continuation == "" { + return false, nil + } + if _, duplicate := seen[status.Continuation]; duplicate { + return false, errors.New("microVM status returned a repeated continuation") + } + seen[status.Continuation] = struct{}{} + continuation = status.Continuation + } + return false, errors.New("microVM delete validation exceeded 1024 status pages") +} + +// WriteHelp documents the canonical command and the compatibility frontend. +func WriteHelp(out io.Writer) { + lines := []string{ + "Usage: mecated microvm doctor|status|delete [flags]", + " mecatui microvm doctor|status|delete [flags]", + "", + "Administers microVM state owned by the current local OS principal on this host.", + "It never targets a remote server selected by 'mecatui connect'.", + "'mecatui microvm' is a compatibility frontend over this same command.", + "", + "doctor read-only host preflight and backend health; never installs or starts", + "status read-only owner-scoped repository generations and logical worktrees", + "delete remove an exact logical --session/--ref/--generation after validation and confirmation", + "", + "Run ' microvm --help' for command flags and examples.", + "Text output is the default; every command accepts --output text|json.", + "Repository-VM deletion is not supported.", + "", + "Create a microVM session in mecatui:", + " mecatui --default-placement microvm-local", + "Create one through a headless HTTP server (child asks use headless policy, not a local TUI):", + ` mecated serve --headless --default-placement microvm-local; then POST /v1/sessions with {}`, + "Selecting the deployment default performs installation/readiness; doctor and status never do.", + } + _, _ = fmt.Fprintln(out, strings.Join(lines, "\n")) +} + +func writeDoctorHelp(out io.Writer) { + _, _ = fmt.Fprintln(out, `Usage: mecated microvm doctor [--output text|json] + mecatui microvm doctor [--output text|json] + +Read-only host preflight and backend diagnosis. This command never downloads, +installs, configures, or starts microvmd. On a fresh home it reports "not +configured" and shows how selecting microvm-local performs readiness.`) +} + +func writeStatusHelp(out io.Writer) { + _, _ = fmt.Fprintln(out, `Usage: mecated microvm status [--page-size 1..64] [--continuation TOKEN] [--output text|json] + mecatui microvm status [--page-size 1..64] [--continuation TOKEN] [--output text|json] + +Read-only owner-scoped backend and logical-worktree inventory. It never installs, +starts, stops, or deletes anything. Pass the printed opaque continuation token to +read the next page.`) +} + +func writeDeleteHelp(out io.Writer) { + _, _ = fmt.Fprintln(out, `Usage: mecated microvm delete --session ID --ref REF --generation N [--yes] [--output text|json] + mecatui microvm delete --session ID --ref REF --generation N [--yes] [--output text|json] + +Copy all three selector values from one 'microvm status' row. This removes only +the exact logical attachment and a clean worktree; dirty worktrees and the shared +repository VM are retained. Interactive text mode prompts unless --yes is set; +noninteractive and JSON use require --yes.`) +} diff --git a/internal/microvmcmd/command_test.go b/internal/microvmcmd/command_test.go new file mode 100644 index 000000000..caf7759cc --- /dev/null +++ b/internal/microvmcmd/command_test.go @@ -0,0 +1,373 @@ +package microvmcmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" +) + +func TestStatusJSONIsTypedAndKeepsContinuationAsData(t *testing.T) { + mgr := &fakeManager{status: microvmmanager.Status{ + Configured: true, Running: true, Socket: "/run/microvmd.sock", Continuation: "opaque token", + Generations: []microvmmanager.Generation{{SessionID: "s1", EnvironmentID: "e1", Ref: "e1@7", Generation: 7, WorktreePath: "/work/s1", State: "ready", Health: "healthy"}}, + }} + var out strings.Builder + if err := Run(t.Context(), FrontendMecated, []string{"status", "--page-size", "1", "--output", "json"}, strings.NewReader(""), &out, mgr, false); err != nil { + t.Fatal(err) + } + var got struct { + Profile string `json:"profile"` + Continuation string `json:"continuation"` + Generations []struct { + SessionID string `json:"session_id"` + Generation uint32 `json:"generation"` + } `json:"generations"` + } + if err := json.Unmarshal([]byte(out.String()), &got); err != nil { + t.Fatalf("invalid JSON %q: %v", out.String(), err) + } + if got.Profile != microvmmanager.Alias || got.Continuation != "opaque token" || len(got.Generations) != 1 || got.Generations[0].SessionID != "s1" || got.Generations[0].Generation != 7 { + t.Fatalf("status JSON = %+v", got) + } + if strings.Contains(out.String(), "next page") || mgr.statusRequest.PageSize != 1 { + t.Fatalf("JSON contains prose or wrong request: %q %+v", out.String(), mgr.statusRequest) + } +} + +func TestStatusContinuationNamesOnlyInvokingFrontend(t *testing.T) { + for _, frontend := range []Frontend{FrontendMecated, FrontendMecatui} { + t.Run(string(frontend), func(t *testing.T) { + mgr := &fakeManager{status: microvmmanager.Status{Configured: true, Running: true, Continuation: "opaque token"}} + var out strings.Builder + if err := Run(t.Context(), frontend, []string{"status"}, strings.NewReader(""), &out, mgr, false); err != nil { + t.Fatal(err) + } + want := fmt.Sprintf("next page: %s microvm status --continuation %q", frontend, "opaque token") + if !strings.Contains(out.String(), want) { + t.Fatalf("status omitted invoking frontend continuation %q:\n%s", want, out.String()) + } + other := FrontendMecated + if frontend == FrontendMecated { + other = FrontendMecatui + } + if strings.Contains(out.String(), string(other)+" microvm status --continuation") { + t.Fatalf("status named uninvoked frontend %q:\n%s", other, out.String()) + } + }) + } +} + +func TestStatusEmptyStateIsExplicitAndActionable(t *testing.T) { + var out strings.Builder + if err := Run(t.Context(), FrontendMecated, []string{"status"}, strings.NewReader(""), &out, &fakeManager{}, false); err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "backend state: not configured", + "status is read-only", + "mecatui --default-placement microvm-local", + `mecated serve --headless --default-placement microvm-local`, + } { + if !strings.Contains(out.String(), want) { + t.Fatalf("fresh status omitted %q:\n%s", want, out.String()) + } + } +} + +func TestDeleteRequiresExactSelectorAndExplicitConsent(t *testing.T) { + mgr := &fakeManager{status: microvmmanager.Status{Generations: []microvmmanager.Generation{{SessionID: "s1", Ref: "env@2", Generation: 2}}}} + for _, args := range [][]string{ + {"delete", "--session", "s1", "--ref", "env@2", "--generation", "2"}, + {"delete", "--session", "s1", "--ref", "env@3", "--generation", "2", "--yes"}, + } { + if err := Run(t.Context(), FrontendMecated, args, strings.NewReader("yes\n"), &strings.Builder{}, mgr, false); err == nil { + t.Fatalf("Run(%v) succeeded", args) + } + } + if mgr.deleteCalls != 0 { + t.Fatalf("delete calls = %d", mgr.deleteCalls) + } +} + +func TestDeleteJSONReportsSelectorAndDirtyRetention(t *testing.T) { + mgr := &fakeManager{ + status: microvmmanager.Status{Generations: []microvmmanager.Generation{{SessionID: "s1", Ref: "env@2", Generation: 2}}}, + deleteResult: microvmmanager.DeleteResult{WorktreePath: "/work/s1", WorktreeRetained: true}, + } + var out strings.Builder + err := Run(t.Context(), FrontendMecated, []string{"delete", "--session", "s1", "--ref", "env@2", "--generation", "2", "--yes", "--output", "json"}, strings.NewReader(""), &out, mgr, false) + if err != nil { + t.Fatal(err) + } + var got struct { + Selector struct { + SessionID string `json:"session_id"` + } `json:"selector"` + Result struct { + WorktreePath string `json:"worktree_path"` + } `json:"result"` + DirtyRetained bool `json:"dirty_retained"` + } + if err := json.Unmarshal([]byte(out.String()), &got); err != nil { + t.Fatal(err) + } + if got.Selector.SessionID != "s1" || got.Result.WorktreePath != "/work/s1" || !got.DirtyRetained { + t.Fatalf("delete JSON = %+v", got) + } +} + +func TestDoctorJSONReportsSuccessAndFailure(t *testing.T) { + for _, tc := range []struct { + err error + success bool + }{{nil, true}, {errors.New("KVM unavailable"), false}} { + mgr := &fakeManager{doctorReport: "PASS kvm\n", doctorErr: tc.err} + var out strings.Builder + err := Run(t.Context(), FrontendMecated, []string{"doctor", "--output", "json"}, strings.NewReader(""), &out, mgr, false) + if (err == nil) != tc.success { + t.Fatalf("err = %v, success=%t", err, tc.success) + } + var got struct { + Success bool `json:"success"` + Report string `json:"report"` + } + if json.Unmarshal([]byte(out.String()), &got) != nil || got.Success != tc.success || got.Report != "PASS kvm\n" { + t.Fatalf("doctor JSON = %q", out.String()) + } + } +} + +func TestStableJSONAndTextOutput(t *testing.T) { + status := microvmmanager.Status{ + Configured: true, + Running: true, + Socket: "/run/microvmd.sock", + GuestEgress: "deny-all", + Generations: []microvmmanager.Generation{{ + SessionID: "s1", EnvironmentID: "env1", Ref: "env1@7", Generation: 7, + WorktreePath: "/work/s1", State: "ready", Health: "healthy", Error: "", + }}, + Continuation: "next-token", + } + selector := []string{"--session", "s1", "--ref", "env1@7", "--generation", "7", "--yes"} + tests := []struct { + name string + args []string + manager *fakeManager + wantOutput string + wantErr bool + }{ + { + name: "status json", args: []string{"status", "--output", "json"}, manager: &fakeManager{status: status}, + wantOutput: "{\"profile\":\"microvm-local\",\"configured\":true,\"running\":true,\"socket\":\"/run/microvmd.sock\",\"guest_egress\":\"deny-all\",\"generations\":[{\"session_id\":\"s1\",\"environment_id\":\"env1\",\"ref\":\"env1@7\",\"generation\":7,\"worktree_path\":\"/work/s1\",\"state\":\"ready\",\"health\":\"healthy\",\"error\":\"\"}],\"continuation\":\"next-token\"}\n", + }, + { + name: "status text", args: []string{"status"}, manager: &fakeManager{status: status}, + wantOutput: "profile: microvm-local\nconfigured: true\ndaemon running: true\nsocket: /run/microvmd.sock\nguest egress: deny-all\nlogical worktree: session=s1 ref=env1@7 repository-generation=7 health=healthy state=ready worktree=/work/s1\nnext page: mecated microvm status --continuation \"next-token\"\nstatus is read-only; it never installs, starts, stops, or deletes microVM state\n", + }, + { + name: "doctor success json", args: []string{"doctor", "--output", "json"}, manager: &fakeManager{doctorReport: "PASS kvm\n"}, + wantOutput: "{\"success\":true,\"report\":\"PASS kvm\\n\",\"error\":\"\"}\n", + }, + { + name: "doctor success text", args: []string{"doctor"}, manager: &fakeManager{doctorReport: "PASS kvm\n"}, + wantOutput: "PASS kvm\n", + }, + { + name: "doctor failure json", args: []string{"doctor", "--output", "json"}, manager: &fakeManager{doctorReport: "FAIL kvm\n", doctorErr: errors.New("KVM unavailable")}, + wantOutput: "{\"success\":false,\"report\":\"FAIL kvm\\n\",\"error\":\"KVM unavailable\"}\n", wantErr: true, + }, + { + name: "doctor failure text", args: []string{"doctor"}, manager: &fakeManager{doctorReport: "FAIL kvm\n", doctorErr: errors.New("KVM unavailable")}, + wantOutput: "FAIL kvm\n", wantErr: true, + }, + { + name: "clean delete json", args: append([]string{"delete"}, append(selector, "--output", "json")...), manager: deleteManager(false), + wantOutput: "{\"selector\":{\"session_id\":\"s1\",\"ref\":\"env1@7\",\"generation\":7},\"result\":{\"worktree_path\":\"/work/s1\",\"worktree_removed\":true,\"repository_vm_retained\":true},\"dirty_retained\":false}\n", + }, + { + name: "dirty delete json", args: append([]string{"delete"}, append(selector, "--output", "json")...), manager: deleteManager(true), + wantOutput: "{\"selector\":{\"session_id\":\"s1\",\"ref\":\"env1@7\",\"generation\":7},\"result\":{\"worktree_path\":\"/work/s1\",\"worktree_removed\":false,\"repository_vm_retained\":true},\"dirty_retained\":true}\n", + }, + { + name: "clean delete text", args: append([]string{"delete"}, selector...), manager: deleteManager(false), + wantOutput: "Permanently delete logical worktree session=s1 ref=env1@7 repository-generation=7. The shared repository VM is not deleted. Closing a host client normally only detaches and preserves this worktree. Dirty worktrees are retained.\nlogical attachment and clean worktree removed; repository VM retained: /work/s1\n", + }, + { + name: "dirty delete text", args: append([]string{"delete"}, selector...), manager: deleteManager(true), + wantOutput: "Permanently delete logical worktree session=s1 ref=env1@7 repository-generation=7. The shared repository VM is not deleted. Closing a host client normally only detaches and preserves this worktree. Dirty worktrees are retained.\nlogical attachment deleted; dirty worktree retained: /work/s1\n", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var out strings.Builder + err := Run(t.Context(), FrontendMecated, tc.args, strings.NewReader(""), &out, tc.manager, false) + if (err != nil) != tc.wantErr { + t.Fatalf("error = %v, wantErr %t", err, tc.wantErr) + } + if got := out.String(); got != tc.wantOutput { + t.Fatalf("output mismatch\n got: %q\nwant: %q", got, tc.wantOutput) + } + }) + } +} + +func deleteManager(retained bool) *fakeManager { + return &fakeManager{ + status: microvmmanager.Status{Generations: []microvmmanager.Generation{{SessionID: "s1", Ref: "env1@7", Generation: 7}}}, + deleteResult: microvmmanager.DeleteResult{WorktreePath: "/work/s1", WorktreeRetained: retained}, + } +} + +func TestDeletePaginationFailuresDoNotPromptOrDelete(t *testing.T) { + tests := []struct { + name string + status func(microvmmanager.StatusRequest) microvmmanager.Status + wantCalls int + wantError string + }{ + { + name: "repeated continuation", + status: func(microvmmanager.StatusRequest) microvmmanager.Status { + return microvmmanager.Status{Continuation: "same-token"} + }, + wantCalls: 2, wantError: "repeated continuation", + }, + { + name: "page cap", + status: func(request microvmmanager.StatusRequest) microvmmanager.Status { + return microvmmanager.Status{Continuation: fmt.Sprintf("page-%04d", requestCount(request.Continuation)+1)} + }, + wantCalls: 1024, wantError: "exceeded 1024 status pages", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mgr := &fakeManager{statusFunc: tc.status} + var out strings.Builder + err := Run(t.Context(), FrontendMecated, []string{"delete", "--session", "s1", "--ref", "env@1", "--generation", "1"}, strings.NewReader("yes\n"), &out, mgr, true) + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("error = %v, want %q", err, tc.wantError) + } + if mgr.statusCalls != tc.wantCalls || mgr.deleteCalls != 0 || strings.Contains(out.String(), "[y/N]") { + t.Fatalf("status calls=%d delete calls=%d output=%q", mgr.statusCalls, mgr.deleteCalls, out.String()) + } + }) + } +} + +func requestCount(continuation string) int { + if continuation == "" { + return 0 + } + var count int + _, _ = fmt.Sscanf(continuation, "page-%04d", &count) + return count +} + +func TestDeleteNoninteractiveNeverReadsConsent(t *testing.T) { + mgr := deleteManager(false) + var out strings.Builder + err := Run(t.Context(), FrontendMecated, []string{"delete", "--session", "s1", "--ref", "env1@7", "--generation", "7"}, panicReader{}, &out, mgr, false) + if err == nil || err.Error() != "microVM delete requires confirmation (--yes for noninteractive use)" { + t.Fatalf("error = %v", err) + } + if mgr.deleteCalls != 0 { + t.Fatalf("delete calls = %d", mgr.deleteCalls) + } +} + +type panicReader struct{} + +func (panicReader) Read([]byte) (int, error) { panic("noninteractive confirmation read stdin") } + +func TestHelpAllIsAccepted(t *testing.T) { + var out strings.Builder + if err := Run(t.Context(), FrontendMecated, []string{"--help-all"}, strings.NewReader(""), &out, &fakeManager{}, false); err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "Usage: mecated microvm doctor|status|delete", + "mecatui microvm doctor|status|delete", + "doctor read-only", + "status read-only", + "mecatui --default-placement microvm-local", + `mecated serve --headless --default-placement microvm-local`, + } { + if !strings.Contains(out.String(), want) { + t.Fatalf("help omitted %q: %q", want, out.String()) + } + } +} + +func TestSubcommandHelpIsSideEffectFreeAndShowsEssentialFlags(t *testing.T) { + for _, tc := range []struct { + command string + want []string + }{ + {command: "doctor", want: []string{"never downloads", "--output text|json"}}, + {command: "status", want: []string{"Read-only owner-scoped", "--page-size 1..64", "--continuation TOKEN", "--output text|json"}}, + {command: "delete", want: []string{"Copy all three selector values", "--session ID", "--ref REF", "--generation N", "--yes", "--output text|json"}}, + } { + mgr := &fakeManager{} + var out strings.Builder + if err := Run(t.Context(), FrontendMecated, []string{tc.command, "--help"}, strings.NewReader(""), &out, mgr, false); err != nil { + t.Fatalf("%s --help: %v", tc.command, err) + } + for _, want := range tc.want { + if !strings.Contains(out.String(), want) { + t.Fatalf("%s help omitted %q:\n%s", tc.command, want, out.String()) + } + } + if mgr.doctorCalls != 0 || mgr.statusCalls != 0 || mgr.deleteCalls != 0 { + t.Fatalf("%s help called manager: doctor=%d status=%d delete=%d", tc.command, mgr.doctorCalls, mgr.statusCalls, mgr.deleteCalls) + } + } + err := Run(t.Context(), FrontendMecated, []string{"wat"}, strings.NewReader(""), &strings.Builder{}, &fakeManager{}, false) + if err == nil || !strings.Contains(err.Error(), "mecated microvm --help") || !strings.Contains(err.Error(), "mecatui microvm --help") { + t.Fatalf("unknown command guidance = %v", err) + } +} + +func TestRejectsUnknownOutputFormat(t *testing.T) { + err := Run(t.Context(), FrontendMecated, []string{"status", "--output", "yaml"}, strings.NewReader(""), &strings.Builder{}, &fakeManager{}, false) + if err == nil || !strings.Contains(err.Error(), "unknown output format") { + t.Fatalf("error = %v", err) + } +} + +type fakeManager struct { + status microvmmanager.Status + statusFunc func(microvmmanager.StatusRequest) microvmmanager.Status + statusRequest microvmmanager.StatusRequest + statusCalls int + doctorReport string + doctorErr error + doctorCalls int + deleteResult microvmmanager.DeleteResult + deleteCalls int +} + +func (f *fakeManager) Doctor(context.Context) (string, error) { + f.doctorCalls++ + return f.doctorReport, f.doctorErr +} +func (f *fakeManager) Status(_ context.Context, requests ...microvmmanager.StatusRequest) (microvmmanager.Status, error) { + f.statusCalls++ + if len(requests) > 0 { + f.statusRequest = requests[0] + if f.statusFunc != nil { + return f.statusFunc(requests[0]), nil + } + } + return f.status, nil +} +func (f *fakeManager) Delete(context.Context, microvmmanager.DeleteRequest) (microvmmanager.DeleteResult, error) { + f.deleteCalls++ + return f.deleteResult, nil +} diff --git a/sdk/typescript/etc/mecatl-sdk-node.api.md b/sdk/typescript/etc/mecatl-sdk-node.api.md index ef2ff79a0..828712e2e 100644 --- a/sdk/typescript/etc/mecatl-sdk-node.api.md +++ b/sdk/typescript/etc/mecatl-sdk-node.api.md @@ -701,7 +701,7 @@ export interface McpInventory { export const MECATL_ATTACH_FILTERED_KINDS: readonly ["approval", "compaction.archive", "network.attempt", "request.manifest", "user_prompt"]; // @public -export const MECATL_ERROR_CODES: readonly ["activity_gap", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "migration_backend", "migration_conflict", "migration_unsupported", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unimplemented", "watch_lagging", "watch_unsupported"]; +export const MECATL_ERROR_CODES: readonly ["activity_gap", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "environment_logical_root_unavailable", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "migration_backend", "migration_conflict", "migration_unsupported", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unimplemented", "watch_lagging", "watch_unsupported"]; // @public export const MECATL_EVENT_KINDS: readonly ["approval", "authorization.required", "authorization.resolved", "compaction", "compaction.archive", "hook", "message.delta", "model.retry", "network.attempt", "no_progress", "parallel.branch", "parallel.end", "parallel.start", "permission.ask", "permission.retract", "provider.route", "reasoning.delta", "recover_notice", "request.manifest", "result", "schedule.failed", "schedule.fired", "schedule.skipped", "session.init", "session.title", "steer", "steer.outcome", "subagent.end", "subagent.start", "subagent.tool", "team.end", "team.findings", "team.member", "team.start", "team.tasks", "tool.call", "tool.progress", "tool.result", "turn.end", "turn.start", "user_prompt"]; diff --git a/sdk/typescript/etc/mecatl-sdk.api.md b/sdk/typescript/etc/mecatl-sdk.api.md index 1d8f8493d..0d72f0236 100644 --- a/sdk/typescript/etc/mecatl-sdk.api.md +++ b/sdk/typescript/etc/mecatl-sdk.api.md @@ -667,7 +667,7 @@ export interface McpInventory { export const MECATL_ATTACH_FILTERED_KINDS: readonly ["approval", "compaction.archive", "network.attempt", "request.manifest", "user_prompt"]; // @public -export const MECATL_ERROR_CODES: readonly ["activity_gap", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "migration_backend", "migration_conflict", "migration_unsupported", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unimplemented", "watch_lagging", "watch_unsupported"]; +export const MECATL_ERROR_CODES: readonly ["activity_gap", "attempt_live_claim_conflict", "attempt_terminal_conflict", "attempt_version_conflict", "child_not_found", "cleanup_backend", "cleanup_plan_stale", "cleanup_unsupported", "client_mcp_unreachable", "client_mcp_unsupported", "conflict", "cursor_expired", "cursor_malformed", "draining", "dream_apply_failed", "dream_capacity", "dream_conflict", "dream_deadline", "dream_generate_failed", "dream_in_progress", "dream_not_found", "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", "environment_logical_root_unavailable", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", "internal", "invalid_argument", "learning_unavailable", "management_unauthorized", "migration_backend", "migration_conflict", "migration_unsupported", "no_active_run", "no_event_log", "no_mcp_provider", "no_schedule_store", "not_awaiting_plan", "not_found", "placement_binding_invalid", "placement_changed", "placement_selector_invalid", "placement_selector_not_found", "placement_selector_stale", "placement_unavailable", "proposal_conflict", "reflection_cancelled", "reflection_deadline", "reflection_failed", "reflection_queue_full", "request_too_large", "resource_exhausted", "schedule_disabled", "schedule_exhausted", "schedule_not_found", "schedule_not_leader", "schedule_unsupported", "scheduler_not_running", "session_delete_unsupported", "session_leased_elsewhere", "session_metadata_cursor_restart", "session_metadata_paging_unsupported", "session_not_found", "stale_run_control", "storage_health_backend", "team_not_found", "team_not_running", "team_running", "teams_disabled", "too_many_session_engines", "too_many_teams", "unimplemented", "watch_lagging", "watch_unsupported"]; // @public export const MECATL_EVENT_KINDS: readonly ["approval", "authorization.required", "authorization.resolved", "compaction", "compaction.archive", "hook", "message.delta", "model.retry", "network.attempt", "no_progress", "parallel.branch", "parallel.end", "parallel.start", "permission.ask", "permission.retract", "provider.route", "reasoning.delta", "recover_notice", "request.manifest", "result", "schedule.failed", "schedule.fired", "schedule.skipped", "session.init", "session.title", "steer", "steer.outcome", "subagent.end", "subagent.start", "subagent.tool", "team.end", "team.findings", "team.member", "team.start", "team.tasks", "tool.call", "tool.progress", "tool.result", "turn.end", "turn.start", "user_prompt"]; diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index a80e07f96..2e2c046e5 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -27,6 +27,7 @@ export const MECATL_ERROR_CODES = [ "dream_request_failed", "dream_terminal_conflict", "dream_unavailable", + "environment_logical_root_unavailable", "failed_precondition", "failed_step_retry_ineligible", "fire_now_overlap", diff --git a/user-docs/building/deployment/index.md b/user-docs/building/deployment/index.md index 6ae42bcfd..fa0da9847 100644 --- a/user-docs/building/deployment/index.md +++ b/user-docs/building/deployment/index.md @@ -26,6 +26,8 @@ pods, and mecatui provides an interactive terminal skin over an embedded server. - [**Single-shot CI with mecatequi**](mecatequi.md) — adopt the `mecatequi-reusable.yml` reusable workflow, understand the split-privilege job graph (agent job holds no write token; publish job applies the patch as data), and read `stop-reason` + `non-empty-diff` from action outputs correctly. +- [**Local microVM environments**](microvm-environments.md) — package the opt-in local execution backend, understand source/worktree/guest paths, artifact and egress trust, and operate doctor/reconciliation without implying remote or scheduled placement support. + - [**Drive via gRPC / HTTP**](grpc-http.md) — the wire protocol: the gRPC `Converse` stream, the HTTP/SSE surface, the `ResumeApproval` frame for permission verdicts, and the `POST /v1/sessions/{id}/approve` endpoint. - [**mecatui container image (brood-box)**](mecatui.md) — the `ghcr.io/stacklok/mecatl/mecatui` container image: built + signed on release, carries a brood-box agent manifest, importable via `bbox agents import`. diff --git a/user-docs/building/deployment/microvm-environments.md b/user-docs/building/deployment/microvm-environments.md new file mode 100644 index 000000000..13589696a --- /dev/null +++ b/user-docs/building/deployment/microvm-environments.md @@ -0,0 +1,99 @@ +--- +sidebar_position: 7 +title: Local microVM environments +--- + +# Local microVM environments + +Use `microvm-local` on Linux amd64 with KVM to run model-controlled filesystem and +shell tools in a local microVM. Providers, MCP, hooks, credentials, memory, and the +mecatl server remain on the host. It is for one local operator and Git repository; +Linux arm64, macOS, remote placement, schedules, multi-user sharing, and non-Git +sources are not available. + +Install a published, release-stamped `mecatui` or `mecated` binary and verify it before +use. Source builds are for the separate repository-developer workflow, not ordinary +local installation. Git, Python 3, read-write `/dev/kvm`, and unprivileged user +namespaces are required. The full [operator runbook](https://github.com/stacklok/mecatl/blob/main/docs/usage/microvm-environments.md) +includes verification and developer workflow instructions. + +## mecatui-only journey + +From the Git repository: + +```sh +mecatui microvm doctor +mecatui --default-placement microvm-local +``` + +`microvm doctor` is read-only and can report an unconfigured backend on a fresh home. +Selecting the profile prepares the verified local runtime and creates the session. Bare +`mecatui` remains host-local. + +Inspect and resume with the same profile: + +```sh +mecatui microvm status +mecatui --default-placement microvm-local --resume SESSION_ID +``` + +A microVM session remains in the microVM profile for its lifetime; it is never moved to +host execution. + +## Headless mecated-only journey + +In one terminal: + +```sh +mecated microvm doctor +mecated serve --headless --mock --default-placement microvm-local +``` + +In another terminal, create a session on that deployment default, then +prompt and inspect it: + +```sh +curl -sS -X POST http://127.0.0.1:8081/v1/sessions \ + -H 'Content-Type: application/json' \ + -d '{}' + +SESSION_ID=copy-from-create-response +curl -sS -N -X POST "http://127.0.0.1:8081/v1/sessions/${SESSION_ID}/prompt" \ + -H 'Content-Type: application/json' \ + -d '{"text":"Inspect this repository and report its test command."}' +mecated microvm status +``` + +`--headless` is for unattended API use, so configure main-agent permissions for +autonomous work. `--mock` is only for offline smoke testing; configure a real provider +for model work. Session details expose bounded placement metadata only; host paths and +exact environment refs remain private. + +## Guest egress and isolation + +Guest IPv4 egress is permissive by default. External IPv6 is unrouted and unsupported. +The host operator can restrict guest egress only at the local composition root: + +```sh +mecatui --default-placement microvm-local --microvm-guest-egress=deny-all + +mecated serve --headless \ + --microvm-guest-egress=allowlist \ + --microvm-guest-allow=api.example.com:443/tcp +``` + +Allowlist rules use `HOST:PORT/tcp|udp`; at least one valid rule is required. Invalid +rules or unavailable enforcement stop readiness. HTTP/gRPC requests and project +configuration cannot set or weaken this policy. Host provider, MCP, web, hook, artifact, +and telemetry traffic is outside guest egress policy. + +Sessions and isolated children receive separate Git worktrees in a repository VM. +Worktrees separate Git state and routing, not mutually hostile processes in the same VM. +Different repositories receive different VMs. A daemon restart may leave records, +rootfs, and worktrees intact while live hosted dependencies are unavailable; affected +sessions report that condition. Mecatl never falls back to host filesystem or shell +execution and never creates an empty replacement environment. + +Use read-only `microvm doctor` and `microvm status` to inspect the local environment. +`microvm delete --session ID --ref REF --generation N` removes one logical attachment, +retains dirty worktrees, and does not delete the shared repository VM. diff --git a/user-docs/building/what-you-get/core-tools.md b/user-docs/building/what-you-get/core-tools.md index dd6140e20..334239b10 100644 --- a/user-docs/building/what-you-get/core-tools.md +++ b/user-docs/building/what-you-get/core-tools.md @@ -84,6 +84,20 @@ A `Bash` call with `background: true` returns immediately with a `bashcmd-` A session can be created with `profile: "no-fs"` — for a workspace that has no real filesystem to speak of, or a deployment that never wants one in reach. It removes `Read`/`ListDir`/`Write`/`Edit`/`Copy`/`Move`/`Remove`/`Grep`/`Glob`/`Bash`/`BashStatus`/`Parallel`/`SkillDraft` from the catalog entirely; `WebFetch`, `WebSearch`, the memory tools, and any MCP tools stay. A `Subagent`/`Team` child spawned from a no-fs session gets the equivalent file-less catalog, not the default one. This is a session-creation choice the client makes, not something the model can flip mid-session — see [Engine & session model](engine-and-session.md) for how a session is created. +### Execution placement providers + +`microvm-local` is a trusted deployment default selected only by a local composition root +with `--default-placement microvm-local`. Ordinary session creation then uses that default; +clients cannot submit a placement alias, workspace path, or exact environment ref. Public +session data contains bounded `PlacementMetadata` only. `profile: "no-fs"` remains the one +client-selected attenuation. The daemon owns image, resource, egress, lifecycle, and +attestation policy, and unavailable placement fails without host fallback. + +Guest IPv4 is permissive by default, with external IPv6 unrouted. Local composition-root +flags may tighten it: `--microvm-guest-egress=deny-all`, or `allowlist` with repeatable +`--microvm-guest-allow=HOST:PORT/tcp|udp`. HTTP/gRPC requests and project config cannot +select or weaken placement or egress policy. + --- ## Memory tools From 30141076276b5db18ac6266c34886ebb3bd464e9 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Fri, 11 Sep 2026 09:03:34 +0300 Subject: [PATCH 2/3] fix: simplify microVM execution experience Co-Authored-By: mecatl --- .github/scripts/microvm-ci-release_test.sh | 76 ++-- Taskfile.yml | 13 +- cmd/mecated/authwiring_test.go | 10 +- cmd/mecated/command.go | 2 +- cmd/mecated/main.go | 111 +---- cmd/mecated/microvm_command.go | 2 +- cmd/mecated/microvm_command_test.go | 62 ++- cmd/mecatui/command.go | 9 - cmd/mecatui/config.go | 67 +-- cmd/mecatui/config_test.go | 165 +++++++ cmd/mecatui/helpmeta.go | 19 +- cmd/mecatui/main.go | 282 +++++++----- cmd/mecatui/main_test.go | 99 +++++ cmd/mecatui/microvm_command.go | 147 ------- cmd/mecatui/microvm_command_test.go | 209 --------- .../microvm_development_microvm_dev_test.go | 20 +- cmd/mecatui/microvm_egress_test.go | 49 --- cmd/mecatui/ui/model.go | 24 ++ cmd/mecatui/ui/startup_progress_test.go | 53 +++ cmd/mecatui/ui/update.go | 10 + cmd/mecatui/ui/view.go | 3 + .../microvm-execution-environments.md | 50 ++- docs/architecture.md | 35 +- docs/architecture/microvm-environments.md | 87 ++-- docs/configuration-reference.md | 14 + docs/design/IMPLEMENTATION-NOTES.md | 75 ++-- docs/usage/configuration.md | 6 +- docs/usage/microvm-environments.md | 157 ++++--- environment/microvm/control/service_test.go | 4 +- .../microvm/repository_composition_test.go | 3 +- environment/microvm/runtime_test.go | 3 +- internal/adapter/acp/agent_test.go | 6 +- internal/adapter/microvm/client.go | 15 +- .../adapter/microvm/client_profile_test.go | 41 ++ internal/adapter/microvmmanager/bootstrap.go | 1 - .../microvmmanager/default_operations.go | 20 +- .../microvmmanager/default_operations_test.go | 129 +++++- .../development_release_microvm_dev.go | 8 +- .../development_release_microvm_dev_test.go | 5 + internal/adapter/microvmmanager/egress.go | 38 +- .../microvmmanager/ensure_ready_test.go | 30 +- internal/adapter/microvmmanager/manager.go | 302 ++++++++----- .../adapter/microvmmanager/manager_test.go | 227 ++++++++-- .../adapter/microvmmanager/userns_linux.go | 2 +- .../adapter/permconfig/ast_foundation_test.go | 3 + internal/adapter/permconfig/execution_test.go | 56 +++ internal/adapter/permconfig/permconfig.go | 1 + internal/adapter/permconfig/resolve.go | 28 ++ internal/adapter/permconfig/schema.go | 84 ++++ .../adapter/permconfig/strictkeys_test.go | 3 + internal/adapter/server/acp_placement.go | 35 +- .../server/canonical_seams_repair_test.go | 10 +- .../server/environment_resolver_test.go | 6 +- internal/adapter/server/learning.go | 12 +- internal/adapter/server/mcp_authorization.go | 10 +- .../mcp_authorization_lifecycle_test.go | 8 +- internal/adapter/server/placement.go | 88 ++-- .../server/placement_composition_test.go | 334 ++++++++++++++ .../adapter/server/placement_discovery.go | 8 +- .../adapter/server/placement_repair_test.go | 33 +- .../adapter/server/placement_successor.go | 6 +- .../server/placement_testfixture_test.go | 7 +- .../server/sdk_typescript_scenario11_test.go | 3 +- internal/adapter/server/service.go | 167 ++++--- .../apicheck/microvm_module_contract_test.go | 2 +- internal/app/attempt_recovery.go | 5 +- internal/app/build.go | 23 +- internal/app/environment_test.go | 6 +- internal/app/execution.go | 170 ++++++++ internal/app/execution_test.go | 137 ++++++ internal/app/microvm_lifecycle_e2e_test.go | 408 ++++++++++++++++-- internal/app/placement.go | 9 +- internal/app/placement_test.go | 30 +- internal/configgen/build.go | 16 + internal/configgen/configgen_test.go | 4 + internal/configgen/settings.skeleton.yaml | 16 + internal/microvmcmd/command.go | 188 ++++---- internal/microvmcmd/command_test.go | 353 ++++++--------- .../deployment/microvm-environments.md | 114 +++-- user-docs/building/what-you-get/core-tools.md | 13 +- 80 files changed, 3460 insertions(+), 1626 deletions(-) delete mode 100644 cmd/mecatui/microvm_command.go delete mode 100644 cmd/mecatui/microvm_command_test.go delete mode 100644 cmd/mecatui/microvm_egress_test.go create mode 100644 cmd/mecatui/ui/startup_progress_test.go create mode 100644 internal/adapter/permconfig/execution_test.go create mode 100644 internal/adapter/server/placement_composition_test.go create mode 100644 internal/app/execution.go create mode 100644 internal/app/execution_test.go diff --git a/.github/scripts/microvm-ci-release_test.sh b/.github/scripts/microvm-ci-release_test.sh index 9e419dc03..72d1183b9 100644 --- a/.github/scripts/microvm-ci-release_test.sh +++ b/.github/scripts/microvm-ci-release_test.sh @@ -164,49 +164,53 @@ printf '%s\n' "$publish_host_section" | grep -F 'main.microVMReleaseVersion' >/d printf '%s\n' "$publish_host_section" | grep -F 'main.microVMReleaseStampRequired=release' >/dev/null require '[ "${PLATFORM}" != linux-amd64 ] && [ "$(basename "${asset}")" = install-microvm-release.sh ]' "$release" -# Functional host-stamp/entrypoint contract: both real binaries must consume the -# same defaults through their package-specific version symbol, execute the shared -# offline command before server/TUI construction, and derive the same XDG socket. +# Functional host-stamp/entrypoint contract: both real binaries consume the same +# defaults through their package-specific version symbol. Mecated exercises its offline +# administration entrypoint; mecatui proves the stamp reaches embedded app composition. host_scratch="$repo_root/.scratch/microvm-host-entrypoint-test" rm -rf "$host_scratch" mkdir -p "$host_scratch/home" "$host_scratch/config" "$host_scratch/data" "$host_scratch/state" "$host_scratch/runtime" host_version=v0.0.0-host-contract host_platform=linux-amd64 host_defaults=$(printf '{"%s":{"version":"%s","platform":"%s","url":"https://example.invalid/microvm.tar.gz","sha256":"%064d","policy_revision":"contract","certificate_identity":"https://example.invalid/release.yml","oidc_issuer":"https://token.actions.githubusercontent.com"}}' "$host_platform" "$host_version" "$host_platform" 0 | base64 -w0) -for binary in mecated mecatui; do - case "$binary" in - mecated) version_symbol=main.microVMReleaseVersion; wrong_version_symbol=main.version ;; - mecatui) version_symbol=main.version; wrong_version_symbol=main.microVMReleaseVersion ;; - esac - go build -trimpath -buildvcs=false \ - -ldflags="-X ${version_symbol}=${host_version} -X main.microVMReleaseDefaultsB64=${host_defaults} -X main.microVMReleaseStampRequired=release" \ - -o "$host_scratch/$binary" "./cmd/$binary" - HOME="$host_scratch/home" XDG_CONFIG_HOME="$host_scratch/config" XDG_DATA_HOME="$host_scratch/data" \ - XDG_STATE_HOME="$host_scratch/state" XDG_RUNTIME_DIR="$host_scratch/runtime" \ - "$host_scratch/$binary" microvm status --output json >"$host_scratch/$binary.json" - - go build -trimpath -buildvcs=false \ - -ldflags="-X ${wrong_version_symbol}=${host_version} -X main.microVMReleaseDefaultsB64=${host_defaults} -X main.microVMReleaseStampRequired=release" \ - -o "$host_scratch/$binary-wrong-version" "./cmd/$binary" - if HOME="$host_scratch/home" XDG_CONFIG_HOME="$host_scratch/config" XDG_DATA_HOME="$host_scratch/data" \ - XDG_STATE_HOME="$host_scratch/state" XDG_RUNTIME_DIR="$host_scratch/runtime" \ - "$host_scratch/$binary-wrong-version" microvm status --output json >/dev/null 2>&1; then - echo "$binary accepted defaults stamped through the other frontend's version symbol" >&2 - exit 1 - fi +# mecated owns lifecycle administration; exercise its real status entrypoint. +binary=mecated +version_symbol=main.microVMReleaseVersion +wrong_version_symbol=main.version +go build -trimpath -buildvcs=false \ + -ldflags="-X ${version_symbol}=${host_version} -X main.microVMReleaseDefaultsB64=${host_defaults} -X main.microVMReleaseStampRequired=release" \ + -o "$host_scratch/$binary" "./cmd/$binary" +HOME="$host_scratch/home" XDG_CONFIG_HOME="$host_scratch/config" XDG_DATA_HOME="$host_scratch/data" \ + XDG_STATE_HOME="$host_scratch/state" XDG_RUNTIME_DIR="$host_scratch/runtime" \ + "$host_scratch/$binary" microvm status --output json >"$host_scratch/$binary.json" - go build -trimpath -buildvcs=false \ - -ldflags="-X ${version_symbol}=${host_version} -X main.missingMicroVMReleaseDefaults=${host_defaults} -X main.microVMReleaseStampRequired=release" \ - -o "$host_scratch/$binary-missing-defaults" "./cmd/$binary" - if HOME="$host_scratch/home" XDG_CONFIG_HOME="$host_scratch/config" XDG_DATA_HOME="$host_scratch/data" \ - XDG_STATE_HOME="$host_scratch/state" XDG_RUNTIME_DIR="$host_scratch/runtime" \ - "$host_scratch/$binary-missing-defaults" microvm status --output json >/dev/null 2>&1; then - echo "$binary accepted a missing defaults stamp symbol" >&2 - exit 1 - fi -done -cmp "$host_scratch/mecated.json" "$host_scratch/mecatui.json" -expected_status=$(printf '{"profile":"microvm-local","configured":false,"running":false,"socket":"/tmp/mv-%s/microvmd.sock","guest_egress":"","generations":[],"continuation":""}\n' "$(id -u)") +go build -trimpath -buildvcs=false \ + -ldflags="-X ${wrong_version_symbol}=${host_version} -X main.microVMReleaseDefaultsB64=${host_defaults} -X main.microVMReleaseStampRequired=release" \ + -o "$host_scratch/$binary-wrong-version" "./cmd/$binary" +if HOME="$host_scratch/home" XDG_CONFIG_HOME="$host_scratch/config" XDG_DATA_HOME="$host_scratch/data" \ + XDG_STATE_HOME="$host_scratch/state" XDG_RUNTIME_DIR="$host_scratch/runtime" \ + "$host_scratch/$binary-wrong-version" microvm status --output json >/dev/null 2>&1; then + echo "$binary accepted defaults stamped through the wrong version symbol" >&2 + exit 1 +fi + +go build -trimpath -buildvcs=false \ + -ldflags="-X ${version_symbol}=${host_version} -X main.missingMicroVMReleaseDefaults=${host_defaults} -X main.microVMReleaseStampRequired=release" \ + -o "$host_scratch/$binary-missing-defaults" "./cmd/$binary" +if HOME="$host_scratch/home" XDG_CONFIG_HOME="$host_scratch/config" XDG_DATA_HOME="$host_scratch/data" \ + XDG_STATE_HOME="$host_scratch/state" XDG_RUNTIME_DIR="$host_scratch/runtime" \ + "$host_scratch/$binary-missing-defaults" microvm status --output json >/dev/null 2>&1; then + echo "$binary accepted a missing defaults stamp symbol" >&2 + exit 1 +fi + +# mecatui has no administration frontend. Verify its package-specific linker +# symbols feed embedded app composition without starting KVM or an interactive TUI. +go test -run '^TestMecatuiReleaseStampFeedsEmbeddedReadinessDefaults$' \ + -ldflags="-X main.version=${host_version} -X main.microVMReleaseDefaultsB64=${host_defaults} -X main.microVMReleaseStampRequired=release" \ + ./cmd/mecatui + +expected_status=$(printf '{"backend":"microvm-local","configured":false,"running":false,"state":"unconfigured","error":"","remediation":"Select microvm-local in operator settings; run '\''mecated microvm doctor'\'' first.","socket":"/tmp/mv-%s/microvmd.sock","guest_egress":"","generations":[],"continuation":""}\n' "$(id -u)") test "$(cat "$host_scratch/mecated.json")" = "$expected_status" scratch="$repo_root/.scratch/microvm-release-test" diff --git a/Taskfile.yml b/Taskfile.yml index a3f50a06d..491af72f6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -299,7 +299,7 @@ tasks: - GOTMPDIR="$(pwd)/.scratch/go-build" go build -trimpath -o "{{.MECATL_MICROVM_E2E_MECATUI_BIN}}" -ldflags "-X main.version={{.MECATL_MICROVM_E2E_VERSION}} -X main.microVMReleaseDefaultsB64={{.MECATL_MICROVM_E2E_DEFAULTS_B64}}" ./cmd/mecatui e2e:microvm: - desc: Run the required Linux amd64 KVM microVM MVP journey (network required; never part of task test). + desc: Run the deterministic mock-provider Linux amd64 KVM microVM MVP journey (artifact network required; no live LLM; never part of task test). cmds: - cmd: | set -eu @@ -325,8 +325,10 @@ tasks: (cd environment/microvm && GOWORK=off go build -o "${MICROVMD_BIN}" ./cmd/mecatl-microvmd) # The focused live test crosses ordinary first-use readiness and the # production UDS through the server-owned microVM default. It then drives - # a normal CreateSession agent loop whose Bash call reads and changes the + # one normal CreateSession agent loop whose Bash call reads and changes the # isolated guest worktree, and proves that change cannot reach the source. + # Multi-session sharing, restart, and delegation routing are deterministic + # composition tests in the ordinary offline gate below. DEFAULT_LIVE_TEST_NAMES='TestMicroVMDefaultPlacementDailyHarnessJourney' run_live_tests() { LIVE_TESTS=$1 @@ -376,8 +378,11 @@ tasks: rm -rf "${TEST_RUN_ROOT}" done fi - # Keep the current composition contract beside the live journey. - go test -count=1 -run '^TestMicroVMDefaultPlacementUsesNormalCreateSessionAndExactReattach$' ./internal/app + # Keep the deterministic production-composition contract beside the live + # journey: exact reattach plus lazy/no-fs allocation, multi-session + # isolation, harness restart continuity, fail-closed daemon-state loss, + # and direct-write versus isolated-child routing. + go test -count=1 -run '^Test(MicroVMDefaultPlacementUsesNormalCreateSessionAndExactReattach|MicroVMOperatorJourneyIsLazyIsolatedAndRestartExact)$' ./internal/app test: desc: Run unit tests with the race detector (root + engine + opt-in modules + standalone hygiene proofs) diff --git a/cmd/mecated/authwiring_test.go b/cmd/mecated/authwiring_test.go index 6a4a2918a..99b469663 100644 --- a/cmd/mecated/authwiring_test.go +++ b/cmd/mecated/authwiring_test.go @@ -43,12 +43,18 @@ type offlinePlacementProvider struct{} func (offlinePlacementProvider) Bind(context.Context, server.PlacementBindRequest) (server.PlacementBinding, error) { ref := session.EnvironmentRef{Kind: session.EnvKindMem, ID: "offline", Revision: "v1"} - return server.PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/ws"), memledger.New(), nil)}, nil + return server.PlacementBinding{ + Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/ws"), memledger.New(), nil), + CompositionRoot: "/ws", + }, nil } func (offlinePlacementProvider) Reattach(context.Context, server.PlacementReattachRequest) (server.PlacementBinding, error) { ref := session.EnvironmentRef{Kind: session.EnvKindMem, ID: "offline", Revision: "v1"} - return server.PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/ws"), memledger.New(), nil)}, nil + return server.PlacementBinding{ + Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/ws"), memledger.New(), nil), + CompositionRoot: "/ws", + }, nil } // newOfflineService builds a *server.Service over the offline reference adapters diff --git a/cmd/mecated/command.go b/cmd/mecated/command.go index 4f69ac1a0..7e6557ad1 100644 --- a/cmd/mecated/command.go +++ b/cmd/mecated/command.go @@ -245,7 +245,7 @@ func writeTopLevelHelp(out io.Writer) { _, _ = fmt.Fprintf(out, " serve start the network daemon (gRPC + HTTP/SSE)\n") _, _ = fmt.Fprintf(out, " acp serve the Agent Client Protocol over stdio\n") _, _ = fmt.Fprintf(out, " mcp login SERVER [flags] authorize an operator-configured OAuth MCP server\n") - _, _ = fmt.Fprintf(out, " microvm doctor|status|delete administer local microVM state (offline)\n") + _, _ = fmt.Fprintf(out, " microvm doctor|status|delete inspect and administer local microVM attachments\n") _, _ = fmt.Fprintf(out, " import import a Codex or Claude Code session, skills, and workspace files\n") _, _ = fmt.Fprintf(out, " config init write/print the operator settings.yaml skeleton (--print, --force)\n") _, _ = fmt.Fprintf(out, " config validate validate operator settings.yaml without writing (--file, --learning-patch)\n") diff --git a/cmd/mecated/main.go b/cmd/mecated/main.go index cace4a695..bdac6d823 100644 --- a/cmd/mecated/main.go +++ b/cmd/mecated/main.go @@ -45,14 +45,11 @@ import ( mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" "github.com/stacklok/mecatl/engine/agent" "github.com/stacklok/mecatl/engine/port" - "github.com/stacklok/mecatl/engine/session" - "github.com/stacklok/mecatl/engine/tool" "github.com/stacklok/mecatl/internal/adapter/agents" "github.com/stacklok/mecatl/internal/adapter/daemonconfig" "github.com/stacklok/mecatl/internal/adapter/mcpauthority" "github.com/stacklok/mecatl/internal/adapter/mcpbroker" "github.com/stacklok/mecatl/internal/adapter/mcpperf" - microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" "github.com/stacklok/mecatl/internal/adapter/microvmmanager" "github.com/stacklok/mecatl/internal/adapter/server" "github.com/stacklok/mecatl/internal/adapter/skills" @@ -961,9 +958,6 @@ func run(mode commandMode, remaining []string) error { } composition := appConfig(cfg, sink, mainScoped, roleScoper, obs.metrics, diag) - if err := configureDefaultPlacement(&composition, cfg, diag); err != nil { - return err - } built, err := app.Build(ctx, composition) if err != nil { return err @@ -1081,17 +1075,6 @@ const mecatedServerImplementation = "mecated" // appConfig constructs the command root's declarative app.Config. app.Build loads the // injected provider credential after resolving operator definitions. -func configureDefaultPlacement(cfg *app.Config, command config, diag port.Diagnostics) error { - if command.defaultPlacement != microvmmanager.Alias { - return nil - } - var egress []microvmmanager.GuestEgressSelection - if command.cliExplicit["microvm-guest-egress"] || command.cliExplicit["microvm-guest-allow"] { - egress = append(egress, command.microVMGuestEgress) - } - return configureMicroVMLocalProfile(cfg, diag, command.microVMDevRelease, command.microVMDevAcknowledge, egress...) -} - func microVMReadyRequestWithDevelopment(descriptor string, acknowledge bool, egress ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { request, enabled, err := microVMDevelopmentReadyRequest(descriptor, acknowledge, buildinfo.BuildID, microVMReleaseStampRequired != "" || microVMReleaseDefaultsB64 != "", egress...) if enabled || err != nil { @@ -1104,77 +1087,16 @@ func microVMReadyRequest(egress ...microvmmanager.GuestEgressSelection) (microvm return microvmmanager.ReadyRequestFromDefaults(microVMReleaseDefaultsB64, microVMReleaseVersion, egress...) } -func withMicroVMReadinessDiagnostics(ctx context.Context, diag port.Diagnostics) context.Context { - return microvmmanager.WithReadinessObserver(ctx, func(stage microvmmanager.ReadinessStage, message string) { - if diag != nil { - diag.Log(ctx, port.LevelInfo, "microvm-local readiness", "stage", string(stage), "status", message) - } - }) -} - -type microVMReadyManager interface { - EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) -} - -func configureMicroVMLocalProfile(cfg *app.Config, diag port.Diagnostics, developmentDescriptor string, developmentAcknowledge bool, egress ...microvmmanager.GuestEgressSelection) error { - if developmentDescriptor != "" || developmentAcknowledge { - if _, err := microVMReadyRequestWithDevelopment(developmentDescriptor, developmentAcknowledge, egress...); err != nil { - return err - } - if diag != nil { - diag.Log(context.Background(), port.LevelInfo, "microvm-local development release enabled", "support", "unsupported", "trust", "local-artifacts") - } - } - manager, endpoint, err := microvmmanager.DefaultLocal() - if err != nil { - return err - } - return configureMicroVMLocalProfileWithManager(cfg, diag, manager, endpoint, developmentDescriptor, developmentAcknowledge, egress...) -} - -func configureMicroVMLocalProfileWithManager(cfg *app.Config, diag port.Diagnostics, manager microVMReadyManager, endpoint, developmentDescriptor string, developmentAcknowledge bool, egress ...microvmmanager.GuestEgressSelection) error { - if manager == nil || endpoint == "" { - return errors.New("microvm-local readiness manager and endpoint are required") - } - if len(egress) > 1 { - return errors.New("multiple guest egress selections supplied") - } - readiness := func(ctx context.Context) error { - request, requestErr := microVMReadyRequestWithDevelopment(developmentDescriptor, developmentAcknowledge, egress...) - if requestErr != nil { - return requestErr - } - ctx = withMicroVMReadinessDiagnostics(ctx, diag) - readyEndpoint, readyErr := manager.EnsureReady(ctx, request) - if readyErr != nil { - return readyErr - } - if readyEndpoint != endpoint { - return fmt.Errorf("microvm-local readiness returned unexpected endpoint %q", readyEndpoint) - } - return nil - } - const scope server.PlacementScope = "deployment" - provider, err := microvmadapter.NewPlacementProvider(endpoint, cfg.Workspace, microvmmanager.Alias, scope, readiness) - if err != nil { - return err - } - cfg.PlacementProvider = provider - cfg.PlacementScope = scope - if cfg.EnvironmentForkers == nil { - cfg.EnvironmentForkers = make(map[session.EnvironmentKind]tool.EnvironmentForker) - } - if cfg.EnvironmentMergers == nil { - cfg.EnvironmentMergers = make(map[session.EnvironmentKind]tool.EnvironmentMerger) - } - cfg.EnvironmentForkers[session.EnvironmentKind("microvm")] = provider - cfg.EnvironmentMergers[session.EnvironmentKind("microvm")] = provider - return nil -} - func appConfig(cfg config, sink port.EventSink, recorder port.ToolCallRecorder, roleScoper func(string) (port.EventSink, port.ToolCallRecorder), metrics *telemetry.Metrics, diag port.Diagnostics) app.Config { out := app.Config{ - Workspace: cfg.workspace, + Workspace: cfg.workspace, + DefaultPlacement: cfg.defaultPlacement, + DefaultPlacementSet: cfg.cliExplicit["default-placement"], + MicroVMGuestEgress: cfg.microVMGuestEgress, + MicroVMGuestEgressSet: cfg.cliExplicit["microvm-guest-egress"] || cfg.cliExplicit["microvm-guest-allow"], + MicroVMReadyRequest: func(selection microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + return microVMReadyRequestWithDevelopment(cfg.microVMDevRelease, cfg.microVMDevAcknowledge, selection) + }, ClientMCPOnCreate: clientMCPOnCreateForListeners(cfg), Model: cfg.model, DefaultProvider: cfg.defaultProvider, @@ -1698,8 +1620,8 @@ func parseFlagsModeOut(mode commandMode, argv []string, out io.Writer) (*flag.Fl fs.StringVar(&cfg.authorityEvaluator, "authority-evaluator", "local", "authority evaluator: local (default), noop, or cedar; cedar requires --cedar-authority-policy") fs.StringVar(&cfg.cedarAuthorityPolicy, "cedar-authority-policy", "", "path to the static operator Cedar authority policy; read once at startup when --authority-evaluator=cedar") fs.BoolVar(&cfg.noBash, "no-bash", false, "disable the Bash tool entirely (shell-less mode); overrides --shell") - fs.StringVar(&cfg.defaultPlacement, "default-placement", "", "deployment default placement provider (microvm-local or empty for host-local)") - fs.Var(cfg.microVMGuestEgress.ModeValue(), "microvm-guest-egress", "microvm-local guest egress: permissive, deny-all, or allowlist; omitted preserves an existing local policy (first use: permissive); host-operator policy, never client-selectable") + fs.StringVar(&cfg.defaultPlacement, "default-placement", "", "override execution.default_placement for this mecated serve: host-local or microvm-local (omitted: operator settings, then host-local)") + fs.Var(cfg.microVMGuestEgress.ModeValue(), "microvm-guest-egress", "override execution.microvm.guest_egress.mode: permissive, deny-all, or allowlist; omitted: operator settings, then permissive") fs.Var(cfg.microVMGuestEgress.AllowValue(), "microvm-guest-allow", "allow one microvm-local guest destination as HOST:PORT/tcp|udp (repeatable; requires --microvm-guest-egress=allowlist; hostnames only, no IP literals or wildcards)") registerMicroVMDevelopmentFlags(fs, &cfg.microVMDevRelease, &cfg.microVMDevAcknowledge) @@ -2036,15 +1958,12 @@ func recordExplicitFlags(fs *flag.FlagSet, cfg *config) { } func validateMicroVMPlacementFlags(mode commandMode, cfg config) error { - if cfg.defaultPlacement != "" && cfg.defaultPlacement != microvmmanager.Alias { - return fmt.Errorf("unsupported --default-placement %q (supported: %s)", cfg.defaultPlacement, microvmmanager.Alias) - } - egressSet := cfg.cliExplicit["microvm-guest-egress"] || cfg.cliExplicit["microvm-guest-allow"] - if egressSet && cfg.defaultPlacement != microvmmanager.Alias { - return errors.New("microVM guest egress flags require --default-placement microvm-local") + if cfg.defaultPlacement != "" && cfg.defaultPlacement != app.PlacementHostLocal && cfg.defaultPlacement != app.PlacementMicroVMLocal { + return fmt.Errorf("unsupported --default-placement %q (supported: %s|%s)", cfg.defaultPlacement, app.PlacementHostLocal, app.PlacementMicroVMLocal) } - if mode == modeACP && egressSet { - return errors.New("microVM guest egress flags are supported only by 'mecated serve'") + executionSet := cfg.cliExplicit["default-placement"] || cfg.cliExplicit["microvm-guest-egress"] || cfg.cliExplicit["microvm-guest-allow"] + if mode == modeACP && executionSet { + return errors.New("execution placement flags are supported only by 'mecated serve'") } return validateMicroVMDevelopmentFlags(mode, cfg) } diff --git a/cmd/mecated/microvm_command.go b/cmd/mecated/microvm_command.go index 9df429304..171dd78eb 100644 --- a/cmd/mecated/microvm_command.go +++ b/cmd/mecated/microvm_command.go @@ -32,5 +32,5 @@ func runLocalMicroVMCommand(args []string, stdin io.Reader, stdout io.Writer) er interactive = term.IsTerminal(int(inFile.Fd())) && term.IsTerminal(int(outFile.Fd())) } } - return microvmcmd.Run(context.Background(), microvmcmd.FrontendMecated, args, stdin, stdout, manager, interactive) + return microvmcmd.Run(context.Background(), args, stdin, stdout, manager, interactive) } diff --git a/cmd/mecated/microvm_command_test.go b/cmd/mecated/microvm_command_test.go index 7ea5d1ad4..a26070bc8 100644 --- a/cmd/mecated/microvm_command_test.go +++ b/cmd/mecated/microvm_command_test.go @@ -6,15 +6,15 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" - "fmt" "io" "net" + "os" "path/filepath" "runtime" "strings" "testing" - "github.com/stacklok/mecatl/engine/port" + "github.com/stacklok/mecatl/engine/session" "github.com/stacklok/mecatl/internal/adapter/microvmmanager" "github.com/stacklok/mecatl/internal/app" "github.com/stacklok/mecatl/internal/microvmcmd" @@ -61,6 +61,9 @@ func TestMecatedMicroVMHelpAllMatchesAdvertisedTopLevelGuidance(t *testing.T) { t.Fatalf("help-all omitted %q: %q", want, out.String()) } } + if strings.Contains(out.String(), "mecatui microvm") { + t.Fatalf("help advertised noncanonical frontend: %q", out.String()) + } if manager.doctorCalls != 0 || manager.statusCalls != 0 || manager.deleteCalls != 0 { t.Fatalf("help called manager: doctor=%d status=%d delete=%d", manager.doctorCalls, manager.statusCalls, manager.deleteCalls) } @@ -97,15 +100,6 @@ func TestMecatedReleaseStampFeedsReadinessDefaults(t *testing.T) { } } -func TestMecatedReadinessStagesUseCommandDiagnostics(t *testing.T) { - diag := &readinessDiagnostics{} - ctx := withMicroVMReadinessDiagnostics(t.Context(), diag) - microvmmanager.ReportReadinessStage(ctx, microvmmanager.StageDownload) - if len(diag.records) != 1 || diag.records[0] != "microvm-local readiness stage download status Downloading microVM components (up to about 2 GiB)" { - t.Fatalf("diagnostics = %q", diag.records) - } -} - func TestConfigureMicroVMLocalProfileWiresHeadlessReadinessDiagnosticsAndEgress(t *testing.T) { originalVersion, originalDefaults := microVMReleaseVersion, microVMReleaseDefaultsB64 t.Cleanup(func() { microVMReleaseVersion, microVMReleaseDefaultsB64 = originalVersion, originalDefaults }) @@ -120,11 +114,16 @@ func TestConfigureMicroVMLocalProfileWiresHeadlessReadinessDiagnosticsAndEgress( } microVMReleaseDefaultsB64 = base64.StdEncoding.EncodeToString(defaults) manager := &mecatedMicroVMFake{endpoint: "unix:///run/private/microvmd.sock"} - diag := &readinessDiagnostics{} - var cfg app.Config - cfg.Workspace = t.TempDir() selection := microvmmanager.GuestEgressSelection{Mode: microvmmanager.GuestEgressDenyAll} - if err := configureMicroVMLocalProfileWithManager(&cfg, diag, manager, manager.endpoint, "", false, selection); err != nil { + cfg, err := app.ConfigureExecution(app.Config{ + Workspace: t.TempDir(), DefaultPlacement: app.PlacementMicroVMLocal, DefaultPlacementSet: true, + MicroVMGuestEgress: selection, MicroVMGuestEgressSet: true, + MicroVMReadyRequest: func(selection microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + return microVMReadyRequestWithDevelopment("", false, selection) + }, + MicroVMManagerFactory: func() (app.MicroVMReadyManager, string, error) { return manager, manager.endpoint, nil }, + }) + if err != nil { t.Fatal(err) } if cfg.PlacementProvider == nil || cfg.PlacementScope != "deployment" { @@ -149,7 +148,15 @@ func TestMecatedCLICompositionValidatesMicroVMDefaultAfterReadiness(t *testing.T } microVMReleaseDefaultsB64 = base64.StdEncoding.EncodeToString(defaults) - listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "microvmd.sock")) + socketDir, err := os.MkdirTemp("/tmp", "mecatl-mv-cli-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = os.Remove(filepath.Join(socketDir, "microvmd.sock")) + _ = os.Remove(socketDir) + }) + listener, err := net.Listen("unix", filepath.Join(socketDir, "microvmd.sock")) if err != nil { t.Fatal(err) } @@ -164,14 +171,17 @@ func TestMecatedCLICompositionValidatesMicroVMDefaultAfterReadiness(t *testing.T } composition := appConfig(parsed, nil, nil, nil, nil, nil) manager := &mecatedMicroVMFake{endpoint: "unix://" + listener.Addr().String()} - if err := configureMicroVMLocalProfileWithManager(&composition, nil, manager, manager.endpoint, "", false); err != nil { - t.Fatalf("configure CLI-selected placement: %v", err) + composition.MicroVMManagerFactory = func() (app.MicroVMReadyManager, string, error) { + return manager, manager.endpoint, nil } built, err := app.Build(t.Context(), composition) if err != nil { t.Fatalf("CLI composition did not become service-ready: %v", err) } defer built.Close() + if _, err := built.Service.CreateSession(t.Context(), session.ModeDefault, session.Limits{}); err != nil { + t.Fatalf("create CLI-selected MicroVM session: %v", err) + } if err := <-requestSeen; err != nil { t.Fatal(err) } @@ -245,27 +255,13 @@ func writeCLICompositionFrame(w io.Writer, value any) error { func TestMecatedHelpDiscoversMicroVMCommands(t *testing.T) { var out strings.Builder writeTopLevelHelp(&out) - for _, want := range []string{"microvm doctor|status|delete", "local microVM state", "offline"} { + for _, want := range []string{"microvm doctor|status|delete", "local microVM attachments"} { if !strings.Contains(out.String(), want) { t.Fatalf("help omitted %q:\n%s", want, out.String()) } } } -type readinessDiagnostics struct { - records []string -} - -func (d *readinessDiagnostics) Log(_ context.Context, _ port.Level, message string, args ...any) { - parts := []string{message} - for _, arg := range args { - parts = append(parts, fmt.Sprint(arg)) - } - d.records = append(d.records, strings.Join(parts, " ")) -} - -func (d *readinessDiagnostics) With(...any) port.Diagnostics { return d } - type mecatedMicroVMFake struct { status microvmmanager.Status statusCalls int diff --git a/cmd/mecatui/command.go b/cmd/mecatui/command.go index 32baf55ca..06a206cfd 100644 --- a/cmd/mecatui/command.go +++ b/cmd/mecatui/command.go @@ -42,7 +42,6 @@ const ( // distinct from modeLogin so an address can never accidentally invoke the // ToolHive browser flow. modeRemoteLogin transportMode = "remote-login" - modeMicroVM transportMode = "microvm" ) // topLevelCommand is the single catalog for named entry points. Resolution, @@ -96,14 +95,6 @@ var topLevelCommands = []topLevelCommand{ purpose: "run the ToolHive LLM gateway OIDC browser flow (no session)", resolve: resolveLLMCommand, }, - { - name: "microvm", - synopsis: "microvm doctor|status|delete", - purpose: "inspect or delete owner-scoped microVM state on this host", - resolve: func(args []string) invocationResolution { - return invocationResolution{mode: modeMicroVM, remaining: args} - }, - }, } // invocationResolution is the pure classification of a complete CLI invocation: diff --git a/cmd/mecatui/config.go b/cmd/mecatui/config.go index d70fc15e3..a2cedcda0 100644 --- a/cmd/mecatui/config.go +++ b/cmd/mecatui/config.go @@ -1,7 +1,6 @@ package main import ( - "context" "errors" "flag" "fmt" @@ -9,13 +8,12 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "time" "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" - microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" - "github.com/stacklok/mecatl/internal/adapter/microvmmanager" "github.com/stacklok/mecatl/internal/app" "github.com/stacklok/mecatl/internal/cliconfig" ) @@ -47,26 +45,20 @@ type config struct { // workspaceExplicit distinguishes an operator-supplied --workspace from the // empty default. Remote connect rejects the former without resolving it. workspaceExplicit bool - defaultPlacement string - microVMGuestEgress microvmmanager.GuestEgressSelection - microVMEgressSet bool microVMDevRelease string microVMDevAcknowledge bool - // microVMEndpoint and microVMReadiness configure the embedded server's - // trusted default placement provider. - microVMEndpoint string - microVMReadiness func(context.Context) error - microVMProvider *microvmadapter.Client - mode string - theme string - themeDir string - authToken string - anonymous bool - useTLS bool - tlsExplicit bool - tlsCA string - insecure bool - listThemes bool + microVMProgress chan string + microVMSelected *atomic.Bool + mode string + theme string + themeDir string + authToken string + anonymous bool + useTLS bool + tlsExplicit bool + tlsCA string + insecure bool + listThemes bool // debug enables mecatui's client-side diagnostic surfaces. An explicit // --debug value outranks MECATUI_DEBUG and the legacy per-surface aliases. debug bool @@ -372,15 +364,11 @@ func parseFlags(args []string) (config, error) { // local/connect, the command word / ADDRESS — resolveInvocation strips them). func parseTransportFlags(mode transportMode, out io.Writer, args []string, browseSessions ...bool) (*flag.FlagSet, config, error) { var cfg config - cfg.microVMGuestEgress = microvmmanager.NewGuestEgressSelection() cfg.transportMode = mode cfg.browseSessions = len(browseSessions) > 0 && browseSessions[0] fs := flag.NewFlagSet("mecatui", flag.ContinueOnError) fs.SetOutput(out) fs.StringVar(&cfg.workspace, "workspace", "", "embedded server only: absolute deployment workspace root (default: cwd); not accepted by connect") - fs.StringVar(&cfg.defaultPlacement, "default-placement", "", "embedded deployment default placement (microvm-local: managed local microVM; empty: host-local)") - fs.Var(cfg.microVMGuestEgress.ModeValue(), "microvm-guest-egress", "microvm-local guest egress: permissive, deny-all, or allowlist; omitted preserves an existing local policy (first use: permissive); local embedded mode only") - fs.Var(cfg.microVMGuestEgress.AllowValue(), "microvm-guest-allow", "allow one microvm-local guest destination as HOST:PORT/tcp|udp (repeatable; requires allowlist; hostnames only, no IP literals or wildcards)") registerMicroVMDevelopmentFlags(fs, &cfg.microVMDevRelease, &cfg.microVMDevAcknowledge) fs.StringVar(&cfg.mode, "mode", "default", "permission mode: default | plan | accept-edits") fs.Func("debug-mcp", "debug sessions only: select one already-configured server-global streaming-HTTP MCP server by name (repeatable)", func(value string) error { @@ -512,7 +500,7 @@ func parseTransportFlags(mode transportMode, out io.Writer, args []string, brows if err := finalizeParsedConfig(fs, &cfg); err != nil { return fs, config{}, err } - if err := validateMicroVMFlags(mode, cfg); err != nil { + if err := validateMicroVMDevelopmentFlags(cfg); err != nil { return fs, config{}, err } if err := validateResumeSelectors(cfg); err != nil { @@ -554,24 +542,13 @@ func handleTransportHelp(fs *flag.FlagSet, cfg config, mode transportMode) (*fla return nil, true, flag.ErrHelp } -func validateMicroVMFlags(mode transportMode, cfg config) error { - if cfg.microVMEgressSet && cfg.defaultPlacement != microvmmanager.Alias { - return errors.New("microVM guest egress flags require --default-placement microvm-local in local embedded mode") - } - if err := cfg.microVMGuestEgress.Validate(); err != nil { - return err - } - if cfg.microVMDevRelease == "" && !cfg.microVMDevAcknowledge { - return nil - } - if mode != modeLocal || cfg.defaultPlacement != microvmmanager.Alias { - return errors.New("microVM development release flags require local embedded mode with --default-placement microvm-local") - } - var egress []microvmmanager.GuestEgressSelection - if cfg.microVMEgressSet { - egress = append(egress, cfg.microVMGuestEgress) - } - _, _, err := microVMDevelopmentReadyRequest(cfg.microVMDevRelease, cfg.microVMDevAcknowledge, version, microVMReleaseStampRequired != "" || microVMReleaseDefaultsB64 != "", egress...) +func validateMicroVMDevelopmentFlags(cfg config) error { + _, _, err := microVMDevelopmentReadyRequest( + cfg.microVMDevRelease, + cfg.microVMDevAcknowledge, + version, + microVMReleaseStampRequired != "" || microVMReleaseDefaultsB64 != "", + ) return err } @@ -696,8 +673,6 @@ func recordExplicitFlag(f *flag.Flag, cfg *config) { cfg.terminalTitleFlagSet = true case "workspace": cfg.workspaceExplicit = true - case "microvm-guest-egress", "microvm-guest-allow": - cfg.microVMEgressSet = true } markRetentionCLIFlag(&cfg.retentionCLISet, f.Name) } diff --git a/cmd/mecatui/config_test.go b/cmd/mecatui/config_test.go index c35eb1ccc..9127a1dfa 100644 --- a/cmd/mecatui/config_test.go +++ b/cmd/mecatui/config_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "context" + "errors" "fmt" "io" "os" @@ -14,6 +16,8 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/ui" "github.com/stacklok/mecatl/engine/port" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" "github.com/stacklok/mecatl/internal/app" "github.com/stacklok/mecatl/internal/buildinfo" "github.com/stacklok/mecatl/internal/testutil/codextest" @@ -56,6 +60,167 @@ func TestContextWindowOverrideFlagWiring(t *testing.T) { // TestEmbeddedConfigEnablesAgentDefs asserts the embedded server enables conventional // agent-definition discovery (consistent with EnableTeams/EnableParallel; inert until a // .md exists under a conventional dir). +type embeddedExecutionReadyManager struct { + calls int + err error +} + +func (m *embeddedExecutionReadyManager) EnsureReady(ctx context.Context, _ microvmmanager.ReadyRequest) (string, error) { + m.calls++ + microvmmanager.ReportReadinessStage(ctx, microvmmanager.StageDownload) + if m.err != nil { + return "", m.err + } + return "unix:///run/test-microvmd.sock", nil +} + +func TestBareEmbeddedConfigResolvesOperatorExecutionSettings(t *testing.T) { + settings := filepath.Join(t.TempDir(), "settings.yaml") + if err := os.WriteFile(settings, []byte("execution: {default_placement: microvm-local}\n"), 0o600); err != nil { + t.Fatal(err) + } + progress := make(chan string, 16) + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + cfg := embeddedConfig(config{workspace: t.TempDir(), model: "m", mock: true, microVMProgress: progress}, port.NopDiagnostics{}) + cfg.PermissionConfigs = []string{settings} + cfg.MicroVMReadyRequest = func(microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + return microvmmanager.ReadyRequest{}, nil + } + factoryCalled := false + manager := &embeddedExecutionReadyManager{err: errors.New("private readiness failure at /home/operator/secret")} + cfg.MicroVMManagerFactory = func() (app.MicroVMReadyManager, string, error) { + factoryCalled = true + return manager, "unix:///run/test-microvmd.sock", nil + } + built, err := app.Build(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer built.Close() + if !factoryCalled || manager.calls != 0 { + t.Fatalf("bare embedded construction did not select MicroVM lazily: factory=%v readiness=%d", factoryCalled, manager.calls) + } + if _, err := built.Service.CreateSession(t.Context(), session.ModeDefault, session.Limits{}); err == nil { + t.Fatal("bare embedded session silently fell back after unavailable microvmd") + } else { + if manager.calls != 1 { + t.Fatalf("bare embedded session failed before selected MicroVM readiness: calls=%d err=%v", manager.calls, err) + } + if !strings.Contains(err.Error(), "placement unavailable") { + t.Fatalf("embedded server did not preserve placement error redaction: %v", err) + } + } + var updates []string + for len(progress) > 0 { + updates = append(updates, <-progress) + } + joined := strings.Join(updates, "\n") + for _, want := range []string{"Guest IPv4 egress is permissive", "Downloading microVM components"} { + if !strings.Contains(joined, want) { + t.Fatalf("embedded progress %q omitted %q", updates, want) + } + } + for _, want := range []string{"mecated microvm doctor", filepath.Join(stateHome, "mecatl", "mecatui.log")} { + if !strings.Contains(microVMFailureHint(config{}), want) { + t.Fatalf("embedded failure hint omitted %q", want) + } + } +} + +func TestBareEmbeddedHostLocalOmissionDoesNoMicroVMWork(t *testing.T) { + cfg := embeddedConfig(config{workspace: t.TempDir(), model: "m", mock: true}, port.NopDiagnostics{}) + factoryCalled := false + cfg.MicroVMManagerFactory = func() (app.MicroVMReadyManager, string, error) { + factoryCalled = true + return nil, "", fmt.Errorf("must not initialize microVM manager") + } + built, err := app.Build(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer built.Close() + sess, err := built.Service.CreateSession(t.Context(), session.ModeDefault, session.Limits{}) + if err != nil { + t.Fatal(err) + } + if factoryCalled || sess.EnvironmentRef.Kind != session.EnvKindLocal { + t.Fatalf("bare host-local omission touched MicroVM or selected wrong placement: factory=%v ref=%+v", factoryCalled, sess.EnvironmentRef) + } +} + +func TestConnectDoesNotResolveOperatorExecutionSettings(t *testing.T) { + configHome := t.TempDir() + stateHome := t.TempDir() + dataHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_STATE_HOME", stateHome) + t.Setenv("XDG_DATA_HOME", dataHome) + if err := os.MkdirAll(filepath.Join(configHome, "mecatl"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configHome, "mecatl", "settings.yaml"), []byte("execution: {default_placement: invalid}\n"), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := parseRunConfig(invocationResolution{ + mode: modeConnect, address: "127.0.0.1:8080", remaining: []string{"--anonymous"}, + }) + if err != nil { + t.Fatal(err) + } + if _, _, cleanup, err := resolveTransport(t.Context(), cfg); err != nil { + t.Fatal(err) + } else { + cleanup() + } + for _, path := range []string{ + filepath.Join(stateHome, "mecatl", "microvm"), + filepath.Join(dataHome, "mecatl", "microvm"), + } { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("remote connect touched local MicroVM manager state %q: %v", path, err) + } + } +} + +func TestEmbeddedConfigCarriesMicroVMReadinessForOperatorSettings(t *testing.T) { + cfg := embeddedConfig(config{workspace: "/ws", model: "m", mock: true}, port.NopDiagnostics{}) + if cfg.DefaultPlacementSet || cfg.MicroVMGuestEgressSet { + t.Fatal("bare mecatui must not synthesize command-line execution overrides") + } + if cfg.MicroVMReadyRequest == nil { + t.Fatal("bare mecatui did not provide embedded MicroVM release readiness") + } +} + +func TestMecatuiReleaseStampFeedsEmbeddedReadinessDefaults(t *testing.T) { + if microVMReleaseStampRequired != "release" { + t.Skip("release linker-contract assertion") + } + if version != "v0.0.0-host-contract" { + t.Fatalf("version linker stamp = %q", version) + } + cfg := embeddedConfig(config{workspace: "/ws", model: "m", mock: true}, port.NopDiagnostics{}) + request, err := cfg.MicroVMReadyRequest(microvmmanager.NewGuestEgressSelection()) + if err != nil { + t.Fatal(err) + } + if request.Release.URL != "https://example.invalid/microvm.tar.gz" || request.Policy.PolicyRevision != "contract" { + t.Fatalf("embedded readiness did not consume mecatui linker defaults: %+v", request) + } +} + +func TestMecatuiRemovedMicroVMSurface(t *testing.T) { + for _, args := range [][]string{{"--default-placement=microvm-local"}, {"--microvm-guest-egress=deny-all"}} { + if _, _, err := parseTransportFlags(modeLocal, io.Discard, args); err == nil { + t.Fatalf("removed mecatui flag %q was accepted", args[0]) + } + } + if got := resolveInvocation([]string{"mecatui", "microvm", "status"}); got.err == nil { + t.Fatal("removed mecatui microvm administration command was accepted") + } +} + func TestEmbeddedConfigEnablesAgentDefs(t *testing.T) { ac := embeddedConfig(config{workspace: "/ws", model: "m", mock: true}, port.NopDiagnostics{}) if ac.ServerImplementation != mecatuiServerImplementation { diff --git a/cmd/mecatui/helpmeta.go b/cmd/mecatui/helpmeta.go index 4de1cbbba..186ea2cf8 100644 --- a/cmd/mecatui/helpmeta.go +++ b/cmd/mecatui/helpmeta.go @@ -67,17 +67,14 @@ var flagApplicabilityByFlag = map[string]flagApplicability{ "insecure": {group: groupTransport, common: false, local: false, connect: true}, // ── Session (shared) ────────────────────────────────────────────────── - "workspace": {group: groupSession, common: true, local: true, connect: true}, - "default-placement": {group: groupSession, common: true, local: true, connect: false}, - "microvm-guest-egress": {group: groupSession, common: false, local: true, connect: false}, - "microvm-guest-allow": {group: groupSession, common: false, local: true, connect: false}, - "mode": {group: groupSession, common: true, local: true, connect: true}, - "debug-mcp": {group: groupSession, common: true, local: true, connect: true}, - "resume": {group: groupSession, common: true, local: true, connect: true}, - "resume-latest": {group: groupSession, common: true, local: true, connect: true}, - "prompt": {group: groupSession, common: true, local: true, connect: true}, - "p": {group: groupSession, common: false, local: true, connect: true}, // alias for --prompt - "prompt-file": {group: groupSession, common: true, local: true, connect: true}, + "workspace": {group: groupSession, common: true, local: true, connect: true}, + "mode": {group: groupSession, common: true, local: true, connect: true}, + "debug-mcp": {group: groupSession, common: true, local: true, connect: true}, + "resume": {group: groupSession, common: true, local: true, connect: true}, + "resume-latest": {group: groupSession, common: true, local: true, connect: true}, + "prompt": {group: groupSession, common: true, local: true, connect: true}, + "p": {group: groupSession, common: false, local: true, connect: true}, // alias for --prompt + "prompt-file": {group: groupSession, common: true, local: true, connect: true}, // ── UI (shared) ─────────────────────────────────────────────────────── "theme": {group: groupUI, common: true, local: true, connect: true}, diff --git a/cmd/mecatui/main.go b/cmd/mecatui/main.go index dab62b69f..7dd3135c7 100644 --- a/cmd/mecatui/main.go +++ b/cmd/mecatui/main.go @@ -30,6 +30,7 @@ import ( "path/filepath" "runtime" "strings" + "sync/atomic" "syscall" "time" @@ -43,13 +44,10 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/theme" "github.com/stacklok/mecatl/cmd/mecatui/ui" "github.com/stacklok/mecatl/engine/port" - "github.com/stacklok/mecatl/engine/session" - "github.com/stacklok/mecatl/engine/tool" "github.com/stacklok/mecatl/internal/adapter/clientauth" "github.com/stacklok/mecatl/internal/adapter/credentialstore" "github.com/stacklok/mecatl/internal/adapter/mcpauthority" "github.com/stacklok/mecatl/internal/adapter/microvmmanager" - "github.com/stacklok/mecatl/internal/adapter/server" "github.com/stacklok/mecatl/internal/adapter/slogdiag" "github.com/stacklok/mecatl/internal/adapter/xdgconfig" "github.com/stacklok/mecatl/internal/app" @@ -159,6 +157,8 @@ type runOptions struct { connectResumeSessionID string connectTransport restartTransport recoveryOnly bool + beforeEmbeddedStart func(app.Config) error + runProgram func(context.Context, ui.Model) (tea.Model, error) } //nolint:gocyclo // composition root sequences transport, safe auth recovery, and Bubble Tea lifecycle. @@ -179,32 +179,14 @@ func runWithOptions(argv []string, options runOptions) error { if handled, err := runSpecialMode(res); handled { return err } - if res.mode == modeMicroVM { - if err := validateMicroVMReleaseStamp(); err != nil { - return err - } - manager, err := defaultMicroVMManager() - if err != nil { - return err - } - interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) - return runMicroVMCommand(context.Background(), res.remaining, os.Stdin, os.Stdout, manager, interactive) - } cfg, err := parseRunConfig(res) if err != nil { return err } - if cfg.transportMode == modeLocal && cfg.defaultPlacement == microvmmanager.Alias { - endpoint, endpointErr := defaultMicroVMEndpoint() - if endpointErr != nil { - return endpointErr - } - if err := configureSelectedEnvironmentReadiness(&cfg, endpoint, func() (microVMReadyManager, error) { - return defaultMicroVMManager() - }); err != nil { - return err - } + if cfg.transportMode == modeLocal { + cfg.microVMProgress = make(chan string, 16) + cfg.microVMSelected = &atomic.Bool{} } if cfg.providerKeys.AuthFileWarning != "" { fmt.Fprintln(os.Stderr, "mecatui: WARNING: "+wrapAuthFileWarning(cfg.providerKeys.AuthFileWarning)) @@ -248,7 +230,7 @@ func runWithOptions(argv []string, options runOptions) error { // Resolve where to connect before any potentially mutating readiness work. Resume // placement is authoritative and must reject a mismatched profile first. - target, dial, transCleanup, err := resolveTransport(ctx, cfg) + target, dial, transCleanup, err := resolveTransportWithHook(ctx, cfg, options.beforeEmbeddedStart) if err != nil { if reason, ok := client.AuthFailure(err, cfg.authToken != ""); ok { recoveryTarget := target @@ -278,7 +260,7 @@ func runWithOptions(argv []string, options runOptions) error { resumeCfg.resumeID = options.connectResumeSessionID resumeCfg.resumeLatest = false } - resume, uiWorkspace, err := startupResumeAndReadiness(ctx, cl, resumeCfg, os.Stderr) + resume, uiWorkspace, err := startupResumeConfig(ctx, cl, resumeCfg) if options.connectResumeSessionID != "" { // An auth-recovery candidate is opportunistic. Only a verified terminal // boundary is adopted; every other state and every ambiguous verification @@ -350,11 +332,18 @@ func runWithOptions(argv []string, options runOptions) error { Theme: th, ThemeAutoDetect: themeAutoDetect, StatusSource: statusSource, - LocalSessionContext: cl, - Server: target, - ConnectionMode: connectionMode, - ClientBuild: buildinfo.BuildID, - Embedded: cfg.transportMode == modeLocal, + StartupProgress: cfg.microVMProgress, + StartupFailureHint: func() string { + if cfg.microVMSelected != nil && cfg.microVMSelected.Load() { + return microVMFailureHint(cfg) + } + return "" + }, + LocalSessionContext: cl, + Server: target, + ConnectionMode: connectionMode, + ClientBuild: buildinfo.BuildID, + Embedded: cfg.transportMode == modeLocal, // Model is best-effort display only. For an EXTERNAL --server it reflects // the locally-configured --model flag and may NOT match the server's actual // model (the server owns provider config); for an embedded server it is @@ -404,8 +393,14 @@ func runWithOptions(argv []string, options runOptions) error { return err } - prog := tea.NewProgram(ui.New(deps), tea.WithContext(ctx)) - finalModel, runErr := prog.Run() + model := ui.New(deps) + var finalModel tea.Model + var runErr error + if options.runProgram != nil { + finalModel, runErr = options.runProgram(ctx, model) + } else { + finalModel, runErr = tea.NewProgram(model, tea.WithContext(ctx)).Run() + } interrupted := ctx.Err() != nil runCleanup(forceExit, func() { @@ -839,89 +834,18 @@ func keyOverridesFromConfig(cfg config) map[string][]string { // probes, never embeds). // - bare `mecatui` (modeLocal): host an embedded server over a UNIX socket // (never probes loopback). -// -//nolint:gocyclo // composition root resolves the mutually exclusive remote and embedded transports. func resolveTransport(ctx context.Context, cfg config) (target string, dial client.DialConfig, cleanup func(), err error) { + return resolveTransportWithHook(ctx, cfg, nil) +} + +func resolveTransportWithHook(ctx context.Context, cfg config, beforeEmbeddedStart func(app.Config) error) (target string, dial client.DialConfig, cleanup func(), err error) { noop := func() {} // The two modes are PURE (ADR 0087): `mecatui connect ADDRESS` ALWAYS dials // ADDRESS and NEVER probes/embeds; the bare invocation ALWAYS embeds and // NEVER probes loopback. if cfg.transportMode == modeConnect { - dial := client.DialConfig{Server: cfg.connectAddress, AuthToken: cfg.authToken, ExplicitAnonymous: cfg.anonymous, UseTLS: cfg.useTLS, TLSCAFile: cfg.tlsCA, Insecure: cfg.insecure, RemotePlaintextAllowed: cfg.tlsExplicit && !cfg.useTLS} - if cfg.authToken == "" && !cfg.anonymous { - root := filepath.Join(xdg.ConfigHome, "mecatl") - registry, regErr := clientauth.OpenExistingRegistry(root) - if regErr != nil { - return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} - } - conn, findErr := registry.Find(cfg.connectAddress) - if findErr == nil { - target = conn.Identity.Target - dial.Server = target - if err := applySavedRemoteTLSPolicy(cfg, &dial); err != nil { - return target, client.DialConfig{}, noop, err - } - var ca []byte - var readErr error - if conn.IssuerCAFile != "" { - ca, readErr = os.ReadFile(conn.IssuerCAFile) - } - if readErr != nil { - return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} - } - store, _, storeErr := clientauth.OpenExistingCredentialStore(ctx, root) - if storeErr != nil { - return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} - } - creds, credsErr := clientauth.NewCredentials(store) - if credsErr != nil { - _ = store.Close() - return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} - } - if _, loadErr := creds.Load(ctx, conn.Identity); loadErr != nil { - _ = store.Close() - if errors.Is(loadErr, credentialstore.ErrNotFound) { - // The registry entry above proves this target IS enrolled, so an - // absent credential means the stored one is gone -- typically - // deleted after a provider rejected its refresh. NotEnrolled - // belongs to the FindTarget miss below, not here. clientauth - // remaps this within one process lifetime; across a restart that - // memory is gone and only the registry can tell them apart. - return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthSessionExpired} - } - if errors.Is(loadErr, clientauth.ErrCorrupt) { - return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthCredentialUnusable} - } - return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} - } - source, sourceErr := clientauth.NewRefreshSource(ctx, creds, clientauth.LoginConfig{Identity: conn.Identity, IssuerAddressPolicy: conn.IssuerAddressPolicy, TrustedCAPEM: ca, Registry: registry}) - if sourceErr != nil { - _ = store.Close() - // NewRefreshSource fails with ErrDiscovery when the issuer is - // unreachable, its TLS is untrusted, or JWKS will not load -- - // an infrastructure/network problem, not evidence the local - // keyring/registry/store is broken. Return it unwrapped so it - // falls through AuthFailure's deliberate unclassified case - // instead of steering the user toward local-storage recovery. - if errors.Is(sourceErr, clientauth.ErrDiscovery) { - return target, client.DialConfig{}, noop, sourceErr - } - return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} - } - dial.TokenSource = mapAuthTokenSource(source) - return target, dial, func() { _ = source.Close(); _ = store.Close() }, nil - } - if errors.Is(findErr, credentialstore.ErrNotFound) { - // A clean registry miss is not an authentication decision. The server - // remains authoritative: dial without a bearer and recover only if it - // actually returns Unauthenticated. Registry/storage errors still fail - // closed. - return cfg.connectAddress, dial, noop, nil - } - return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} - } - return cfg.connectAddress, dial, noop, nil + return resolveRemoteTransport(ctx, cfg, noop) } // We are about to HOST an embedded server (the bare/local mode). @@ -961,6 +885,12 @@ func resolveTransport(ctx context.Context, cfg config) (target string, dial clie cfg = applyTrustPrompt(cfg, diag) composition := embeddedConfig(cfg, diag) + if beforeEmbeddedStart != nil { + if err := beforeEmbeddedStart(composition); err != nil { + _ = diagCloser.Close() + return target, client.DialConfig{}, noop, err + } + } srv, err := embed.Start(ctx, composition, perfConfig(cfg, perfLogger)) if err != nil { _ = diagCloser.Close() @@ -997,6 +927,86 @@ func resolveTransport(ctx context.Context, cfg config) (target string, dial clie }, nil } +// resolveRemoteTransport dials only the configured remote target. It never starts +// an embedded server or reports embedded startup progress. +func resolveRemoteTransport(ctx context.Context, cfg config, noop func()) (target string, dial client.DialConfig, cleanup func(), err error) { + dial = client.DialConfig{Server: cfg.connectAddress, AuthToken: cfg.authToken, ExplicitAnonymous: cfg.anonymous, UseTLS: cfg.useTLS, TLSCAFile: cfg.tlsCA, Insecure: cfg.insecure, RemotePlaintextAllowed: cfg.tlsExplicit && !cfg.useTLS} + if cfg.authToken != "" || cfg.anonymous { + return cfg.connectAddress, dial, noop, nil + } + + root := filepath.Join(xdg.ConfigHome, "mecatl") + registry, regErr := clientauth.OpenExistingRegistry(root) + if regErr != nil { + return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} + } + conn, findErr := registry.Find(cfg.connectAddress) + if errors.Is(findErr, credentialstore.ErrNotFound) { + // A clean registry miss is not an authentication decision. The server + // remains authoritative: dial without a bearer and recover only if it + // actually returns Unauthenticated. Registry/storage errors still fail + // closed. + return cfg.connectAddress, dial, noop, nil + } + if findErr != nil { + return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} + } + + target = conn.Identity.Target + dial.Server = target + if err := applySavedRemoteTLSPolicy(cfg, &dial); err != nil { + return target, client.DialConfig{}, noop, err + } + var ca []byte + if conn.IssuerCAFile != "" { + ca, err = os.ReadFile(conn.IssuerCAFile) + } + if err != nil { + return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} + } + store, _, storeErr := clientauth.OpenExistingCredentialStore(ctx, root) + if storeErr != nil { + return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} + } + creds, credsErr := clientauth.NewCredentials(store) + if credsErr != nil { + _ = store.Close() + return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} + } + if _, loadErr := creds.Load(ctx, conn.Identity); loadErr != nil { + _ = store.Close() + if errors.Is(loadErr, credentialstore.ErrNotFound) { + // The registry entry above proves this target IS enrolled, so an + // absent credential means the stored one is gone -- typically + // deleted after a provider rejected its refresh. NotEnrolled + // belongs to the FindTarget miss above, not here. clientauth + // remaps this within one process lifetime; across a restart that + // memory is gone and only the registry can tell them apart. + return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthSessionExpired} + } + if errors.Is(loadErr, clientauth.ErrCorrupt) { + return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthCredentialUnusable} + } + return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} + } + source, sourceErr := clientauth.NewRefreshSource(ctx, creds, clientauth.LoginConfig{Identity: conn.Identity, IssuerAddressPolicy: conn.IssuerAddressPolicy, TrustedCAPEM: ca, Registry: registry}) + if sourceErr != nil { + _ = store.Close() + // NewRefreshSource fails with ErrDiscovery when the issuer is + // unreachable, its TLS is untrusted, or JWKS will not load -- + // an infrastructure/network problem, not evidence the local + // keyring/registry/store is broken. Return it unwrapped so it + // falls through AuthFailure's deliberate unclassified case + // instead of steering the user toward local-storage recovery. + if errors.Is(sourceErr, clientauth.ErrDiscovery) { + return target, client.DialConfig{}, noop, sourceErr + } + return target, client.DialConfig{}, noop, &client.AuthError{Reason: client.AuthStorageUnavailable} + } + dial.TokenSource = mapAuthTokenSource(source) + return target, dial, func() { _ = source.Close(); _ = store.Close() }, nil +} + // applyTrustPrompt runs the pre-TUI first-encounter workspace-trust gate // (Workspace-Trust Phase 2c) and returns cfg with trustProject set when the // operator (or an already-existing trust grant) trusts the run. It builds the @@ -1025,13 +1035,58 @@ func applyTrustPrompt(cfg config, diag port.Diagnostics) config { // mecatuiServerImplementation is the stable family of the embedded server. const mecatuiServerImplementation = "mecatui" +func microVMFailureHint(cfg config) string { + diagnosticsPath := cfg.diagnosticsLog + if diagnosticsPath == "" { + diagnosticsPath = resolveDiagLogPath(xdgconfig.OSEnv) + } + failureHint := "next: run 'mecated microvm doctor'" + if diagnosticsPath != "" { + if cfg.quiet { + failureHint += "; diagnostics are disabled by --quiet (rerun without --quiet for log: " + diagnosticsPath + ")" + } else { + failureHint += "; diagnostics log: " + diagnosticsPath + } + } + return failureHint +} + // embeddedConfig constructs the embedded server's declarative app.Config. app.Build // loads the injected provider credential; connect mode never calls this function. func embeddedConfig(cfg config, diag port.Diagnostics) app.Config { cmdDir, enableCmds := resolveCommands(cfg) skillDirs, skillsConv := resolveSkills(cfg) + failureHint := microVMFailureHint(cfg) out := app.Config{ - Workspace: cfg.workspace, + Workspace: cfg.workspace, + MicroVMReadinessObserver: func(_ microvmmanager.ReadinessStage, message string) { + if cfg.microVMProgress == nil { + return + } + select { + case cfg.microVMProgress <- message: + default: + } + }, + MicroVMReadinessFailed: func(microvmmanager.ReadinessStage) { + if cfg.microVMSelected != nil { + cfg.microVMSelected.Store(true) + } + }, + MicroVMReadinessFailureHint: failureHint, + MicroVMReadyRequest: func(selection microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + request, enabled, err := microVMDevelopmentReadyRequest( + cfg.microVMDevRelease, + cfg.microVMDevAcknowledge, + version, + microVMReleaseStampRequired != "" || microVMReleaseDefaultsB64 != "", + selection, + ) + if enabled { + return request, err + } + return microvmmanager.ReadyRequestFromDefaults(microVMReleaseDefaultsB64, version, selection) + }, ServerImplementation: mecatuiServerImplementation, Model: cfg.model, DefaultProvider: cfg.defaultProvider, @@ -1202,13 +1257,6 @@ func embeddedConfig(cfg config, diag port.Diagnostics) app.Config { if !cfg.providerKeysResolved { keys = cfg.providerFlags.Resolve() } - if cfg.microVMProvider != nil { - const scope server.PlacementScope = "deployment" - out.PlacementProvider = cfg.microVMProvider - out.PlacementScope = scope - out.EnvironmentForkers = map[session.EnvironmentKind]tool.EnvironmentForker{session.EnvironmentKind("microvm"): cfg.microVMProvider} - out.EnvironmentMergers = map[session.EnvironmentKind]tool.EnvironmentMerger{session.EnvironmentKind("microvm"): cfg.microVMProvider} - } cfg.providerFlags.ApplyResolved(&out, keys) out.UseOpenAI = keys.OpenAI != "" cfg.toolhiveLLMFlags.Apply(&out) diff --git a/cmd/mecatui/main_test.go b/cmd/mecatui/main_test.go index f8e4186c5..77c6f5d20 100644 --- a/cmd/mecatui/main_test.go +++ b/cmd/mecatui/main_test.go @@ -14,8 +14,13 @@ import ( "testing" "time" + tea "charm.land/bubbletea/v2" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/ui" "github.com/stacklok/mecatl/engine/adapter/mockllm" "github.com/stacklok/mecatl/internal/adapter/mcpauthority" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" "github.com/stacklok/mecatl/internal/app" "github.com/stacklok/mecatl/internal/cliconfig" "github.com/stacklok/mecatl/internal/testutil/testhome" @@ -37,6 +42,100 @@ func TestMain(m *testing.M) { })) } +type inertMicroVMReadyManager struct{} + +func (inertMicroVMReadyManager) EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) { + return "unused", nil +} + +func writeIsolatedExecutionSettings(t *testing.T, placement string) { + t.Helper() + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + settingsDir := filepath.Join(configHome, "mecatl") + if err := os.MkdirAll(settingsDir, 0o700); err != nil { + t.Fatal(err) + } + body := "execution:\n default_placement: " + placement + "\n" + if err := os.WriteFile(filepath.Join(settingsDir, "settings.yaml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestBareEmbeddedRunDiscoversConventionalMicroVMPlacement(t *testing.T) { + writeIsolatedExecutionSettings(t, app.PlacementMicroVMLocal) + stop := errors.New("configuration inspected") + workspace := t.TempDir() + err := runWithOptions([]string{"mecatui", "--mock", "--quiet", "--workspace=" + workspace}, runOptions{ + beforeEmbeddedStart: func(cfg app.Config) error { + if len(cfg.PermissionConfigs) != 0 { + t.Fatalf("PermissionConfigs = %v, want conventional discovery only", cfg.PermissionConfigs) + } + cfg.MicroVMManagerFactory = func() (app.MicroVMReadyManager, string, error) { + return inertMicroVMReadyManager{}, "unix:///unused", nil + } + resolved, err := app.ConfigureExecution(cfg) + if err != nil { + t.Fatalf("ConfigureExecution: %v", err) + } + if resolved.PlacementProvider == nil { + t.Fatal("bare embedded startup did not select the microvm-local placement provider") + } + return stop + }, + }) + if !errors.Is(err, stop) { + t.Fatalf("runWithOptions error = %v, want inspection stop", err) + } +} + +func TestEmbeddedReadinessCallbacksReachFirstSessionModel(t *testing.T) { + writeIsolatedExecutionSettings(t, app.PlacementHostLocal) + workspace := t.TempDir() + var composition app.Config + err := runWithOptions([]string{"mecatui", "--mock", "--quiet", "--no-store", "--no-memory", "--no-user-model", "--no-soul", "--no-skills", "--no-commands", "--workspace=" + workspace}, runOptions{ + beforeEmbeddedStart: func(cfg app.Config) error { + composition = cfg + return nil + }, + runProgram: func(_ context.Context, model ui.Model) (tea.Model, error) { + const progress = "Preparing microVM first session" + composition.MicroVMReadinessObserver(microvmmanager.StagePrepare, progress) + batch, ok := model.Init()().(tea.BatchMsg) + if !ok { + t.Fatalf("Init message = %T, want tea.BatchMsg", model.Init()()) + } + seen := false + for _, cmd := range batch { + msg := cmd() + updated, _ := model.Update(msg) + model = updated.(ui.Model) + if strings.Contains(model.View().Content, progress) { + seen = true + break + } + } + if !seen { + t.Fatalf("app readiness progress did not reach UI model:\n%s", model.View().Content) + } + + composition.MicroVMReadinessFailed(microvmmanager.StagePrepare) + updated, _ := model.Update(client.ConnectErrMsg{Err: errors.New("placement unavailable: private detail")}) + model = updated.(ui.Model) + view := model.View().Content + if !strings.Contains(view, "mecated microvm doctor") || strings.Contains(view, "private detail") { + t.Fatalf("app readiness failure did not reach redacted UI remediation:\n%s", view) + } + return model, nil + }, + }) + if err != nil { + t.Fatalf("runWithOptions: %v", err) + } +} + // TestResolveThemeAutoDetect pins the light/dark auto-detect gate (ADR 0280): // armed only when no explicit theme was given AND stdout is a real terminal — // every other combination (explicit theme, redirected stdout, or both) must diff --git a/cmd/mecatui/microvm_command.go b/cmd/mecatui/microvm_command.go deleted file mode 100644 index d1cbe7c41..000000000 --- a/cmd/mecatui/microvm_command.go +++ /dev/null @@ -1,147 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "io" - "sync" - - "github.com/stacklok/mecatl/cmd/mecatui/client" - microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" - "github.com/stacklok/mecatl/internal/adapter/microvmmanager" - "github.com/stacklok/mecatl/internal/adapter/server" - "github.com/stacklok/mecatl/internal/microvmcmd" -) - -type microVMManager interface { - microvmcmd.Manager - EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) -} - -type microVMReadyManager interface { - EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) -} - -func defaultMicroVMManager() (microVMManager, error) { - manager, _, err := microvmmanager.DefaultLocal() - return manager, err -} - -func defaultMicroVMEndpoint() (string, error) { - _, endpoint, err := microvmmanager.DefaultLocal() - return endpoint, err -} - -func runMicroVMCommand(ctx context.Context, args []string, in io.Reader, out io.Writer, manager microVMManager, interactive bool) error { - return microvmcmd.Run(ctx, microvmcmd.FrontendMecatui, args, in, out, manager, interactive) -} - -func validateMicroVMReleaseStamp() error { - if microVMReleaseStampRequired == "" { - return nil - } - _, err := microVMReadyRequest() - return err -} - -func microVMReadyRequestWithDevelopment(descriptor string, acknowledge bool, egress ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { - request, enabled, err := microVMDevelopmentReadyRequest(descriptor, acknowledge, version, microVMReleaseStampRequired != "" || microVMReleaseDefaultsB64 != "", egress...) - if enabled || err != nil { - return request, err - } - return microVMReadyRequest(egress...) -} - -func microVMReadyRequest(egress ...microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { - return microvmmanager.ReadyRequestFromDefaults(microVMReleaseDefaultsB64, version, egress...) -} - -type microVMPrewarmKey struct{} - -type readinessHandoff struct { - mu sync.Mutex - ready func(context.Context) error - skipNext bool -} - -func (h *readinessHandoff) call(ctx context.Context) error { - h.mu.Lock() - defer h.mu.Unlock() - prewarm, _ := ctx.Value(microVMPrewarmKey{}).(bool) - if !prewarm && h.skipNext { - h.skipNext = false - return nil - } - if err := h.ready(ctx); err != nil { - return err - } - if prewarm { - h.skipNext = true - } - return nil -} - -func ensureSelectedEnvironmentReady(ctx context.Context, cfg config, out io.Writer) error { - if cfg.transportMode != modeLocal || cfg.defaultPlacement != microvmmanager.Alias || cfg.microVMReadiness == nil { - return nil - } - ctx = context.WithValue(ctx, microVMPrewarmKey{}, true) - if cfg.microVMDevRelease != "" { - _, _ = fmt.Fprintln(out, "mecatui: microvm-local: unsupported development release enabled; trusting acknowledged local artifacts") - } - ctx = microvmmanager.WithReadinessObserver(ctx, func(_ microvmmanager.ReadinessStage, message string) { - _, _ = fmt.Fprintln(out, "mecatui: microvm-local:", message) - }) - return cfg.microVMReadiness(ctx) -} - -func startupResumeAndReadiness(ctx context.Context, source startupResumeSource, cfg config, out io.Writer) (*client.ResumeSelection, string, error) { - resume, workspace, err := startupResumeConfig(ctx, source, cfg) - if err != nil { - return nil, "", err - } - if err := ensureSelectedEnvironmentReady(ctx, cfg, out); err != nil { - return nil, "", err - } - return resume, workspace, nil -} - -func configureSelectedEnvironmentReadiness(cfg *config, endpoint string, factory func() (microVMReadyManager, error)) error { - if cfg.transportMode != modeLocal || cfg.defaultPlacement != microvmmanager.Alias { - return nil - } - if endpoint == "" { - return errors.New("microvm-local endpoint is empty") - } - cfg.microVMEndpoint = endpoint - ready := &readinessHandoff{ready: func(ctx context.Context) error { - manager, err := factory() - if err != nil { - return err - } - var egress []microvmmanager.GuestEgressSelection - if cfg.microVMEgressSet { - egress = append(egress, cfg.microVMGuestEgress) - } - request, err := microVMReadyRequestWithDevelopment(cfg.microVMDevRelease, cfg.microVMDevAcknowledge, egress...) - if err != nil { - return err - } - readyEndpoint, err := manager.EnsureReady(ctx, request) - if err != nil { - return err - } - if readyEndpoint != endpoint { - return fmt.Errorf("microvm-local readiness returned unexpected endpoint %q", readyEndpoint) - } - return nil - }} - cfg.microVMReadiness = ready.call - provider, err := microvmadapter.NewPlacementProvider(endpoint, cfg.workspace, microvmmanager.Alias, server.PlacementScope("deployment"), cfg.microVMReadiness) - if err != nil { - return err - } - cfg.microVMProvider = provider - return nil -} diff --git a/cmd/mecatui/microvm_command_test.go b/cmd/mecatui/microvm_command_test.go deleted file mode 100644 index 44c17ff57..000000000 --- a/cmd/mecatui/microvm_command_test.go +++ /dev/null @@ -1,209 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "io" - "strings" - "testing" - - "github.com/stacklok/mecatl/internal/adapter/microvmmanager" - "github.com/stacklok/mecatl/internal/microvmcmd" -) - -func TestMicroVMLifecycleUX_StatusAndDeleteReportExactGeneration(t *testing.T) { - mgr := &fakeMicroVMManager{status: microvmmanager.Status{ - Configured: true, Running: true, Socket: "/run/microvmd.sock", - Generations: []microvmmanager.Generation{{SessionID: "s1", Ref: "env-1@7", Generation: 7, WorktreePath: "/worktrees/s1", Health: "stale"}}, - }, deleteResult: microvmmanager.DeleteResult{WorktreePath: "/worktrees/s1", WorktreeRetained: true}} - var out strings.Builder - if err := runMicroVMCommand(context.Background(), []string{"status"}, strings.NewReader(""), &out, mgr, false); err != nil { - t.Fatal(err) - } - for _, want := range []string{"session=s1", "ref=env-1@7", "repository-generation=7", "health=stale", "worktree=/worktrees/s1"} { - if !strings.Contains(out.String(), want) { - t.Fatalf("status omitted %q:\n%s", want, out.String()) - } - } - out.Reset() - args := []string{"delete", "--session", "s1", "--ref", "env-1@7", "--generation", "7", "--yes"} - if err := runMicroVMCommand(context.Background(), args, strings.NewReader(""), &out, mgr, false); err != nil { - t.Fatal(err) - } - if mgr.deleteRequest != (microvmmanager.DeleteRequest{SessionID: "s1", Ref: "env-1@7", Generation: 7}) { - t.Fatalf("delete request = %+v", mgr.deleteRequest) - } - if !strings.Contains(out.String(), "dirty worktree retained: /worktrees/s1") { - t.Fatalf("dirty retention was not reported: %s", out.String()) - } -} - -func TestMecatuiMicroVM_RepositoryGenerationShowsDistinctLogicalWorktrees(t *testing.T) { - mgr := &fakeMicroVMManager{status: microvmmanager.Status{ - Configured: true, Running: true, Continuation: "next", - Generations: []microvmmanager.Generation{ - {SessionID: "session-a", Ref: "logical-a@4", Generation: 4, WorktreePath: "/worktrees/a", State: "ready", Health: "healthy"}, - {SessionID: "session-b", Ref: "logical-b@4", Generation: 4, WorktreePath: "/worktrees/b", State: "ready", Health: "healthy"}, - }, - }, deleteResult: microvmmanager.DeleteResult{WorktreePath: "/worktrees/b", WorktreeRetained: true}} - var out strings.Builder - if err := runMicroVMCommand(t.Context(), []string{"status", "--page-size", "2"}, strings.NewReader(""), &out, mgr, false); err != nil { - t.Fatal(err) - } - for _, want := range []string{"session=session-a", "session=session-b", "repository-generation=4", "worktree=/worktrees/a", "worktree=/worktrees/b", "--continuation"} { - if !strings.Contains(out.String(), want) { - t.Fatalf("repository status omitted %q:\n%s", want, out.String()) - } - } - out.Reset() - if err := runMicroVMCommand(t.Context(), []string{"delete", "--session", "session-b", "--ref", "logical-b@4", "--generation", "4", "--yes"}, strings.NewReader(""), &out, mgr, false); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), "shared repository VM is not deleted") || !strings.Contains(out.String(), "dirty worktree retained") { - t.Fatalf("logical delete UX is ambiguous:\n%s", out.String()) - } -} - -func TestMecatuiMicroVM_StatusOutputIsOneBoundedContinuablePage(t *testing.T) { - generations := make([]microvmmanager.Generation, 64) - for i := range generations { - generations[i] = microvmmanager.Generation{SessionID: fmt.Sprintf("s-%03d", i), Ref: fmt.Sprintf("env-%03d@1", i), Generation: 1, WorktreePath: fmt.Sprintf("/worktrees/%03d", i), Health: "stale"} - } - mgr := &fakeMicroVMManager{status: microvmmanager.Status{Configured: true, Running: true, Generations: generations, Continuation: "opaque-next"}} - var out strings.Builder - if err := runMicroVMCommand(context.Background(), []string{"status", "--page-size", "64"}, strings.NewReader(""), &out, mgr, false); err != nil { - t.Fatal(err) - } - if got := strings.Count(out.String(), "logical worktree: "); got != 64 { - t.Fatalf("generation rows = %d, want 64", got) - } - want := `mecatui microvm status --continuation "opaque-next"` - if !strings.Contains(out.String(), want) { - t.Fatalf("status omitted continuation command %q:\n%s", want, out.String()) - } - if strings.Contains(out.String(), "mecated microvm status --continuation") { - t.Fatalf("status named uninvoked mecated frontend:\n%s", out.String()) - } - if mgr.statusRequest.PageSize != 64 || mgr.statusRequest.Continuation != "" { - t.Fatalf("status request = %+v", mgr.statusRequest) - } -} - -func TestMicroVMFirstRunRepair_DeleteValidatesTargetBeforePrompt(t *testing.T) { - mgr := &fakeMicroVMManager{status: microvmmanager.Status{Configured: true, Running: true}} - var out strings.Builder - err := runMicroVMCommand(context.Background(), []string{"delete", "--session", "missing", "--ref", "env@2", "--generation", "2"}, strings.NewReader("yes\n"), &out, mgr, true) - if err == nil || !strings.Contains(err.Error(), "not present in microvm status") { - t.Fatalf("invalid target error = %v", err) - } - if strings.Contains(out.String(), "[y/N]") || mgr.deleteCalls != 0 { - t.Fatalf("invalid target reached confirmation/delete: out=%q calls=%d", out.String(), mgr.deleteCalls) - } -} - -func TestMicroVMDoctorFailureGuidesRetryNotStatus(t *testing.T) { - mgr := &fakeMicroVMManager{doctorErr: errors.New("KVM unavailable")} - err := runMicroVMCommand(t.Context(), []string{"doctor"}, strings.NewReader(""), io.Discard, mgr, false) - if err == nil || !strings.Contains(err.Error(), "rerun 'mecated microvm doctor' (or 'mecatui microvm doctor')") || strings.Contains(err.Error(), "microvm status") { - t.Fatalf("doctor failure guidance = %v", err) - } -} - -func TestMicroVMDoctorPrintsStructuredWarningsOnce(t *testing.T) { - mgr := &fakeMicroVMManager{doctorReport: "PASS hypervisor ready; remediation: none\nWARN stale-resources 2 stale resources; remediation: inspect retained worktrees\n"} - var out strings.Builder - if err := runMicroVMCommand(t.Context(), []string{"doctor"}, strings.NewReader(""), &out, mgr, false); err != nil { - t.Fatal(err) - } - if strings.Count(out.String(), "WARN stale-resources") != 1 || !strings.Contains(out.String(), "inspect retained worktrees") { - t.Fatalf("doctor warning was not surfaced exactly once:\n%s", out.String()) - } - if strings.Contains(out.String(), "microvm-local is ready") { - t.Fatalf("generic success duplicated the structured report:\n%s", out.String()) - } -} - -func TestMicroVMDeleteFindsTargetAfterFirstStatusPage(t *testing.T) { - first := make([]microvmmanager.Generation, 64) - for i := range first { - first[i] = microvmmanager.Generation{SessionID: fmt.Sprintf("s-%03d", i), Ref: fmt.Sprintf("env-%03d@1", i), Generation: 1} - } - mgr := &fakeMicroVMManager{statusPages: map[string]microvmmanager.Status{ - "": {Configured: true, Running: true, Generations: first, Continuation: "page-2"}, - "page-2": {Configured: true, Running: true, Generations: []microvmmanager.Generation{{SessionID: "target", Ref: "env-target@9", Generation: 9}}}, - }} - var out strings.Builder - args := []string{"delete", "--session", "target", "--ref", "env-target@9", "--generation", "9", "--yes"} - if err := runMicroVMCommand(t.Context(), args, strings.NewReader(""), &out, mgr, false); err != nil { - t.Fatal(err) - } - if mgr.statusCalls != 2 || mgr.deleteCalls != 1 { - t.Fatalf("status calls=%d delete calls=%d, want 2 and 1", mgr.statusCalls, mgr.deleteCalls) - } -} - -func TestMecatuiMicroVMCompatibilityFrontendUsesSharedImplementation(t *testing.T) { - manager := &fakeMicroVMManager{status: microvmmanager.Status{Configured: true, Running: true, Continuation: "next"}} - var compatibility, canonical strings.Builder - args := []string{"status", "--output", "json"} - if err := runMicroVMCommand(t.Context(), args, strings.NewReader(""), &compatibility, manager, false); err != nil { - t.Fatal(err) - } - if err := microvmcmd.Run(t.Context(), microvmcmd.FrontendMecatui, args, strings.NewReader(""), &canonical, manager, false); err != nil { - t.Fatal(err) - } - if compatibility.String() != canonical.String() { - t.Fatalf("compatibility output differs:\n%s\ncanonical:\n%s", compatibility.String(), canonical.String()) - } - - compatibility.Reset() - if err := runMicroVMCommand(t.Context(), []string{"--help"}, strings.NewReader(""), &compatibility, manager, false); err != nil { - t.Fatal(err) - } - for _, want := range []string{"current local OS principal", "never targets a remote server", "compatibility frontend"} { - if !strings.Contains(compatibility.String(), want) { - t.Fatalf("compatibility help omitted %q:\n%s", want, compatibility.String()) - } - } -} - -type fakeMicroVMManager struct { - status microvmmanager.Status - statusPages map[string]microvmmanager.Status - statusRequest microvmmanager.StatusRequest - statusCalls int - doctorReport string - doctorErr error - deleteRequest microvmmanager.DeleteRequest - deleteResult microvmmanager.DeleteResult - readyEndpoint string - readyErr error - ensureCalls int - deleteCalls int -} - -func (f *fakeMicroVMManager) EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) { - f.ensureCalls++ - return f.readyEndpoint, f.readyErr -} -func (f *fakeMicroVMManager) Doctor(context.Context) (string, error) { - return f.doctorReport, f.doctorErr -} -func (f *fakeMicroVMManager) Status(_ context.Context, requests ...microvmmanager.StatusRequest) (microvmmanager.Status, error) { - f.statusCalls++ - if len(requests) > 0 { - f.statusRequest = requests[0] - if f.statusPages != nil { - return f.statusPages[requests[0].Continuation], nil - } - } - return f.status, nil -} -func (f *fakeMicroVMManager) Delete(_ context.Context, request microvmmanager.DeleteRequest) (microvmmanager.DeleteResult, error) { - f.deleteCalls++ - f.deleteRequest = request - return f.deleteResult, nil -} - -var _ microVMManager = (*fakeMicroVMManager)(nil) diff --git a/cmd/mecatui/microvm_development_microvm_dev_test.go b/cmd/mecatui/microvm_development_microvm_dev_test.go index bc2b023a7..55f4b96c3 100644 --- a/cmd/mecatui/microvm_development_microvm_dev_test.go +++ b/cmd/mecatui/microvm_development_microvm_dev_test.go @@ -5,17 +5,29 @@ package main import ( "strings" "testing" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" ) -func TestMicroVMDevelopmentFlagsRequirePairAndLocalProfile(t *testing.T) { - if _, err := parseFlags([]string{"--default-placement=microvm-local", "--microvm-dev-release=/absolute/release.json"}); err == nil || !strings.Contains(err.Error(), "required together") { +func TestMicroVMDevelopmentFlagsRequirePairAndStayLocal(t *testing.T) { + if _, err := parseFlags([]string{"--microvm-dev-release=/absolute/release.json"}); err == nil || !strings.Contains(err.Error(), "required together") { t.Fatalf("unpaired descriptor error = %v", err) } if _, _, err := parseTransportFlags(modeConnect, t.Output(), []string{"--microvm-dev-release=/absolute/release.json", "--microvm-dev-acknowledge-untrusted-local-artifacts"}); err == nil { t.Fatal("connect mode accepted development release flags") } - if _, err := parseFlags([]string{"--microvm-dev-release=/absolute/release.json", "--microvm-dev-acknowledge-untrusted-local-artifacts"}); err == nil || !strings.Contains(err.Error(), "--default-placement microvm-local") { - t.Fatalf("missing profile error = %v", err) +} + +func TestEmbeddedMicroVMDevelopmentActivationUsesCentralExecutionReadiness(t *testing.T) { + cfg := embeddedConfig(config{ + workspace: "/workspace", + model: "m", + mock: true, + microVMDevRelease: "/nonexistent/development-release.json", + microVMDevAcknowledge: true, + }, nil) + if _, err := cfg.MicroVMReadyRequest(microvmmanager.NewGuestEgressSelection()); err == nil || !strings.Contains(err.Error(), "development release descriptor") { + t.Fatalf("embedded development readiness error = %v", err) } } diff --git a/cmd/mecatui/microvm_egress_test.go b/cmd/mecatui/microvm_egress_test.go deleted file mode 100644 index a038091ed..000000000 --- a/cmd/mecatui/microvm_egress_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "strings" - "testing" - - "github.com/stacklok/mecatl/internal/adapter/microvmmanager" -) - -func TestMecatuiMicroVMGuestEgressFlags(t *testing.T) { - cfg, err := parseFlags([]string{ - "--default-placement=microvm-local", - "--microvm-guest-egress=allowlist", - "--microvm-guest-allow=API.Example.COM.:443/tcp", - }) - if err != nil { - t.Fatal(err) - } - if cfg.microVMGuestEgress.Mode != microvmmanager.GuestEgressAllowlist || len(cfg.microVMGuestEgress.Allow) != 1 || cfg.microVMGuestEgress.Allow[0].Hostname != "api.example.com" { - t.Fatalf("parsed egress = %#v", cfg.microVMGuestEgress) - } -} - -func TestMecatuiMicroVMGuestEgressScopeAndCombinations(t *testing.T) { - for _, args := range [][]string{ - {"--microvm-guest-egress=deny-all"}, - {"--default-placement=other", "--microvm-guest-egress=deny-all"}, - {"--default-placement=microvm-local", "--microvm-guest-egress=allowlist"}, - {"--default-placement=microvm-local", "--microvm-guest-allow=example.com:443/tcp"}, - } { - if _, err := parseFlags(args); err == nil { - t.Fatalf("parseFlags(%v) succeeded", args) - } - } - _, _, err := parseTransportFlags(modeConnect, &strings.Builder{}, []string{"--microvm-guest-egress=deny-all"}) - if err == nil || !strings.Contains(err.Error(), "not applicable") { - t.Fatalf("remote rejection = %v", err) - } -} - -func TestMecatuiMicroVMGuestEgressDefaultIsPermissive(t *testing.T) { - cfg, err := parseFlags(nil) - if err != nil { - t.Fatal(err) - } - if cfg.microVMGuestEgress.Mode != microvmmanager.GuestEgressPermissive || len(cfg.microVMGuestEgress.Allow) != 0 { - t.Fatalf("default egress = %#v", cfg.microVMGuestEgress) - } -} diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 9674f5f7e..5edd7d171 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -273,6 +273,12 @@ type Deps struct { // (composed in main). Default false (full splash). NoBanner bool + // StartupProgress carries bounded host-composition progress while the first + // session is being created. StartupFailureHint returns matching host-owned + // remediation when that preparation fails behind a redacted server error. + StartupProgress <-chan string + StartupFailureHint func() string + // Ctx is the program-level context; per-run stream contexts derive from it. Ctx context.Context //nolint:containedctx // stored to parent per-run stream cancels @@ -1214,6 +1220,21 @@ func (m Model) resetSessionDerived() Model { // startupResumeReadyMsg starts post-adoption work only after Bubble Tea owns the // model, preserving transcript-before-seed ordering. type startupResumeReadyMsg struct{} +type startupProgressMsg string + +func (m Model) startupProgressCmd() tea.Cmd { + if m.deps.StartupProgress == nil { + return nil + } + return func() tea.Msg { + select { + case message := <-m.deps.StartupProgress: + return startupProgressMsg(message) + case <-m.deps.Ctx.Done(): + return nil + } + } +} // Init starts the spinner and kicks off connect. // @@ -1228,6 +1249,9 @@ type startupResumeReadyMsg struct{} // CreateSession directly (the historical path, with an empty selection). func (m Model) Init() tea.Cmd { startup := m.startupCmd() + if m.deps.StartupProgress != nil { + startup = tea.Batch(startup, m.startupProgressCmd()) + } // The light/dark auto-detect (ADR 0280) wraps structurally around whatever // startup fires, so every branch gets it without threading a themeDetectCmd // through each one. Deps.ThemeAutoDetect is false unless composition armed it diff --git a/cmd/mecatui/ui/startup_progress_test.go b/cmd/mecatui/ui/startup_progress_test.go new file mode 100644 index 000000000..1cb3e2f73 --- /dev/null +++ b/cmd/mecatui/ui/startup_progress_test.go @@ -0,0 +1,53 @@ +package ui + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/theme" +) + +func TestStartupPlacementFailureShowsStableRemediation(t *testing.T) { + m := New(Deps{ + Theme: theme.New("aztec", theme.AztecPalette()), Ctx: context.Background(), + StartupFailureHint: func() string { + return "next: run 'mecated microvm doctor'; diagnostics log: /state/mecatl/mecatui.log" + }, + }) + updated, _ := m.Update(client.ConnectErrMsg{Err: errors.New("server: placement unavailable at /private/root")}) + got := updated.(Model) + for _, want := range []string{"placement_unavailable", "mecated microvm doctor", "/state/mecatl/mecatui.log"} { + if !strings.Contains(got.fatalErr, want) { + t.Fatalf("fatal error %q omitted %q", got.fatalErr, want) + } + } + if strings.Contains(got.fatalErr, "/private/root") { + t.Fatalf("fatal error leaked server detail: %q", got.fatalErr) + } +} + +func TestStartupProgressIsVisibleAndTerminalSafeWhileConnecting(t *testing.T) { + progress := make(chan string, 1) + progress <- "Downloading microVM components\x1b[31m /private/path" + m := New(Deps{ + Theme: theme.New("aztec", theme.AztecPalette()), + Ctx: context.Background(), + StartupProgress: progress, + }) + msg := m.startupProgressCmd()() + updated, next := m.Update(msg) + got := updated.(Model) + status := stripANSIstr(got.statusMsg) + if !strings.Contains(status, "Downloading microVM components") || strings.Contains(status, "\x1b") { + t.Fatalf("startup status = %q", status) + } + if view := stripANSIstr(got.View().Content); !strings.Contains(view, "Downloading microVM components") { + t.Fatalf("startup progress is not visible while connecting:\n%s", view) + } + if next == nil { + t.Fatal("startup progress listener was not re-armed") + } +} diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 905333417..6d606745c 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -637,6 +637,11 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd, bool) { m.submitStatusLine() } return m, nil, true + case startupProgressMsg: + if m.phase == phaseConnecting && msg != "" { + m.statusMsg = m.deps.Theme.Style("muted").Render(sanitizeTerminal(string(msg))) + } + return m, m.startupProgressCmd(), true case client.SessionReadyMsg: return m.applySessionReady(msg) case workspaceEnrollmentMsg: @@ -760,6 +765,11 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd, bool) { } m.phase = phaseFatal m.fatalErr = msg.Err.Error() + if m.deps.StartupFailureHint != nil { + if hint := m.deps.StartupFailureHint(); hint != "" { + m.fatalErr = "microvm-local preparation failed (placement_unavailable); " + hint + } + } return m, nil, true case restartFailedMsg: // A /models restart-now re-create (or a /worktrees re-create, or an /effort diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index d3d95a7c9..90843b06d 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -552,6 +552,9 @@ func (m Model) footerActivity() string { left = m.deps.Theme.Style("askTitle").Render(label) case phaseConnecting: left = m.sp.View() + " connecting…" + if m.statusMsg != "" { + left = m.sp.View() + " " + m.statusMsg + } default: left = m.idleFooterLeft() } diff --git a/docs/acceptance/microvm-execution-environments.md b/docs/acceptance/microvm-execution-environments.md index cddf9c580..1b3544311 100644 --- a/docs/acceptance/microvm-execution-environments.md +++ b/docs/acceptance/microvm-execution-environments.md @@ -1,8 +1,8 @@ # MicroVM execution environments — acceptance plan **Phase:** MVP rescope — repository-scoped local microVM execution -**Status:** landed -**Evidence:** Tasks 59–65 are complete. The required Linux amd64 KVM journey passes; all other live-platform claims remain deferred. +**Status:** landed; required Linux amd64 qualification manually verified +**Evidence:** Tasks 59–65 and deterministic offline coverage are complete. `task e2e:microvm` is the automated Linux amd64 KVM gate and uses the deterministic in-process mock provider; it does not contact OpenRouter and is not the evidence for the live-provider claim. A separate manual qualification trace was executed on 2026-09-10 with Linux amd64 KVM and OpenRouter `openai/gpt-5-mini` through the public HTTP create and prompt APIs. Normal Write, Read, and Bash ran in the Wolfi guest as UID 65532; the proof marker existed only in the MicroVM logical worktree. The same session reattached after mecated restarted while microvmd remained alive, and `microvm doctor` and `microvm status` both reported healthy. This does not claim recovery after a microvmd restart. No credential, exact private placement ref, socket, or host path from that trace is retained in this document. All other live-platform claims remain deferred. **Issue:** [stacklok/mecatl#526](https://github.com/stacklok/mecatl/issues/526) **ADR:** [ADR 0326](../adr/0326-microvm-execution-environments.md) **Accumulator branch:** `acc/microvm-execution-environments` @@ -25,8 +25,8 @@ This completed scenario follows **Acceptance:** -- AC1.1: Selecting `microvm-local` as the trusted deployment default runs idempotent `EnsureReady` before MicroVM placement, while the host-local default does not start or probe microvmd. - - verify: `TestMicroVMDefaultPlacementUsesNormalCreateSessionAndExactReattach` +- AC1.1: Selecting `microvm-local` as the trusted deployment default runs idempotent `EnsureReady` before MicroVM placement, while the host-local default does not start or probe microvmd. Service construction allocates no attachment, no-fs bypasses MicroVM readiness, and failed readiness persists nothing or falls back nowhere. + - verify: `TestBareEmbeddedConfigResolvesOperatorExecutionSettings`, `TestBareEmbeddedHostLocalOmissionDoesNoMicroVMWork`, `TestMicroVMOperatorJourneyIsLazyIsolatedAndRestartExact`, `TestServiceConstructionDoesNotAllocatePlacement`, `TestPlacementReadinessFailurePersistsNothingAndDoesNotFallBack` - AC1.2: Concurrent startup or first-use calls are serialized by the inter-process manager lock and converge on one compatible daemon and configuration. - verify: `TestMicroVMRedesign_Scenario1_EnsureReadyConvergesUnderManagerLock` - AC1.3: A failed readiness attempt returns an actionable error without changing the configured deployment default; repeating ordinary use retries readiness. @@ -99,7 +99,7 @@ cross-process merge coordinator or crash-durable merge journal is required. **Acceptance:** - AC5.1: Sessions in one repository attach to the same repository VM with distinct logical refs and worktrees; a direct-write child reuses its parent's logical Environment, while a read-only Subagent, Parallel branch, or Team member receives a distinct logical ref and worktree in that VM. - - verify: `TestMicroVMMVP_Scenario5_SessionsAndChildrenReuseRepositoryVM` + - verify: `TestMicroVMOperatorJourneyIsLazyIsolatedAndRestartExact`, `TestMicroVMMVP_Scenario5_SessionsAndChildrenReuseRepositoryVM` - AC5.2: Closing a session or child detaches its process-local handles without destroying the repository VM, rootfs, shared cache, or another attached logical environment. - verify: `TestMicroVMMVP_Scenario5_CloseDetachesWithoutDestroyingRepositoryVM` - AC5.3: The existing isolated-child merge path applies a non-conflicting child change and preserves the child on conflict; the MVP makes no cross-process serialization or crash-recovery claim. @@ -125,12 +125,37 @@ containment; tightening remains optional and fail-closed when selected. This follows [the required live journey](../architecture/microvm-environments.md#required-live-journey-and-limits). -The live proof is intentionally narrow and operator-oriented. +The automated `task e2e:microvm` proof is intentionally narrow and deterministic: it uses the +mock LLM provider while crossing production Linux amd64 KVM placement and guest boundaries. It +never requires or reads `OPENROUTER_API_KEY` and is suitable for the opt-in KVM CI job. + +A separate manual qualification was executed on 2026-09-10 with OpenRouter +`openai/gpt-5-mini`. The reproducible procedure was: + +1. Start from a verified release-stamped `mecated` in a disposable Git checkout on a Linux amd64 + host where the invoking user can open `/dev/kvm`; use private, newly created XDG state, data, + config, and runtime directories. +2. Supply `OPENROUTER_API_KEY` only in the server process environment (never a command argument, + file, trace, or captured output), select `microvm-local`, and run `mecated microvm doctor` before + starting the headless HTTP server. +3. Through the documented public HTTP API, create one session and prompt the model to use Write, + Read, and Bash to create and read a unique marker and report `id -u`. Confirm the terminal result + reports UID 65532, then confirm the source checkout does not contain the marker. +4. Stop only `mecated`, leave microvmd running, restart `mecated` with the same private XDG roots, + and prompt the same public session ID to read the marker. Confirm exact session continuation. +5. Run `mecated microvm doctor` and `mecated microvm status`; confirm healthy output. Copy no status + row into evidence because it contains private attachment/ref/path data. End the server, unset the + credential, and remove the disposable state through the operator-controlled cleanup procedure. + +The observed outcomes were successful Write/Read/Bash in the Wolfi guest, UID 65532, source-checkout +isolation, harness-restart reattachment while microvmd remained alive, and healthy doctor/status. +No credential, private placement ref, socket, or host path was retained. This manual trace is not an +automated gate and is not evidence for microvmd restart recovery. **Acceptance:** -- AC7.1: Linux amd64 KVM enters through production profile/session composition and proves ordinary first use, direct admitted Brood consumption with in-process verification, one repository VM/rootfs, two sessions with a shared declared cache and distinct worktrees, confined filesystem and exec, unrestricted IPv4 networking with external IPv6 unsupported, close-detach, and prompt explicit daemon-restart failure that preserves the record/rootfs/worktrees and mints no replacement. Optional fail-closed tightening is proven separately by AC6.2's production app/profile and network enforcement tests, not by this live journey. - - verify: demonstration — `task e2e:microvm` is the required Linux amd64 KVM live gate +- AC7.1: Linux amd64 KVM enters through production profile/session composition and proves ordinary first use, direct admitted Brood consumption with in-process verification, one guest-backed session, confined filesystem/Bash execution, and source-checkout isolation. Deterministic production-composition tests separately prove two-session logical-worktree isolation, exact harness-restart reattachment, fail-closed daemon-state loss, and direct-write versus isolated-child routing. Repository-VM/rootfs singleton, networking, close-detach, and merge/conflict behavior remain proven by their focused AC2–AC6 tests; the live journey does not overclaim those observations. + - verify: `task e2e:microvm` runs `TestMicroVMDefaultPlacementDailyHarnessJourney` plus `TestMicroVMOperatorJourneyIsLazyIsolatedAndRestartExact`; focused evidence is listed by AC2–AC6 - AC7.2: Concise architecture, operator, and public documentation describes profile selection, the repository sharing boundary, distinct worktrees, direct Brood admission, Linux ownership, permissive networking, optional tightening, restart failure behavior, and the deferred surfaces without claiming Linux arm64 or macOS live support. - verify: inspection — `task docs` and `task site:build` prove the linked documentation surfaces build @@ -149,13 +174,11 @@ also remain out of scope. ## Cross-cutting deliverables -- Historical tasks 1–58 and completed tasks 59–61 remain intact. Tasks 62–65 are the only - remaining implementation tasks. +- Historical tasks 1–58 and completed tasks 59–65 remain intact. - The durable registry fails closed on inconsistent restart state; it does not silently recreate, delete, or reconcile an orphan. - Default, no-fs, and engine-standalone gates preserve the opt-in module boundary. -- Every remaining numbered AC is quoted with its exact `verify:` line in exactly one of - tasks 62–65. +- Every numbered AC is quoted with its exact `verify:` line in exactly one of tasks 62–65. ## Definition of done @@ -167,5 +190,4 @@ also remain out of scope. ## Exit criteria -When the definition of done holds on the accumulator and review finds no ship blocker, the -orchestrator may flip this plan from `in-progress` to `landed`. +The definition of done holds on the accumulator; humans retain merge authority. diff --git a/docs/architecture.md b/docs/architecture.md index 61ea9a5cf..522f33ad8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1140,7 +1140,8 @@ fetched by the `FetchMcpResource` tool through `ValidateMediaURL` (SSRF backstop, CWE-918). See `docs/adr/0078-mcp-typed-tool-results.md`. **Server-owned placement.** Trusted composition installs one placement provider and -scope before listeners serve. `CreateSession` accepts only the provider's deployment +scope before listeners serve, but service construction validates only that configuration: it +never calls `Bind` or allocates a provisional placement. `CreateSession` accepts only the provider's deployment `default` or explicit `no-fs`; the public request has no workspace, cwd, placement ID, or selector. Local embedded and daemon deployments configure their root privately with `--workspace`; remote/cloud-native providers may bind another backend without widening @@ -1149,28 +1150,42 @@ cannot select authority. A microVM placement keeps tools and shell in guest `/workspace` while host-owned settings, soul, memory, MCP, hooks, identity, provider credentials, and the TUI remain outside the -guest. Sessions persist only exact private `EnvironmentRef` identity; unknown, disabled, +guest. Each private placement binding separately carries the trusted host source checkout for +project instructions, rules, commands, permission learning, and authorization; host composition +never infers that root from guest `/workspace`, and the value is neither persisted nor exposed. +No-FS has no host composition root. Sessions persist only exact private `EnvironmentRef` identity; unknown, disabled, mismatched, or unavailable generations fail without host-local fallback. Lifecycle composition reattaches the exact generation and explicitly detaches or deletes it without exposing source, worktree, endpoint, or guest paths on public APIs. -The user-local manager surface is `mecatui microvm doctor|status|delete`; daily use selects -`mecatui --default-placement microvm-local`. The shared provisioning boundary performs -idempotent readiness immediately before the first provision attempt. The live +The user-local administration surface is `mecated microvm doctor|status|delete`, scoped +to the current OS principal and local execution host. Doctor and status are read-only; +delete requires one exact backend/attachment/ref/generation status row plus confirmation, +preserves dirty worktrees, and never deletes or resets the repository VM. Daily embedded +use sets `execution.default_placement` to `microvm-local` once in operator settings and then +launches bare `mecatui`; remote `mecatui connect` remains client-only. The shared +provisioning boundary performs +idempotent readiness immediately before each actual default MicroVM provision attempt; service +startup and no-FS creation do not run readiness or allocate a validation attachment. The live `microvm-local` support boundary is the signed Linux-amd64 `mecatui` release binary: ordinary source builds have no authenticated release defaults and fail closed. For source development only, [ADR 0326](adr/0326-microvm-execution-environments.md#6-keep-source-build-release-activation-developer-only) defines a -separately tagged `microvm_dev` binary whose local composition roots require explicit +separately tagged `microvm_dev` mecated and embedded-local mecatui binaries whose +development activation requires explicit acknowledgement and a strict owner-only local release descriptor. Untagged and published binaries do not expose this path; release verification and daemon-policy checks remain. Readiness preflights Git, Python 3, KVM access, and an actual ephemeral unprivileged user-namespace -creation before downloading or provisioning repository state. Readiness and doctor +creation before downloading or provisioning repository state. One repository-scoped daemon +is shared across sessions and host processes. Under the manager lock, genuinely fresh state +is installed and started once; compatible callers reuse it. A desired release or egress-policy +conflict, corrupt configuration, identity mismatch, stopped daemon, or unhealthy runtime fails +without rewriting configuration, stopping the daemon, deleting state, or replacing repository +runtime. Readiness and doctor authenticate the daemon serving the owner-only socket and require its protocol, release/binary, loaded-config, policy, profile-set, and socket identities to match. -A stale daemon is signaled only after its persisted PID, process-start token, binary, -arguments, and socket identify the exact managed process; otherwise the alias remains -disabled with user-service-manager guidance. It supports local single-user Git sessions on +A mismatch or unhealthy daemon is left untouched and readiness fails with local repair +guidance. It supports local single-user Git sessions on Linux amd64 with KVM. Linux arm64 and Apple Silicon macOS have compile/static coverage only; live support is deferred. Schedules, remote placement, multi-user enforcement, non-Git sources, and unified host+guest egress policy remain out of scope. See the [microVM architecture](architecture/microvm-environments.md) and diff --git a/docs/architecture/microvm-environments.md b/docs/architecture/microvm-environments.md index db8dcd242..9707ba39e 100644 --- a/docs/architecture/microvm-environments.md +++ b/docs/architecture/microvm-environments.md @@ -67,7 +67,19 @@ accepting an arbitrary host path. Stale generations, replayed credentials, sibli path escapes fail before dispatch. Transport failure never falls back to host filesystem or shell. -Workspace and CommandRunner remain affined to the same root and cwd. Existing opaque file +The placement binding also carries an internal host-composition root that is distinct from +the guest execution root. MicroVM Bind and exact Reattach supply the configured source checkout +only after the daemon resolves the binding against that repository. Host-side project +instructions, rules, slash commands, permission learning, and authorization use that root; +Read/Edit/Bash continue to use guest `/workspace`. The composition root is not snapshot data, +public metadata, or model-visible content. Missing MicroVM composition context fails closed, +and no-FS deliberately supplies none. + +Workspace and CommandRunner remain affined to the same root and cwd. The MicroVM Workspace's +`tool.AuthorityResourceResolver` projects relative tool paths onto the same confined guest +`/workspace` identity used by filesystem RPCs; this lets authorization evaluate the guest +resource without mistaking the host composition root for tool authority. Absolute, empty, +NUL-containing, and escaping paths fail closed. Existing opaque file versions, create-only and conditional writes, ordered stdout/stderr, exit status, bounded output, and process-group cancellation remain the data-plane contract. This confines protocol requests; it does not create a kernel sandbox between worktrees in one repository @@ -113,11 +125,12 @@ ownership parity are deferred, regardless of compile/static coverage. `microvm-local` defaults to unrestricted guest IPv4 egress. The guest IPv6 stack remains enabled, but go-microvm's hosted topology does not route external IPv6; external IPv6 is -unsupported rather than a dual-stack claim. At the two host-local composition roots, -operators select `--microvm-guest-egress=permissive|deny-all|allowlist`; allowlist mode -uses repeatable `--microvm-guest-allow=HOST:PORT/tcp|udp` rules. That means -`mecated serve --headless ...` for the local server, or local embedded -`mecatui --default-placement microvm-local ...`. Rules normalize hostnames and reject +unsupported rather than a dual-stack claim. The strict operator-tier `execution:` +settings select `default_placement: host-local|microvm-local` and +`microvm.guest_egress.mode: permissive|deny-all|allowlist`; allowlist mode uses +`allow: [HOST:PORT/tcp|udp]`. Both mecated and bare embedded mecatui consume the same +resolved policy, while explicit mecated serve flags remain higher-precedence overrides. +Rules normalize hostnames and reject IP literals, wildcards, malformed values, and duplicates. The selection is host-only: no engine port, HTTP/gRPC request, project setting, or release-default field carries it. No API client selects this placement; `profile` remains limited to the default tool surface @@ -126,43 +139,65 @@ or explicit `no-fs` attenuation. A selected tightening mode filters IPv4 and disables IPv6, aborting readiness if either enforcement step fails; it never falls back to permissive operation. The validated selection overlays only the guest-egress fields after authenticated release/resource -defaults are built. Because daemon config identity includes policy, changing the selection -causes the existing mismatch/restart convergence rather than reusing a daemon with stale -policy. Omitting the flags leaves the prior permissive request byte-compatible. +default configuration is built. Omission resolves to permissive egress. Guest egress policy does not govern host providers, WebFetch, WebSearch, MCP, hooks, OCI discovery, or telemetry. ## Readiness and bounded operations -Ordinary `--default-placement microvm-local` deployment selection calls the completed idempotent -`EnsureReady` flow from the signed Linux-amd64 release binary. Ordinary source builds do +Selecting the `execution.default_placement` value `microvm-local` (or the mecated serve flag override) +configures the provider without provisioning. Actual default session creation calls the completed +idempotent +`EnsureReady` flow from the signed Linux-amd64 release binary; service startup and explicit +`no-fs` creation bypass it. A readiness failure occurs before placement creation and session +persistence, with no host-local fallback. Composition attaches one bounded readiness observer +used by diagnostics and the embedded TUI; the first-session connecting screen therefore reports +verified download/install/start stages without a second prewarm or policy path. The permissive +IPv4 default is disclosed before readiness starts. Embedded failures expose only a stable stage, +`mecated microvm doctor`, and the diagnostics-log location; detailed manager errors remain in the +operator log. Ordinary source builds do not embed authenticated release defaults and fail closed. Before release download or repository provisioning, readiness checks Git, Python 3, read-write KVM access, the Linux user-namespace controls, and an actual ephemeral namespace creation to detect disabled or exhausted quota. Doctor repeats those non-destructive host checks. Neither path changes -ACLs, groups, sysctls, or quota. The manager lock converges concurrent startup, and errors never rewrite -desired configuration. There is no dedicated activation flag, init command, or recover -command. +ACLs, groups, sysctls, or quota. The manager lock serializes concurrent startup across +sessions and host processes. Only genuinely fresh state is installed and started; compatible +callers reuse the repository daemon. Desired release or egress conflicts and unhealthy or +incompatible runtime state fail without rewriting active configuration, stopping the daemon, +deleting state, or replacing repository runtime. There is no dedicated activation flag, init +command, or recover command. Status reads the durable repository logical-attachment inventory as the sole `microvm-local` inventory authority; it survives daemon restart and shows the shared repository generation with each -exact session/ref/worktree and health, in deterministic owner-scoped pages of at most 64 entries -plus an opaque continuation token. Exact delete remains available after restart, removes only the -selected logical attachment and a clean worktree, and records a dirty worktree as retained stale -recovery state. It never deletes the repository VM. -Doctor remains diagnostic. Repository-VM deletion UX and a broad reconciliation state machine -are deferred. Same-process reattachment requires every dependency to remain live; daemon +exact attachment/ref/worktree and health, in deterministic owner-scoped pages of at most 64 entries +plus an opaque continuation token. Its JSON uses `backend` and `attachment_id`; an attachment ID +is not a public mecatl session ID. Stopped and unhealthy states still return a bounded status +object with `state`, stable `error`, and `remediation` before the command exits nonzero. +`mecated microvm delete` requires the exact backend, +attachment, ref, and generation from one owner-scoped status row plus confirmation. It removes +only that logical attachment and a clean worktree, preserves dirty worktrees, and never deletes +or resets the repository VM. Doctor, status, and delete are local to the execution host and +current OS principal; mecatui and remote connect expose no administration surface. A fresh host +with satisfied prerequisites is successfully reported as ready to configure on first use. +Same-process reattachment requires every dependency to remain live; daemon restart fails loudly and preserves state because hosted networking cannot be reconstructed safely. ## Required live journey and limits -Linux amd64 KVM is the only required live platform. Its journey proves ordinary first use, -direct admitted Brood boot and in-process verification, one VM/rootfs, two sessions sharing -a declared cache but not a worktree, confined filesystem and exec, unrestricted IPv4 -networking with the external-IPv6 limitation explicit, detach, and prompt daemon-restart -failure with preserved state and no replacement. Optional fail-closed tightening is proven -by AC6.2's production app/profile and network enforcement tests rather than a second live VM. +The automated `task e2e:microvm` Linux-amd64 KVM gate uses the deterministic mock provider and +does not contact OpenRouter. Separately, a manual qualification executed on 2026-09-10 used +OpenRouter `openai/gpt-5-mini` through public HTTP session creation and prompting. Normal Write, +Read, and Bash ran in the Wolfi guest as UID 65532, with a proof marker absent from the source +checkout. The same session reattached after mecated restarted while microvmd remained alive; +doctor and status were healthy. No credential, private placement ref, socket, or host path was +retained in the evidence. This proves first-use readiness, direct artifact admission and in-process verification, guest +filesystem and Bash execution, source isolation from the guest namespace, and exact harness +restart reattachment. It does not prove microvmd restart recovery: that remains fail-closed as +described above. Deterministic composition tests prove repository-VM reuse across multiple +sessions, daemon-restart failure behavior, and delegation routing. Optional fail-closed network +tightening is proven by AC6.2's deterministic app/profile and network-enforcement tests rather +than a second live VM. Deferred after the MVP: repository-VM deletion UX, sophisticated retention, crash-orphan reconciliation, crash-durable and cross-process diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 07afda0ec..7467385d2 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -194,6 +194,20 @@ Exact verified OIDC issuer/subject pairs authorized for process-wide storage hea | `storage_management.principals[].issuer` | `string` | `(empty)` | Issuer must equal the verified token issuer byte-for-byte. | | `storage_management.principals[].subject` | `string` | `(empty)` | Subject must equal the verified token subject byte-for-byte. | +## `execution` + +Tier: **operator** + +Server-owned execution placement and MicroVM guest-egress policy. Operator-tier only; project values are ignored. Bare mecatui and mecated resolve the same settings. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `execution.default_placement` | `string` | `host-local` | The default_placement setting selects the server-owned default placement backend. | +| `execution.microvm` | `executionmicrovmsection` | `(absent)` | The microvm block configures local MicroVM execution without creating a public profile. | +| `execution.microvm.guest_egress` | `executionguestegresssection` | `(absent)` | The guest_egress block controls network destinations reachable from the guest. | +| `execution.microvm.guest_egress.mode` | `string` | `permissive` | mode accepts permissive, deny-all, or allowlist. Permissive is the default. | +| `execution.microvm.guest_egress.allow` | `[]string` | `(absent)` | allow lists HOST:PORT/tcp\|udp destinations and is valid only in allowlist mode. | + ## `steer` Tier: **operator** diff --git a/docs/design/IMPLEMENTATION-NOTES.md b/docs/design/IMPLEMENTATION-NOTES.md index 9a929fe6b..85b23d948 100644 --- a/docs/design/IMPLEMENTATION-NOTES.md +++ b/docs/design/IMPLEMENTATION-NOTES.md @@ -96,7 +96,10 @@ Exact reattachment is allowed only while every owned dependency remains live in- Daemon restart loses the in-process hosted network provider, so readiness and resolve fail promptly with a named phase while preserving the durable record, rootfs, and worktrees; no replacement or destructive reconciliation occurs. The registry layer itself does not -multiplex guest environments. +multiplex guest environments. Selecting the backend at service startup performs no Bind and +allocates no logical attachment. `EnsureReady` runs only when an actual default MicroVM session +is created; no-FS bypasses it, and readiness failure occurs before session persistence with no +host-local fallback. The daemon creates and registers one distinct Git worktree for each session or isolated child. A newly accepted guest connection first proves repository-generation boot authority @@ -119,7 +122,22 @@ On Linux the backend supplies namespace-side UID/GID 65532 through go-microvm's creates the unprivileged user namespace, without world-mode widening. The built-in hosted network provides unrestricted IPv4; the guest IPv6 stack remains enabled but external IPv6 is unrouted and unsupported. Optional deny-all or allowlist tightening filters IPv4, -disables IPv6, and is fail-closed. Linux amd64 KVM is the sole live claim. +disables IPv6, and is fail-closed. Linux amd64 KVM is the sole live claim. Qualification used +OpenRouter `openai/gpt-5-mini` through the public HTTP create/prompt path and observed normal +Write, Read, and Bash in the Wolfi guest as UID 65532, source isolation, exact same-session +reattachment after only mecated restarted, and healthy doctor/status results. Microvmd remained +alive throughout the harness restart, so this evidence does not widen the documented fail-closed +microvmd-restart boundary. + +Two defects found during qualification are fixed at their owning seams. The development descriptor +check in `internal/adapter/microvmmanager/development_release_microvm_dev.go` +(`ReadyRequestFromDevelopmentDescriptor`) now distinguishes malformed identity from source-build +drift and tells developers to regenerate and rebuild, without disclosing either identity. The +MicroVM Workspace in `internal/adapter/microvm/client.go` (`AuthorityResourcePath`) now implements +`tool.AuthorityResourceResolver`, projecting authorization resources onto confined guest +`/workspace` rather than leaving policy without a workspace resource identity. Focused tests pin +the actionable diagnostic and reject empty, absolute, NUL-containing, and escaping authority +paths. Status uses deterministic owner-scoped pages of at most 64 entries with opaque continuation tokens. Explicit deferrals are repository-VM deletion UX, @@ -187,16 +205,16 @@ never opens registry files. `Status` holds one bounded page and its continuation a lifetime inventory. Readiness starts or reuses the verified daemon, invokes `Daemon.reconcile`, and walks bounded inventory pages without retaining prior rows; status prints one page plus a copyable `--continuation` command. Startup and readiness share the existing reconciler and -never call Create or enable host fallback. Generation deletion takes the complete local -owner/session/ref/generation binding and returns a bounded cleanup result distinguishing a -removed clean worktree from the retained dirty path. The shared -`internal/microvmcmd` command validates that the complete selector appears in the -current status inventory before it asks for interactive confirmation (or honors -`--yes`); ordinary TUI close remains detach-only. Continuing a preserved chat uses ordinary profile selection with -`--default-placement microvm-local --resume SESSION_ID`; exact persisted placement is -reattached by the server and never reselected by the resume request. The canonical command surface is -`mecated microvm doctor|status|delete`; `mecatui microvm` invokes exactly the same -local-host implementation as a compatibility frontend and never follows `mecatui connect`. Automatic readiness reports the exact resolved +never call Create or enable host fallback. Exact logical-attachment deletion is exposed +only by `mecated microvm delete`: it validates the exact owner-scoped backend, attachment, +ref, and generation from status, confirms destructive action, preserves dirty worktrees, +and never deletes or resets the repository VM. It is not a server API. +Continuing a preserved chat uses +`mecatui --resume SESSION_ID`; exact persisted placement is reattached by the server and +never reselected by the resume request. The local administration surface is +`mecated microvm doctor|status|delete`, scoped to the current execution host and OS +principal; doctor and status are read-only. `mecatui connect` never invokes local MicroVM +administration or readiness. Automatic readiness reports the exact resolved manager paths plus download, trust, resource, and egress policy; failures direct the operator to correct the reported cause and retry ordinary profile selection. Published standalone host binaries are bound by signed checksum manifests and provenance, embed only @@ -205,19 +223,20 @@ their verified bundle supplies its own installer. No ambient installer override `environment/microvm/daemon.go` (`LifecycleInfo`) exposes protocol, release/running-binary/config identities, policy revision, sorted profiles, and socket only after Unix peer authentication. -`internal/adapter/microvmmanager/manager.go` (`EnsureReady`) reuses a serving daemon only -when that projection exactly matches the newly installed binary and config. -Otherwise `internal/adapter/microvmmanager/default_operations.go` (`Stop`) verifies -the persisted PID plus process-start token, binary digest, exact launch arguments, -and socket before signaling; PID reuse or ambiguous service ownership fails with -service-manager guidance while the alias remains disabled. Before any download or repository -provisioning, `internal/adapter/microvmmanager/default_operations.go` (`Preflight`) checks Git, +`internal/adapter/microvmmanager/manager.go` (`EnsureReady`) serializes all callers with the manager lock. It installs and starts only a +genuinely fresh repository-scoped runtime; subsequent sessions and host processes reuse only +a serving daemon whose persisted desired release/policy, installed binary/config, protocol, +profile set, and socket identities all match. Conflicts or unhealthy state fail without stop, +config rewrite, cache/state deletion, or runtime replacement. The exact `Stop` operation remains +internal for explicit lifecycle cleanup and is never called by ordinary readiness. Before any +download or repository provisioning, `internal/adapter/microvmmanager/default_operations.go` +(`Preflight`) checks Git, Python 3, KVM, and actual ephemeral unprivileged-user-namespace creation; disabled controls and exhausted quota fail actionably without changing host policy. `Doctor` repeats that host preflight, first queries this serving identity, then runs the complete readiness probes against the same exact installation rather than validating only newly written files. Initialization -defaults to 2 vCPUs, 4 GiB RAM, and deny-all guest +defaults to 2 vCPUs, 4 GiB RAM, and permissive guest egress. `internal/adapter/microvmmanager/manager.go` (`DefaultPaths`) separates config (`$XDG_CONFIG_HOME/mecatl`), artifacts and the daemon binary (`$XDG_DATA_HOME/mecatl/microvm`), durable registry/worktrees (`$XDG_STATE_HOME/mecatl/microvm`), and the short owner-only runtime socket; each @@ -6814,10 +6833,18 @@ Placement is server-owned across embedded, loopback, remote, and cloud-native co `internal/app/placement.go` installs the local immutable provider over the operator's private configured root plus no-FS attenuation. `server.PlacementBinder` is mandatory and is the single Bind choke point; provider authorization and resolution happen in one snapshot and return a complete -Environment, exact `EnvironmentRef{Kind, ID, Revision}`, and bounded display metadata. Startup -validates the deployment default without caching it, and ordinary run entry always reattaches a -fresh Environment so the read ledger resets; after restart, a verified local binding whose workspace -root differs from the configured default rebuilds its root-scoped per-session engine and policy before +Environment, exact `EnvironmentRef{Kind, ID, Revision}`, bounded display metadata, and an +internal host-composition root where applicable. Service construction validates provider/scope +configuration only and never calls Bind: allocation and backend readiness occur on actual default +creation, while no-FS bypasses MicroVM readiness. Ordinary run entry always exactly reattaches a +fresh Environment so the read ledger resets; Reattach never calls Bind or bootstraps replacement +state. The host-composition root—not `Environment.Workspace().Root()`—selects the shared or +per-session project policy assembly. Local placement supplies its selected host root; MicroVM +supplies its configured source checkout after exact repository resolution while tools retain guest +`/workspace`; no-FS supplies none. This value is process-local binding context, never snapshot or +public/model-visible data, and missing required MicroVM context fails closed. After restart, a +verified binding whose host-composition root differs from the configured default rebuilds its +root-scoped per-session engine and policy before running rather than using the shared default-root engine. `sessionEnvironments` remains overrides-only (ACP and other explicitly owned overlays). Placement-provider, discovery, and storage failures cross public gRPC/HTTP only as stable content-free categories; bounded detailed causes remain on injected diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index d963a3904..6bbddc639 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -231,8 +231,10 @@ opaque selector on ClearSession/ForkSession. Sessions persist the exact private `EnvironmentRef`, not a duplicate workspace path; public inventory exposes bounded placement metadata only. -`microvm-local` is selected only by the local composition root with -`--default-placement microvm-local`. It becomes the deployment default used by ordinary +`microvm-local` is selected by the strict operator-tier `execution.default_placement` +setting or a higher-precedence explicit `mecated serve --default-placement` override. Bare +embedded mecatui consumes the same operator setting; `mecatui connect` never resolves or +forwards local placement intent. It becomes the deployment default used by ordinary CreateSession requests; clients cannot select a placement alias, path, or exact ref. Project settings cannot weaken this policy. diff --git a/docs/usage/microvm-environments.md b/docs/usage/microvm-environments.md index 4f2124881..9b48b0582 100644 --- a/docs/usage/microvm-environments.md +++ b/docs/usage/microvm-environments.md @@ -7,8 +7,19 @@ It supports one local operator and one canonical Git repository per VM. Linux ar macOS, remote placement, schedules, multi-user sharing, and non-Git sources are not available. -Install only the host binary for the journey you use. Installing both `mecatui` and -`mecated` is optional. +The embedded journey requires both host binaries: `mecatui` runs the in-process server, +while `mecated` supplies the local administration commands. No separately running +`mecated` process is required. The headless journey needs only `mecated`. + +`task e2e:microvm` is the opt-in automated Linux amd64 KVM gate. It uses the +deterministic mock provider, never reads `OPENROUTER_API_KEY`, and does not prove a live-provider +journey. A separate manual qualification was executed on 2026-09-10 with OpenRouter +`openai/gpt-5-mini` through the public HTTP create and prompt APIs. Write, Read, and Bash ran in +the Wolfi guest as UID 65532, with a proof marker absent from the source checkout. The same +session reattached after restarting only mecated while microvmd remained alive; doctor and status +were healthy. No credential, private placement ref, socket, or host path was retained. See the +acceptance plan for the reproducible qualification steps. This is not a microvmd-restart recovery +claim. ## Before either journey @@ -17,21 +28,22 @@ Use a published, release-stamped binary. Verify it before installation: ```sh VERSION=vX.Y.Z PLATFORM=linux-amd64 -BINARY=mecatui # or mecated mkdir -p "$HOME/.local/bin" .scratch/mecatl-host-release cd .scratch/mecatl-host-release -gh release download "$VERSION" --repo stacklok/mecatl \ - --pattern "${BINARY}-${VERSION}-${PLATFORM}" \ - --pattern "${BINARY}-${VERSION}-${PLATFORM}.sha256" \ - --pattern "${BINARY}-${VERSION}-${PLATFORM}.sigstore.json" -cosign verify-blob \ - --bundle "${BINARY}-${VERSION}-${PLATFORM}.sigstore.json" \ - --certificate-identity "https://github.com/stacklok/mecatl/.github/workflows/release.yml@refs/tags/${VERSION}" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - "${BINARY}-${VERSION}-${PLATFORM}" -gh attestation verify "${BINARY}-${VERSION}-${PLATFORM}" --repo stacklok/mecatl -sha256sum --check "${BINARY}-${VERSION}-${PLATFORM}.sha256" -install -m 0755 "${BINARY}-${VERSION}-${PLATFORM}" "$HOME/.local/bin/${BINARY}" +for BINARY in mecatui mecated; do + gh release download "$VERSION" --repo stacklok/mecatl \ + --pattern "${BINARY}-${VERSION}-${PLATFORM}" \ + --pattern "${BINARY}-${VERSION}-${PLATFORM}.sha256" \ + --pattern "${BINARY}-${VERSION}-${PLATFORM}.sigstore.json" + cosign verify-blob \ + --bundle "${BINARY}-${VERSION}-${PLATFORM}.sigstore.json" \ + --certificate-identity "https://github.com/stacklok/mecatl/.github/workflows/release.yml@refs/tags/${VERSION}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "${BINARY}-${VERSION}-${PLATFORM}" + gh attestation verify "${BINARY}-${VERSION}-${PLATFORM}" --repo stacklok/mecatl + sha256sum --check "${BINARY}-${VERSION}-${PLATFORM}.sha256" + install -m 0755 "${BINARY}-${VERSION}-${PLATFORM}" "$HOME/.local/bin/${BINARY}" +done export PATH="$HOME/.local/bin:$PATH" cd ../.. ``` @@ -52,16 +64,21 @@ local descriptor only from this checkout: task microvm:dev:prepare task microvm:dev:build DESCRIPTOR="$(pwd)/.scratch/microvm-dev/linux-amd64/release.json" - -# Interactive root: -.scratch/microvm-dev/bin/mecatui \ +DEV_CONFIG="$(pwd)/.scratch/microvm-dev/config" +mkdir -p "$DEV_CONFIG/mecatl" +cat >"$DEV_CONFIG/mecatl/settings.yaml" <<'YAML' +execution: + default_placement: microvm-local +YAML + +# Headless server composition: +.scratch/microvm-dev/bin/mecated serve --headless \ --default-placement microvm-local \ --microvm-dev-release="$DESCRIPTOR" \ --microvm-dev-acknowledge-untrusted-local-artifacts -# Or headless server root: -.scratch/microvm-dev/bin/mecated serve --headless \ - --default-placement microvm-local \ +# Embedded local mecatui composition; the isolated operator settings above are required: +XDG_CONFIG_HOME="$DEV_CONFIG" .scratch/microvm-dev/bin/mecatui \ --microvm-dev-release="$DESCRIPTOR" \ --microvm-dev-acknowledge-untrusted-local-artifacts ``` @@ -72,29 +89,40 @@ are required. They are unavailable to published binaries and cannot be supplied settings, environment variables, HTTP, or gRPC. Runtime artifact verification and admission still apply. -## mecatui-only journey +## Embedded mecatui journey + +Set the operator-owned deployment policy once in `~/.config/mecatl/settings.yaml`: -From the Git repository: +```yaml +execution: + default_placement: microvm-local +``` + +Then use the canonical administration command and launch the embedded server normally: ```sh -mecatui microvm doctor -mecatui --default-placement microvm-local +mecated microvm doctor +mecatui ``` -`microvm doctor` is diagnostic only. It can report `backend: not configured` on a fresh -home. Selecting `microvm-local` prepares the verified local runtime and creates the -session. Bare `mecatui` remains host-local. +`microvm doctor` is diagnostic only. With prerequisites satisfied, a fresh home reports +`backend: ready to configure on first use` and exits successfully. Bare `mecatui` embeds +its own server; do not start a separate `mecated serve` process. It resolves the operator +execution policy, then the first-session screen shows bounded download, verification, +installation, and daemon-start progress while the verified runtime is prepared. A +preparation failure names a stable stage and directs the operator to `mecated microvm +doctor` and the exact mecatui diagnostics log. `mecatui connect ADDRESS` is a pure remote +client and never reads or forwards local placement intent. -Inspect placement and resume with the same profile: +Inspect placement and resume without reselecting it: ```sh -mecatui microvm status -mecatui --default-placement microvm-local --resume SESSION_ID +mecated microvm status +mecatui --resume SESSION_ID ``` -Placement selection is per `mecatui` invocation. A microVM session retains its exact -persisted placement and is never moved to host execution; the same -`--default-placement microvm-local` is needed only when starting a new embedded server. +A microVM session retains its exact persisted placement and is never moved to host +execution. Public session `profile` remains limited to the deployment default and `no-fs`. ## Headless mecated-only journey @@ -131,27 +159,25 @@ only bounded placement metadata; exact refs and host/guest paths remain private. Guest IPv4 egress is permissive by default. External IPv6 is unrouted and unsupported. The local host operator can restrict guest networking: -```sh -# Block all guest egress. -mecatui --default-placement microvm-local \ - --microvm-guest-egress=deny-all - -# Allow only these guest destinations. -mecated serve --headless \ - --microvm-guest-egress=allowlist \ - --microvm-guest-allow=api.example.com:443/tcp \ - --microvm-guest-allow=dns.example.com:53/udp +```yaml +execution: + default_placement: microvm-local + microvm: + guest_egress: + mode: allowlist + allow: + - api.example.com:443/tcp + - dns.example.com:53/udp ``` -`--microvm-guest-egress` accepts `permissive`, `deny-all`, or `allowlist`. -`--microvm-guest-allow` is repeatable and requires at least one valid rule with -`allowlist`. Rules use `HOST:PORT/tcp|udp`; IP literals (including IPv6), wildcards, -invalid ports or protocols, whitespace/control characters, and duplicate rules are -invalid. Invalid policy or unavailable enforcement stops readiness. Existing validated -owner-only policy is retained across daemon restarts and session resumes; pass an -explicit mode to change it, including `--microvm-guest-egress=permissive` to reset it. +The strict operator-tier `mode` accepts `permissive`, `deny-all`, or `allowlist`. +`allow` requires at least one valid rule with `allowlist`. Rules use +`HOST:PORT/tcp|udp`; IP literals (including IPv6), wildcards, invalid ports or +protocols, whitespace/control characters, and duplicate rules are invalid. Invalid +policy or unavailable enforcement stops readiness. Omission means permissive. +Explicit `mecated serve` flags remain higher-precedence one-run overrides. -These flags are host-local only. HTTP/gRPC requests and project configuration cannot select +This policy is host-operator owned. HTTP/gRPC requests and project configuration cannot select or weaken `microvm-local` placement or guest egress. Guest egress does not cover host provider, WebFetch, WebSearch, MCP, hook, artifact, or telemetry traffic. The fixed VM defaults are 2 virtual CPUs and 4 GiB memory. @@ -165,16 +191,31 @@ routing; they do not provide kernel isolation between mutually hostile processes same VM. Different canonical repositories receive different VMs. A direct-write child uses its parent's environment. -`microvm doctor` and `microvm status` are read-only. Use status before deleting one -specific logical attachment: +`microvm doctor` and `microvm status` are read-only and inspect only state owned by +the current OS principal on the execution host. Status JSON names the backend as +`backend` and daemon attachment rows as `attachment_id`; those attachment IDs are not +public mecatl session IDs. A configured-but-stopped or unhealthy backend still emits a +bounded JSON document with `state`, stable `error`, and `remediation` fields, then exits +nonzero. `mecated microvm delete` removes one exact logical attachment: +copy `backend`, `attachment_id`, `ref`, and `generation` from one status row and confirm: ```sh -mecated microvm status --output json -mecated microvm delete --session ID --ref REF --generation N --yes +mecated microvm delete \ + --backend microvm-local \ + --attachment-id ATTACHMENT_ID \ + --ref REF \ + --generation GENERATION ``` -`mecatui microvm ...` provides the same local commands. Delete retains dirty worktrees -and does not delete the shared repository VM. After a daemon restart, repository +Deletion preserves a dirty worktree and never deletes or resets the repository VM. It is +local-only administration in `mecated`; `mecatui connect` cannot invoke it. + +One repository-scoped daemon is shared by sessions and host processes. Ordinary readiness +installs and starts only genuinely fresh state and reuses only an exactly compatible healthy +daemon. A different desired release or guest-egress policy, corrupt configuration, identity +mismatch, stopped daemon, or orphaned runtime fails loudly without stopping the daemon, +rewriting its active config, deleting state, or replacing repository runtime. Resolve the +reported local state conflict explicitly before retrying. After a daemon restart, repository records, rootfs, and worktrees can remain available while live hosted dependencies do not. Such sessions report the problem; mecatl does not fall back to host filesystem or shell execution and does not create an empty replacement environment. diff --git a/environment/microvm/control/service_test.go b/environment/microvm/control/service_test.go index 6bbc15297..6ba015587 100644 --- a/environment/microvm/control/service_test.go +++ b/environment/microvm/control/service_test.go @@ -4,7 +4,6 @@ import ( "errors" "net" "os" - "path/filepath" "testing" "github.com/stacklok/mecatl/environment/microvm/control" @@ -27,7 +26,8 @@ func TestMicroVMEnvironments_Scenario2_LocalPeerCredentialsBindOwner(t *testing. t.Fatalf("new control service: %v", err) } - socketPath := filepath.Join(t.TempDir(), "control.sock") + t.Chdir(t.TempDir()) + socketPath := "control.sock" listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) if err != nil { t.Fatalf("listen on local control socket: %v", err) diff --git a/environment/microvm/repository_composition_test.go b/environment/microvm/repository_composition_test.go index ff76382e6..b213bbed4 100644 --- a/environment/microvm/repository_composition_test.go +++ b/environment/microvm/repository_composition_test.go @@ -560,7 +560,8 @@ func TestRepositoryRuntimeStartRollsBackEveryOwnedAcquisition(t *testing.T) { } { t.Run(tc.stage, func(t *testing.T) { root := t.TempDir() - endpoint := filepath.Join(root, "guest.sock") + t.Chdir(root) + endpoint := "guest.sock" provider := &fakeNetworkProvider{socket: filepath.Join(root, "network.sock")} if tc.stage == "network" { provider.socket = "" diff --git a/environment/microvm/runtime_test.go b/environment/microvm/runtime_test.go index 05ccef7db..c3c60a8dc 100644 --- a/environment/microvm/runtime_test.go +++ b/environment/microvm/runtime_test.go @@ -153,7 +153,8 @@ func TestLibkrunBackend_FreshBackendOpensDestructionOnlyExactProcess(t *testing. }() root := t.TempDir() - endpoint := filepath.Join(root, "guest.sock") + t.Chdir(root) + endpoint := "guest.sock" listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: endpoint, Net: "unix"}) if err != nil { t.Fatal(err) diff --git a/internal/adapter/acp/agent_test.go b/internal/adapter/acp/agent_test.go index fc6ad4b3a..6758b6c1c 100644 --- a/internal/adapter/acp/agent_test.go +++ b/internal/adapter/acp/agent_test.go @@ -62,7 +62,7 @@ func (p acpPlacementProvider) binding() server.PlacementBinding { panic(err) } env := tool.MustEnvironment(p.ref, ws, memledger.New(), nil) - return server.PlacementBinding{Environment: env, Ref: p.ref} + return server.PlacementBinding{Environment: env, Ref: p.ref, CompositionRoot: p.root} } func (p acpPlacementProvider) Bind(_ context.Context, req server.PlacementBindRequest) (server.PlacementBinding, error) { @@ -991,8 +991,8 @@ func TestADR_0291_ACPBindAndLoadAssertConfiguredPlacement(t *testing.T) { if _, err := a.Handle(context.Background(), "session/load", badLoad, true); err == nil || !strings.Contains(err.Error(), "cwd does not match") { t.Fatalf("session/load cwd mismatch = %v", err) } - if got := binds.Load(); got != 3 { // startup validation + both session/new calls - t.Fatalf("Bind calls = %d, want 3", got) + if got := binds.Load(); got != 2 { // both session/new calls + t.Fatalf("Bind calls = %d, want 2", got) } if got := reattaches.Load(); got != 4 { // create/load discovery plus both load attempts t.Fatalf("Reattach calls = %d, want 4", got) diff --git a/internal/adapter/microvm/client.go b/internal/adapter/microvm/client.go index 41e8aeff1..484b43f83 100644 --- a/internal/adapter/microvm/client.go +++ b/internal/adapter/microvm/client.go @@ -16,6 +16,7 @@ import ( "io/fs" "net" "net/url" + pathpkg "path" "strconv" "strings" "sync" @@ -228,7 +229,8 @@ func (c *Client) provisionPlacement(ctx context.Context, principal *session.Prin func (c *Client) placementBinding(ref session.EnvironmentRef, claim binding, closePlacement func() error) server.PlacementBinding { return server.PlacementBinding{ Ref: ref, Environment: c.environment(ref, claim), Close: closePlacement, - Metadata: server.PlacementMetadata{Kind: string(kindMicroVM), Label: "Local microVM", Revision: ref.Revision}, + CompositionRoot: c.sourceCheckout, + Metadata: server.PlacementMetadata{Kind: string(kindMicroVM), Label: "Local microVM", Revision: ref.Revision}, } } @@ -663,7 +665,16 @@ type workspace struct { ledger map[string]tool.FileVersion } -func (*workspace) Root() string { return "/workspace" } +func (*workspace) Root() string { return publicGuestRoot } + +// AuthorityResourcePath projects the same confined guest path used by workspace RPCs. +func (*workspace) AuthorityResourcePath(p string) (target, root string, err error) { + cleaned := pathpkg.Clean(p) + if p == "" || pathpkg.IsAbs(p) || cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.IndexByte(p, 0) >= 0 { + return "", publicGuestRoot, errors.New("microvm workspace path escapes the guest root") + } + return pathpkg.Join(publicGuestRoot, cleaned), publicGuestRoot, nil +} type workspaceRequest struct { Operation string `json:"operation"` diff --git a/internal/adapter/microvm/client_profile_test.go b/internal/adapter/microvm/client_profile_test.go index 1ce99c07d..86cf0a780 100644 --- a/internal/adapter/microvm/client_profile_test.go +++ b/internal/adapter/microvm/client_profile_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/stacklok/mecatl/engine/tool" "github.com/stacklok/mecatl/internal/adapter/server" ) @@ -54,6 +55,46 @@ func TestPlacementProviderUsesOpaqueDaemonProfileAndExactRef(t *testing.T) { if placed.Metadata.Label != "Local microVM" || strings.Contains(placed.Metadata.Label, "/private/") { t.Fatalf("public placement metadata = %+v", placed.Metadata) } + if placed.CompositionRoot != "/source" || placed.Environment.Workspace().Root() != "/workspace" { + t.Fatalf("host/guest roots = %q/%q", placed.CompositionRoot, placed.Environment.Workspace().Root()) + } +} + +func TestMicroVMWorkspaceProvidesConfinedAuthorityResourceIdentity(t *testing.T) { + var resolver tool.AuthorityResourceResolver = &workspace{} + target, root, err := resolver.AuthorityResourcePath("proof/file.txt") + if err != nil { + t.Fatal(err) + } + if target != "/workspace/proof/file.txt" || root != "/workspace" { + t.Fatalf("authority resource = %q, %q", target, root) + } + for _, escaped := range []string{"", "/etc/passwd", "../outside", "dir/../../outside", "bad\x00path"} { + if _, _, err := resolver.AuthorityResourcePath(escaped); err == nil { + t.Errorf("AuthorityResourcePath(%q) accepted an invalid path", escaped) + } + } +} + +func TestNoFSBindBypassesMicroVMReadiness(t *testing.T) { + readinessCalls := 0 + client, err := NewPlacementProvider("unix:///run/unused-microvmd.sock", "/source", "microvm-local", "deployment", func(context.Context) error { + readinessCalls++ + return nil + }) + if err != nil { + t.Fatal(err) + } + binding, err := client.Bind(context.Background(), server.PlacementBindRequest{Selector: server.NoFSPlacement(), Scope: "deployment", Operation: server.PlacementOperationCreate}) + if err != nil { + t.Fatal(err) + } + if readinessCalls != 0 { + t.Fatalf("no-fs readiness calls = %d, want 0", readinessCalls) + } + if binding.CompositionRoot != "" || binding.Environment.Workspace().Root() != "" { + t.Fatalf("no-fs host/guest roots = %q/%q, want empty", binding.CompositionRoot, binding.Environment.Workspace().Root()) + } } func mustJSON(t *testing.T, value any) []byte { diff --git a/internal/adapter/microvmmanager/bootstrap.go b/internal/adapter/microvmmanager/bootstrap.go index daaf05aba..38428180b 100644 --- a/internal/adapter/microvmmanager/bootstrap.go +++ b/internal/adapter/microvmmanager/bootstrap.go @@ -53,7 +53,6 @@ func ReadyRequestFromDefaults(encoded, version string, egress ...GuestEgressSele RequiredAttestations: requiredMicroVMAttestations(), GuestEgressMode: GuestEgressPermissive, Resources: defaultMicroVMResources(), }, - PreserveExistingGuestEgress: len(egress) == 0, } if err := applyGuestEgress(&request, egress); err != nil { return ReadyRequest{}, err diff --git a/internal/adapter/microvmmanager/default_operations.go b/internal/adapter/microvmmanager/default_operations.go index fd9245fd1..5d685f0fc 100644 --- a/internal/adapter/microvmmanager/default_operations.go +++ b/internal/adapter/microvmmanager/default_operations.go @@ -23,6 +23,7 @@ import ( "syscall" "time" + "github.com/stacklok/mecatl/internal/adapter/envscrub" microvmclient "github.com/stacklok/mecatl/internal/adapter/microvm" ) @@ -30,6 +31,11 @@ const maxReleaseBundleBytes = 2 << 30 const managedProcessSchema = "mecatl-microvmd-process/v1" +func scrubbedCommand(cmd *exec.Cmd) *exec.Cmd { + cmd.Env = envscrub.Scrub(os.Environ()) + return cmd +} + type managedProcessRecord struct { Schema string `json:"schema"` PID int `json:"pid"` @@ -85,11 +91,11 @@ func (o *DefaultOperations) Preflight(ctx context.Context, _ Paths) error { } return file.Close() } - version, err := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output() + version, err := scrubbedCommand(exec.CommandContext(ctx, "sw_vers", "-productVersion")).Output() if err != nil || darwinMajor(strings.TrimSpace(string(version))) < 15 { return errors.New("microVMs require Apple Silicon macOS 15 or newer") } - output, err := exec.CommandContext(ctx, "sysctl", "-n", "kern.hv_support").Output() + output, err := scrubbedCommand(exec.CommandContext(ctx, "sysctl", "-n", "kern.hv_support")).Output() if err != nil || strings.TrimSpace(string(output)) != "1" { return errors.New("hypervisor.framework is unavailable to the current user") } @@ -309,7 +315,7 @@ func (o *DefaultOperations) Install(ctx context.Context, manifest, installRoot s if err := atomicWriteMode(installer, installerData, 0o700); err != nil { return InstalledArtifacts{}, fmt.Errorf("materialize verified installer: %w", err) } - cmd := exec.CommandContext(ctx, installer, manifest, installRoot) // #nosec G204 -- executable and each argument are distinct verified paths. + cmd := scrubbedCommand(exec.CommandContext(ctx, installer, manifest, installRoot)) // #nosec G204 -- executable and each argument are distinct verified paths. if output, err := cmd.CombinedOutput(); err != nil { return InstalledArtifacts{}, fmt.Errorf("release installer: %w: %s", err, strings.TrimSpace(string(output))) } @@ -446,7 +452,7 @@ func (*DefaultOperations) Start(_ context.Context, paths Paths) error { return err } args := []string{"--state-dir", paths.StateDir, "--socket", paths.Socket, "--config", paths.ConfigFile} - cmd := exec.Command(paths.DaemonBinary, args...) // #nosec G204 -- absolute manager-owned executable and separate fixed arguments. + cmd := scrubbedCommand(exec.Command(paths.DaemonBinary, args...)) // #nosec G204 -- absolute manager-owned executable and separate fixed arguments. cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} cmd.Stdout, cmd.Stderr = log, log if err := cmd.Start(); err != nil { @@ -526,7 +532,7 @@ func (o *DefaultOperations) Doctor(ctx context.Context, paths Paths) (string, er if !serving.Equal(expected) { return "", errors.New("serving microvmd identity does not match installed release, binary, policy, profiles, config, and socket") } - cmd := exec.CommandContext(ctx, paths.DaemonBinary, "--doctor", "--state-dir", paths.StateDir, "--socket", paths.Socket, "--config", paths.ConfigFile) // #nosec G204 -- exact identity was authenticated above; arguments are fixed manager paths. + cmd := scrubbedCommand(exec.CommandContext(ctx, paths.DaemonBinary, "--doctor", "--state-dir", paths.StateDir, "--socket", paths.Socket, "--config", paths.ConfigFile)) // #nosec G204 -- exact identity was authenticated above; arguments are fixed manager paths. output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("microvmd readiness checks: %w: %s", err, strings.TrimSpace(string(output))) @@ -623,7 +629,7 @@ func validateManagedProcess(record managedProcessRecord, paths Paths) error { // return nil } if runtime.GOOS == "darwin" { - output, err := exec.Command("ps", "-p", strconv.Itoa(record.PID), "-o", "command=").Output() + output, err := scrubbedCommand(exec.Command("ps", "-p", strconv.Itoa(record.PID), "-o", "command=")).Output() if err != nil { return fmt.Errorf("read managed daemon process identity: %w", err) } @@ -657,7 +663,7 @@ func processStartIdentity(pid int) (string, error) { return strings.TrimSpace(string(bootID)) + ":" + fields[19], nil } if runtime.GOOS == "darwin" { - output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "lstart=").Output() + output, err := scrubbedCommand(exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "lstart=")).Output() if err != nil { return "", err } diff --git a/internal/adapter/microvmmanager/default_operations_test.go b/internal/adapter/microvmmanager/default_operations_test.go index c4aec2d72..b936fa1d1 100644 --- a/internal/adapter/microvmmanager/default_operations_test.go +++ b/internal/adapter/microvmmanager/default_operations_test.go @@ -12,9 +12,12 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "runtime" + "strconv" "strings" + "syscall" "testing" "time" ) @@ -65,7 +68,8 @@ func TestMicroVMUserBootstrap_Scenario14_PIDReuseWithSameBinaryIsRejected(t *tes } func TestDefaultOperationsWaitSocketRejectsStaleSocket(t *testing.T) { - path := filepath.Join(t.TempDir(), "microvmd.sock") + t.Chdir(t.TempDir()) + path := "microvmd.sock" listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) if err != nil { t.Fatal(err) @@ -108,6 +112,129 @@ func TestDefaultOperationsWaitSocketRejectsStaleSocket(t *testing.T) { } } +func TestMicroVMSubprocessEnvironmentScrubsSecretsAndPreservesRuntimeValues(t *testing.T) { + secrets := map[string]string{ + "OPENROUTER_API_KEY": "provider-canary", + "GH_TOKEN": "github-canary", + "AWS_SECRET_ACCESS_KEY": "aws-canary", + "AZURE_CLIENT_SECRET": "azure-canary", + "DEPLOYMENT_TOKEN": "shaped-canary", + } + for name, value := range secrets { + t.Setenv(name, value) + } + kept := map[string]string{ + "HOME": "/home/microvm-canary", + "XDG_DATA_HOME": "/var/lib/microvm-canary", + "SSL_CERT_FILE": "/etc/ssl/microvm-canary.pem", + "GOTOOLCHAIN": "local", + } + for name, value := range kept { + t.Setenv(name, value) + } + output, err := scrubbedCommand(exec.Command("env")).Output() + if err != nil { + t.Fatal(err) + } + environment := string(output) + for name, value := range secrets { + if strings.Contains(environment, name+"=") || strings.Contains(environment, value) { + t.Fatalf("scrubbed subprocess leaked %s", name) + } + } + for name, value := range kept { + if !strings.Contains(environment, name+"="+value+"\n") { + t.Fatalf("scrubbed subprocess dropped %s", name) + } + } + if !strings.Contains(environment, "PATH=") { + t.Fatal("scrubbed subprocess dropped PATH") + } +} + +func TestDefaultOperationsStartScrubsDaemonEnvironmentAndDetachesFromRequest(t *testing.T) { + root := t.TempDir() + paths := testPaths(root) + if err := preparePaths(paths); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(paths.DaemonBinary), 0o700); err != nil { + t.Fatal(err) + } + capture := filepath.Join(root, "daemon.env") + script := fmt.Sprintf("#!/bin/sh\n/usr/bin/env > %q\nwhile :; do sleep 1; done\n", capture) + if err := os.WriteFile(paths.DaemonBinary, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + secrets := map[string]string{ + "OPENROUTER_API_KEY": "provider-start-canary", + "GH_TOKEN": "github-start-canary", + "DEPLOYMENT_TOKEN": "shaped-start-canary", + } + for name, value := range secrets { + t.Setenv(name, value) + } + kept := map[string]string{ + "HOME": filepath.Join(root, "home"), + "XDG_DATA_HOME": filepath.Join(root, "data-home"), + "XDG_RUNTIME_DIR": filepath.Join(root, "runtime-home"), + "SSL_CERT_FILE": filepath.Join(root, "cert.pem"), + } + for name, value := range kept { + t.Setenv(name, value) + } + + ctx, cancel := context.WithCancel(t.Context()) + if err := (&DefaultOperations{}).Start(ctx, paths); err != nil { + cancel() + t.Fatal(err) + } + cancel() // the detached daemon must outlive the initiating readiness request. + pidData, err := os.ReadFile(filepath.Join(paths.StateDir, "microvmd.pid")) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = syscall.Kill(-pid, syscall.SIGKILL) + _ = os.Remove(paths.RuntimeDir) + }) + + deadline := time.Now().Add(2 * time.Second) + var environment string + for time.Now().Before(deadline) { + data, readErr := os.ReadFile(capture) + if readErr == nil && len(data) > 0 { + environment = string(data) + break + } + time.Sleep(10 * time.Millisecond) + } + if environment == "" { + t.Fatal("detached daemon did not capture its launch environment") + } + if err := syscall.Kill(pid, 0); err != nil { + t.Fatalf("daemon did not survive initiating-request cancellation: %v", err) + } + for name, value := range secrets { + if strings.Contains(environment, name+"=") || strings.Contains(environment, value) { + t.Fatalf("daemon launch environment leaked %s", name) + } + } + for name, value := range kept { + if !strings.Contains(environment, name+"="+value+"\n") { + t.Fatalf("daemon launch environment dropped %s", name) + } + } + if !strings.Contains(environment, "PATH=") { + t.Fatal("daemon launch environment dropped PATH") + } +} + func TestMicroVMUserBootstrap_Scenario7_DownloadVerifiesBeforeExtraction(t *testing.T) { bundle := releaseBundle(t, "microvm-release-linux-amd64.json", []byte(`{"schema":"mecatl-microvm-release/v2"}`)) srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(bundle) })) diff --git a/internal/adapter/microvmmanager/development_release_microvm_dev.go b/internal/adapter/microvmmanager/development_release_microvm_dev.go index 8bcb590ad..11fbd76b2 100644 --- a/internal/adapter/microvmmanager/development_release_microvm_dev.go +++ b/internal/adapter/microvmmanager/development_release_microvm_dev.go @@ -51,8 +51,11 @@ func ReadyRequestFromDevelopmentDescriptor(path, sourceBuildIdentity string, egr if decoder.Decode(&struct{}{}) != io.EOF { return ReadyRequest{}, errors.New("microVM development release descriptor must contain exactly one JSON object") } - if descriptor.Schema != DevelopmentReleaseSchema || descriptor.Platform != "linux-amd64" || descriptor.SourceBuildIdentity == "" || descriptor.SourceBuildIdentity != sourceBuildIdentity || descriptor.PolicyRevision == "" { - return ReadyRequest{}, errors.New("microVM development release descriptor identity does not match this source build") + if descriptor.Schema != DevelopmentReleaseSchema || descriptor.Platform != "linux-amd64" || descriptor.SourceBuildIdentity == "" || descriptor.PolicyRevision == "" { + return ReadyRequest{}, errors.New("microVM development release descriptor identity is invalid") + } + if descriptor.SourceBuildIdentity != sourceBuildIdentity { + return ReadyRequest{}, errors.New("microVM development release descriptor does not match this source build; after source changes, rerun task microvm:dev:prepare and task microvm:dev:build") } if !lowerSHA256(descriptor.BundleSHA256) || !strings.HasPrefix(descriptor.PublicKeyIdentity, "sha256:") || !lowerSHA256(strings.TrimPrefix(descriptor.PublicKeyIdentity, "sha256:")) { return ReadyRequest{}, errors.New("microVM development release descriptor contains an invalid digest identity") @@ -91,7 +94,6 @@ func ReadyRequestFromDevelopmentDescriptor(path, sourceBuildIdentity string, egr PolicyRevision: descriptor.PolicyRevision, PublicKeyIdentity: descriptor.PublicKeyIdentity, publicKey: key, RequiredAttestations: requiredMicroVMAttestations(), GuestEgressMode: GuestEgressPermissive, Resources: defaultMicroVMResources(), }, - PreserveExistingGuestEgress: len(egress) == 0, } if err := applyGuestEgress(&request, egress); err != nil { _ = bundle.Close() diff --git a/internal/adapter/microvmmanager/development_release_microvm_dev_test.go b/internal/adapter/microvmmanager/development_release_microvm_dev_test.go index e6e3f3bbe..0a315236b 100644 --- a/internal/adapter/microvmmanager/development_release_microvm_dev_test.go +++ b/internal/adapter/microvmmanager/development_release_microvm_dev_test.go @@ -45,6 +45,11 @@ func TestDevelopmentReleaseDescriptorIsStrictAndLocal(t *testing.T) { t.Fatalf("development request = %#v", request) } + if _, err := ReadyRequestFromDevelopmentDescriptor(descriptor, "changed-source"); err == nil || + !strings.Contains(err.Error(), "task microvm:dev:prepare") || !strings.Contains(err.Error(), "task microvm:dev:build") { + t.Fatalf("source-build mismatch error = %v, want actionable rebuild commands", err) + } + bundleLink := filepath.Join(root, "release-link.tar.gz") if err := os.Symlink(bundle, bundleLink); err != nil { t.Fatal(err) diff --git a/internal/adapter/microvmmanager/egress.go b/internal/adapter/microvmmanager/egress.go index 3f6f6e707..1f1ce960e 100644 --- a/internal/adapter/microvmmanager/egress.go +++ b/internal/adapter/microvmmanager/egress.go @@ -6,7 +6,6 @@ import ( "flag" "fmt" "io" - "io/fs" "net" "os" "strconv" @@ -24,8 +23,8 @@ const ( GuestEgressAllowlist = "allowlist" ) -// GuestEgressSelection is the host-operator CLI projection of guest egress. -// It is intentionally not part of any API or project configuration surface. +// GuestEgressSelection is the host-operator projection of guest egress. It is +// intentionally not part of any public session API or project configuration surface. type GuestEgressSelection struct { Mode string Allow []EgressRule @@ -36,6 +35,26 @@ func NewGuestEgressSelection() GuestEgressSelection { return GuestEgressSelection{Mode: GuestEgressPermissive} } +// ParseGuestEgressSelection parses settings values with the same destination +// grammar used by the command-line flags. +func ParseGuestEgressSelection(mode string, allow []string) (GuestEgressSelection, error) { + selection := GuestEgressSelection{Mode: mode} + if selection.Mode == "" { + selection.Mode = GuestEgressPermissive + } + for _, value := range allow { + rule, err := parseEgressRule(value) + if err != nil { + return GuestEgressSelection{}, err + } + selection.Allow = append(selection.Allow, rule) + } + if err := selection.Validate(); err != nil { + return GuestEgressSelection{}, err + } + return selection, nil +} + // ModeValue returns a flag.Value for --microvm-guest-egress. func (s *GuestEgressSelection) ModeValue() flag.Value { return guestEgressModeValue{s} } @@ -190,19 +209,6 @@ func GuestEgressSummary(selection GuestEgressSelection) string { } } -func preserveGuestEgressPolicy(path string, policy *Policy) error { - selection, err := readGuestEgressPolicy(path) - if errors.Is(err, fs.ErrNotExist) { - return nil - } - if err != nil { - return fmt.Errorf("refusing to replace existing microvmd guest egress policy: %w", err) - } - policy.GuestEgressMode = selection.Mode - policy.GuestAllow = append([]EgressRule(nil), selection.Allow...) - return nil -} - func readGuestEgressPolicy(path string) (GuestEgressSelection, error) { info, err := os.Lstat(path) if err != nil { diff --git a/internal/adapter/microvmmanager/ensure_ready_test.go b/internal/adapter/microvmmanager/ensure_ready_test.go index 27f58c9b2..76dd7c7d3 100644 --- a/internal/adapter/microvmmanager/ensure_ready_test.go +++ b/internal/adapter/microvmmanager/ensure_ready_test.go @@ -48,7 +48,7 @@ func TestMicroVMRedesign_Scenario1_EnsureReadyConvergesUnderManagerLock(t *testi } } -func TestEnsureReadyRefreshesCacheOnlyWhenReleaseAdmissionChanges(t *testing.T) { +func TestEnsureReadyPreservesExistingAdmissionStateOnMismatch(t *testing.T) { root := t.TempDir() paths := testPaths(root) if err := preparePaths(paths); err != nil { @@ -66,26 +66,20 @@ func TestEnsureReadyRefreshesCacheOnlyWhenReleaseAdmissionChanges(t *testing.T) t.Fatal(err) } request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("c", 64)}, Policy: testPolicy(root)} - manager := New(paths, &fakeOps{}) - if _, err := manager.EnsureReady(context.Background(), request); err != nil { - t.Fatalf("converge changed release admission: %v", err) - } - if _, err := os.Lstat(cacheMarker); !os.IsNotExist(err) { - t.Fatalf("stale cache marker survived admission change: %v", err) - } - - steadyMarker := filepath.Join(paths.DataDir, "cache", "current-policy-entry") - if err := os.MkdirAll(filepath.Dir(steadyMarker), 0o700); err != nil { - t.Fatal(err) + ops := &fakeOps{} + if _, err := New(paths, ops).EnsureReady(context.Background(), request); err == nil || !strings.Contains(err.Error(), "configuration is incompatible") { + t.Fatalf("admission mismatch error = %v", err) } - if err := os.WriteFile(steadyMarker, []byte("current"), 0o600); err != nil { - t.Fatal(err) + if got, err := os.ReadFile(paths.ConfigFile); err != nil || string(got) != string(oldConfig) { + t.Fatalf("existing config changed: %q, err=%v", got, err) } - if _, err := manager.EnsureReady(context.Background(), request); err != nil { - t.Fatalf("reuse current release admission: %v", err) + if got, err := os.ReadFile(cacheMarker); err != nil || string(got) != "stale" { + t.Fatalf("existing cache changed: %q, err=%v", got, err) } - if _, err := os.Stat(steadyMarker); err != nil { - t.Fatalf("current-policy cache was unnecessarily removed: %v", err) + for _, forbidden := range []string{"download", "verify", "install", "start", "stop"} { + if contains(ops.calls, forbidden) { + t.Fatalf("mismatch called %s: %v", forbidden, ops.calls) + } } } diff --git a/internal/adapter/microvmmanager/manager.go b/internal/adapter/microvmmanager/manager.go index 83fa9affb..484c6e571 100644 --- a/internal/adapter/microvmmanager/manager.go +++ b/internal/adapter/microvmmanager/manager.go @@ -14,6 +14,7 @@ import ( "io/fs" "os" "path/filepath" + "reflect" "sort" "strconv" "strings" @@ -193,10 +194,6 @@ type EgressRule struct { type ReadyRequest struct { Release Release Policy Policy - // PreserveExistingGuestEgress keeps an existing validated owner-only daemon - // policy when the operator omitted both guest-egress flags. With no existing - // config, Policy's permissive default applies. - PreserveExistingGuestEgress bool } // Artifact is one installer-projected verified admission artifact. @@ -283,78 +280,82 @@ func (m *Manager) EnsureReady(ctx context.Context, request ReadyRequest) (string } defer unlock() if len(request.Policy.publicKey) != 0 { - keyPath := filepath.Join(m.paths.DataDir, "trust", "development-"+strings.TrimPrefix(request.Policy.PublicKeyIdentity, "sha256:")+".pub") if bytesSHA256(request.Policy.publicKey) != request.Policy.PublicKeyIdentity { return "", readinessError(StagePrepare, errors.New("local microVM release public-key identity mismatch")) } - if err := atomicWrite(keyPath, request.Policy.publicKey); err != nil { - return "", readinessError(StagePrepare, fmt.Errorf("materialize local microVM release public key: %w", err)) - } - request.Policy.PublicKey = keyPath + request.Policy.PublicKey = filepath.Join(m.paths.DataDir, "trust", "development-"+strings.TrimPrefix(request.Policy.PublicKeyIdentity, "sha256:")+".pub") } if err := validateReleasePolicy(request.Release, request.Policy); err != nil { return "", readinessError(StagePrepare, err) } - if request.PreserveExistingGuestEgress { - if err := preserveGuestEgressPolicy(m.paths.ConfigFile, &request.Policy); err != nil { - return "", readinessError(StagePrepare, err) - } - } ReportReadinessStage(ctx, StagePreflight) if err := m.ops.Preflight(ctx, m.paths); err != nil { return "", readinessError(StagePreflight, fmt.Errorf("microVM preflight: %w", err)) } - ReportReadinessStage(ctx, StageDownload) - manifest, err := m.ops.Download(ctx, request.Release, filepath.Join(m.paths.DataDir, "download")) - if err != nil { - return "", readinessError(StageDownload, fmt.Errorf("download release bundle: %w", err)) - } - ReportReadinessStage(ctx, StageVerify) - if err := m.ops.Verify(ctx, request.Release, manifest); err != nil { - return "", readinessError(StageVerify, fmt.Errorf("verify release bundle: %w", err)) - } - ReportReadinessStage(ctx, StageInstall) - installed, err := m.ops.Install(ctx, manifest, filepath.Join(m.paths.DataDir, "verified")) - if err != nil { - return "", readinessError(StageInstall, fmt.Errorf("install verified release bundle: %w", err)) - } - if err := validateInstalled(installed, m.paths.DataDir); err != nil { - return "", readinessError(StageInstall, err) - } - cacheRefresh, err := verifiedArtifactCacheNeedsRefresh(m.paths.ConfigFile, request.Release, request.Policy) - if err != nil { - return "", readinessError(StageInstall, err) - } - if err := writeDaemonConfig(m.paths.ConfigFile, m.paths, request.Release, request.Policy, installed); err != nil { - return "", readinessError(StageInstall, err) - } - ReportReadinessStage(ctx, StageDaemon) + running, err := m.ops.Running(ctx, m.paths) if err != nil { - return "", readinessError(StageDaemon, err) + ReportReadinessStage(ctx, StageDaemon) + return "", readinessError(StageDaemon, fmt.Errorf("inspect existing microvmd runtime: %w", err)) } - if running { - expected, expectedErr := expectedDaemonInfo(m.paths) - if expectedErr != nil { - return "", readinessError(StageDaemon, expectedErr) + configured := regularFile(m.paths.ConfigFile) + if configured || running { + ReportReadinessStage(ctx, StageDaemon) + if !configured { + return "", readinessError(StageDaemon, errors.New("microvmd is serving without the manager-owned configuration; existing runtime was left unchanged")) } - serving, infoErr := m.ops.DaemonInfo(ctx, m.paths) - if infoErr != nil || !serving.Equal(expected) { - if stopErr := m.ops.Stop(ctx, m.paths); stopErr != nil { - return "", readinessError(StageDaemon, fmt.Errorf("serving microvmd is incompatible and safe managed restart failed; stop the exact user service with your service manager, then retry: %w", stopErr)) - } - running = false + if err := configuredRequestCompatible(m.paths.ConfigFile, request.Release, request.Policy); err != nil { + return "", readinessError(StageDaemon, fmt.Errorf("existing microvmd configuration is incompatible with the requested release or policy; existing runtime was left unchanged: %w", err)) } - } - if cacheRefresh { - if running { - return "", readinessError(StageDaemon, errors.New("serving microvmd reported current identity while its prior artifact admission differed")) + if !running { + return "", readinessError(StageDaemon, errors.New("configured microvmd is not serving; refusing to replace or restart existing repository runtime")) + } + expected, err := expectedDaemonInfo(m.paths) + if err != nil { + return "", readinessError(StageDaemon, fmt.Errorf("validate installed microvmd identity without changing it: %w", err)) + } + serving, err := m.ops.DaemonInfo(ctx, m.paths) + if err != nil { + return "", readinessError(StageDaemon, fmt.Errorf("query serving microvmd identity; existing runtime was left unchanged: %w", err)) } - if err := resetVerifiedArtifactCache(m.paths.DataDir); err != nil { + if !serving.Equal(expected) { + return "", readinessError(StageDaemon, errors.New("serving microvmd identity is incompatible with its installed release, policy, process, or runtime state; existing runtime was left unchanged")) + } + } else { + fresh, err := managerRuntimeFresh(m.paths) + if err != nil { + return "", readinessError(StageDaemon, err) + } + if !fresh { + ReportReadinessStage(ctx, StageDaemon) + return "", readinessError(StageDaemon, errors.New("unconfigured microvmd has existing repository runtime state; refusing to overwrite or delete it")) + } + ReportReadinessStage(ctx, StageDownload) + manifest, err := m.ops.Download(ctx, request.Release, filepath.Join(m.paths.DataDir, "download")) + if err != nil { + return "", readinessError(StageDownload, fmt.Errorf("download release bundle: %w", err)) + } + ReportReadinessStage(ctx, StageVerify) + if err := m.ops.Verify(ctx, request.Release, manifest); err != nil { + return "", readinessError(StageVerify, fmt.Errorf("verify release bundle: %w", err)) + } + ReportReadinessStage(ctx, StageInstall) + installed, err := m.ops.Install(ctx, manifest, filepath.Join(m.paths.DataDir, "verified")) + if err != nil { + return "", readinessError(StageInstall, fmt.Errorf("install verified release bundle: %w", err)) + } + if err := validateInstalled(installed, m.paths.DataDir); err != nil { return "", readinessError(StageInstall, err) } - } - if !running { + if len(request.Policy.publicKey) != 0 { + if err := atomicWrite(request.Policy.PublicKey, request.Policy.publicKey); err != nil { + return "", readinessError(StageInstall, fmt.Errorf("materialize local microVM release public key: %w", err)) + } + } + if err := writeDaemonConfig(m.paths.ConfigFile, m.paths, request.Release, request.Policy, installed); err != nil { + return "", readinessError(StageInstall, err) + } + ReportReadinessStage(ctx, StageDaemon) if err := m.ops.Start(ctx, m.paths); err != nil { return "", readinessError(StageDaemon, fmt.Errorf("start microvmd: %w", err)) } @@ -381,6 +382,88 @@ func readinessError(stage ReadinessStage, err error) error { return fmt.Errorf("microvm-local readiness failed during %s: %w; correct the reported problem and retry ordinary use", stage, err) } +func configuredRequestCompatible(path string, release Release, policy Policy) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read existing microvmd config: %w", err) + } + var current map[string]any + if err := json.Unmarshal(data, ¤t); err != nil { + return fmt.Errorf("decode existing microvmd config: %w", err) + } + desired := map[string]any{ + "release_identity": "sha256:" + release.SHA256, + "policy_revision": policy.PolicyRevision, + "required_attestations": policy.RequiredAttestations, + "guest_egress": map[string]any{"Mode": policy.GuestEgressMode, "Allow": policy.GuestAllow}, + "admission": policy.Admission, + "profiles": map[string]any{Alias: map[string]any{"resources": policy.Resources}}, + } + if policy.CertificateIdentity != "" { + desired["certificate_identity"], desired["oidc_issuer"] = policy.CertificateIdentity, policy.OIDCIssuer + } else { + desired["public_key"], desired["public_key_identity"] = policy.PublicKey, policy.PublicKeyIdentity + } + normalized, err := json.Marshal(desired) + if err != nil { + return err + } + if err := json.Unmarshal(normalized, &desired); err != nil { + return err + } + for key, want := range desired { + if got, ok := current[key]; !ok || !reflect.DeepEqual(got, want) { + return fmt.Errorf("configured %s differs from the requested value", key) + } + } + if policy.CertificateIdentity != "" { + for _, key := range []string{"public_key", "public_key_identity"} { + if _, present := current[key]; present { + return fmt.Errorf("configured %s conflicts with requested trust policy", key) + } + } + } else { + for _, key := range []string{"certificate_identity", "oidc_issuer"} { + if _, present := current[key]; present { + return fmt.Errorf("configured %s conflicts with requested trust policy", key) + } + } + } + return nil +} + +func managerRuntimeFresh(paths Paths) (bool, error) { + for _, path := range []string{ + paths.DaemonBinary, + filepath.Join(paths.DataDir, "verified"), + filepath.Join(paths.DataDir, "cache"), + filepath.Join(paths.StateDir, "microvmd.pid"), + filepath.Join(paths.StateDir, "microvmd.process.json"), + paths.ConfigFile, + paths.Socket, + } { + if _, err := os.Lstat(path); err == nil { + return false, nil + } else if !errors.Is(err, fs.ErrNotExist) { + return false, fmt.Errorf("inspect existing microvmd runtime state: %w", err) + } + } + entries, err := os.ReadDir(paths.StateDir) + if err != nil { + return false, fmt.Errorf("inspect microvmd state directory: %w", err) + } + for _, entry := range entries { + if entry.Name() != "manager.lock" { + return false, nil + } + } + entries, err = os.ReadDir(paths.RuntimeDir) + if err != nil { + return false, fmt.Errorf("inspect microvmd runtime directory: %w", err) + } + return len(entries) == 0, nil +} + // Generation is one exact owner-scoped daemon generation. type Generation struct { SessionID, EnvironmentID, Ref, WorktreePath, State, Error string @@ -421,19 +504,35 @@ func (m *Manager) Status(ctx context.Context, requests ...StatusRequest) (Status configured := regularFile(m.paths.ConfigFile) running, err := m.ops.Running(ctx, m.paths) status := Status{Configured: configured, Running: running, Socket: m.paths.Socket} - if configured { - selection, policyErr := readGuestEgressPolicy(m.paths.ConfigFile) - if policyErr != nil { - return Status{}, policyErr + if err != nil { + return status, fmt.Errorf("inspect microVM daemon state: %w", err) + } + if !configured { + if running { + return status, errors.New("microvmd is serving without manager-owned configuration") } - status.GuestEgress = GuestEgressSummary(selection) + return status, nil + } + selection, err := readGuestEgressPolicy(m.paths.ConfigFile) + if err != nil { + return status, fmt.Errorf("inspect configured microVM policy: %w", err) } - if err != nil || !running || m.lifecycle == nil { - return status, err + status.GuestEgress = GuestEgressSummary(selection) + if _, err := expectedDaemonInfo(m.paths); err != nil { + return status, fmt.Errorf("inspect configured microVM identity: %w", err) + } + if !running { + return status, errors.New("configured microvmd is not serving") + } + if _, err := m.ops.Doctor(ctx, m.paths); err != nil { + return status, fmt.Errorf("inspect serving microVM health and identity: %w", err) + } + if m.lifecycle == nil { + return status, nil } page, err := m.lifecycle.Inventory(ctx, "local", microvmclient.InventoryRequest{PageSize: request.PageSize, Continuation: request.Continuation}) if err != nil { - return Status{}, err + return status, fmt.Errorf("inspect microVM inventory: %w", err) } status.Continuation = page.Continuation status.Generations = make([]Generation, len(page.Entries)) @@ -447,10 +546,11 @@ func (m *Manager) Status(ctx context.Context, requests ...StatusRequest) (Status // installing, starting, or changing configuration. func (m *Manager) Doctor(ctx context.Context) (string, error) { if m.ops == nil { - return "host preflight: failed: manager operations are not configured\nbackend: not checked\n", errors.New("microVM manager operations are not configured") + return "scope: current OS principal on this execution host\nhost preflight: failed: manager operations are not configured\nbackend: not checked\n", errors.New("microVM manager operations are not configured") } var report strings.Builder + _, _ = fmt.Fprintln(&report, "scope: current OS principal on this execution host") var failures []error preflightErr := m.ops.Preflight(ctx, m.paths) if preflightErr != nil { @@ -463,16 +563,25 @@ func (m *Manager) Doctor(ctx context.Context) (string, error) { configured := regularFile(m.paths.ConfigFile) running, runningErr := m.ops.Running(ctx, m.paths) switch { - case !configured: - _, _ = fmt.Fprintln(&report, "backend: not configured") - failures = append(failures, errors.New("microVM backend is not configured")) case runningErr != nil: _, _ = fmt.Fprintf(&report, "backend: unhealthy: inspect daemon state: %v\n", runningErr) failures = append(failures, fmt.Errorf("inspect microVM daemon state: %w", runningErr)) - case !running: - _, _ = fmt.Fprintln(&report, "backend: configured; daemon not running") - failures = append(failures, errors.New("microVM daemon is not running")) - default: + case !configured && running: + _, _ = fmt.Fprintln(&report, "backend: unhealthy: daemon is serving without manager-owned configuration") + failures = append(failures, errors.New("orphaned microVM daemon runtime")) + case !configured: + _, _ = fmt.Fprintln(&report, "backend: ready to configure on first use") + case configured: + if _, err := expectedDaemonInfo(m.paths); err != nil { + _, _ = fmt.Fprintf(&report, "backend: unhealthy: configured state is invalid: %v\n", err) + failures = append(failures, fmt.Errorf("microVM configured state is invalid: %w", err)) + break + } + if !running { + _, _ = fmt.Fprintln(&report, "backend: configured; daemon not running") + failures = append(failures, errors.New("microVM daemon is not running")) + break + } daemonReport, err := m.ops.Doctor(ctx, m.paths) if err != nil { _, _ = fmt.Fprintf(&report, "backend: unhealthy: %v\n", err) @@ -489,13 +598,18 @@ func (m *Manager) Doctor(ctx context.Context) (string, error) { } if preflightErr != nil { - _, _ = fmt.Fprintln(&report, "next: fix the failed host prerequisite, then select microvm-local to install/start the backend automatically:") + _, _ = fmt.Fprintln(&report, "next: fix the failed host prerequisite, then rerun doctor") + } else if !configured && !running && runningErr == nil { + _, _ = fmt.Fprintln(&report, "next: select microvm-local to configure the backend on first use:") } else if len(failures) > 0 { - _, _ = fmt.Fprintln(&report, "next: host prerequisites passed; select microvm-local to configure or start the backend automatically:") + _, _ = fmt.Fprintln(&report, "next: inspect and repair the existing local daemon state; ordinary use will not replace or restart it automatically") } else { _, _ = fmt.Fprintln(&report, "next: select microvm-local as the deployment default:") } - _, _ = fmt.Fprintln(&report, " mecatui --default-placement microvm-local") + _, _ = fmt.Fprintln(&report, " embedded mecatui operator settings:") + _, _ = fmt.Fprintln(&report, " execution:") + _, _ = fmt.Fprintln(&report, " default_placement: microvm-local") + _, _ = fmt.Fprintln(&report, " guest IPv4 egress defaults to permissive; before first use set execution.microvm.guest_egress.mode to deny-all or allowlist") _, _ = fmt.Fprintln(&report, ` mecated serve --headless --default-placement microvm-local; then POST /v1/sessions with {}`) _, _ = fmt.Fprintln(&report, "doctor is read-only; it never downloads, installs, or starts microvmd") return report.String(), errors.Join(failures...) @@ -658,42 +772,6 @@ func pathWithin(root, path string) bool { return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } -func verifiedArtifactCacheNeedsRefresh(configPath string, release Release, policy Policy) (bool, error) { - data, err := os.ReadFile(configPath) - if errors.Is(err, fs.ErrNotExist) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("read existing microvmd config before artifact-cache reconciliation: %w", err) - } - var current struct { - ReleaseIdentity string `json:"release_identity"` - PolicyRevision string `json:"policy_revision"` - } - if err := json.Unmarshal(data, ¤t); err != nil { - return false, fmt.Errorf("decode existing microvmd config before artifact-cache reconciliation: %w", err) - } - return current.ReleaseIdentity != "sha256:"+release.SHA256 || current.PolicyRevision != policy.PolicyRevision, nil -} - -func resetVerifiedArtifactCache(dataDir string) error { - cache := filepath.Join(dataDir, "cache") - info, err := os.Lstat(cache) - if errors.Is(err, fs.ErrNotExist) { - return nil - } - if err != nil { - return fmt.Errorf("inspect stale verified artifact cache: %w", err) - } - if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return errors.New("refusing to replace stale verified artifact cache with an unsafe file type") - } - if err := os.RemoveAll(cache); err != nil { - return fmt.Errorf("replace stale verified artifact cache: %w", err) - } - return nil -} - func writeDaemonConfig(path string, paths Paths, release Release, policy Policy, installed InstalledArtifacts) error { binaryIdentity, err := fileSHA256(paths.DaemonBinary) if err != nil { diff --git a/internal/adapter/microvmmanager/manager_test.go b/internal/adapter/microvmmanager/manager_test.go index b6f177883..5f86c7010 100644 --- a/internal/adapter/microvmmanager/manager_test.go +++ b/internal/adapter/microvmmanager/manager_test.go @@ -2,6 +2,7 @@ package microvmmanager import ( "context" + "crypto/sha256" "errors" "fmt" "os" @@ -43,10 +44,11 @@ func TestDefaultPathsBoundsDerivedRepositoryNetworkSocket(t *testing.T) { } } -func TestEnsureReadyPreservesOmittedGuestEgressAndExplicitPermissiveResets(t *testing.T) { +func TestEnsureReadyTreatsDesiredGuestEgressAsAuthoritative(t *testing.T) { root := t.TempDir() paths := testPaths(root) - manager := New(paths, &fakeOps{}) + ops := &fakeOps{} + manager := New(paths, ops) request := ReadyRequest{ Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root), @@ -55,53 +57,58 @@ func TestEnsureReadyPreservesOmittedGuestEgressAndExplicitPermissiveResets(t *te if _, err := manager.EnsureReady(t.Context(), request); err != nil { t.Fatal(err) } - - omitted := request - omitted.Policy.GuestEgressMode = GuestEgressPermissive - omitted.PreserveExistingGuestEgress = true - if _, err := manager.EnsureReady(t.Context(), omitted); err != nil { + before, err := os.ReadFile(paths.ConfigFile) + if err != nil { t.Fatal(err) } - selection, err := readGuestEgressPolicy(paths.ConfigFile) - if err != nil || selection.Mode != GuestEgressDenyAll { - t.Fatalf("preserved policy = %#v, err=%v", selection, err) - } - status, err := manager.Status(t.Context()) - if err != nil || status.GuestEgress != GuestEgressDenyAll { - t.Fatalf("status policy = %q, err=%v", status.GuestEgress, err) - } + calls := len(ops.calls) - explicit := omitted - explicit.PreserveExistingGuestEgress = false - if _, err := manager.EnsureReady(t.Context(), explicit); err != nil { + conflict := request + conflict.Policy.GuestEgressMode = GuestEgressPermissive + if _, err := manager.EnsureReady(t.Context(), conflict); err == nil || !strings.Contains(err.Error(), "configuration is incompatible") { + t.Fatalf("conflicting policy error = %v", err) + } + after, err := os.ReadFile(paths.ConfigFile) + if err != nil { t.Fatal(err) } - selection, err = readGuestEgressPolicy(paths.ConfigFile) - if err != nil || selection.Mode != GuestEgressPermissive { - t.Fatalf("reset policy = %#v, err=%v", selection, err) + if !reflect.DeepEqual(after, before) { + t.Fatal("conflicting readiness overwrote the active daemon config") + } + for _, call := range ops.calls[calls:] { + if call == "download" || call == "verify" || call == "install" || call == "start" || call == "stop" { + t.Fatalf("conflicting readiness mutated daemon lifecycle: %v", ops.calls[calls:]) + } } } -func TestEnsureReadyRejectsUnsafeExistingPolicyWhenFlagsOmitted(t *testing.T) { +func TestEnsureReadyRejectsCorruptExistingConfigWithoutMutation(t *testing.T) { root := t.TempDir() paths := testPaths(root) if err := os.MkdirAll(filepath.Dir(paths.ConfigFile), 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(paths.ConfigFile, []byte(`{"guest_egress":{"Mode":"allowlist","Allow":[]}}`), 0o600); err != nil { + before := []byte(`{"guest_egress":{"Mode":"allowlist","Allow":[]}}`) + if err := os.WriteFile(paths.ConfigFile, before, 0o600); err != nil { t.Fatal(err) } request := ReadyRequest{ Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, - Policy: testPolicy(root), PreserveExistingGuestEgress: true, + Policy: testPolicy(root), } - ops := &fakeOps{} + ops := &fakeOps{running: true} _, err := New(paths, ops).EnsureReady(t.Context(), request) - if err == nil || !strings.Contains(err.Error(), "refusing to replace existing microvmd guest egress policy") { + if err == nil || !strings.Contains(err.Error(), "configuration is incompatible") { t.Fatalf("error = %v", err) } - if len(ops.calls) != 0 { - t.Fatalf("unsafe policy reached operations: %v", ops.calls) + after, readErr := os.ReadFile(paths.ConfigFile) + if readErr != nil || !reflect.DeepEqual(after, before) { + t.Fatalf("config changed: %q, err=%v", after, readErr) + } + for _, forbidden := range []string{"download", "verify", "install", "start", "stop"} { + if contains(ops.calls, forbidden) { + t.Fatalf("corrupt policy reached %q: %v", forbidden, ops.calls) + } } } @@ -202,23 +209,138 @@ func TestReadinessStageProjectionIsClosedAndUnknownIsSilent(t *testing.T) { } } -func TestMicroVMRedesign_EnsureReadyRestartsOnlyIncompatibleDaemon(t *testing.T) { +func TestEnsureReadyRefusesUnsafeExistingRuntimeBranchesWithoutMutation(t *testing.T) { + t.Run("configured but stopped", func(t *testing.T) { + root := t.TempDir() + paths := testPaths(root) + ops := &fakeOps{} + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} + if _, err := New(paths, ops).EnsureReady(t.Context(), request); err != nil { + t.Fatal(err) + } + ops.running = false + ops.calls = nil + _, err := New(paths, ops).EnsureReady(t.Context(), request) + if err == nil || !strings.Contains(err.Error(), "configured microvmd is not serving") { + t.Fatalf("stopped configured runtime error = %v", err) + } + assertNoReadinessMutation(t, ops.calls) + }) + + t.Run("serving without manager config", func(t *testing.T) { + root := t.TempDir() + ops := &fakeOps{running: true} + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} + _, err := New(testPaths(root), ops).EnsureReady(t.Context(), request) + if err == nil || !strings.Contains(err.Error(), "serving without the manager-owned configuration") { + t.Fatalf("unmanaged serving runtime error = %v", err) + } + assertNoReadinessMutation(t, ops.calls) + }) + + t.Run("residual unconfigured runtime artifacts", func(t *testing.T) { + root := t.TempDir() + paths := testPaths(root) + if err := preparePaths(paths); err != nil { + t.Fatal(err) + } + marker := filepath.Join(paths.RuntimeDir, "residual.sock") + if err := os.WriteFile(marker, []byte("residual"), 0o600); err != nil { + t.Fatal(err) + } + ops := &fakeOps{} + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} + _, err := New(paths, ops).EnsureReady(t.Context(), request) + if err == nil || !strings.Contains(err.Error(), "existing repository runtime state") { + t.Fatalf("residual runtime error = %v", err) + } + if _, statErr := os.Stat(marker); statErr != nil { + t.Fatalf("residual artifact changed: %v", statErr) + } + assertNoReadinessMutation(t, ops.calls) + }) +} + +func assertNoReadinessMutation(t *testing.T, calls []string) { + t.Helper() + for _, forbidden := range []string{"download", "verify", "install", "start", "stop"} { + if contains(calls, forbidden) { + t.Fatalf("unsafe readiness branch called %q: %v", forbidden, calls) + } + } +} + +func TestEnsureReadyReusesOnlyCompatibleDaemonWithoutRestart(t *testing.T) { for _, mismatch := range []string{"", "binary", "policy"} { t.Run(mismatch, func(t *testing.T) { root := t.TempDir() - ops := &fakeOps{running: true, identityMismatch: mismatch} + paths := testPaths(root) + ops := &fakeOps{} request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} - if _, err := New(testPaths(root), ops).EnsureReady(context.Background(), request); err != nil { + manager := New(paths, ops) + if _, err := manager.EnsureReady(t.Context(), request); err != nil { + t.Fatal(err) + } + ops.identityMismatch = mismatch + before, err := os.ReadFile(paths.ConfigFile) + if err != nil { t.Fatal(err) } - wantRestart := mismatch != "" - if got := contains(ops.calls, "stop") && contains(ops.calls, "start"); got != wantRestart { - t.Fatalf("calls = %v, restart=%t want %t", ops.calls, got, wantRestart) + callStart := len(ops.calls) + _, err = manager.EnsureReady(t.Context(), request) + if mismatch == "" && err != nil { + t.Fatal(err) + } + if mismatch != "" && (err == nil || !strings.Contains(err.Error(), "identity is incompatible")) { + t.Fatalf("mismatch error = %v", err) + } + for _, forbidden := range []string{"stop", "start", "download", "verify", "install"} { + if contains(ops.calls[callStart:], forbidden) { + t.Fatalf("reuse called %s: %v", forbidden, ops.calls[callStart:]) + } + } + after, readErr := os.ReadFile(paths.ConfigFile) + if readErr != nil || !reflect.DeepEqual(after, before) { + t.Fatalf("reuse changed config: err=%v", readErr) } }) } } +func TestEnsureReadyConcurrentCallersStartFreshDaemonOnce(t *testing.T) { + root := t.TempDir() + ops := &fakeOps{} + manager := New(testPaths(root), ops) + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} + start := make(chan struct{}) + errs := make(chan error, 8) + for range 8 { + go func() { + <-start + _, err := manager.EnsureReady(t.Context(), request) + errs <- err + }() + } + close(start) + for range 8 { + if err := <-errs; err != nil { + t.Fatal(err) + } + } + starts, installs := 0, 0 + for _, call := range ops.calls { + if call == "start" { + starts++ + } + if call == "install" { + installs++ + } + } + if starts != 1 || installs != 1 || contains(ops.calls, "stop") { + t.Fatalf("calls = %v; starts=%d installs=%d", ops.calls, starts, installs) + } +} + func TestDoctorReportsFreshAndFailureStatesWithoutMutation(t *testing.T) { tests := []struct { name string @@ -226,26 +348,27 @@ func TestDoctorReportsFreshAndFailureStatesWithoutMutation(t *testing.T) { ops *fakeOps want []string }{ - {name: "fresh home", ops: &fakeOps{}, want: []string{"host preflight: passed", "backend: not configured", "host prerequisites passed; select microvm-local", "mecatui --default-placement microvm-local", "doctor is read-only"}}, - {name: "daemon stopped", configured: true, ops: &fakeOps{}, want: []string{"host preflight: passed", "backend: configured; daemon not running", "host prerequisites passed; select microvm-local"}}, + {name: "fresh home", ops: &fakeOps{}, want: []string{"host preflight: passed", "backend: ready to configure on first use", "select microvm-local to configure", "default_placement: microvm-local", "guest IPv4 egress defaults to permissive", "doctor is read-only"}}, + {name: "daemon stopped", configured: true, ops: &fakeOps{}, want: []string{"host preflight: passed", "backend: configured; daemon not running", "ordinary use will not replace or restart"}}, {name: "healthy", configured: true, ops: &fakeOps{running: true}, want: []string{"host preflight: passed", "backend: healthy", "PASS hypervisor ready"}}, {name: "daemon unhealthy", configured: true, ops: &fakeOps{running: true, doctorErr: errors.New("guest transport unavailable")}, want: []string{"backend: unhealthy", "guest transport unavailable"}}, - {name: "host preflight failed", ops: &fakeOps{failAt: "preflight"}, want: []string{"host preflight: failed", "backend: not configured"}}, + {name: "host preflight failed", ops: &fakeOps{failAt: "preflight"}, want: []string{"host preflight: failed", "backend: ready to configure on first use"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { root := t.TempDir() paths := testPaths(root) if tc.configured { - if err := os.MkdirAll(filepath.Dir(paths.ConfigFile), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(paths.ConfigFile, []byte("{}"), 0o600); err != nil { + running, doctorErr, failAt := tc.ops.running, tc.ops.doctorErr, tc.ops.failAt + tc.ops.running, tc.ops.doctorErr, tc.ops.failAt = false, nil, "" + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} + if _, err := New(paths, tc.ops).EnsureReady(t.Context(), request); err != nil { t.Fatal(err) } + tc.ops.running, tc.ops.doctorErr, tc.ops.failAt, tc.ops.calls = running, doctorErr, failAt, nil } report, err := New(paths, tc.ops).Doctor(t.Context()) - if tc.name == "healthy" { + if tc.name == "healthy" || tc.name == "fresh home" { if err != nil { t.Fatal(err) } @@ -272,7 +395,12 @@ func TestDoctorReportsFreshAndFailureStatesWithoutMutation(t *testing.T) { func TestMicroVMLifecycleUX_ManagerStatusAndDeleteRemainExact(t *testing.T) { root := t.TempDir() paths := testPaths(root) - ops := &fakeOps{running: true} + ops := &fakeOps{} + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} + if _, err := New(paths, ops).EnsureReady(t.Context(), request); err != nil { + t.Fatal(err) + } + ops.calls = nil daemon := &fakeLifecycleClient{entries: []microvmclient.InventoryEntry{{ Owner: "local", SessionID: "s1", EnvironmentID: "env-1", Ref: "env-1@7", Generation: 7, WorktreePath: "/worktrees/s1", Health: microvmclient.GenerationStale, @@ -296,11 +424,18 @@ func TestMicroVMLifecycleUX_ManagerStatusAndDeleteRemainExact(t *testing.T) { func TestRepositoryManagerInventoryPagesAndExactLogicalDelete(t *testing.T) { root := t.TempDir() + paths := testPaths(root) + ops := &fakeOps{} + request := ReadyRequest{Release: Release{URL: "https://example.invalid/release.tar.gz", SHA256: strings.Repeat("a", 64)}, Policy: testPolicy(root)} + if _, err := New(paths, ops).EnsureReady(t.Context(), request); err != nil { + t.Fatal(err) + } + ops.calls = nil daemon := &fakeLifecycleClient{entries: []microvmclient.InventoryEntry{{ Owner: "local", SessionID: "session-a", EnvironmentID: "logical-a", Ref: "logical-a@9", Generation: 9, WorktreePath: "/worktrees/a", State: "ready", Health: microvmclient.GenerationHealthy, }}, continuation: "next"} - manager := New(testPaths(root), &fakeOps{running: true}, daemon) + manager := New(paths, ops, daemon) first, err := manager.Status(t.Context(), StatusRequest{PageSize: 1}) if err != nil { t.Fatal(err) @@ -449,7 +584,9 @@ func (f *fakeOps) Stop(context.Context, Paths) error { } func testPaths(root string) Paths { - return Paths{StateDir: filepath.Join(root, "state"), RuntimeDir: filepath.Join(root, "run"), Socket: filepath.Join(root, "run", "d.sock"), DataDir: filepath.Join(root, "data"), ConfigFile: filepath.Join(root, "config", "microvmd.json"), UserSettings: filepath.Join(root, "config", "settings.yaml"), DaemonBinary: filepath.Join(root, "data", "bin", "mecatl-microvmd")} + digest := sha256.Sum256([]byte(root)) + runtimeDir := filepath.Join(os.TempDir(), fmt.Sprintf("mvt-%x", digest[:8])) + return Paths{StateDir: filepath.Join(root, "state"), RuntimeDir: runtimeDir, Socket: filepath.Join(runtimeDir, "d.sock"), DataDir: filepath.Join(root, "data"), ConfigFile: filepath.Join(root, "config", "microvmd.json"), UserSettings: filepath.Join(root, "config", "settings.yaml"), DaemonBinary: filepath.Join(root, "data", "bin", "mecatl-microvmd")} } func testPolicy(_ string) Policy { diff --git a/internal/adapter/microvmmanager/userns_linux.go b/internal/adapter/microvmmanager/userns_linux.go index 437fa0cbd..ee830e1e2 100644 --- a/internal/adapter/microvmmanager/userns_linux.go +++ b/internal/adapter/microvmmanager/userns_linux.go @@ -16,7 +16,7 @@ import ( func checkLinuxUserNamespaces(ctx context.Context) error { return checkLinuxUserNamespaceControls(os.ReadFile, func() error { - cmd := exec.CommandContext(ctx, "true") + cmd := scrubbedCommand(exec.CommandContext(ctx, "true")) cmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWUSER} return cmd.Run() }) diff --git a/internal/adapter/permconfig/ast_foundation_test.go b/internal/adapter/permconfig/ast_foundation_test.go index 99bf3dd18..26c3f6795 100644 --- a/internal/adapter/permconfig/ast_foundation_test.go +++ b/internal/adapter/permconfig/ast_foundation_test.go @@ -208,6 +208,9 @@ func TestPermconfigNodeDecoderShapeInventory(t *testing.T) { {"LearningSkillsSection", func() yaml.NodeUnmarshaler { return &LearningSkillsSection{} }}, {"LearningAutomaticSection", func() yaml.NodeUnmarshaler { return &LearningAutomaticSection{} }}, {"LearningSection", func() yaml.NodeUnmarshaler { return &LearningSection{} }}, + {"ExecutionSection", func() yaml.NodeUnmarshaler { return &ExecutionSection{} }}, + {"ExecutionMicroVMSection", func() yaml.NodeUnmarshaler { return &ExecutionMicroVMSection{} }}, + {"ExecutionGuestEgressSection", func() yaml.NodeUnmarshaler { return &ExecutionGuestEgressSection{} }}, {"OpenRouterSection", func() yaml.NodeUnmarshaler { return &OpenRouterSection{} }}, {"OpenRouterModelRoute", func() yaml.NodeUnmarshaler { return &OpenRouterModelRoute{} }}, {"ContextWindows", func() yaml.NodeUnmarshaler { return &ContextWindows{} }}, diff --git a/internal/adapter/permconfig/execution_test.go b/internal/adapter/permconfig/execution_test.go new file mode 100644 index 000000000..0d7638dc2 --- /dev/null +++ b/internal/adapter/permconfig/execution_test.go @@ -0,0 +1,56 @@ +package permconfig + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/stacklok/mecatl/engine/adapter/memfs" + "github.com/stacklok/mecatl/engine/port" + "github.com/stacklok/mecatl/internal/adapter/slogdiag" +) + +func TestOperatorExecutionStrictAndCaptured(t *testing.T) { + r := newWithEnv(Options{ExplicitFiles: []string{"/operator/settings.yaml"}}, envWithExplicit("/operator/settings.yaml", ` +execution: + default_placement: microvm-local + microvm: + guest_egress: + mode: allowlist + allow: [api.example.com:443/tcp] +`)) + got, err := r.OperatorExecution() + if err != nil { + t.Fatal(err) + } + if got == nil || got.DefaultPlacement != "microvm-local" || got.MicroVM == nil || got.MicroVM.GuestEgress == nil || len(got.MicroVM.GuestEgress.Allow) != 1 { + t.Fatalf("operator execution = %#v", got) + } +} + +func TestOperatorExecutionMalformedFailsClosed(t *testing.T) { + r := newWithEnv(Options{ExplicitFiles: []string{"/operator/settings.yaml"}}, envWithExplicit("/operator/settings.yaml", `execution: {default_placement: container}`)) + if _, err := r.OperatorExecution(); err == nil { + t.Fatal("malformed operator execution configuration was not retained as a startup error") + } +} + +func TestProjectExecutionIgnoredWithValueFreeWarning(t *testing.T) { + var log bytes.Buffer + diag := slogdiag.New(&log, false, port.LevelDebug) + ws := &countingWS{Workspace: memfs.NewWorkspace("/repo")} + ws.seed(t, projectFileMecatl, `execution: {default_placement: microvm-local}`) + r := newWithEnv(Options{Conventional: true, TrustProject: true, Diagnostics: diag}, fakeEnv()) + _ = r.Resolve(context.Background(), ws) + if got, err := r.OperatorExecution(); got != nil || err != nil { + t.Fatalf("project execution escaped operator tier: got=%#v err=%v", got, err) + } + line := log.String() + if !strings.Contains(line, "IGNORING project-tier execution block") { + t.Fatalf("missing ignore warning: %s", line) + } + if strings.Contains(line, "microvm-local") { + t.Fatalf("warning leaked ignored value: %s", line) + } +} diff --git a/internal/adapter/permconfig/permconfig.go b/internal/adapter/permconfig/permconfig.go index 41a39a05c..8d68a1dcb 100644 --- a/internal/adapter/permconfig/permconfig.go +++ b/internal/adapter/permconfig/permconfig.go @@ -166,6 +166,7 @@ func (c *Config) UnmarshalYAML(node ast.Node) error { "mcp": newPermconfigNodePointer(&c.MCP), "retention": newPermconfigNodePointer(&c.Retention), "storage_management": newPermconfigNodePointer(&c.StorageManagement), + "execution": newPermconfigNodePointer(&c.Execution), "temporary_storage": newPermconfigNodePointer(&c.TemporaryStorage), } for _, entry := range mapping.Values { diff --git a/internal/adapter/permconfig/resolve.go b/internal/adapter/permconfig/resolve.go index 3e7abb936..15bf8fbba 100644 --- a/internal/adapter/permconfig/resolve.go +++ b/internal/adapter/permconfig/resolve.go @@ -211,6 +211,8 @@ type Resolver struct { operatorStorageManagementErr error operatorTemporaryStorage *TemporaryStorageSection operatorTemporaryStorageErr error + operatorExecution *ExecutionSection + operatorExecutionErr error // operatorProviders and operatorProviderOverrides are immutable operator-tier // provider configuration captured once at resolver construction. @@ -250,6 +252,15 @@ func (r *Resolver) OperatorTemporaryStorage() (*TemporaryStorageSection, error) return r.operatorTemporaryStorage, r.operatorTemporaryStorageErr } +// OperatorExecution returns the immutable operator-tier execution policy and any +// strict parse failure that would otherwise silently restore host execution. +func (r *Resolver) OperatorExecution() (*ExecutionSection, error) { + if r == nil { + return nil, nil + } + return r.operatorExecution, r.operatorExecutionErr +} + // OperatorGuardrails returns the operator-tier guardrails config (user-global + CLI // only), or nil when none was configured. It is the SOLE accessor the composition // layer uses to read guardrails from config — by construction it never returns a @@ -684,6 +695,11 @@ func (r *Resolver) loadProjectRules(ws tool.WorkspaceReader) ([]governance.Rule, "retention: IGNORING a project-tier retention block (operator-tier only; projects cannot weaken cleanup protection)", "file", src.path, "root", ws.Root()) } + if cfg.Execution != nil { + r.diag.Log(context.Background(), port.LevelWarn, + "execution: IGNORING project-tier execution block (operator-tier only)", + "file", src.path, "root", ws.Root()) + } if cfg.TemporaryStorage != nil { r.diag.Log(context.Background(), port.LevelWarn, "temporary_storage: IGNORING a project-tier temporary_storage block (operator-tier only; projects cannot redirect command temporary storage or alter cleanup retention)", @@ -858,6 +874,9 @@ func (r *Resolver) captureOperatorParseError(data []byte, err error) { if hasTopLevelKey(data, "storage_management") && r.operatorStorageManagementErr == nil { r.operatorStorageManagementErr = err } + if hasTopLevelKey(data, "execution") && r.operatorExecutionErr == nil { + r.operatorExecutionErr = err + } if hasTopLevelKey(data, "temporary_storage") && r.operatorTemporaryStorageErr == nil { r.operatorTemporaryStorageErr = err } @@ -909,6 +928,7 @@ func (r *Resolver) loadUserRules(report *Report) []governance.Rule { r.captureMCP(cfg.MCP) r.captureRetention(cfg.Retention) r.captureStorageManagement(cfg.StorageManagement) + r.captureExecution(cfg.Execution) r.captureProviders(cfg.Providers, cfg.ProviderOverrides) } @@ -948,6 +968,7 @@ func (r *Resolver) loadUserRules(report *Report) []governance.Rule { r.captureMCP(cfg.MCP) r.captureRetention(cfg.Retention) r.captureStorageManagement(cfg.StorageManagement) + r.captureExecution(cfg.Execution) r.captureTemporaryStorage(cfg.TemporaryStorage) r.captureProviders(cfg.Providers, cfg.ProviderOverrides) } @@ -973,6 +994,13 @@ func (r *Resolver) loadUserRules(report *Report) []governance.Rule { // captureProviders records the first complete operator provider snapshot. Explicit // files precede user-global settings, so the command-line operator tier wins. +func (r *Resolver) captureExecution(s *ExecutionSection) { + if s == nil || r.operatorExecution != nil { + return + } + r.operatorExecution = s +} + func (r *Resolver) captureProviders(definitions ProviderDefinitions, overrides ProviderOverrides) { if r.operatorProviders == nil && definitions != nil { r.operatorProviders = definitions diff --git a/internal/adapter/permconfig/schema.go b/internal/adapter/permconfig/schema.go index 7cfa32a72..0190ef44d 100644 --- a/internal/adapter/permconfig/schema.go +++ b/internal/adapter/permconfig/schema.go @@ -163,12 +163,96 @@ type Config struct { // StorageManagement names the verified OIDC identities allowed to operate on // process-wide storage. It is strict and operator-tier only. StorageManagement *StorageManagementSection `yaml:"storage_management"` + // Execution defines the strict OPERATOR-TIER execution-placement policy. Project-tier + // values are ignored with a value-free warning. Omission selects host-local + // placement with permissive MicroVM guest egress. + Execution *ExecutionSection `yaml:"execution"` // TemporaryStorage controls managed command temporary storage. It is strict and // read exclusively from the user-global settings.yaml; project-tier and explicit // CLI configuration values are ignored by the Resolver. TemporaryStorage *TemporaryStorageSection `yaml:"temporary_storage"` } +// ExecutionSection is the strict operator-owned placement policy. +type ExecutionSection struct { + // The default_placement setting selects the server-owned default placement backend. + DefaultPlacement string `yaml:"default_placement"` + // The microvm block configures local MicroVM execution without creating a public profile. + MicroVM *ExecutionMicroVMSection `yaml:"microvm"` +} + +// ExecutionMicroVMSection configures the local MicroVM backend. +type ExecutionMicroVMSection struct { + // The guest_egress block controls network destinations reachable from the guest. + GuestEgress *ExecutionGuestEgressSection `yaml:"guest_egress"` +} + +// ExecutionGuestEgressSection configures guest network destinations. Allow entries +// use the MicroVM manager's HOST:PORT/tcp|udp destination grammar. +type ExecutionGuestEgressSection struct { + // mode accepts permissive, deny-all, or allowlist. Permissive is the default. + Mode string `yaml:"mode"` + // allow lists HOST:PORT/tcp|udp destinations and is valid only in allowlist mode. + Allow []string `yaml:"allow"` +} + +// UnmarshalYAML decodes and validates the strict operator-owned execution policy. +func (s *ExecutionSection) UnmarshalYAML(node ast.Node) error { + if err := decodeStrictMapping(node, "execution", s.strictFields()); err != nil { + return err + } + if s.DefaultPlacement == "" { + s.DefaultPlacement = "host-local" + } + if s.DefaultPlacement != "host-local" && s.DefaultPlacement != "microvm-local" { + return fmt.Errorf("execution.default_placement: must be host-local or microvm-local") + } + return nil +} + +func (s *ExecutionSection) strictFields() map[string]any { + return map[string]any{ + "default_placement": &s.DefaultPlacement, + "microvm": newPermconfigNodePointer(&s.MicroVM), + } +} + +// UnmarshalYAML decodes the strict MicroVM execution policy. +func (s *ExecutionMicroVMSection) UnmarshalYAML(node ast.Node) error { + return decodeStrictMapping(node, "execution.microvm", s.strictFields()) +} + +func (s *ExecutionMicroVMSection) strictFields() map[string]any { + return map[string]any{"guest_egress": newPermconfigNodePointer(&s.GuestEgress)} +} + +// UnmarshalYAML decodes and validates the strict MicroVM guest egress policy. +func (s *ExecutionGuestEgressSection) UnmarshalYAML(node ast.Node) error { + if err := decodeStrictMapping(node, "execution.microvm.guest_egress", s.strictFields()); err != nil { + return err + } + if s.Mode == "" { + s.Mode = "permissive" + } + switch s.Mode { + case "permissive", "deny-all": + if len(s.Allow) != 0 { + return fmt.Errorf("execution.microvm.guest_egress.allow: valid only in allowlist mode") + } + case "allowlist": + if len(s.Allow) == 0 { + return fmt.Errorf("execution.microvm.guest_egress.allow: requires at least one destination in allowlist mode") + } + default: + return fmt.Errorf("execution.microvm.guest_egress.mode: must be permissive, deny-all, or allowlist") + } + return nil +} + +func (s *ExecutionGuestEgressSection) strictFields() map[string]any { + return map[string]any{"mode": &s.Mode, "allow": &s.Allow} +} + // TemporaryStorageSection is the strict operator policy for command temporary // storage. Durations are parsed during decoding so invalid settings fail before // composition can enable a runner. diff --git a/internal/adapter/permconfig/strictkeys_test.go b/internal/adapter/permconfig/strictkeys_test.go index 166c49e66..2a67161f4 100644 --- a/internal/adapter/permconfig/strictkeys_test.go +++ b/internal/adapter/permconfig/strictkeys_test.go @@ -27,6 +27,9 @@ func TestStrictFieldsMatchYAMLTags(t *testing.T) { {"RouterCategory", (&RouterCategory{}).strictFields(), RouterCategory{}}, {"OpenRouterSection", (&OpenRouterSection{}).strictFields(), OpenRouterSection{}}, {"OpenRouterModelRoute", (&OpenRouterModelRoute{}).strictFields(), OpenRouterModelRoute{}}, + {"ExecutionSection", (&ExecutionSection{}).strictFields(), ExecutionSection{}}, + {"ExecutionMicroVMSection", (&ExecutionMicroVMSection{}).strictFields(), ExecutionMicroVMSection{}}, + {"ExecutionGuestEgressSection", (&ExecutionGuestEgressSection{}).strictFields(), ExecutionGuestEgressSection{}}, {"MCPSection", (&MCPSection{}).strictFields(), MCPSection{}}, {"MCPServerProfile", (&MCPServerProfile{}).strictFields(), MCPServerProfile{}}, {"MCPAuthProfile", (&MCPAuthProfile{}).strictFields(), MCPAuthProfile{}}, diff --git a/internal/adapter/server/acp_placement.go b/internal/adapter/server/acp_placement.go index 5c95b60ba..e76a9e240 100644 --- a/internal/adapter/server/acp_placement.go +++ b/internal/adapter/server/acp_placement.go @@ -23,27 +23,38 @@ func (s *Service) CreateACPSession(ctx context.Context, cwd string, mode session if err != nil { return nil, err } - if err := assertACPPlacementCWD(cwd, binding.Environment); err != nil { + if err := assertACPPlacementCWD(cwd, binding); err != nil { + discardPlacementBinding(binding) return nil, err } id := s.cfg.NewID() + var environmentClose func() error if overlay != nil { binding.Environment, err = overlay(id, binding.Environment) if err != nil { + discardPlacementBinding(binding) return nil, fmt.Errorf("%w: editor filesystem overlay unavailable", ErrFailedPrecondition) } if err := validatePlacementBinding(binding); err != nil { + discardPlacementBinding(binding) return nil, err } + // The override still depends on this exact attached placement. Transfer its + // detach callback to Service ownership instead of letting createSession's + // provisional-binding defer close it before ACP can use the environment. + environmentClose, binding.Close = binding.Close, nil } created, err := s.createSession(ctx, mode, limits, ProviderSelector{}, specs, ProfileDefault, createSessionOpts{ id: id, idSet: true, placement: &binding, }) if err != nil { + if environmentClose != nil { + _ = environmentClose() + } return nil, err } if overlay != nil { - s.SetSessionEnvironment(created.ID, binding.Environment) + s.setSessionEnvironment(created.ID, binding.Environment, environmentClose) } return created, nil } @@ -63,33 +74,45 @@ func (s *Service) LoadACPSession(ctx context.Context, id session.SessionID, cwd if err != nil { return nil, err } - if err := assertACPPlacementCWD(cwd, binding.Environment); err != nil { + if err := assertACPPlacementCWD(cwd, binding); err != nil { + discardPlacementBinding(binding) return nil, err } if overlay != nil { binding.Environment, err = overlay(id, binding.Environment) if err != nil { + discardPlacementBinding(binding) return nil, fmt.Errorf("%w: editor filesystem overlay unavailable", ErrFailedPrecondition) } binding.Ref = persisted.EnvironmentRef if err := validatePlacementBinding(binding); err != nil { + discardPlacementBinding(binding) return nil, err } } sess, err := s.LoadSessionWithMCP(ctx, id, specs) if err != nil { + discardPlacementBinding(binding) return nil, err } if sess.EnvironmentRef != persisted.EnvironmentRef { + discardPlacementBinding(binding) return nil, fmt.Errorf("%w: session placement changed while loading", ErrFailedPrecondition) } - s.SetSessionEnvironment(id, binding.Environment) + if overlay != nil { + s.setSessionEnvironment(id, binding.Environment, binding.Close) + } else { + // Without an editor-buffer override, ordinary run entry performs its own + // exact reattachment. This load-time binding is only provisional. + discardPlacementBinding(binding) + } return sess, nil } -func assertACPPlacementCWD(cwd string, env tool.Environment) error { +func assertACPPlacementCWD(cwd string, binding PlacementBinding) error { + compositionRoot, rootErr := PlacementCompositionRoot(binding) resolved, err := filepath.EvalSymlinks(cwd) - if err != nil || !filepath.IsAbs(cwd) || filepath.Clean(resolved) != filepath.Clean(env.Workspace().Root()) { + if rootErr != nil || err != nil || compositionRoot == "" || !filepath.IsAbs(cwd) || filepath.Clean(resolved) != filepath.Clean(compositionRoot) { return fmt.Errorf("%w: cwd does not match the configured session placement", ErrInvalidArgument) } return nil diff --git a/internal/adapter/server/canonical_seams_repair_test.go b/internal/adapter/server/canonical_seams_repair_test.go index 6d766bfb8..17015158a 100644 --- a/internal/adapter/server/canonical_seams_repair_test.go +++ b/internal/adapter/server/canonical_seams_repair_test.go @@ -61,7 +61,7 @@ type revisionPlacementProvider struct{ binds int } func (*revisionPlacementProvider) binding(revision string) PlacementBinding { ref := session.EnvironmentRef{Kind: session.EnvKindMem, ID: "/same-root", Revision: revision} - return PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/same-root"), memledger.New(), nil)} + return PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/same-root"), memledger.New(), nil), CompositionRoot: "/same-root"} } func (p *revisionPlacementProvider) Bind(context.Context, PlacementBindRequest) (PlacementBinding, error) { @@ -70,7 +70,7 @@ func (p *revisionPlacementProvider) Bind(context.Context, PlacementBindRequest) } func (*revisionPlacementProvider) Reattach(_ context.Context, req PlacementReattachRequest) (PlacementBinding, error) { - return PlacementBinding{Ref: req.Ref, Environment: tool.MustEnvironment(req.Ref, memfs.NewWorkspace("/same-root"), memledger.New(), nil)}, nil + return PlacementBinding{Ref: req.Ref, Environment: tool.MustEnvironment(req.Ref, memfs.NewWorkspace("/same-root"), memledger.New(), nil), CompositionRoot: "/same-root"}, nil } func (*revisionPlacementProvider) ListWorktrees(context.Context, PlacementDiscoveryRequest) ([]ScopedWorktree, error) { @@ -91,7 +91,7 @@ func TestInvariant_create_retry_matches_complete_environment_ref(t *testing.T) { if err != nil { t.Fatal(err) } - if first.EnvironmentRef.Revision != "2" { + if first.EnvironmentRef.Revision != "1" { t.Fatalf("first ref = %+v, want provider's complete create binding", first.EnvironmentRef) } if _, err := svc.CreateSessionWithProfile(ctx, session.ModeDefault, session.Limits{}, ProviderSelector{}, ProfileDefault, WithSessionID("fixed")); err == nil { @@ -126,7 +126,7 @@ func TestInvariant_create_session_rejects_only_legacy_unknown_placement_fields(t func TestInvariant_filesystem_placement_root_selects_policy_engine_for_every_kind(t *testing.T) { ref := session.EnvironmentRef{Kind: "custom-fs", ID: "opaque", Revision: "r1"} - provider := &repairPlacementProvider{binding: PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/root-b"), memledger.New(), nil)}} + provider := &repairPlacementProvider{binding: PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/root-b"), memledger.New(), nil), CompositionRoot: "/root-b"}} factoryCalls := 0 svc, err := NewService(Config{ Engine: repairEngine(), Store: memstore.New(), PlacementProvider: provider, PlacementScope: "test", SharedEngineRoot: "/root-a", @@ -151,7 +151,7 @@ func TestInvariant_filesystem_placement_root_selects_policy_engine_for_every_kin func TestInvariant_environment_override_is_reauthorized_at_every_run_entry(t *testing.T) { ref := session.EnvironmentRef{Kind: "custom-fs", ID: "opaque", Revision: "r1"} - provider := &repairPlacementProvider{binding: PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/fresh"), memledger.New(), nil)}} + provider := &repairPlacementProvider{binding: PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/fresh"), memledger.New(), nil), CompositionRoot: "/fresh"}} store := memstore.New() svc, err := NewService(Config{Engine: repairEngine(), Store: store, PlacementProvider: provider, PlacementScope: "test", SharedEngineRoot: "/fresh"}) if err != nil { diff --git a/internal/adapter/server/environment_resolver_test.go b/internal/adapter/server/environment_resolver_test.go index 88baae3e5..398d72e1b 100644 --- a/internal/adapter/server/environment_resolver_test.go +++ b/internal/adapter/server/environment_resolver_test.go @@ -48,7 +48,11 @@ func (p resolverPlacementProvider) Reattach(ctx context.Context, req server.Plac if err != nil { return server.PlacementBinding{}, err } - return server.PlacementBinding{Ref: req.Ref, Environment: env}, nil + binding := server.PlacementBinding{Ref: req.Ref, Environment: env} + if env.Workspace() != nil { + binding.CompositionRoot = env.Workspace().Root() + } + return binding, nil } // newEnvTestService builds a minimal Service for exact placement-reattachment tests. diff --git a/internal/adapter/server/learning.go b/internal/adapter/server/learning.go index 0118f4274..02b9cf036 100644 --- a/internal/adapter/server/learning.go +++ b/internal/adapter/server/learning.go @@ -82,11 +82,11 @@ func (s *Service) ReflectSession(ctx context.Context, id session.SessionID) (*me return nil, fmt.Errorf("%w: reflection requires a completed session", ErrFailedPrecondition) } if s.placementBinder != nil { - binding, bindErr := s.ReattachPlacement(ctx, sess.EnvironmentRef) - if bindErr != nil { - return nil, bindErr + workspace, workspaceErr := s.privateCompositionRoot(ctx, sess) + if workspaceErr != nil { + return nil, workspaceErr } - ctx = memory.WithWorkspace(ctx, binding.Environment.Workspace().Root()) + ctx = memory.WithWorkspace(ctx, workspace) } r, err := s.cfg.ReflectSession(ctx, sess) if err != nil { @@ -570,11 +570,11 @@ func (s *Service) learningWorkspace(ctx context.Context, sess *session.Session) if s.placementBinder == nil { return "", true } - binding, err := s.ReattachPlacement(ctx, sess.EnvironmentRef) + workspace, err := s.privateCompositionRoot(ctx, sess) if err != nil { return "", false } - return binding.Environment.Workspace().Root(), true + return workspace, true } func sameEventSequence(a, b *int64) bool { diff --git a/internal/adapter/server/mcp_authorization.go b/internal/adapter/server/mcp_authorization.go index 1249c0bf9..256f4820c 100644 --- a/internal/adapter/server/mcp_authorization.go +++ b/internal/adapter/server/mcp_authorization.go @@ -327,7 +327,7 @@ func (s *Service) authorizationAttachment(ctx context.Context, sess *session.Ses func (s *Service) continueGrantedAuthorizationLocked(ctx context.Context, sess *session.Session) (MCPAuthorizationResult, error) { s.cfg.Diagnostics.Log(ctx, port.LevelDebug, "MCP authorization: granted, starting continuation", "session", string(sess.ID), "state", string(sess.State)) - engine, env, err := s.engineAndEnvironmentFor(ctx, sess) + engine, env, compositionRoot, err := s.engineAndEnvironmentFor(ctx, sess) if err != nil { return MCPAuthorizationResult{}, fmt.Errorf("%w: continuation engine: %v", ErrFailedPrecondition, err) } @@ -354,7 +354,8 @@ func (s *Service) continueGrantedAuthorizationLocked(ctx context.Context, sess * return MCPAuthorizationResult{}, fmt.Errorf("%w: persist authorization claim", ErrInternal) } s.stopAuthorizationExpiry(sess.ID) - prepared, err := engine.PrepareAuthorizationContinuation(memory.WithWorkspace(ctx, env.Workspace().Root()), sess, env, claimed, resolution) + continuationCtx := memory.WithWorkspace(ctx, compositionRoot) + prepared, err := engine.PrepareAuthorizationContinuation(continuationCtx, sess, env, claimed, resolution) if err != nil { if restoreErr := s.restoreAuthorizationClaimOrSettle(ctx, sess.ID, sess, claimed); restoreErr != nil { return MCPAuthorizationResult{}, fmt.Errorf("%w: prepare granted authorization continuation: %v; restore claim: %v", ErrInternal, err, restoreErr) @@ -481,7 +482,7 @@ func (s *Service) resolveAuthorizationLocked(ctx context.Context, sess *session. } }() s.stopAuthorizationExpiry(sess.ID) - engine, env, err := s.engineAndEnvironmentFor(ctx, sess) + engine, env, compositionRoot, err := s.engineAndEnvironmentFor(ctx, sess) if err != nil { // Engine/environment reconstruction is not required to make a terminal // authorization lifecycle reconstructable. The snapshot is already settled; @@ -494,7 +495,8 @@ func (s *Service) resolveAuthorizationLocked(ctx context.Context, sess *session. } return mcpAuthorizationResult(pending, status, nil), nil } - prepared, err := engine.PrepareAfterAuthorization(memory.WithWorkspace(ctx, env.Workspace().Root()), sess, env, pending.Authorization, pending.Call.ID, results, resolution) + continuationCtx := memory.WithWorkspace(ctx, compositionRoot) + prepared, err := engine.PrepareAfterAuthorization(continuationCtx, sess, env, pending.Authorization, pending.Call.ID, results, resolution) if err != nil { return MCPAuthorizationResult{}, fmt.Errorf("%w: prepare terminal authorization continuation", ErrInternal) } diff --git a/internal/adapter/server/mcp_authorization_lifecycle_test.go b/internal/adapter/server/mcp_authorization_lifecycle_test.go index 784651021..e5cc74202 100644 --- a/internal/adapter/server/mcp_authorization_lifecycle_test.go +++ b/internal/adapter/server/mcp_authorization_lifecycle_test.go @@ -1291,7 +1291,7 @@ func TestMCPAuthorizationPreparedRegistrationCancellationRestoresClaim(t *testin if err != nil { t.Fatal(err) } - engine, env, err := f.svc.engineAndEnvironmentFor(ctx, loaded) + engine, env, compositionRoot, err := f.svc.engineAndEnvironmentFor(ctx, loaded) if err != nil { t.Fatal(err) } @@ -1306,7 +1306,7 @@ func TestMCPAuthorizationPreparedRegistrationCancellationRestoresClaim(t *testin if err != nil { t.Fatal(err) } - prepared, err := engine.PrepareAuthorizationContinuation(memory.WithWorkspace(ctx, env.Workspace().Root()), loaded, env, claimed, resolution) + prepared, err := engine.PrepareAuthorizationContinuation(memory.WithWorkspace(ctx, compositionRoot), loaded, env, claimed, resolution) if err != nil { t.Fatal(err) } @@ -1420,7 +1420,7 @@ func TestMCPAuthorizationPreparedRegistrationFailureNeverExecutes(t *testing.T) if err != nil { t.Fatal(err) } - if _, _, err = f.svc.engineAndEnvironmentFor(t.Context(), loaded); err != nil { + if _, _, _, err = f.svc.engineAndEnvironmentFor(t.Context(), loaded); err != nil { t.Fatal(err) } blockerSession := session.New("blocker", session.ModeDefault, session.EnvironmentRef{Kind: session.EnvKindMem, ID: "/repo", Revision: "in-tree-v1"}, session.Limits{}, time.Now()) @@ -1472,7 +1472,7 @@ func TestMCPAuthorizationGrantedCompensationSaveFailureSettlesInsteadOfStranding if err != nil { t.Fatal(err) } - if _, _, err = f.svc.engineAndEnvironmentFor(t.Context(), loaded); err != nil { + if _, _, _, err = f.svc.engineAndEnvironmentFor(t.Context(), loaded); err != nil { t.Fatal(err) } blockerSession := session.New("blocker", session.ModeDefault, session.EnvironmentRef{Kind: session.EnvKindMem, ID: "/repo", Revision: "in-tree-v1"}, session.Limits{}, time.Now()) diff --git a/internal/adapter/server/placement.go b/internal/adapter/server/placement.go index cc8ccb81b..78a590fb6 100644 --- a/internal/adapter/server/placement.go +++ b/internal/adapter/server/placement.go @@ -119,6 +119,10 @@ type PlacementBinding struct { Environment tool.Environment Ref session.EnvironmentRef Metadata PlacementMetadata + // CompositionRoot is the trusted host root used only to assemble host-side + // project policy and prompt sources. It is independent of the execution + // namespace exposed by Environment.Workspace().Root(). No-FS bindings leave it empty. + CompositionRoot string // Close releases provisional provider resources. It is called after creation // because ordinary bindings are reattached fresh at run entry. Close func() error @@ -230,32 +234,14 @@ func NewPlacementBinder(provider PlacementProvider) (*PlacementBinder, error) { return &PlacementBinder{provider: provider}, nil } -func configuredPlacementBinder(ctx context.Context, cfg Config) (*PlacementBinder, error) { +func configuredPlacementBinder(cfg Config) (*PlacementBinder, error) { if cfg.PlacementProvider == nil { return nil, fmt.Errorf("%w: PlacementProvider is required", ErrConfig) } if cfg.PlacementScope == "" { return nil, fmt.Errorf("%w: PlacementScope is required with PlacementProvider", ErrConfig) } - binder, err := NewPlacementBinder(cfg.PlacementProvider) - if err != nil { - return nil, err - } - // NewService runs before a listener can serve. Binding the configured - // default proves that its current record is authorized, available, - // revision-stable, and capable of constructing a complete environment. The - // result is deliberately not cached. - validation, err := binder.Bind(ctx, PlacementBindRequest{ - Selector: DefaultPlacement(), Scope: cfg.PlacementScope, - Operation: PlacementOperationCreate, - }) - if err != nil { - return nil, fmt.Errorf("server: validate default placement: %w", err) - } - if validation.Close != nil { - _ = validation.Close() - } - return binder, nil + return NewPlacementBinder(cfg.PlacementProvider) } func (s *Service) bindPlacementForCreate(ctx context.Context, profile SessionProfile, owner *session.Principal) (string, *PlacementBinding, error) { @@ -268,13 +254,20 @@ func (s *Service) bindPlacementForCreate(ctx context.Context, profile SessionPro Operation: PlacementOperationCreate, }) if err != nil { + s.logPlacementProviderError(ctx, "bind", err) return "", nil, err } - boundRoot := binding.Environment.Workspace().Root() - if profile == ProfileNoFS && boundRoot != "" { + executionRoot := binding.Environment.Workspace().Root() + if profile == ProfileNoFS && executionRoot != "" { + discardPlacementBinding(binding) return "", nil, ErrInvalidPlacementBinding } - return boundRoot, &binding, nil + compositionRoot, err := PlacementCompositionRoot(binding) + if err != nil { + discardPlacementBinding(binding) + return "", nil, err + } + return compositionRoot, &binding, nil } func (s *Service) persistPlacedCreatedSession(ctx context.Context, sess *session.Session, owner *session.Principal, request *createRequest, placement *PlacementBinding) (*session.Session, error) { @@ -311,18 +304,38 @@ func (s *Service) resolveSchedulePlacement(ctx context.Context, ref session.Envi return binding.Ref, string(s.cfg.PlacementScope), ProfileDefault, nil } -func (s *Service) privateWorkspace(ctx context.Context, sess *session.Session) (string, error) { - s.mu.Lock() - env, ok := s.sessionEnvironments[sess.ID] - s.mu.Unlock() - if ok && env.Ref() == sess.EnvironmentRef && env.Workspace() != nil { - return env.Workspace().Root(), nil - } +func (s *Service) privateCompositionRoot(ctx context.Context, sess *session.Session) (string, error) { binding, err := s.ReattachPlacement(ctx, sess.EnvironmentRef) if err != nil { return "", err } - return binding.Environment.Workspace().Root(), nil + if binding.Close != nil { + defer func() { _ = binding.Close() }() + } + return PlacementCompositionRoot(binding) +} + +// PlacementCompositionRoot returns the validated host-side project root for a binding. +// It never treats a guest execution root as host composition context. +func PlacementCompositionRoot(binding PlacementBinding) (string, error) { + if binding.Ref.Kind == session.EnvKindNoFS { + if binding.CompositionRoot != "" { + return "", ErrInvalidPlacementBinding + } + return "", nil + } + if binding.CompositionRoot != "" { + return binding.CompositionRoot, nil + } + // Local providers historically exposed one host namespace for both execution + // and composition. Preserve that internal compatibility without ever treating + // a non-local guest root (notably microVM /workspace) as a host path. + if binding.Ref.Kind == session.EnvKindLocal { + return binding.Environment.Workspace().Root(), nil + } + // Other remote placements may intentionally have no host-side project context. + // MicroVM cannot reach this branch because validation requires CompositionRoot. + return "", nil } // Bind validates the request, delegates exactly one atomic operation to the @@ -340,6 +353,7 @@ func (b *PlacementBinder) Bind(ctx context.Context, req PlacementBindRequest) (P return PlacementBinding{}, sanitizePlacementProviderError(err) } if err := validatePlacementBinding(binding); err != nil { + discardPlacementBinding(binding) return PlacementBinding{}, err } return binding, nil @@ -364,14 +378,22 @@ func (b *PlacementBinder) Reattach(ctx context.Context, req PlacementReattachReq return PlacementBinding{}, sanitizePlacementProviderError(err) } if binding.Ref != req.Ref { + discardPlacementBinding(binding) return PlacementBinding{}, ErrInvalidPlacementBinding } if err := validatePlacementBinding(binding); err != nil { + discardPlacementBinding(binding) return PlacementBinding{}, err } return binding, nil } +func discardPlacementBinding(binding PlacementBinding) { + if binding.Close != nil { + _ = binding.Close() + } +} + func clonePlacementRequest(req PlacementBindRequest) PlacementBindRequest { req.Principal = req.Principal.Clone() return req @@ -405,6 +427,10 @@ func validatePlacementBinding(binding PlacementBinding) error { if binding.Environment.Ref() != binding.Ref { return ErrInvalidPlacementBinding } + if binding.Ref.Kind == session.EnvKindNoFS && binding.CompositionRoot != "" || + binding.Ref.Kind == session.EnvironmentKind("microvm") && binding.CompositionRoot == "" { + return ErrInvalidPlacementBinding + } if runner := binding.Environment.CommandRunner(); runner != nil { bound, ok := runner.(boundWorkspaceRunner) if !ok || bound.BoundWorkspaceRoot() != binding.Environment.Workspace().Root() { diff --git a/internal/adapter/server/placement_composition_test.go b/internal/adapter/server/placement_composition_test.go new file mode 100644 index 000000000..d194cef38 --- /dev/null +++ b/internal/adapter/server/placement_composition_test.go @@ -0,0 +1,334 @@ +package server_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/stacklok/mecatl/engine/adapter/memfs" + "github.com/stacklok/mecatl/engine/adapter/memledger" + "github.com/stacklok/mecatl/engine/adapter/memstore" + "github.com/stacklok/mecatl/engine/adapter/mockllm" + "github.com/stacklok/mecatl/engine/adapter/nofs" + "github.com/stacklok/mecatl/engine/agent" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/internal/adapter/mcp" + microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" + "github.com/stacklok/mecatl/internal/adapter/server" +) + +type placementLifecycleSpy struct { + compositionRoot string + binds atomic.Int32 + defaultBinds atomic.Int32 + noFSBinds atomic.Int32 + reattaches atomic.Int32 + closes atomic.Int32 +} + +func (p *placementLifecycleSpy) Bind(_ context.Context, req server.PlacementBindRequest) (server.PlacementBinding, error) { + p.binds.Add(1) + if req.Selector.IsNoFS() { + p.noFSBinds.Add(1) + ref := session.EnvironmentRef{Kind: session.EnvKindNoFS, ID: "no-fs", Revision: "v1"} + return server.PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, nofs.New(), memledger.New(), nil)}, nil + } + p.defaultBinds.Add(1) + return p.binding(), nil +} + +func (p *placementLifecycleSpy) Reattach(_ context.Context, req server.PlacementReattachRequest) (server.PlacementBinding, error) { + p.reattaches.Add(1) + binding := p.binding() + binding.Ref = req.Ref + binding.Environment = tool.MustEnvironment(req.Ref, memfs.NewWorkspace("/workspace"), memledger.New(), nil) + binding.Close = nil + return binding, nil +} + +func (p *placementLifecycleSpy) binding() server.PlacementBinding { + ref := session.EnvironmentRef{Kind: "microvm", ID: "opaque", Revision: "7"} + return server.PlacementBinding{ + Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/workspace"), memledger.New(), nil), + CompositionRoot: p.compositionRoot, + Close: func() error { p.closes.Add(1); return nil }, + } +} + +func placementLifecycleConfig(provider server.PlacementProvider, store *memstore.Store) server.Config { + var ids atomic.Int32 + return server.Config{ + Engine: agent.NewEngine(agent.Deps{LLM: mockllm.New(mockllm.TextTurn("done")), Catalog: tool.NewCatalog()}), + Store: store, PlacementProvider: provider, PlacementScope: "test", SharedEngineRoot: "/host/shared", + SessionEngine: func(_ context.Context, _ server.ProviderSelector, _ []mcp.ServerConfig, _ server.SessionProfile, _ string, _ session.PermissionMode) (server.SessionEngineResult, error) { + return server.SessionEngineResult{Engine: agent.NewEngine(agent.Deps{LLM: mockllm.New(mockllm.TextTurn("done")), Catalog: tool.NewCatalog()})}, nil + }, + NewID: func() session.SessionID { + if ids.Add(1) == 1 { + return "created" + } + return "created-next" + }, + } +} + +func TestServiceConstructionDoesNotAllocatePlacement(t *testing.T) { + provider := &placementLifecycleSpy{compositionRoot: "/host/source"} + svc, err := server.NewService(placementLifecycleConfig(provider, memstore.New())) + if err != nil { + t.Fatal(err) + } + svc.Close() + if provider.binds.Load() != 0 || provider.reattaches.Load() != 0 || provider.closes.Load() != 0 { + t.Fatalf("service build/close allocated placement: bind=%d reattach=%d close=%d", provider.binds.Load(), provider.reattaches.Load(), provider.closes.Load()) + } +} + +func TestPlacementAllocationOccursOnlyForRequestedProfile(t *testing.T) { + provider := &placementLifecycleSpy{compositionRoot: "/host/source"} + svc, err := server.NewService(placementLifecycleConfig(provider, memstore.New())) + if err != nil { + t.Fatal(err) + } + defer svc.Close() + + if _, err := svc.CreateSession(context.Background(), session.ModeDefault, session.Limits{}); err != nil { + t.Fatal(err) + } + if provider.defaultBinds.Load() != 1 { + t.Fatalf("default Bind calls = %d, want 1", provider.defaultBinds.Load()) + } + if _, err := svc.CreateSessionWithProfile(context.Background(), session.ModeDefault, session.Limits{}, server.ProviderSelector{}, server.ProfileNoFS); err != nil { + t.Fatal(err) + } + if provider.defaultBinds.Load() != 1 || provider.noFSBinds.Load() != 1 { + t.Fatalf("bind calls after no-fs: default=%d no-fs=%d", provider.defaultBinds.Load(), provider.noFSBinds.Load()) + } +} + +func TestPlacementReadinessFailurePersistsNothingAndDoesNotFallBack(t *testing.T) { + store := memstore.New() + readinessCalls := 0 + provider, err := microvmadapter.NewPlacementProvider("unix:///run/unused-microvmd.sock", "/host/source", "microvm-local", "test", func(context.Context) error { + readinessCalls++ + return errors.New("readiness failed") + }) + if err != nil { + t.Fatal(err) + } + svc, err := server.NewService(placementLifecycleConfig(provider, store)) + if err != nil { + t.Fatalf("startup must not exercise readiness: %v", err) + } + if readinessCalls != 0 { + t.Fatalf("startup readiness calls = %d, want 0", readinessCalls) + } + defer svc.Close() + if _, err := svc.CreateSession(context.Background(), session.ModeDefault, session.Limits{}); err == nil { + t.Fatal("CreateSession succeeded after readiness failure") + } + if _, err := store.Load(context.Background(), "created"); err == nil { + t.Fatal("failed readiness persisted a session") + } + if readinessCalls != 1 { + t.Fatalf("create readiness calls = %d, want 1", readinessCalls) + } +} + +func TestACPUsesHostCompositionRootAndClosesRejectedBinding(t *testing.T) { + hostRoot := t.TempDir() + provider := &placementLifecycleSpy{compositionRoot: hostRoot} + svc, err := server.NewService(placementLifecycleConfig(provider, memstore.New())) + if err != nil { + t.Fatal(err) + } + defer svc.Close() + + created, err := svc.CreateACPSession(t.Context(), hostRoot, session.ModeDefault, session.Limits{}, nil, nil) + if err != nil { + t.Fatalf("ACP rejected host composition root in favor of guest /workspace: %v", err) + } + if created == nil || provider.closes.Load() != 1 { + t.Fatalf("created=%v close calls=%d, want persisted session and one provisional detach", created != nil, provider.closes.Load()) + } + + if _, err := svc.CreateACPSession(t.Context(), t.TempDir(), session.ModeDefault, session.Limits{}, nil, nil); err == nil { + t.Fatal("ACP accepted cwd outside the host composition root") + } + if provider.closes.Load() != 2 { + t.Fatalf("rejected ACP binding close calls=%d, want 2", provider.closes.Load()) + } + + if _, err := svc.CreateACPSession(t.Context(), hostRoot, session.ModeDefault, session.Limits{}, nil, func(session.SessionID, tool.Environment) (tool.Environment, error) { + return tool.Environment{}, errors.New("overlay failed") + }); err == nil { + t.Fatal("ACP accepted failed editor overlay") + } + if provider.closes.Load() != 3 { + t.Fatalf("failed-overlay binding close calls=%d, want 3", provider.closes.Load()) + } +} + +type acpBindingOwnershipSpy struct { + *placementLifecycleSpy + reattachCloses atomic.Int32 +} + +func (p *acpBindingOwnershipSpy) Reattach(_ context.Context, req server.PlacementReattachRequest) (server.PlacementBinding, error) { + p.reattaches.Add(1) + binding := p.binding() + binding.Ref = req.Ref + binding.Environment = tool.MustEnvironment(req.Ref, memfs.NewWorkspace("/workspace"), memledger.New(), nil) + binding.Close = func() error { p.reattachCloses.Add(1); return nil } + return binding, nil +} + +func TestLoadACPSessionOwnsExactBindingUntilOverrideLifecycleEnds(t *testing.T) { + overlay := func(_ session.SessionID, base tool.Environment) (tool.Environment, error) { return base, nil } + for _, tc := range []struct { + name string + shutdown bool + }{ + {name: "replacement and close session"}, + {name: "service shutdown", shutdown: true}, + } { + t.Run(tc.name, func(t *testing.T) { + hostRoot := t.TempDir() + provider := &acpBindingOwnershipSpy{placementLifecycleSpy: &placementLifecycleSpy{compositionRoot: hostRoot}} + svc, err := server.NewService(placementLifecycleConfig(provider, memstore.New())) + if err != nil { + t.Fatal(err) + } + created, err := svc.CreateACPSession(t.Context(), hostRoot, session.ModeDefault, session.Limits{}, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, err := svc.LoadACPSession(t.Context(), created.ID, hostRoot, nil, overlay); err != nil { + t.Fatal(err) + } + if got := provider.reattachCloses.Load(); got != 0 { + t.Fatalf("successful load closed live binding %d times, want 0", got) + } + if tc.shutdown { + svc.Close() + if got := provider.reattachCloses.Load(); got != 1 { + t.Fatalf("shutdown close calls = %d, want 1", got) + } + return + } + if _, err := svc.LoadACPSession(t.Context(), created.ID, hostRoot, nil, overlay); err != nil { + t.Fatal(err) + } + if got := provider.reattachCloses.Load(); got != 1 { + t.Fatalf("replacement close calls = %d, want 1", got) + } + svc.CloseSession(created.ID) + if got := provider.reattachCloses.Load(); got != 2 { + t.Fatalf("CloseSession close calls = %d, want 2", got) + } + svc.Close() + if got := provider.reattachCloses.Load(); got != 2 { + t.Fatalf("shutdown reclosed binding: got %d calls, want 2", got) + } + }) + } +} + +func TestInvalidProviderBindingIsClosed(t *testing.T) { + provider := &placementLifecycleSpy{} + svc, err := server.NewService(placementLifecycleConfig(provider, memstore.New())) + if err != nil { + t.Fatal(err) + } + defer svc.Close() + if _, err := svc.CreateSession(t.Context(), session.ModeDefault, session.Limits{}); !errors.Is(err, server.ErrInvalidPlacementBinding) { + t.Fatalf("CreateSession error = %v, want invalid placement binding", err) + } + if provider.closes.Load() != 1 { + t.Fatalf("invalid binding close calls=%d, want 1", provider.closes.Load()) + } +} + +type rootCapturingCommandLister struct{ root string } + +func (l *rootCapturingCommandLister) List(_ context.Context, root string) ([]server.Command, error) { + l.root = root + return []server.Command{{Name: "test"}}, nil +} + +func TestSlashCommandsUseHostCompositionRoot(t *testing.T) { + hostRoot := t.TempDir() + provider := &placementLifecycleSpy{compositionRoot: hostRoot} + lister := &rootCapturingCommandLister{} + cfg := placementLifecycleConfig(provider, memstore.New()) + cfg.Commands = lister + svc, err := server.NewService(cfg) + if err != nil { + t.Fatal(err) + } + defer svc.Close() + sess, err := svc.CreateSession(t.Context(), session.ModeDefault, session.Limits{}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.ListCommandsForSession(t.Context(), sess.ID); err != nil { + t.Fatal(err) + } + if lister.root != hostRoot || lister.root == "/workspace" { + t.Fatalf("command composition root = %q, want host %q", lister.root, hostRoot) + } +} + +func TestGuestExecutionRootNeverBecomesHostCompositionRoot(t *testing.T) { + hostSource := t.TempDir() + const marker = "host-source-project-policy" + if err := os.WriteFile(filepath.Join(hostSource, "project.policy"), []byte(marker), 0o600); err != nil { + t.Fatal(err) + } + provider := &placementLifecycleSpy{compositionRoot: hostSource} + store := memstore.New() + cfg := placementLifecycleConfig(provider, store) + cfg.SessionEngine = func(_ context.Context, _ server.ProviderSelector, _ []mcp.ServerConfig, profile server.SessionProfile, compositionRoot string, _ session.PermissionMode) (server.SessionEngineResult, error) { + if profile == server.ProfileNoFS { + if compositionRoot != "" { + return server.SessionEngineResult{}, errors.New("no-fs received host composition root") + } + } else { + content, err := os.ReadFile(filepath.Join(compositionRoot, "project.policy")) + if err != nil || string(content) != marker { + return server.SessionEngineResult{}, errors.New("factory did not receive exact host project root") + } + } + return server.SessionEngineResult{Engine: cfg.Engine}, nil + } + svc, err := server.NewService(cfg) + if err != nil { + t.Fatal(err) + } + defer svc.Close() + sess, err := svc.CreateSession(context.Background(), session.ModeDefault, session.Limits{}) + if err != nil { + t.Fatal(err) + } + if provider.defaultBinds.Load() != 1 { + t.Fatalf("create Bind calls = %d, want 1", provider.defaultBinds.Load()) + } + + svc.DropSessionEngineForTest(sess.ID) + run, err := svc.StartRunContent(context.Background(), sess.ID, "continue", nil) + if err != nil { + t.Fatal(err) + } + for range run.Events() { + } + if provider.reattaches.Load() == 0 { + t.Fatal("run entry did not exactly reattach the persisted placement") + } + if provider.defaultBinds.Load() != 1 { + t.Fatalf("reattach path called Bind: default Bind calls = %d", provider.defaultBinds.Load()) + } +} diff --git a/internal/adapter/server/placement_discovery.go b/internal/adapter/server/placement_discovery.go index 3dbc4cb75..5a49c8c55 100644 --- a/internal/adapter/server/placement_discovery.go +++ b/internal/adapter/server/placement_discovery.go @@ -47,14 +47,18 @@ func (s *Service) ownedSessionEnvironment(ctx context.Context, id session.Sessio // ListCommandsForSession owner-authorizes and exactly reattaches before command // discovery. A no-FS source returns empty without touching either provider. func (s *Service) ListCommandsForSession(ctx context.Context, id session.SessionID) ([]Command, error) { - sess, env, err := s.ownedSessionEnvironment(ctx, id) + sess, _, err := s.ownedSessionEnvironment(ctx, id) if err != nil { return nil, err } if sess.EnvironmentRef.Kind == session.EnvKindNoFS || s.cfg.Commands == nil { return nil, nil } - commands, err := s.cfg.Commands.List(ctx, env.Workspace().Root()) + compositionRoot, err := s.privateCompositionRoot(ctx, sess) + if err != nil { + return nil, err + } + commands, err := s.cfg.Commands.List(ctx, compositionRoot) if err != nil { s.logDiscoveryError(ctx, "list commands", err) return nil, fmt.Errorf("%w: command discovery failed", ErrInternal) diff --git a/internal/adapter/server/placement_repair_test.go b/internal/adapter/server/placement_repair_test.go index 2c10273d0..f517cdc78 100644 --- a/internal/adapter/server/placement_repair_test.go +++ b/internal/adapter/server/placement_repair_test.go @@ -35,7 +35,11 @@ type repairPlacementProvider struct { func (p *repairPlacementProvider) Bind(context.Context, PlacementBindRequest) (PlacementBinding, error) { p.binds++ - return p.binding, p.err + binding := p.binding + if binding.CompositionRoot == "" && binding.Environment.Workspace() != nil && binding.Ref.Kind != session.EnvKindNoFS { + binding.CompositionRoot = binding.Environment.Workspace().Root() + } + return binding, p.err } func (p *repairPlacementProvider) Reattach(_ context.Context, req PlacementReattachRequest) (PlacementBinding, error) { p.reattaches++ @@ -44,6 +48,9 @@ func (p *repairPlacementProvider) Reattach(_ context.Context, req PlacementReatt } binding := p.binding binding.Ref = req.Ref + if binding.CompositionRoot == "" && binding.Environment.Workspace() != nil && binding.Ref.Kind != session.EnvKindNoFS { + binding.CompositionRoot = binding.Environment.Workspace().Root() + } binding.Environment = tool.MustEnvironment(req.Ref, memfs.NewWorkspace("/fresh"), memledger.New(), nil) return binding, nil } @@ -77,6 +84,28 @@ func TestInvariant_ordinary_placement_bindings_are_not_environment_overrides(t * } } +func TestCreateSessionLogsSanitizedPlacementFailureCause(t *testing.T) { + private := "/srv/private/tenant/repository" + diag := &repairDiagnostics{} + provider := &repairPlacementProvider{err: errors.New("microVM development release descriptor identity does not match this source build at " + private)} + svc, err := NewService(Config{ + Engine: repairEngine(), Store: memstore.New(), PlacementProvider: provider, + PlacementScope: "test", SharedEngineRoot: "/bound", Diagnostics: diag, + }) + if err != nil { + t.Fatal(err) + } + defer svc.Close() + + _, err = svc.CreateSession(context.Background(), session.ModeDefault, session.Limits{}) + if !errors.Is(err, ErrPlacementUnavailable) || strings.Contains(err.Error(), private) { + t.Fatalf("public create error = %q, want content-free placement unavailable", err) + } + if !strings.Contains(diag.text, "descriptor identity does not match this source build") || !strings.Contains(diag.text, "[redacted]") || strings.Contains(diag.text, private) { + t.Fatalf("placement diagnostic was not actionable and sanitized: %q", diag.text) + } +} + func TestInvariant_placement_errors_and_metadata_are_content_free(t *testing.T) { private := "/srv/private/tenant/repository" provider := &repairPlacementProvider{err: errors.New("dial " + private + ": unavailable")} @@ -135,7 +164,7 @@ func (immediateLeaseLoss) Release(context.Context, port.Lease) error { return ni func TestInvariant_successor_lease_loss_cleans_provisional_binding(t *testing.T) { ref := session.EnvironmentRef{Kind: session.EnvKindMem, ID: "placement", Revision: "v1"} closed := &atomic.Int32{} - provider := leaseLossPlacementProvider{binding: PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/source"), memledger.New(), nil)}, closed: closed} + provider := leaseLossPlacementProvider{binding: PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace("/source"), memledger.New(), nil), CompositionRoot: "/provisional"}, closed: closed} store := memstore.New() source := session.New("source", session.ModeDefault, ref, session.Limits{}, time.Unix(1, 0)) if err := store.Save(context.Background(), source); err != nil { diff --git a/internal/adapter/server/placement_successor.go b/internal/adapter/server/placement_successor.go index dafdccb0b..6567c9655 100644 --- a/internal/adapter/server/placement_successor.go +++ b/internal/adapter/server/placement_successor.go @@ -205,7 +205,11 @@ func (s *Service) createPlacedSuccessorLocked(ctx context.Context, req ForkSucce } profile := profileForSession(created) var builtEngine *sessionEngine - if s.sessionNeedsPerFactory(selector, nil, profile, binding.Environment.Workspace().Root()) { + compositionRoot, err := PlacementCompositionRoot(binding) + if err != nil { + return "", err + } + if s.sessionNeedsPerFactory(selector, nil, profile, compositionRoot) { if s.cfg.SessionEngine == nil { return "", fmt.Errorf("%w: per-session engine not supported (no session-engine factory configured)", ErrInvalidArgument) } diff --git a/internal/adapter/server/placement_testfixture_test.go b/internal/adapter/server/placement_testfixture_test.go index 3631c1a65..748983542 100644 --- a/internal/adapter/server/placement_testfixture_test.go +++ b/internal/adapter/server/placement_testfixture_test.go @@ -72,12 +72,7 @@ func (p testPlacementProvider) Bind(_ context.Context, req server.PlacementBindR root = "/ws" } ref := session.EnvironmentRef{Kind: session.EnvKindLocal, ID: root, Revision: "in-tree-v1"} - var ws tool.Workspace - if p.firstBind != nil && !p.firstBind.Swap(true) { - ws = memfs.NewWorkspace(root) - } else { - ws = p.workspace(root) - } + ws := p.workspace(root) if ws == nil { return server.PlacementBinding{}, server.ErrPlacementUnavailable } diff --git a/internal/adapter/server/sdk_typescript_scenario11_test.go b/internal/adapter/server/sdk_typescript_scenario11_test.go index 2c2961de4..45b521ec0 100644 --- a/internal/adapter/server/sdk_typescript_scenario11_test.go +++ b/internal/adapter/server/sdk_typescript_scenario11_test.go @@ -155,7 +155,8 @@ func exerciseSDKScenario11IdentityGate(t *testing.T, script string) { repo := t.TempDir() runGit := func(args ...string) string { t.Helper() - cmd := exec.Command("git", args...) + gitArgs := append([]string{"-c", "tag.gpgSign=false"}, args...) + cmd := exec.Command("git", gitArgs...) cmd.Dir = repo output, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/adapter/server/service.go b/internal/adapter/server/service.go index 0665b87b8..23768ebb7 100644 --- a/internal/adapter/server/service.go +++ b/internal/adapter/server/service.go @@ -189,11 +189,11 @@ type ResolvedModel struct { // and apply the no-FS prompt posture. A no-FS session ALWAYS routes through this // factory — the shared engine has the FS tools baked in. // -// workspace is the SESSION's workspace root (issue #32): the factory pins the -// per-session engine's CHILD permission resolver to it, so a per-session +// compositionRoot is the SESSION's trusted host project-composition root (issue #32): +// the factory pins the per-session engine's CHILD permission resolver to it, so a per-session // engine's subagents/members/branches resolve project permission rules from -// THEIR session's pre-fork base root — never the server flag's root, and never -// a fork root. Empty (a no-fs session, or a resume that persisted none) pins no +// THEIR session's pre-fork base root — never the execution environment's guest root, +// the server flag's root, or a fork root. Empty (a no-fs session) pins no // project root (user/CLI rules only). // // mode is the session's PermissionMode (ADR 0030 Layer 3, the mode→model @@ -376,10 +376,10 @@ type Config struct { // It is read-only and called per request (discovery is cheap file scanning). Commands CommandLister - // SharedEngineRoot is the verified workspace root the shared engine's policy - // collaborators were assembled for. Every filesystem-capable placement with a - // different root must use SessionEngine, including custom environment kinds and - // rootless shared deployments. + // SharedEngineRoot is the verified host project-composition root the shared + // engine's policy collaborators were assembled for. Every filesystem-capable + // placement with a different composition root must use SessionEngine, including + // guest execution namespaces, custom environment kinds, and rootless shared deployments. SharedEngineRoot string // Agents is the resolved agent-definition snapshot taken at startup. It backs @@ -933,6 +933,11 @@ type Service struct { // does NOT guess a ref or runner from the override's presence — every // override carries its own truthful identity (issue #462 phase-2 finding #2). sessionEnvironments map[session.SessionID]tool.Environment + // sessionEnvironmentCloses owns the placement handle behind an environment + // override when that handle must remain live with the override (notably ACP + // exact reattachment). Replacement, CloseSession, and Service.Close each + // detach it exactly once. Guarded by mu. + sessionEnvironmentCloses map[session.SessionID]func() error // reservedIDs holds caller-chosen session ids (WithSessionID) that are // mid-create: reserved under s.mu at the top of createSession and released @@ -1304,23 +1309,15 @@ func normalizeServerImplementation(value string) string { return value } -// NewService validates cfg and constructs a Service using a background startup -// context. Composition roots with a lifecycle context should call -// NewServiceContext. +// NewService validates cfg and constructs a Service. func NewService(cfg Config) (*Service, error) { - return NewServiceContext(context.Background(), cfg) -} - -// NewServiceContext validates cfg and constructs a Service. ctx bounds and -// propagates trusted startup context to configured placement providers. -func NewServiceContext(ctx context.Context, cfg Config) (*Service, error) { if cfg.Engine == nil { return nil, fmt.Errorf("%w: Engine is required", ErrConfig) } if cfg.Store == nil { return nil, fmt.Errorf("%w: Store is required", ErrConfig) } - placementBinder, err := configuredPlacementBinder(ctx, cfg) + placementBinder, err := configuredPlacementBinder(cfg) if err != nil { return nil, err } @@ -1366,25 +1363,26 @@ func NewServiceContext(ctx context.Context, cfg Config) (*Service, error) { } _, shutdownCancel := context.WithCancel(context.Background()) svc := &Service{ - cfg: cfg, - placementBinder: placementBinder, - shutdownCancel: shutdownCancel, - runs: make(map[session.SessionID]*runState), - teams: make(map[string]*teamState), - sessionEngines: make(map[session.SessionID]*sessionEngine), - brokerAttachments: make(map[session.SessionID]brokercontract.Attachment), - authorizationExpiry: make(map[session.SessionID]*authorizationExpiry), - sessionEnvironments: make(map[session.SessionID]tool.Environment), - reservedIDs: make(map[session.SessionID]struct{}), - runEntryGenerations: make(map[session.SessionID]uint64), - replayedApprovals: make(map[session.SessionID]struct{}), - steerMsgIDs: make(map[session.SessionID][]steerMsgID), - heldLeases: make(map[session.SessionID]*heldLease), - lostOwnership: make(map[session.SessionID]struct{}), - cleanupTokenKey: cleanupTokenKey, - cleanupPlans: make(map[string]cleanupTokenPayload), - cleanupJobs: make(map[string]cleanupJobRecord), - subscriptions: make(map[session.SessionID]map[int64]chan session.Event), + cfg: cfg, + placementBinder: placementBinder, + shutdownCancel: shutdownCancel, + runs: make(map[session.SessionID]*runState), + teams: make(map[string]*teamState), + sessionEngines: make(map[session.SessionID]*sessionEngine), + brokerAttachments: make(map[session.SessionID]brokercontract.Attachment), + authorizationExpiry: make(map[session.SessionID]*authorizationExpiry), + sessionEnvironments: make(map[session.SessionID]tool.Environment), + sessionEnvironmentCloses: make(map[session.SessionID]func() error), + reservedIDs: make(map[session.SessionID]struct{}), + runEntryGenerations: make(map[session.SessionID]uint64), + replayedApprovals: make(map[session.SessionID]struct{}), + steerMsgIDs: make(map[session.SessionID][]steerMsgID), + heldLeases: make(map[session.SessionID]*heldLease), + lostOwnership: make(map[session.SessionID]struct{}), + cleanupTokenKey: cleanupTokenKey, + cleanupPlans: make(map[string]cleanupTokenPayload), + cleanupJobs: make(map[string]cleanupJobRecord), + subscriptions: make(map[session.SessionID]map[int64]chan session.Event), } svc.titleCoordinator = buildTitleCoordinator(svc, cfg) // Narrow the durable log to the cursor seam once (ADR 0250). A backend that @@ -2172,8 +2170,12 @@ func (s *Service) createSession(ctx context.Context, mode session.PermissionMode return nil, err } placement = opts.placement - workspace = placement.Environment.Workspace().Root() - if profile == ProfileNoFS && workspace != "" || profile != ProfileNoFS && workspace == "" { + executionRoot := placement.Environment.Workspace().Root() + workspace, err = PlacementCompositionRoot(*placement) + if err != nil { + return nil, err + } + if profile == ProfileNoFS && executionRoot != "" || profile != ProfileNoFS && executionRoot == "" { return nil, ErrInvalidPlacementBinding } } else { @@ -2685,9 +2687,22 @@ func (s *Service) CreateSessionWithMCP(ctx context.Context, mode session.Permiss // tracks the session and does this on disconnect). The gRPC/HTTP surfaces never // call this, so their environment path is unchanged (issue #462 phase-2 finding #2). func (s *Service) SetSessionEnvironment(id session.SessionID, env tool.Environment) { + s.setSessionEnvironment(id, env, nil) +} + +func (s *Service) setSessionEnvironment(id session.SessionID, env tool.Environment, closeFn func() error) { s.mu.Lock() + priorClose := s.sessionEnvironmentCloses[id] s.sessionEnvironments[id] = env + if closeFn == nil { + delete(s.sessionEnvironmentCloses, id) + } else { + s.sessionEnvironmentCloses[id] = closeFn + } s.mu.Unlock() + if priorClose != nil { + _ = priorClose() + } } // CloseSession tears down the per-session engine registered for id (if any) and @@ -2771,7 +2786,10 @@ func (s *Service) closeSessionLocal(id session.SessionID) { brokerAttachment := s.brokerAttachments[id] delete(s.brokerAttachments, id) // Drop any per-session environment override too: it closes over the (now - // disconnecting) connection, so it must not outlive the session. + // disconnecting) connection, so it must not outlive the session. A retained + // placement handle is detached after releasing the registry lock. + environmentClose := s.sessionEnvironmentCloses[id] + delete(s.sessionEnvironmentCloses, id) delete(s.sessionEnvironments, id) // Drop the once-per-id approval-replay marker (cloud-native Phase 3b): the // OnCloseSession above Forgot this session's learned rules, so a LATER reload of @@ -2795,6 +2813,11 @@ func (s *Service) closeSessionLocal(id session.SessionID) { if expiry != nil && expiry.timer != nil { expiry.timer.Stop() } + if environmentClose != nil { + if err := environmentClose(); err != nil { + s.cfg.Diagnostics.Log(context.Background(), port.LevelWarn, "session environment placement close failed") + } + } if ok && se.close != nil { if err := se.close(); err != nil { s.cfg.Diagnostics.Log(context.Background(), port.LevelWarn, "per-session engine close failed") @@ -2932,9 +2955,10 @@ func (s *Service) Close() { s.sessionEngines = make(map[session.SessionID]*sessionEngine) brokerAttachments := s.brokerAttachments s.brokerAttachments = make(map[session.SessionID]brokercontract.Attachment) - // Drop all per-session environment overrides on shutdown; they hold no resources - // of their own (the underlying connection is closed separately) but must not - // linger past the Service. + // Drop all per-session environment overrides on shutdown and retain their + // placement detach callbacks for exactly-once cleanup outside the lock. + environmentCloses := s.sessionEnvironmentCloses + s.sessionEnvironmentCloses = make(map[session.SessionID]func() error) s.sessionEnvironments = make(map[session.SessionID]tool.Environment) // Close all per-session event subscriptions so subscriber goroutines can exit // cleanly. Close owns both shutdown and channel closure while holding subMu: a @@ -2991,6 +3015,13 @@ func (s *Service) Close() { attachmentWG.Wait() cancelAttachments() + for _, closeEnvironment := range environmentCloses { + if closeEnvironment != nil { + if err := closeEnvironment(); err != nil { + s.cfg.Diagnostics.Log(context.Background(), port.LevelWarn, "session environment placement close failed during shutdown") + } + } + } // Stop every renewer and release every held cross-process lease on shutdown // (cloud-native Phase 4), so a restarted process can take the sessions over // without waiting out the TTL. Best-effort (detached short-timeout ctx). @@ -3354,7 +3385,7 @@ func (s *Service) CompactSession(ctx context.Context, id session.SessionID, call if err := admitRunPurpose(sess, runPurposeChat); err != nil { return agent.ManualCompactionResult{}, err } - eng, _, err := s.engineAndEnvironmentFor(ctx, sess) + eng, _, _, err := s.engineAndEnvironmentFor(ctx, sess) if err != nil { return agent.ManualCompactionResult{}, err } @@ -4168,7 +4199,7 @@ func (s *Service) LoadSessionWithMCP(ctx context.Context, id session.SessionID, // loaded session's persisted Mode (ADR 0030 Layer 3), so a session loaded into plan // mode mounts the plan model; builtForMode is stamped from the result so a later // in-process mode switch on this reloaded session triggers the CASE 1 rebuild. - workspace, err := s.privateWorkspace(ctx, sess) + workspace, err := s.privateCompositionRoot(ctx, sess) if err != nil { return nil, err } @@ -4316,7 +4347,7 @@ func (s *Service) RetryFailedRun(ctx context.Context, id session.SessionID) (*ag } }() ctx = admissionCtx - engine, env, err := s.engineAndEnvironmentFor(ctx, sess) + engine, env, compositionRoot, err := s.engineAndEnvironmentFor(ctx, sess) if err != nil { return nil, err } @@ -4330,7 +4361,7 @@ func (s *Service) RetryFailedRun(ctx context.Context, id session.SessionID) (*ag if !leaseHeld() { return nil, fmt.Errorf("%w: %q", ErrSessionLeasedElsewhere, id) } - ctx = memory.WithWorkspace(ctx, env.Workspace().Root()) + ctx = memory.WithWorkspace(ctx, compositionRoot) run, err := s.promoteRunAdmission(id, st, stopAdmission, func() *agent.Run { return engine.RetryFailedStep(ctx, sess, env) }) @@ -4549,7 +4580,7 @@ func (s *Service) startRunContent(ctx context.Context, id session.SessionID, tex } }() } - engine, env, err := s.engineAndEnvironmentFor(ctx, sess) + engine, env, compositionRoot, err := s.engineAndEnvironmentFor(ctx, sess) if err != nil { return nil, err } @@ -4564,7 +4595,7 @@ func (s *Service) startRunContent(ctx context.Context, id session.SessionID, tex if !leaseHeld() { return nil, fmt.Errorf("%w: %q", ErrSessionLeasedElsewhere, id) } - ctx = memory.WithWorkspace(ctx, env.Workspace().Root()) + ctx = memory.WithWorkspace(ctx, compositionRoot) run, err := s.promoteRunAdmission(id, st, stopAdmission, func() *agent.Run { return engine.Run(ctx, sess, env, agent.RunRequest{Text: text, Parts: parts, RunID: runID, CanPresentAuthorization: canPresentAuthorization}) }) @@ -4909,7 +4940,7 @@ func admitRunPurpose(sess *session.Session, purpose runPurpose) error { // plan slot is active): PROMOTE the default-FS session to a per-session factory // engine. ModeNeedsEngine is nil (no plan slot) ⇒ this never fires and the session // keeps the shared engine — BYTE-IDENTICAL to pre-Phase-3. -func (s *Service) engineAndEnvironmentFor(ctx context.Context, sess *session.Session) (*agent.Engine, tool.Environment, error) { //nolint:gocyclo // the per-session engine/environment resolution is inherently branched +func (s *Service) engineAndEnvironmentFor(ctx context.Context, sess *session.Session) (*agent.Engine, tool.Environment, string, error) { //nolint:gocyclo // the per-session engine/environment resolution is inherently branched id := sess.ID attribution := s.ResolvedModel(id) sess.SetUsageAttribution(attribution.ProviderID, attribution.ModelID) @@ -4919,26 +4950,30 @@ func (s *Service) engineAndEnvironmentFor(ctx context.Context, sess *session.Ses envOverride, hasEnvOverride := s.sessionEnvironments[id] s.mu.Unlock() if !sess.EnvironmentRef.Valid() { - return nil, tool.Environment{}, ErrInvalidPlacementSelection + return nil, tool.Environment{}, "", ErrInvalidPlacementSelection } verified, err := s.ReattachPlacement(ctx, sess.EnvironmentRef) if err != nil { - return nil, tool.Environment{}, err + return nil, tool.Environment{}, "", err } if s.cfg.SessionReadLedger != nil { ledger := s.cfg.SessionReadLedger(id) if ledger == nil { - return nil, tool.Environment{}, fmt.Errorf("%w: session read-ledger factory returned nil", ErrConfig) + return nil, tool.Environment{}, "", fmt.Errorf("%w: session read-ledger factory returned nil", ErrConfig) } verified.Environment, err = tool.NewEnvironment(verified.Ref, verified.Environment.Workspace(), ledger, verified.Environment.CommandRunner()) if err != nil { - return nil, tool.Environment{}, fmt.Errorf("%w: bind session read ledger: %v", ErrConfig, err) + return nil, tool.Environment{}, "", fmt.Errorf("%w: bind session read ledger: %v", ErrConfig, err) } } verifiedPlacement := &verified + compositionRoot, err := PlacementCompositionRoot(verified) + if err != nil { + return nil, tool.Environment{}, "", err + } if hasEnvOverride { if err := s.validateEnvironmentOverride(sess, envOverride, verifiedPlacement.Environment); err != nil { - return nil, tool.Environment{}, err + return nil, tool.Environment{}, "", err } } switch { @@ -4957,13 +4992,13 @@ func (s *Service) engineAndEnvironmentFor(ctx context.Context, sess *session.Ses live := st != nil && st.run != nil s.mu.Unlock() if live { - return nil, tool.Environment{}, fmt.Errorf("%w: cannot rebuild engine for session %q mid-run (mode change must be deferred to a turn boundary)", ErrInvalidArgument, id) + return nil, tool.Environment{}, "", fmt.Errorf("%w: cannot rebuild engine for session %q mid-run (mode change must be deferred to a turn boundary)", ErrInvalidArgument, id) } sel := ProviderSelector{ProviderID: sess.ProviderID, ModelID: sess.ModelID, ReasoningEffort: sess.ReasoningEffort} profile := profileForSession(sess) rebuilt, err := s.buildAndRegisterSessionEngine(ctx, sess, sel, profile, sess.Mode, true) if err != nil { - return nil, tool.Environment{}, err + return nil, tool.Environment{}, "", err } se, hasEngine = rebuilt, true // The environment override may have been (re-)registered by the rebuild (no-fs). @@ -4979,13 +5014,13 @@ func (s *Service) engineAndEnvironmentFor(ctx context.Context, sess *session.Ses sel := ProviderSelector{ProviderID: sess.ProviderID, ModelID: sess.ModelID, ReasoningEffort: sess.ReasoningEffort} promoted, err := s.buildAndRegisterSessionEngine(ctx, sess, sel, ProfileDefault, sess.Mode, false) if err != nil { - return nil, tool.Environment{}, err + return nil, tool.Environment{}, "", err } se, hasEngine = promoted, true } placementNeedsEngine := !hasEngine && !s.needsRehydration(sess) && sess.EnvironmentRef.Kind != session.EnvKindNoFS && - verifiedPlacement.Environment.Workspace().Root() != s.cfg.SharedEngineRoot + compositionRoot != s.cfg.SharedEngineRoot if !hasEngine && (s.needsRehydration(sess) || placementNeedsEngine) { // RESTART REHYDRATION (issue #55, widened in the cloud-native Phase 1): a // PERSISTED session that needed a PER-SESSION engine — a non-default @@ -4999,7 +5034,7 @@ func (s *Service) engineAndEnvironmentFor(ctx context.Context, sess *session.Ses var err error se, err = s.rehydrateSession(ctx, sess) if err != nil { - return nil, tool.Environment{}, err + return nil, tool.Environment{}, "", err } hasEngine = true // Placement environments are never restored from the override registry; @@ -5014,9 +5049,9 @@ func (s *Service) engineAndEnvironmentFor(ctx context.Context, sess *session.Ses // overrides are complete environments: the creator supplied the accurate ref // and the correct (possibly nil) CommandRunner. Use them directly; never guess // a ref, namespace, or runner from the override's presence. - return engine, envOverride, nil + return engine, envOverride, compositionRoot, nil } - return engine, verifiedPlacement.Environment, nil + return engine, verifiedPlacement.Environment, compositionRoot, nil } // sessionNeedsPerFactory reports whether a CreateSession with the given inputs @@ -5169,7 +5204,7 @@ func (s *Service) buildAndRegisterSessionEngineWithBrokerTools(ctx context.Conte } res, err = s.cfg.DebugSessionEngine(ctx, sel, profile, mode, sess.Relationship.DebugTargetID, sess.DebugTargetFingerprint, target.Owner, sess.DebugMCPServers, sess.DebugMCPTools) } else if useExactTools { - workspace, workspaceErr := s.privateWorkspace(ctx, sess) + workspace, workspaceErr := s.privateCompositionRoot(ctx, sess) if workspaceErr != nil { return nil, workspaceErr } @@ -5180,7 +5215,7 @@ func (s *Service) buildAndRegisterSessionEngineWithBrokerTools(ctx context.Conte return nil, err } defer s.finalizeBrokerAttachment(broker, &brokerCommitted) - workspace, workspaceErr := s.privateWorkspace(ctx, sess) + workspace, workspaceErr := s.privateCompositionRoot(ctx, sess) if workspaceErr != nil { return nil, workspaceErr } @@ -5672,14 +5707,14 @@ func (s *Service) resumeFromAwaiting(ctx context.Context, id session.SessionID, } }() ctx = admissionCtx - engine, env, err := s.engineAndEnvironmentFor(ctx, sess) + engine, env, compositionRoot, err := s.engineAndEnvironmentFor(ctx, sess) if err != nil { return nil, err } if !leaseHeld() { return nil, fmt.Errorf("%w: %q", ErrSessionLeasedElsewhere, id) } - ctx = memory.WithWorkspace(ctx, env.Workspace().Root()) + ctx = memory.WithWorkspace(ctx, compositionRoot) run, err := s.promoteRunAdmission(id, st, stopAdmission, func() *agent.Run { return engine.ResumeApproval(ctx, sess, env, askID, verdict) }) diff --git a/internal/apicheck/microvm_module_contract_test.go b/internal/apicheck/microvm_module_contract_test.go index 566f46177..3398a8481 100644 --- a/internal/apicheck/microvm_module_contract_test.go +++ b/internal/apicheck/microvm_module_contract_test.go @@ -93,7 +93,7 @@ func assertProductionImportsExclude(t *testing.T, root, excluded string) { return walkErr } if entry.IsDir() { - if path == excluded || (root != filepath.Join(repoRoot(t), "engine") && path == filepath.Join(root, "engine")) { + if path == excluded || path == filepath.Join(repoRoot(t), ".scratch") || (root != filepath.Join(repoRoot(t), "engine") && path == filepath.Join(root, "engine")) { return filepath.SkipDir } return nil diff --git a/internal/app/attempt_recovery.go b/internal/app/attempt_recovery.go index c013cb889..796eb354e 100644 --- a/internal/app/attempt_recovery.go +++ b/internal/app/attempt_recovery.go @@ -162,7 +162,10 @@ func recoverAttempt(ctx context.Context, cfg Config, reg *providerRegistry, sess if err != nil || binding.Ref != source.EnvironmentRef || binding.Environment.Workspace() == nil { return learning.FailureNone, errAttemptSetupTransient } - workspace = binding.Environment.Workspace().Root() + workspace, err = server.PlacementCompositionRoot(binding) + if err != nil { + return learning.FailureNone, errAttemptSetupTransient + } closePlacement = binding.Close if record.CheckpointStage == learning.AttemptCheckpointNone { return learning.FailureNone, nil diff --git a/internal/app/build.go b/internal/app/build.go index 3ae085d4c..fb99339c0 100644 --- a/internal/app/build.go +++ b/internal/app/build.go @@ -70,6 +70,7 @@ import ( "github.com/stacklok/mecatl/internal/adapter/mcpauthority" "github.com/stacklok/mecatl/internal/adapter/mcpbroker" "github.com/stacklok/mecatl/internal/adapter/memory" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" "github.com/stacklok/mecatl/internal/adapter/modelhook" "github.com/stacklok/mecatl/internal/adapter/openaicodex" "github.com/stacklok/mecatl/internal/adapter/osfs" @@ -145,6 +146,21 @@ type Config struct { // PlacementScope is the trusted authorization scope passed to the provider. // Empty defaults to the process deployment scope. PlacementScope server.PlacementScope + // DefaultPlacement and MicroVMGuestEgress are command-root overrides for the + // operator execution policy. Their Set bits preserve omission so settings.yaml + // remains authoritative unless a serve flag was actually supplied. + DefaultPlacement string + DefaultPlacementSet bool + MicroVMGuestEgress microvmmanager.GuestEgressSelection + MicroVMGuestEgressSet bool + MicroVMReadyRequest func(microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) + MicroVMManagerFactory func() (MicroVMReadyManager, string, error) + // MicroVMReadinessObserver receives bounded, secret-free preparation updates. + // MicroVMReadinessFailureHint is appended to a stable preparation error without + // exposing the manager's private paths or process output. + MicroVMReadinessObserver microvmmanager.ReadinessObserver + MicroVMReadinessFailed func(microvmmanager.ReadinessStage) + MicroVMReadinessFailureHint string // ClientMCPOnCreate permits client-provided MCP servers on a session-creating // API request (issue #821, ADR 0237 applied to outbound MCP). It is a // deployment policy the cmd/ main decides from its listener topology and Build passes through verbatim; the zero value fails @@ -1536,6 +1552,11 @@ func Build(ctx context.Context, cfg Config) (*Built, error) { // SAME instance — one discovery pass, one cache, no per-consumer drift. cfg.permResolver = buildPermResolver(cfg) cfg.childPermResolver = buildChildPermResolver(cfg) + var executionErr error + cfg, executionErr = foldExecution(cfg) + if executionErr != nil { + return nil, executionErr + } var temporaryStorageErr error cfg, temporaryStorageErr = foldOperatorTemporaryStorage(cfg) if temporaryStorageErr != nil { @@ -2567,7 +2588,7 @@ func Build(ctx context.Context, cfg Config) (*Built, error) { } applyTeamConfig(&svcCfg, cfg, reg, provider, mainMgr, agentReg, assets.skillIndex, assets) - svc, err := server.NewServiceContext(ctx, svcCfg) + svc, err := server.NewService(svcCfg) if err != nil { closeBroker() childLiveness.Close() diff --git a/internal/app/environment_test.go b/internal/app/environment_test.go index 63060246a..3e8718dbc 100644 --- a/internal/app/environment_test.go +++ b/internal/app/environment_test.go @@ -48,7 +48,7 @@ func (p appTestPlacementProvider) Bind(_ context.Context, req server.PlacementBi } root := p.root ref := session.EnvironmentRef{Kind: session.EnvKindMem, ID: root, Revision: "test-v1"} - return server.PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace(root), memledger.New(), nil)}, nil + return server.PlacementBinding{Ref: ref, Environment: tool.MustEnvironment(ref, memfs.NewWorkspace(root), memledger.New(), nil), CompositionRoot: root}, nil } func (appTestPlacementProvider) ListWorktrees(context.Context, server.PlacementDiscoveryRequest) ([]server.ScopedWorktree, error) { return nil, nil @@ -62,7 +62,7 @@ func (p appTestPlacementProvider) Reattach(_ context.Context, req server.Placeme if req.Ref.Kind == session.EnvKindNoFS { return server.PlacementBinding{Ref: req.Ref, Environment: tool.MustEnvironment(req.Ref, nofs.New(), memledger.New(), nil)}, nil } - return server.PlacementBinding{Ref: req.Ref, Environment: tool.MustEnvironment(req.Ref, memfs.NewWorkspace(root), memledger.New(), nil)}, nil + return server.PlacementBinding{Ref: req.Ref, Environment: tool.MustEnvironment(req.Ref, memfs.NewWorkspace(root), memledger.New(), nil), CompositionRoot: root}, nil } func newTestServerService(cfg server.Config) (*server.Service, error) { @@ -80,7 +80,7 @@ func newTestServerService(cfg server.Config) (*server.Service, error) { cfg.PlacementProvider = appTestPlacementProvider{root: root} cfg.PlacementScope = "legacy-local" } - return server.NewServiceContext(context.Background(), cfg) + return server.NewService(cfg) } func memEnvironment(root string) tool.Environment { diff --git a/internal/app/execution.go b/internal/app/execution.go new file mode 100644 index 000000000..30d015a7d --- /dev/null +++ b/internal/app/execution.go @@ -0,0 +1,170 @@ +package app + +import ( + "context" + "errors" + "fmt" + + "github.com/stacklok/mecatl/engine/port" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" + "github.com/stacklok/mecatl/internal/adapter/permconfig" + "github.com/stacklok/mecatl/internal/adapter/server" +) + +const ( + // PlacementHostLocal selects execution in the host-local environment. + PlacementHostLocal = "host-local" + // PlacementMicroVMLocal selects execution in a local MicroVM environment. + PlacementMicroVMLocal = "microvm-local" +) + +// MicroVMReadyManager is the narrow manager capability needed by composition. +type MicroVMReadyManager interface { + EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) +} + +type resolvedExecution struct { + placement string + egress microvmmanager.GuestEgressSelection +} + +// ConfigureExecution applies the resolved execution policy and wires any selected +// backend. Build calls the same fold after constructing the operator resolver. +func ConfigureExecution(cfg Config) (Config, error) { + if cfg.permResolver == nil { + cfg.permResolver = buildPermResolver(cfg) + } + return foldExecution(cfg) +} + +func foldExecution(cfg Config) (Config, error) { + resolved := resolvedExecution{ + placement: PlacementHostLocal, + egress: microvmmanager.NewGuestEgressSelection(), + } + settingsEgress := false + if resolver, ok := cfg.permResolver.(*permconfig.Resolver); ok { + section, err := resolver.OperatorExecution() + if err != nil { + return cfg, fmt.Errorf("operator execution configuration: %w", err) + } + if section != nil { + resolved.placement = section.DefaultPlacement + if section.MicroVM != nil && section.MicroVM.GuestEgress != nil { + settingsEgress = true + resolved.egress, err = microvmmanager.ParseGuestEgressSelection( + section.MicroVM.GuestEgress.Mode, + section.MicroVM.GuestEgress.Allow, + ) + if err != nil { + return cfg, fmt.Errorf("execution.microvm.guest_egress.allow: %w", err) + } + } + } + } + if cfg.DefaultPlacementSet { + resolved.placement = cfg.DefaultPlacement + } + if cfg.MicroVMGuestEgressSet { + resolved.egress = cfg.MicroVMGuestEgress + } + if resolved.placement != PlacementHostLocal && resolved.placement != PlacementMicroVMLocal { + return cfg, fmt.Errorf("unsupported execution placement %q", resolved.placement) + } + if err := resolved.egress.Validate(); err != nil { + if settingsEgress && !cfg.MicroVMGuestEgressSet { + return cfg, fmt.Errorf("execution.microvm.guest_egress: %w", err) + } + return cfg, err + } + if resolved.placement == PlacementHostLocal { + if cfg.MicroVMGuestEgressSet { + return cfg, errors.New("microVM guest egress flags require microvm-local placement") + } + return cfg, nil + } + return configureMicroVMExecution(cfg, resolved.egress) +} + +func configureMicroVMExecution(cfg Config, egress microvmmanager.GuestEgressSelection) (Config, error) { + if cfg.MicroVMReadyRequest == nil { + return cfg, errors.New("microvm-local placement requires release readiness configuration") + } + factory := cfg.MicroVMManagerFactory + if factory == nil { + factory = func() (MicroVMReadyManager, string, error) { + manager, endpoint, err := microvmmanager.DefaultLocal() + return manager, endpoint, err + } + } + manager, endpoint, err := factory() + if err != nil { + return cfg, err + } + if manager == nil || endpoint == "" { + return cfg, errors.New("microvm-local readiness manager and endpoint are required") + } + if cfg.MicroVMReadinessObserver != nil && egress.Mode == microvmmanager.GuestEgressPermissive { + cfg.MicroVMReadinessObserver(microvmmanager.StagePrepare, "Guest IPv4 egress is permissive by default; set execution.microvm.guest_egress.mode to deny-all or allowlist before first use") + } + readiness := func(ctx context.Context) error { + request, requestErr := cfg.MicroVMReadyRequest(egress) + if requestErr != nil { + if cfg.MicroVMReadinessFailed != nil { + cfg.MicroVMReadinessFailed(microvmmanager.StagePrepare) + } + return requestErr + } + lastStage := microvmmanager.StagePrepare + ctx = microvmmanager.WithReadinessObserver(ctx, func(stage microvmmanager.ReadinessStage, message string) { + lastStage = stage + cfg.diag().Log(ctx, port.LevelInfo, "microvm-local readiness", "stage", string(stage), "status", message) + if cfg.MicroVMReadinessObserver != nil { + cfg.MicroVMReadinessObserver(stage, message) + } + }) + readyEndpoint, readyErr := manager.EnsureReady(ctx, request) + if readyErr != nil { + if cfg.MicroVMReadinessFailed != nil { + cfg.MicroVMReadinessFailed(lastStage) + } + if cfg.MicroVMReadinessFailureHint != "" { + cfg.diag().Log(ctx, port.LevelWarn, "microvm-local readiness failed", "stage", string(lastStage), "error", readyErr) + return fmt.Errorf("microvm-local preparation failed during %s; %s", lastStage, cfg.MicroVMReadinessFailureHint) + } + return readyErr + } + if readyEndpoint != endpoint { + endpointErr := fmt.Errorf("microvm-local readiness returned unexpected endpoint %q", readyEndpoint) + if cfg.MicroVMReadinessFailed != nil { + cfg.MicroVMReadinessFailed(lastStage) + } + if cfg.MicroVMReadinessFailureHint != "" { + cfg.diag().Log(ctx, port.LevelWarn, "microvm-local readiness failed", "stage", string(lastStage), "error", endpointErr) + return fmt.Errorf("microvm-local preparation failed during %s; %s", lastStage, cfg.MicroVMReadinessFailureHint) + } + return endpointErr + } + return nil + } + const scope server.PlacementScope = "deployment" + provider, err := microvmadapter.NewPlacementProvider(endpoint, cfg.Workspace, microvmmanager.Alias, scope, readiness) + if err != nil { + return cfg, err + } + cfg.PlacementProvider = provider + cfg.PlacementScope = scope + if cfg.EnvironmentForkers == nil { + cfg.EnvironmentForkers = make(map[session.EnvironmentKind]tool.EnvironmentForker) + } + if cfg.EnvironmentMergers == nil { + cfg.EnvironmentMergers = make(map[session.EnvironmentKind]tool.EnvironmentMerger) + } + kind := session.EnvironmentKind("microvm") + cfg.EnvironmentForkers[kind] = provider + cfg.EnvironmentMergers[kind] = provider + return cfg, nil +} diff --git a/internal/app/execution_test.go b/internal/app/execution_test.go new file mode 100644 index 000000000..55f438f94 --- /dev/null +++ b/internal/app/execution_test.go @@ -0,0 +1,137 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" + "github.com/stacklok/mecatl/internal/adapter/permconfig" + "github.com/stacklok/mecatl/internal/adapter/server" +) + +type executionReadyManager struct { + err error +} + +func (m *executionReadyManager) EnsureReady(ctx context.Context, _ microvmmanager.ReadyRequest) (string, error) { + microvmmanager.ReportReadinessStage(ctx, microvmmanager.StageDownload) + if m.err != nil { + return "", m.err + } + return "unix:///run/test-microvmd.sock", nil +} + +func TestFoldExecutionConfiguresMicroVMFromOperatorSettings(t *testing.T) { + settings := writeOperatorSettingsFile(t, ` +execution: + default_placement: microvm-local + microvm: + guest_egress: + mode: deny-all +`) + manager := &executionReadyManager{} + cfg := Config{ + Workspace: t.TempDir(), + PermissionConfigs: []string{settings}, + MicroVMReadyRequest: func(selection microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + return microvmmanager.ReadyRequest{Policy: microvmmanager.Policy{GuestEgressMode: selection.Mode}}, nil + }, + MicroVMManagerFactory: func() (MicroVMReadyManager, string, error) { + return manager, "unix:///run/test-microvmd.sock", nil + }, + } + cfg.permResolver = buildPermResolver(cfg) + got, err := foldExecution(cfg) + if err != nil { + t.Fatal(err) + } + if got.PlacementProvider == nil || got.EnvironmentForkers["microvm"] == nil || got.EnvironmentMergers["microvm"] == nil { + t.Fatal("microvm-local settings did not configure the placement/fork/merge bundle") + } +} + +func TestFoldExecutionCLIOverrideWinsOnlyWhenSet(t *testing.T) { + settings := writeOperatorSettingsFile(t, `execution: {default_placement: microvm-local}`) + cfg := Config{PermissionConfigs: []string{settings}, DefaultPlacement: PlacementHostLocal, DefaultPlacementSet: true} + cfg.permResolver = buildPermResolver(cfg) + got, err := foldExecution(cfg) + if err != nil { + t.Fatal(err) + } + if got.PlacementProvider != nil { + t.Fatal("explicit host-local override did not outrank operator settings") + } +} + +func TestMicroVMReadinessProgressAndStableFailureCrossPlacementBoundary(t *testing.T) { + manager := &executionReadyManager{err: errors.New("private manager failure")} + var progress []string + cfg, err := ConfigureExecution(Config{ + Workspace: t.TempDir(), DefaultPlacement: PlacementMicroVMLocal, DefaultPlacementSet: true, + MicroVMReadyRequest: func(microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + return microvmmanager.ReadyRequest{}, nil + }, + MicroVMManagerFactory: func() (MicroVMReadyManager, string, error) { + return manager, "unix:///run/test-microvmd.sock", nil + }, + MicroVMReadinessObserver: func(_ microvmmanager.ReadinessStage, message string) { + progress = append(progress, message) + }, + MicroVMReadinessFailureHint: "next: run 'mecated microvm doctor'; diagnostics log: /state/mecatl/mecatui.log", + }) + if err != nil { + t.Fatal(err) + } + _, err = cfg.PlacementProvider.Bind(t.Context(), server.PlacementBindRequest{Selector: server.DefaultPlacement(), Scope: "deployment"}) + if err == nil { + t.Fatal("readiness failure did not cross the real placement bind boundary") + } + for _, want := range []string{"Guest IPv4 egress is permissive", "Downloading microVM components"} { + if !strings.Contains(strings.Join(progress, "\n"), want) { + t.Fatalf("progress %q omitted %q", progress, want) + } + } + for _, want := range []string{"preparation failed during download", "mecated microvm doctor", "/state/mecatl/mecatui.log"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q omitted %q", err, want) + } + } + if strings.Contains(err.Error(), "private failure") { + t.Fatalf("stable UI error leaked private manager detail: %v", err) + } +} + +func TestFoldExecutionSettingsErrorsNameSettingsKeys(t *testing.T) { + settings := writeOperatorSettingsFile(t, ` +execution: + default_placement: microvm-local + microvm: + guest_egress: + mode: allowlist + allow: [127.0.0.1:443/tcp] +`) + cfg := Config{PermissionConfigs: []string{settings}} + cfg.permResolver = buildPermResolver(cfg) + _, err := foldExecution(cfg) + if err == nil || !strings.Contains(err.Error(), "execution.microvm.guest_egress.allow") || strings.Contains(err.Error(), "--microvm-guest-allow") { + t.Fatalf("settings validation error = %v", err) + } +} + +func TestFoldExecutionRejectsMalformedDestination(t *testing.T) { + settings := writeOperatorSettingsFile(t, ` +execution: + default_placement: microvm-local + microvm: + guest_egress: + mode: allowlist + allow: [127.0.0.1:443/tcp] +`) + cfg := Config{PermissionConfigs: []string{settings}} + cfg.permResolver = permconfig.New(permconfig.Options{ExplicitFiles: cfg.PermissionConfigs}) + if _, err := foldExecution(cfg); err == nil { + t.Fatal("invalid MicroVM destination did not fail closed") + } +} diff --git a/internal/app/microvm_lifecycle_e2e_test.go b/internal/app/microvm_lifecycle_e2e_test.go index 724801ad9..45009eb86 100644 --- a/internal/app/microvm_lifecycle_e2e_test.go +++ b/internal/app/microvm_lifecycle_e2e_test.go @@ -5,41 +5,54 @@ import ( "context" "encoding/binary" "encoding/json" + "errors" + "fmt" "io" + "io/fs" "net" "os" + "os/exec" "path/filepath" + "strings" "sync" "testing" + "github.com/stacklok/mecatl/engine/adapter/mockllm" "github.com/stacklok/mecatl/engine/session" "github.com/stacklok/mecatl/engine/tool" microvmadapter "github.com/stacklok/mecatl/internal/adapter/microvm" + "github.com/stacklok/mecatl/internal/adapter/microvmmanager" "github.com/stacklok/mecatl/internal/adapter/server" ) type placementTestDaemon struct { - listener net.Listener - mu sync.Mutex - ops []string + listener net.Listener + root string + mu sync.Mutex + ops []string + next int + guests map[string]string + execBindings []string } func startPlacementTestDaemon(t *testing.T) *placementTestDaemon { t.Helper() - dir, err := os.MkdirTemp("/tmp", "mecatl-microvm-") + socketDir, err := os.MkdirTemp("/tmp", "mecatl-microvm-") if err != nil { t.Fatalf("create short private socket directory: %v", err) } + socketPath := filepath.Join(socketDir, "microvmd.sock") t.Cleanup(func() { - if err := os.RemoveAll(dir); err != nil { + _ = os.Remove(socketPath) + if err := os.Remove(socketDir); err != nil && !errors.Is(err, fs.ErrNotExist) { t.Errorf("remove short private socket directory: %v", err) } }) - listener, err := net.Listen("unix", filepath.Join(dir, "microvmd.sock")) + listener, err := net.Listen("unix", socketPath) if err != nil { t.Fatal(err) } - d := &placementTestDaemon{listener: listener} + d := &placementTestDaemon{listener: listener, root: t.TempDir(), guests: make(map[string]string)} t.Cleanup(func() { _ = listener.Close() }) go d.serve() return d @@ -53,32 +66,154 @@ func (d *placementTestDaemon) serve() { if err != nil { return } - go func() { - defer func() { _ = conn.Close() }() - var request map[string]any - if readPlacementFrame(conn, &request) != nil { - return - } - op, _ := request["operation"].(string) - d.mu.Lock() - d.ops = append(d.ops, op) - d.mu.Unlock() - binding, _ := request["binding"].(map[string]any) - response := map[string]any{"binding": binding} - if op == "create" { - provision, _ := request["provision"].(map[string]any) - sessionID, _ := provision["session_id"].(string) - binding = map[string]any{"owner": "local", "session_id": sessionID, "environment_id": "env-fixed", "ref": "env-fixed@7", "generation": 7} - response["binding"] = binding - response["created"] = map[string]any{ - "ref": map[string]any{"Kind": "microvm", "ID": "env-fixed@7"}, "generation": 7, - "host_worktree": "/private/host/path", "guest_root": "/workspace", "profile": "microvm-local", - "guest_egress": "deny-all", "host_egress": "not constrained", - } + go d.serveConn(conn) + } +} + +func (d *placementTestDaemon) serveConn(conn net.Conn) { + defer func() { _ = conn.Close() }() + var request map[string]any + if readPlacementFrame(conn, &request) != nil { + return + } + op, _ := request["operation"].(string) + binding, _ := request["binding"].(map[string]any) + d.mu.Lock() + d.ops = append(d.ops, op) + d.mu.Unlock() + + response := map[string]any{"binding": binding} + switch op { + case "create": + provision, _ := request["provision"].(map[string]any) + sessionID, _ := provision["session_id"].(string) + d.mu.Lock() + d.next++ + environmentID := fmt.Sprintf("worktree-%d", d.next) + guestRoot := filepath.Join(d.root, environmentID) + d.guests[environmentID] = guestRoot + d.mu.Unlock() + if err := os.MkdirAll(guestRoot, 0o700); err != nil { + response = map[string]any{"error_code": "create_failed", "error": "guest root unavailable"} + break + } + binding = map[string]any{"owner": "local", "session_id": sessionID, "environment_id": environmentID, "ref": environmentID + "@7", "generation": 7} + response["binding"] = binding + response["created"] = map[string]any{ + "ref": map[string]any{"Kind": "microvm", "ID": environmentID + "@7"}, "generation": 7, + "host_worktree": guestRoot, "guest_root": "/workspace", "profile": "microvm-local", + "guest_egress": "deny-all", "host_egress": "not constrained", + } + case "resolve", "detach": + if !d.hasBinding(binding) { + response = map[string]any{"error_code": "not_found", "error": "generation unavailable"} + } + case "fork": + parentRoot, ok := d.guestRoot(binding) + if !ok { + response = map[string]any{"error_code": "not_found", "error": "generation unavailable"} + break + } + d.mu.Lock() + d.next++ + childID := fmt.Sprintf("worktree-%d", d.next) + childRoot := filepath.Join(d.root, childID) + d.guests[childID] = childRoot + d.mu.Unlock() + if err := copyPlacementTree(parentRoot, childRoot); err != nil { + response = map[string]any{"error_code": "fork_failed", "error": "child root unavailable"} + break + } + response["binding"] = map[string]any{ + "owner": binding["owner"], "session_id": binding["session_id"], + "environment_id": childID, "ref": childID + "@7", "generation": 7, + } + case "child-delete": + if !d.hasBinding(binding) { + response = map[string]any{"error_code": "not_found", "error": "generation unavailable"} + } + case "exec": + guestRoot, ok := d.guestRoot(binding) + if !ok { + response = map[string]any{"error_code": "not_found", "error": "generation unavailable"} + break + } + environmentID, _ := binding["environment_id"].(string) + d.mu.Lock() + d.execBindings = append(d.execBindings, environmentID) + d.mu.Unlock() + var payload struct { + Command string `json:"command"` + } + encoded, _ := json.Marshal(request["payload"]) + var raw json.RawMessage + _ = json.Unmarshal(encoded, &raw) + if err := json.Unmarshal(raw, &payload); err != nil { + response = map[string]any{"error_code": "invalid", "error": "invalid command"} + break + } + command := exec.CommandContext(context.Background(), "/bin/sh", "-c", payload.Command) + command.Dir = guestRoot + command.Env = []string{"HOME=" + guestRoot, "PATH=/usr/bin:/bin"} + stdout, runErr := command.Output() + exitCode := 0 + var stderr []byte + if runErr != nil { + exitCode = 1 + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode, stderr = exitErr.ExitCode(), exitErr.Stderr } - _ = writePlacementFrame(conn, response) - }() + } + if len(stdout) != 0 { + _ = writePlacementFrame(conn, map[string]any{"stream": map[string]any{"channel": "stdout", "data": stdout}}) + } + if len(stderr) != 0 { + _ = writePlacementFrame(conn, map[string]any{"stream": map[string]any{"channel": "stderr", "data": stderr}}) + } + payloadOut, _ := json.Marshal(map[string]any{"exit_code": exitCode}) + response = map[string]any{"binding": binding, "payload": json.RawMessage(payloadOut)} } + _ = writePlacementFrame(conn, response) +} + +func (d *placementTestDaemon) guestRoot(binding map[string]any) (string, bool) { + environmentID, _ := binding["environment_id"].(string) + d.mu.Lock() + defer d.mu.Unlock() + root, ok := d.guests[environmentID] + return root, ok +} + +func (d *placementTestDaemon) hasBinding(binding map[string]any) bool { + _, ok := d.guestRoot(binding) + return ok +} + +func (d *placementTestDaemon) loseRuntimeState() { + d.mu.Lock() + defer d.mu.Unlock() + d.guests = make(map[string]string) +} + +func copyPlacementTree(source, destination string) error { + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, rel) + if entry.IsDir() { + return os.MkdirAll(target, 0o700) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o600) + }) } func (d *placementTestDaemon) operations() []string { @@ -87,6 +222,32 @@ func (d *placementTestDaemon) operations() []string { return append([]string(nil), d.ops...) } +func (d *placementTestDaemon) operationCount(want string) int { + count := 0 + for _, op := range d.operations() { + if op == want { + count++ + } + } + return count +} + +func (d *placementTestDaemon) executedBindings() []string { + d.mu.Lock() + defer d.mu.Unlock() + return append([]string(nil), d.execBindings...) +} + +type placementReadyManager struct { + endpoint string + calls int +} + +func (m *placementReadyManager) EnsureReady(context.Context, microvmmanager.ReadyRequest) (string, error) { + m.calls++ + return m.endpoint, nil +} + func TestMicroVMDefaultPlacementUsesNormalCreateSessionAndExactReattach(t *testing.T) { ctx := context.Background() daemon := startPlacementTestDaemon(t) @@ -133,6 +294,191 @@ func TestMicroVMDefaultPlacementUsesNormalCreateSessionAndExactReattach(t *testi } } +func TestMicroVMOperatorJourneyIsLazyIsolatedAndRestartExact(t *testing.T) { + ctx := context.Background() + daemon := startPlacementTestDaemon(t) + hostSource := t.TempDir() + storeDir := t.TempDir() + settings := writeOperatorSettingsFile(t, `execution: {default_placement: microvm-local}`) + manager := &placementReadyManager{endpoint: daemon.endpoint()} + + config := func(provider *mockllm.Provider) Config { + return Config{ + Workspace: hostSource, StoreDir: storeDir, UseMock: true, MockProvider: provider, + Shell: "/bin/sh", AllowAllTools: true, TrustProject: true, PermissionConfigs: []string{settings}, + MicroVMReadyRequest: func(microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + return microvmmanager.ReadyRequest{}, nil + }, + MicroVMManagerFactory: func() (MicroVMReadyManager, string, error) { + return manager, daemon.endpoint(), nil + }, + } + } + + firstProvider := mockllm.New( + mockllm.ToolCallTurn(session.NewToolCall("write-a", "Bash", json.RawMessage(`{"command":"printf alpha > a.txt"}`))), + mockllm.TextTurn("first complete"), + mockllm.ToolCallTurn(session.NewToolCall("write-b", "Bash", json.RawMessage(`{"command":"test ! -e a.txt && printf beta > b.txt"}`))), + mockllm.TextTurn("second complete"), + mockllm.ToolCallTurn(session.NewToolCall("direct-child", "Subagent", json.RawMessage(`{"prompt":"write a direct child marker","mode":"read-write"}`))), + mockllm.ToolCallTurn(session.NewToolCall("direct-write", "Bash", json.RawMessage(`{"command":"printf direct > direct-child.txt"}`))), + mockllm.TextTurn("direct child complete"), + mockllm.TextTurn("parent observed direct child"), + mockllm.ToolCallTurn(session.NewToolCall("isolated-child", "Subagent", json.RawMessage(`{"prompt":"report your isolated working directory"}`))), + mockllm.ToolCallTurn(session.NewToolCall("isolated-pwd", "Bash", json.RawMessage(`{"command":"pwd"}`))), + mockllm.TextTurn("isolated child complete"), + mockllm.TextTurn("parent observed isolated child"), + ) + first, err := Build(ctx, config(firstProvider)) + if err != nil { + t.Fatalf("Build with operator microVM default: %v", err) + } + if manager.calls != 0 || daemon.operationCount("create") != 0 { + first.Close() + t.Fatalf("composition allocated a microVM: readiness=%d create=%d", manager.calls, daemon.operationCount("create")) + } + + noFS, err := first.Service.CreateSessionWithProfile(ctx, session.ModeDefault, session.Limits{}, server.ProviderSelector{}, server.ProfileNoFS) + if err != nil { + first.Close() + t.Fatalf("create no-fs session: %v", err) + } + if noFS.EnvironmentRef.Kind != session.EnvKindNoFS || manager.calls != 0 || daemon.operationCount("create") != 0 { + first.Close() + t.Fatalf("no-fs touched microVM state: ref=%+v readiness=%d create=%d", noFS.EnvironmentRef, manager.calls, daemon.operationCount("create")) + } + + one, err := first.Service.CreateSession(ctx, session.ModeDefault, session.Limits{}) + if err != nil { + first.Close() + t.Fatalf("create first default session: %v", err) + } + two, err := first.Service.CreateSession(ctx, session.ModeDefault, session.Limits{}) + if err != nil { + first.Close() + t.Fatalf("create second default session: %v", err) + } + if manager.calls != 2 || daemon.operationCount("create") != 2 { + first.Close() + t.Fatalf("default-session allocation counts: readiness=%d create=%d", manager.calls, daemon.operationCount("create")) + } + if one.EnvironmentRef.Kind != "microvm" || two.EnvironmentRef.Kind != "microvm" || one.EnvironmentRef == two.EnvironmentRef { + first.Close() + t.Fatalf("sessions lack distinct exact microVM refs: one=%+v two=%+v", one.EnvironmentRef, two.EnvironmentRef) + } + if one.EnvironmentRef.Revision != "7" || two.EnvironmentRef.Revision != "7" || !strings.Contains(one.EnvironmentRef.ID, "worktree-1") || !strings.Contains(two.EnvironmentRef.ID, "worktree-2") { + first.Close() + t.Fatalf("logical worktree identities are not exact/distinct: one=%+v two=%+v", one.EnvironmentRef, two.EnvironmentRef) + } + + assertSuccessfulRun(t, first.Service, one.ID, "write only in the first guest") + assertSuccessfulRun(t, first.Service, two.ID, "write only in the second guest") + + forksBefore := daemon.operationCount("fork") + assertSuccessfulRun(t, first.Service, one.ID, "delegate a direct-write change") + bindings := daemon.executedBindings() + if daemon.operationCount("fork") != forksBefore || len(bindings) == 0 || bindings[len(bindings)-1] != "worktree-1" { + first.Close() + t.Fatalf("direct-write child did not use the parent environment: forks=%d bindings=%v", daemon.operationCount("fork"), bindings) + } + + assertSuccessfulRun(t, first.Service, one.ID, "delegate isolated inspection") + bindings = daemon.executedBindings() + if daemon.operationCount("fork") != forksBefore+1 || len(bindings) == 0 || bindings[len(bindings)-1] == "worktree-1" { + first.Close() + t.Fatalf("isolated child did not use a distinct logical worktree: forks=%d bindings=%v", daemon.operationCount("fork"), bindings) + } + for _, name := range []string{"a.txt", "b.txt", "direct-child.txt"} { + if _, err := os.Stat(filepath.Join(hostSource, name)); !errors.Is(err, fs.ErrNotExist) { + first.Close() + t.Fatalf("guest file %s appeared in host source checkout: %v", name, err) + } + } + first.Close() + + secondProvider := mockllm.New( + mockllm.ToolCallTurn(session.NewToolCall("read-a", "Bash", json.RawMessage(`{"command":"test \"$(cat a.txt)\" = alpha"}`))), + mockllm.TextTurn("restart complete"), + ) + second, err := Build(ctx, config(secondProvider)) + if err != nil { + t.Fatalf("restart app composition: %v", err) + } + if daemon.operationCount("create") != 2 { + second.Close() + t.Fatalf("restart replaced an existing placement: create=%d", daemon.operationCount("create")) + } + assertSuccessfulRun(t, second.Service, one.ID, "verify the prior guest change") + if daemon.operationCount("resolve") == 0 { + second.Close() + t.Fatal("restart run did not reattach the exact persisted ref") + } + second.Close() + + daemon.loseRuntimeState() + third, err := Build(ctx, config(mockllm.New(mockllm.TextTurn("must not run")))) + if err != nil { + t.Fatalf("build after simulated microvmd restart: %v", err) + } + defer third.Close() + createsBefore := daemon.operationCount("create") + if _, err := third.Service.StartRunContent(ctx, one.ID, "must fail closed", nil); err == nil { + t.Fatal("lost microvmd generation silently fell back to host-local") + } + if daemon.operationCount("create") != createsBefore { + t.Fatalf("lost generation was replaced: create before=%d after=%d", createsBefore, daemon.operationCount("create")) + } + preserved, err := third.Service.GetSession(ctx, one.ID) + if err != nil || preserved.EnvironmentRef != one.EnvironmentRef { + t.Fatalf("failed reattach did not preserve exact state: session=%+v err=%v", preserved, err) + } +} + +func TestHostLocalOmissionDoesNoMicroVMWork(t *testing.T) { + factoryCalled := false + built, err := Build(context.Background(), Config{ + Workspace: t.TempDir(), StoreDir: t.TempDir(), UseMock: true, + MicroVMReadyRequest: func(microvmmanager.GuestEgressSelection) (microvmmanager.ReadyRequest, error) { + return microvmmanager.ReadyRequest{}, nil + }, + MicroVMManagerFactory: func() (MicroVMReadyManager, string, error) { + factoryCalled = true + return nil, "", errors.New("must not be called") + }, + }) + if err != nil { + t.Fatalf("host-local Build: %v", err) + } + defer built.Close() + sess, err := built.Service.CreateSession(context.Background(), session.ModeDefault, session.Limits{}) + if err != nil { + t.Fatalf("host-local CreateSession: %v", err) + } + if factoryCalled || sess.EnvironmentRef.Kind != session.EnvKindLocal { + t.Fatalf("omitted placement did MicroVM work or selected wrong backend: factory=%v ref=%+v", factoryCalled, sess.EnvironmentRef) + } +} + +func assertSuccessfulRun(t *testing.T, svc *server.Service, id session.SessionID, prompt string) { + t.Helper() + run, err := svc.StartRunContent(context.Background(), id, prompt, nil) + if err != nil { + t.Fatalf("StartRunContent(%s): %v", id, err) + } + var result *session.ResultPayload + for event := range run.Events() { + if event.Result != nil { + result = event.Result + } + if event.ToolResult != nil && event.ToolResult.IsError { + t.Fatalf("tool %s failed: %s", event.ToolResult.CallID, event.ToolResult.Content) + } + } + if result == nil || result.Stop == session.StopError { + t.Fatalf("run result = %+v", result) + } +} + func readPlacementFrame(r io.Reader, value any) error { var header [4]byte if _, err := io.ReadFull(r, header[:]); err != nil { diff --git a/internal/app/placement.go b/internal/app/placement.go index fbecac918..6aa94df69 100644 --- a/internal/app/placement.go +++ b/internal/app/placement.go @@ -160,7 +160,7 @@ func (p *localPlacementProvider) bindWorktree(choice server.Worktree) (server.Pl if err != nil { return server.PlacementBinding{}, server.ErrPlacementUnavailable } - return server.PlacementBinding{Environment: env, Ref: ref, Metadata: server.PlacementMetadata{ + return server.PlacementBinding{Environment: env, Ref: ref, CompositionRoot: choice.Path, Metadata: server.PlacementMetadata{ Label: choice.Branch, Branch: choice.Branch, Revision: choice.Head, }}, nil } @@ -189,9 +189,10 @@ func (p *localPlacementProvider) bindLocal() (server.PlacementBinding, error) { return server.PlacementBinding{}, server.ErrPlacementUnavailable } return server.PlacementBinding{ - Environment: env, - Ref: ref, - Metadata: server.PlacementMetadata{Label: "Local workspace", Revision: "configured"}, + Environment: env, + Ref: ref, + CompositionRoot: p.root, + Metadata: server.PlacementMetadata{Label: "Local workspace", Revision: "configured"}, }, nil } diff --git a/internal/app/placement_test.go b/internal/app/placement_test.go index eff6773e1..31e96f27a 100644 --- a/internal/app/placement_test.go +++ b/internal/app/placement_test.go @@ -128,7 +128,7 @@ func TestADR_0291_CompositionConfiguresProviderOwnedPlacements(t *testing.T) { } }) - t.Run("startup validation uses the build context", func(t *testing.T) { + t.Run("first bind uses the session context", func(t *testing.T) { ref := session.EnvironmentRef{Kind: "remote", ID: "opaque-context-id", Revision: "r1"} provider := &compositionPlacementProvider{binding: server.PlacementBinding{ Ref: ref, @@ -146,8 +146,11 @@ func TestADR_0291_CompositionConfiguresProviderOwnedPlacements(t *testing.T) { t.Fatalf("Build: %v", err) } defer built.Close() + if _, err := built.Service.CreateSession(ctx, session.ModeDefault, session.Limits{}); err != nil { + t.Fatalf("CreateSession: %v", err) + } if provider.contextSeen != "build-context" { - t.Fatalf("startup provider context value = %v, want build-context", provider.contextSeen) + t.Fatalf("session provider context value = %v, want build-context", provider.contextSeen) } }) @@ -194,8 +197,8 @@ func TestADR_0291_CompositionConfiguresProviderOwnedPlacements(t *testing.T) { t.Fatalf("Build: %v", err) } defer built.Close() - if len(provider.calls) != 1 || provider.calls[0].Selector.Kind != server.PlacementSelectorDefault { - t.Fatalf("startup Bind calls = %+v, want exactly one default Bind", provider.calls) + if len(provider.calls) != 0 { + t.Fatalf("Build eagerly bound provider: %+v", provider.calls) } binding, err := built.Service.BindPlacement(context.Background(), server.DefaultPlacement(), server.PlacementOperationCreate) @@ -205,24 +208,31 @@ func TestADR_0291_CompositionConfiguresProviderOwnedPlacements(t *testing.T) { if binding.Ref != ref { t.Fatalf("provider ref = %+v, want stable exact %+v", binding.Ref, ref) } + if len(provider.calls) != 1 || provider.calls[0].Selector.Kind != server.PlacementSelectorDefault { + t.Fatalf("first Bind calls = %+v, want exactly one default Bind", provider.calls) + } }) } - t.Run("invalid default fails startup", func(t *testing.T) { + t.Run("invalid default fails first bind without startup allocation", func(t *testing.T) { provider := &compositionPlacementProvider{err: server.ErrPlacementUnavailable} built, err := Build(context.Background(), Config{ Workspace: rootForPlacementTest(t), UseMock: true, PlacementProvider: provider, PlacementScope: "deployment-a", }) - if built != nil { - built.Close() - t.Fatal("Build returned a service for an invalid default placement") + if err != nil { + t.Fatalf("Build: %v", err) + } + defer built.Close() + if len(provider.calls) != 0 { + t.Fatalf("Build eagerly bound invalid provider: %d calls", len(provider.calls)) } + _, err = built.Service.BindPlacement(context.Background(), server.DefaultPlacement(), server.PlacementOperationCreate) if !errors.Is(err, server.ErrPlacementUnavailable) { - t.Fatalf("Build error = %v, want ErrPlacementUnavailable", err) + t.Fatalf("BindPlacement error = %v, want ErrPlacementUnavailable", err) } if len(provider.calls) != 1 { - t.Fatalf("startup Bind calls = %d, want 1", len(provider.calls)) + t.Fatalf("first Bind calls = %d, want 1", len(provider.calls)) } }) } diff --git a/internal/configgen/build.go b/internal/configgen/build.go index 6aaa34697..f65bcee26 100644 --- a/internal/configgen/build.go +++ b/internal/configgen/build.go @@ -33,6 +33,7 @@ func BuildModel(docs Docs) *Model { retentionSubtree(docs), temporaryStorageSubtree(docs), storageManagementSubtree(docs), + executionSubtree(docs), steerSubtree(docs), modelsSubtree(docs), openRouterSubtree(docs), @@ -226,6 +227,21 @@ func temporaryStorageSubtree(docs Docs) *Subtree { Doc: "Managed command temporary-storage policy. Read only from user-global settings.yaml; project and explicit CLI config values are ignored. Managed mode is Linux-only; system preserves inherited temporary-directory behavior.", Fields: fields} } +func executionSubtree(docs Docs) *Subtree { + fields := fieldsOf("ExecutionSection", permconfig.ExecutionSection{}, docs) + microVM := fieldsOf("ExecutionMicroVMSection", permconfig.ExecutionMicroVMSection{}, docs) + guestEgress := fieldsOf("ExecutionGuestEgressSection", permconfig.ExecutionGuestEgressSection{}, docs) + fields[0].Default, fields[0].ExampleValue = "host-local", "host-local" + fields[1].Nested = microVM + microVM[0].Nested = guestEgress + guestEgress[0].Default, guestEgress[0].ExampleValue = "permissive", "permissive" + return &Subtree{ + Key: "execution", Tier: TierOperator, CommentedOut: true, + Doc: "Server-owned execution placement and MicroVM guest-egress policy. Operator-tier only; project values are ignored. Bare mecatui and mecated resolve the same settings.", + Fields: fields, + } +} + func storageManagementSubtree(docs Docs) *Subtree { fields := fieldsOf("StorageManagementSection", permconfig.StorageManagementSection{}, docs) principals := fieldsOf("StorageManagementPrincipal", permconfig.StorageManagementPrincipal{}, docs) diff --git a/internal/configgen/configgen_test.go b/internal/configgen/configgen_test.go index ce569a403..e30053733 100644 --- a/internal/configgen/configgen_test.go +++ b/internal/configgen/configgen_test.go @@ -46,6 +46,9 @@ func authoritativeKeys() []string { collect("temporary_storage", permconfig.TemporaryStorageSection{}) collect("storage_management", permconfig.StorageManagementSection{}) collect("storage_management.principals", permconfig.StorageManagementPrincipal{}) + collect("execution", permconfig.ExecutionSection{}) + collect("execution.microvm", permconfig.ExecutionMicroVMSection{}) + collect("execution.microvm.guest_egress", permconfig.ExecutionGuestEgressSection{}) collect("models", permconfig.ModelsSection{}) collect("models.router", permconfig.RouterSection{}) collect("models.router.categories", permconfig.RouterCategory{}) @@ -309,6 +312,7 @@ func TestSubtreeTiersAreAsPinned(t *testing.T) { "retention": configgen.TierOperator, // operator-only: project cannot enable destructive cleanup "temporary_storage": configgen.TierOperator, // operator-only: project cannot redirect command storage or cleanup "storage_management": configgen.TierOperator, // operator-only: project cannot grant process-wide management + "execution": configgen.TierOperator, // operator-only: project cannot choose host execution placement or guest egress "steer": configgen.TierOperator, // operator-only: a project cannot flip the mid-run steer surface (issue #512) "models": configgen.TierProject, // operator + project (project within the operator allowlist) "openrouter": configgen.TierOperator, // operator-only: a project cannot steer the OpenRouter downstream provider (issue #480) diff --git a/internal/configgen/settings.skeleton.yaml b/internal/configgen/settings.skeleton.yaml index 7fdb30f95..088623d7f 100644 --- a/internal/configgen/settings.skeleton.yaml +++ b/internal/configgen/settings.skeleton.yaml @@ -236,6 +236,22 @@ # - issuer: https://idp.example/realms/operators # subject: storage-admin +#| === execution === (tier: operator) +#| Server-owned execution placement and MicroVM guest-egress policy. Operator-tier only; +#| project values are ignored. Bare mecatui and mecated resolve the same settings. +# execution: +# # The default_placement setting selects the server-owned default placement backend. +# default_placement: host-local +# # The microvm block configures local MicroVM execution without creating a public +# # profile. +# microvm: +# # The guest_egress block controls network destinations reachable from the guest. +# guest_egress: +# # mode accepts permissive, deny-all, or allowlist. Permissive is the default. +# mode: permissive +# # allow lists HOST:PORT/tcp|udp destinations and is valid only in allowlist mode. +# allow: [] + #| === steer === (tier: operator) #| OPERATOR-TIER mid-run steer knob (steer-while-running, issue #512): when true (the #| DEFAULT), a client may inject an operator instruction into an in-flight run, drained diff --git a/internal/microvmcmd/command.go b/internal/microvmcmd/command.go index 154cb6f23..096ebe438 100644 --- a/internal/microvmcmd/command.go +++ b/internal/microvmcmd/command.go @@ -1,5 +1,5 @@ -// Package microvmcmd implements the binary-neutral, local microVM lifecycle -// administration command shared by mecated and mecatui. +// Package microvmcmd implements the canonical mecated local microVM lifecycle +// administration command. package microvmcmd import ( @@ -24,24 +24,13 @@ type Manager interface { type outputFormat string -// Frontend identifies the installed binary that invoked the shared command. -type Frontend string - const ( - // FrontendMecated identifies the mecated command frontend. - FrontendMecated Frontend = "mecated" - // FrontendMecatui identifies the mecatui command frontend. - FrontendMecatui Frontend = "mecatui" - outputText outputFormat = "text" outputJSON outputFormat = "json" ) // Run parses and executes one local microVM administration command. -func Run(ctx context.Context, frontend Frontend, args []string, in io.Reader, out io.Writer, manager Manager, interactive bool) error { - if frontend != FrontendMecated && frontend != FrontendMecatui { - return fmt.Errorf("unsupported microvm command frontend %q", frontend) - } +func Run(ctx context.Context, args []string, in io.Reader, out io.Writer, manager Manager, interactive bool) error { if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "--help-all" || args[0] == "-h" { WriteHelp(out) return nil @@ -50,11 +39,11 @@ func Run(ctx context.Context, frontend Frontend, args []string, in io.Reader, ou case "doctor": return runDoctor(ctx, args[1:], out, manager) case "status": - return runStatus(ctx, frontend, args[1:], out, manager) + return runStatus(ctx, args[1:], out, manager) case "delete": return runDelete(ctx, args[1:], in, out, manager, interactive) default: - return fmt.Errorf("unknown microvm command %q; run 'mecated microvm --help' (or 'mecatui microvm --help') for doctor, status, and delete usage", args[0]) + return fmt.Errorf("unknown microvm command %q; run 'mecated microvm --help' for doctor, status, and delete usage", args[0]) } } @@ -115,18 +104,18 @@ func runDoctor(ctx context.Context, args []string, out io.Writer, manager Manage } } if doctorErr != nil { - return fmt.Errorf("microVM doctor found problems: %w; follow the next action in the report, then rerun 'mecated microvm doctor' (or 'mecatui microvm doctor')", doctorErr) + return fmt.Errorf("microVM doctor found problems: %w; follow the next action in the report, then rerun 'mecated microvm doctor'", doctorErr) } return nil } -func runStatus(ctx context.Context, frontend Frontend, args []string, out io.Writer, manager Manager) error { +func runStatus(ctx context.Context, args []string, out io.Writer, manager Manager) error { fs := flag.NewFlagSet("microvm status", flag.ContinueOnError) fs.SetOutput(out) fs.Usage = func() { writeStatusHelp(out) } var pageSize int var continuation, output string - fs.IntVar(&pageSize, "page-size", 50, "generation rows per page (maximum 64)") + fs.IntVar(&pageSize, "page-size", 50, "attachment/status rows per page (maximum 64)") fs.StringVar(&continuation, "continuation", "", "opaque continuation from the previous status page") outputFlag(fs, &output) if err := fs.Parse(args); err != nil { @@ -142,22 +131,49 @@ func runStatus(ctx context.Context, frontend Frontend, args []string, out io.Wri if err != nil { return err } - status, err := manager.Status(ctx, microvmmanager.StatusRequest{PageSize: pageSize, Continuation: continuation}) - if err != nil { - return fmt.Errorf("inspect microVM status: %w", err) - } + status, statusErr := manager.Status(ctx, microvmmanager.StatusRequest{PageSize: pageSize, Continuation: continuation}) + state, errorClass, remediation := statusSummary(status, statusErr) if format == outputJSON { - return writeJSON(out, statusJSON{ - Profile: microvmmanager.Alias, Configured: status.Configured, Running: status.Running, + if writeErr := writeJSON(out, statusJSON{ + Backend: microvmmanager.Alias, Configured: status.Configured, Running: status.Running, + State: state, Error: errorClass, Remediation: remediation, Socket: status.Socket, GuestEgress: status.GuestEgress, Generations: generationJSONs(status.Generations), Continuation: status.Continuation, - }) + }); writeErr != nil { + return writeErr + } + if statusErr != nil { + return fmt.Errorf("inspect microVM status: %w", statusErr) + } + return nil + } + writeStatusText(out, status) + if statusErr != nil { + _, _ = fmt.Fprintf(out, "backend state: %s\nerror: %s\nremediation: %s\n", state, errorClass, remediation) + return fmt.Errorf("inspect microVM status: %w", statusErr) } - writeStatusText(out, frontend, status) return nil } +func statusSummary(status microvmmanager.Status, err error) (state, errorClass, remediation string) { + if err == nil { + if !status.Configured { + return "unconfigured", "", "Select microvm-local in operator settings; run 'mecated microvm doctor' first." + } + return "ready", "", "" + } + remediation = "Run 'mecated microvm doctor', follow its next action, then retry." + switch { + case status.Configured && !status.Running: + return "stopped", "daemon_not_running", remediation + case !status.Configured && status.Running: + return "unhealthy", "unmanaged_daemon", remediation + default: + return "unhealthy", "status_check_failed", remediation + } +} + type generationJSON struct { - SessionID string `json:"session_id"` + AttachmentID string `json:"attachment_id"` EnvironmentID string `json:"environment_id"` Ref string `json:"ref"` Generation uint32 `json:"generation"` @@ -168,9 +184,12 @@ type generationJSON struct { } type statusJSON struct { - Profile string `json:"profile"` + Backend string `json:"backend"` Configured bool `json:"configured"` Running bool `json:"running"` + State string `json:"state"` + Error string `json:"error"` + Remediation string `json:"remediation"` Socket string `json:"socket"` GuestEgress string `json:"guest_egress"` Generations []generationJSON `json:"generations"` @@ -181,7 +200,7 @@ func generationJSONs(generations []microvmmanager.Generation) []generationJSON { result := make([]generationJSON, len(generations)) for i, generation := range generations { result[i] = generationJSON{ - SessionID: generation.SessionID, EnvironmentID: generation.EnvironmentID, Ref: generation.Ref, + AttachmentID: generation.SessionID, EnvironmentID: generation.EnvironmentID, Ref: generation.Ref, Generation: generation.Generation, WorktreePath: generation.WorktreePath, State: generation.State, Health: string(generation.Health), Error: generation.Error, } @@ -189,21 +208,22 @@ func generationJSONs(generations []microvmmanager.Generation) []generationJSON { return result } -func writeStatusText(out io.Writer, frontend Frontend, status microvmmanager.Status) { - _, _ = fmt.Fprintf(out, "profile: %s\nconfigured: %t\ndaemon running: %t\nsocket: %s\nguest egress: %s\n", microvmmanager.Alias, status.Configured, status.Running, status.Socket, configuredValue(status.GuestEgress)) +func writeStatusText(out io.Writer, status microvmmanager.Status) { + _, _ = fmt.Fprintf(out, "backend: %s\nconfigured: %t\ndaemon running: %t\nsocket: %s\nguest egress: %s\n", microvmmanager.Alias, status.Configured, status.Running, status.Socket, configuredValue(status.GuestEgress)) for _, generation := range status.Generations { - _, _ = fmt.Fprintf(out, "logical worktree: session=%s ref=%s repository-generation=%d health=%s state=%s worktree=%s", generation.SessionID, generation.Ref, generation.Generation, generation.Health, generation.State, generation.WorktreePath) + _, _ = fmt.Fprintf(out, "logical worktree: attachment_id=%s ref=%s repository-generation=%d health=%s state=%s worktree=%s", generation.SessionID, generation.Ref, generation.Generation, generation.Health, generation.State, generation.WorktreePath) if generation.Error != "" { _, _ = fmt.Fprintf(out, " error=%q", generation.Error) } _, _ = fmt.Fprintln(out) } if status.Continuation != "" { - _, _ = fmt.Fprintf(out, "next page: %s microvm status --continuation %q\n", frontend, status.Continuation) + _, _ = fmt.Fprintf(out, "next page: mecated microvm status --continuation %q\n", status.Continuation) } + _, _ = fmt.Fprintln(out, "status scope: current OS principal on this execution host") _, _ = fmt.Fprintln(out, "status is read-only; it never installs, starts, stops, or deletes microVM state") if !status.Configured { - _, _ = fmt.Fprintln(out, "backend state: not configured") + _, _ = fmt.Fprintln(out, "backend state: ready to configure on first use") writeSelectionExamples(out) } else if !status.Running { _, _ = fmt.Fprintln(out, "backend state: configured; daemon not running") @@ -219,7 +239,13 @@ func configuredValue(value string) string { } func writeSelectionExamples(out io.Writer) { - _, _ = fmt.Fprintln(out, "next (mecatui): mecatui --default-placement microvm-local") + _, _ = fmt.Fprintln(out, "guest IPv4 egress defaults to permissive; to deny it before first use, add this to operator settings:") + _, _ = fmt.Fprintln(out, " execution:") + _, _ = fmt.Fprintln(out, " default_placement: microvm-local") + _, _ = fmt.Fprintln(out, " microvm:") + _, _ = fmt.Fprintln(out, " guest_egress:") + _, _ = fmt.Fprintln(out, " mode: deny-all") + _, _ = fmt.Fprintln(out, "next (embedded mecatui): run mecatui (use mode: allowlist plus allow entries for selected destinations)") _, _ = fmt.Fprintln(out, `next (HTTP API): run 'mecated serve --headless --default-placement microvm-local', then POST /v1/sessions with {}`) } @@ -228,12 +254,13 @@ func runDelete(ctx context.Context, args []string, in io.Reader, out io.Writer, fs.SetOutput(out) fs.Usage = func() { writeDeleteHelp(out) } var yes bool - var sessionID, ref, output string + var backend, attachmentID, ref, output string var generation uint fs.BoolVar(&yes, "yes", false, "confirm destructive removal") - fs.StringVar(&sessionID, "session", "", "exact session id") + fs.StringVar(&backend, "backend", "", "exact backend name (microvm-local)") + fs.StringVar(&attachmentID, "attachment-id", "", "exact logical attachment id from status") fs.StringVar(&ref, "ref", "", "exact microVM environment ref") - fs.UintVar(&generation, "generation", 0, "exact microVM generation") + fs.UintVar(&generation, "generation", 0, "exact repository generation") outputFlag(fs, &output) if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -248,27 +275,30 @@ func runDelete(ctx context.Context, args []string, in io.Reader, out io.Writer, if err != nil { return err } + if backend != microvmmanager.Alias { + return fmt.Errorf("microVM delete requires --backend %s", microvmmanager.Alias) + } if uint64(generation) > uint64(^uint32(0)) { return errors.New("microvm delete: generation exceeds uint32") } - request := microvmmanager.DeleteRequest{SessionID: sessionID, Ref: ref, Generation: uint32(generation)} // #nosec G115 -- range checked above. + request := microvmmanager.DeleteRequest{SessionID: attachmentID, Ref: ref, Generation: uint32(generation)} // #nosec G115 -- range checked above. if err := microvmmanager.ValidateDeleteRequest(request); err != nil { - return err + return errors.New("microVM delete requires matching --attachment-id, --ref, and --generation") } found, err := statusHasGeneration(ctx, manager, request) if err != nil { return fmt.Errorf("validate microVM delete target: %w", err) } if !found { - return errors.New("microVM delete target is not present in microvm status; refresh status and copy all three values from one row") + return errors.New("microVM delete target is not present in owner-scoped local status; refresh status and copy backend, attachment_id, ref, and generation from one row") } if format == outputJSON && !yes { return errors.New("microVM delete with --output json requires explicit confirmation with --yes") } if format == outputText { - _, _ = fmt.Fprintf(out, "Permanently delete logical worktree session=%s ref=%s repository-generation=%d. The shared repository VM is not deleted. Closing a host client normally only detaches and preserves this worktree. Dirty worktrees are retained.\n", sessionID, ref, generation) + _, _ = fmt.Fprintf(out, "Permanently delete local logical attachment backend=%s attachment_id=%s ref=%s generation=%d. The repository VM is retained. Dirty worktrees are preserved.\n", backend, attachmentID, ref, generation) if interactive && !yes { - _, _ = fmt.Fprint(out, "Permanently delete this exact microVM generation? [y/N] ") + _, _ = fmt.Fprint(out, "Permanently delete this exact local logical attachment? [y/N] ") answer, _ := bufio.NewReader(in).ReadString('\n') yes = strings.EqualFold(strings.TrimSpace(answer), "y") || strings.EqualFold(strings.TrimSpace(answer), "yes") } @@ -278,31 +308,33 @@ func runDelete(ctx context.Context, args []string, in io.Reader, out io.Writer, } result, err := manager.Delete(ctx, request) if err != nil { - return fmt.Errorf("delete exact microVM generation: %w", err) + return fmt.Errorf("delete exact local logical attachment: %w", err) } if format == outputJSON { return writeJSON(out, struct { + Backend string `json:"backend"` Selector deleteSelectorJSON `json:"selector"` Result deleteResultJSON `json:"result"` DirtyRetained bool `json:"dirty_retained"` }{ - Selector: deleteSelectorJSON{SessionID: request.SessionID, Ref: request.Ref, Generation: request.Generation}, + Backend: backend, + Selector: deleteSelectorJSON{AttachmentID: attachmentID, Ref: ref, Generation: uint32(generation)}, Result: deleteResultJSON{WorktreePath: result.WorktreePath, WorktreeRemoved: !result.WorktreeRetained, RepositoryVMRetained: true}, DirtyRetained: result.WorktreeRetained, }) } if result.WorktreeRetained { - _, _ = fmt.Fprintf(out, "logical attachment deleted; dirty worktree retained: %s\n", result.WorktreePath) + _, _ = fmt.Fprintf(out, "logical attachment deleted; dirty worktree preserved on this host: %s\n", result.WorktreePath) } else { - _, _ = fmt.Fprintf(out, "logical attachment and clean worktree removed; repository VM retained: %s\n", result.WorktreePath) + _, _ = fmt.Fprintf(out, "logical attachment and clean worktree removed; repository VM retained on this host: %s\n", result.WorktreePath) } return nil } type deleteSelectorJSON struct { - SessionID string `json:"session_id"` - Ref string `json:"ref"` - Generation uint32 `json:"generation"` + AttachmentID string `json:"attachment_id"` + Ref string `json:"ref"` + Generation uint32 `json:"generation"` } type deleteResultJSON struct { @@ -313,6 +345,7 @@ type deleteResultJSON struct { func statusHasGeneration(ctx context.Context, manager Manager, request microvmmanager.DeleteRequest) (bool, error) { continuation := "" + matches := 0 seen := make(map[string]struct{}) for range 1024 { status, err := manager.Status(ctx, microvmmanager.StatusRequest{PageSize: 64, Continuation: continuation}) @@ -321,11 +354,14 @@ func statusHasGeneration(ctx context.Context, manager Manager, request microvmma } for _, generation := range status.Generations { if generation.SessionID == request.SessionID && generation.Ref == request.Ref && generation.Generation == request.Generation { - return true, nil + matches++ + if matches > 1 { + return false, errors.New("microVM status returned an ambiguous duplicate delete target") + } } } if status.Continuation == "" { - return false, nil + return matches == 1, nil } if _, duplicate := seen[status.Continuation]; duplicate { return false, errors.New("microVM status returned a repeated continuation") @@ -336,26 +372,24 @@ func statusHasGeneration(ctx context.Context, manager Manager, request microvmma return false, errors.New("microVM delete validation exceeded 1024 status pages") } -// WriteHelp documents the canonical command and the compatibility frontend. +// WriteHelp documents the canonical mecated command. func WriteHelp(out io.Writer) { lines := []string{ "Usage: mecated microvm doctor|status|delete [flags]", - " mecatui microvm doctor|status|delete [flags]", "", - "Administers microVM state owned by the current local OS principal on this host.", - "It never targets a remote server selected by 'mecatui connect'.", - "'mecatui microvm' is a compatibility frontend over this same command.", + "Administers microVM state owned by the current OS principal on this local execution host.", "", "doctor read-only host preflight and backend health; never installs or starts", - "status read-only owner-scoped repository generations and logical worktrees", - "delete remove an exact logical --session/--ref/--generation after validation and confirmation", + "status read-only owner-scoped repository generations and logical attachments", + "delete remove one exact logical attachment after validation and confirmation", "", - "Run ' microvm --help' for command flags and examples.", + "Run 'mecated microvm --help' for command flags and examples.", "Text output is the default; every command accepts --output text|json.", - "Repository-VM deletion is not supported.", + "Repository-VM deletion and reset are not supported.", "", - "Create a microVM session in mecatui:", - " mecatui --default-placement microvm-local", + "Configure embedded mecatui placement in operator settings:", + " execution:", + " default_placement: microvm-local", "Create one through a headless HTTP server (child asks use headless policy, not a local TUI):", ` mecated serve --headless --default-placement microvm-local; then POST /v1/sessions with {}`, "Selecting the deployment default performs installation/readiness; doctor and status never do.", @@ -365,28 +399,28 @@ func WriteHelp(out io.Writer) { func writeDoctorHelp(out io.Writer) { _, _ = fmt.Fprintln(out, `Usage: mecated microvm doctor [--output text|json] - mecatui microvm doctor [--output text|json] -Read-only host preflight and backend diagnosis. This command never downloads, -installs, configures, or starts microvmd. On a fresh home it reports "not -configured" and shows how selecting microvm-local performs readiness.`) +Read-only host preflight and backend diagnosis for the current OS principal on +this execution host. This command never downloads, installs, configures, or starts +microvmd. On a fresh home with satisfied prerequisites it reports "ready to +configure on first use" and succeeds.`) } func writeStatusHelp(out io.Writer) { _, _ = fmt.Fprintln(out, `Usage: mecated microvm status [--page-size 1..64] [--continuation TOKEN] [--output text|json] - mecatui microvm status [--page-size 1..64] [--continuation TOKEN] [--output text|json] -Read-only owner-scoped backend and logical-worktree inventory. It never installs, -starts, stops, or deletes anything. Pass the printed opaque continuation token to -read the next page.`) +Read-only inventory local to the current OS principal on this execution host. +Rows are owner-scoped repository generations and logical worktrees. It never +installs, starts, stops, or deletes anything. Pass the printed opaque continuation +token to read the next page.`) } func writeDeleteHelp(out io.Writer) { - _, _ = fmt.Fprintln(out, `Usage: mecated microvm delete --session ID --ref REF --generation N [--yes] [--output text|json] - mecatui microvm delete --session ID --ref REF --generation N [--yes] [--output text|json] + _, _ = fmt.Fprintln(out, `Usage: mecated microvm delete --backend microvm-local --attachment-id ID --ref REF --generation N [--yes] [--output text|json] -Copy all three selector values from one 'microvm status' row. This removes only -the exact logical attachment and a clean worktree; dirty worktrees and the shared -repository VM are retained. Interactive text mode prompts unless --yes is set; +Copy backend, attachment_id, ref, and generation from one owner-scoped local +'mecated microvm status' row. This removes only that exact logical attachment +and a clean worktree. Dirty worktrees are preserved; the repository VM is never +deleted or reset. Interactive text mode prompts unless --yes is set; noninteractive and JSON use require --yes.`) } diff --git a/internal/microvmcmd/command_test.go b/internal/microvmcmd/command_test.go index caf7759cc..4e4cda697 100644 --- a/internal/microvmcmd/command_test.go +++ b/internal/microvmcmd/command_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "strings" "testing" @@ -14,110 +13,86 @@ import ( func TestStatusJSONIsTypedAndKeepsContinuationAsData(t *testing.T) { mgr := &fakeManager{status: microvmmanager.Status{ Configured: true, Running: true, Socket: "/run/microvmd.sock", Continuation: "opaque token", - Generations: []microvmmanager.Generation{{SessionID: "s1", EnvironmentID: "e1", Ref: "e1@7", Generation: 7, WorktreePath: "/work/s1", State: "ready", Health: "healthy"}}, + Generations: []microvmmanager.Generation{{SessionID: "attachment-1", EnvironmentID: "e1", Ref: "e1@7", Generation: 7, WorktreePath: "/work/s1", State: "ready", Health: "healthy"}}, }} var out strings.Builder - if err := Run(t.Context(), FrontendMecated, []string{"status", "--page-size", "1", "--output", "json"}, strings.NewReader(""), &out, mgr, false); err != nil { + if err := Run(t.Context(), []string{"status", "--page-size", "1", "--output", "json"}, strings.NewReader(""), &out, mgr, false); err != nil { t.Fatal(err) } var got struct { - Profile string `json:"profile"` + Backend string `json:"backend"` Continuation string `json:"continuation"` Generations []struct { - SessionID string `json:"session_id"` - Generation uint32 `json:"generation"` + AttachmentID string `json:"attachment_id"` + Generation uint32 `json:"generation"` } `json:"generations"` } if err := json.Unmarshal([]byte(out.String()), &got); err != nil { t.Fatalf("invalid JSON %q: %v", out.String(), err) } - if got.Profile != microvmmanager.Alias || got.Continuation != "opaque token" || len(got.Generations) != 1 || got.Generations[0].SessionID != "s1" || got.Generations[0].Generation != 7 { + if got.Backend != microvmmanager.Alias || got.Continuation != "opaque token" || len(got.Generations) != 1 || got.Generations[0].AttachmentID != "attachment-1" || got.Generations[0].Generation != 7 { t.Fatalf("status JSON = %+v", got) } - if strings.Contains(out.String(), "next page") || mgr.statusRequest.PageSize != 1 { - t.Fatalf("JSON contains prose or wrong request: %q %+v", out.String(), mgr.statusRequest) + if strings.Contains(out.String(), "session_id") || strings.Contains(out.String(), `"profile"`) || strings.Contains(out.String(), "next page") || mgr.statusRequest.PageSize != 1 { + t.Fatalf("status JSON exposed stale names/prose or wrong request: %q %+v", out.String(), mgr.statusRequest) } } -func TestStatusContinuationNamesOnlyInvokingFrontend(t *testing.T) { - for _, frontend := range []Frontend{FrontendMecated, FrontendMecatui} { - t.Run(string(frontend), func(t *testing.T) { - mgr := &fakeManager{status: microvmmanager.Status{Configured: true, Running: true, Continuation: "opaque token"}} - var out strings.Builder - if err := Run(t.Context(), frontend, []string{"status"}, strings.NewReader(""), &out, mgr, false); err != nil { - t.Fatal(err) - } - want := fmt.Sprintf("next page: %s microvm status --continuation %q", frontend, "opaque token") - if !strings.Contains(out.String(), want) { - t.Fatalf("status omitted invoking frontend continuation %q:\n%s", want, out.String()) - } - other := FrontendMecated - if frontend == FrontendMecated { - other = FrontendMecatui - } - if strings.Contains(out.String(), string(other)+" microvm status --continuation") { - t.Fatalf("status named uninvoked frontend %q:\n%s", other, out.String()) - } - }) +func TestStatusJSONKeepsStoppedStateOnError(t *testing.T) { + mgr := &fakeManager{ + status: microvmmanager.Status{Configured: true, Running: false, Socket: "/run/microvmd.sock", GuestEgress: "deny-all"}, + statusErr: errors.New("configured microvmd is not serving at /private/socket"), + } + var out strings.Builder + err := Run(t.Context(), []string{"status", "--output", "json"}, strings.NewReader(""), &out, mgr, false) + if err == nil { + t.Fatal("stopped status returned success") + } + var got statusJSON + if json.Unmarshal([]byte(out.String()), &got) != nil { + t.Fatalf("invalid status JSON: %q", out.String()) + } + if got.State != "stopped" || got.Error != "daemon_not_running" || !strings.Contains(got.Remediation, "mecated microvm doctor") || got.Running { + t.Fatalf("stopped status = %+v", got) + } + if strings.Contains(out.String(), "/private/socket") { + t.Fatalf("status JSON leaked raw failure detail: %q", out.String()) } } -func TestStatusEmptyStateIsExplicitAndActionable(t *testing.T) { +func TestStatusTextIsLocalReadOnlyAndCanonical(t *testing.T) { + mgr := &fakeManager{status: microvmmanager.Status{ + Configured: true, Running: true, Continuation: "opaque token", + Generations: []microvmmanager.Generation{{SessionID: "attachment-1", Ref: "env@2", Generation: 2, Health: "healthy", State: "ready"}}, + }} var out strings.Builder - if err := Run(t.Context(), FrontendMecated, []string{"status"}, strings.NewReader(""), &out, &fakeManager{}, false); err != nil { + if err := Run(t.Context(), []string{"status"}, strings.NewReader(""), &out, mgr, false); err != nil { t.Fatal(err) } for _, want := range []string{ - "backend state: not configured", + "backend: microvm-local", + "logical worktree: attachment_id=attachment-1", + "next page: mecated microvm status --continuation \"opaque token\"", "status is read-only", - "mecatui --default-placement microvm-local", - `mecated serve --headless --default-placement microvm-local`, } { if !strings.Contains(out.String(), want) { - t.Fatalf("fresh status omitted %q:\n%s", want, out.String()) + t.Fatalf("status omitted %q:\n%s", want, out.String()) } } -} - -func TestDeleteRequiresExactSelectorAndExplicitConsent(t *testing.T) { - mgr := &fakeManager{status: microvmmanager.Status{Generations: []microvmmanager.Generation{{SessionID: "s1", Ref: "env@2", Generation: 2}}}} - for _, args := range [][]string{ - {"delete", "--session", "s1", "--ref", "env@2", "--generation", "2"}, - {"delete", "--session", "s1", "--ref", "env@3", "--generation", "2", "--yes"}, - } { - if err := Run(t.Context(), FrontendMecated, args, strings.NewReader("yes\n"), &strings.Builder{}, mgr, false); err == nil { - t.Fatalf("Run(%v) succeeded", args) - } - } - if mgr.deleteCalls != 0 { - t.Fatalf("delete calls = %d", mgr.deleteCalls) + if strings.Contains(out.String(), "profile:") || strings.Contains(out.String(), "session=") || strings.Contains(out.String(), "mecatui microvm") { + t.Fatalf("status exposed stale terminology:\n%s", out.String()) } } -func TestDeleteJSONReportsSelectorAndDirtyRetention(t *testing.T) { - mgr := &fakeManager{ - status: microvmmanager.Status{Generations: []microvmmanager.Generation{{SessionID: "s1", Ref: "env@2", Generation: 2}}}, - deleteResult: microvmmanager.DeleteResult{WorktreePath: "/work/s1", WorktreeRetained: true}, - } +func TestStatusEmptyStateIsExplicitAndActionable(t *testing.T) { var out strings.Builder - err := Run(t.Context(), FrontendMecated, []string{"delete", "--session", "s1", "--ref", "env@2", "--generation", "2", "--yes", "--output", "json"}, strings.NewReader(""), &out, mgr, false) - if err != nil { - t.Fatal(err) - } - var got struct { - Selector struct { - SessionID string `json:"session_id"` - } `json:"selector"` - Result struct { - WorktreePath string `json:"worktree_path"` - } `json:"result"` - DirtyRetained bool `json:"dirty_retained"` - } - if err := json.Unmarshal([]byte(out.String()), &got); err != nil { + if err := Run(t.Context(), []string{"status"}, strings.NewReader(""), &out, &fakeManager{}, false); err != nil { t.Fatal(err) } - if got.Selector.SessionID != "s1" || got.Result.WorktreePath != "/work/s1" || !got.DirtyRetained { - t.Fatalf("delete JSON = %+v", got) + for _, want := range []string{"backend state: ready to configure on first use", "status is read-only", "default_placement: microvm-local", "guest IPv4 egress defaults to permissive", `mecated serve --headless --default-placement microvm-local`} { + if !strings.Contains(out.String(), want) { + t.Fatalf("fresh status omitted %q:\n%s", want, out.String()) + } } } @@ -128,7 +103,7 @@ func TestDoctorJSONReportsSuccessAndFailure(t *testing.T) { }{{nil, true}, {errors.New("KVM unavailable"), false}} { mgr := &fakeManager{doctorReport: "PASS kvm\n", doctorErr: tc.err} var out strings.Builder - err := Run(t.Context(), FrontendMecated, []string{"doctor", "--output", "json"}, strings.NewReader(""), &out, mgr, false) + err := Run(t.Context(), []string{"doctor", "--output", "json"}, strings.NewReader(""), &out, mgr, false) if (err == nil) != tc.success { t.Fatalf("err = %v, success=%t", err, tc.success) } @@ -142,167 +117,107 @@ func TestDoctorJSONReportsSuccessAndFailure(t *testing.T) { } } -func TestStableJSONAndTextOutput(t *testing.T) { - status := microvmmanager.Status{ - Configured: true, - Running: true, - Socket: "/run/microvmd.sock", - GuestEgress: "deny-all", - Generations: []microvmmanager.Generation{{ - SessionID: "s1", EnvironmentID: "env1", Ref: "env1@7", Generation: 7, - WorktreePath: "/work/s1", State: "ready", Health: "healthy", Error: "", - }}, - Continuation: "next-token", - } - selector := []string{"--session", "s1", "--ref", "env1@7", "--generation", "7", "--yes"} - tests := []struct { - name string - args []string - manager *fakeManager - wantOutput string - wantErr bool - }{ - { - name: "status json", args: []string{"status", "--output", "json"}, manager: &fakeManager{status: status}, - wantOutput: "{\"profile\":\"microvm-local\",\"configured\":true,\"running\":true,\"socket\":\"/run/microvmd.sock\",\"guest_egress\":\"deny-all\",\"generations\":[{\"session_id\":\"s1\",\"environment_id\":\"env1\",\"ref\":\"env1@7\",\"generation\":7,\"worktree_path\":\"/work/s1\",\"state\":\"ready\",\"health\":\"healthy\",\"error\":\"\"}],\"continuation\":\"next-token\"}\n", - }, - { - name: "status text", args: []string{"status"}, manager: &fakeManager{status: status}, - wantOutput: "profile: microvm-local\nconfigured: true\ndaemon running: true\nsocket: /run/microvmd.sock\nguest egress: deny-all\nlogical worktree: session=s1 ref=env1@7 repository-generation=7 health=healthy state=ready worktree=/work/s1\nnext page: mecated microvm status --continuation \"next-token\"\nstatus is read-only; it never installs, starts, stops, or deletes microVM state\n", - }, - { - name: "doctor success json", args: []string{"doctor", "--output", "json"}, manager: &fakeManager{doctorReport: "PASS kvm\n"}, - wantOutput: "{\"success\":true,\"report\":\"PASS kvm\\n\",\"error\":\"\"}\n", - }, - { - name: "doctor success text", args: []string{"doctor"}, manager: &fakeManager{doctorReport: "PASS kvm\n"}, - wantOutput: "PASS kvm\n", - }, - { - name: "doctor failure json", args: []string{"doctor", "--output", "json"}, manager: &fakeManager{doctorReport: "FAIL kvm\n", doctorErr: errors.New("KVM unavailable")}, - wantOutput: "{\"success\":false,\"report\":\"FAIL kvm\\n\",\"error\":\"KVM unavailable\"}\n", wantErr: true, - }, - { - name: "doctor failure text", args: []string{"doctor"}, manager: &fakeManager{doctorReport: "FAIL kvm\n", doctorErr: errors.New("KVM unavailable")}, - wantOutput: "FAIL kvm\n", wantErr: true, - }, - { - name: "clean delete json", args: append([]string{"delete"}, append(selector, "--output", "json")...), manager: deleteManager(false), - wantOutput: "{\"selector\":{\"session_id\":\"s1\",\"ref\":\"env1@7\",\"generation\":7},\"result\":{\"worktree_path\":\"/work/s1\",\"worktree_removed\":true,\"repository_vm_retained\":true},\"dirty_retained\":false}\n", - }, - { - name: "dirty delete json", args: append([]string{"delete"}, append(selector, "--output", "json")...), manager: deleteManager(true), - wantOutput: "{\"selector\":{\"session_id\":\"s1\",\"ref\":\"env1@7\",\"generation\":7},\"result\":{\"worktree_path\":\"/work/s1\",\"worktree_removed\":false,\"repository_vm_retained\":true},\"dirty_retained\":true}\n", - }, - { - name: "clean delete text", args: append([]string{"delete"}, selector...), manager: deleteManager(false), - wantOutput: "Permanently delete logical worktree session=s1 ref=env1@7 repository-generation=7. The shared repository VM is not deleted. Closing a host client normally only detaches and preserves this worktree. Dirty worktrees are retained.\nlogical attachment and clean worktree removed; repository VM retained: /work/s1\n", - }, - { - name: "dirty delete text", args: append([]string{"delete"}, selector...), manager: deleteManager(true), - wantOutput: "Permanently delete logical worktree session=s1 ref=env1@7 repository-generation=7. The shared repository VM is not deleted. Closing a host client normally only detaches and preserves this worktree. Dirty worktrees are retained.\nlogical attachment deleted; dirty worktree retained: /work/s1\n", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - var out strings.Builder - err := Run(t.Context(), FrontendMecated, tc.args, strings.NewReader(""), &out, tc.manager, false) - if (err != nil) != tc.wantErr { - t.Fatalf("error = %v, wantErr %t", err, tc.wantErr) - } - if got := out.String(); got != tc.wantOutput { - t.Fatalf("output mismatch\n got: %q\nwant: %q", got, tc.wantOutput) - } - }) +func TestDeleteRequiresExactTargetAndConfirmation(t *testing.T) { + mgr := &fakeManager{status: microvmmanager.Status{Generations: []microvmmanager.Generation{{SessionID: "attachment-1", Ref: "env-1@7", Generation: 7}}}} + var out strings.Builder + err := Run(t.Context(), []string{ + "delete", "--backend", "microvm-local", "--attachment-id", "attachment-1", "--ref", "env-1@7", "--generation", "7", "--yes", + }, strings.NewReader(""), &out, mgr, false) + if err != nil { + t.Fatal(err) } -} - -func deleteManager(retained bool) *fakeManager { - return &fakeManager{ - status: microvmmanager.Status{Generations: []microvmmanager.Generation{{SessionID: "s1", Ref: "env1@7", Generation: 7}}}, - deleteResult: microvmmanager.DeleteResult{WorktreePath: "/work/s1", WorktreeRetained: retained}, + if mgr.deleteCalls != 1 || mgr.deleteRequest.SessionID != "attachment-1" || !strings.Contains(out.String(), "repository VM retained") { + t.Fatalf("delete calls=%d request=%+v output=%q", mgr.deleteCalls, mgr.deleteRequest, out.String()) } } -func TestDeletePaginationFailuresDoNotPromptOrDelete(t *testing.T) { +func TestDeleteRejectsIncompleteMismatchedAndUnconfirmedTargets(t *testing.T) { + target := microvmmanager.Generation{SessionID: "attachment-1", Ref: "env-1@7", Generation: 7} tests := []struct { - name string - status func(microvmmanager.StatusRequest) microvmmanager.Status - wantCalls int - wantError string + name string + args []string }{ - { - name: "repeated continuation", - status: func(microvmmanager.StatusRequest) microvmmanager.Status { - return microvmmanager.Status{Continuation: "same-token"} - }, - wantCalls: 2, wantError: "repeated continuation", - }, - { - name: "page cap", - status: func(request microvmmanager.StatusRequest) microvmmanager.Status { - return microvmmanager.Status{Continuation: fmt.Sprintf("page-%04d", requestCount(request.Continuation)+1)} - }, - wantCalls: 1024, wantError: "exceeded 1024 status pages", - }, + {name: "missing confirmation", args: []string{"delete", "--backend", "microvm-local", "--attachment-id", "attachment-1", "--ref", "env-1@7", "--generation", "7"}}, + {name: "wrong backend", args: []string{"delete", "--backend", "other", "--attachment-id", "attachment-1", "--ref", "env-1@7", "--generation", "7", "--yes"}}, + {name: "missing attachment", args: []string{"delete", "--backend", "microvm-local", "--ref", "env-1@7", "--generation", "7", "--yes"}}, + {name: "wrong attachment", args: []string{"delete", "--backend", "microvm-local", "--attachment-id", "attachment-2", "--ref", "env-1@7", "--generation", "7", "--yes"}}, + {name: "missing ref", args: []string{"delete", "--backend", "microvm-local", "--attachment-id", "attachment-1", "--generation", "7", "--yes"}}, + {name: "wrong ref", args: []string{"delete", "--backend", "microvm-local", "--attachment-id", "attachment-1", "--ref", "env-2@7", "--generation", "7", "--yes"}}, + {name: "missing generation", args: []string{"delete", "--backend", "microvm-local", "--attachment-id", "attachment-1", "--ref", "env-1@7", "--yes"}}, + {name: "wrong generation", args: []string{"delete", "--backend", "microvm-local", "--attachment-id", "attachment-1", "--ref", "env-1@8", "--generation", "8", "--yes"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - mgr := &fakeManager{statusFunc: tc.status} - var out strings.Builder - err := Run(t.Context(), FrontendMecated, []string{"delete", "--session", "s1", "--ref", "env@1", "--generation", "1"}, strings.NewReader("yes\n"), &out, mgr, true) - if err == nil || !strings.Contains(err.Error(), tc.wantError) { - t.Fatalf("error = %v, want %q", err, tc.wantError) + mgr := &fakeManager{status: microvmmanager.Status{Generations: []microvmmanager.Generation{target}}} + err := Run(t.Context(), tc.args, strings.NewReader(""), &strings.Builder{}, mgr, false) + if err == nil { + t.Fatal("unsafe delete succeeded") } - if mgr.statusCalls != tc.wantCalls || mgr.deleteCalls != 0 || strings.Contains(out.String(), "[y/N]") { - t.Fatalf("status calls=%d delete calls=%d output=%q", mgr.statusCalls, mgr.deleteCalls, out.String()) + if mgr.deleteCalls != 0 { + t.Fatalf("unsafe delete reached mutation %d times", mgr.deleteCalls) } }) } } -func requestCount(continuation string) int { - if continuation == "" { - return 0 - } - var count int - _, _ = fmt.Sscanf(continuation, "page-%04d", &count) - return count -} - -func TestDeleteNoninteractiveNeverReadsConsent(t *testing.T) { - mgr := deleteManager(false) - var out strings.Builder - err := Run(t.Context(), FrontendMecated, []string{"delete", "--session", "s1", "--ref", "env1@7", "--generation", "7"}, panicReader{}, &out, mgr, false) - if err == nil || err.Error() != "microVM delete requires confirmation (--yes for noninteractive use)" { - t.Fatalf("error = %v", err) - } - if mgr.deleteCalls != 0 { - t.Fatalf("delete calls = %d", mgr.deleteCalls) - } +func TestDeleteValidationTraversesPagesAndRejectsAmbiguity(t *testing.T) { + target := microvmmanager.Generation{SessionID: "attachment-1", Ref: "env-1@7", Generation: 7} + t.Run("later page", func(t *testing.T) { + mgr := &fakeManager{statusPages: map[string]microvmmanager.Status{ + "": {Continuation: "next"}, + "next": {Generations: []microvmmanager.Generation{target}}, + }} + err := Run(t.Context(), []string{"delete", "--backend", "microvm-local", "--attachment-id", "attachment-1", "--ref", "env-1@7", "--generation", "7", "--yes"}, strings.NewReader(""), &strings.Builder{}, mgr, false) + if err != nil || mgr.deleteCalls != 1 || mgr.statusCalls != 2 { + t.Fatalf("paged delete err=%v status=%d delete=%d", err, mgr.statusCalls, mgr.deleteCalls) + } + }) + t.Run("duplicate target", func(t *testing.T) { + mgr := &fakeManager{statusPages: map[string]microvmmanager.Status{ + "": {Generations: []microvmmanager.Generation{target}, Continuation: "next"}, + "next": {Generations: []microvmmanager.Generation{target}}, + }} + err := Run(t.Context(), []string{"delete", "--backend", "microvm-local", "--attachment-id", "attachment-1", "--ref", "env-1@7", "--generation", "7", "--yes"}, strings.NewReader(""), &strings.Builder{}, mgr, false) + if err == nil || !strings.Contains(err.Error(), "ambiguous") || mgr.deleteCalls != 0 { + t.Fatalf("ambiguous delete err=%v delete=%d", err, mgr.deleteCalls) + } + }) + t.Run("repeated continuation", func(t *testing.T) { + mgr := &fakeManager{statusPages: map[string]microvmmanager.Status{ + "": {Continuation: "next"}, + "next": {Continuation: "next"}, + }} + err := Run(t.Context(), []string{"delete", "--backend", "microvm-local", "--attachment-id", "attachment-1", "--ref", "env-1@7", "--generation", "7", "--yes"}, strings.NewReader(""), &strings.Builder{}, mgr, false) + if err == nil || !strings.Contains(err.Error(), "repeated continuation") || mgr.deleteCalls != 0 { + t.Fatalf("cyclic pagination err=%v delete=%d", err, mgr.deleteCalls) + } + }) } -type panicReader struct{} - -func (panicReader) Read([]byte) (int, error) { panic("noninteractive confirmation read stdin") } - -func TestHelpAllIsAccepted(t *testing.T) { +func TestHelpIsReadOnlyLocalAndSideEffectFree(t *testing.T) { + mgr := &fakeManager{} var out strings.Builder - if err := Run(t.Context(), FrontendMecated, []string{"--help-all"}, strings.NewReader(""), &out, &fakeManager{}, false); err != nil { + if err := Run(t.Context(), []string{"--help-all"}, strings.NewReader(""), &out, mgr, false); err != nil { t.Fatal(err) } for _, want := range []string{ "Usage: mecated microvm doctor|status|delete", - "mecatui microvm doctor|status|delete", + "current OS principal", + "local execution host", "doctor read-only", "status read-only", - "mecatui --default-placement microvm-local", - `mecated serve --headless --default-placement microvm-local`, + "delete remove one exact logical attachment", } { if !strings.Contains(out.String(), want) { t.Fatalf("help omitted %q: %q", want, out.String()) } } + if strings.Contains(out.String(), "mecatui microvm") { + t.Fatalf("help advertised noncanonical frontend: %q", out.String()) + } + if mgr.doctorCalls != 0 || mgr.statusCalls != 0 { + t.Fatal("help called manager") + } } func TestSubcommandHelpIsSideEffectFreeAndShowsEssentialFlags(t *testing.T) { @@ -310,13 +225,12 @@ func TestSubcommandHelpIsSideEffectFreeAndShowsEssentialFlags(t *testing.T) { command string want []string }{ - {command: "doctor", want: []string{"never downloads", "--output text|json"}}, - {command: "status", want: []string{"Read-only owner-scoped", "--page-size 1..64", "--continuation TOKEN", "--output text|json"}}, - {command: "delete", want: []string{"Copy all three selector values", "--session ID", "--ref REF", "--generation N", "--yes", "--output text|json"}}, + {command: "doctor", want: []string{"current OS principal", "fresh home", "--output text|json"}}, + {command: "status", want: []string{"current OS principal", "owner-scoped", "--page-size 1..64", "--continuation TOKEN", "--output text|json"}}, } { mgr := &fakeManager{} var out strings.Builder - if err := Run(t.Context(), FrontendMecated, []string{tc.command, "--help"}, strings.NewReader(""), &out, mgr, false); err != nil { + if err := Run(t.Context(), []string{tc.command, "--help"}, strings.NewReader(""), &out, mgr, false); err != nil { t.Fatalf("%s --help: %v", tc.command, err) } for _, want := range tc.want { @@ -324,18 +238,14 @@ func TestSubcommandHelpIsSideEffectFreeAndShowsEssentialFlags(t *testing.T) { t.Fatalf("%s help omitted %q:\n%s", tc.command, want, out.String()) } } - if mgr.doctorCalls != 0 || mgr.statusCalls != 0 || mgr.deleteCalls != 0 { - t.Fatalf("%s help called manager: doctor=%d status=%d delete=%d", tc.command, mgr.doctorCalls, mgr.statusCalls, mgr.deleteCalls) + if mgr.doctorCalls != 0 || mgr.statusCalls != 0 { + t.Fatalf("%s help called manager", tc.command) } } - err := Run(t.Context(), FrontendMecated, []string{"wat"}, strings.NewReader(""), &strings.Builder{}, &fakeManager{}, false) - if err == nil || !strings.Contains(err.Error(), "mecated microvm --help") || !strings.Contains(err.Error(), "mecatui microvm --help") { - t.Fatalf("unknown command guidance = %v", err) - } } func TestRejectsUnknownOutputFormat(t *testing.T) { - err := Run(t.Context(), FrontendMecated, []string{"status", "--output", "yaml"}, strings.NewReader(""), &strings.Builder{}, &fakeManager{}, false) + err := Run(t.Context(), []string{"status", "--output", "yaml"}, strings.NewReader(""), &strings.Builder{}, &fakeManager{}, false) if err == nil || !strings.Contains(err.Error(), "unknown output format") { t.Fatalf("error = %v", err) } @@ -343,12 +253,14 @@ func TestRejectsUnknownOutputFormat(t *testing.T) { type fakeManager struct { status microvmmanager.Status - statusFunc func(microvmmanager.StatusRequest) microvmmanager.Status + statusPages map[string]microvmmanager.Status + statusErr error statusRequest microvmmanager.StatusRequest statusCalls int doctorReport string doctorErr error doctorCalls int + deleteRequest microvmmanager.DeleteRequest deleteResult microvmmanager.DeleteResult deleteCalls int } @@ -357,17 +269,20 @@ func (f *fakeManager) Doctor(context.Context) (string, error) { f.doctorCalls++ return f.doctorReport, f.doctorErr } + func (f *fakeManager) Status(_ context.Context, requests ...microvmmanager.StatusRequest) (microvmmanager.Status, error) { f.statusCalls++ if len(requests) > 0 { f.statusRequest = requests[0] - if f.statusFunc != nil { - return f.statusFunc(requests[0]), nil + if f.statusPages != nil { + return f.statusPages[requests[0].Continuation], f.statusErr } } - return f.status, nil + return f.status, f.statusErr } -func (f *fakeManager) Delete(context.Context, microvmmanager.DeleteRequest) (microvmmanager.DeleteResult, error) { + +func (f *fakeManager) Delete(_ context.Context, request microvmmanager.DeleteRequest) (microvmmanager.DeleteResult, error) { f.deleteCalls++ + f.deleteRequest = request return f.deleteResult, nil } diff --git a/user-docs/building/deployment/microvm-environments.md b/user-docs/building/deployment/microvm-environments.md index 13589696a..46c9f6389 100644 --- a/user-docs/building/deployment/microvm-environments.md +++ b/user-docs/building/deployment/microvm-environments.md @@ -11,34 +11,77 @@ mecatl server remain on the host. It is for one local operator and Git repositor Linux arm64, macOS, remote placement, schedules, multi-user sharing, and non-Git sources are not available. -Install a published, release-stamped `mecatui` or `mecated` binary and verify it before -use. Source builds are for the separate repository-developer workflow, not ordinary -local installation. Git, Python 3, read-write `/dev/kvm`, and unprivileged user -namespaces are required. The full [operator runbook](https://github.com/stacklok/mecatl/blob/main/docs/usage/microvm-environments.md) +Install and verify published, release-stamped `mecatui` **and** `mecated` binaries before +use. `mecatui` runs the embedded server; `mecated` supplies the local `microvm doctor`, +`status`, and `delete` administration commands and does not need to remain running. +Source builds are for the separate repository-developer workflow, not ordinary local +installation. Git, Python 3, read-write `/dev/kvm`, and unprivileged user namespaces are +required. The full [operator runbook](https://github.com/stacklok/mecatl/blob/main/docs/usage/microvm-environments.md) includes verification and developer workflow instructions. -## mecatui-only journey +> **Evidence boundary:** `task e2e:microvm` is the opt-in automated Linux amd64 KVM gate +> and uses the deterministic mock provider; it never contacts OpenRouter. Separately, on +> 2026-09-10, a manual qualification used OpenRouter `openai/gpt-5-mini` through the public +> HTTP create and prompt APIs. Write, Read, and Bash ran in the Wolfi guest as UID 65532, the +> marker stayed out of the source checkout, and the same session reattached after restarting +> only mecated while microvmd remained alive. Doctor and status were healthy. No credential, +> private placement ref, socket, or host path was retained; this is not a microvmd-restart claim. -From the Git repository: +Install and verify both host binaries (set `VERSION` to the release tag): ```sh -mecatui microvm doctor -mecatui --default-placement microvm-local +VERSION=vX.Y.Z +PLATFORM=linux-amd64 +mkdir -p "$HOME/.local/bin" .scratch/mecatl-host-release +cd .scratch/mecatl-host-release +for BINARY in mecatui mecated; do + gh release download "$VERSION" --repo stacklok/mecatl \ + --pattern "${BINARY}-${VERSION}-${PLATFORM}" \ + --pattern "${BINARY}-${VERSION}-${PLATFORM}.sha256" \ + --pattern "${BINARY}-${VERSION}-${PLATFORM}.sigstore.json" + cosign verify-blob \ + --bundle "${BINARY}-${VERSION}-${PLATFORM}.sigstore.json" \ + --certificate-identity "https://github.com/stacklok/mecatl/.github/workflows/release.yml@refs/tags/${VERSION}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "${BINARY}-${VERSION}-${PLATFORM}" + gh attestation verify "${BINARY}-${VERSION}-${PLATFORM}" --repo stacklok/mecatl + sha256sum --check "${BINARY}-${VERSION}-${PLATFORM}.sha256" + install -m 0755 "${BINARY}-${VERSION}-${PLATFORM}" "$HOME/.local/bin/${BINARY}" +done +export PATH="$HOME/.local/bin:$PATH" +cd ../.. ``` -`microvm doctor` is read-only and can report an unconfigured backend on a fresh home. -Selecting the profile prepares the verified local runtime and creates the session. Bare -`mecatui` remains host-local. +## Embedded mecatui journey -Inspect and resume with the same profile: +Set the server-owned placement once in `~/.config/mecatl/settings.yaml`, then use bare +`mecatui`: + +```yaml +execution: + default_placement: microvm-local +``` + +```sh +mecated microvm doctor +mecatui +``` + +`microvm doctor` is read-only. With prerequisites satisfied, a fresh home reports +`ready to configure on first use` and succeeds. Bare mecatui hosts its in-process server; +you do not start a separate `mecated serve` process. During the first session, the UI +shows bounded download, verification, installation, and daemon-start progress. A failure +names the preparation stage and directs you to `mecated microvm doctor` plus the exact +mecatui diagnostics log. Inspect and resume without reselecting placement: ```sh -mecatui microvm status -mecatui --default-placement microvm-local --resume SESSION_ID +mecated microvm status +mecatui --resume SESSION_ID ``` -A microVM session remains in the microVM profile for its lifetime; it is never moved to -host execution. +A microVM session remains on its exact server-owned placement for its lifetime; it is +never moved to host execution. `mecatui connect ADDRESS` is a pure remote client and never +resolves, starts, or forwards local MicroVM placement. ## Headless mecated-only journey @@ -72,14 +115,16 @@ exact environment refs remain private. ## Guest egress and isolation Guest IPv4 egress is permissive by default. External IPv6 is unrouted and unsupported. -The host operator can restrict guest egress only at the local composition root: - -```sh -mecatui --default-placement microvm-local --microvm-guest-egress=deny-all - -mecated serve --headless \ - --microvm-guest-egress=allowlist \ - --microvm-guest-allow=api.example.com:443/tcp +The host operator restricts guest egress in the same settings file: + +```yaml +execution: + default_placement: microvm-local + microvm: + guest_egress: + mode: allowlist + allow: + - api.example.com:443/tcp ``` Allowlist rules use `HOST:PORT/tcp|udp`; at least one valid rule is required. Invalid @@ -94,6 +139,21 @@ rootfs, and worktrees intact while live hosted dependencies are unavailable; aff sessions report that condition. Mecatl never falls back to host filesystem or shell execution and never creates an empty replacement environment. -Use read-only `microvm doctor` and `microvm status` to inspect the local environment. -`microvm delete --session ID --ref REF --generation N` removes one logical attachment, -retains dirty worktrees, and does not delete the shared repository VM. +Use `mecated microvm doctor` and `mecated microvm status` for read-only inspection +local to the execution host and current OS principal. Status calls daemon rows +`attachment_id`; they are placement attachments, not public mecatl session IDs. To remove +one exact retained logical attachment, copy its `backend`, `attachment_id`, `ref`, and +`generation` from the same status row and run: + +```sh +mecated microvm delete --backend microvm-local \ + --attachment-id ATTACHMENT_ID --ref REF --generation GENERATION +``` + +The command confirms before deletion, preserves dirty worktrees, and never deletes or resets +the repository VM. Administration exists only in local `mecated`, not mecatui or remote +connect mode. One repository-scoped daemon is shared across sessions and host processes. First use installs +and starts only genuinely fresh state. A conflicting requested release or egress policy, +corrupt configuration, process identity mismatch, stopped daemon, or unhealthy runtime +fails without restarting the daemon, rewriting active configuration, deleting state, or +replacing repository runtime. diff --git a/user-docs/building/what-you-get/core-tools.md b/user-docs/building/what-you-get/core-tools.md index 334239b10..039572ce9 100644 --- a/user-docs/building/what-you-get/core-tools.md +++ b/user-docs/building/what-you-get/core-tools.md @@ -86,16 +86,19 @@ A session can be created with `profile: "no-fs"` — for a workspace that has no ### Execution placement providers -`microvm-local` is a trusted deployment default selected only by a local composition root -with `--default-placement microvm-local`. Ordinary session creation then uses that default; +`microvm-local` is a trusted deployment default selected through the strict operator-tier +`execution.default_placement` setting (or a higher-precedence explicit mecated serve flag). +Bare mecatui consumes that setting for its embedded server; mecatui connect remains remote-only. +Ordinary session creation then uses that default; clients cannot submit a placement alias, workspace path, or exact environment ref. Public session data contains bounded `PlacementMetadata` only. `profile: "no-fs"` remains the one client-selected attenuation. The daemon owns image, resource, egress, lifecycle, and attestation policy, and unavailable placement fails without host fallback. -Guest IPv4 is permissive by default, with external IPv6 unrouted. Local composition-root -flags may tighten it: `--microvm-guest-egress=deny-all`, or `allowlist` with repeatable -`--microvm-guest-allow=HOST:PORT/tcp|udp`. HTTP/gRPC requests and project config cannot +Guest IPv4 is permissive by default, with external IPv6 unrouted. The operator can tighten +it with `execution.microvm.guest_egress.mode: deny-all`, or `allowlist` plus +`allow: [HOST:PORT/tcp|udp]`. Explicit mecated serve flags override settings for one run. +HTTP/gRPC requests and project config cannot select or weaken placement or egress policy. --- From 60c673f4fa54e4e47a464d5671314936f11268a4 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Fri, 11 Sep 2026 09:51:02 +0300 Subject: [PATCH 3/3] fix microVM release workflow invariant Co-Authored-By: mecatl --- .github/scripts/microvm-ci-release_test.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/scripts/microvm-ci-release_test.sh b/.github/scripts/microvm-ci-release_test.sh index 72d1183b9..2e25b89b7 100644 --- a/.github/scripts/microvm-ci-release_test.sh +++ b/.github/scripts/microvm-ci-release_test.sh @@ -120,10 +120,14 @@ test "$(printf '%s\n' "$assemble_bundle" | grep -Fc 'signing_ref="${{ needs.vali require 'release.yml@${signing_ref}' "$release" checkout_count=$(grep -c 'uses: actions/checkout@' "$release") bound_checkout_count=$(grep -c 'ref: ${{ github.sha }}' "$release") +version_checkout_count=$(grep -c 'ref: ${{ env.VERSION }}' "$release") head_assertion_count=$(grep -c 'run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"' "$release") -test "$checkout_count" -eq "$bound_checkout_count" -test "$checkout_count" -eq "$head_assertion_count" -forbid 'ref: ${{ env.VERSION }}' "$release" +# The CLI publisher deliberately checks out the validated release tag so +# GoReleaser can inspect tag history; every other release checkout remains bound +# to the workflow SHA and immediately asserts it. +test "$version_checkout_count" -eq 1 +test "$checkout_count" -eq "$((bound_checkout_count + version_checkout_count))" +test "$bound_checkout_count" -eq "$head_assertion_count" if "$validate_release_ref" v1.2.3 refs/heads/main >/dev/null 2>&1; then echo 'branch-dispatched release tag input was accepted' >&2 exit 1