diff --git a/docs/backends/wslc/wsl-container-getting-started.md b/docs/backends/wslc/wsl-container-getting-started.md index 1f8c32e22..05c5894e3 100644 --- a/docs/backends/wslc/wsl-container-getting-started.md +++ b/docs/backends/wslc/wsl-container-getting-started.md @@ -395,9 +395,42 @@ The separate `provision` operation takes the same list under The lifecycle daemon creates one container per sandbox, so a forward belongs to the sandbox that declared it. The session-wide `cpuCount` / `memoryMb` / -`gpu` settings remain available only for create-and-run execution, because every -lifecycle `provision` currently joins one shared session. The daemon itself holds -several independently sized sessions; what no caller can yet do is ask for one. +`gpu` settings size the session rather than the sandbox, so a lifecycle caller +asks for them on a separate `provision-session` operation and then names the +returned session on each `provision`: + +```json +{ + "version": "1.1.0-alpha", + "phase": "provision-session", + "containment": "wslc", + "wslc": { + "provisionSession": { "cpuCount": 4, "memoryMb": 8192 } + } +} +``` + +```json +{ + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "wslc": { + "provision": { + "image": "alpine:latest", + "sessionId": "wslcsess:0123456789abcdef0123456789abcdef" + } + } +} +``` + +Every sandbox in the session shares that VM, so `cpuCount: 4` means the VM has +four virtual CPUs rather than four per sandbox. Omitting `sessionId` joins an +implicit session shared by every request that names none, which is what a WSLC +`provision` has always done. Release the session with `deprovision-session` +once its sandboxes are gone; it is refused while any remain. See +[the lifecycle guide](wslc-state-aware.md#sessions). + `storagePath` is create-and-run-only for a different reason: the daemon leases its store from a slot pool rather than taking a caller-named path, so warming its cache is a separate step. See @@ -643,7 +676,9 @@ policy-persistence primitive, so there is nothing for the flag to select. Container lifecycle JSON requests differ: they reject the whole `lifecycle` section at parse time, because a multi-invocation sandbox's lifetime is driven by the -explicit `provision` / `deprovision` phases rather than by per-run flags. +explicit `provision` / `deprovision` phases rather than by per-run flags. The +session those sandboxes live in has its own pair, `provision-session` / +`deprovision-session`. ## Supported workloads diff --git a/docs/backends/wslc/wslc-state-aware.md b/docs/backends/wslc/wslc-state-aware.md index bc8ff7495..8ce8bcb2c 100644 --- a/docs/backends/wslc/wslc-state-aware.md +++ b/docs/backends/wslc/wslc-state-aware.md @@ -101,10 +101,10 @@ container has no networking for a forward to reach. The field requires development contract `1.1.0-alpha`. Requests declaring `0.9.0-alpha` cannot include it and must select `1.1.0-alpha` explicitly. -The daemon shares one session (VM) but creates a separate container for each -provision, so a mapping applies only to the container that declared it, unlike -the session-wide `cpuCount` / `memoryMb` / `gpu` knobs that -remain available only for create-and-run execution. `storagePath` is not among them either way: +The daemon creates a separate container for each provision, so a mapping +applies only to the container that declared it. `cpuCount`, `memoryMb`, and +`gpu` instead size the session's VM and are set by `provision-session` +(see [Sessions](#sessions)). `storagePath` remains create-and-run-only: the daemon leases its store from the slot pool rather than taking a caller-named path (see [Image store](#image-store)). @@ -123,10 +123,33 @@ process already holds on loopback fails the `start` phase, not `provision`. A WSLc session is one WSL2 utility VM. A daemon holds as many of them as it is asked for, each with its own leased image store, and places sandboxes in them. +A caller addresses a session through two lifecycle phases of its own: + +| Phase | Carries | Returns | +|-------|---------|---------| +| `provision-session` | `wslc.provisionSession.cpuCount`, `memoryMb`, `gpu` | `result.sessionId` | +| `deprovision-session` | `sessionId` | an empty result | + +A sandbox joins one by naming it on `wslc.provision.sessionId`. Sizing belongs +to the session, so `cpuCount: 4` means the VM has four virtual CPUs shared by +every sandbox in it, not a four-CPU quota per sandbox — which is why a +`provision` declares no sizing of its own. + +Both session phases declare `containment: "wslc"` and are routed by it, since a +session ID carries no backend prefix to route by. WSLc is the only backend that +owns a session, so a session phase naming any other containment is refused with +`unsupported_containment`. + +Both phases require contract `1.1.0-alpha`. No v1 SDK can reach them: the Rust, +Node, and .NET v1 surfaces all emit published contract `1.0.0`, which declares +no session fields. Use MXC request JSON through +`mxc_sdk::__ffi::run_lifecycle_json`, the FFI, or `wxc-exec` until a new +stable contract publishes. + A `provision` that names no session joins the daemon's **implicit** session, which is booted on first use and shared by every request that names none. That -is the whole of the daemon's behaviour, and the only reachable behaviour today: -nothing on the public surface carries a session id yet. +is what every provision did before sessions became addressable, so an existing +caller is unaffected. Session ids take the form `wslcsess:<32 lowercase hex>`. The implicit session gets a random id like any other and it is never handed back, so one caller @@ -134,10 +157,48 @@ cannot name the session everyone else is sharing and release it from under them. Releasing the implicit session by id is refused for the same reason; it goes when nothing is using it, or when the daemon does. +Malformed IDs return `malformed_id` for both session placement and release. + +> **A session ID is a bearer capability.** The daemon's pipe is owner-only, so +> any same-user process holding one can place sandboxes in that session and +> release it. Nothing well-known is ever minted — not `wslcsess:default`, not +> anything derivable — and `wxc-exec` refuses an ID that is not +> `wslcsess:` plus 32 lowercase hex before the daemon is contacted. Treat the +> ID as opaque and keep it out of logs, as mxc does. + The SDK is given one name per session, derived from its id (`mxc-wslc-daemon-<32 hex>`). Two live sessions under one name is an untested SDK failure mode, which is why the name is not fixed. +### A sandbox's policy identity does not cover its session's sizing + +When telemetry or diagnostic logging is active, `wxc-exec` computes +`MXC.PolicyHash` from the request **before** dispatch. In-process lifecycle +entry points do not emit this record. A `provision-session` hashes the sizing +it declares. A sandbox joining an existing session declares none, so two otherwise identical +`provision` requests hash identically whether they land in a two-CPU VM or an +eight-CPU VM with GPU passthrough — and `gpu` is a capability grant. + +The gap is accepted rather than papered over: + +- **Restating sizing on the provision request** would make the hash an + unverified caller claim. The request is hashed before the daemon resolves the + session, so a request could declare no GPU and still land in a session that + has one. A false attestation is worse than a known omission. +- **Hashing the `sessionId`** would give two identically sized sessions + unrelated identities, and would put a bearer capability in a log. + +What the hash does record is the one related fact the request carries: +`joinsNamedSession: true` when `wslc.provision.sessionId` is present, meaning +this sandbox's VM was sized by another request rather than by the host defaults +the implicit session uses. It does not identify which session, or what sizing. +A request that names no session hashes exactly as it did before the session +surface existed. + +Closing the gap properly means establishing identity after the backend resolves +the session, which changes when identity is computed for every backend. That is +tracked separately. + ### How many sessions Nothing in mxc bounds the number of concurrent sessions. Neither the store pool, @@ -215,6 +276,10 @@ failed ... (0x80004005)`, blaming the image store for a processor problem. Checking up front returns `policy_validation` naming the processor count and the host's. +`memoryMb` must fit in the SDK's unsigned 32-bit megabyte field +(`0` through `4294967295`). Session validation rejects a larger value before +contacting the daemon, including during a dry run. + `0` means "use the default" for both (2 processors, 2000 MB) and is passed through rather than measured. `gpu` is a feature flag, not a quantity; whether passthrough is available is answered by the runtime component check. The @@ -257,23 +322,42 @@ an unused session until someone explicitly released it — makes an abandoned session pin a utility VM, a store slot, *and* the daemon, with nothing able to reclaim any of them short of ending the process by hand. -## Sandbox IDs +A `deprovision-session` naming a session that still holds something is +**refused**, not cascaded. The sandboxes in a session are addressed +individually and their owners still hold those ids, so deleting sandboxes the +caller never named would be worse than an error. Deprovision them first, then +release the session. + +## Sandbox and session IDs -`provision` mints an id of the form `wslc:<32 lowercase hex>` (`wslc:` + a UUID simple form). -"MXC request JSON" carries this id in `sandboxId` for every post-provision phase -(`start` / `exec` / `stop` / `deprovision`). Direct `wxc-exec` calls omit it from JSON and pass it -as `--container-id`; the dispatcher derives the backend from the `wslc:` prefix (later operations do -**not** repeat `containment`). +`provision` mints an id of the form `wslc:<32 lowercase hex>` (`wslc:` + a UUID simple form), and +`provision-session` mints `wslcsess:<32 lowercase hex>` the same way. +"MXC request JSON" carries the sandbox id in `sandboxId` for every post-provision phase +(`start` / `exec` / `stop` / `deprovision`), and the session id in `sessionId` for +`deprovision-session`. Direct `wxc-exec` calls omit the id from JSON and pass it +as `--container-id` or `--session-id` respectively; the two flags are mutually exclusive, and a +phase supplied the wrong one is told which it wanted. The dispatcher derives the backend from the +`wslc:` prefix for sandbox-addressed phases (later operations do **not** repeat `containment`), and +from the declared `containment` for the session phases. + +The session a sandbox joins is not a routing value, so it stays in the request body at +`wslc.provision.sessionId` rather than moving to a flag. ## Phase → WSLc SDK mapping | Phase | Daemon action (WSLc SDK) | |-------|--------------------------| -| provision | Load SDK, ensure the session (`WslcCreateSession` on first use), resolve/import image, `WslcCreateContainer` with a keepalive init process (so the container survives across separate `exec` phases). The container is created **not started** (`started: false`). | +| provision-session | Load SDK, lease a store slot, `WslcCreateSession` with the requested sizing on a thread of its own, then validate the session with a store probe. Returns the minted `sessionId`. | +| provision | Load SDK, ensure the session (`WslcCreateSession` on first use when none was named), resolve/import image, `WslcCreateContainer` with a keepalive init process (so the container survives across separate `exec` phases). The container is created **not started** (`started: false`). | | start | `WslcStartContainer` — starts the container minted at provision-time (the keepalive init keeps it warm across later `exec` phases); marks it `started`. | | exec | `WslcCreateContainerProcess` in the warm container; stream stdout/stderr and return the process exit code. Stdin is unavailable because the WSLc SDK exposes no process-input API. A timeout or caller cancellation SIGKILLs the **process**, not the container. | | stop | `WslcStopContainer`. | | deprovision | `WslcDeleteContainer`. The session stays warm for the next provision and is released once nothing is using it (see [Session lifetime](#session-lifetime)). | +| deprovision-session | Refuse while anything is still in the session, else terminate the VM and return its store slot. | + +A dry run of either session phase validates the request and stops there: no ID +is minted, no VM is booted, no store slot is leased, and the daemon is never +contacted. A session is created with the CPU count, memory, and GPU flag it was asked for, and always with a **boot timeout** (180s, the same budget create-and-run @@ -320,8 +404,11 @@ that lets a caller request a second session. A returned slot keeps its images — that is the point of leasing rather than minting. A slot that has never been leased holds nothing at all, and an uncached -image under the isolated posture is refused before the VM is reached, so a cold -slot fails a provision outright rather than pulling. Warm the pool with either: +image under the isolated posture is refused before a container is created. +The error names the selected store's slot and path, the uncached image, and +the command to warm it. MXC does not relocate the session to another store or +pull outside the declared network policy. The SDK session may already be +running when its image cache is inspected. Warm the pool with either: ```powershell .\scripts\setup-wslc.ps1 -Image alpine:latest -DaemonSlots 1 @@ -611,12 +698,25 @@ it can observe idle-teardown within seconds. ## Testing `tests/scripts/run_wslc_state_aware_tests.ps1` is the multi-invocation E2E harness (requires a WSL2 -host that can reach a registry or already has the image cached in daemon store slot 0, and `wxc-wslc-daemon.exe` staged next to `wxc-exec.exe`). It exercises +host that can reach a registry or already has the images cached in daemon store slots 0–2, and `wxc-wslc-daemon.exe` staged next to `wxc-exec.exe`). It exercises core lifecycle, warm-reuse (a marker written by one `exec` is read back by a separate `exec` process — only possible if the container stayed warm), filesystem volumes, bridged networking + proxy, validation rejections, exec concurrency, and idle teardown. Fixtures live in `tests/configs/wslc_state_aware_*.json`. +The session tests place two sandboxes in one session and compare their VM +boot IDs. They also keep two identically sized sessions alive together and +check that releasing one leaves the other's sandbox usable. The setup +preflight warms three stores for the implicit session and the two explicit +sessions, because isolated provisions cannot pull into a cold store. + +The cold-store regression uses a private pool and daemon discovery root, +warms only slot 0 with `-DaemonSlots 1`, and holds that slot with a sandbox. +An isolated provision on slot 1 must name the cold store in its refusal, +leave no container behind, and leave the original warm sandbox usable. +This case needs registry access even with `-SkipSetup`; it does not reuse or +modify the normal image cache. + The concurrency section launches a phase without waiting for it (`Start-StateAware` / `Wait-StateAware`), which is what lets it observe two sandboxes running at once, a refused same-container second exec, and a lifecycle command issued while a run is in flight. Every other @@ -630,17 +730,24 @@ run individually or in an arbitrary order: - **Order is mandatory.** A sandbox must go through `provision → start → exec… → stop → deprovision`. `provision` is what boots the session and mints the id; every other phase fails without it (`start`/`exec` before provision → `not_provisioned` / `not_started`, and any phase after - `deprovision` → `not_provisioned`). + `deprovision` → `not_provisioned`). An explicit session widens that to + `provision-session → provision → start → exec… → stop → deprovision → deprovision-session`: + a `provision` cannot name a session that does not exist yet, and a + `deprovision-session` is refused while a sandbox is still in it. - **The id must be threaded through.** `provision` returns the real `wslc:<32-hex>` id on stdout - (`result.sandboxId`). The post-provision fixtures (`_start`, `_stop`, `_deprovision`, and every - `_exec_*`) ship with a literal **`{{SANDBOX_ID}}` placeholder**. These test inputs use - "MXC request JSON"; the harness replaces the placeholder before converting the request to - direct executor CLI form. + (`result.sandboxId`), and `provision-session` returns `wslcsess:<32-hex>` in `result.sessionId`. + The post-provision fixtures (`_start`, `_stop`, `_deprovision`, and every + `_exec_*`) ship with a literal **`{{SANDBOX_ID}}` placeholder**; the session fixtures + (`_provision_in_session`, `_deprovision_session`) ship with **`{{SESSION_ID}}`**. These test + inputs use "MXC request JSON"; the harness replaces the placeholders before converting the + request to direct executor CLI form. `run_wslc_state_aware_tests.ps1` handles both concerns automatically (it drives the phases in order -and does the `{{SANDBOX_ID}}` substitution from each provision's output). It then removes `phase` -and `sandboxId` from the JSON and passes them as `--operation` and `--container-id`. Exercise these -fixtures **through the harness**, not by pointing `wxc-exec --config` at them directly. +and does the `{{SANDBOX_ID}}` / `{{SESSION_ID}}` substitution from each provision's output). It then +removes `phase` and the phase's routing id from the JSON and passes them as `--operation` and +`--container-id` or `--session-id`. The session a `provision` joins stays in the body, since it is +configuration rather than routing. Exercise these fixtures **through the harness**, not by pointing +`wxc-exec --config` at them directly. ## Known limitations @@ -654,8 +761,13 @@ fixtures **through the harness**, not by pointing `wxc-exec --config` at them di one-for-one, so it spans Rust, Node, .NET, the versioned references, and schema regeneration. This is tracked as follow-up work. -- **No typed SDK can set port mappings yet.** The Rust, Node, and .NET v1 SDKs - all use published stable contract `1.0.0`, which does not declare the - field, and released schemas are immutable. "MXC request JSON" using contract `1.1.0-alpha` through the - FFI or `wxc-exec` is the only way to reach it today. Promoting `1.1.0-alpha` - to a stable release is what lets the three SDKs expose it together. +- **No typed SDK can set port mappings or sessions yet.** The Rust, Node, and + .NET v1 SDKs all use published stable contract `1.0.0`, which declares + neither `wslc.provision.portMappings` nor any session field, and released + schemas are immutable. "MXC request JSON" using contract `1.1.0-alpha` through the + raw Rust entry point `mxc_sdk::__ffi::run_lifecycle_json`, FFI, or `wxc-exec` + reaches them today. Promoting `1.1.0-alpha` to a stable release is what lets + the three typed SDKs expose them together. + +- **A sandbox's policy identity does not cover its session's sizing.** See + [the section above](#a-sandboxs-policy-identity-does-not-cover-its-sessions-sizing). diff --git a/docs/containment-policy/dev/policy.md b/docs/containment-policy/dev/policy.md index 87e2fb9c9..4924aee90 100644 --- a/docs/containment-policy/dev/policy.md +++ b/docs/containment-policy/dev/policy.md @@ -169,16 +169,91 @@ operations exist for **IsolationSession, WSLc, and Windows Sandbox** in this contract. `provision` requires a concrete `containment` and must omit `sandboxId`; subsequent phases require the ID returned by provision (`iso:...`, `wslc:...`, or `wsb:...`) and infer the backend from its prefix. -Every phase requires `version`; all admit optional `$schema`, `_comment`, -and `telemetry.enabled`. +WSLc also supports `provision-session` and `deprovision-session`, which require +`containment: "wslc"` and carry no `sandboxId`. Every phase requires `version`; +all admit optional `$schema`, `_comment`, and `telemetry.enabled`. | Phase | Allowed request fields besides the common fields | |---|---| | `provision` / `isolation_session` | Required all-allow `network`; optional `isolationSession.provision.appId` (string). No filesystem, UI, or proxy. | -| `provision` / `wslc` | Optional `filesystem`, `network`, and `wslc.provision.image`, `imageTarPath`, or `portMappings`. Port mappings require all-allow bridged networking; per-container CPU/memory/GPU/storage are not available at provision. | +| `provision-session` / `wslc` | Optional `wslc.provisionSession.cpuCount`, `memoryMb`, and `gpu`; no process, filesystem, network, or UI policy. | +| `provision` / `wslc` | Optional `filesystem`, `network`, and `wslc.provision.image`, `imageTarPath`, `portMappings`, or `sessionId`. Port mappings require all-allow bridged networking; CPU/memory/GPU sizing belongs to the session, not the sandbox. No storage-path override. | | `provision` / `windows_sandbox` | Optional `filesystem` only. No network, UI, proxy, or backend-specific settings; provision stores the immutable filesystem policy. Requires experimental authorization. | | `start`, `stop`, `deprovision` | Required `sandboxId`; no process or policy sections. | | `exec` | Required `sandboxId` and `process` (non-empty `commandLine`); optional `network` and `runtimeConfig` in the JSON shape. All three backends reject supplied network policy at exec and inherit their provision posture. IsolationSession and Windows Sandbox reject a proxy; WSLc accepts an exec proxy. | +| `deprovision-session` / `wslc` | Required `sessionId`; no process, filesystem, network, UI, or backend configuration. | + +### WSLc sessions + +A session is one WSL2 utility VM shared by the sandboxes placed in it. +`provision-session` sets VM sizing; it does not create a sandbox: + +```json +{ + "version": "1.1.0-alpha", + "phase": "provision-session", + "containment": "wslc", + "wslc": { + "provisionSession": { "cpuCount": 2, "memoryMb": 4096, "gpu": false } + } +} +``` + +| Sizing field | Meaning | +|---|---| +| `cpuCount` | Optional non-negative integer, up to `4294967295`; a positive value cannot exceed the host's logical processor count. Omitted or `0` uses the SDK default. | +| `memoryMb` | Optional non-negative integer; the backend rejects values above `4294967295`. A ceiling, not a reservation, so it may exceed installed host RAM. Omitted or `0` uses the SDK default. | +| `gpu` | Optional boolean, default `false`; enables GPU passthrough when supported by the host runtime. | + +Success returns `{"result":{"sessionId":"wslcsess:0123456789abcdef0123456789abcdef"}}`. +The ID shown here is illustrative: use the returned value, which has exactly +32 lowercase hexadecimal digits after `wslcsess:`, in later requests. +Treat it as an opaque bearer capability and keep it out of logs. + +Place a sandbox in that session with `wslc.provision.sessionId`: + +```json +{ + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "wslc": { + "provision": { + "image": "alpine:latest", + "sessionId": "wslcsess:0123456789abcdef0123456789abcdef" + } + } +} +``` + +The VM's CPU, memory, and GPU settings apply to every sandbox in the session, +not as per-sandbox quotas. Omitting `sessionId` joins the implicit shared +session, preserving existing behavior. An isolated provision needs its image +already cached in the selected session's store or supplied as `imageTarPath`. + +After deprovisioning every sandbox in the session, release it: + +```json +{ + "version": "1.1.0-alpha", + "phase": "deprovision-session", + "containment": "wslc", + "sessionId": "wslcsess:0123456789abcdef0123456789abcdef" +} +``` + +Release returns `{"result":{}}`. A session still holding sandboxes or work +is refused rather than cascading deletion; the implicit session cannot be +released explicitly. Unused sessions can expire before explicit release. +Malformed IDs return `malformed_id`; a valid ID that no longer names a +session returns `not_provisioned`. + +A dry run validates either session request without contacting the daemon, +minting an ID, creating a VM or store, or reserving capacity, and returns +`{"result":{}}`. Neither session phase is available through typed SDK V1, +which still targets contract `1.0.0`. + +### Sandbox examples Example WSLc provision with a container-specific TCP port mapping: @@ -228,7 +303,8 @@ with the returned one): } ``` -When calling the executor directly, pass routing fields via `--operation` and -`--container-id` instead of putting `phase` and `sandboxId` in the JSON file; -the executor inserts them before parsing. JSON library/FFI calls include -those fields as shown above. +When calling the executor directly, pass `phase` via `--operation`, `sandboxId` +via `--container-id`, and a release's `sessionId` via `--session-id` instead +of putting those fields in the JSON file; the executor inserts them before +parsing. Placement's nested `wslc.provision.sessionId` stays in JSON. +JSON library/FFI calls include routing fields as shown above. diff --git a/docs/development/architecture/container-lifecycle.md b/docs/development/architecture/container-lifecycle.md index a789aa95c..25dc0cee9 100644 --- a/docs/development/architecture/container-lifecycle.md +++ b/docs/development/architecture/container-lifecycle.md @@ -446,8 +446,9 @@ Backend-routing fields: | Field | Type | Required | Description | |---|---|---|---| -| `containment` | `ContainmentType` or `ContainmentBackend` member | One-shot: yes. State-aware: yes for `provision`, absent for `start` / `exec` / `stop` / `deprovision`. | Backend selection on calls that do not yet have a `sandboxId`. | -| `sandboxId` | branded string | State-aware non-provision: yes. Otherwise absent. | Opaque sandbox id returned by `provision`. Carries the backend prefix used to route non-provision calls (§5). | +| `containment` | `ContainmentType` or `ContainmentBackend` member | One-shot: yes. State-aware: yes for `provision` and both session phases, absent for `start` / `exec` / `stop` / `deprovision`. | Backend selection on calls that carry no backend-prefixed `sandboxId`. | +| `sandboxId` | branded string | State-aware sandbox-addressed phases: yes. Otherwise absent. | Opaque sandbox id returned by `provision`. Carries the backend prefix used to route those calls (§5). | +| `sessionId` | string | `deprovision-session`: yes. Otherwise absent. | Opaque session id returned by `provision-session`. Carries no routing prefix, which is why its phase declares `containment`. | State-aware-only fields: @@ -456,29 +457,66 @@ State-aware-only fields: | `phase` | `Phase` member | Yes | Discriminator. Absence means a one-shot request. | | `process` | `ProcessConfig` | Required for `exec`; absent otherwise. | Cross-backend execution fields. | +#### Raw JSON entry points + +Rust's `mxc_sdk::__ffi::run_lifecycle_json` returns the response-envelope JSON +as a string. The C function `mxc_run_state_aware_json` accepts NUL-terminated +UTF-8 request JSON and writes its response to `MxcStateAwareResult`. +Both accept the routing fields shown above and use the declared contract. + +| Phase | Successful response | +|---|---| +| `provision` | `result.sandboxId` and any backend metadata | +| `start`, `stop`, `deprovision` | `{"result":{}}` | +| `provision-session` | `result.sessionId`; WSLc contract `1.1.0-alpha` only | +| `deprovision-session` | `{"result":{}}`; WSLc contract `1.1.0-alpha` only | + +Session operations are not exposed by typed SDK V1. Their sizing, placement, +and cleanup rules are described in the +[development policy reference](../../containment-policy/dev/policy.md#wslc-sessions). +With `dry_run` enabled, either entry point validates without allocating +resources or minting IDs and returns `{"result":{}}`. A non-dry-run `exec` +uses Rust's `execute_lifecycle_json` or a C exec entry point instead. + +The separate `experimental` argument authorizes experimental backends; without +it they fail as `backend_unavailable` before execution. Production backends +ignore this argument. Rust failures return `Error` with the matching +`ErrorCode`; C failures populate the status and error fields, leaving the +response JSON absent. + +The C status is both returned and stored in `out->status`. A null `out` returns +`MXC_STATUS_NULL_ARGUMENT` before dispatch. Release every populated result with +`mxc_state_aware_result_free`, including failures. + #### `wxc-exec` lifecycle transport -"MXC request JSON" retains `phase` and `sandboxId`. Direct +"MXC request JSON" retains `phase`, `sandboxId`, and `sessionId`. Direct `wxc-exec` lifecycle calls instead remove those routing fields from the supplied JSON and pass them as command-line arguments: ```text +wxc-exec.exe policy.json --operation provision-session wxc-exec.exe policy.json --operation provision wxc-exec.exe policy.json --operation start --container-id iso:abc wxc-exec.exe policy.json --operation exec --container-id iso:abc -- command arg wxc-exec.exe policy.json --operation stop --container-id iso:abc wxc-exec.exe policy.json --operation deprovision --container-id iso:abc +wxc-exec.exe policy.json --operation deprovision-session --session-id wslcsess:0123456789abcdef0123456789abcdef ``` -Provision JSON still contains `containment`; later operations route from the -`--container-id` prefix. Supplying `phase` or `sandboxId` in CLI JSON is rejected rather +Replace the illustrative session ID with `result.sessionId` from a prior +`provision-session` response; a correctly formatted ID must also name a live session. + +Provision and session JSON still contains `containment`; sandbox-addressed operations route from the +`--container-id` prefix. Supplying `phase`, `sandboxId`, or `sessionId` in CLI JSON is rejected rather than overriding the command-line routing. Without `--operation`, `wxc-exec` accepts only one-shot JSON. `--operation` cannot be combined with delete or maintenance routing, including `--delete`, its `--containername` option, setup modes, probing, force-reclaim, or -audit modes. Lifecycle identity comes only from `--container-id`; conflicting options -are rejected rather than ignored. +audit modes. Lifecycle identity comes only from `--container-id` or `--session-id`, +which are mutually exclusive; an operation supplied the wrong one is told which it +wanted. Conflicting options are rejected rather than ignored. Cross-cutting fields available to state-aware (state-aware-only at top level — backends declare which phases honor them, see §10.3): diff --git a/docs/development/architecture/telemetry.md b/docs/development/architecture/telemetry.md index 59b941cb9..49a4a6f9d 100644 --- a/docs/development/architecture/telemetry.md +++ b/docs/development/architecture/telemetry.md @@ -175,7 +175,7 @@ the one-shot path, a clean non-zero sandbox exit is not treated as an MXC error. | `mxc.outcome` | string | `"success"` or `"failure"` | | `mxc.duration_ms` | uint64 | Total execution time | | `mxc.failure_reason` | string | Failure category (if applicable) | -| `mxc.phase` | string | State-aware lifecycle phase (`provision`\|`start`\|`exec`\|`stop`\|`deprovision`); empty for one-shot executions | +| `mxc.phase` | string | Lifecycle phase (`provision-session`\|`provision`\|`start`\|`exec`\|`stop`\|`deprovision`\|`deprovision-session`); empty for create-and-run execution | | `__TlgCV__` | string | Microsoft Correlation Vector (MS-CV) — the lifecycle correlation key (see [Correlating a lifecycle](#correlating-a-lifecycle)); empty for one-shot executions | ### MXC.Error @@ -283,19 +283,19 @@ MS-CV is a hierarchical, propagatable identifier of the form `..…` — a random base (128 bits, 22 base64 chars) plus a dotted chain of decimal elements. MXC uses it as follows: -- **`provision`** seeds a fresh random base (`correlation_vector::seed()`) and - returns it to the client in its result envelope as `result.correlationVector`. - This is the lifecycle's root vector. -- The client **relays** that vector verbatim into every later phase (the SDK - surfaces it as `ProvisionResult.correlationVector` and accepts it back as - `SandboxSpawnOptions.correlationVector`, sent on the wire as the top-level - `correlationVector` field). -- Each non-`provision` phase **spins** the relayed base - (`correlation_vector::spin()`) to derive a distinct child vector that still - shares the lifecycle's base prefix. Spin (rather than a plain extend) is used - because `exec` is multi-invocation and the client is a dumb relay — a plain - extend would collapse every phase to the same `base.0`, whereas spin folds in a - coarse timestamp + entropy so sibling phases get distinct, ordered vectors. +- **`provision`** seeds a fresh random base and persists it locally once the + sandbox ID is known. +- **`start`, `exec`, `stop`, and `deprovision`** recall that persisted root and + spin distinct child vectors sharing its base prefix. Successful deprovision + removes the record; a dry run keeps it. +- Callers neither receive nor relay a correlation vector. It is internal to + MXC, not a JSON request or response field. + +`provision-session` and `deprovision-session` each seed an independent vector +when telemetry is authorized. They do not read, create, refresh, sweep, or +delete the sandbox correlation store. Session IDs are not correlation-store +keys or vector seeds. This applies to the executor, raw JSON entry points, +and typed engine calls. Sandbox operations retain their existing store cleanup. An analyst groups all phases of one lifecycle by the shared **base prefix** of `__TlgCV__`, and orders/​distinguishes phases within it by the spun elements. @@ -307,11 +307,11 @@ element boundary, if needed to stay within the cap — never truncating a multi-digit element to a falsified value like `.42` → `.4`) and the vector is never mutated again. `increment` likewise freezes when its trailing element is already at `u32::MAX` and so cannot advance (the spec's value-overflow -behaviour). Every later phase then relays and emits that same frozen vector +behaviour). Every later sandbox phase then emits that same frozen vector verbatim, so correlation by base prefix still holds — only the per-phase ordering elements stop advancing. -**Canonical validation.** A relayed vector is only built on (spun) when it is +**Canonical validation.** A persisted vector is only built on (spun) when it is *relayable* — a well-formed mutable vector (canonical 22-char base64 base whose final char encodes a zero low nibble, followed by one or more canonical decimal `u32` elements) or a valid frozen (`!`-terminated) vector. Elements must be @@ -325,15 +325,15 @@ base rather than emitted verbatim. The correlation vector is **not** derived from the `sandbox_id`, so no caller-supplied identity (e.g. a UPN embedded in an IsolationSession `iso:` id) is ever involved: the base is pure randomness. Spin is defensive -— a missing, empty, or malformed relayed value falls back to a fresh seed rather +— a missing, empty, or malformed stored value falls back to a fresh seed rather than panicking telemetry. `__TlgCV__` is empty for one-shot executions (which have no lifecycle to correlate) and for a crash during `provision` before the vector is -stashed. It is only computed and emitted when experimental telemetry is active, so -provision output is unchanged when telemetry is off. +stashed. It is only computed and emitted when telemetry is authorized; response +envelopes never contain it. -**Why a relayed random vector rather than a hashed `sandbox_id`.** An alternative +**Why a random vector rather than a hashed `sandbox_id`.** An alternative design would derive the correlation key deterministically from the `sandbox_id` -(e.g. a hash), avoiding the wire/SDK relay entirely. We deliberately do **not** do +(e.g. a hash), avoiding persistence of a random root. We deliberately do **not** do this, for two reasons: 1. **PII-safety.** A `sandbox_id` is caller-influenced and can embed identity — an @@ -349,9 +349,8 @@ this, for two reasons: the hierarchical parent/child structure (base prefix + spun elements) that lets an analyst reconstruct phase ordering, not just group-by a flat key. -The client relay is therefore a required part of the design, not incidental -plumbing: it is how the random root vector minted at `provision` reaches the later -per-phase executor processes, which otherwise share no state. +Local persistence lets the random root minted at `provision` reach later +executor processes without exposing it to callers. ### Crash telemetry (panic hook) diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index 9948187fd..2db7a64b4 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -136,9 +136,12 @@ reasons: Raw SDK entry points enforce the same exact set. High-level v1 one-shot and state-aware APIs select stable `1.0.0` internally and expose only the backends supported by that contract. Capabilities the published contract does - not declare — Windows Sandbox state-aware, and - `wslc.provision.portMappings` — are reachable through the raw `1.1.0-alpha` - path only. The compatibility constants in + not declare — Windows Sandbox state-aware, `wslc.provision.portMappings`, + and WSLc session operations (`provision-session`, `deprovision-session`, + and `wslc.provision.sessionId`) — require MXC request JSON declaring + `1.1.0-alpha`, or the in-process engine with that contract. + `stateAwareWslc` names this development contract; it does not change the + published `1.0.0` target of the V1 SDKs. The compatibility constants in `schemas/schema-version.json` do not authorize other versions within their minimum/maximum range. - **Product version** tracks the shipped artifacts and moves independently of the diff --git a/docs/development/build-and-test/schema-codegen.md b/docs/development/build-and-test/schema-codegen.md index 20cded92f..9fa3bdb68 100644 --- a/docs/development/build-and-test/schema-codegen.md +++ b/docs/development/build-and-test/schema-codegen.md @@ -20,7 +20,7 @@ identities. ## Sources of truth `src/mxc-sdk/src/core/mxc_contract/dev/` defines the exact mutable -`1.1.0-alpha` contract. Its one-shot and seven state-aware request roots are +`1.1.0-alpha` contract. Its one-shot and nine state-aware request roots are independent closed Rust types. Constrained primitives and the `string_enum!` and `string_marker!` macros implement `JsonSchema` so deserialization and generated constants cannot drift. @@ -92,10 +92,12 @@ The exact schema uses one shared `definitions` table and nested `if`/`then` dispatch: 1. absence of `phase` selects one-shot; -2. a present `phase` selects provision, start, exec, stop, or deprovision; +2. a present `phase` selects provision-session, provision, start, exec, stop, + deprovision, or deprovision-session; 3. provision additionally selects a backend-specific root by `containment` (Windows Sandbox, IsolationSession, or WSLC in v1.1 development; - IsolationSession or WSLC in published v0.9 and v1.0). + IsolationSession or WSLC in published v0.9 and v1.0). The two session phases + select by `containment` as well, but admit only WSLC. This structure evaluates only the relevant branch and gives more focused editor diagnostics than a bare request-root union. One-shot and exec require diff --git a/docs/schema.md b/docs/schema.md index c88b7e4d0..5bca3bd11 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -39,11 +39,16 @@ Typed Rust, .NET, and Node SDK callers use `ContainerRequest`, `ProvisionRequest`, and `ExecutionRequest`, not JSON. Node and .NET produce "MXC request JSON" internally. -Creation requests omit `phase` and `sandboxId`. Lifecycle requests include -`phase`, and operations after provision include `sandboxId`. For executor -lifecycle calls, supply those values through `--operation` and `--container-id`, -not in the JSON file. The executor inserts them before using the same contract -parser. +Creation requests omit `phase`, `sandboxId`, and `sessionId`. Lifecycle +requests include `phase`. Sandbox-addressed operations (`start`, `exec`, +`stop`, and `deprovision`) take `sandboxId`; `deprovision-session` instead +takes `sessionId`. Both provision operations create their own IDs. + +For executor lifecycle calls, supply `phase` through `--operation` and the +target ID through `--container-id` or `--session-id`, not as top-level JSON +fields. The executor inserts them before using the same contract parser. +`wslc.provision.sessionId` stays in the JSON body: it selects the session a new +sandbox joins, not the target of the operation. For example, starting a provisioned WSLc container uses this "MXC request JSON" with `mxc_run_state_aware_json`: @@ -497,7 +502,8 @@ These examples use ["MXC request JSON"](#mxc-request-json). For executor calls, use the conversion described above. The development schema documents JSON objects for container lifecycle -operations (`provision` / `start` / `exec` / `stop` / `deprovision`). +operations (`provision-session` / `provision` / `start` / `exec` / `stop` / +`deprovision` / `deprovision-session`). The create-and-run JSON above describes container setup and a workload together. Lifecycle JSON instead identifies an operation: `provision` allocates a new container; later operations use the returned `sandboxId`. @@ -506,8 +512,8 @@ Choose a supported contract version for the backend and operation: - IsolationSession uses published `0.9.0-alpha`. - WSLC uses published `0.9.0-alpha`, or development `1.1.0-alpha` for - `wslc.provision.portMappings`; Windows Sandbox uses development - `1.1.0-alpha`. + `wslc.provision.portMappings` and the session operations; Windows Sandbox + uses development `1.1.0-alpha`. Contracts before `0.9.0-alpha` are retired. The supported published `0.9.0-alpha` and `1.0.0` contracts describe create-and-run JSON requests plus @@ -518,30 +524,34 @@ development schema for `1.1.0-alpha`: { "$schema": "./schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", - "phase": "exec", // One of: provision | start | exec | stop | deprovision - "sandboxId": "wsb:abcd1234", // Required for non-provision phases. - // Prefix routes to the backend (wsb: -> windows_sandbox, - // iso: -> isolation_session). - "containment": "windows_sandbox", // Required for `provision`; ignored for other phases - // (the backend is inferred from sandboxId). + "phase": "exec", + "sandboxId": "wsb:abcd1234", "process": { "commandLine": "echo hi" } - // Cross-cutting fields (process / filesystem / network / ui) sit at the TOP - // level, exactly as in a create-and-run request -- there is no wrapping `config` - // object. Backend- and phase-specific config, when a phase has any, nests - // under its permanent backend section, e.g.: - // "isolationSession": { "provision": { "appId": "PFN:Contoso.App_8wekyb3d8bbwe" } } } ``` -Phase / sandboxId / containment validation: - -| Phase | `sandboxId` | `containment` | -|---|---|---| -| `provision` | (not allowed) | **Required** — picks the backend whose `provision` mints a fresh sandboxId | -| `start` | **Required** (`:`) | Ignored if present | -| `exec` | **Required** | Ignored if present | -| `stop` | **Required** | Ignored if present | -| `deprovision` | **Required** | Ignored if present | +The `sandboxId` prefix routes sandbox-addressed operations to the backend; +those requests do not accept `containment`. Provision and both WSLc session +operations instead require `containment`. Cross-cutting policy stays at the +top level, and backend-specific settings stay under the backend section +(such as `wslc.provision`). + +Phase / sandboxId / sessionId / containment validation: + +| Phase | `sandboxId` | `sessionId` | `containment` | +|---|---|---|---| +| `provision-session` | (not allowed) | (not allowed) | **Required** — `wslc` only | +| `provision` | (not allowed) | (not allowed) | **Required** — picks the backend whose `provision` mints a fresh sandboxId | +| `start` | **Required** (`:`) | (not allowed) | (not allowed) | +| `exec` | **Required** | (not allowed) | (not allowed) | +| `stop` | **Required** | (not allowed) | (not allowed) | +| `deprovision` | **Required** | (not allowed) | (not allowed) | +| `deprovision-session` | (not allowed) | **Required** (`wslcsess:`) | **Required** — `wslc` only | + +A session ID carries no backend prefix, so the session phases declare their +containment instead of being routed by their target. Any containment other than +`wslc` is refused with `unsupported_containment`: no other backend owns a +session that sandboxes are placed into. Backends supporting container lifecycle operations today are `isolation_session`, `windows_sandbox`, and `wslc` (all Windows-only). IsolationSession does not require runtime @@ -575,12 +585,60 @@ WSLC `provision` accepts `wslc.provision.portMappings`, the same Mappings require the bridged (all-`allow`) posture shown above; the isolated posture is rejected at provision. -Two entries claiming the same `windowsPort` are rejected on both surfaces. The -session sizing knobs (`cpuCount` / `memoryMb` / `gpu` / `storagePath`) stay -available only for create-and-run execution, because every lifecycle `provision` -currently joins one shared session and so cannot size sandboxes individually. -The daemon itself holds several independently sized sessions; what no caller can -yet do is ask for one. +Two entries claiming the same `windowsPort` are rejected on both surfaces. + +#### WSLC session operations + +Sizing is a property of the session rather than of a sandbox, so a WSLC caller +asks for a sized session first and then places sandboxes in it: + +```json +{ + "$schema": "./schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "phase": "provision-session", + "containment": "wslc", + "wslc": { + "provisionSession": { + "cpuCount": 4, + "memoryMb": 8192, + "gpu": false + } + } +} +``` + +The response carries `result.sessionId` (`wslcsess:<32 lowercase hex>`). A +`provision` joins that session by naming it: + +```json +{ + "$schema": "./schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "wslc": { + "provision": { + "image": "alpine:latest", + "sessionId": "wslcsess:0123456789abcdef0123456789abcdef" + } + } +} +``` + +`cpuCount: 4` means the VM has four virtual CPUs shared by every sandbox in the +session, not four per sandbox, which is why a `provision` declares no sizing of +its own. Omitting `sessionId` joins an implicit session shared by every request +that names none — the behaviour every WSLC `provision` had before these +operations existed. + +A `deprovision-session` names the session in a top-level `sessionId` and is +refused while any sandbox is still in it; deprovision those first. Session IDs +are opaque bearer values: any same-user process holding one can release that +session, so keep them out of logs. + +The create-and-run `wslc` section keeps its own `cpuCount` / `memoryMb` / `gpu` +/ `storagePath` knobs, which size the session that one run creates for itself. Full lifecycle API: [container lifecycle](container-lifecycle.md). diff --git a/docs/telemetry.md b/docs/telemetry.md index 438528de1..f6eb2f868 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -166,4 +166,5 @@ application-specific policy under `SOFTWARE\Policies\Microsoft\`. ## See also - [Telemetry architecture](development/architecture/telemetry.md) +- [Lifecycle correlation](development/architecture/telemetry.md#correlating-a-lifecycle) - [Telemetry consent design](development/architecture/telemetry-consent-design.md) diff --git a/schemas/dev/mxc-config.schema.1.1.0-alpha.json b/schemas/dev/mxc-config.schema.1.1.0-alpha.json index 76880db38..97c7b68ed 100644 --- a/schemas/dev/mxc-config.schema.1.1.0-alpha.json +++ b/schemas/dev/mxc-config.schema.1.1.0-alpha.json @@ -88,11 +88,54 @@ "else": { "else": { "else": { - "else": false, + "else": { + "else": { + "else": false, + "if": { + "properties": { + "phase": { + "const": "deprovision-session" + } + }, + "required": [ + "phase" + ] + }, + "then": { + "else": false, + "if": { + "properties": { + "containment": { + "const": "wslc" + } + }, + "required": [ + "containment" + ] + }, + "then": { + "$ref": "#/definitions/WslcDeprovisionSessionRequest" + } + } + }, + "if": { + "properties": { + "phase": { + "const": "deprovision" + } + }, + "required": [ + "phase" + ] + }, + "then": { + "$ref": "#/definitions/DeprovisionRequest" + } + }, "if": { "properties": { "phase": { - "const": "deprovision" + "const": "stop" } }, "required": [ @@ -100,13 +143,13 @@ ] }, "then": { - "$ref": "#/definitions/DeprovisionRequest" + "$ref": "#/definitions/StopRequest" } }, "if": { "properties": { "phase": { - "const": "stop" + "const": "exec" } }, "required": [ @@ -114,13 +157,13 @@ ] }, "then": { - "$ref": "#/definitions/StopRequest" + "$ref": "#/definitions/ExecRequest" } }, "if": { "properties": { "phase": { - "const": "exec" + "const": "start" } }, "required": [ @@ -128,13 +171,13 @@ ] }, "then": { - "$ref": "#/definitions/ExecRequest" + "$ref": "#/definitions/StartRequest" } }, "if": { "properties": { "phase": { - "const": "start" + "const": "provision" } }, "required": [ @@ -142,27 +185,27 @@ ] }, "then": { - "$ref": "#/definitions/StartRequest" - } - }, - "if": { - "properties": { - "phase": { - "const": "provision" - } - }, - "required": [ - "phase" - ] - }, - "then": { - "else": { "else": { - "else": false, + "else": { + "else": false, + "if": { + "properties": { + "containment": { + "const": "wslc" + } + }, + "required": [ + "containment" + ] + }, + "then": { + "$ref": "#/definitions/WslcProvisionRequest" + } + }, "if": { "properties": { "containment": { - "const": "wslc" + "const": "isolation_session" } }, "required": [ @@ -170,13 +213,13 @@ ] }, "then": { - "$ref": "#/definitions/WslcProvisionRequest" + "$ref": "#/definitions/IsolationSessionProvisionRequest" } }, "if": { "properties": { "containment": { - "const": "isolation_session" + "const": "windows_sandbox" } }, "required": [ @@ -184,13 +227,26 @@ ] }, "then": { - "$ref": "#/definitions/IsolationSessionProvisionRequest" + "$ref": "#/definitions/WindowsSandboxProvisionRequest" + } + } + }, + "if": { + "properties": { + "phase": { + "const": "provision-session" } }, + "required": [ + "phase" + ] + }, + "then": { + "else": false, "if": { "properties": { "containment": { - "const": "windows_sandbox" + "const": "wslc" } }, "required": [ @@ -198,7 +254,7 @@ ] }, "then": { - "$ref": "#/definitions/WindowsSandboxProvisionRequest" + "$ref": "#/definitions/WslcProvisionSessionRequest" } } } @@ -283,6 +339,12 @@ ], "type": "object" }, + "DeprovisionSessionPhase": { + "enum": [ + "deprovision-session" + ], + "type": "string" + }, "ExecPhase": { "enum": [ "exec" @@ -1224,6 +1286,12 @@ ], "type": "string" }, + "ProvisionSessionPhase": { + "enum": [ + "provision-session" + ], + "type": "string" + }, "RuntimeConfig": { "additionalProperties": false, "description": "Runtime configuration supplied alongside the sandbox policy.", @@ -1328,6 +1396,16 @@ }, "type": "object" }, + "StateAwareWslcSession": { + "additionalProperties": false, + "description": "WSLc settings for provisioning a shared utility VM.", + "properties": { + "provisionSession": { + "$ref": "#/definitions/WslcProvisionSession" + } + }, + "type": "object" + }, "StopPhase": { "enum": [ "stop" @@ -1515,6 +1593,42 @@ ], "type": "string" }, + "WslcDeprovisionSessionRequest": { + "additionalProperties": false, + "description": "Request to release an empty WSLc session.", + "properties": { + "$schema": { + "description": "JSON Schema URL for editor validation.", + "type": "string" + }, + "_comment": { + "description": "Annotation ignored by the runtime." + }, + "containment": { + "$ref": "#/definitions/WslcContainment" + }, + "phase": { + "$ref": "#/definitions/DeprovisionSessionPhase" + }, + "sessionId": { + "description": "Session identifier returned by provision-session.", + "type": "string" + }, + "telemetry": { + "$ref": "#/definitions/Telemetry" + }, + "version": { + "$ref": "#/definitions/Version" + } + }, + "required": [ + "containment", + "phase", + "sessionId", + "version" + ], + "type": "object" + }, "WslcProvision": { "additionalProperties": false, "description": "WSLC settings accepted during provisioning.", @@ -1533,6 +1647,10 @@ "$ref": "#/definitions/PortMapping" }, "type": "array" + }, + "sessionId": { + "description": "Session to join; omitted to use the shared default VM.", + "type": "string" } }, "type": "object" @@ -1583,6 +1701,63 @@ "version" ], "type": "object" + }, + "WslcProvisionSession": { + "additionalProperties": false, + "description": "Utility VM sizing shared by every sandbox in the session.", + "properties": { + "cpuCount": { + "description": "Virtual CPUs available to the session's utility VM.", + "maximum": 4294967295, + "minimum": 0.0, + "type": "integer" + }, + "gpu": { + "description": "Enable GPU passthrough for the session's utility VM.", + "type": "boolean" + }, + "memoryMb": { + "description": "Utility VM memory ceiling, in megabytes.", + "maximum": 18446744073709551615, + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + }, + "WslcProvisionSessionRequest": { + "additionalProperties": false, + "description": "Request to create a WSLc session.", + "properties": { + "$schema": { + "description": "JSON Schema URL for editor validation.", + "type": "string" + }, + "_comment": { + "description": "Annotation ignored by the runtime." + }, + "containment": { + "$ref": "#/definitions/WslcContainment" + }, + "phase": { + "$ref": "#/definitions/ProvisionSessionPhase" + }, + "telemetry": { + "$ref": "#/definitions/Telemetry" + }, + "version": { + "$ref": "#/definitions/Version" + }, + "wslc": { + "$ref": "#/definitions/StateAwareWslcSession" + } + }, + "required": [ + "containment", + "phase", + "version" + ], + "type": "object" } } } diff --git a/schemas/schema-version.json b/schemas/schema-version.json index e0913809a..5f6dc6b31 100644 --- a/schemas/schema-version.json +++ b/schemas/schema-version.json @@ -4,7 +4,7 @@ "maxSupported": "1.1.0-alpha", "stateAware": "0.9.0-alpha", "stateAwareWindowsSandbox": "1.1.0-alpha", - "stateAwareWslc": "0.9.0-alpha", + "stateAwareWslc": "1.1.0-alpha", "stableLatest": "1.0.0", "sdkMajorTargets": { "1": "1.0.0" diff --git a/scripts/versioning/check-schema-versions.js b/scripts/versioning/check-schema-versions.js index 053068b8b..db31ab7be 100644 --- a/scripts/versioning/check-schema-versions.js +++ b/scripts/versioning/check-schema-versions.js @@ -83,14 +83,18 @@ if (publishedVersions.at(-1) !== stableLatest) { `Exact Rust contract registry latest published version is "${publishedVersions.at(-1)}" but canonical stableLatest is "${stableLatest}"` ); } -for (const [label, version, requiredRoot] of [ - ["stateAware", stateAware, "isolation_session_provision"], +for (const [label, version, requiredRoots] of [ + ["stateAware", stateAware, ["isolation_session_provision"]], [ "stateAwareWindowsSandbox", stateAwareWindowsSandbox, - "windows_sandbox_provision", + ["windows_sandbox_provision"], + ], + [ + "stateAwareWslc", + stateAwareWslc, + ["wslc_provision", "wslc_provision_session", "wslc_deprovision_session"], ], - ["stateAwareWslc", stateAwareWslc, "wslc_provision"], ]) { if (!registryByVersion.has(version)) { errors.push(`Canonical ${label} version "${version}" is absent from the exact Rust registry`); @@ -98,10 +102,12 @@ for (const [label, version, requiredRoot] of [ } try { const roots = requestRootsForContract(registryByVersion.get(version)); - if (!roots.has(requiredRoot)) { - errors.push( - `Canonical ${label} version "${version}" does not support ${requiredRoot}` - ); + for (const requiredRoot of requiredRoots) { + if (!roots.has(requiredRoot)) { + errors.push( + `Canonical ${label} version "${version}" does not support ${requiredRoot}` + ); + } } } catch (error) { errors.push( diff --git a/sdk/node/src/generated/v1_1_0_alpha/wire.ts b/sdk/node/src/generated/v1_1_0_alpha/wire.ts index 5d85caab1..4a16ac6ed 100644 --- a/sdk/node/src/generated/v1_1_0_alpha/wire.ts +++ b/sdk/node/src/generated/v1_1_0_alpha/wire.ts @@ -64,6 +64,8 @@ export interface DeprovisionRequest { version: Version; } +export type DeprovisionSessionPhase = "deprovision-session"; + export type ExecPhase = "exec"; /** @@ -640,6 +642,8 @@ export type ProcessContainerUiSystemSettings = "none" | "parameters" | "display" export type ProvisionPhase = "provision"; +export type ProvisionSessionPhase = "provision-session"; + /** * Runtime configuration supplied alongside the sandbox policy. */ @@ -728,6 +732,13 @@ export interface StateAwareWslc { provision?: WslcProvision; } +/** + * WSLc settings for provisioning a shared utility VM. + */ +export interface StateAwareWslcSession { + provisionSession?: WslcProvisionSession; +} + export type StopPhase = "stop"; /** @@ -842,6 +853,28 @@ export interface WindowsSandboxProvisionRequest { export type WslcContainment = "wslc"; +/** + * Request to release an empty WSLc session. + */ +export interface WslcDeprovisionSessionRequest { + /** + * JSON Schema URL for editor validation. + */ + $schema?: string; + /** + * Annotation ignored by the runtime. + */ + _comment?: unknown; + containment: WslcContainment; + phase: DeprovisionSessionPhase; + /** + * Session identifier returned by provision-session. + */ + sessionId: string; + telemetry?: Telemetry; + version: Version; +} + /** * WSLC settings accepted during provisioning. */ @@ -858,6 +891,10 @@ export interface WslcProvision { * Optional host-to-container TCP port mappings. */ portMappings?: PortMapping[]; + /** + * Session to join; omitted to use the shared default VM. + */ + sessionId?: string; } /** @@ -902,7 +939,44 @@ export interface WslcProvisionRequest { wslc?: StateAwareWslc; } +/** + * Utility VM sizing shared by every sandbox in the session. + */ +export interface WslcProvisionSession { + /** + * Virtual CPUs available to the session's utility VM. + */ + cpuCount?: number; + /** + * Enable GPU passthrough for the session's utility VM. + */ + gpu?: boolean; + /** + * Utility VM memory ceiling, in megabytes. + */ + memoryMb?: number; +} + +/** + * Request to create a WSLc session. + */ +export interface WslcProvisionSessionRequest { + /** + * JSON Schema URL for editor validation. + */ + $schema?: string; + /** + * Annotation ignored by the runtime. + */ + _comment?: unknown; + containment: WslcContainment; + phase: ProvisionSessionPhase; + telemetry?: Telemetry; + version: Version; + wslc?: StateAwareWslcSession; +} + /** * Exact mutable MXC development configuration contract. */ -export type MXCConfiguration = OneShotRequest | DeprovisionRequest | StopRequest | ExecRequest | StartRequest | WslcProvisionRequest | IsolationSessionProvisionRequest | WindowsSandboxProvisionRequest; +export type MXCConfiguration = OneShotRequest | WslcDeprovisionSessionRequest | DeprovisionRequest | StopRequest | ExecRequest | StartRequest | WslcProvisionRequest | IsolationSessionProvisionRequest | WindowsSandboxProvisionRequest | WslcProvisionSessionRequest; diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 6e8f05a46..46c7a1258 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -15,9 +15,10 @@ //! - **Streaming** (`streaming` module) — [`mxc_spawn_json`] accepts the same //! document and returns an opaque live handle. //! - **State-aware lifecycle** (`state_aware` module) — -//! [`mxc_run_state_aware_json`] drives the envelope phases (provision / -//! start / stop / deprovision), and [`mxc_exec_state_aware_json`] runs the -//! exec phase as a live streaming handle (reusing the streaming externs). +//! [`mxc_run_state_aware_json`] drives the envelope phases (provision-session / +//! provision / start / stop / deprovision / deprovision-session), and +//! [`mxc_exec_state_aware_json`] runs the exec phase as a live streaming handle +//! (reusing the streaming externs). //! //! ## Ingress rule //! diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index 4dd14ba77..e8afe01db 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -6,8 +6,9 @@ //! Entry points mirror the SDK's [`mxc_sdk::__ffi::run_lifecycle_json`], //! [`mxc_engine::exec_state_aware_attached`] and [`mxc_sdk::__ffi::execute_lifecycle_json`]: //! -//! - [`mxc_run_state_aware_json`] drives the **envelope phases** (`provision` / -//! `start` / `stop` / `deprovision`, and a dry run of any phase): JSON +//! - [`mxc_run_state_aware_json`] drives the **envelope phases** (`provision-session` / +//! `provision` / `start` / `stop` / `deprovision` / `deprovision-session`, +//! and a dry run of any phase): JSON //! request in, JSON response envelope out, filled into an //! [`MxcStateAwareResult`]. //! - [`mxc_exec_state_aware_attached_json`] drives the **exec phase attached @@ -100,26 +101,7 @@ impl MxcStateAwareResult { } } -/// Run a state-aware lifecycle request (envelope phases) and capture the -/// response-envelope JSON. -/// -/// Parses `request_json_utf8` (the wire-format request, with a `phase` field), -/// runs the requested phase, and writes the outcome into `*out`. A non-dry-run -/// `exec` produces no envelope and is rejected here — run it through -/// [`mxc_exec_state_aware_attached_json`] to attach the workload to this -/// process's stdio, or [`mxc_exec_state_aware_json`] to drive the pipes -/// directly. -/// -/// Returns the resulting status code (also stored in `out->status`). Returns -/// [`MXC_STATUS_NULL_ARGUMENT`] **without running the phase** if `out` is null: -/// the caller has nowhere to receive a sandbox id, so provisioning one would -/// strand it — nothing else can reclaim a sandbox whose only handle was -/// discarded. [`mxc_exec_state_aware_json`] checks its out-parameter first for -/// the same reason. -/// -/// `experimental` is nonzero to permit an experimental backend such as Windows -/// Sandbox; with zero such a backend is refused with `backend_unavailable` -/// before any work is done. Production backends ignore it. +/// Run an [MXC lifecycle JSON request](https://github.com/microsoft/mxc/blob/main/docs/development/architecture/container-lifecycle.md#raw-json-entry-points) and capture its response envelope. /// /// # Safety /// - `request_json_utf8` must be null or a valid NUL-terminated UTF-8 C string. @@ -668,6 +650,23 @@ mod tests { assert!(out.error.message_utf8.is_null()); } + #[test] + fn a_malformed_session_release_keeps_its_id_error_through_ffi() { + for dry_run in [false, true] { + let mut out = call( + r#"{"version":"1.1.0-alpha","phase":"deprovision-session", + "containment":"wslc","sessionId":"wslcsess:short"}"#, + dry_run, + ); + assert_eq!(out.status, crate::MXC_STATUS_MALFORMED_ID); + assert!(out.response_json_utf8.is_null()); + assert!(!out.error.message_utf8.is_null()); + + // SAFETY: this call returned the result's owned allocations. + unsafe { mxc_state_aware_result_free(&mut out) }; + } + } + #[test] fn exact_payload_errors_keep_their_paths_and_source_coordinates() { for fields in [ diff --git a/src/mxc-sdk/src/backends/wslc/common/daemon_client.rs b/src/mxc-sdk/src/backends/wslc/common/daemon_client.rs index ddbe993ff..616f58816 100644 --- a/src/mxc-sdk/src/backends/wslc/common/daemon_client.rs +++ b/src/mxc-sdk/src/backends/wslc/common/daemon_client.rs @@ -40,9 +40,9 @@ use serde::Serialize; use crate::wslc_common::container_steps::OutStream; use crate::wslc_common::daemon_protocol::{ - encode_frame, CancelExecConfig, DaemonRequest, DaemonResponse, DeprovisionConfig, ErrKind, - ExecConfig, ExecTerminal, ProvisionConfig, StartConfig, StopConfig, StreamFrame, - MAX_FRAME_SIZE, PROTOCOL_VERSION, + encode_frame, CancelExecConfig, DaemonRequest, DaemonResponse, DeprovisionConfig, + DeprovisionSessionConfig, ErrKind, ExecConfig, ExecTerminal, ProvisionConfig, + ProvisionSessionConfig, StartConfig, StopConfig, StreamFrame, MAX_FRAME_SIZE, PROTOCOL_VERSION, }; use crate::wslc_common::daemon_record::{live_daemon, DaemonRecord, TransitionLock}; @@ -343,6 +343,20 @@ impl DaemonClient { } } + pub fn provision_session(&self, config: ProvisionSessionConfig) -> DaemonResult { + match self.call(&DaemonRequest::ProvisionSession(config))? { + DaemonResponse::SessionProvisioned { session_ref } => Ok(session_ref), + DaemonResponse::Err { kind, message } => Err(DaemonError::Daemon { kind, message }), + other => Err(DaemonError::transport(format!( + "unexpected reply to provision_session: {other:?}" + ))), + } + } + + pub fn deprovision_session(&self, config: DeprovisionSessionConfig) -> DaemonResult<()> { + expect_ok(self.call(&DaemonRequest::DeprovisionSession(config))?) + } + /// Provision a container; returns the daemon-minted sandbox id. pub fn provision(&self, config: ProvisionConfig) -> DaemonResult { match self.call(&DaemonRequest::Provision(config))? { diff --git a/src/mxc-sdk/src/backends/wslc/common/image.rs b/src/mxc-sdk/src/backends/wslc/common/image.rs index 131cf4b79..d56595264 100644 --- a/src/mxc-sdk/src/backends/wslc/common/image.rs +++ b/src/mxc-sdk/src/backends/wslc/common/image.rs @@ -809,8 +809,18 @@ pub unsafe fn begin_resolve( } } -/// The refusal for an uncached image a sandbox declaring no egress cannot pull. fn no_egress_refusal(image: &str, store: &StoreHint) -> ScriptResponse { + if let StoreHint::DaemonSlot(index) = store { + return WslcError::Rejected(format!( + "WSLC isolated provision refused: daemon image store slot {index} at '{}' is cold \ + for image '{image}'. No container was created. Release the session or let it \ + expire before warming this store with wxc-exec.exe --setup-wslc --image {image}{}. \ + Alternatively, import a local tar with wslc.provision.imageTarPath or allow egress.", + store.store_path(), + store.warm_instruction(), + )) + .into_response(); + } WslcError::Rejected(format!( "WSLC image '{}' is not cached, and this sandbox declares no egress. \ Pulling it would reach the registry on the host's network, before the \ @@ -1707,6 +1717,22 @@ mod tests { ); } + #[test] + fn an_isolated_provision_names_the_selected_cold_store_and_how_to_warm_it() { + let refusal = no_egress_refusal("alpine:latest", &StoreHint::DaemonSlot(1)); + let message = refusal.error_message; + assert_eq!( + refusal.failure_phase, + crate::mxc_common::models::FailurePhase::Rejected + ); + assert!(message.contains("daemon image store slot 1")); + assert!(message.contains(store_slots::slot_path(1).to_string_lossy().as_ref())); + assert!(message.contains("cold for image 'alpine:latest'")); + assert!(message.contains("No container was created")); + assert!(message.contains("--wslc-daemon-slots 2")); + assert!(message.contains("wslc.provision.imageTarPath")); + } + #[test] fn a_higher_slot_asks_for_enough_slots_to_reach_it() { let instruction = StoreHint::DaemonSlot(2).warm_instruction(); diff --git a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs index fcc9a07c3..6b8a994be 100644 --- a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs +++ b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs @@ -22,12 +22,15 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use crate::mxc_common::logger::{Logger, Mode}; -use crate::mxc_common::models::{ContainerPolicy, ExecutionRequest, WslcProvisionConfig}; +use crate::mxc_common::models::{ + ContainerPolicy, ExecutionRequest, WslcProvisionConfig, WslcProvisionSessionConfig, +}; use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::state_aware_backend::{ null_pipe_handle, DeprovisionResult, ExecHandle, ExecOutcome, ExecStdio, ProvisionResult, - StartResult, StatefulSandboxBackend, StopResult, + ProvisionSessionResult, StartResult, StatefulSandboxBackend, StopResult, }; +use crate::mxc_common::state_aware_operation::validate_wslc_session_id; use crate::mxc_common::validator::validate_state_aware_network_policy_support; use crate::wslc_common::container_steps::OutStream; @@ -35,8 +38,9 @@ use crate::wslc_common::daemon_client::{ truncation_suffix, DaemonClient, DaemonError, DaemonExecOutcome, }; use crate::wslc_common::daemon_protocol::{ - DeprovisionConfig, ErrKind, ExecConfig, NetworkMode, PortMapping as DaemonPortMapping, - ProvisionConfig, StartConfig, StopConfig, VolumeMount, + DeprovisionConfig, DeprovisionSessionConfig, ErrKind, ExecConfig, NetworkMode, + PortMapping as DaemonPortMapping, ProvisionConfig, + ProvisionSessionConfig as DaemonProvisionSessionConfig, StartConfig, StopConfig, VolumeMount, }; use crate::wslc_common::policy::{ exec_proxy_url, validate_exec_policy, validate_post_provision_policy, validate_provision_policy, @@ -83,6 +87,21 @@ impl StatefulSandboxBackend for WslcStateAwareRunner { type StopMetadata = (); type DeprovisionMetadata = (); + fn provision_session( + &mut self, + _request: &ExecutionRequest, + config: Option, + ) -> Result { + let session_config = build_provision_session_config(config); + + let client = connect_daemon()?; + let session_id = client + .provision_session(session_config) + .map_err(map_daemon_error)?; + + Ok(ProvisionSessionResult { session_id }) + } + fn provision( &mut self, request: &ExecutionRequest, @@ -95,8 +114,6 @@ impl StatefulSandboxBackend for WslcStateAwareRunner { .provision(provision_config) .map_err(map_daemon_error)?; - // The daemon mints a fully `wslc:`-prefixed id; return it verbatim so - // later phases present the daemon's own map key. Ok(ProvisionResult { sandbox_id, metadata: None, @@ -148,6 +165,20 @@ impl StatefulSandboxBackend for WslcStateAwareRunner { Ok(DeprovisionResult { metadata: None }) } + fn deprovision_session( + &mut self, + session_id: &str, + _request: &ExecutionRequest, + ) -> Result<(), MxcError> { + validate_wslc_session_id(session_id)?; + let client = connect_daemon()?; + client + .deprovision_session(DeprovisionSessionConfig { + session_ref: session_id.to_string(), + }) + .map_err(map_daemon_error) + } + /// Runs one command in the warm container. Relayed callers receive live /// output on this process's stdout/stderr; piped callers receive synthesized /// native stdout/stderr pipes fed from the same daemon stream. @@ -172,8 +203,11 @@ impl StatefulSandboxBackend for WslcStateAwareRunner { fn validate_provision( &self, request: &ExecutionRequest, - _config: Option<&WslcProvisionConfig>, + config: Option<&WslcProvisionConfig>, ) -> Result<(), MxcError> { + if let Some(session_id) = config.and_then(|config| config.session_id.as_deref()) { + validate_wslc_session_id(session_id)?; + } validate_state_aware_network_policy_support( request, crate::wslc_common::policy::network_policy_support(), @@ -181,6 +215,34 @@ impl StatefulSandboxBackend for WslcStateAwareRunner { validate_provision_policy(request) } + fn validate_provision_session( + &self, + request: &ExecutionRequest, + config: Option<&WslcProvisionSessionConfig>, + ) -> Result<(), MxcError> { + validate_session_sizing(config)?; + validate_state_aware_network_policy_support( + request, + crate::wslc_common::policy::network_policy_support(), + )?; + + // Filesystem and network policy belong to containers, not the session. + validate_post_provision_policy(request) + } + + fn validate_deprovision_session( + &self, + session_id: &str, + request: &ExecutionRequest, + ) -> Result<(), MxcError> { + validate_wslc_session_id(session_id)?; + validate_state_aware_network_policy_support( + request, + crate::wslc_common::policy::network_policy_support(), + )?; + validate_post_provision_policy(request) + } + fn validate_start( &self, sandbox_id: &str, @@ -474,6 +536,39 @@ fn truncated_run_error(exit_code: i32) -> MxcError { .with_details(serde_json::json!({ "exitCode": exit_code })) } +fn build_provision_session_config( + config: Option, +) -> DaemonProvisionSessionConfig { + let config = config.unwrap_or_default(); + DaemonProvisionSessionConfig { + cpu_count: config.cpu_count, + memory_mb: config.memory_mb, + gpu: config.gpu.unwrap_or(false), + } +} + +fn validate_session_sizing(config: Option<&WslcProvisionSessionConfig>) -> Result<(), MxcError> { + let Some(config) = config else { + return Ok(()); + }; + if let Some(memory_mb) = config.memory_mb { + u32::try_from(memory_mb).map_err(|_| { + MxcError::policy_validation(format!( + "wslc.provisionSession.memoryMb {memory_mb} exceeds the {} MB the WSLc SDK accepts", + u32::MAX + )) + })?; + } + crate::wslc_common::container_steps::check_sizing_against_host( + crate::wslc_common::container_steps::SessionSizing { + cpu_count: config.cpu_count, + memory_mb: config.memory_mb, + gpu: config.gpu.unwrap_or(false), + }, + ) + .map_err(|response| MxcError::policy_validation(response.error_message)) +} + /// Map a typed [`DaemonError`] onto the matching wire-format [`MxcError`] code. fn map_daemon_error(err: DaemonError) -> MxcError { match err { @@ -541,7 +636,11 @@ fn build_provision_config( .collect() }) .unwrap_or_default(); - let image_tar_path = config.and_then(|c| c.image_tar_path); + let image_tar_path = config.as_ref().and_then(|c| c.image_tar_path.clone()); + let session_id = config.and_then(|c| c.session_id); + if let Some(session_id) = session_id.as_deref() { + validate_wslc_session_id(session_id)?; + } // WSLc provision-time filesystem-policy gate (D6 normalization → D3 // delegation → denied-path overlap), shared verbatim with the one-shot @@ -576,11 +675,7 @@ fn build_provision_config( volumes, network, port_mappings, - - // No request can name a session yet: nothing on the public surface - // carries one, so every provision joins the daemon's implicit session, - // which is exactly what it did before sessions became addressable. - session_ref: None, + session_ref: session_id, }) } @@ -644,6 +739,177 @@ fn split_env(env: &[String]) -> Vec<(String, String)> { .collect() } +#[cfg(test)] +mod session_tests { + use super::*; + use crate::mxc_common::mxc_error::MxcErrorCode; + + const VALID_SESSION_ID: &str = "wslcsess:0123456789abcdef0123456789abcdef"; + + #[test] + fn validate_session_id_accepts_a_daemon_minted_id() { + assert!(validate_wslc_session_id(VALID_SESSION_ID).is_ok()); + } + + #[test] + fn validate_session_id_rejects_everything_else() { + let mut runner = WslcStateAwareRunner::new(); + for session_id in [ + "", + "wslcsess", + "wslcsess:", + "0123456789abcdef0123456789abcdef", + "wslc:0123456789abcdef0123456789abcdef", + "wslcsess:default", + "wslcsess:0123456789abcdef0123456789abcde", + "wslcsess:0123456789abcdef0123456789abcdef0", + "wslcsess:0123456789ABCDEF0123456789ABCDEF", + "wslcsess:0123456789abcdef0123456789abcdeg", + ] { + let error = validate_wslc_session_id(session_id) + .expect_err("malformed session IDs must not be accepted"); + assert_eq!(error.code, MxcErrorCode::MalformedId); + let error = runner + .deprovision_session(session_id, &ExecutionRequest::default()) + .expect_err("direct session release must reject a malformed ID before connecting"); + assert_eq!(error.code, MxcErrorCode::MalformedId); + } + } + + #[test] + fn absent_sizing_reaches_the_daemon_as_host_defaults() { + for config in [None, Some(WslcProvisionSessionConfig::default())] { + let forwarded = build_provision_session_config(config); + assert_eq!(forwarded.cpu_count, None); + assert_eq!(forwarded.memory_mb, None); + assert!( + !forwarded.gpu, + "an unrequested GPU must not become a passthrough grant" + ); + } + } + + #[test] + fn each_sizing_field_is_forwarded_to_the_daemon() { + let forwarded = build_provision_session_config(Some(WslcProvisionSessionConfig { + cpu_count: Some(4), + memory_mb: Some(8192), + gpu: Some(true), + })); + assert_eq!(forwarded.cpu_count, Some(4)); + assert_eq!(forwarded.memory_mb, Some(8192)); + assert!(forwarded.gpu); + + let gpu_off = build_provision_session_config(Some(WslcProvisionSessionConfig { + gpu: Some(false), + ..Default::default() + })); + assert!(!gpu_off.gpu); + } + + #[test] + fn zero_sizing_is_accepted_and_forwarded_as_the_sdk_default() { + for config in [ + WslcProvisionSessionConfig { + cpu_count: Some(0), + ..Default::default() + }, + WslcProvisionSessionConfig { + memory_mb: Some(0), + ..Default::default() + }, + WslcProvisionSessionConfig { + cpu_count: Some(0), + memory_mb: Some(0), + gpu: Some(false), + }, + ] { + validate_session_sizing(Some(&config)) + .unwrap_or_else(|error| panic!("zero selects the SDK default: {error}")); + let forwarded = build_provision_session_config(Some(config)); + assert_eq!(forwarded.cpu_count, config.cpu_count); + assert_eq!(forwarded.memory_mb, config.memory_mb); + assert!(!forwarded.gpu); + } + assert!(validate_session_sizing(None).is_ok()); + } + + #[test] + fn session_memory_is_bounded_by_the_sdk_integer_width() { + for memory_mb in [u64::from(u32::MAX) + 1, u64::MAX] { + let error = validate_session_sizing(Some(&WslcProvisionSessionConfig { + memory_mb: Some(memory_mb), + ..Default::default() + })) + .expect_err("the SDK cannot represent this memory size"); + assert_eq!(error.code, MxcErrorCode::PolicyValidation); + assert!(error.message.contains("wslc.provisionSession.memoryMb")); + assert!(error.message.contains(&u32::MAX.to_string())); + } + assert!(validate_session_sizing(Some(&WslcProvisionSessionConfig { + memory_mb: Some(u64::from(u32::MAX)), + ..Default::default() + })) + .is_ok()); + } + + #[test] + fn session_cpu_validation_reuses_the_create_and_run_host_check() { + let expected = crate::wslc_common::container_steps::check_sizing_against_host( + crate::wslc_common::container_steps::SessionSizing { + cpu_count: Some(u32::MAX), + ..Default::default() + }, + ) + .expect_err("this host cannot supply u32::MAX virtual CPUs"); + let error = validate_session_sizing(Some(&WslcProvisionSessionConfig { + cpu_count: Some(u32::MAX), + ..Default::default() + })) + .expect_err("session provisioning must reject the same host CPU limit"); + assert_eq!(error.code, MxcErrorCode::PolicyValidation); + assert_eq!(error.message, expected.error_message); + } + + #[test] + fn a_provision_forwards_the_session_it_names_and_omits_one_it_does_not() { + let forward = |session_id: Option<&str>| { + build_provision_config( + &ExecutionRequest::default(), + Some(WslcProvisionConfig { + image: Some("alpine:latest".to_string()), + session_id: session_id.map(str::to_string), + ..Default::default() + }), + ) + }; + + assert_eq!(forward(None).unwrap().session_ref, None); + assert_eq!( + forward(Some(VALID_SESSION_ID)).unwrap().session_ref, + Some(VALID_SESSION_ID.to_string()) + ); + } + + #[test] + fn a_provision_naming_a_malformed_session_is_refused_before_the_daemon() { + let runner = WslcStateAwareRunner::new(); + let config = WslcProvisionConfig { + session_id: Some("wslcsess:not-hex".to_string()), + ..Default::default() + }; + + let validated = runner + .validate_provision(&ExecutionRequest::default(), Some(&config)) + .expect_err("a malformed session must not validate"); + assert_eq!(validated.code, MxcErrorCode::MalformedId); + + let built = build_provision_config(&ExecutionRequest::default(), Some(config)) + .expect_err("a malformed session must not be forwarded"); + assert_eq!(built.code, MxcErrorCode::MalformedId); + } +} + #[cfg(test)] mod tests { use super::*; @@ -905,6 +1171,7 @@ mod tests { let phase = WslcProvisionConfig { image: Some("custom/image:tag".to_string()), image_tar_path: Some("C:\\images\\custom.tar".to_string()), + session_id: None, port_mappings: None, }; let cfg = build_provision_config(&ExecutionRequest::default(), Some(phase)).unwrap(); @@ -1016,6 +1283,7 @@ mod tests { WslcProvisionConfig { image: Some("custom/image:tag".to_string()), image_tar_path: None, + session_id: None, port_mappings: None, }, "custom/image:tag", @@ -1025,6 +1293,7 @@ mod tests { WslcProvisionConfig { image: None, image_tar_path: Some("C:\\images\\custom.tar".to_string()), + session_id: None, port_mappings: None, }, "alpine:latest", @@ -1034,6 +1303,7 @@ mod tests { WslcProvisionConfig { image: Some(String::new()), image_tar_path: Some(String::new()), + session_id: None, port_mappings: None, }, "", diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/mod.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/mod.rs index 6aef6b30a..ec62024e0 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/mod.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/mod.rs @@ -21,11 +21,17 @@ pub(crate) fn adapt_request(request: contract::Request) -> Result { + state_aware::provision_session_into_input(request) + } contract::Request::Provision(request) => state_aware::provision_into_input(request), contract::Request::Start(request) => state_aware::start_into_input(request), contract::Request::Exec(request) => state_aware::exec_into_input(request), contract::Request::Stop(request) => state_aware::stop_into_input(request), contract::Request::Deprovision(request) => state_aware::deprovision_into_input(request), + contract::Request::DeprovisionSession(request) => { + state_aware::deprovision_session_into_input(request) + } }?; Ok(AdaptedConfigRequest::StateAware(input)) } diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs index b0265f49f..e665d714e 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs @@ -5,7 +5,9 @@ use crate::mxc_common::config_contract_adapters::dev::common::{ convert_filesystem, convert_network, convert_process, convert_runtime_config, convert_telemetry, }; use crate::mxc_common::error::WxcError; -use crate::mxc_common::models::{IsolationSessionProvisionConfig, WslcProvisionConfig}; +use crate::mxc_common::models::{ + IsolationSessionProvisionConfig, WslcProvisionConfig, WslcProvisionSessionConfig, +}; use crate::mxc_common::state_aware_input::StateAwareInput; use crate::mxc_common::state_aware_operation::{StateAwareOperation, StateAwareProvision}; use crate::mxc_common::wire; @@ -72,11 +74,13 @@ fn convert_wslc_provision(value: contract::WslcProvision) -> WslcProvisionConfig let contract::WslcProvision { image, image_tar_path, + session_id, port_mappings, } = value; WslcProvisionConfig { image: image.into_option(), image_tar_path: image_tar_path.into_option(), + session_id: session_id.into_option(), port_mappings: port_mappings.into_option().map(|mappings| { mappings .into_iter() @@ -91,6 +95,30 @@ fn convert_state_aware_wslc(value: contract::StateAwareWslc) -> Option WslcProvisionSessionConfig { + let contract::WslcProvisionSession { + cpu_count, + memory_mb, + gpu, + } = value; + WslcProvisionSessionConfig { + cpu_count: cpu_count.into_option(), + memory_mb: memory_mb.into_option(), + gpu: gpu.into_option(), + } +} + +fn convert_state_aware_wslc_session( + value: contract::StateAwareWslcSession, +) -> Option { + let contract::StateAwareWslcSession { provision_session } = value; + provision_session + .into_option() + .map(convert_wslc_provision_session) +} + fn state_aware_common( schema: contract::OptionalField, comment: contract::OptionalField, @@ -123,6 +151,25 @@ fn state_aware_common( } } +pub(super) fn provision_session_into_input( + request: contract::WslcProvisionSessionRequest, +) -> Result { + let contract::WslcProvisionSessionRequest { + schema, + comment, + version, + phase: contract::ProvisionSessionPhase, + containment: contract::WslcContainment, + telemetry, + wslc, + } = request; + let sizing = wslc + .into_option() + .and_then(convert_state_aware_wslc_session); + let common = state_aware_common(schema, comment, version, telemetry); + StateAwareInput::new(common, StateAwareOperation::ProvisionSession(sizing)) +} + pub(super) fn provision_into_input( request: contract::ProvisionRequest, ) -> Result { @@ -267,6 +314,25 @@ pub(super) fn deprovision_into_input( StateAwareInput::new(common, StateAwareOperation::Deprovision { sandbox_id }) } +pub(super) fn deprovision_session_into_input( + request: contract::WslcDeprovisionSessionRequest, +) -> Result { + let contract::WslcDeprovisionSessionRequest { + schema, + comment, + version, + phase: contract::DeprovisionSessionPhase, + containment: contract::WslcContainment, + session_id, + telemetry, + } = request; + let common = state_aware_common(schema, comment, version, telemetry); + StateAwareInput::new( + common, + StateAwareOperation::DeprovisionSession { session_id }, + ) +} + #[cfg(test)] #[path = "state_aware_tests/mod.rs"] mod tests; diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/mod.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/mod.rs index bada1f8d3..c71a154b6 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/mod.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/mod.rs @@ -5,5 +5,6 @@ mod common; mod deprovision; mod exec; mod provision; +mod session; mod start; mod stop; diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/session.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/session.rs new file mode 100644 index 000000000..c7ce9ebb9 --- /dev/null +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware_tests/session.rs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::common::{adapt, assert_clean_common}; +use crate::mxc_common::models::WslcProvisionSessionConfig; +use crate::mxc_common::state_aware_operation::StateAwareOperation; + +fn provision_session( + fields: &str, +) -> ( + crate::mxc_common::common_request_ir::CommonRequestIR, + StateAwareOperation, +) { + adapt(&format!( + r#"{{"version":"1.1.0-alpha","phase":"provision-session","containment":"wslc"{fields}}}"# + )) +} + +#[test] +fn an_absent_wslc_section_leaves_the_sizing_absent() { + for fields in ["", r#","wslc":{}"#, r#","wslc":{"provisionSession":{}}"#] { + let (common, operation) = provision_session(fields); + assert_clean_common(&common); + assert!(common.process.is_none()); + assert!(common.filesystem.is_none()); + assert!(common.network.is_none()); + + let expected = if fields.contains("provisionSession") { + Some(WslcProvisionSessionConfig::default()) + } else { + None + }; + assert_eq!( + operation, + StateAwareOperation::ProvisionSession(expected), + "{fields}" + ); + } +} + +#[test] +fn each_sizing_field_survives_adaptation_independently() { + let (_, operation) = provision_session( + r#","wslc":{"provisionSession":{"cpuCount":4,"memoryMb":8192,"gpu":true}}"#, + ); + assert_eq!( + operation, + StateAwareOperation::ProvisionSession(Some(WslcProvisionSessionConfig { + cpu_count: Some(4), + memory_mb: Some(8192), + gpu: Some(true), + })) + ); + + let (_, gpu_off) = provision_session(r#","wslc":{"provisionSession":{"gpu":false}}"#); + assert_eq!( + gpu_off, + StateAwareOperation::ProvisionSession(Some(WslcProvisionSessionConfig { + gpu: Some(false), + ..Default::default() + })), + "an explicit false must not read as an absent field" + ); +} + +#[test] +fn a_session_release_carries_its_id_and_nothing_else() { + let (common, operation) = adapt( + r#"{"version":"1.1.0-alpha","phase":"deprovision-session","containment":"wslc", + "sessionId":"wslcsess:0123456789abcdef0123456789abcdef"}"#, + ); + assert_clean_common(&common); + assert!(common.process.is_none()); + assert!(common.filesystem.is_none()); + assert!(common.network.is_none()); + assert_eq!( + operation, + StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:0123456789abcdef0123456789abcdef".to_string(), + } + ); +} + +#[test] +fn both_session_phases_declare_wslc_containment() { + let (_, provisioned) = provision_session(""); + let (_, released) = adapt( + r#"{"version":"1.1.0-alpha","phase":"deprovision-session","containment":"wslc", + "sessionId":"wslcsess:0123456789abcdef0123456789abcdef"}"#, + ); + for operation in [provisioned, released] { + assert_eq!( + operation.containment(), + Some(crate::mxc_common::models::ContainmentBackend::Wslc) + ); + assert!(operation.sandbox_id().is_none()); + } +} + +#[test] +fn a_provision_carries_the_session_it_joins_only_when_one_is_named() { + let config = |fields: &str| { + let (_, operation) = adapt(&format!( + r#"{{"version":"1.1.0-alpha","phase":"provision","containment":"wslc", + "wslc":{{"provision":{{{fields}}}}}}}"# + )); + let StateAwareOperation::Provision( + crate::mxc_common::state_aware_operation::StateAwareProvision::Wslc(config), + ) = operation + else { + panic!("expected a WSLc provision"); + }; + config.expect("a present provision section yields a config") + }; + + assert_eq!(config(r#""image":"alpine:latest""#).session_id, None); + assert_eq!( + config(r#""sessionId":"wslcsess:0123456789abcdef0123456789abcdef""#).session_id, + Some("wslcsess:0123456789abcdef0123456789abcdef".to_string()) + ); + assert_eq!( + config(r#""sessionId":"""#).session_id, + Some(String::new()), + "an explicitly empty string is distinct from an absent field" + ); +} diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs index 09470fe62..62f38d183 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs @@ -59,6 +59,7 @@ fn convert_wslc_provision(value: contract::WslcProvision) -> WslcProvisionConfig WslcProvisionConfig { image: image.into_option(), image_tar_path: image_tar_path.into_option(), + session_id: None, port_mappings: None, } } diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs index dabaadb66..0a728aa32 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs @@ -59,6 +59,7 @@ fn convert_wslc_provision(value: contract::WslcProvision) -> WslcProvisionConfig WslcProvisionConfig { image: image.into_option(), image_tar_path: image_tar_path.into_option(), + session_id: None, port_mappings: None, } } diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index c4a349e16..622e56149 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -105,6 +105,12 @@ pub struct LoadOptions<'a> { pub cli_command: &'a [String], } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CliTargetId<'a> { + pub flag: &'a str, + pub value: &'a str, +} + /// Workspace-internal exact one-shot contract selected by a trusted typed /// producer. /// @@ -206,6 +212,52 @@ fn exact_containment_error(error: crate::mxc_contract::dev::ContainmentProbeErro ParseError::StateAware(MxcError::malformed_request(message)) } +fn session_containment_error( + phase: ContractPhase, + error: crate::mxc_contract::dev::ContainmentProbeError, +) -> ParseError { + use crate::mxc_contract::dev::ContainmentProbeError; + + let phase = phase.as_str(); + match error { + ContainmentProbeError::InvalidDeclaration(source) => { + ParseError::StateAware(MxcError::malformed_request(format!( + "Invalid containment declaration for {phase} phase: {source}" + ))) + } + ContainmentProbeError::UnsupportedContainment(_) => { + ParseError::StateAware(unsupported_session_containment(phase)) + } + } +} + +fn unsupported_session_containment(phase: &str) -> MxcError { + MxcError::unsupported_containment(format!( + "the {phase} phase requires containment \"wslc\": no other backend owns a session that \ + sandboxes are placed into" + )) +} + +fn deserialize_session_root( + json: &str, + phase: ContractPhase, + contract: &'static str, +) -> Result +where + T: serde::de::DeserializeOwned, +{ + match crate::mxc_contract::dev::probe_containment(json) + .map_err(|error| session_containment_error(phase, error))? + { + crate::mxc_contract::dev::Containment::Wslc => { + deserialize_development_root(json, contract, "1.1", true) + } + _ => Err(ParseError::StateAware(unsupported_session_containment( + phase.as_str(), + ))), + } +} + fn development_network_migration(contract: &str, path: Option<&str>) -> Option<&'static str> { match (contract, path) { ("IsolationSession provision", Some("network.proxy")) => Some( @@ -486,6 +538,10 @@ fn deserialize_development_request( .map_err(|error| ParseError::OneShot(WxcError::ConfigParse(error.to_string())))?; Ok(Request::OneShot(Box::new(request))) } + Some(Phase::ProvisionSession) => { + deserialize_session_root(json, Phase::ProvisionSession, "WSLC provision-session") + .map(Request::ProvisionSession) + } Some(Phase::Provision) => { let request = match dev::probe_containment(json).map_err(exact_containment_error)? { Containment::WindowsSandbox => { @@ -515,6 +571,10 @@ fn deserialize_development_request( Some(Phase::Deprovision) => { deserialize_development_root(json, "deprovision", "1.1", true).map(Request::Deprovision) } + Some(Phase::DeprovisionSession) => { + deserialize_session_root(json, Phase::DeprovisionSession, "WSLC deprovision-session") + .map(Request::DeprovisionSession) + } } } @@ -522,7 +582,7 @@ fn parse_exact_development(json: &str, logger: &mut Logger) -> Result { @@ -534,9 +594,7 @@ fn parse_exact_development(json: &str, logger: &mut Logger) -> Result normalize_state_aware(input, logger, true) .map(MxcRequest::StateAware) - .map_err(|error| { - ParseError::StateAware(MxcError::malformed_request(error.to_string())) - }), + .map_err(|error| ParseError::StateAware(error.into_state_aware())), } } @@ -674,7 +732,7 @@ pub fn load_state_aware_request_from_json_with_options( json_str: &str, logger: &mut Logger, phase: Phase, - sandbox_id: Option<&str>, + target_id: Option>, cli_command: &[String], ) -> Result { let result = (|| { @@ -689,18 +747,15 @@ pub fn load_state_aware_request_from_json_with_options( ))); } - // The CLI collapses whichever identity flag it was given into one - // argument, so the rejection below can name a flag the caller did not - // pass once more than one exists. let required_flag = match phase.target_id_kind() { TargetIdKind::None => None, TargetIdKind::SandboxId => Some("--container-id"), TargetIdKind::SessionId => Some("--session-id"), }; - match (required_flag, sandbox_id) { - (None, Some(_)) => { + match (required_flag, target_id) { + (None, Some(supplied)) => { return Err(ParseError::StateAware(MxcError::malformed_request( - format!("the {phase} operation does not accept --container-id"), + format!("the {phase} operation does not accept {}", supplied.flag), ))); } (None, None) => {} @@ -709,7 +764,15 @@ pub fn load_state_aware_request_from_json_with_options( format!("the {phase} operation requires {flag}"), ))); } - (Some(flag), Some("")) => { + (Some(flag), Some(supplied)) if supplied.flag != flag => { + return Err(ParseError::StateAware(MxcError::malformed_request( + format!( + "the {phase} operation requires {flag}, not {}", + supplied.flag + ), + ))); + } + (Some(flag), Some(supplied)) if supplied.value.is_empty() => { return Err(ParseError::StateAware(MxcError::malformed_request( format!("{flag} must not be empty"), ))); @@ -726,7 +789,10 @@ pub fn load_state_aware_request_from_json_with_options( let routed_json = source .splice_lifecycle_routing( phase.as_str(), - phase.target_id_kind().wire_member().zip(sandbox_id), + phase + .target_id_kind() + .wire_member() + .zip(target_id.map(|supplied| supplied.value)), ) .ok_or_else(|| { ParseError::StateAware(MxcError::malformed_request( @@ -1724,6 +1790,20 @@ mod tests { Logger::new(Mode::Buffer) } + fn container_flag(value: &str) -> CliTargetId<'_> { + CliTargetId { + flag: "--container-id", + value, + } + } + + fn session_flag(value: &str) -> CliTargetId<'_> { + CliTargetId { + flag: "--session-id", + value, + } + } + fn repository_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() @@ -2967,6 +3047,7 @@ mod tests { StateAwareProvision::Wslc(Some(crate::mxc_common::models::WslcProvisionConfig { image: Some("alpine:latest".into()), image_tar_path: None, + session_id: None, port_mappings: None, })), ), @@ -2975,6 +3056,7 @@ mod tests { StateAwareProvision::Wslc(Some(crate::mxc_common::models::WslcProvisionConfig { image: Some("python:3.12-alpine".into()), image_tar_path: None, + session_id: None, port_mappings: Some(vec![crate::mxc_common::models::PortMapping { windows_port: 18081, container_port: 8080, @@ -4541,7 +4623,7 @@ mod tests { json, &mut test_logger(), phase, - sandbox_id, + sandbox_id.map(container_flag), &[], ) .unwrap_or_else(|error| panic!("{phase}: {error:?}")); @@ -4560,7 +4642,7 @@ mod tests { json, &mut test_logger(), Phase::Start, - Some("iso:cli"), + Some(container_flag("iso:cli")), &[], ) .unwrap_err(); @@ -4574,7 +4656,7 @@ mod tests { r#"{"version":"1.0.0","containment":"wslc"}"#, &mut test_logger(), Phase::Provision, - Some("wslc:abc"), + Some(container_flag("wslc:abc")), &[], ) .unwrap_err(); @@ -4599,13 +4681,13 @@ mod tests { r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, &mut test_logger(), Phase::ProvisionSession, - Some("wslcsess:abc"), + Some(session_flag("wslcsess:0123456789abcdef0123456789abcdef")), &[], ) .unwrap_err(); assert!(provision_session_error .message() - .contains("the provision-session operation does not accept --container-id")); + .contains("the provision-session operation does not accept --session-id")); let missing = load_state_aware_request_from_json_with_options( r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, @@ -4623,7 +4705,7 @@ mod tests { r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, &mut test_logger(), Phase::DeprovisionSession, - Some(""), + Some(session_flag("")), &[], ) .unwrap_err(); @@ -4631,26 +4713,151 @@ mod tests { } #[test] - fn session_phases_pass_cli_routing_and_are_then_refused_by_every_registered_contract() { - for (phase, session_id) in [ - (Phase::ProvisionSession, None), - (Phase::DeprovisionSession, Some("wslcsess:abcd")), + fn a_rejection_names_the_identity_flag_the_caller_supplied() { + let wrong_flag_for_a_sandbox = load_state_aware_request_from_json_with_options( + r#"{"version":"1.1.0-alpha"}"#, + &mut test_logger(), + Phase::Start, + Some(session_flag("wslcsess:0123456789abcdef0123456789abcdef")), + &[], + ) + .unwrap_err(); + assert_eq!( + wrong_flag_for_a_sandbox.message(), + "malformed_request: the start operation requires --container-id, not --session-id" + ); + + let wrong_flag_for_a_session = load_state_aware_request_from_json_with_options( + r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, + &mut test_logger(), + Phase::DeprovisionSession, + Some(container_flag("wslc:abcd")), + &[], + ) + .unwrap_err(); + assert_eq!( + wrong_flag_for_a_session.message(), + "malformed_request: the deprovision-session operation requires --session-id, not \ + --container-id" + ); + + let unwanted_container_id = load_state_aware_request_from_json_with_options( + r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, + &mut test_logger(), + Phase::ProvisionSession, + Some(container_flag("wslc:abcd")), + &[], + ) + .unwrap_err(); + assert_eq!( + unwanted_container_id.message(), + "malformed_request: the provision-session operation does not accept --container-id" + ); + } + + #[test] + fn session_phases_route_through_the_development_contract() { + let provisioned = load_state_aware_request_from_json_with_options( + r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, + &mut test_logger(), + Phase::ProvisionSession, + None, + &[], + ) + .unwrap(); + assert_eq!(provisioned.phase(), Phase::ProvisionSession); + assert_eq!( + provisioned.containment(), + Some(ContainmentBackend::Wslc), + "a session phase routes by its declared containment" + ); + assert!(provisioned.session_id().is_none()); + + let released = load_state_aware_request_from_json_with_options( + r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, + &mut test_logger(), + Phase::DeprovisionSession, + Some(session_flag("wslcsess:0123456789abcdef0123456789abcdef")), + &[], + ) + .unwrap(); + assert_eq!(released.phase(), Phase::DeprovisionSession); + assert_eq!( + released.session_id(), + Some("wslcsess:0123456789abcdef0123456789abcdef") + ); + assert!(released.sandbox_id().is_none()); + } + + #[test] + fn malformed_session_ids_keep_their_error_code_in_cli_requests() { + for session_id in [ + "wslcsess:short", + "wslc:0123456789abcdef0123456789abcdef", + "wslcsess:0123456789ABCDEF0123456789ABCDEF", ] { let error = load_state_aware_request_from_json_with_options( r#"{"version":"1.1.0-alpha","containment":"wslc"}"#, &mut test_logger(), + Phase::DeprovisionSession, + Some(session_flag(session_id)), + &[], + ) + .unwrap_err(); + let ParseError::StateAware(error) = error else { + panic!("a session release must return a lifecycle error"); + }; + assert_eq!(error.code, MxcErrorCode::MalformedId); + } + } + + #[test] + fn session_phases_are_refused_by_the_published_contracts() { + for version in [r#""0.9.0-alpha""#, r#""1.0.0""#] { + let error = load_state_aware_request_from_json_with_options( + &format!(r#"{{"version":{version},"containment":"wslc"}}"#), + &mut test_logger(), + Phase::ProvisionSession, + None, + &[], + ) + .unwrap_err(); + assert_eq!(error.message(), "malformed_request: Unsupported phase"); + } + } + + #[test] + fn a_session_phase_refuses_a_containment_that_owns_no_session() { + for (containment, phase, target) in [ + ( + r#""isolation_session""#, + Phase::ProvisionSession, + None::<&str>, + ), + (r#""windows_sandbox""#, Phase::ProvisionSession, None), + (r#""not_a_backend""#, Phase::ProvisionSession, None), + ( + r#""isolation_session""#, + Phase::DeprovisionSession, + Some("wslcsess:0123456789abcdef0123456789abcdef"), + ), + ] { + let error = load_state_aware_request_from_json_with_options( + &format!(r#"{{"version":"1.1.0-alpha","containment":{containment}}}"#), + &mut test_logger(), phase, - session_id, + target.map(session_flag), &[], ) .unwrap_err(); - assert_eq!( - error.message(), - "malformed_request: Unsupported phase", - "{phase} must reach the contract's phase probe, so the refusal comes from the \ - registered contracts rather than from the routing category" + let message = error.message(); + assert!( + message.starts_with("unsupported_containment:"), + "{containment} on {phase} must be refused as an unsupported containment, got \ + {message}" ); + assert!(message.contains(r#"containment "wslc""#), "{message}"); } } @@ -4744,7 +4951,7 @@ mod tests { }"#, &mut logger, Phase::Exec, - Some("iso:abc"), + Some(container_flag("iso:abc")), &["echo".to_string(), "hello".to_string()], ) .unwrap(); diff --git a/src/mxc-sdk/src/core/mxc_common/error.rs b/src/mxc-sdk/src/core/mxc_common/error.rs index 7e4cf7473..37cb5ed5e 100644 --- a/src/mxc-sdk/src/core/mxc_common/error.rs +++ b/src/mxc-sdk/src/core/mxc_common/error.rs @@ -3,11 +3,16 @@ use thiserror::Error; +use crate::mxc_common::mxc_error::MxcError; + #[derive(Debug, Error)] pub enum WxcError { #[error("Configuration parse error: {0}")] ConfigParse(String), + #[error(transparent)] + StateAware(Box), + #[error("Validation error: {0}")] Validation(String), @@ -36,6 +41,21 @@ pub enum WxcError { StringConversion(String), } +impl WxcError { + pub(crate) fn into_state_aware(self) -> MxcError { + match self { + Self::StateAware(error) => *error, + error => MxcError::malformed_request(error.to_string()), + } + } +} + +impl From for WxcError { + fn from(error: MxcError) -> Self { + Self::StateAware(Box::new(error)) + } +} + impl From for WxcError { fn from(err: serde_json::Error) -> Self { WxcError::ConfigParse(err.to_string()) diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 7921fe8ef..5a352cd50 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -256,6 +256,9 @@ pub struct WslcProvisionConfig { /// Local image tarball to import instead of pulling an image. pub image_tar_path: Option, + /// Join this session, or the shared default session when absent. + pub session_id: Option, + /// Host-to-container TCP forwards applied to the sandbox's own container. pub port_mappings: Option>, } diff --git a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs index 80ae4587a..51ec9b5b8 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -99,17 +99,7 @@ pub fn policy_hash(request: &ExecutionRequest) -> String { hash_canonical_json(&canonical) } -/// Compute a state-aware policy identity from the backend resolved from the -/// lifecycle request and its typed operation. -/// -/// Only allow-listed provision fields enter the hash. An absent config remains -/// JSON `null`, a present config with no fields remains `{}`, and explicit empty -/// strings remain distinct, preserving identities for exact accepted requests. -/// -/// A session's identity covers the sizing it declares. A sandbox placed into an -/// existing session declares none, and this hash is computed from the request -/// before dispatch, so a sandbox's identity does not cover the sizing of the -/// session it joins. +/// Hash the declared lifecycle policy without resolving the session's CPU, memory, or GPU settings. pub fn state_aware_policy_hash( request: &ExecutionRequest, backend: &str, @@ -130,8 +120,6 @@ pub fn state_aware_policy_hash( hash_canonical_json(&canonical_json(&projection)) } -/// Exhaustive matching forces new operation/config fields to be classified -/// explicitly instead of accidentally hashing a newly added secret. fn state_aware_config_projection(operation: &StateAwareOperation) -> Value { match operation { StateAwareOperation::Provision(StateAwareProvision::IsolationSession(Some( @@ -146,6 +134,7 @@ fn state_aware_config_projection(operation: &StateAwareOperation) -> Value { StateAwareOperation::Provision(StateAwareProvision::Wslc(Some(WslcProvisionConfig { image, image_tar_path, + session_id, port_mappings, }))) => { let mut config = Map::new(); @@ -156,6 +145,11 @@ fn state_aware_config_projection(operation: &StateAwareOperation) -> Value { config.insert("imageTarPath".into(), Value::String(image_tar_path.clone())); } + // Session IDs are capabilities, so record their presence without their values. + if session_id.is_some() { + config.insert("joinsNamedSession".into(), Value::Bool(true)); + } + // A forwarded host port is policy, so it belongs in the identity. if let Some(port_mappings) = port_mappings { let mappings = port_mappings @@ -987,6 +981,61 @@ mod tests { } } + #[test] + fn joining_a_session_is_recorded_without_distinguishing_which_session() { + let base = request(); + let joined = |session_id: Option<&str>| { + state_aware_policy_hash( + &base, + "wslc", + &StateAwareOperation::Provision(StateAwareProvision::Wslc(Some( + WslcProvisionConfig { + image: Some("alpine:latest".to_string()), + session_id: session_id.map(str::to_string), + ..Default::default() + }, + ))), + ) + }; + + let implicit = joined(None); + let named = joined(Some("wslcsess:aaaa")); + assert_ne!( + implicit, named, + "a sandbox sized by the host defaults must not share an identity with one whose \ + sizing was chosen by another request" + ); + assert_eq!( + named, + joined(Some("wslcsess:bbbb")), + "the session id is a bearer capability and must not reach the identity" + ); + } + + #[test] + fn an_unnamed_session_leaves_the_provision_identity_untouched() { + let base = request(); + let hash = |config: WslcProvisionConfig| { + state_aware_policy_hash( + &base, + "wslc", + &StateAwareOperation::Provision(StateAwareProvision::Wslc(Some(config))), + ) + }; + + assert_eq!( + hash(WslcProvisionConfig { + image: Some("alpine:latest".to_string()), + ..Default::default() + }), + hash(WslcProvisionConfig { + image: Some("alpine:latest".to_string()), + session_id: None, + ..Default::default() + }) + ); + } + fn parse_state_aware( json: &str, ) -> crate::mxc_common::state_aware_request::ParsedStateAwareRequest { diff --git a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs index 38005f780..7cb1f6ae4 100644 --- a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs +++ b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs @@ -138,7 +138,7 @@ impl SdkStateAwareInput { version.as_str() ))); } - if session_operation(&operation) && version != ContractVersion::V1_1_0Alpha { + if wslc_session_requested(&operation) && version != ContractVersion::V1_1_0Alpha { return Err(WxcError::ConfigParse(format!( "state-aware session operations require schema version \ 1.1.0-alpha, got {}", @@ -210,11 +210,15 @@ fn wslc_port_mappings_requested(operation: &StateAwareOperation) -> bool { ) } -fn session_operation(operation: &StateAwareOperation) -> bool { - matches!( - operation, - StateAwareOperation::ProvisionSession(_) | StateAwareOperation::DeprovisionSession { .. } - ) +fn wslc_session_requested(operation: &StateAwareOperation) -> bool { + match operation { + StateAwareOperation::ProvisionSession(_) + | StateAwareOperation::DeprovisionSession { .. } => true, + StateAwareOperation::Provision( + crate::mxc_common::state_aware_operation::StateAwareProvision::Wslc(Some(config)), + ) => config.session_id.is_some(), + _ => false, + } } fn map_network(network: SdkNetworkInput) -> wire::Network { @@ -316,6 +320,7 @@ mod tests { crate::mxc_common::models::WslcProvisionConfig { image: None, image_tar_path: None, + session_id: None, port_mappings, }, )), @@ -352,6 +357,51 @@ mod tests { } } + fn wslc_provision_in_session(session_id: Option<&str>) -> StateAwareOperation { + StateAwareOperation::Provision( + crate::mxc_common::state_aware_operation::StateAwareProvision::Wslc(Some( + crate::mxc_common::models::WslcProvisionConfig { + session_id: session_id.map(str::to_string), + ..Default::default() + }, + )), + ) + } + + #[test] + fn the_stable_contract_rejects_session_ids_even_when_empty() { + for session_id in ["", "wslcsess:0123456789abcdef0123456789abcdef"] { + let error = SdkStateAwareInput::new( + ContractVersion::V1_0_0, + wslc_provision_in_session(Some(session_id)), + ) + .expect_err("1.0.0 does not declare wslc.provision.sessionId"); + assert!( + error.to_string().contains("1.1.0-alpha"), + "the message must name the contract that declares session placement: {error}" + ); + } + } + + #[test] + fn the_alpha_contract_preserves_session_id_presence() { + for session_id in ["", "wslcsess:0123456789abcdef0123456789abcdef"] { + let operation = wslc_provision_in_session(Some(session_id)); + let input = SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation.clone()) + .expect("1.1.0-alpha declares wslc.provision.sessionId"); + let (_, observed) = input.into_normalization_input().unwrap().into_parts(); + assert_eq!(observed, operation); + } + } + + #[test] + fn an_omitted_session_id_is_accepted_on_both_contracts() { + for version in [ContractVersion::V1_0_0, ContractVersion::V1_1_0Alpha] { + SdkStateAwareInput::new(version, wslc_provision_in_session(None)) + .expect("unnamed-session provisioning must remain compatible"); + } + } + #[test] fn the_constructor_validates_ports_before_normalization() { for mappings in [ diff --git a/src/mxc-sdk/src/core/mxc_common/splice.rs b/src/mxc-sdk/src/core/mxc_common/splice.rs index 88c46f91f..9e4661b72 100644 --- a/src/mxc-sdk/src/core/mxc_common/splice.rs +++ b/src/mxc-sdk/src/core/mxc_common/splice.rs @@ -276,7 +276,10 @@ mod tests { fn lifecycle_routing_carries_a_session_id_in_its_own_member() { let source = CommandSource::parse(r#"{"version":"1.1.0-alpha"}"#).unwrap(); let spliced = source - .splice_lifecycle_routing("deprovision-session", Some(("sessionId", "wslcsess:abcd"))) + .splice_lifecycle_routing( + "deprovision-session", + Some(("sessionId", "wslcsess:0123456789abcdef0123456789abcdef")), + ) .unwrap(); assert_eq!( @@ -284,7 +287,7 @@ mod tests { serde_json::json!({ "version": "1.1.0-alpha", "phase": "deprovision-session", - "sessionId": "wslcsess:abcd" + "sessionId": "wslcsess:0123456789abcdef0123456789abcdef" }) ); } diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs index 026508fb8..4d3f7298c 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs @@ -692,7 +692,7 @@ fn wslc_binding_carries_session_sizing_and_the_session_id_through() { let bound = bind_wslc::>(session_parsed(StateAwareOperation::DeprovisionSession { - session_id: "wslcsess:abcd".into(), + session_id: "wslcsess:0123456789abcdef0123456789abcdef".into(), })) .unwrap(); assert_eq!(bound.phase(), Phase::DeprovisionSession); @@ -701,7 +701,7 @@ fn wslc_binding_carries_session_sizing_and_the_session_id_through() { let BoundStateAwareOperation::DeprovisionSession { session_id } = operation else { panic!("expected a bound session deprovision"); }; - assert_eq!(session_id, "wslcsess:abcd"); + assert_eq!(session_id, "wslcsess:0123456789abcdef0123456789abcdef"); } #[test] @@ -709,7 +709,7 @@ fn session_operations_never_bind_to_a_backend_without_sessions() { for operation in [ StateAwareOperation::ProvisionSession(None), StateAwareOperation::DeprovisionSession { - session_id: "wslcsess:abcd".into(), + session_id: "wslcsess:0123456789abcdef0123456789abcdef".into(), }, ] { let error = @@ -742,7 +742,7 @@ fn session_phases_keep_the_same_validation_execution_and_dry_run_order_as_every_ assert_dispatch_parsed::( || { session_parsed(StateAwareOperation::DeprovisionSession { - session_id: "wslcsess:abcd".into(), + session_id: "wslcsess:0123456789abcdef0123456789abcdef".into(), }) }, Config::Absent, diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs index dcd4ea5ce..a564fc3f2 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_dispatch.rs @@ -679,6 +679,8 @@ mod tests { use std::cell::RefCell; use std::time::Duration; + const SESSION_ID: &str = "wslcsess:0123456789abcdef0123456789abcdef"; + /// Fully-configurable backend stub for dispatcher tests. /// Each phase is wired to either succeed (the default — empty result with /// no metadata) or fail with a typed `MxcError` (set via the `*_error` @@ -1083,14 +1085,14 @@ mod tests { let env = assert_envelope( dispatch_state_aware( &mut b, - session_bound(Phase::DeprovisionSession, Some("wslcsess:abcd")), + session_bound(Phase::DeprovisionSession, Some(SESSION_ID)), false, ) .unwrap(), ); assert_eq!(b.validate_deprovision_session_calls.get(), 1); assert_eq!(b.deprovision_session_calls.get(), 1); - assert_eq!(*b.released.borrow(), vec!["wslcsess:abcd".to_string()]); + assert_eq!(*b.released.borrow(), vec![SESSION_ID.to_string()]); assert_eq!(env, json!({"result": {}})); } @@ -1133,7 +1135,7 @@ mod tests { let mut b = SessionStubBackend::new(); for bound in [ session_bound(Phase::ProvisionSession, None), - session_bound(Phase::DeprovisionSession, Some("wslcsess:abcd")), + session_bound(Phase::DeprovisionSession, Some(SESSION_ID)), ] { assert_eq!( assert_envelope(dispatch_state_aware(&mut b, bound, true).unwrap()), @@ -1153,7 +1155,7 @@ mod tests { let mut b = StubBackend::new(); for bound in [ bound(Phase::ProvisionSession, None), - bound(Phase::DeprovisionSession, Some("wslcsess:abcd")), + bound(Phase::DeprovisionSession, Some(SESSION_ID)), ] { let error = dispatch_state_aware(&mut b, bound, dry_run).unwrap_err(); assert_eq!(error.code, MxcErrorCode::UnsupportedPhase, "{dry_run}"); @@ -1166,7 +1168,7 @@ mod tests { fn a_session_phase_cannot_be_streamed_as_an_exec() { let mut b = SessionStubBackend::new(); for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] { - let bound = session_bound(phase, Some("wslcsess:abcd")); + let bound = session_bound(phase, Some(SESSION_ID)); let error = dispatch_state_aware_exec(&mut b, bound).unwrap_err(); assert_eq!(error.code, MxcErrorCode::MalformedRequest); assert_eq!( @@ -1448,7 +1450,7 @@ mod tests { for operation in [ StateAwareOperation::ProvisionSession(None), StateAwareOperation::DeprovisionSession { - session_id: "wslcsess:deadbeef".into(), + session_id: SESSION_ID.into(), }, ] { let parsed = ParsedStateAwareRequest::new(ExecutionRequest::default(), operation); diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs index 5395dc8cc..0b5ebbe4e 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_operation.rs @@ -13,6 +13,7 @@ use crate::mxc_common::models::{ ContainmentBackend, IsolationSessionProvisionConfig, WslcProvisionConfig, WslcProvisionSessionConfig, }; +use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::state_aware_request::Phase; /// Backend-specific provision input, before backend-owned validation/defaulting. @@ -128,7 +129,9 @@ impl StateAwareOperation { None => Ok(()), } } - Self::DeprovisionSession { session_id } => validate_session_id(session_id), + Self::DeprovisionSession { session_id } => { + validate_wslc_session_id(session_id).map_err(Into::into) + } // Session sizing carries no cross-backend constraint; the WSLc // backend decides what it can honor. @@ -145,7 +148,7 @@ impl StateAwareOperation { const SESSION_ID_TAG: &str = "wslcsess:"; const SESSION_ID_HEX_DIGITS: usize = 32; -fn validate_session_id(session_id: &str) -> Result<(), crate::mxc_common::error::WxcError> { +pub(crate) fn validate_wslc_session_id(session_id: &str) -> Result<(), MxcError> { let well_formed = session_id .strip_prefix(SESSION_ID_TAG) .is_some_and(|token| { @@ -157,7 +160,7 @@ fn validate_session_id(session_id: &str) -> Result<(), crate::mxc_common::error: if well_formed { Ok(()) } else { - Err(crate::mxc_common::error::WxcError::ConfigParse(format!( + Err(MxcError::malformed_id(format!( "a session id must be `{SESSION_ID_TAG}` followed by {SESSION_ID_HEX_DIGITS} \ lowercase hex digits" ))) diff --git a/src/mxc-sdk/src/core/mxc_common/telemetry/correlation_state.rs b/src/mxc-sdk/src/core/mxc_common/telemetry/correlation_state.rs index e4d2bda31..30561c6c2 100644 --- a/src/mxc-sdk/src/core/mxc_common/telemetry/correlation_state.rs +++ b/src/mxc-sdk/src/core/mxc_common/telemetry/correlation_state.rs @@ -1,20 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Per-lifecycle persistence for the [`super::correlation_vector`] root, so a -//! state-aware sandbox's phases share one telemetry lineage without a caller -//! ever holding or relaying a vector. -//! -//! `provision` mints its vector the ordinary way ([`super::correlation_vector::seed`], -//! before its `sandbox_id` exists) and, once dispatch mints that id, this module -//! persists the vector under it. Every later phase recalls the persisted root -//! and [`super::correlation_vector::spin`]s a child off it; `deprovision` -//! forgets the record on success. A record that cannot be found or read (never -//! persisted, removed, corrupted, or the store is unavailable) degrades to a -//! fresh, disconnected [`super::correlation_vector::seed`] — lineage is lost, -//! but a phase never derives a vector from `sandbox_id` itself, which — unlike -//! a persisted root — is not guaranteed to carry 128 bits of entropy and, on -//! some backends, can embed caller-supplied data. +//! Per-sandbox telemetry correlation with independent vectors for session operations. use std::fs; use std::path::{Path, PathBuf}; @@ -23,6 +10,7 @@ use std::time::{Duration, SystemTime}; use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; use crate::mxc_common::state_aware_dispatch::DispatchOutcome; +use crate::mxc_common::state_aware_request::Phase; use super::correlation_vector::{is_relayable, seed, spin}; @@ -33,19 +21,15 @@ const STORE_LOCK_RETRY_COUNT: usize = 200; const STORE_LOCK_RETRY_DELAY: Duration = Duration::from_millis(5); const PRUNE_DELETE_LIMIT: usize = 256; -/// Pre-dispatch correlation vector for a state-aware phase. `provision` always -/// seeds a fresh vector (its `sandbox_id` doesn't exist yet — persist it via -/// [`on_provision_outcome`] once dispatch mints one). Every other phase recalls -/// the persisted lifecycle root for `sandbox_id` and spins a child off it, or -/// seeds a fresh, disconnected vector if `sandbox_id` is absent or has no -/// record. Returns an empty string when `active` is false, so an inactive -/// telemetry provider pays for no RNG/clock/file-system work. -pub fn pre_dispatch_vector(active: bool, is_provision: bool, sandbox_id: Option<&str>) -> String { +pub fn pre_dispatch_vector(active: bool, phase: Phase, sandbox_id: Option<&str>) -> String { if !active { return String::new(); } + if matches!(phase, Phase::ProvisionSession | Phase::DeprovisionSession) { + return seed(); + } match sandbox_id { - Some(id) if !is_provision => { + Some(id) if phase != Phase::Provision => { // Load and refresh the active lifecycle before sweeping abandoned // records. Otherwise a valid sandbox whose last phase was over the // retention threshold ago would delete its own root here. @@ -553,8 +537,48 @@ mod tests { #[test] fn inactive_pre_dispatch_vector_is_empty() { - assert_eq!(pre_dispatch_vector(false, true, None), ""); - assert_eq!(pre_dispatch_vector(false, false, Some("wsb:abcdef01")), ""); + assert_eq!(pre_dispatch_vector(false, Phase::Provision, None), ""); + assert_eq!( + pre_dispatch_vector(false, Phase::Exec, Some("wsb:abcdef01")), + "" + ); + } + + #[test] + fn session_phases_seed_without_sweeping_sandbox_records() { + let store = Arc::new(MemoryStore::default()); + let _guard = replace_store(store.clone()); + let root = seed(); + store.persist("wslc:existing", &root); + + for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] { + assert_eq!(pre_dispatch_vector(false, phase, None), ""); + let first = pre_dispatch_vector(true, phase, None); + let second = pre_dispatch_vector(true, phase, None); + assert!(is_relayable(&first)); + assert!(is_relayable(&second)); + assert_ne!(first, second); + } + assert_eq!(*store.prune_count.lock().unwrap(), 0); + assert_eq!( + store.record("wslc:existing").as_deref(), + Some(root.as_str()) + ); + } + + #[test] + fn session_phases_do_not_create_a_correlation_store_or_lock() { + let directory = tempfile::tempdir().unwrap(); + let store = directory.path().join("correlation"); + let _guard = StoreDirGuard::set(&store); + + for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] { + assert!(is_relayable(&pre_dispatch_vector(true, phase, None))); + } + assert!( + !store.exists(), + "session phases must not access the sandbox correlation store" + ); } #[test] @@ -562,13 +586,13 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let _guard = StoreDirGuard::set(tmp.path()); - let provisioned = pre_dispatch_vector(true, true, None); + let provisioned = pre_dispatch_vector(true, Phase::Provision, None); assert!(is_relayable(&provisioned)); // A non-provision phase with no sandbox_id (should never happen in // practice; the parser requires one) still degrades to a fresh seed // rather than panicking or emitting an empty vector. - let no_id = pre_dispatch_vector(true, false, None); + let no_id = pre_dispatch_vector(true, Phase::Exec, None); assert!(is_relayable(&no_id)); } @@ -579,15 +603,15 @@ mod tests { let sandbox_id = "wsb:12345678"; // provision: seed, then persist once the id is known. - let provisioned = pre_dispatch_vector(true, true, None); + let provisioned = pre_dispatch_vector(true, Phase::Provision, None); on_provision_outcome(true, &provisioned, &provision_envelope(sandbox_id)); let base_of = |cv: &str| cv.split('.').next().unwrap().to_string(); let provisioned_base = base_of(&provisioned); // start / exec / stop all recall the same root and spin off it. - for _ in 0..3 { - let spun = pre_dispatch_vector(true, false, Some(sandbox_id)); + for phase in [Phase::Start, Phase::Exec, Phase::Stop] { + let spun = pre_dispatch_vector(true, phase, Some(sandbox_id)); assert!(is_relayable(&spun)); assert_eq!(base_of(&spun), provisioned_base); assert_ne!( @@ -597,7 +621,7 @@ mod tests { } // deprovision recalls the same root one last time, then forgets it. - let deprovisioned = pre_dispatch_vector(true, false, Some(sandbox_id)); + let deprovisioned = pre_dispatch_vector(true, Phase::Deprovision, Some(sandbox_id)); assert_eq!(base_of(&deprovisioned), provisioned_base); on_deprovision_outcome( true, @@ -608,7 +632,7 @@ mod tests { // A later phase (e.g. a caller mistakenly reusing the id after // teardown) finds no record and gets a disconnected vector. - let after_teardown = pre_dispatch_vector(true, false, Some(sandbox_id)); + let after_teardown = pre_dispatch_vector(true, Phase::Exec, Some(sandbox_id)); assert_ne!(base_of(&after_teardown), provisioned_base); } @@ -645,7 +669,7 @@ mod tests { let _guard = StoreDirGuard::set(tmp.path()); let sandbox_id = "wsb:87654321"; - let provisioned = pre_dispatch_vector(true, true, None); + let provisioned = pre_dispatch_vector(true, Phase::Provision, None); on_provision_outcome(true, &provisioned, &provision_envelope(sandbox_id)); let provisioned_base = provisioned.split('.').next().unwrap().to_string(); @@ -656,7 +680,7 @@ mod tests { &Err(MxcError::backend_error("teardown failed, please retry")), ); - let retried = pre_dispatch_vector(true, false, Some(sandbox_id)); + let retried = pre_dispatch_vector(true, Phase::Deprovision, Some(sandbox_id)); assert_eq!( retried.split('.').next().unwrap(), provisioned_base, @@ -669,7 +693,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let _guard = StoreDirGuard::set(tmp.path()); - let provisioned = pre_dispatch_vector(true, true, None); + let provisioned = pre_dispatch_vector(true, Phase::Provision, None); // No `result.sandboxId` at all. on_provision_outcome( true, @@ -678,8 +702,8 @@ mod tests { ); // Nothing to recall for any id: every subsequent phase seeds fresh. - let a = pre_dispatch_vector(true, false, Some("wsb:abcdef01")); - let b = pre_dispatch_vector(true, false, Some("wsb:abcdef01")); + let a = pre_dispatch_vector(true, Phase::Exec, Some("wsb:abcdef01")); + let b = pre_dispatch_vector(true, Phase::Exec, Some("wsb:abcdef01")); assert_ne!( a.split('.').next().unwrap(), b.split('.').next().unwrap(), @@ -692,15 +716,15 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let _guard = StoreDirGuard::set(tmp.path()); - let provisioned = pre_dispatch_vector(true, true, None); + let provisioned = pre_dispatch_vector(true, Phase::Provision, None); on_provision_outcome( true, &provisioned, &Err(MxcError::backend_error("provisioning failed")), ); - let a = pre_dispatch_vector(true, false, Some("wsb:abcdef01")); - let b = pre_dispatch_vector(true, false, Some("wsb:abcdef01")); + let a = pre_dispatch_vector(true, Phase::Exec, Some("wsb:abcdef01")); + let b = pre_dispatch_vector(true, Phase::Exec, Some("wsb:abcdef01")); assert_ne!(a.split('.').next().unwrap(), b.split('.').next().unwrap()); } @@ -777,8 +801,8 @@ mod tests { let store = Arc::new(MemoryStore::default()); let _guard = replace_store(store.clone()); - let _ = pre_dispatch_vector(true, true, None); - let _ = pre_dispatch_vector(true, true, None); + let _ = pre_dispatch_vector(true, Phase::Provision, None); + let _ = pre_dispatch_vector(true, Phase::Provision, None); assert_eq!( *store.prune_count.lock().unwrap_or_else(|e| e.into_inner()), @@ -806,7 +830,7 @@ mod tests { store.record(sandbox_id).unwrap().split('.').next().unwrap(), base ); - let later = pre_dispatch_vector(true, false, Some(sandbox_id)); + let later = pre_dispatch_vector(true, Phase::Exec, Some(sandbox_id)); assert_eq!(later.split('.').next().unwrap(), base); } @@ -850,7 +874,7 @@ mod tests { filetime::set_file_mtime(&path, FileTime::from_system_time(stale_time)).unwrap(); filetime::set_file_mtime(&abandoned_path, FileTime::from_system_time(stale_time)).unwrap(); - let spun = pre_dispatch_vector(true, false, Some(sandbox_id)); + let spun = pre_dispatch_vector(true, Phase::Exec, Some(sandbox_id)); assert_eq!(spun.split('.').next().unwrap(), base); assert!(path.exists()); assert!( @@ -901,7 +925,7 @@ mod tests { ) .unwrap(); - let spun = pre_dispatch_vector(true, false, Some(sandbox_id)); + let spun = pre_dispatch_vector(true, Phase::Exec, Some(sandbox_id)); assert_eq!(spun.split('.').next().unwrap(), base); assert!(path.exists()); } @@ -918,7 +942,7 @@ mod tests { filetime::set_file_mtime(&path, FileTime::from_system_time(stale_time)).unwrap(); } - let _ = pre_dispatch_vector(true, true, None); + let _ = pre_dispatch_vector(true, Phase::Provision, None); let remaining_records = fs::read_dir(tmp.path()) .unwrap() @@ -938,7 +962,7 @@ mod tests { let tmp_path = store.tmp_path("wsb:fresh000").unwrap(); fs::create_dir_all(tmp.path()).unwrap(); fs::write(&tmp_path, seed()).unwrap(); - let _ = pre_dispatch_vector(true, true, None); + let _ = pre_dispatch_vector(true, Phase::Provision, None); assert!( tmp_path.exists(), @@ -961,7 +985,7 @@ mod tests { ), ) .unwrap(); - let _ = pre_dispatch_vector(true, true, None); + let _ = pre_dispatch_vector(true, Phase::Provision, None); assert!( !tmp_path.exists(), diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs b/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs index ded0c9c5b..ef280938d 100644 --- a/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs +++ b/src/mxc-sdk/src/core/mxc_contract/dev/mod.rs @@ -250,6 +250,7 @@ pub use stable::{ pub use state_aware::{probe_containment, Containment, ContainmentProbeError}; pub use state_aware::{probe_phase, Phase, PhaseProbeError}; pub use state_aware::{DeprovisionPhase, DeprovisionRequest}; +pub use state_aware::{DeprovisionSessionPhase, WslcDeprovisionSessionRequest}; pub use state_aware::{ExecPhase, ExecRequest}; pub use state_aware::{ IsolationSessionContainment, IsolationSessionNetwork, IsolationSessionNetworkAllow, @@ -257,6 +258,9 @@ pub use state_aware::{ IsolationSessionProvisionRequest, StateAwareIsolationSession, }; pub use state_aware::{ProvisionPhase, ProvisionRequest}; +pub use state_aware::{ + ProvisionSessionPhase, StateAwareWslcSession, WslcProvisionSession, WslcProvisionSessionRequest, +}; pub use state_aware::{StartPhase, StartRequest}; pub use state_aware::{StateAwareWslc, WslcContainment, WslcProvision, WslcProvisionRequest}; pub use state_aware::{StopPhase, StopRequest}; diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/request.rs b/src/mxc-sdk/src/core/mxc_contract/dev/request.rs index e3e33b494..b7fb8be15 100644 --- a/src/mxc-sdk/src/core/mxc_contract/dev/request.rs +++ b/src/mxc-sdk/src/core/mxc_contract/dev/request.rs @@ -6,6 +6,7 @@ use super::state_aware::{probe_containment, Containment, ContainmentProbeError}; use super::state_aware::{probe_phase, Phase, PhaseProbeError}; use super::state_aware::{ DeprovisionRequest, ExecRequest, ProvisionRequest, StartRequest, StopRequest, + WslcDeprovisionSessionRequest, WslcProvisionSessionRequest, }; use super::NetworkAction; @@ -14,6 +15,8 @@ use super::NetworkAction; pub enum Request { /// A one-shot execution request with no lifecycle phase. OneShot(Box), + ProvisionSession(WslcProvisionSessionRequest), + /// A state-aware provision request selected by its containment backend. Provision(ProvisionRequest), /// A state-aware start request. @@ -24,6 +27,7 @@ pub enum Request { Stop(StopRequest), /// A state-aware deprovision request. Deprovision(DeprovisionRequest), + DeprovisionSession(WslcDeprovisionSessionRequest), } /// An error encountered while selecting or deserializing a development request. @@ -77,6 +81,18 @@ fn parse_provision(json: &str) -> Result { } } +fn parse_session(json: &str, contract: &'static str) -> Result +where + T: serde::de::DeserializeOwned, +{ + match probe_containment(json)? { + Containment::Wslc => deserialize(json, contract), + other => Err(RequestParseError::Containment( + ContainmentProbeError::UnsupportedContainment(other.as_str().to_string()), + )), + } +} + fn parse_one_shot(json: &str) -> Result { let request: OneShotRequest = deserialize(json, "one-shot")?; validate_one_shot_request(&request)?; @@ -130,28 +146,20 @@ fn validate_isolation_session_one_shot_network( Ok(()) } -/// Selects and deserializes one exact development request from raw JSON source. -/// -/// An absent `phase` selects the one-shot contract. A present phase selects its -/// corresponding state-aware contract, with provision requests additionally -/// selected by their required `containment` declaration. The selected concrete -/// request still requires the exact `1.1.0-alpha` version marker. -/// -/// # Errors -/// -/// Returns [`RequestParseError::Phase`] when the phase declaration is malformed -/// or unsupported, [`RequestParseError::Containment`] when a provision -/// containment declaration is malformed or unsupported, and -/// [`RequestParseError::InvalidRequest`] when the selected concrete contract -/// rejects the document. Returns [`RequestParseError::InvalidCombination`] when -/// individually valid fields violate a request-level invariant. +/// Parse an MXC request using the development contract. pub fn parse_request(json: &str) -> Result { match probe_phase(json)? { None => parse_one_shot(json), + Some(Phase::ProvisionSession) => { + parse_session(json, "WSLC provision-session").map(Request::ProvisionSession) + } Some(Phase::Provision) => parse_provision(json).map(Request::Provision), Some(Phase::Start) => deserialize(json, "start").map(Request::Start), Some(Phase::Exec) => deserialize(json, "exec").map(Request::Exec), Some(Phase::Stop) => deserialize(json, "stop").map(Request::Stop), Some(Phase::Deprovision) => deserialize(json, "deprovision").map(Request::Deprovision), + Some(Phase::DeprovisionSession) => { + parse_session(json, "WSLC deprovision-session").map(Request::DeprovisionSession) + } } } diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/schema.rs b/src/mxc-sdk/src/core/mxc_contract/dev/schema.rs index 8d1e4f51c..9e89f5235 100644 --- a/src/mxc-sdk/src/core/mxc_contract/dev/schema.rs +++ b/src/mxc-sdk/src/core/mxc_contract/dev/schema.rs @@ -8,7 +8,8 @@ use serde_json::{json, Value}; use super::{ DeprovisionRequest, ExecRequest, IsolationSessionProvisionRequest, OneShotRequest, - StartRequest, StopRequest, WindowsSandboxProvisionRequest, WslcProvisionRequest, + StartRequest, StopRequest, WindowsSandboxProvisionRequest, WslcDeprovisionSessionRequest, + WslcProvisionRequest, WslcProvisionSessionRequest, }; fn subschema(generator: &mut SchemaGenerator) -> Value { @@ -50,6 +51,14 @@ fn provision_dispatch(windows_sandbox: Value, isolation_session: Value, wslc: Va ) } +fn session_dispatch(wslc: Value) -> Value { + branch( + discriminator("containment", "wslc"), + wslc, + Value::Bool(false), + ) +} + fn one_shot_dispatch(one_shot: Value) -> Value { json!({ "allOf": [ @@ -98,28 +107,38 @@ fn one_shot_dispatch(one_shot: Value) -> Value { #[allow(clippy::too_many_arguments)] fn phase_dispatch( + provision_session: Value, provision: Value, start: Value, exec: Value, stop: Value, deprovision: Value, + deprovision_session: Value, ) -> Value { branch( - discriminator("phase", "provision"), - provision, + discriminator("phase", "provision-session"), + provision_session, branch( - discriminator("phase", "start"), - start, + discriminator("phase", "provision"), + provision, branch( - discriminator("phase", "exec"), - exec, + discriminator("phase", "start"), + start, branch( - discriminator("phase", "stop"), - stop, + discriminator("phase", "exec"), + exec, branch( - discriminator("phase", "deprovision"), - deprovision, - Value::Bool(false), + discriminator("phase", "stop"), + stop, + branch( + discriminator("phase", "deprovision"), + deprovision, + branch( + discriminator("phase", "deprovision-session"), + deprovision_session, + Value::Bool(false), + ), + ), ), ), ), @@ -127,16 +146,12 @@ fn phase_dispatch( ) } -/// Generates the unrendered JSON Schema for the mutable `1.1.0-alpha` -/// development contract. -/// -/// The document selects one of eight closed request roots through nested -/// `if`/`then` discriminators. This avoids reporting validation failures from -/// unrelated lifecycle phases while retaining a shared definitions table. +/// Generate the development contract's JSON Schema. pub fn development_schema() -> Value { let mut generator = SchemaGenerator::default(); let one_shot = subschema::(&mut generator); + let wslc_session = subschema::(&mut generator); let windows_sandbox = subschema::(&mut generator); let isolation_session = subschema::(&mut generator); let wslc = subschema::(&mut generator); @@ -144,9 +159,18 @@ pub fn development_schema() -> Value { let exec = subschema::(&mut generator); let stop = subschema::(&mut generator); let deprovision = subschema::(&mut generator); + let wslc_release_session = subschema::(&mut generator); let provision = provision_dispatch(windows_sandbox, isolation_session, wslc); - let state_aware = phase_dispatch(provision, start, exec, stop, deprovision); + let state_aware = phase_dispatch( + session_dispatch(wslc_session), + provision, + start, + exec, + stop, + deprovision, + session_dispatch(wslc_release_session), + ); let dispatch = branch( json!({ "required": ["phase"] }), state_aware, @@ -172,6 +196,7 @@ mod tests { const ROOT_NAMES: &[&str] = &[ "OneShotRequest", + "WslcProvisionSessionRequest", "WindowsSandboxProvisionRequest", "IsolationSessionProvisionRequest", "WslcProvisionRequest", @@ -179,6 +204,7 @@ mod tests { "ExecRequest", "StopRequest", "DeprovisionRequest", + "WslcDeprovisionSessionRequest", ]; #[test] @@ -189,6 +215,7 @@ mod tests { regenerated_schema: development_schema(), request_roots: ROOT_NAMES, phase_markers: &[ + ("WslcProvisionSessionRequest", "provision-session"), ("WindowsSandboxProvisionRequest", "provision"), ("IsolationSessionProvisionRequest", "provision"), ("WslcProvisionRequest", "provision"), @@ -196,11 +223,14 @@ mod tests { ("ExecRequest", "exec"), ("StopRequest", "stop"), ("DeprovisionRequest", "deprovision"), + ("WslcDeprovisionSessionRequest", "deprovision-session"), ], containment_markers: &[ + ("WslcProvisionSessionRequest", "wslc"), ("WindowsSandboxProvisionRequest", "windows_sandbox"), ("IsolationSessionProvisionRequest", "isolation_session"), ("WslcProvisionRequest", "wslc"), + ("WslcDeprovisionSessionRequest", "wslc"), ], compatibility_aliases: false, one_shot_required: &["process"], diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/deprovision_session.rs b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/deprovision_session.rs new file mode 100644 index 000000000..c5b78a7fd --- /dev/null +++ b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/deprovision_session.rs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::mxc_contract::dev::state_aware::provision::WslcContainment; +use crate::mxc_contract::dev::{OptionalField, Telemetry, Version}; +use serde::Deserialize; + +string_marker! { + pub struct DeprovisionSessionPhase => "deprovision-session"; +} + +/// Request to release an empty WSLc session. +#[derive(Debug, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WslcDeprovisionSessionRequest { + /// JSON Schema URL for editor validation. + #[serde(rename = "$schema", default)] + pub schema: OptionalField, + + /// Annotation ignored by the runtime. + #[serde(rename = "_comment", default)] + pub comment: OptionalField, + pub version: Version, + pub phase: DeprovisionSessionPhase, + pub containment: WslcContainment, + + /// Session identifier returned by provision-session. + pub session_id: String, + #[serde(default)] + pub telemetry: OptionalField, +} diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/mod.rs b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/mod.rs index b6e56bb63..43cf70ba0 100644 --- a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/mod.rs +++ b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/mod.rs @@ -56,13 +56,16 @@ macro_rules! string_marker { } mod deprovision; +mod deprovision_session; mod exec; mod phase; mod provision; +mod provision_session; mod start; mod stop; pub use deprovision::{DeprovisionPhase, DeprovisionRequest}; +pub use deprovision_session::{DeprovisionSessionPhase, WslcDeprovisionSessionRequest}; pub use exec::{ExecPhase, ExecRequest}; pub use phase::{probe_phase, Phase, PhaseProbeError}; pub use provision::{probe_containment, Containment, ContainmentProbeError}; @@ -74,5 +77,8 @@ pub use provision::{ pub use provision::{ProvisionPhase, ProvisionRequest}; pub use provision::{StateAwareWslc, WslcContainment, WslcProvision, WslcProvisionRequest}; pub use provision::{WindowsSandboxContainment, WindowsSandboxProvisionRequest}; +pub use provision_session::{ + ProvisionSessionPhase, StateAwareWslcSession, WslcProvisionSession, WslcProvisionSessionRequest, +}; pub use start::{StartPhase, StartRequest}; pub use stop::{StopPhase, StopRequest}; diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/phase.rs b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/phase.rs index 439193527..cd6451b1e 100644 --- a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/phase.rs +++ b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/phase.rs @@ -4,6 +4,8 @@ /// Lifecycle phase declared by a state-aware configuration request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Phase { + ProvisionSession, + /// Provision a new sandbox. Provision, /// Start a provisioned sandbox. @@ -14,17 +16,20 @@ pub enum Phase { Stop, /// Release a provisioned sandbox and its resources. Deprovision, + DeprovisionSession, } impl Phase { /// Returns the exact registered spelling of this phase. pub const fn as_str(self) -> &'static str { match self { + Phase::ProvisionSession => "provision-session", Phase::Provision => "provision", Phase::Start => "start", Phase::Exec => "exec", Phase::Stop => "stop", Phase::Deprovision => "deprovision", + Phase::DeprovisionSession => "deprovision-session", } } @@ -33,11 +38,13 @@ impl Phase { /// Returns `None` when `value` is not an exact supported spelling. pub fn parse_exact(value: &str) -> Option { match value { + "provision-session" => Some(Phase::ProvisionSession), "provision" => Some(Phase::Provision), "start" => Some(Phase::Start), "exec" => Some(Phase::Exec), "stop" => Some(Phase::Stop), "deprovision" => Some(Phase::Deprovision), + "deprovision-session" => Some(Phase::DeprovisionSession), _ => None, } } @@ -139,6 +146,50 @@ mod tests { assert_eq!(phase.unwrap().as_str(), "deprovision"); } + #[test] + fn probe_accepts_both_session_phases() { + for (json, expected) in [ + (r#"{"phase": "provision-session"}"#, Phase::ProvisionSession), + ( + r#"{"phase": "deprovision-session"}"#, + Phase::DeprovisionSession, + ), + ] { + assert_eq!(probe_phase(json).unwrap(), Some(expected)); + } + } + + #[test] + fn probe_keeps_the_sandbox_phases_distinct_from_the_session_phases() { + assert_eq!( + probe_phase(r#"{"phase": "provision"}"#).unwrap(), + Some(Phase::Provision) + ); + assert_eq!( + probe_phase(r#"{"phase": "deprovision"}"#).unwrap(), + Some(Phase::Deprovision) + ); + assert!(matches!( + probe_phase(r#"{"phase": "provision-sessions"}"#), + Err(PhaseProbeError::UnsupportedPhase(_)) + )); + } + + #[test] + fn every_phase_round_trips_through_its_registered_spelling() { + for phase in [ + Phase::ProvisionSession, + Phase::Provision, + Phase::Start, + Phase::Exec, + Phase::Stop, + Phase::Deprovision, + Phase::DeprovisionSession, + ] { + assert_eq!(Phase::parse_exact(phase.as_str()), Some(phase)); + } + } + #[test] fn probe_ignores_unrelated_fields() { let json = r#"{ diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision/wslc.rs b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision/wslc.rs index 4ff790199..afc86c7f5 100644 --- a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision/wslc.rs +++ b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision/wslc.rs @@ -23,6 +23,10 @@ pub struct WslcProvision { #[serde(default)] pub image_tar_path: OptionalField, + /// Session to join; omitted to use the shared default VM. + #[serde(default)] + pub session_id: OptionalField, + /// Optional host-to-container TCP port mappings. #[serde(default)] pub port_mappings: OptionalField>, diff --git a/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision_session.rs b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision_session.rs new file mode 100644 index 000000000..4e9ee28fa --- /dev/null +++ b/src/mxc-sdk/src/core/mxc_contract/dev/state_aware/provision_session.rs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::mxc_contract::dev::state_aware::provision::WslcContainment; +use crate::mxc_contract::dev::{OptionalField, Telemetry, Version}; +use serde::Deserialize; + +string_marker! { + pub struct ProvisionSessionPhase => "provision-session"; +} + +/// Utility VM sizing shared by every sandbox in the session. +#[derive(Debug, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WslcProvisionSession { + /// Virtual CPUs available to the session's utility VM. + #[serde(default)] + pub cpu_count: OptionalField, + + /// Utility VM memory ceiling, in megabytes. + #[serde(default)] + pub memory_mb: OptionalField, + + /// Enable GPU passthrough for the session's utility VM. + #[serde(default)] + pub gpu: OptionalField, +} + +/// WSLc settings for provisioning a shared utility VM. +#[derive(Debug, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StateAwareWslcSession { + #[serde(default)] + pub provision_session: OptionalField, +} + +/// Request to create a WSLc session. +#[derive(Debug, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WslcProvisionSessionRequest { + /// JSON Schema URL for editor validation. + #[serde(rename = "$schema", default)] + pub schema: OptionalField, + + /// Annotation ignored by the runtime. + #[serde(rename = "_comment", default)] + pub comment: OptionalField, + pub version: Version, + pub phase: ProvisionSessionPhase, + pub containment: WslcContainment, + #[serde(default)] + pub telemetry: OptionalField, + #[serde(default)] + pub wslc: OptionalField, +} diff --git a/src/mxc-sdk/src/core/mxc_contract/registry.rs b/src/mxc-sdk/src/core/mxc_contract/registry.rs index cf6a5d7de..68e8b9bde 100644 --- a/src/mxc-sdk/src/core/mxc_contract/registry.rs +++ b/src/mxc-sdk/src/core/mxc_contract/registry.rs @@ -142,6 +142,10 @@ const V1_1_ROOTS: &[ContractRequestRoot] = &[ fixture_directory: "one_shot", schema_definition: "OneShotRequest", }, + ContractRequestRoot { + fixture_directory: "wslc_provision_session", + schema_definition: "WslcProvisionSessionRequest", + }, ContractRequestRoot { fixture_directory: "windows_sandbox_provision", schema_definition: "WindowsSandboxProvisionRequest", @@ -170,6 +174,10 @@ const V1_1_ROOTS: &[ContractRequestRoot] = &[ fixture_directory: "deprovision", schema_definition: "DeprovisionRequest", }, + ContractRequestRoot { + fixture_directory: "wslc_deprovision_session", + schema_definition: "WslcDeprovisionSessionRequest", + }, ]; /// Metadata for every configuration contract currently registered by this @@ -287,6 +295,34 @@ mod tests { .any(|root| root.fixture_directory() == "wslc_provision")); } + #[test] + fn only_the_development_contract_advertises_the_session_roots() { + let session_roots = ["wslc_provision_session", "wslc_deprovision_session"]; + let development = descriptor(ContractVersion::V1_1_0Alpha); + + for root in session_roots { + assert!( + development + .request_roots() + .iter() + .any(|candidate| candidate.fixture_directory() == root), + "the development contract must advertise {root}" + ); + } + + for version in [ContractVersion::V0_9_0Alpha, ContractVersion::V1_0_0] { + for root in session_roots { + assert!( + !descriptor(version) + .request_roots() + .iter() + .any(|candidate| candidate.fixture_directory() == root), + "published {version:?} must not advertise {root}" + ); + } + } + } + #[test] fn supported_contracts_advertise_generated_request_roots() { for version in [ diff --git a/src/mxc-sdk/src/core/mxc_engine/state_aware.rs b/src/mxc-sdk/src/core/mxc_engine/state_aware.rs index 39c284d68..ea8945794 100644 --- a/src/mxc-sdk/src/core/mxc_engine/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_engine/state_aware.rs @@ -50,14 +50,8 @@ fn isolation_session_unavailable() -> MxcError { ) } -/// This phase's telemetry correlation vector, purely internal to MXC: no -/// caller ever supplies or relays one. `provision` (whose `sandboxId` doesn't -/// exist yet) mints a fresh vector; every later sandbox phase recalls the same -/// lifecycle root persisted by `provision` and spins a distinct child off it — -/// see [`telemetry::correlation_state`]. Session phases carry no `sandboxId`, -/// so each seeds its own vector. fn phase_correlation(active: bool, phase: Phase, sandbox_id: Option<&str>) -> String { - telemetry::correlation_state::pre_dispatch_vector(active, phase == Phase::Provision, sandbox_id) + telemetry::correlation_state::pre_dispatch_vector(active, phase, sandbox_id) } fn surface_attached_warnings(logger: &mut Logger, mut surface: impl FnMut(&str)) { @@ -468,7 +462,7 @@ fn normalize_sdk_state_aware( ) -> Result { let mut parsed = crate::mxc_common::config_parser::normalize_sdk_state_aware_request(input, logger) - .map_err(|error| Error::from(MxcError::malformed_request(error.to_string())))?; + .map_err(|error| Error::from(error.into_state_aware()))?; parsed.set_experimental_enabled(experimental); Ok(parsed) } @@ -981,6 +975,8 @@ pub fn exec_state_aware_pty_json( #[cfg(test)] mod tests { use super::*; + #[cfg(all(target_os = "windows", feature = "wslc"))] + use crate::mxc_common::models::ExecutionRequest; use crate::mxc_common::mxc_error::MxcErrorCode; use crate::mxc_common::sdk_input::{ SdkFilesystemInput, SdkNetworkAction, SdkNetworkEgressInput, SdkNetworkIngressInput, @@ -1009,6 +1005,7 @@ mod tests { Some(crate::mxc_common::models::WslcProvisionConfig { image, image_tar_path, + session_id: None, port_mappings: None, }) }; @@ -1392,6 +1389,20 @@ mod tests { ); } + #[test] + fn session_phase_correlation_does_not_open_the_sandbox_store() { + let directory = tempfile::tempdir().unwrap(); + let store = directory.path().join("correlation"); + let _guard = StoreDirGuard::set(&store); + for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] { + assert!(telemetry::correlation_vector::is_relayable( + &phase_correlation(true, phase, None,) + )); + assert!(phase_correlation(false, phase, None).is_empty()); + } + assert!(!store.exists()); + } + #[test] fn attached_warning_surface_drains_retained_warnings() { let mut logger = Logger::new(Mode::Buffer); @@ -1634,14 +1645,17 @@ mod tests { ] } + fn parsed_session(operation: StateAwareOperation) -> ParsedStateAwareRequest { + let input = SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation).unwrap(); + normalize_sdk_state_aware(input, false, &mut Logger::new(Mode::Buffer)).unwrap() + } + fn session_refusal_on_each_route( operation: &StateAwareOperation, dry_run: bool, ) -> [(&'static str, Error); 2] { let input = || SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation.clone()); - let parsed = - normalize_sdk_state_aware(input().unwrap(), false, &mut Logger::new(Mode::Buffer)) - .unwrap(); + let parsed = parsed_session(operation.clone()); let envelope = run_state_aware(parsed, dry_run).unwrap_err(); let typed = run_typed_state_aware_request(input().unwrap(), false, dry_run).unwrap_err(); [ @@ -1668,21 +1682,131 @@ mod tests { #[cfg(all(target_os = "windows", feature = "wslc"))] #[test] - fn a_session_phase_reaches_wslc_and_is_refused_as_unsupported_until_it_is_implemented() { + fn a_dry_run_session_phase_validates_without_minting_or_booting_anything() { for operation in session_operations() { + let envelope = run_state_aware(parsed_session(operation.clone()), true).unwrap(); + let DispatchOutcome::Envelope(envelope) = envelope else { + panic!("a dry run must return a result envelope"); + }; + assert_eq!(envelope, serde_json::json!({"result": {}})); + let input = SdkStateAwareInput::new(ContractVersion::V1_1_0Alpha, operation).unwrap(); + let result = run_typed_state_aware_request(input, false, true).unwrap(); + assert_eq!( + result.session_id, None, + "a dry run must not mint a session id" + ); + assert_eq!(result.sandbox_id, None); + } + } + + #[cfg(all(target_os = "windows", feature = "wslc"))] + #[test] + fn invalid_session_sizing_is_refused_before_live_or_dry_run_dispatch() { + for config in [ + crate::mxc_common::models::WslcProvisionSessionConfig { + memory_mb: Some(u64::from(u32::MAX) + 1), + ..Default::default() + }, + crate::mxc_common::models::WslcProvisionSessionConfig { + cpu_count: Some(u32::MAX), + ..Default::default() + }, + ] { for dry_run in [false, true] { + let operation = StateAwareOperation::ProvisionSession(Some(config)); for (route, error) in session_refusal_on_each_route(&operation, dry_run) { assert_eq!( error.code, - crate::ErrorCode::UnsupportedPhase, - "{route} with dry_run={dry_run}: {error:?}" + crate::ErrorCode::PolicyValidation, + "{route}, {config:?}, dry_run={dry_run}: {error:?}" ); - assert!(error.message.contains("wslc"), "{route}: {error:?}"); } } } } + #[cfg(all(target_os = "windows", feature = "wslc"))] + #[test] + fn zero_session_sizing_validates_as_the_sdk_default() { + let parsed = parsed_session(StateAwareOperation::ProvisionSession(Some( + crate::mxc_common::models::WslcProvisionSessionConfig { + cpu_count: Some(0), + memory_mb: Some(0), + gpu: Some(false), + }, + ))); + let result = run_state_aware_typed(parsed, true).unwrap(); + assert!(result.session_id.is_none()); + assert!(result.sandbox_id.is_none()); + } + + #[cfg(all(target_os = "windows", feature = "wslc"))] + #[test] + fn a_malformed_session_id_is_refused_before_the_daemon_is_contacted() { + for session_id in [ + "wslcsess:short", + "wslc:0123456789abcdef0123456789abcdef", + "0123456789abcdef0123456789abcdef", + "wslcsess:0123456789ABCDEF0123456789ABCDEF", + ] { + for dry_run in [false, true] { + let parsed = ParsedStateAwareRequest::new( + ExecutionRequest::default(), + StateAwareOperation::DeprovisionSession { + session_id: session_id.to_string(), + }, + ); + let error = run_state_aware(parsed, dry_run).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::MalformedId, "dry_run={dry_run}"); + } + } + } + + #[cfg(all(target_os = "windows", feature = "wslc"))] + #[test] + fn malformed_session_ids_have_the_same_error_on_join_and_release() { + for session_id in [ + "", + "wslcsess:short", + "wslc:0123456789abcdef0123456789abcdef", + "wslcsess:0123456789ABCDEF0123456789ABCDEF", + ] { + for request in [ + serde_json::json!({ + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "wslc": { "provision": { "sessionId": session_id } } + }), + serde_json::json!({ + "version": "1.1.0-alpha", + "phase": "deprovision-session", + "containment": "wslc", + "sessionId": session_id + }), + ] { + for dry_run in [false, true] { + let error = run_state_aware_json(&request.to_string(), dry_run, false) + .expect_err("a malformed session ID must be refused"); + assert_eq!(error.code, crate::ErrorCode::MalformedId); + } + } + } + } + + #[test] + fn typed_session_release_preserves_the_malformed_id_error() { + let error = SdkStateAwareInput::new( + ContractVersion::V1_1_0Alpha, + StateAwareOperation::DeprovisionSession { + session_id: "wslcsess:short".to_string(), + }, + ) + .unwrap_err() + .into_state_aware(); + assert_eq!(error.code, MxcErrorCode::MalformedId); + } + #[test] fn session_operations_require_the_development_contract() { for operation in session_operations() { @@ -1697,13 +1821,16 @@ mod tests { let minted = typed_dispatch_result( TypedDispatchOutcome::<(), (), (), ()>::ProvisionSession( crate::mxc_common::state_aware_backend::ProvisionSessionResult { - session_id: "wslcsess:abcd".to_string(), + session_id: "wslcsess:0123456789abcdef0123456789abcdef".to_string(), }, ), no_provision_metadata, ) .unwrap(); - assert_eq!(minted.session_id.as_deref(), Some("wslcsess:abcd")); + assert_eq!( + minted.session_id.as_deref(), + Some("wslcsess:0123456789abcdef0123456789abcdef") + ); assert_eq!(minted.sandbox_id, None); assert_eq!(minted.metadata, None); @@ -1720,9 +1847,14 @@ mod tests { #[test] fn the_sdk_facade_cannot_silently_discard_a_minted_session_id() { let result = crate::state_aware_sdk::StateAwareResult::from_engine( - EngineStateAwareResult::provision_session("wslcsess:abcd".to_string()), + EngineStateAwareResult::provision_session( + "wslcsess:0123456789abcdef0123456789abcdef".to_string(), + ), + ); + assert_eq!( + result.session_id.as_deref(), + Some("wslcsess:0123456789abcdef0123456789abcdef") ); - assert_eq!(result.session_id.as_deref(), Some("wslcsess:abcd")); assert!( result.clone().into_lifecycle().is_err(), diff --git a/src/mxc-sdk/src/lib.rs b/src/mxc-sdk/src/lib.rs index cef215ddf..de8436f83 100644 --- a/src/mxc-sdk/src/lib.rs +++ b/src/mxc-sdk/src/lib.rs @@ -415,24 +415,7 @@ pub mod __ffi { wait_with_output(spawn_container_json(request_json, experimental)?) } - /// Run a lifecycle request (as a JSON string) and return the - /// response-envelope JSON string. - /// - /// Handles the envelope phases (`provision`, `start`, `stop`, `deprovision`) - /// and a dry run of any phase. A non-dry-run execution produces no envelope, so - /// it is rejected here; use an execution entry point instead: - /// [`execute_lifecycle_json`] to drive the pipes yourself. - /// - /// The request JSON is the same wire format the executor accepts (an object with - /// a `phase` field). Errors (malformed request, unsupported phase, backend - /// failures) come back as an [`Error`] with the matching [`ErrorCode`]. - /// - /// `experimental` is the in-process equivalent of the executor's - /// `--experimental` flag. It permits selecting an experimental backend, which - /// is otherwise refused with [`ErrorCode::BackendUnavailable`] before any work - /// is done, and is ignored for production backends. - /// It is an API parameter rather than a field in the request JSON so that a - /// config cannot grant itself experimental access. + /// Run an [MXC lifecycle JSON request](https://github.com/microsoft/mxc/blob/main/docs/development/architecture/container-lifecycle.md#raw-json-entry-points) and return its JSON response envelope. pub fn run_lifecycle_json( request_json: &str, dry_run: bool, diff --git a/src/mxc-sdk/src/state_aware_sdk.rs b/src/mxc-sdk/src/state_aware_sdk.rs index 4e0f15e95..4fa151b12 100644 --- a/src/mxc-sdk/src/state_aware_sdk.rs +++ b/src/mxc-sdk/src/state_aware_sdk.rs @@ -55,6 +55,7 @@ impl ProvisionContainment { Some(crate::mxc_common::models::WslcProvisionConfig { image: image.clone(), image_tar_path: image_tar_path.clone(), + session_id: None, port_mappings: None, }) }; diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures.rs b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures.rs index 0530bdecb..f150daab8 100644 --- a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures.rs +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures.rs @@ -3,7 +3,8 @@ use mxc_sdk::mxc_contract::dev::{ parse_request, DeprovisionRequest, ExecRequest, IsolationSessionProvisionRequest, Request, - StartRequest, StopRequest, WindowsSandboxProvisionRequest, WslcProvisionRequest, + StartRequest, StopRequest, WindowsSandboxProvisionRequest, WslcDeprovisionSessionRequest, + WslcProvisionRequest, WslcProvisionSessionRequest, }; const VERSION: &str = "v1_1_0_alpha"; @@ -33,6 +34,7 @@ fn assert_one_shot_fixtures() { #[test] fn accepts_and_rejects_every_discovered_fixture() { assert_one_shot_fixtures(); + assert_root_fixtures::("wslc_provision_session"); assert_root_fixtures::("windows_sandbox_provision"); assert_root_fixtures::("isolation_session_provision"); assert_root_fixtures::("wslc_provision"); @@ -40,4 +42,45 @@ fn accepts_and_rejects_every_discovered_fixture() { assert_root_fixtures::("exec"); assert_root_fixtures::("stop"); assert_root_fixtures::("deprovision"); + assert_root_fixtures::("wslc_deprovision_session"); +} + +#[test] +fn the_request_parser_selects_each_session_root() { + for (root, expect_provision_session) in + [("wslc_provision_session", true), ("wslc_deprovision_session", false)] + { + for (name, json) in read_fixtures(root, "valid") { + let request = parse_request(&json) + .unwrap_or_else(|error| panic!("valid fixture '{root}/valid/{name}': {error}")); + let selected = if expect_provision_session { + matches!(request, Request::ProvisionSession(_)) + } else { + matches!(request, Request::DeprovisionSession(_)) + }; + assert!(selected, "'{root}/valid/{name}' selected {request:?}"); + } + } +} + +#[test] +fn the_request_parser_refuses_a_session_phase_for_a_foreign_containment() { + for phase in ["provision-session", "deprovision-session"] { + for containment in ["isolation_session", "windows_sandbox"] { + let json = format!( + r#"{{"version":"1.1.0-alpha","phase":"{phase}","containment":"{containment}", + "sessionId":"wslcsess:0123456789abcdef0123456789abcdef"}}"# + ); + let error = parse_request(&json) + .err() + .unwrap_or_else(|| panic!("{containment} must not satisfy {phase}")); + assert!( + matches!( + error, + mxc_sdk::mxc_contract::dev::RequestParseError::Containment(_) + ), + "{phase}/{containment} must be refused as an unsupported containment: {error}" + ); + } + } } diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/foreign_sandbox_id.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/foreign_sandbox_id.json new file mode 100644 index 000000000..2a5badd25 --- /dev/null +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/foreign_sandbox_id.json @@ -0,0 +1,6 @@ +{ + "version": "1.1.0-alpha", + "phase": "deprovision-session", + "containment": "wslc", + "sandboxId": "wslc:0123456789abcdef0123456789abcdef" +} diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/missing_session_id.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/missing_session_id.json new file mode 100644 index 000000000..4001e53ff --- /dev/null +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/invalid/missing_session_id.json @@ -0,0 +1,5 @@ +{ + "version": "1.1.0-alpha", + "phase": "deprovision-session", + "containment": "wslc" +} diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/valid/minimal.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/valid/minimal.json new file mode 100644 index 000000000..e83bd4896 --- /dev/null +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_deprovision_session/valid/minimal.json @@ -0,0 +1,6 @@ +{ + "version": "1.1.0-alpha", + "phase": "deprovision-session", + "containment": "wslc", + "sessionId": "wslcsess:0123456789abcdef0123456789abcdef" +} diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision/valid/named_session.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision/valid/named_session.json new file mode 100644 index 000000000..1a9f910da --- /dev/null +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision/valid/named_session.json @@ -0,0 +1,11 @@ +{ + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "wslc": { + "provision": { + "image": "alpine:latest", + "sessionId": "wslcsess:0123456789abcdef0123456789abcdef" + } + } +} diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_sandbox_provision.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_sandbox_provision.json new file mode 100644 index 000000000..49dc19e7c --- /dev/null +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_sandbox_provision.json @@ -0,0 +1,10 @@ +{ + "version": "1.1.0-alpha", + "phase": "provision-session", + "containment": "wslc", + "wslc": { + "provision": { + "image": "alpine:latest" + } + } +} diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_session_id.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_session_id.json new file mode 100644 index 000000000..01052e4ad --- /dev/null +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/invalid/foreign_session_id.json @@ -0,0 +1,6 @@ +{ + "version": "1.1.0-alpha", + "phase": "provision-session", + "containment": "wslc", + "sessionId": "wslcsess:0123456789abcdef0123456789abcdef" +} diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/minimal.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/minimal.json new file mode 100644 index 000000000..32948989e --- /dev/null +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/minimal.json @@ -0,0 +1,5 @@ +{ + "version": "1.1.0-alpha", + "phase": "provision-session", + "containment": "wslc" +} diff --git a/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/sized.json b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/sized.json new file mode 100644 index 000000000..c2c9e8a96 --- /dev/null +++ b/src/mxc-sdk/tests/mxc_contract/v1_1_0_alpha/fixtures/wslc_provision_session/valid/sized.json @@ -0,0 +1,12 @@ +{ + "version": "1.1.0-alpha", + "phase": "provision-session", + "containment": "wslc", + "wslc": { + "provisionSession": { + "cpuCount": 4, + "memoryMb": 8192, + "gpu": true + } + } +} diff --git a/src/tools/wxc/src/main.rs b/src/tools/wxc/src/main.rs index 685738be2..a30ad4fd0 100644 --- a/src/tools/wxc/src/main.rs +++ b/src/tools/wxc/src/main.rs @@ -11,7 +11,7 @@ use std::time::Instant; use clap::{Parser, ValueEnum}; use mxc_sdk::mxc_common::audit::{AuditEvent, AuditEventName, RejectionReason}; -use mxc_sdk::mxc_common::config_parser::{LoadOptions, ParseError, RequestInputError}; +use mxc_sdk::mxc_common::config_parser::{CliTargetId, LoadOptions, ParseError, RequestInputError}; #[cfg(target_os = "windows")] use mxc_sdk::mxc_common::diagnostic::DiagnosticConfig; use mxc_sdk::mxc_common::error::WxcError; @@ -87,6 +87,14 @@ struct Cli { #[arg(long = "container-id", requires = "operation")] container_id: Option, + /// Session ID returned by provision-session, used by deprovision-session. + #[arg( + long = "session-id", + requires = "operation", + conflicts_with = "container_id" + )] + session_id: Option, + /// Path to diagnostic log file (appends, creates if missing) #[arg(long = "log-file")] log_file: Option, @@ -177,7 +185,8 @@ struct Cli { "probe", "force_reclaim", "operation", - "container_id" + "container_id", + "session_id" ] )] #[cfg_attr( @@ -232,21 +241,25 @@ impl Cli { #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] enum CliOperation { + ProvisionSession, Provision, Start, Exec, Stop, Deprovision, + DeprovisionSession, } impl From for mxc_sdk::mxc_common::state_aware_request::Phase { fn from(operation: CliOperation) -> Self { match operation { + CliOperation::ProvisionSession => Self::ProvisionSession, CliOperation::Provision => Self::Provision, CliOperation::Start => Self::Start, CliOperation::Exec => Self::Exec, CliOperation::Stop => Self::Stop, CliOperation::Deprovision => Self::Deprovision, + CliOperation::DeprovisionSession => Self::DeprovisionSession, } } } @@ -533,14 +546,9 @@ fn run_state_aware_main( .as_ref() .and_then(|config| config.requested_sandbox_kind); - // This phase's Microsoft Correlation Vector (MS-CV), purely internal to - // MXC — no caller supplies or relays one. `provision` seeds a fresh - // vector (persisted post-dispatch below, once its `sandbox_id` exists); - // every later phase recalls that persisted lifecycle root and spins a - // child off it. Empty when telemetry is inactive. let correlation = telemetry::correlation_state::pre_dispatch_vector( telemetry_active, - is_provision, + parsed.phase(), sandbox_id.as_deref(), ); @@ -1333,12 +1341,25 @@ fn main() { let config_json = config_json.expect("config_json is Some on non-delete paths"); if let Some(operation) = cli.operation { let phase = operation.into(); + let target_id = cli + .container_id + .as_deref() + .map(|value| CliTargetId { + flag: "--container-id", + value, + }) + .or_else(|| { + cli.session_id.as_deref().map(|value| CliTargetId { + flag: "--session-id", + value, + }) + }); let parsed = match mxc_sdk::mxc_common::config_parser::load_state_aware_request_from_json_with_options( &config_json, &mut logger, phase, - cli.container_id.as_deref(), + target_id, &cli.command, ) { Ok(parsed) => parsed, @@ -1788,11 +1809,13 @@ mod tests { #[test] fn cli_accepts_lifecycle_operation_and_container_id() { for (name, expected) in [ + ("provision-session", CliOperation::ProvisionSession), ("provision", CliOperation::Provision), ("start", CliOperation::Start), ("exec", CliOperation::Exec), ("stop", CliOperation::Stop), ("deprovision", CliOperation::Deprovision), + ("deprovision-session", CliOperation::DeprovisionSession), ] { let cli = parse_cli(&["wxc-exec", "policy.json", "--operation", name]); assert_eq!(cli.operation, Some(expected)); @@ -1855,9 +1878,56 @@ mod tests { let help = Cli::command().render_help().to_string(); assert!(help.contains("--container-id")); + assert!(help.contains("--session-id")); assert!(!help.contains("--sandbox-id")); } + #[test] + fn cli_keeps_the_two_lifecycle_identities_on_separate_flags() { + let cli = parse_cli(&[ + "wxc-exec", + "policy.json", + "--operation", + "deprovision-session", + "--session-id", + "wslcsess:0123456789abcdef0123456789abcdef", + ]); + assert_eq!( + cli.session_id.as_deref(), + Some("wslcsess:0123456789abcdef0123456789abcdef") + ); + assert_eq!(cli.container_id, None); + + let error = match Cli::try_parse_from([ + "wxc-exec", + "policy.json", + "--session-id", + "wslcsess:0123456789abcdef0123456789abcdef", + ]) { + Err(error) => error, + Ok(_) => panic!("--session-id must require --operation"), + }; + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + + let error = match Cli::try_parse_from([ + "wxc-exec", + "policy.json", + "--operation", + "deprovision-session", + "--container-id", + "wslc:0123456789abcdef0123456789abcdef", + "--session-id", + "wslcsess:0123456789abcdef0123456789abcdef", + ]) { + Err(error) => error, + Ok(_) => panic!("--container-id and --session-id must conflict"), + }; + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + #[test] fn cli_rejects_lifecycle_operation_with_utility_modes() { for utility_mode in [ @@ -2097,11 +2167,13 @@ mod tests { logger.enable_file_sink(&log_path).unwrap(); for operation in [ + CliOperation::ProvisionSession, CliOperation::Provision, CliOperation::Start, CliOperation::Exec, CliOperation::Stop, CliOperation::Deprovision, + CliOperation::DeprovisionSession, ] { let error = lifecycle_input_error( operation, @@ -2127,17 +2199,24 @@ mod tests { let log = std::fs::read_to_string(log_path).unwrap(); assert_eq!( log.matches(r#""reason":"malformed_json""#).count(), - 5, + 7, "each lifecycle input failure must emit one ConfigRejected record: {log}" ); - for phase in ["provision", "start", "exec", "stop", "deprovision"] { + for phase in [ + "provision-session", + "provision", + "start", + "exec", + "stop", + "deprovision", + "deprovision-session", + ] { assert_eq!( log.matches(&format!(r#""phase":"{phase}""#)).count(), 1, "missing lifecycle phase {phase}: {log}" ); } - let mut logger = test_logger(); let source_path = directory.path().join("source-audit.log"); logger.enable_file_sink(&source_path).unwrap(); @@ -3095,14 +3174,31 @@ mod tests { #[test] fn pre_dispatch_vector_is_empty_when_telemetry_inactive() { - // Inactive telemetry does no RNG/clock work regardless of phase. - assert!(telemetry::correlation_state::pre_dispatch_vector(false, true, None).is_empty()); - assert!(telemetry::correlation_state::pre_dispatch_vector( - false, - false, - Some("wsb:12345678") - ) - .is_empty()); + for phase in [ + Phase::ProvisionSession, + Phase::Provision, + Phase::Start, + Phase::Exec, + Phase::Stop, + Phase::Deprovision, + Phase::DeprovisionSession, + ] { + assert!( + telemetry::correlation_state::pre_dispatch_vector(false, phase, None).is_empty() + ); + } + } + + #[test] + fn session_phase_correlation_never_creates_the_sandbox_store() { + let directory = tempfile::tempdir().unwrap(); + let store = directory.path().join("correlation"); + let _guard = StoreDirGuard::set(&store); + for phase in [Phase::ProvisionSession, Phase::DeprovisionSession] { + let vector = telemetry::correlation_state::pre_dispatch_vector(true, phase, None); + assert!(telemetry::correlation_vector::is_relayable(&vector)); + } + assert!(!store.exists()); } #[test] @@ -3111,19 +3207,18 @@ mod tests { let _guard = StoreDirGuard::set(tmp.path()); let sandbox_id = "wsb:12345678"; - // Active provision seeds a fresh valid vector. - let provisioned = telemetry::correlation_state::pre_dispatch_vector(true, true, None); + let provisioned = + telemetry::correlation_state::pre_dispatch_vector(true, Phase::Provision, None); assert!(telemetry::correlation_vector::is_relayable(&provisioned)); - // Persisting it (as `run_state_aware_main` does post-dispatch) lets a - // later phase recall it and spin a distinct child off the same base. let outcome: Result = Ok(DispatchOutcome::Envelope( serde_json::json!({ "result": { "sandboxId": sandbox_id } }), )); telemetry::correlation_state::on_provision_outcome(true, &provisioned, &outcome); let base_prefix = provisioned.split('.').next().unwrap(); - let spun = telemetry::correlation_state::pre_dispatch_vector(true, false, Some(sandbox_id)); + let spun = + telemetry::correlation_state::pre_dispatch_vector(true, Phase::Start, Some(sandbox_id)); assert!(spun.starts_with(&format!("{base_prefix}.")), "{spun:?}"); assert_ne!(spun, provisioned); } diff --git a/tests/configs/wslc_state_aware_deprovision_session.json b/tests/configs/wslc_state_aware_deprovision_session.json new file mode 100644 index 000000000..9a00b62fa --- /dev/null +++ b/tests/configs/wslc_state_aware_deprovision_session.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "phase": "deprovision-session", + "containment": "wslc", + "sessionId": "{{SESSION_ID}}" +} diff --git a/tests/configs/wslc_state_aware_provision_in_session.json b/tests/configs/wslc_state_aware_provision_in_session.json new file mode 100644 index 000000000..9959fc568 --- /dev/null +++ b/tests/configs/wslc_state_aware_provision_in_session.json @@ -0,0 +1,15 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "network": { + "egress": { "default": "deny" } + }, + "wslc": { + "provision": { + "image": "alpine:latest", + "sessionId": "{{SESSION_ID}}" + } + } +} diff --git a/tests/configs/wslc_state_aware_provision_session.json b/tests/configs/wslc_state_aware_provision_session.json new file mode 100644 index 000000000..679db0350 --- /dev/null +++ b/tests/configs/wslc_state_aware_provision_session.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "version": "1.1.0-alpha", + "phase": "provision-session", + "containment": "wslc", + "wslc": { + "provisionSession": { + "cpuCount": 1, + "memoryMb": 3072 + } + } +} diff --git a/tests/scripts/README.md b/tests/scripts/README.md index d0c81e400..ff9b19810 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -45,8 +45,8 @@ Linux / macOS (`.sh`): | `run_windows_sandbox_state_aware_tests.ps1` | Windows Sandbox state-aware lifecycle E2E (single VM held across provision/start/exec*/stop/deprovision) | Windows Sandbox enabled | | `run_isolation_session_tests.ps1` | IsolationSession one-shot E2E suite | Interactive local session; OS-side IsolationSession service | | `run_isolation_session_state_aware_tests.ps1` | IsolationSession provision/start/exec/stop/deprovision E2E suite | Interactive local session; OS-side IsolationSession service | -| `run_wslc_all_tests.ps1` | All WSLC create-and-run and container-lifecycle E2E tests; delegates the lifecycle half to `run_wslc_state_aware_tests.ps1` | WSL2, WSLC SDK, staged daemon, and registry access for the image preflight, which warms both the create-and-run store and daemon store slot 0 (`-SkipSetup` skips it and needs both already warm, because the lifecycle fixtures deny egress) | -| `run_wslc_state_aware_tests.ps1` | WSLC provision/start/exec/stop/deprovision lifecycle E2E suite, plus warm reuse, exec concurrency and idle teardown | WSL2, WSLC SDK, `wxc-wslc-daemon.exe` staged beside `wxc-exec.exe`, and `alpine:latest` / `python:3.12-alpine` cached in daemon store slot 0 (warm it with `scripts\setup-wslc.ps1 -DaemonSlots 1`) | +| `run_wslc_all_tests.ps1` | All WSLC create-and-run and container-lifecycle E2E tests; delegates the lifecycle half to `run_wslc_state_aware_tests.ps1` | WSL2, WSLC SDK, staged daemon, and registry access for the image preflight, which warms the create-and-run store and daemon store slots 0 through 2 (`-SkipSetup` needs all stores already warm, because the lifecycle fixtures deny egress) | +| `run_wslc_state_aware_tests.ps1` | WSLC container and session lifecycle E2E suite, including shared sandboxes, identical-session independence, guest CPU/memory sizing, cold-store refusal, warm reuse, exec concurrency and idle teardown; GPU enforcement is not verified | WSL2, WSLC SDK, `wxc-wslc-daemon.exe` staged beside `wxc-exec.exe`, and `alpine:latest` / `python:3.12-alpine` cached in daemon store slots 0 through 2 (warm them with `scripts\setup-wslc.ps1 -DaemonSlots 3`); the fresh cold-store regression requires registry access even with `-SkipSetup` | | `run_processcontainer_all_tests.ps1` | Process container (AppContainer / BaseContainer) primitives suite — tier probes, rw/ro/denied matrix, enumeration-only grants, UI mitigations, DACL restore, crash recovery, directional networking. Dispatches to the per-area `run_processcontainer_*_test.ps1` scripts | `wxc-exec.exe`, `wxc-ui-probe.exe`, `plm.exe` and `winhttp-proxy-shim.exe` beside `wxc-exec.exe` | | `T3-Workloads.ps1` | Real workloads (pwsh, git, node, python, cmd) on top of the T3 primitives. A missing interpreter is reported as a skip, not a failure | `wxc-exec.exe`; `pwsh` / `git` / `node` / `python` each optional, gating their own cases | | `run_telemetry_consent_smoke_test.ps1` | Consent maintenance, presentation, policy, and exit-code smoke tests | Debug `wxc-exec.exe` built with `test-support` | diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1 index 389a8f927..4c3fa8dbc 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -17,10 +17,8 @@ # .\run_wslc_all_tests.ps1 -SkipSetup # skip pre-pull (assume cache is warm) # # Image pre-pull: -# This script invokes scripts\setup-wslc.ps1 as a preflight to populate the -# WSLC image cache. The runner pulls on a cache miss by itself, so this only -# keeps per-test timings clear of one-off download cost. Pass -SkipSetup to -# bypass and let the first test that needs an image pull it. +# Warm the create-and-run store and three daemon stores. The lifecycle +# fixtures deny egress, so -SkipSetup requires all of these stores to be warm. # # Prerequisites for tar import tests: # @@ -75,11 +73,8 @@ if (-not $WxcExec -or -not (Test-Path $WxcExec)) { exit 1 } -# Preflight: warm the WSLC image cache so per-test timings stay clear of one-off -# download cost. The runner pulls on a miss by itself, so -SkipSetup is a -# supported way to exercise that path instead. The lifecycle fixtures provision -# with the isolated posture, which refuses a registry pull, so the daemon's slot -# has to be warmed here too or the delegated suite below starts cold. +# Preflight: warm the create-and-run store and stores for the implicit session +# and two explicit sessions. if (-not $SkipSetup) { $SetupScript = Join-Path $RepoRoot "scripts\setup-wslc.ps1" if (Test-Path $SetupScript) { @@ -94,14 +89,11 @@ if (-not $SkipSetup) { "ghcr.io/linuxserver/baseimage-alpine:3.21", "quay.io/fedora/fedora-minimal:latest" ) - & $SetupScript -WxcExecPath $WxcExec -Image $images -DaemonSlots 1 -Force + & $SetupScript -WxcExecPath $WxcExec -Image $images -DaemonSlots 3 -Force if ($LASTEXITCODE -ne 0) { - # The create-and-run half pulls on a miss, but the delegated - # lifecycle suite provisions with the isolated posture and cannot, - # so a cold slot 0 surfaces much later as unrelated failures. - Write-Host "ERROR: setup-wslc.ps1 failed; daemon store slot 0 may be cold." -ForegroundColor Red + Write-Host "ERROR: setup-wslc.ps1 failed; daemon store slots 0 through 2 may be cold." -ForegroundColor Red Write-Host " The delegated lifecycle suite refuses registry pulls, so it cannot recover." -ForegroundColor Yellow - Write-Host " Resolve the setup failure above, or pass -SkipSetup if both stores are warm." -ForegroundColor Yellow + Write-Host " Resolve the setup failure above, or pass -SkipSetup if all stores are warm." -ForegroundColor Yellow exit 1 } Write-Host "" @@ -477,11 +469,6 @@ $null = $results.Add((Run-WslcTest "wslc_destroy_on_exit_false_rejected.json" ` -OutputContains "destroyOnExit=false")) Write-Host "`n--- State-Aware Lifecycle Tests ---" -ForegroundColor Cyan -# Delegate the multi-invocation provision/start/exec/stop/deprovision lifecycle -# to its owning harness (it needs the daemon binary, mints + threads sandbox ids, -# and drives the idle-teardown watchdog). The preflight above warmed the daemon -# store slot as well, so skip its redundant preflight. Fold its exit code into -# the summary. $saScript = Join-Path $PSScriptRoot "run_wslc_state_aware_tests.ps1" $saArgs = @{ WxcExecPath = $WxcExec; SkipSetup = $true } if ($Debug) { $saArgs.Debug = $true } diff --git a/tests/scripts/run_wslc_state_aware_tests.ps1 b/tests/scripts/run_wslc_state_aware_tests.ps1 index 587fb32fe..be907f6cd 100644 --- a/tests/scripts/run_wslc_state_aware_tests.ps1 +++ b/tests/scripts/run_wslc_state_aware_tests.ps1 @@ -11,7 +11,8 @@ .DESCRIPTION Each test invokes wxc-exec.exe with lifecycle routing in --operation / - --container-id and a base64-encoded phase-specific request payload. + --container-id / --session-id and a base64-encoded phase-specific request + payload. Provision / start / stop / deprovision return a JSON envelope on stdout (asserted on `result` / `error`); a successful exec streams the script's own stdout (relayed from the daemon) and exits with the script's exit code. @@ -22,6 +23,11 @@ and (2) after the last sandbox is deprovisioned the daemon idles out and exits on its own. + Explicit-session tests cover shared sandboxes and independent lifetimes + for identically sized VMs, plus guest CPU and memory for different sizing. + GPU passthrough enforcement is not verified by this suite. + The cold-store regression warms one private store and requires registry access. + Requires: Windows 11, WSL2, the WSLC SDK runtime (wslcsdk.dll staged next to the binaries), registry access or an already-cached alpine:latest, and a wxc-exec.exe + wxc-wslc-daemon.exe @@ -50,7 +56,8 @@ .PARAMETER SkipSetup Skip the image-cache warming preflight. The provision fixtures declare deny - egress, which refuses a registry pull, so the cache has to be warm already. + egress, so daemon store slots 0 through 2 must already contain the images. + The cold-store regression still warms its own isolated store. .EXAMPLE .\run_wslc_state_aware_tests.ps1 @@ -124,23 +131,18 @@ if (-not $ConfigDir) { # ---------------- Image pre-pull preflight ---------------- -# The daemon leases its store from the slot pool, not from -# %TEMP%\mxc-wslc-sessions, and the fixtures below provision with the isolated -# posture, which refuses a registry pull. Slot 0 is the one a daemon holding a -# single session leases, so that is the store these images have to be in. +# Warm stores for the implicit session and two explicit sessions; isolated +# provisions cannot pull images into a cold store. if (-not $SkipSetup) { $SetupScript = Join-Path $RepoRoot "scripts\setup-wslc.ps1" if (Test-Path $SetupScript) { Write-Host "Pre-pulling WSLc images (pass -SkipSetup to skip)..." -ForegroundColor Cyan - & $SetupScript -WxcExecPath $WxcExec -Image @("alpine:latest", "python:3.12-alpine") -DaemonSlots 1 -Force + & $SetupScript -WxcExecPath $WxcExec -Image @("alpine:latest", "python:3.12-alpine") -DaemonSlots 3 -Force if ($LASTEXITCODE -ne 0) { - # Continuing would leave slot 0 cold, and the isolated fixtures - # cannot pull, so every later provision fails for a reason that - # looks nothing like this preflight. - Write-Host "ERROR: setup-wslc.ps1 failed to warm daemon store slot 0." -ForegroundColor Red + Write-Host "ERROR: setup-wslc.ps1 failed to warm daemon store slots 0 through 2." -ForegroundColor Red Write-Host " The lifecycle fixtures provision with the isolated posture, which refuses a" -ForegroundColor Yellow Write-Host " registry pull, so a cold slot fails every provision rather than pulling." -ForegroundColor Yellow - Write-Host " Resolve the setup failure above, or pass -SkipSetup if slot 0 is already warm." -ForegroundColor Yellow + Write-Host " Resolve the setup failure above, or pass -SkipSetup if all three slots are warm." -ForegroundColor Yellow exit 1 } Write-Host "" @@ -156,6 +158,7 @@ function ConvertTo-StateAwareInvocation { [hashtable]$Request, [string]$ConfigFile, [string]$SandboxId, + [string]$SessionId, [int]$WindowsPort ) @@ -173,6 +176,16 @@ function ConvertTo-StateAwareInvocation { $json = $json -replace 'wslc:\{\{SANDBOX_ID\}\}', $SandboxId $json = $json -replace '\{\{SANDBOX_ID\}\}', $SandboxId } + + if ($json -match '\{\{SESSION_ID\}\}') { + if (-not $SessionId) { + throw "Fixture $ConfigFile contains {{SESSION_ID}} but -SessionId was not supplied" + } + if (-not $SessionId.StartsWith('wslcsess:')) { + throw "Fixture $ConfigFile requires a wslcsess: session ID, got '$SessionId'" + } + $json = $json -replace '\{\{SESSION_ID\}\}', $SessionId + } try { $requestObject = $json | ConvertFrom-Json } catch { @@ -213,29 +226,41 @@ function ConvertTo-StateAwareInvocation { } if ($phase -isnot [string]) { throw "State-aware request must contain a string 'phase'" } - $routingSandboxId = $null - if ($phase -ne 'provision') { + $targetMember = switch ($phase) { + 'provision-session' { $null } + 'provision' { $null } + 'deprovision-session' { 'sessionId' } + default { 'sandboxId' } + } + $targetFlag = switch ($targetMember) { + 'sessionId' { '--session-id' } + 'sandboxId' { '--container-id' } + default { $null } + } + + $routingId = $null + if ($targetMember) { if ($requestObject -is [System.Collections.IDictionary]) { - $sandboxIdKey = $requestObject.Keys | - Where-Object { $_ -ceq 'sandboxId' } | + $targetKey = $requestObject.Keys | + Where-Object { $_ -ceq $targetMember } | Select-Object -First 1 - if ($null -eq $sandboxIdKey) { - throw "State-aware '$phase' request must contain a string 'sandboxId'" + if ($null -eq $targetKey) { + throw "State-aware '$phase' request must contain a string '$targetMember'" } - $routingSandboxId = $requestObject[$sandboxIdKey] - $null = $requestObject.Remove($sandboxIdKey) + $routingId = $requestObject[$targetKey] + $null = $requestObject.Remove($targetKey) } else { - $sandboxIdProperty = $requestObject.PSObject.Properties | - Where-Object { $_.Name -ceq 'sandboxId' } | + $targetProperty = $requestObject.PSObject.Properties | + Where-Object { $_.Name -ceq $targetMember } | Select-Object -First 1 - if ($null -eq $sandboxIdProperty) { - throw "State-aware '$phase' request must contain a string 'sandboxId'" + if ($null -eq $targetProperty) { + throw "State-aware '$phase' request must contain a string '$targetMember'" } - $routingSandboxId = $sandboxIdProperty.Value - $requestObject.PSObject.Properties.Remove($sandboxIdProperty.Name) + $routingId = $targetProperty.Value + $requestObject.PSObject.Properties.Remove($targetProperty.Name) } - if ($routingSandboxId -isnot [string]) { - throw "State-aware '$phase' request must contain a string 'sandboxId'" + if ($routingId -isnot [string]) { + throw "State-aware '$phase' request must contain a string '$targetMember'" } } @@ -243,7 +268,9 @@ function ConvertTo-StateAwareInvocation { $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) @{ Operation = [string]$phase - SandboxId = $routingSandboxId + TargetFlag = $targetFlag + TargetId = $routingId + SandboxId = if ($targetMember -eq 'sandboxId') { $routingId } else { $null } ConfigBase64 = $b64 } } @@ -265,16 +292,18 @@ function Start-StateAware { [hashtable]$Request, [string]$ConfigFile, [string]$SandboxId, + [string]$SessionId, [int]$WindowsPort, [switch]$DryRun ) $invocation = ConvertTo-StateAwareInvocation ` - -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId -WindowsPort $WindowsPort + -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId ` + -SessionId $SessionId -WindowsPort $WindowsPort $argList = @('--operation', $invocation.Operation) - if ($invocation.Operation -ne 'provision') { - $argList += @('--container-id', $invocation.SandboxId) + if ($invocation.TargetFlag) { + $argList += @($invocation.TargetFlag, $invocation.TargetId) } if ($DryRun) { $argList += '--dry-run' } if ($Debug) { $argList += '--debug' } @@ -331,15 +360,17 @@ function Start-StateAwareLines { [hashtable]$Request, [string]$ConfigFile, [string]$SandboxId, + [string]$SessionId, [int]$WindowsPort ) $invocation = ConvertTo-StateAwareInvocation ` - -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId -WindowsPort $WindowsPort + -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId ` + -SessionId $SessionId -WindowsPort $WindowsPort $argList = @('--operation', $invocation.Operation) - if ($invocation.Operation -ne 'provision') { - $argList += @('--container-id', $invocation.SandboxId) + if ($invocation.TargetFlag) { + $argList += @($invocation.TargetFlag, $invocation.TargetId) } if ($Debug) { $argList += '--debug' } $argList += @('--config-base64', $invocation.ConfigBase64) @@ -433,13 +464,14 @@ function Invoke-StateAware { [hashtable]$Request, [string]$ConfigFile, [string]$SandboxId, + [string]$SessionId, [int]$WindowsPort, [switch]$DryRun ) Wait-StateAware (Start-StateAware ` -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId ` - -WindowsPort $WindowsPort -DryRun:$DryRun) + -SessionId $SessionId -WindowsPort $WindowsPort -DryRun:$DryRun) } # Like Invoke-StateAware but records the wall-clock arrival time of each stdout @@ -457,8 +489,8 @@ function Invoke-StateAwareStreaming { -Request $Request -ConfigFile $ConfigFile -SandboxId $SandboxId $argList = @('--operation', $invocation.Operation) - if ($invocation.Operation -ne 'provision') { - $argList += @('--container-id', $invocation.SandboxId) + if ($invocation.TargetFlag) { + $argList += @($invocation.TargetFlag, $invocation.TargetId) } if ($Debug) { $argList += '--debug' } $argList += @('--config-base64', $invocation.ConfigBase64) @@ -664,6 +696,72 @@ function Assert-ResultEnvelope { return $envObj } +function Assert-ErrorEnvelope { + param($Result, [string]$ExpectedCode, [string]$What) + $envObj = Parse-Envelope -Stdout $Result.Stdout + $isExpected = (Envelope-Arm $envObj) -eq 'error' -and + $envObj.error.code -eq $ExpectedCode -and $Result.ExitCode -ne 0 + if (-not $isExpected) { + Write-Host " Stdout: $($Result.Stdout)" -ForegroundColor Gray + Write-Host " Stderr: $($Result.Stderr)" -ForegroundColor Gray + } + Assert-True $isExpected "$What returns $ExpectedCode and exits non-zero" + return $envObj +} + +function Get-SandboxBootId { + param([string]$SandboxId, [string]$What) + + # The Linux boot ID identifies the VM, not an individual container. + $r = Invoke-StateAware -Request @{ + version = '1.1.0-alpha' + phase = 'exec' + sandboxId = $SandboxId + process = @{ commandLine = 'cat /proc/sys/kernel/random/boot_id'; timeout = 30000 } + } + $bootId = $r.Stdout.Trim() + $valid = $r.ExitCode -eq 0 -and + $bootId -cmatch '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$' + if (-not $valid) { + Write-Host " Stdout: $($r.Stdout)" -ForegroundColor Gray + Write-Host " Stderr: $($r.Stderr)" -ForegroundColor Gray + } + Assert-True $valid "$What reads a valid VM boot ID" + if ($valid) { return $bootId } + return $null +} + +function Assert-SandboxSizing { + param([string]$SandboxId, [int]$CpuCount, [int]$MemoryMb, [string]$What) + + $r = Invoke-StateAware -Request @{ + version = '1.1.0-alpha' + phase = 'exec' + sandboxId = $SandboxId + process = @{ + commandLine = 'nproc; awk ''/^MemTotal:/ { print $2 }'' /proc/meminfo' + timeout = 30000 + } + } + $values = $r.Stdout.Trim() -split '\s+' + $valid = $r.ExitCode -eq 0 -and $values.Count -eq 2 -and + $values[0] -match '^\d+$' -and $values[1] -match '^\d+$' + Assert-True $valid "$What reports guest CPU count and memory" + if (-not $valid) { + Write-Host " Stdout: $($r.Stdout)" -ForegroundColor Gray + Write-Host " Stderr: $($r.Stderr)" -ForegroundColor Gray + return + } + Assert-True ([int]$values[0] -eq $CpuCount) ` + "$What has $CpuCount CPUs (reported $($values[0]))" + $expectedKiB = [long]$MemoryMb * 1024 + + # Allow up to 10% for guest-kernel reservations excluded from MemTotal. + $minimumKiB = [math]::Ceiling($expectedKiB * 0.9) + Assert-True ([long]$values[1] -ge $minimumKiB -and [long]$values[1] -le $expectedKiB) ` + "$What guest memory is within 10% below $MemoryMb MB (reported $($values[1]) KiB)" +} + # Provision a throwaway sandbox and return its sandbox_id (or $null). function Provision-Sandbox { param([string]$ConfigFile = 'wslc_state_aware_provision.json') @@ -1870,6 +1968,381 @@ try { } } +# ---------------- Lifecycle K: explicit sessions ---------------- + +$sharedSandboxes = @( + @{ Name = 'A1'; Id = $null; Started = $false; Released = $false; BootId = $null }, + @{ Name = 'A2'; Id = $null; Started = $false; Released = $false; BootId = $null } +) +$independentSandbox = @{ + Name = 'B'; Id = $null; Started = $false; Released = $false; BootId = $null +} +$resizedSandbox = @{ + Name = 'C'; Id = $null; Started = $false; Released = $false +} +$resizedSession = @{ + Name = 'C'; Id = $null; Released = $false; Sandboxes = @($resizedSandbox) +} +$explicitSessions = @( + @{ Name = 'A'; Id = $null; Released = $false; Sandboxes = $sharedSandboxes }, + @{ Name = 'B'; Id = $null; Released = $false; Sandboxes = @($independentSandbox) }, + $resizedSession +) +$sharedSession = $explicitSessions[0] +$independentSession = $explicitSessions[1] +$sessionFixture = Get-Content (Join-Path $ConfigDir 'wslc_state_aware_provision_session.json') -Raw | + ConvertFrom-Json +$sessionSizing = $sessionFixture.wslc.provisionSession +try { + Run-StateAwareTest "K: dry-run provision-session mints nothing" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_session.json' -DryRun + $envObj = Assert-ResultEnvelope $r "dry-run provision-session" + if ($envObj) { + Assert-True ($null -eq $envObj.result.sessionId) ` + "a dry run must not mint a session id" + } + } | Out-Null + + foreach ($session in @($sharedSession, $independentSession)) { + $provisioned = Run-StateAwareTest "K: provision-session $($session.Name) with identical sizing" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_session.json' + $envObj = Assert-ResultEnvelope $r "provision-session $($session.Name)" + if ($envObj) { + $session.Id = [string]$envObj.result.sessionId + Assert-True ($session.Id -cmatch '^wslcsess:[0-9a-f]{32}$') ` + "session_id matches wslcsess:<32 lowercase hex>" + Assert-True ($null -eq $envObj.result.sandboxId) ` + "provision-session returns no sandbox id" + } + } + if (-not $provisioned) { continue } + + foreach ($sandbox in $session.Sandboxes) { + Run-StateAwareTest "K: provision and start sandbox $($sandbox.Name) in session $($session.Name)" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_in_session.json' ` + -SessionId $session.Id + $envObj = Assert-ResultEnvelope $r "provision sandbox $($sandbox.Name)" + if ($null -eq $envObj) { return } + $sandbox.Id = [string]$envObj.result.sandboxId + Assert-True ($sandbox.Id -cmatch '^wslc:[0-9a-f]{32}$') ` + "sandbox_id matches wslc:<32 lowercase hex>" + + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' ` + -SandboxId $sandbox.Id + $envObj = Assert-ResultEnvelope $r "start sandbox $($sandbox.Name)" + $sandbox.Started = $null -ne $envObj -and $r.ExitCode -eq 0 + if ($sandbox.Started) { + $sandbox.BootId = Get-SandboxBootId -SandboxId $sandbox.Id -What $sandbox.Name + } + } | Out-Null + } + } + + Run-StateAwareTest "K: two sandboxes share the named session's VM" { + Assert-True ($sharedSandboxes[0].Started -and $sharedSandboxes[1].Started) ` + "both sandboxes are running at the same time" + Assert-True (-not [string]::IsNullOrEmpty($sharedSandboxes[0].BootId) -and + $sharedSandboxes[0].BootId -ceq $sharedSandboxes[1].BootId) ` + "two sandboxes in the same session have the same VM boot ID" + foreach ($sandbox in $sharedSandboxes) { + if ($sandbox.Started) { + Assert-SandboxSizing -SandboxId $sandbox.Id -What $sandbox.Name ` + -CpuCount $sessionSizing.cpuCount -MemoryMb $sessionSizing.memoryMb + } + } + } | Out-Null + + Run-StateAwareTest "K: identical sizing creates two distinct sessions and VMs" { + Assert-True (-not [string]::IsNullOrEmpty($sharedSession.Id) -and + -not [string]::IsNullOrEmpty($independentSession.Id) -and + $sharedSession.Id -cne $independentSession.Id) ` + "two sessions with identical settings have different IDs" + Assert-True ($sharedSandboxes[0].Started -and $independentSandbox.Started) ` + "sandboxes in both sessions are live simultaneously" + Assert-True (-not [string]::IsNullOrEmpty($sharedSandboxes[0].BootId) -and + -not [string]::IsNullOrEmpty($independentSandbox.BootId) -and + $sharedSandboxes[0].BootId -cne $independentSandbox.BootId) ` + "identically sized sessions have different VM boot IDs" + } | Out-Null + + foreach ($sandbox in $sharedSandboxes) { + Run-StateAwareTest "K: session release is refused while sandbox $($sandbox.Name) remains" { + Assert-True $sandbox.Started "sandbox $($sandbox.Name) was started" + if (-not $sandbox.Started) { return } + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' ` + -SessionId $sharedSession.Id + $null = Assert-ErrorEnvelope $r 'policy_validation' "release of a held session" + $bootId = Get-SandboxBootId -SandboxId $sandbox.Id -What $sandbox.Name + Assert-True (-not [string]::IsNullOrEmpty($bootId) -and + $bootId -ceq $sandbox.BootId) "the refused release leaves the sandbox usable" + } | Out-Null + + Run-StateAwareTest "K: stop and deprovision shared sandbox $($sandbox.Name)" { + Assert-True (-not [string]::IsNullOrEmpty($sandbox.Id)) ` + "sandbox $($sandbox.Name) was provisioned" + if (-not $sandbox.Id) { return } + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' -SandboxId $sandbox.Id + $null = Assert-ResultEnvelope $r "stop sandbox $($sandbox.Name)" + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' ` + -SandboxId $sandbox.Id + $envObj = Assert-ResultEnvelope $r "deprovision sandbox $($sandbox.Name)" + $sandbox.Released = $null -ne $envObj -and $r.ExitCode -eq 0 + } | Out-Null + } + + Run-StateAwareTest "K: deprovision-session once both shared sandboxes are gone" { + Assert-True ($sharedSandboxes[0].Released -and $sharedSandboxes[1].Released) ` + "both sandboxes were deprovisioned before their session" + if (-not $sharedSession.Id) { return } + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' ` + -SessionId $sharedSession.Id + $envObj = Assert-ResultEnvelope $r "release session A" + $sharedSession.Released = $null -ne $envObj -and $r.ExitCode -eq 0 + } | Out-Null + + Run-StateAwareTest "K: the identically sized session survives the other's release" { + Assert-True ($sharedSession.Released -and $independentSandbox.Started) ` + "session A is gone while session B still has a running sandbox" + if (-not $sharedSession.Released -or -not $independentSandbox.Started) { return } + $bootId = Get-SandboxBootId -SandboxId $independentSandbox.Id -What 'surviving session B' + Assert-True (-not [string]::IsNullOrEmpty($bootId) -and + $bootId -ceq $independentSandbox.BootId) ` + "releasing session A leaves session B's original VM usable" + } | Out-Null + + Run-StateAwareTest "K: different CPU and memory sizing reaches each session's guest" { + Assert-True ($sharedSession.Released -and $independentSandbox.Started) ` + "session A returned its warm store while session B remains live" + if (-not $sharedSession.Released -or -not $independentSandbox.Started) { return } + Assert-SandboxSizing -SandboxId $independentSandbox.Id -What 'session B' ` + -CpuCount $sessionSizing.cpuCount -MemoryMb $sessionSizing.memoryMb + + $r = Invoke-StateAware -Request @{ + version = '1.1.0-alpha' + phase = 'provision-session' + containment = 'wslc' + wslc = @{ provisionSession = @{ cpuCount = 2; memoryMb = 4096 } } + } + $envObj = Assert-ResultEnvelope $r "provision differently sized session C" + if ($null -eq $envObj) { return } + $resizedSession.Id = [string]$envObj.result.sessionId + + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_in_session.json' ` + -SessionId $resizedSession.Id + $envObj = Assert-ResultEnvelope $r "provision sandbox C" + if ($null -eq $envObj) { return } + $resizedSandbox.Id = [string]$envObj.result.sandboxId + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' ` + -SandboxId $resizedSandbox.Id + $envObj = Assert-ResultEnvelope $r "start sandbox C" + $resizedSandbox.Started = $null -ne $envObj -and $r.ExitCode -eq 0 + if (-not $resizedSandbox.Started) { return } + Assert-SandboxSizing -SandboxId $resizedSandbox.Id -What 'session C' ` + -CpuCount 2 -MemoryMb 4096 + + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' ` + -SandboxId $resizedSandbox.Id + $null = Assert-ResultEnvelope $r "stop sandbox C" + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' ` + -SandboxId $resizedSandbox.Id + $envObj = Assert-ResultEnvelope $r "deprovision sandbox C" + $resizedSandbox.Released = $null -ne $envObj -and $r.ExitCode -eq 0 + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' ` + -SessionId $resizedSession.Id + $envObj = Assert-ResultEnvelope $r "release session C" + $resizedSession.Released = $null -ne $envObj -and $r.ExitCode -eq 0 + } | Out-Null + + Run-StateAwareTest "K: a released session is no longer addressable" { + Assert-True $sharedSession.Released "session A was released" + if (-not $sharedSession.Released) { return } + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' ` + -SessionId $sharedSession.Id + $null = Assert-ErrorEnvelope $r 'not_provisioned' "release of an already released session" + } | Out-Null + + Run-StateAwareTest "K: stop and deprovision the independent session's sandbox" { + Assert-True (-not [string]::IsNullOrEmpty($independentSandbox.Id)) ` + "session B's sandbox was provisioned" + if (-not $independentSandbox.Id) { return } + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_stop.json' ` + -SandboxId $independentSandbox.Id + $null = Assert-ResultEnvelope $r "stop session B's sandbox" + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' ` + -SandboxId $independentSandbox.Id + $envObj = Assert-ResultEnvelope $r "deprovision session B's sandbox" + $independentSandbox.Released = $null -ne $envObj -and $r.ExitCode -eq 0 + } | Out-Null + + Run-StateAwareTest "K: deprovision the independent session" { + Assert-True $independentSandbox.Released "session B's sandbox was deprovisioned" + if (-not $independentSession.Id) { return } + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' ` + -SessionId $independentSession.Id + $envObj = Assert-ResultEnvelope $r "release session B" + $independentSession.Released = $null -ne $envObj -and $r.ExitCode -eq 0 + } | Out-Null + + Run-StateAwareTest "K: provision naming an unknown session is refused" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_in_session.json' ` + -SessionId 'wslcsess:00000000000000000000000000000000' + $envObj = Assert-ErrorEnvelope $r 'not_provisioned' "provision in an unknown session" + Assert-True ($null -eq $envObj.result.sandboxId) ` + "a refused provision mints no sandbox" + } | Out-Null + + Run-StateAwareTest "K: malformed placement and release IDs return malformed_id" { + foreach ($dryRun in @($false, $true)) { + foreach ($phase in @('provision', 'deprovision-session')) { + $req = @{ + version = '1.1.0-alpha' + phase = $phase + containment = 'wslc' + } + if ($phase -eq 'provision') { + $req.wslc = @{ provision = @{ sessionId = 'wslcsess:not-hex' } } + } else { + $req.sessionId = 'wslcsess:not-hex' + } + $r = Invoke-StateAware -Request $req -DryRun:$dryRun + $null = Assert-ErrorEnvelope $r 'malformed_id' "$phase with a malformed session id" + } + } + } | Out-Null +} finally { + foreach ($session in $explicitSessions) { + foreach ($sandbox in $session.Sandboxes) { + if (-not $sandbox.Id -or $sandbox.Released) { continue } + Run-StateAwareTest "K: cleanup sandbox $($sandbox.Name)" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' ` + -SandboxId $sandbox.Id + $envObj = Parse-Envelope -Stdout $r.Stdout + if ((Envelope-Arm $envObj) -eq 'error' -and + $envObj.error.code -eq 'not_provisioned') { + $sandbox.Released = $true + } else { + $envObj = Assert-ResultEnvelope $r "cleanup sandbox $($sandbox.Name)" + $sandbox.Released = $null -ne $envObj -and $r.ExitCode -eq 0 + } + } | Out-Null + } + } + foreach ($session in $explicitSessions) { + if (-not $session.Id -or $session.Released) { continue } + Run-StateAwareTest "K: cleanup session $($session.Name)" { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' ` + -SessionId $session.Id + $envObj = Parse-Envelope -Stdout $r.Stdout + if ((Envelope-Arm $envObj) -eq 'error' -and + $envObj.error.code -eq 'not_provisioned') { + $session.Released = $true + } else { + $envObj = Assert-ResultEnvelope $r "cleanup session $($session.Name)" + $session.Released = $null -ne $envObj -and $r.ExitCode -eq 0 + } + } | Out-Null + } +} + +# ---------------- Lifecycle L: one warmed slot ---------------- + +Run-StateAwareTest "L: isolated provision names the cold store when the only warm slot is held" { + $root = Join-Path ([IO.Path]::GetTempPath()) ("mxc-wslc-cold-" + [guid]::NewGuid().ToString('N')) + $root = [IO.Path]::GetFullPath($root) + $oldLocalAppData = $env:LOCALAPPDATA + $oldStateRoot = $env:MXC_WSLC_STATE_ROOT + $sessionIds = [System.Collections.Generic.List[string]]::new() + $warmSandboxId = $null + $coldSandboxId = $null + $daemonProcess = $null + $null = New-Item -ItemType Directory -Path $root + try { + + # Keep this test's stores and daemon discovery separate from normal runs. + $env:LOCALAPPDATA = $root + $env:MXC_WSLC_STATE_ROOT = Join-Path $root 'records' + & (Join-Path $RepoRoot 'scripts\setup-wslc.ps1') -WxcExecPath $WxcExec ` + -StoragePath (Join-Path $root 'create-and-run') -Image @('alpine:latest') ` + -DaemonSlots 1 -Force + $setupExit = $LASTEXITCODE + Assert-True ($setupExit -eq 0) "the private pool was warmed with -DaemonSlots 1" + if ($setupExit -ne 0) { return } + + $coldStore = Join-Path $root 'mxc\wslc-store-slots\slot-1' + Assert-True (-not (Test-Path $coldStore)) "setup left the second slot cold" + + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_session.json' + $record = Get-Content (Join-Path $env:MXC_WSLC_STATE_ROOT 'daemon.json') -Raw | ConvertFrom-Json + $daemonProcess = [System.Diagnostics.Process]::GetProcessById([int]$record.pid) + $envObj = Assert-ResultEnvelope $r "create the warm session" + if ($null -eq $envObj) { return } + $warmSessionId = [string]$envObj.result.sessionId + $sessionIds.Add($warmSessionId) + + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_in_session.json' ` + -SessionId $warmSessionId + $envObj = Assert-ResultEnvelope $r "hold the only warm slot with a sandbox" + if ($null -eq $envObj) { return } + $warmSandboxId = [string]$envObj.result.sandboxId + + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_session.json' + $envObj = Assert-ResultEnvelope $r "create a session on the second slot" + if ($null -eq $envObj) { return } + $coldSessionId = [string]$envObj.result.sessionId + $sessionIds.Add($coldSessionId) + + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision_in_session.json' ` + -SessionId $coldSessionId + $envObj = Assert-ErrorEnvelope $r 'policy_validation' "isolated provision on the cold store" + $coldSandboxId = [string]$envObj.result.sandboxId + $message = [string]$envObj.error.message + Assert-True ($message.Contains('daemon image store slot 1')) ` + "the refusal names the selected slot" + Assert-True ($message.Contains($coldStore)) ` + "the refusal names the actual private store path" + Assert-True ($message.Contains("cold for image 'alpine:latest'")) ` + "the refusal identifies the cold cache rather than a missing registry image" + Assert-True ($message.Contains('No container was created')) ` + "the cold-store refusal happens before container creation" + Assert-True ($null -eq $envObj.result.sandboxId) "the refused provision returns no sandbox" + + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $warmSandboxId + $null = Assert-ResultEnvelope $r "start the original warm sandbox after the refusal" + $null = Get-SandboxBootId -SandboxId $warmSandboxId -What 'the original warm sandbox' + } finally { + try { + foreach ($sandboxId in @($warmSandboxId, $coldSandboxId)) { + if (-not $sandboxId) { continue } + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision.json' ` + -SandboxId $sandboxId + $null = Assert-ResultEnvelope $r "cleanup a private sandbox" + } + foreach ($sessionId in $sessionIds) { + $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_deprovision_session.json' ` + -SessionId $sessionId + $envObj = Parse-Envelope -Stdout $r.Stdout + if ((Envelope-Arm $envObj) -ne 'error' -or + $envObj.error.code -ne 'not_provisioned') { + $null = Assert-ResultEnvelope $r "cleanup a private session" + } + } + } finally { + try { + if ($null -ne $daemonProcess -and -not $daemonProcess.WaitForExit(60000)) { + Assert-True $false "the private test daemon did not exit after cleanup" + Stop-Process -Id $daemonProcess.Id -Force + $daemonProcess.WaitForExit() + } + } finally { + if ($null -ne $daemonProcess) { $daemonProcess.Dispose() } + $env:LOCALAPPDATA = $oldLocalAppData + $env:MXC_WSLC_STATE_ROOT = $oldStateRoot + } + } + Remove-Item -LiteralPath $root -Recurse -Force + } +} | Out-Null + # ---------------- Lifecycle S: store isolation from create-and-run ---------------- # A store's VHDX admits one live session, and the daemon used to default to the