Public dry run dryrun-20b59dc46b72

Source commit: 20b59dc46b72103c2f8a516c692b5cc3d54fab19

Public tree identity: sha256:aaa5ac7b58b2a0d23c6b11e5f76324bf3839ca1f62653a83deb736932adcfbd9
This commit is contained in:
Disasmer release dry run 2026-07-14 10:08:15 +02:00
commit 2bef715211
221 changed files with 79792 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/target/
/.disasmer/
**/.disasmer/
/vscode-extension/node_modules/
/private/*/Cargo.lock
/private/*/target/
/scripts/containers-home/

3045
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

44
Cargo.toml Normal file
View file

@ -0,0 +1,44 @@
[workspace]
resolver = "2"
members = [
"crates/disasmer-cli",
"crates/disasmer-control",
"crates/disasmer-coordinator",
"crates/disasmer-core",
"crates/disasmer-dap",
"crates/disasmer-macros",
"crates/disasmer-node",
"crates/disasmer-sdk",
"crates/disasmer-wasm-runtime",
"examples/launch-build-demo",
]
[workspace.package]
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://git.michelpaulissen.com/michel/disasmer"
[workspace.dependencies]
anyhow = "1.0"
base64 = "0.22"
clap = { version = "4.5", features = ["derive"] }
ed25519-dalek = "2"
futures-executor = "0.3"
getrandom = "0.3"
hex = "0.4"
libc = "0.2"
proc-macro2 = "1.0"
postgres = { version = "0.19", features = ["with-serde_json-1"] }
quinn = { version = "0.11.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] }
quote = "1.0"
rcgen = "0.14"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
syn = { version = "2.0", features = ["full"] }
tempfile = "3.10"
thiserror = "1.0"
ureq = { version = "2.12", default-features = false, features = ["tls"] }
tokio = { version = "1.52", features = ["io-util", "macros", "rt-multi-thread"] }
wasmtime = { version = "=43.0.2", default-features = false, features = ["async", "cranelift", "debug", "runtime", "std", "wat"] }
wasmparser = "0.245.1"

21
DISASMER_PUBLIC_TREE.json Normal file
View file

@ -0,0 +1,21 @@
{
"kind": "disasmer-filtered-public-tree",
"source_commit": "20b59dc46b72103c2f8a516c692b5cc3d54fab19",
"release_name": "dryrun-20b59dc46b72",
"filtered_out": [
"private/**",
"experiments/**",
".git",
"target",
"git-ignored source paths",
"root/*.md except README.md and SECURITY.md",
"**/.disasmer/**",
".forgejo/**"
],
"public_export": {
"host_neutral": true,
"include_forgejo_workflows": false
},
"forgejo_host": "git.michelpaulissen.com",
"default_hosted_coordinator_endpoint": "https://disasmer.michelpaulissen.com"
}

175
LICENSE-APACHE Normal file
View file

@ -0,0 +1,175 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

21
LICENSE-MIT Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Disasmer contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

421
README.md Normal file
View file

@ -0,0 +1,421 @@
# Disasmer
Disasmer is a distributed Wasm runtime for source-debuggable, local-first execution. The flagship workflow is a build written as Rust source code: one virtual process, many virtual threads/tasks, ordinary debugger controls, attached user nodes, and explicit artifact handling.
The MVP shape is:
```text
examples/launch-build-demo/envs/linux/Containerfile
examples/launch-build-demo/src/build.rs
```
```rust
use disasmer::env;
let linux = env!("linux");
```
`envs/<name>/Containerfile` or `envs/<name>/Dockerfile` defines an environment named `<name>`. Source code references that environment by logical name with `env!("name")`, not by runner label or machine name.
Bundle metadata is inspectable before launch:
```bash
disasmer bundle inspect --project examples/launch-build-demo
```
The inspection output includes discovered environments, selected input digests, default source-provider choices, and the bundle identity. Container images are not embedded by default.
The development example lives in `examples/launch-build-demo`. Its coordinator
main prepares a node-local source snapshot, compiles a real static executable
with `cc` in the declared rootless-Podman environment, flushes the output file,
and passes its artifact handle to a downstream deterministic packaging task.
Canonical public documentation covers the [architecture](docs/architecture.md),
[security model](docs/security.md), [task ABI](docs/task-abi.md),
[artifact semantics](docs/artifacts.md), [debugging](docs/debugging.md), and
[self-hosting](docs/self-hosting.md).
## Quickstart
Prerequisites for the local MVP path are a Rust toolchain, Node.js for smoke
scripts, rootless Podman for Linux environment materialization, and VS Code when
debugging through the extension.
Build the public workspace and install the local command binaries:
```bash
cargo build --workspace
cargo install --path crates/disasmer-cli --bin disasmer
cargo install --path crates/disasmer-node --bin disasmer-node
cargo install --path crates/disasmer-coordinator --bin disasmer-coordinator
cargo install --path crates/disasmer-dap --bin disasmer-debug-dap
```
Install or run the VS Code extension from this checkout:
```bash
code --extensionDevelopmentPath "$(pwd)/vscode-extension" examples/launch-build-demo
```
For a persistent local install, copy or symlink `vscode-extension` into the VS
Code user extension directory under a versioned folder such as
`disasmer.disasmer-vscode-0.1.0`, then restart VS Code.
Inspect and run the flagship project:
```bash
disasmer bundle inspect --project examples/launch-build-demo
disasmer run --local --project examples/launch-build-demo build
```
For explicit process-boundary inspection, run the local services yourself:
```bash
disasmer-coordinator --listen 127.0.0.1:7999 --allow-local-trusted-loopback
disasmer node attach --coordinator 127.0.0.1:7999
disasmer run --local --coordinator 127.0.0.1:7999 --project examples/launch-build-demo build
```
`disasmer run --local` starts a loopback coordinator and user-attached local
node for the run when no coordinator address is supplied. `disasmer run [entry]`
selects an entrypoint such as `build`; `--project` overrides the project
directory. When the CLI has a hosted login and no local override is selected,
`disasmer run` uses the hosted coordinator. Use `--local` or
`--coordinator <host:port>` to force local coordinator mode. The
`--allow-local-trusted-loopback` switch is an explicit single-machine development
compatibility mode: it is rejected on non-loopback listeners and must not be
used for a shared or remotely reachable coordinator.
The MVP keeps bundles inline in the existing 1 MiB control frame. The CLI
therefore supports raw Wasm modules up to approximately 696 KiB (712704 bytes)
after reserving space for the authenticated request and task metadata. It builds,
resolves, validates, and checks this boundary before creating the virtual
process. Larger bundles fail without leaving an active process; a larger-bundle
transport is explicitly post-MVP.
In VS Code, open `examples/launch-build-demo`, start the `Disasmer: Launch
Virtual Process` configuration or press F5, inspect the Disasmer nodes,
processes, logs, artifacts, and inspector views, and use the ordinary debugger
controls for breakpoints, continue, pause, and restart. Pause is
participant-acknowledged; source stepping still fails explicitly instead of
simulating success. Restarting a terminal task rebuilds the bundle: a compatible
edit launches only that task under a fresh instance ID while preserving its
validated arguments, handles, environment, and clean VFS boundary. An active
task cannot be silently replaced, and an ABI-incompatible edit clearly requires
a whole-process restart. Artifact download
behavior is exercised by `node scripts/artifact-download-smoke.js`; final export
is explicit and should go through an attached receiver node or user-provided
storage integration rather than hidden coordinator storage.
Cleanup for the local quickstart is ordinary process and artifact cleanup: stop
the coordinator process, stop attached node processes, remove any temporary
artifacts under `target/acceptance/`, and remove the local VS Code extension copy
or symlink if one was installed.
## First-Run Diagnostics
Use these messages as first checks before debugging infrastructure:
- Missing nodes: attach a node with `disasmer node attach --coordinator <host:port>` or check the Disasmer Nodes view.
- Missing environments: add `envs/<name>/Containerfile` or `envs/<name>/Dockerfile`; the VS Code extension highlights unknown `env!("name")` references.
- Quota limits: hosted/community denials are returned before dispatch with a specific community tier reason.
- Unavailable artifacts: downloads fail before showing a link when retention, size, authorization, quota, or connectivity makes streaming impossible.
- Auth failures: browser login uses the device/browser flow, while agents and nodes use public-key identity and scoped enrollment grants.
- Failed debug freezes: all-stop reports the participant that could not freeze instead of claiming success.
- Source-provider capability gaps: source preparation stays pending until a node reports `SourceGit` or `SourceFilesystem`.
## Repository Shape
The public workspace contains the open-source contract layer:
```text
crates/disasmer-core shared identities, policy traits, scheduling, VFS/artifacts
crates/disasmer-coordinator local coordinator state model
crates/disasmer-dap Debug Adapter Protocol adapter for VS Code/debug clients
crates/disasmer-macros #[disasmer::main] and #[disasmer::task]
crates/disasmer-node node backend interfaces and local node runtime
crates/disasmer-cli CLI command surface
crates/disasmer-sdk user-facing Rust SDK
scripts/verify-public-split.sh
```
Hosted-only policy code lives under `private/**`. The public split is verified by copying the repo without `private/**` and running the public workspace tests.
Deployment names and authority names describe different things. The
**standalone Core coordinator** is the open-source self-hosted server. The
**Hosted coordinator** is the managed deployment that adds hosted policy around
Core. Within either deployment, **Client**, **Node**, **Identity**, and
**Operator** name authority lanes. “Public” and “private” describe source or
release visibility; they do not grant authority.
## Hosted Coordinator
Public CLI binaries default to the hosted coordinator at
`https://disasmer.michelpaulissen.com` unless local/self-hosted mode is
selected. The CLI asks the hosted coordinator to create a state-, nonce-, and
PKCE-bound login transaction, opens the returned Authentik authorization URL,
and polls with an opaque transaction secret. Authentik returns to the hosted
`/auth/callback`; provider codes and identity claims never pass through the CLI.
The hosted website for the MVP is deliberately barebones HTML with no CSS;
layout and UX polish are later work.
The hosted coordinator combines private hosted policy code with the open-source
Core coordinator. Client traffic uses authenticated user sessions, Identity
traffic handles browser/OIDC login, Node traffic is signed by enrolled workers,
and privileged Operator actions require a separate private credential. It does
not give community users arbitrary hosted native commands or hosted containers.
Real work runs on attached nodes.
Self-hosted users do not need the hosted website. They can run the public
coordinator and public node runtime from this repository, attach their own
nodes, and use the CLI/API for normal project, process, log, artifact, debug,
quota, and admin operations.
The standalone Core coordinator uses strict Client authority by default. A
self-hosted operator supplies one scoped bootstrap session through protected
service configuration:
```bash
DISASMER_SELF_HOSTED_SESSION_SECRET="$SELF_HOSTED_SESSION_SECRET" \
DISASMER_SELF_HOSTED_TENANT=my-team \
DISASMER_SELF_HOSTED_PROJECT=my-project \
DISASMER_SELF_HOSTED_USER=me \
disasmer-coordinator --listen 127.0.0.1:7999
```
From the project directory, connect the CLI without placing the secret in a
process argument:
```bash
printf '%s\n' "$SELF_HOSTED_SESSION_SECRET" | \
disasmer auth connect-self-hosted \
--coordinator 127.0.0.1:7999 \
--tenant my-team --project-id my-project --user me \
--session-secret-stdin
```
The CLI verifies the scoped session before writing `.disasmer/session.json`; on
Unix the file is mode `0600`. Enrollment-grant creation and ordinary Client
operations then use this authenticated session. Signed Node and Agent requests
remain separate authority lanes.
The native Core transport is plaintext and therefore refuses non-loopback
listeners. For administration from another machine, create an authenticated SSH
tunnel to the coordinator loopback port and connect the CLI to the tunnel's
local `127.0.0.1` endpoint. Do not expose port 7999 directly.
Release dry-run mechanics, Forgejo publication, filtered repository preparation,
deployment evidence, and public-release e2e commands are source-side release
operations, not the product quickstart. They live in
`public_release_dryrun.md`.
## Coordinator State
The coordinator separates durable project and identity state from live runtime state. Tenants, users, projects, node identities, credentials, source-provider configuration, durable service policy records, and explicit project permissions can be stored in Postgres. Active virtual processes, live virtual threads, scheduler state, debug epochs, ephemeral VFS manifests, and transient artifact locations remain in coordinator memory for the MVP and are not represented in the Postgres schema.
Process lifecycle is independent of task count. `disasmer process list` and
`disasmer process status` read the live virtual-process registry directly.
`disasmer process cancel --yes` requests cooperative cancellation and leaves the
process visible as `cancelling` until its runtime exits. `disasmer process abort
--yes` is the explicit forced operation: it terminates the coordinator-side
process identity, signals active node work to stop, and immediately releases the
single-process slot. VS Code uses the same live registry for its sidebar and
offers Attach, Restart, Cancel, or Abort when F5 finds an existing process.
The local workspace and the hosted **Coordinator Project** are distinct. A
Coordinator Project is the identity, authorization, quota, and one-active-process
boundary; a local workspace is the source/bundle launch target. When another
workspace launch is occupying the same Coordinator Project, VS Code avoids
blindly attaching with the wrong source and offers different-project actions.
## Local-First Builds
Disasmer is designed so local source checkouts and large outputs stay node-local unless user code or policy explicitly moves bytes.
`flush()` publishes metadata and visibility information. It makes produced artifacts visible to downstream tasks without implying durable coordinator storage.
`sync()` is explicit. It may move bytes to another node or user-provided storage according to code or configured policy.
User-provided storage/export integrations are ordinary project code or external
commands, such as publishing through a CLI. Disasmer records metadata and
coordinates capability, scheduling, and transfer decisions; it does not provide
or manage an explicit artifact-store feature as part of the MVP.
Artifacts are best-effort retained on nodes by default. If unsynced node-local bytes are garbage collected or the retaining node is lost, the artifact becomes unavailable instead of silently recovering from coordinator storage.
Artifact downloads are created only when current retention, size, authorization, and community tier accounting allow it. Download links are scoped to the tenant, project, process, artifact, actor, and policy context, expire after a bounded TTL, can be revoked, and streaming usage is charged before and during transfer.
On Linux nodes, Containerfile and Dockerfile environments are materialized with rootless user Podman. The node runtime has a concrete runner path for `podman build` followed by `podman run`; tests use a recording runner, while real attached nodes can use the `std::process::Command` runner. A local build from an attached local checkout uses a node-local bind mount into the selected environment; the coordinator does not create a full-repo tarball or route compiler file reads. Command output is associated with the virtual thread that started it and can be staged into the VFS artifact namespace. `node scripts/wasmtime-node-smoke.js` builds the launch example for `wasm32-unknown-unknown`, runs its exported task through the node Wasmtime runtime, and verifies a Wasm task can invoke a native command through the versioned `disasmer.command_run_v1` host capability without moving command execution into the hosted coordinator.
## Nodes And Trust
Users attach their own nodes for real work. The hosted coordinator is a control plane for identity, rendezvous, scheduling metadata, debug sessions, logs, artifact metadata, and operator state. The community tier does not provide arbitrary hosted native commands or hosted containers.
Agent and worker automation should use enrolled public-key identity. User OAuth or browser session tokens are not task credentials and should not be passed to nodes.
```bash
disasmer login
disasmer login --browser
disasmer agent enroll --public-key <agent-public-key>
DISASMER_AGENT_PRIVATE_KEY=<agent-private-key> disasmer run --non-interactive build
disasmer run --local --non-interactive build
disasmer node attach --enrollment-grant <grant> --public-key <node-public-key>
disasmer-node --coordinator https://disasmer.michelpaulissen.com --tenant <tenant> --project-id <project> --node <node-id> --public-key <node-public-key> --enrollment-grant <grant> --worker --emit-ready
```
`disasmer login` uses a short-lived human device flow and does not ask users to paste long-lived secrets. `disasmer login --browser` opens the server-provided authorization URL, polls the hosted transaction, and stores only normalized CLI-session metadata in `.disasmer/session.json`; provider authorization codes, OAuth tokens, and identity claims are not accepted or persisted by the CLI. `disasmer login --browser --plan` is the diagnostic JSON login plan path. `--non-interactive` never opens a browser; `disasmer login --browser --non-interactive` and an unauthenticated implicit hosted `disasmer run --non-interactive` fail with an authentication-category report and next actions instead of prompting or guessing. Node attach exchanges a short-lived enrollment grant for a scoped long-lived node identity and then exits; a long-lived `disasmer-node --worker` process must be running for coordinator-side launches and DAP live-services debugging to place real command/container work on that node.
Hosted agent public keys are project-scoped records: a signed-in user can
register, list, rotate, and revoke an agent key without giving the agent browser
or OAuth credentials. An enrolled agent can run CLI commands non-interactively
with `DISASMER_AGENT_PRIVATE_KEY`; the CLI derives the registered public key,
signs workflow requests, and records the public-key fingerprint in its run plan
instead of starting a browser flow. `DISASMER_AGENT_PUBLIC_KEY` may identify or
cross-check the registered key, but cannot authenticate without the private key.
`disasmer node attach` auto-detects OS, architecture, command/container,
VFS/artifact, source-provider, and environment capabilities. `--cap <name>` is
available as an override for unusual local setups, not as required normal
configuration.
For local process-boundary testing, the public workspace includes a TCP JSON-line coordinator service and a node runtime binary:
```bash
cargo run -p disasmer-coordinator -- --listen 127.0.0.1:0
cargo run -p disasmer-node -- --coordinator <host:port> --worker \
--project-root examples/launch-build-demo
```
`scripts/real-flagship-harness.js` starts those as separate long-lived processes. The real `build` Wasm entrypoint spawns its named Wasm tasks through the coordinator; the Linux task selects `env!("linux")`, runs its command through rootless Podman against the node-local checkout, publishes content-addressed bytes, and reports task and artifact metadata. The download and export smokes reuse that workflow and then retrieve the retained bytes through an authorized reverse stream. `scripts/wasmtime-assignment-smoke.js` separately proves cooperative cancellation observed by task code and forced abort of uncooperative Wasm/native work.
For self-hosted local clouds, trusted teams, or VPN deployments, run the public
coordinator on the chosen listen address and attach team-owned nodes with their
public keys. This path uses the open-source coordinator, scheduler, node
heartbeat, task metadata, retained-node downloads, and direct node-to-node export
planning without private hosted OIDC or community tier policy modules.
`node scripts/self-hosted-coordinator-smoke.js` exercises that public path with
two trusted Linux nodes.
## Debugging
The VS Code extension contributes the normal `disasmer` debug type, refreshes bundle metadata before launch, and starts the DAP adapter. Bundle probe ownership comes from actual `#[disasmer::main]` and `#[disasmer::task]` declarations and their declared names, not function-name heuristics. In the verified local-services path, an executing Wasm probe reports a breakpoint hit, every active participant acknowledges the freeze, and only then does DAP emit an all-stop `stopped` event. Runtime child tasks appear as dynamic virtual threads. Continue requests a real coordinator resume. Stack, task-argument, live task-handle, native-command status, and output data come from node acknowledgements; values the runtime did not report are labeled unavailable instead of being inferred or fabricated. Each dispatched task captures a clean entry checkpoint containing its TaskSpec, bundle, ABI, arguments, environment digest, VFS epoch, and required-artifact manifest. After a terminal failure, `restartFrame` rebuilds the current source and relaunches only a boundary-compatible task with a fresh instance ID; an ABI-incompatible edit requires a whole virtual-process restart without mutating the existing process. Active-task restart and arbitrary source stepping fail explicitly. Disasmer-specific side views expose nodes, virtual processes, logs, artifacts, and inspector state alongside the normal debugger UI.
Live-services launch now uses the same bundle builder, Wasm entrypoint TaskSpec,
probe plan, worker placement, Debug Epoch observation, continuation, and restart
code as local-services; only ownership of the already-attached worker differs.
That path remains labeled integration-verified until the final revision-bound
hosted E2E is recorded. F5 and DAP acceptance therefore continue to exercise
local services as well as the strict hosted deployment rather than treating
deployment configuration alone as live proof.
The built-in operator panel preview is rendered by the coordinator from live
process, task, log, and artifact metadata. It uses typed widgets only, keeps
program UI events scoped and rate-limited, and keeps control-plane actions such
as debug, cancel, restart, and artifact download available when program UI events
are disabled.
## Windows
Windows node support uses the same node protocol and capability model as Linux. MVP Windows command execution is user-attached development execution, labeled `windows-command-dev`. Production-grade managed Windows sandboxing is behind an explicit backend stub until it is implemented and validated independently. When the acceptance report shows `windows_validation: "not-run"`, Windows support is best-effort and unvalidated for that release; the public smoke only verifies protocol, placement, metadata, and download behavior for a Windows-capable node identity.
Real Windows validation runs through the manual Forgejo workflow
`.forgejo/workflows/windows-validation.yml` when the intermittent Windows runner is
online. That workflow records `windows_validation: "forgejo-windows-runner"` in
the acceptance report, runs `disasmer node attach` against a coordinator, starts a
Windows node process, executes a simple `windows-command-dev` command, publishes
artifact metadata, checks Windows placement, and verifies the debugger can show a
Windows virtual thread.
## Source Providers
Git is an optional source-provider module included by default. The VFS core depends on source-provider manifests and snapshot handles, not on Git internals. Non-Git source providers can implement the public source-provider interface and run snapshot preparation as node work instead of requiring coordinator filesystem access.
Source preparation is scheduled as node work. The coordinator can return a
pending source-preparation status while waiting for any capable node, then assign
the work after a node reports `SourceGit` or `SourceFilesystem` capability; it
does not need checkout or provider access itself.
Source-provider manifests are validated as public input and must declare a
local-first transfer policy: local source bytes stay node-local, the coordinator
does not receive source bytes by default, and remote preparation uses required
content or explicit snapshot chunks rather than a full-repo tarball.
## Verification
Run the public workspace checks:
```bash
scripts/acceptance-public.sh
```
Run the CLI-first non-e2e gate before any final public-release e2e attempt:
```bash
scripts/acceptance-cli-first.sh
```
This gate composes the CLI/API, local-services, self-hosted, hosted-compat,
debugger, artifact, safety, and public dry-run contract checks without invoking
`public-release-dryrun-e2e.js` or the final evidence verifier. Leave
`DISASMER_PUBLIC_RELEASE_DRYRUN_E2E` and `DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL`
unset until the CLI-first criteria are otherwise implemented to pass.
Or run the individual public checks:
```bash
cargo test --workspace
node scripts/acceptance-report-smoke.js
node scripts/public-private-boundary-smoke.js
node scripts/release-blocker-smoke.js
node scripts/docs-smoke.js
node scripts/public-release-dryrun-contract-smoke.js
node scripts/wasmtime-node-smoke.js
node scripts/podman-backend-smoke.js
node scripts/vscode-extension-smoke.js
node scripts/vscode-f5-smoke.js
node scripts/node-attach-smoke.js
node scripts/wasmtime-assignment-smoke.js
node scripts/cli-local-run-smoke.js
node scripts/artifact-download-smoke.js
node scripts/artifact-export-smoke.js
node scripts/operator-panel-smoke.js
node scripts/source-preparation-smoke.js
node scripts/scheduler-placement-smoke.js
node scripts/windows-best-effort-smoke.js
node scripts/quic-smoke.js
node scripts/flagship-demo-smoke.js
node scripts/dap-smoke.js
node scripts/prepare-public-release-dryrun.js
scripts/verify-public-split.sh
```
When the Forgejo Windows runner is online, run the manual `Windows validation`
workflow as the release Windows gate. The local equivalent on a Windows machine is:
```powershell
$env:DISASMER_WINDOWS_VALIDATION = "forgejo-windows-runner"
node scripts/acceptance-report.js windows
cargo fmt --all --check
cargo test -p disasmer-node windows_backend_is_labeled_user_attached_dev_execution
node scripts/windows-runner-smoke.js
```
Run private hosted policy checks separately:
```bash
scripts/acceptance-private.sh
```
The private hosted gate includes `hosted-client-compat-smoke.js`, which starts
the hosted dry-run service locally and verifies that the released Client CLI and
signed Node runtime can attach, launch work through coordinator task assignment,
and publish debug/log/artifact metadata through the hosted Client protocol
boundary.
Both acceptance scripts write an environment report under `target/acceptance/`
with the commit SHA, toolchain versions, OS/kernel, Podman/Postgres discovery,
browser/VS Code harness metadata, and Windows-validation status. The report
records Podman as `available` only when rootless Podman is discoverable; if it is
missing or not rootless, Linux Podman backend behavior is marked `incomplete`
with the reason and the Podman backend smoke blocks acceptance with the same
incomplete reason.

26
SECURITY.md Normal file
View file

@ -0,0 +1,26 @@
# Security policy
Disasmer is pre-release software that executes untrusted Wasm and can delegate
explicit host capabilities to attached nodes. Security reports are welcome even
when the affected behavior has not shipped in a numbered release.
## Supported versions
Until the first public release, only the current `main` branch is supported.
After releases begin, this table will identify supported release lines.
## Reporting a vulnerability
Do not open a public issue for a suspected vulnerability. Email
`ops@michelpaulissen.com` with:
- the affected commit or release;
- the relevant deployment mode (hosted, self-hosted, or attached node);
- reproduction steps or a minimal proof of concept;
- the security boundary or tenant scope that was crossed; and
- any known mitigations.
Please avoid accessing other users' data, disrupting hosted services, or
retaining secrets while testing. We will acknowledge receipt, coordinate a fix
and disclosure, and credit reporters who want to be named. No response-time or
bounty commitment is made before the first public release.

View file

@ -0,0 +1,22 @@
[package]
name = "disasmer-cli"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "disasmer"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
base64.workspace = true
clap.workspace = true
disasmer-core = { path = "../disasmer-core" }
disasmer-control = { path = "../disasmer-control" }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
wasmparser.workspace = true

View file

@ -0,0 +1,236 @@
use std::path::PathBuf;
use anyhow::Result;
use disasmer_core::admin_request_proof;
use serde_json::{json, Value};
use crate::client::JsonLineSession;
use crate::project::project_init_report;
use crate::tools::{command_nonce, unix_timestamp_seconds};
use crate::{
confirmation_required_report, AdminBootstrapArgs, AdminStatusArgs, AdminSuspendTenantArgs,
ProjectInitArgs,
};
pub(crate) fn admin_status_report(args: AdminStatusArgs) -> Result<Value> {
if let Some(coordinator) = &args.scope.coordinator {
let tenant = args.scope.tenant.clone();
let user = args.scope.user.clone();
let admin_token = admin_token_for_request(args.admin_token.as_deref())?;
let admin_nonce = command_nonce("admin-status");
let issued_at_epoch_seconds = unix_timestamp_seconds();
let admin_proof = admin_request_proof(
&admin_token,
"admin_status",
&tenant,
&user,
&tenant,
&admin_nonce,
issued_at_epoch_seconds,
);
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(json!({
"type": "admin_status",
"tenant": tenant,
"actor_user": user,
"admin_proof": admin_proof,
"admin_nonce": admin_nonce,
"issued_at_epoch_seconds": issued_at_epoch_seconds,
}))?;
return Ok(json!({
"command": "admin status",
"coordinator": coordinator,
"tenant": tenant,
"user": user,
"suspended": response
.get("suspended")
.cloned()
.unwrap_or(json!(false)),
"response": response,
"safe_default": "read_only",
"private_website_required": false,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "admin status",
"mode": "self_hosted_local",
"safe_default": "read_only",
"private_website_required": false,
}))
}
pub(crate) fn admin_bootstrap_report(args: AdminBootstrapArgs, cwd: PathBuf) -> Result<Value> {
let scope = args.scope.clone();
let new_project = args.scope.project.clone();
let project_init = project_init_report(
ProjectInitArgs {
scope: args.scope,
new_project,
name: args.name,
yes: args.yes,
},
cwd,
)?;
let coordinator = scope
.coordinator
.clone()
.unwrap_or_else(|| "<local-coordinator>".to_owned());
let tenant = scope.tenant.clone();
let project = scope.project.clone();
let user = scope.user.clone();
Ok(json!({
"command": "admin bootstrap",
"mode": if scope.coordinator.is_some() { "public_coordinator_api" } else { "self_hosted_local" },
"tenant": tenant.clone(),
"project": project.clone(),
"user": user,
"coordinator": scope.coordinator,
"private_website_required": false,
"self_hosted_cli_only": true,
"project_config_written": project_init
.get("project_config_written")
.cloned()
.unwrap_or(json!(false)),
"project_init": project_init,
"admin_surfaces": {
"coordinator": "disasmer-coordinator",
"project": "disasmer project init/status/list/select",
"node": "disasmer node enroll/list/status/revoke",
"process": "disasmer run/process status/process restart/process cancel",
"logs": "disasmer logs",
"artifacts": "disasmer artifact list/download/export",
"quota": "disasmer quota status",
"policy": "disasmer admin status/suspend-tenant",
},
"bootstrap_sequence": [
{
"step": "start_self_hosted_coordinator",
"command": "disasmer-coordinator --listen 127.0.0.1:0",
"private_website_required": false,
},
{
"step": "create_or_link_project",
"command": "disasmer project init --yes",
"completed": true,
"private_website_required": false,
},
{
"step": "create_node_enrollment_grant",
"command": format!(
"disasmer node enroll --coordinator {coordinator} --tenant {} --project-id {}",
tenant, project
),
"private_website_required": false,
},
{
"step": "attach_worker_node",
"command": format!(
"disasmer node attach --coordinator {coordinator} --tenant {} --project-id {} --worker",
tenant, project
),
"private_website_required": false,
},
{
"step": "run_process",
"command": format!(
"disasmer run --coordinator {coordinator} --tenant {} --project-id {}",
tenant, project
),
"private_website_required": false,
},
{
"step": "inspect_status_logs_artifacts",
"commands": [
"disasmer process status",
"disasmer task list",
"disasmer logs",
"disasmer artifact list",
"disasmer quota status",
],
"private_website_required": false,
},
{
"step": "revoke_access",
"command": "disasmer admin revoke-node --node <node-id> --yes",
"private_website_required": false,
}
],
}))
}
pub(crate) fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result<Value> {
let tenant = args
.target_tenant
.unwrap_or_else(|| args.scope.tenant.clone());
if !args.yes {
return Ok(confirmation_required_report(
"admin suspend-tenant",
"suspend_tenant",
json!({
"coordinator": args.scope.coordinator,
"actor_tenant": args.scope.tenant,
"actor_user": args.scope.user,
"target_tenant": tenant,
}),
"disasmer admin suspend-tenant --yes".to_owned(),
));
}
if let Some(coordinator) = &args.scope.coordinator {
let actor_tenant = args.scope.tenant.clone();
let actor_user = args.scope.user.clone();
let admin_token = admin_token_for_request(args.admin_token.as_deref())?;
let admin_nonce = command_nonce("admin-suspend-tenant");
let issued_at_epoch_seconds = unix_timestamp_seconds();
let admin_proof = admin_request_proof(
&admin_token,
"suspend_tenant",
&actor_tenant,
&actor_user,
&tenant,
&admin_nonce,
issued_at_epoch_seconds,
);
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(json!({
"type": "suspend_tenant",
"tenant": actor_tenant,
"actor_user": actor_user,
"target_tenant": tenant,
"admin_proof": admin_proof,
"admin_nonce": admin_nonce,
"issued_at_epoch_seconds": issued_at_epoch_seconds,
}))?;
return Ok(json!({
"command": "admin suspend-tenant",
"coordinator": coordinator,
"requires_confirmation": !args.yes,
"tenant": tenant,
"actor_tenant": actor_tenant,
"actor_user": actor_user,
"suspended": response.get("type").and_then(Value::as_str) == Some("tenant_suspended"),
"private_website_required": false,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "admin suspend-tenant",
"status": "requires_coordinator",
"requires_confirmation": !args.yes,
"tenant": tenant,
"private_website_required": false,
}))
}
fn admin_token_for_request(explicit: Option<&str>) -> Result<String> {
explicit
.map(str::to_owned)
.or_else(|| std::env::var("DISASMER_ADMIN_TOKEN").ok())
.filter(|token| !token.trim().is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"admin command requires --admin-token or DISASMER_ADMIN_TOKEN for coordinator requests"
)
})
}

View file

@ -0,0 +1,17 @@
use disasmer_core::Digest;
use serde::Serialize;
use crate::AgentEnrollArgs;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct AgentEnrollmentPlan {
pub(crate) public_key_fingerprint: Digest,
pub(crate) browser_interaction_required_each_run: bool,
}
pub(crate) fn agent_enrollment_plan(args: AgentEnrollArgs) -> AgentEnrollmentPlan {
AgentEnrollmentPlan {
public_key_fingerprint: Digest::sha256(args.public_key),
browser_interaction_required_each_run: false,
}
}

View file

@ -0,0 +1,547 @@
use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde_json::{json, Value};
use sha2::{Digest as _, Sha256};
use std::io::Write;
use std::path::Path;
use std::time::{Duration, Instant};
use crate::client::{
authenticated_or_local_trusted_request, list_task_events_if_available_with_session,
JsonLineSession,
};
use crate::config::StoredCliSession;
use crate::errors::cli_error_summary_for_category;
use crate::process_events::{
artifact_download_grant_disclosures, artifact_download_session_summary,
artifact_export_plan_summary, artifact_response_machine_error, artifact_summaries,
};
use crate::{ArtifactDownloadArgs, ArtifactExportArgs, ArtifactListArgs};
pub(crate) const DEFAULT_ARTIFACT_EXPORT_MAX_BYTES: u64 = 64 * 1024 * 1024;
const ARTIFACT_DOWNLOAD_CONTROL_CHUNK_BYTES: u64 = 256 * 1024;
#[cfg(test)]
pub(crate) fn artifact_list_report(args: ArtifactListArgs) -> Result<Value> {
artifact_list_report_with_session(args, None)
}
pub(crate) fn artifact_list_report_with_session(
args: ArtifactListArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
let events = list_task_events_if_available_with_session(
args.scope.coordinator.as_deref(),
&args.scope,
args.process.clone(),
stored_session,
)?;
let artifacts = artifact_summaries(events.as_ref());
Ok(json!({
"command": "artifact list",
"process": args.process,
"source": "task_events",
"artifacts": artifacts,
"default_durable_store_assumed": false,
"events": events,
}))
}
#[cfg(test)]
pub(crate) fn artifact_download_report(args: ArtifactDownloadArgs) -> Result<Value> {
artifact_download_report_with_session(args, None)
}
pub(crate) fn artifact_download_report_with_session(
args: ArtifactDownloadArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
if let Some(coordinator) = &args.scope.coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "create_artifact_download_link",
"artifact": args.artifact,
"max_bytes": args.max_bytes,
}),
json!({
"type": "create_artifact_download_link",
"tenant": args.scope.tenant,
"project": args.scope.project,
"actor_user": args.scope.user,
"artifact": args.artifact,
"max_bytes": args.max_bytes,
}),
)?)?;
let download_session = artifact_download_session_summary(&response);
let grant_disclosures = artifact_download_grant_disclosures(&response);
let local_download = match &args.to {
Some(path)
if response.get("type").and_then(Value::as_str)
== Some("artifact_download_link") =>
{
download_link_to_path(
&mut session,
coordinator,
&args.scope,
&args.artifact,
args.max_bytes,
path,
&response,
stored_session,
)?
}
Some(path) => json!({
"status": "download_link_failed",
"local_path": path,
"local_bytes_written_by_cli": false,
"machine_error": artifact_response_machine_error(
&response,
"coordinator rejected artifact download",
"connectivity"
),
}),
None => Value::Null,
};
return Ok(json!({
"command": "artifact download",
"coordinator": coordinator,
"artifact": args.artifact,
"max_bytes": args.max_bytes,
"to": args.to,
"download_session": download_session,
"local_download": local_download,
"grant_disclosures": grant_disclosures,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "artifact download",
"status": "requires_coordinator",
"artifact": args.artifact,
"max_bytes": args.max_bytes,
"to": args.to,
"download_session": {
"status": "requires_coordinator",
"link_issued": false,
"explicit_user_action_required": true,
"machine_error": cli_error_summary_for_category(
"connectivity",
"artifact download requires a coordinator"
),
},
"grant_disclosures": [],
}))
}
#[cfg(test)]
pub(crate) fn artifact_export_report(args: ArtifactExportArgs) -> Result<Value> {
artifact_export_report_with_session(args, None)
}
pub(crate) fn artifact_export_report_with_session(
args: ArtifactExportArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
if let Some(coordinator) = &args.scope.coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "export_artifact_to_node",
"artifact": args.artifact,
"receiver_node": args.receiver_node,
"direct_connectivity": true,
"failure_reason": "",
}),
json!({
"type": "export_artifact_to_node",
"tenant": args.scope.tenant,
"project": args.scope.project,
"actor_user": args.scope.user,
"artifact": args.artifact,
"receiver_node": args.receiver_node,
"direct_connectivity": true,
"failure_reason": "",
}),
)?)?;
let mut export_plan = artifact_export_plan_summary(&response, &args.to);
let local_export = if response.get("type").and_then(Value::as_str)
== Some("artifact_export_plan")
{
artifact_export_local_write_followup(&mut session, coordinator, &args, stored_session)?
} else {
json!({
"status": "skipped",
"explicit_user_action": true,
"local_path": &args.to,
"local_bytes_written_by_cli": false,
"machine_error": artifact_response_machine_error(
&response,
"coordinator rejected artifact export",
"connectivity"
),
})
};
apply_local_export_summary(&mut export_plan, &local_export);
let grant_disclosures = local_export
.get("grant_disclosures")
.cloned()
.unwrap_or_else(|| json!([]));
return Ok(json!({
"command": "artifact export",
"coordinator": coordinator,
"artifact": args.artifact,
"to": args.to,
"receiver_node": args.receiver_node,
"export_plan": export_plan,
"local_export": local_export,
"grant_disclosures": grant_disclosures,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "artifact export",
"status": "requires_coordinator",
"artifact": args.artifact,
"to": args.to,
"receiver_node": args.receiver_node,
"export_plan": {
"status": "requires_coordinator",
"explicit_user_action": true,
"local_bytes_written_by_cli": false,
"default_durable_store_assumed": false,
"machine_error": cli_error_summary_for_category(
"connectivity",
"artifact export requires a coordinator"
),
},
"grant_disclosures": [],
}))
}
fn artifact_export_local_write_followup(
session: &mut JsonLineSession,
coordinator: &str,
args: &ArtifactExportArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
let link_response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "create_artifact_download_link",
"artifact": args.artifact,
"max_bytes": DEFAULT_ARTIFACT_EXPORT_MAX_BYTES,
}),
json!({
"type": "create_artifact_download_link",
"tenant": args.scope.tenant,
"project": args.scope.project,
"actor_user": args.scope.user,
"artifact": args.artifact,
"max_bytes": DEFAULT_ARTIFACT_EXPORT_MAX_BYTES,
}),
)?)?;
if link_response.get("type").and_then(Value::as_str) != Some("artifact_download_link") {
return Ok(json!({
"status": "download_link_failed",
"explicit_user_action": true,
"local_path": &args.to,
"local_bytes_written_by_cli": false,
"content_bytes_available": false,
"download_session": artifact_download_session_summary(&link_response),
"grant_disclosures": [],
"machine_error": artifact_response_machine_error(
&link_response,
"coordinator rejected artifact download for local export",
"connectivity"
),
"link_response": link_response,
}));
}
download_link_to_path(
session,
coordinator,
&args.scope,
&args.artifact,
DEFAULT_ARTIFACT_EXPORT_MAX_BYTES,
&args.to,
&link_response,
stored_session,
)
}
#[allow(clippy::too_many_arguments)]
fn download_link_to_path(
session: &mut JsonLineSession,
coordinator: &str,
scope: &crate::CliScopeArgs,
artifact: &str,
max_bytes: u64,
destination: &Path,
link_response: &Value,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
let download_session = artifact_download_session_summary(link_response);
let grant_disclosures = artifact_download_grant_disclosures(link_response);
let token_digest = link_response
.pointer("/link/scoped_token_digest")
.cloned()
.context("artifact download link did not include a scoped token digest")?;
let expected_digest: disasmer_core::Digest = serde_json::from_value(
link_response
.pointer("/link/artifact_digest")
.cloned()
.context("artifact download link omitted the published digest")?,
)?;
let expected_size = link_response
.pointer("/link/artifact_size_bytes")
.and_then(Value::as_u64)
.context("artifact download link omitted the published size")?;
if expected_size > max_bytes {
return Ok(json!({
"status": "download_limit_failed",
"local_path": destination,
"local_bytes_written_by_cli": false,
"content_bytes_available": false,
"download_session": download_session,
"grant_disclosures": grant_disclosures,
"machine_error": cli_error_summary_for_category(
"quota",
&format!("artifact size {expected_size} exceeds requested limit {max_bytes}")
),
}));
}
let parent = destination
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
let mut temporary = tempfile::Builder::new()
.prefix(".disasmer-download-")
.tempfile_in(parent)
.with_context(|| {
format!(
"failed to create a bounded download in {}",
parent.display()
)
})?;
let started = Instant::now();
let mut received_bytes = 0_u64;
let mut hasher = Sha256::new();
let stream_response = loop {
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "open_artifact_download_stream",
"artifact": artifact,
"max_bytes": max_bytes,
"token_digest": token_digest,
"chunk_bytes": expected_size.clamp(1, ARTIFACT_DOWNLOAD_CONTROL_CHUNK_BYTES),
}),
json!({
"type": "open_artifact_download_stream",
"tenant": scope.tenant,
"project": scope.project,
"actor_user": scope.user,
"artifact": artifact,
"max_bytes": max_bytes,
"token_digest": token_digest,
"chunk_bytes": expected_size.clamp(1, ARTIFACT_DOWNLOAD_CONTROL_CHUNK_BYTES),
}),
)?)?;
if response.get("type").and_then(Value::as_str) != Some("artifact_download_stream") {
break response;
}
if response
.get("content_bytes_available")
.and_then(Value::as_bool)
== Some(true)
{
let offset = response
.get("content_offset")
.and_then(Value::as_u64)
.unwrap_or(u64::MAX);
if offset != received_bytes {
break json!({
"type": "error",
"message": format!(
"artifact reverse stream returned offset {offset}, expected {received_bytes}"
),
});
}
let Some(content_base64) = response.get("content_base64").and_then(Value::as_str)
else {
break json!({
"type": "error",
"message": "artifact reverse stream marked bytes available without content",
});
};
let content = BASE64_STANDARD
.decode(content_base64)
.context("artifact download stream returned invalid base64 content")?;
received_bytes = received_bytes
.checked_add(content.len() as u64)
.context("artifact download byte count overflowed")?;
if received_bytes > expected_size || received_bytes > max_bytes {
break json!({
"type": "error",
"message": "artifact reverse stream exceeded its published size or CLI limit",
});
}
temporary
.write_all(&content)
.context("write bounded artifact download")?;
hasher.update(&content);
if response.get("content_eof").and_then(Value::as_bool) == Some(true) {
break response;
}
continue;
}
if started.elapsed() >= Duration::from_secs(120) {
break json!({
"type": "error",
"message": "timed out waiting for the retaining node to reverse-stream artifact bytes",
});
}
std::thread::sleep(Duration::from_millis(25));
};
if stream_response.get("type").and_then(Value::as_str) != Some("artifact_download_stream") {
return Ok(json!({
"status": "download_stream_failed",
"explicit_user_action": true,
"local_path": destination,
"local_bytes_written_by_cli": false,
"content_bytes_available": false,
"download_session": download_session,
"grant_disclosures": grant_disclosures,
"machine_error": artifact_response_machine_error(
&stream_response,
"coordinator rejected artifact download stream",
"connectivity"
),
"stream": artifact_stream_summary(&stream_response),
}));
}
let actual_digest_hex = format!("{:x}", hasher.finalize());
let actual_digest =
disasmer_core::Digest::from_sha256_hex(&actual_digest_hex).map_err(anyhow::Error::msg)?;
if received_bytes != expected_size || actual_digest != expected_digest {
return Ok(json!({
"status": "download_integrity_failed",
"explicit_user_action": true,
"local_path": destination,
"local_bytes_written_by_cli": false,
"content_bytes_available": false,
"download_session": download_session,
"grant_disclosures": grant_disclosures,
"machine_error": cli_error_summary_for_category(
"program",
&format!(
"artifact download digest/size mismatch: received {received_bytes} bytes with {actual_digest}, expected {expected_size} bytes with {expected_digest}"
)
),
"stream": artifact_stream_summary(&stream_response),
}));
}
temporary
.as_file()
.sync_all()
.context("sync verified artifact download")?;
temporary.persist_noclobber(destination).map_err(|error| {
anyhow::anyhow!(
"refusing to replace artifact download destination {}: {}",
destination.display(),
error.error
)
})?;
Ok(json!({
"status": "local_bytes_written",
"explicit_user_action": true,
"local_path": destination,
"local_bytes_written_by_cli": true,
"bytes_written": received_bytes,
"verified_digest": actual_digest,
"published_digest": expected_digest,
"content_bytes_available": true,
"content_source": stream_response.get("content_source").cloned().unwrap_or(Value::Null),
"download_session": download_session,
"grant_disclosures": grant_disclosures,
"stream": artifact_stream_summary(&stream_response),
}))
}
pub(crate) fn artifact_stream_summary(response: &Value) -> Value {
let status = response
.get("type")
.and_then(Value::as_str)
.unwrap_or("coordinator_response");
let mut summary = json!({
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
"streamed_bytes": response.get("streamed_bytes").cloned().unwrap_or_else(|| json!(0)),
"charged_download_bytes": response.get("charged_download_bytes").cloned().unwrap_or_else(|| json!(0)),
"content_bytes_available": response.get("content_bytes_available").cloned().unwrap_or(json!(false)),
"content_offset": response.get("content_offset").cloned().unwrap_or(Value::Null),
"content_eof": response.get("content_eof").cloned().unwrap_or(json!(false)),
"content_source": response.get("content_source").cloned().unwrap_or(Value::Null),
"content_material_returned_in_report": false,
"error": response.get("message").cloned().unwrap_or(Value::Null),
});
if status != "artifact_download_stream" {
if let Some(object) = summary.as_object_mut() {
object.insert(
"machine_error".to_owned(),
artifact_response_machine_error(
response,
"coordinator rejected artifact download stream",
"connectivity",
),
);
}
}
summary
}
fn apply_local_export_summary(export_plan: &mut Value, local_export: &Value) {
let Some(object) = export_plan.as_object_mut() else {
return;
};
object.insert(
"local_bytes_written_by_cli".to_owned(),
local_export
.get("local_bytes_written_by_cli")
.cloned()
.unwrap_or(json!(false)),
);
object.insert(
"local_export_status".to_owned(),
local_export
.get("status")
.cloned()
.unwrap_or_else(|| json!("unknown")),
);
object.insert(
"content_bytes_available".to_owned(),
local_export
.get("content_bytes_available")
.cloned()
.unwrap_or(json!(false)),
);
if let Some(bytes_written) = local_export.get("bytes_written") {
object.insert("bytes_written".to_owned(), bytes_written.clone());
object.insert(
"writes_require_data_plane_followup".to_owned(),
json!(false),
);
}
}

View file

@ -0,0 +1,780 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{json, Value};
use crate::client::{
authenticated_or_local_trusted_request, stored_session_for_coordinator, JsonLineSession,
};
use crate::config::{
default_hosted_coordinator_endpoint, effective_scope_value, read_cli_session,
read_project_config, write_cli_session, write_project_config, ProjectConfig, StoredCliSession,
};
use crate::errors::cli_error_summary;
use crate::run::{session_from_env, CliSession};
use crate::ConnectSelfHostedArgs;
use crate::{AuthStatusArgs, LoginArgs};
const DEFAULT_BROWSER_LOGIN_TRANSACTION_TIMEOUT_SECONDS: u64 = 300;
pub(crate) fn read_session_secret_from_stdin() -> Result<String> {
let mut secret = String::new();
std::io::stdin()
.read_line(&mut secret)
.context("failed to read self-hosted session secret from stdin")?;
let secret = secret.trim_end_matches(['\r', '\n']).to_owned();
if secret.trim().is_empty() {
anyhow::bail!("self-hosted session secret from stdin must not be empty");
}
Ok(secret)
}
pub(crate) fn connect_self_hosted_report(
args: ConnectSelfHostedArgs,
cwd: PathBuf,
session_secret: String,
) -> Result<Value> {
if !args.session_secret_stdin {
anyhow::bail!("self-hosted session configuration requires --session-secret-stdin");
}
let coordinator = args
.scope
.coordinator
.as_deref()
.filter(|value| !value.trim().is_empty())
.context("self-hosted session configuration requires --coordinator <host:port>")?;
if session_secret.trim().is_empty() {
anyhow::bail!("self-hosted session secret from stdin must not be empty");
}
let mut connection = JsonLineSession::connect(coordinator)?;
let response = connection.request(json!({
"type": "authenticated",
"session_secret": session_secret,
"request": { "type": "auth_status" },
}))?;
for (field, expected) in [
("tenant", args.scope.tenant.as_str()),
("project", args.scope.project.as_str()),
("actor", args.scope.user.as_str()),
] {
let actual = response.get(field).and_then(Value::as_str).unwrap_or("");
if actual != expected {
anyhow::bail!(
"self-hosted session {field} mismatch: coordinator returned {actual:?}, expected {expected:?}"
);
}
}
if response.get("authenticated").and_then(Value::as_bool) != Some(true) {
anyhow::bail!("self-hosted coordinator did not confirm the CLI session");
}
let stored = StoredCliSession {
kind: "self_hosted".to_owned(),
coordinator: coordinator.to_owned(),
tenant: args.scope.tenant,
project: args.scope.project,
user: args.scope.user,
cli_session_credential_kind: "CliDeviceSession".to_owned(),
session_secret: Some(session_secret),
token_expiry_posture: "configured_by_self_hosted_operator".to_owned(),
expires_at: None,
provider_tokens_exposed_to_cli: false,
provider_tokens_sent_to_nodes: false,
created_at_unix_seconds: unix_timestamp_seconds(),
};
let session_file = write_cli_session(&cwd, &stored)?;
Ok(json!({
"command": "auth connect-self-hosted",
"status": "connected",
"coordinator": coordinator,
"tenant": stored.tenant,
"project": stored.project,
"user": stored.user,
"session_file": session_file,
"session_secret_read_from_stdin": true,
"session_secret_exposed_in_report": false,
"provider_tokens_exposed_to_cli": false,
"provider_tokens_sent_to_nodes": false,
"coordinator_response": response,
"coordinator_session_requests": connection.requests(),
}))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct LoginPlan {
pub(crate) coordinator: String,
pub(crate) human_flow: LoginFlowPlan,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub(crate) struct LoginCompletionReport {
pub(crate) plan: LoginPlan,
pub(crate) boundary: LoginCompletionBoundaryEvidence,
pub(crate) coordinator_response: Value,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct LoginCompletionBoundaryEvidence {
pub(crate) cli_contacted_coordinator: bool,
pub(crate) coordinator_address: String,
pub(crate) scoped_cli_session_received: bool,
pub(crate) local_cli_session_file_written: bool,
pub(crate) provider_tokens_persisted_locally: bool,
pub(crate) provider_tokens_exposed_to_cli: bool,
pub(crate) provider_tokens_sent_to_nodes: bool,
pub(crate) coordinator_session_requests: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) enum LoginFlowPlan {
Browser(HostedBrowserLoginPlan),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct HostedBrowserLoginPlan {
pub(crate) authorization_url: Option<String>,
pub(crate) requested_project: String,
pub(crate) server_owns_state: bool,
pub(crate) server_owns_nonce: bool,
pub(crate) pkce_required: bool,
pub(crate) hosted_callback: bool,
pub(crate) cli_receives_provider_authorization_code: bool,
pub(crate) cli_submits_identity_claims: bool,
}
pub(crate) fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result<Value> {
let config = read_project_config(&cwd)?;
let stored_session = read_cli_session(&cwd)?;
let configured_coordinator = args
.scope
.coordinator
.clone()
.or_else(|| {
config
.as_ref()
.and_then(|config| config.coordinator.clone())
})
.or_else(|| {
stored_session
.as_ref()
.map(|session| session.coordinator.clone())
});
let active_coordinator = configured_coordinator
.clone()
.unwrap_or_else(default_hosted_coordinator_endpoint);
let tenant = effective_scope_value(
&args.scope.tenant,
config
.as_ref()
.map(|config| config.tenant.as_str())
.or_else(|| {
stored_session
.as_ref()
.map(|session| session.tenant.as_str())
}),
"tenant",
);
let project = effective_scope_value(
&args.scope.project,
config
.as_ref()
.map(|config| config.project.as_str())
.or_else(|| {
stored_session
.as_ref()
.map(|session| session.project.as_str())
}),
"project",
);
let principal = effective_scope_value(
&args.scope.user,
config
.as_ref()
.map(|config| config.user.as_str())
.or_else(|| stored_session.as_ref().map(|session| session.user.as_str())),
"user",
);
let session_scope_mismatch = crate::auth_scope::session_scope_mismatch(
stored_session.as_ref(),
&active_coordinator,
&tenant,
&project,
&principal,
);
let session_matches_project = stored_session.is_some() && session_scope_mismatch.is_none();
let coordinator_account_status = configured_coordinator
.as_ref()
.filter(|_| session_scope_mismatch.is_none())
.map(|coordinator| {
coordinator_auth_status_summary(
coordinator,
&tenant,
&project,
&principal,
stored_session.as_ref(),
)
})
.unwrap_or_else(|| {
if let Some(fields) = &session_scope_mismatch {
return crate::auth_scope::session_scope_mismatch_status(fields);
}
json!({
"checked": false,
"reason": "no project or session coordinator configured",
"suspension_known": false,
"account_state_known": false,
"account_status": "unknown",
"private_moderation_details_exposed": false,
"signup_failure_details_exposed": false,
})
});
Ok(json!({
"command": "auth status",
"active_coordinator": active_coordinator,
"principal": principal,
"tenant": tenant,
"project": project,
"session": auth_state_value(&cwd)?,
"session_matches_current_project": session_matches_project,
"session_scope_mismatch": session_scope_mismatch,
"coordinator_account_status": coordinator_account_status,
"project_config": config,
}))
}
fn coordinator_auth_status_summary(
coordinator: &str,
tenant: &str,
project: &str,
principal: &str,
stored_session: Option<&StoredCliSession>,
) -> Value {
let mut session = match JsonLineSession::connect(coordinator) {
Ok(session) => session,
Err(error) => {
let message = error.to_string();
return json!({
"checked": true,
"reachable": false,
"source": "public_coordinator_api",
"account_status": "unknown",
"suspension_known": false,
"account_state_known": false,
"private_moderation_details_exposed": false,
"signup_failure_details_exposed": false,
"machine_error": cli_error_summary(&message),
"error": message,
"next_actions": ["disasmer doctor", "check coordinator status"],
"coordinator_session_requests": 0,
});
}
};
let request = match authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "auth_status",
}),
json!({
"type": "auth_status",
"tenant": tenant,
"project": project,
"actor_user": principal,
}),
) {
Ok(request) => request,
Err(error) => {
let message = error.to_string();
return json!({
"checked": false,
"reachable": "not_checked",
"source": "public_coordinator_api",
"authenticated_for_current_project": false,
"account_status": "unknown",
"suspension_known": false,
"account_state_known": false,
"private_moderation_details_exposed": false,
"signup_failure_details_exposed": false,
"machine_error": cli_error_summary(&message),
"error": message,
"next_actions": ["disasmer login --browser"],
"coordinator_session_requests": 0,
});
}
};
let response = match session.request_allow_error(request) {
Ok(response) => response,
Err(error) => {
let message = error.to_string();
return json!({
"checked": true,
"reachable": false,
"source": "public_coordinator_api",
"account_status": "unknown",
"suspension_known": false,
"account_state_known": false,
"private_moderation_details_exposed": false,
"signup_failure_details_exposed": false,
"machine_error": cli_error_summary(&message),
"error": message,
"next_actions": ["disasmer doctor", "check coordinator status"],
"coordinator_session_requests": session.requests(),
});
}
};
let coordinator_session_requests = session.requests();
if response.get("type").and_then(Value::as_str) == Some("error") {
let message = response
.get("message")
.and_then(Value::as_str)
.unwrap_or("coordinator rejected auth status");
return json!({
"checked": true,
"reachable": true,
"source": "public_coordinator_api",
"account_status": "unknown",
"suspension_known": false,
"account_state_known": false,
"private_moderation_details_exposed": false,
"signup_failure_details_exposed": false,
"machine_error": cli_error_summary(message),
"coordinator_response_type": "error",
"next_actions": ["disasmer doctor", "disasmer login --browser"],
"coordinator_session_requests": coordinator_session_requests,
});
}
let suspended = response
.get("suspended")
.and_then(Value::as_bool)
.unwrap_or(false);
let disabled = response
.get("disabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let deleted = response
.get("deleted")
.and_then(Value::as_bool)
.unwrap_or(false);
let manual_review = response
.get("manual_review")
.and_then(Value::as_bool)
.unwrap_or(false);
let account_status = response
.get("account_status")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| {
if deleted {
"deleted"
} else if disabled {
"disabled"
} else if suspended {
"suspended"
} else if manual_review {
"manual_review"
} else {
"active"
}
.to_owned()
});
let sanitized_reason = response.get("sanitized_reason").and_then(Value::as_str);
let next_actions = response
.get("next_actions")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|value| value.as_str().map(str::to_owned))
.collect::<Vec<_>>();
json!({
"checked": true,
"reachable": true,
"source": "public_coordinator_api",
"used_cli_session_credential": stored_session_for_coordinator(coordinator, stored_session).is_some(),
"account_status": account_status,
"suspension_known": true,
"account_state_known": true,
"suspended": suspended,
"disabled": disabled,
"deleted": deleted,
"manual_review": manual_review,
"sanitized_reason": sanitized_reason,
"next_actions": next_actions,
"private_moderation_details_exposed": false,
"signup_failure_details_exposed": false,
"coordinator_response_type": response.get("type").and_then(Value::as_str).unwrap_or("auth_status"),
"coordinator_session_requests": coordinator_session_requests,
})
}
pub(crate) fn non_interactive_browser_login_report(args: &LoginArgs) -> Value {
let message =
"browser login requires an interactive browser, but non-interactive mode is enabled";
let next_actions = vec![
"rerun without --non-interactive to open the browser",
"disasmer login --browser --plan",
"use DISASMER_AGENT_PRIVATE_KEY for automation",
];
json!({
"command": "login",
"status": "authentication_required",
"coordinator": args.coordinator,
"non_interactive": true,
"browser_requested": true,
"browser_opened": false,
"safe_failure": true,
"message": message,
"next_actions": next_actions,
"machine_error": crate::auth_scope::non_interactive_auth_machine_error(message, next_actions),
})
}
pub(crate) fn auth_state_value(cwd: &Path) -> Result<Value> {
match session_from_env()? {
CliSession::Anonymous => {
if let Some(session) = read_cli_session(cwd)? {
return Ok(json!({
"kind": session.kind,
"authenticated": true,
"source": "session_file",
"coordinator": session.coordinator,
"tenant": session.tenant,
"project": session.project,
"principal": session.user,
"cli_session_credential_kind": session.cli_session_credential_kind,
"provider_tokens_exposed_to_cli": session.provider_tokens_exposed_to_cli,
"provider_tokens_exposed_to_nodes": session.provider_tokens_sent_to_nodes,
"expires_at": session.expires_at,
"token_expiry_posture": session.token_expiry_posture,
}));
}
Ok(json!({
"kind": "anonymous",
"authenticated": false,
"source": "environment",
"token_expiry_posture": "no_session",
}))
}
CliSession::HumanSession => {
let expires_at = std::env::var("DISASMER_TOKEN_EXPIRES_AT").ok();
Ok(json!({
"kind": "human",
"authenticated": true,
"source": "DISASMER_TOKEN",
"provider_tokens_exposed_to_nodes": false,
"expires_at": expires_at,
"token_expiry_posture": if expires_at.is_some() { "expires_at" } else { "unknown_env_token" },
}))
}
CliSession::AgentPublicKey {
agent,
public_key_fingerprint,
browser_interaction_required,
..
} => Ok(json!({
"kind": "agent_public_key",
"authenticated": true,
"agent": agent,
"source": "DISASMER_AGENT_PRIVATE_KEY",
"public_key_fingerprint": public_key_fingerprint,
"browser_interaction_required": browser_interaction_required,
"token_expiry_posture": "not_applicable_public_key",
})),
}
}
pub(crate) fn login_plan(args: LoginArgs) -> LoginPlan {
let human_flow = LoginFlowPlan::Browser(HostedBrowserLoginPlan {
authorization_url: None,
requested_project: args.project.clone(),
server_owns_state: true,
server_owns_nonce: true,
pkce_required: true,
hosted_callback: true,
cli_receives_provider_authorization_code: false,
cli_submits_identity_claims: false,
});
LoginPlan {
coordinator: args.coordinator,
human_flow,
}
}
fn finalize_browser_login(
args: LoginArgs,
plan: LoginPlan,
coordinator_response: Value,
coordinator_session_requests: u64,
) -> Result<LoginCompletionReport> {
let coordinator = args.coordinator.clone();
let scoped_cli_session_received = coordinator_response
.pointer("/session/cli_session_credential_kind")
.and_then(Value::as_str)
== Some("CliDeviceSession");
let provider_tokens_sent_to_nodes = coordinator_response
.pointer("/session/provider_tokens_sent_to_nodes")
.and_then(Value::as_bool)
.unwrap_or(true);
let provider_tokens_exposed_to_cli = contains_provider_token_field(&coordinator_response);
let local_cli_session_file_written = if scoped_cli_session_received {
let cwd = std::env::current_dir()?;
let stored_session = stored_cli_session_from_login_response(
&coordinator,
&coordinator_response,
provider_tokens_exposed_to_cli,
provider_tokens_sent_to_nodes,
)?;
write_cli_session(&cwd, &stored_session)?;
write_project_config(
&cwd,
&ProjectConfig {
tenant: stored_session.tenant.clone(),
project: stored_session.project.clone(),
user: stored_session.user.clone(),
coordinator: Some(stored_session.coordinator.clone()),
},
)?;
true
} else {
false
};
Ok(LoginCompletionReport {
plan,
boundary: LoginCompletionBoundaryEvidence {
cli_contacted_coordinator: true,
coordinator_address: coordinator,
scoped_cli_session_received,
local_cli_session_file_written,
provider_tokens_persisted_locally: false,
provider_tokens_exposed_to_cli,
provider_tokens_sent_to_nodes,
coordinator_session_requests,
},
coordinator_response,
})
}
pub(crate) fn stored_cli_session_from_login_response(
coordinator: &str,
coordinator_response: &Value,
provider_tokens_exposed_to_cli: bool,
provider_tokens_sent_to_nodes: bool,
) -> Result<StoredCliSession> {
let session = coordinator_response.get("session").unwrap_or(&Value::Null);
let expires_at = session
.get("expires_at")
.or_else(|| session.get("token_expires_at"))
.and_then(Value::as_str)
.map(str::to_owned)
.or_else(|| {
session
.get("expires_at_epoch_seconds")
.and_then(Value::as_u64)
.map(|value| value.to_string())
});
let tenant = session
.get("tenant")
.and_then(Value::as_str)
.context("hosted login session omitted tenant")?;
let project = session
.get("project")
.and_then(Value::as_str)
.context("hosted login session omitted project")?;
let user = session
.get("user")
.and_then(Value::as_str)
.context("hosted login session omitted user")?;
let session_secret = session
.get("cli_session_secret")
.or_else(|| session.get("session_secret"))
.and_then(Value::as_str)
.context("hosted login session omitted CLI session secret")?;
Ok(StoredCliSession {
kind: "human".to_owned(),
coordinator: coordinator.to_owned(),
tenant: tenant.to_owned(),
project: project.to_owned(),
user: user.to_owned(),
cli_session_credential_kind: session
.get("cli_session_credential_kind")
.and_then(Value::as_str)
.unwrap_or("CliDeviceSession")
.to_owned(),
session_secret: Some(session_secret.to_owned()),
token_expiry_posture: if expires_at.is_some() {
"expires_at".to_owned()
} else {
"unknown_coordinator_session".to_owned()
},
expires_at,
provider_tokens_exposed_to_cli,
provider_tokens_sent_to_nodes,
created_at_unix_seconds: unix_timestamp_seconds(),
})
}
pub(crate) fn execute_interactive_browser_login(args: LoginArgs) -> Result<LoginCompletionReport> {
let coordinator = args.coordinator.clone();
let mut session = JsonLineSession::connect(&coordinator)?;
let started = session.request(json!({
"type": "begin_oidc_browser_login",
"requested_project": args.project,
}))?;
if started.get("type").and_then(Value::as_str) != Some("oidc_browser_login_started") {
anyhow::bail!("coordinator did not start a hosted browser login transaction");
}
let transaction_id = started
.get("transaction_id")
.and_then(Value::as_str)
.context("hosted login transaction omitted transaction id")?
.to_owned();
let polling_secret = started
.get("polling_secret")
.and_then(Value::as_str)
.context("hosted login transaction omitted polling secret")?
.to_owned();
let authorization_url = started
.get("authorization_url")
.and_then(Value::as_str)
.context("hosted login transaction omitted authorization URL")?
.to_owned();
let loopback_test_flow = coordinator.starts_with("http://")
&& crate::client::is_loopback_coordinator(&coordinator)
&& authorization_url.starts_with("http://")
&& crate::client::is_loopback_coordinator(&authorization_url);
if !authorization_url.starts_with("https://") && !loopback_test_flow {
anyhow::bail!(
"hosted login authorization URL must use HTTPS (plain HTTP is accepted only when both coordinator and identity provider are loopback test services)"
);
}
let plan = LoginPlan {
coordinator: coordinator.clone(),
human_flow: LoginFlowPlan::Browser(HostedBrowserLoginPlan {
authorization_url: Some(authorization_url.clone()),
requested_project: args.project.clone(),
server_owns_state: true,
server_owns_nonce: true,
pkce_required: true,
hosted_callback: true,
cli_receives_provider_authorization_code: false,
cli_submits_identity_claims: false,
}),
};
eprintln!("Opening Disasmer browser login: {authorization_url}");
eprintln!("Waiting for the hosted login callback to complete.");
open_browser(&authorization_url)?;
let deadline = Instant::now() + browser_login_timeout();
loop {
let response = session.request(json!({
"type": "poll_oidc_browser_login",
"transaction_id": transaction_id,
"polling_secret": polling_secret,
}))?;
match response.get("type").and_then(Value::as_str) {
Some("oidc_browser_login_pending") => {
if Instant::now() >= deadline {
anyhow::bail!("timed out waiting for hosted browser login completion");
}
std::thread::sleep(Duration::from_millis(500));
}
Some("oidc_browser_session") => {
return finalize_browser_login(args, plan, response, session.requests());
}
_ => anyhow::bail!("coordinator returned an invalid hosted login status"),
}
}
}
pub(crate) fn print_browser_login_success(report: &LoginCompletionReport) {
let session = report.coordinator_response.get("session");
let tenant = session
.and_then(|value| value.get("tenant"))
.and_then(Value::as_str)
.unwrap_or("tenant");
let project = session
.and_then(|value| value.get("project"))
.and_then(Value::as_str)
.unwrap_or("project");
let user = session
.and_then(|value| value.get("user"))
.and_then(Value::as_str)
.unwrap_or("user");
println!(
"Signed in to {} as {user} for {tenant}/{project}.",
report.plan.coordinator
);
if report.boundary.scoped_cli_session_received {
println!("Received a scoped CLI session from the coordinator.");
}
}
fn open_browser(url: &str) -> Result<()> {
let mut command = if let Some(command) = std::env::var_os("DISASMER_BROWSER_OPEN_COMMAND") {
Command::new(command)
} else {
platform_browser_command()
};
command
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
command
.spawn()
.with_context(|| format!("failed to start browser opener for {url}"))?;
Ok(())
}
#[cfg(target_os = "macos")]
fn platform_browser_command() -> Command {
Command::new("open")
}
#[cfg(target_os = "windows")]
fn platform_browser_command() -> Command {
let mut command = Command::new("cmd");
command.args(["/C", "start", ""]);
command
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
fn platform_browser_command() -> Command {
Command::new("xdg-open")
}
fn browser_login_timeout() -> Duration {
let seconds = std::env::var("DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_BROWSER_LOGIN_TRANSACTION_TIMEOUT_SECONDS);
Duration::from_secs(seconds)
}
fn unix_timestamp_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub(crate) fn contains_provider_token_field(value: &Value) -> bool {
match value {
Value::Object(object) => object.iter().any(|(key, value)| {
matches!(
key.as_str(),
"access_token" | "refresh_token" | "id_token" | "provider_token" | "oauth_token"
) || contains_provider_token_field(value)
}),
Value::Array(items) => items.iter().any(contains_provider_token_field),
_ => false,
}
}

View file

@ -0,0 +1,79 @@
use std::path::Path;
use anyhow::Result;
use serde_json::{json, Value};
use crate::client::control_endpoint_identity;
use crate::config::{
effective_scope_value, read_project_config, StoredCliSession,
DEFAULT_HOSTED_COORDINATOR_ENDPOINT,
};
use crate::errors::cli_error_summary_for_category;
use crate::LoginArgs;
pub(crate) fn login_args_for_project(mut args: LoginArgs, cwd: &Path) -> Result<LoginArgs> {
let Some(config) = read_project_config(cwd)? else {
return Ok(args);
};
args.project = effective_scope_value(&args.project, Some(&config.project), "project");
if args.coordinator == DEFAULT_HOSTED_COORDINATOR_ENDPOINT {
if let Some(coordinator) = config.coordinator {
args.coordinator = coordinator;
}
}
Ok(args)
}
pub(crate) fn session_scope_mismatch(
session: Option<&StoredCliSession>,
active_coordinator: &str,
tenant: &str,
project: &str,
user: &str,
) -> Option<Vec<&'static str>> {
session.and_then(|session| {
let mut fields = Vec::new();
if control_endpoint_identity(&session.coordinator).ok()
!= control_endpoint_identity(active_coordinator).ok()
{
fields.push("coordinator");
}
if session.tenant != tenant {
fields.push("tenant");
}
if session.project != project {
fields.push("project");
}
if session.user != user {
fields.push("user");
}
(!fields.is_empty()).then_some(fields)
})
}
pub(crate) fn session_scope_mismatch_status(fields: &[&str]) -> Value {
json!({
"checked": false,
"reason": "stored CLI session does not match the current project",
"mismatched_fields": fields,
"authenticated_for_current_project": false,
"account_status": "unknown",
"suspension_known": false,
"account_state_known": false,
"private_moderation_details_exposed": false,
"signup_failure_details_exposed": false,
"next_actions": ["disasmer login --browser"],
})
}
pub(crate) fn non_interactive_auth_machine_error(
message: &str,
next_actions: Vec<&'static str>,
) -> Value {
let mut machine_error = cli_error_summary_for_category("authentication", message);
if let Some(object) = machine_error.as_object_mut() {
object.insert("next_actions".to_owned(), json!(next_actions));
object.insert("browser_opened".to_owned(), json!(false));
}
machine_error
}

View file

@ -0,0 +1,322 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use disasmer_core::Digest;
use serde_json::{json, Value};
use wasmparser::{Parser, Payload};
use crate::errors::cli_error_summary_for_category;
use crate::{bundle_inspection, BuildArgs, BundleInspectArgs};
pub(crate) fn build_report(args: BuildArgs, cwd: PathBuf) -> Result<Value> {
let inspection = bundle_inspection(
BundleInspectArgs {
project: args.project.clone(),
source_provider: args.source_provider.clone(),
disabled_source_providers: args.disabled_source_providers.clone(),
json: true,
},
cwd,
)?;
let diagnostics = inspection.pre_schedule_diagnostics.clone();
let blocking_diagnostic = diagnostics
.iter()
.find(|diagnostic| diagnostic.severity == "error")
.cloned();
if let Some(diagnostic) = blocking_diagnostic {
let machine_category = match diagnostic.category.as_str() {
"environment" => "environment",
"source_provider" | "capability" => "capability",
_ => "unknown",
};
return Ok(json!({
"command": "build",
"status": "blocked_before_schedule",
"bundle": inspection,
"diagnostics": diagnostics,
"contains_full_repository_upload": false,
"content_addressed": false,
"debug_metadata_available": false,
"scheduled_work": false,
"machine_error": cli_error_summary_for_category(machine_category, &diagnostic.message),
}));
}
let mut wasm = compile_project_wasm(&inspection.project)?;
let environment_manifest = serde_json::to_vec(&inspection.metadata.environments)?;
append_custom_section(
&mut wasm.bytes,
"disasmer.environments",
&environment_manifest,
);
let bundle_digest = Digest::sha256(&wasm.bytes);
let task_descriptors = descriptor_records(&wasm.bytes, "disasmer.tasks")?;
let entrypoint_descriptors = descriptor_records(&wasm.bytes, "disasmer.entrypoints")?;
if task_descriptors.is_empty() {
bail!(
"compiled Wasm module contains no #[disasmer::task] descriptors; annotate at least one exported task"
);
}
if entrypoint_descriptors.is_empty() {
bail!(
"compiled Wasm module contains no #[disasmer::main] descriptors; annotate at least one entrypoint"
);
}
let output = args.output.unwrap_or_else(|| {
inspection.project.join(".disasmer/build").join(
bundle_digest
.as_str()
.trim_start_matches("sha256:")
.get(..16)
.unwrap_or("bundle"),
)
});
let bundle_artifact = write_bundle(
&output,
&wasm,
&bundle_digest,
&inspection,
&task_descriptors,
&entrypoint_descriptors,
)?;
Ok(json!({
"command": "build",
"status": "built",
"bundle": inspection,
"bundle_artifact": bundle_artifact,
"diagnostics": diagnostics,
"contains_full_repository_upload": false,
"content_addressed": true,
"debug_metadata_available": true,
"scheduled_work": false,
}))
}
fn append_custom_section(module: &mut Vec<u8>, name: &str, data: &[u8]) {
let mut section = Vec::new();
encode_unsigned_leb(name.len() as u64, &mut section);
section.extend_from_slice(name.as_bytes());
section.extend_from_slice(data);
module.push(0);
encode_unsigned_leb(section.len() as u64, module);
module.extend_from_slice(&section);
}
fn encode_unsigned_leb(mut value: u64, output: &mut Vec<u8>) {
loop {
let mut byte = (value & 0x7f) as u8;
value >>= 7;
if value != 0 {
byte |= 0x80;
}
output.push(byte);
if value == 0 {
break;
}
}
}
struct CompiledWasm {
bytes: Vec<u8>,
package: String,
target: String,
source_path: PathBuf,
}
fn compile_project_wasm(project: &Path) -> Result<CompiledWasm> {
let manifest = project.join("Cargo.toml");
let metadata_output = Command::new("cargo")
.args([
"metadata",
"--format-version",
"1",
"--no-deps",
"--manifest-path",
])
.arg(&manifest)
.output()
.with_context(|| format!("failed to run cargo metadata for {}", manifest.display()))?;
if !metadata_output.status.success() {
bail!(
"cargo metadata failed for {}: {}",
manifest.display(),
String::from_utf8_lossy(&metadata_output.stderr).trim()
);
}
let metadata: Value = serde_json::from_slice(&metadata_output.stdout)?;
let canonical_manifest = std::fs::canonicalize(&manifest)?;
let package = metadata["packages"]
.as_array()
.and_then(|packages| {
packages.iter().find(|package| {
package["manifest_path"]
.as_str()
.and_then(|path| std::fs::canonicalize(path).ok())
.as_ref()
== Some(&canonical_manifest)
})
})
.context("cargo metadata did not return the requested project package")?;
let package_name = package["name"]
.as_str()
.context("cargo package name missing")?;
let target = package["targets"]
.as_array()
.and_then(|targets| {
targets.iter().find(|target| {
target["crate_types"]
.as_array()
.is_some_and(|types| types.iter().any(|kind| kind == "cdylib"))
})
})
.context("Disasmer project library must include crate-type = [\"cdylib\"]")?;
let target_name = target["name"]
.as_str()
.context("cargo target name missing")?;
let build = Command::new("cargo")
// The MVP transports one Wasm bundle in a bounded control frame. These are
// ordinary Cargo release-profile settings, applied only to the guest build,
// that keep the product SDK/runtime inside that accepted boundary.
.env("CARGO_PROFILE_RELEASE_OPT_LEVEL", "z")
.env("CARGO_PROFILE_RELEASE_LTO", "thin")
.env("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1")
.args([
"build",
"--quiet",
"--release",
"--target",
"wasm32-unknown-unknown",
"--lib",
"--manifest-path",
])
.arg(&manifest)
.output()
.with_context(|| format!("failed to compile {} to Wasm", manifest.display()))?;
if !build.status.success() {
bail!(
"Wasm bundle compilation failed for {}: {}",
manifest.display(),
String::from_utf8_lossy(&build.stderr).trim()
);
}
let target_directory = metadata["target_directory"]
.as_str()
.context("cargo target_directory missing")?;
let source_path = Path::new(target_directory)
.join("wasm32-unknown-unknown/release")
.join(format!("{}.wasm", target_name.replace('-', "_")));
let bytes = std::fs::read(&source_path)
.with_context(|| format!("compiled Wasm module missing at {}", source_path.display()))?;
Ok(CompiledWasm {
bytes,
package: package_name.to_owned(),
target: target_name.to_owned(),
source_path,
})
}
fn descriptor_records(module: &[u8], section_name: &str) -> Result<Vec<Value>> {
let mut records: Vec<Value> = Vec::new();
for payload in Parser::new(0).parse_all(module) {
let Payload::CustomSection(section) = payload? else {
continue;
};
if section.name() != section_name {
continue;
}
for record in section
.data()
.split(|byte| *byte == b'\n' || *byte == 0)
.filter(|record| !record.is_empty())
{
records.push(serde_json::from_slice(record).with_context(|| {
format!("invalid descriptor record in Wasm custom section {section_name}")
})?);
}
}
records.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str()));
Ok(records)
}
fn write_bundle(
output: &Path,
wasm: &CompiledWasm,
bundle_digest: &Digest,
inspection: &crate::bundle::BundleInspection,
tasks: &[Value],
entrypoints: &[Value],
) -> Result<Value> {
std::fs::create_dir_all(output)?;
let module_path = output.join("module.wasm");
let task_path = output.join("task-descriptors.json");
let entrypoint_path = output.join("entrypoints.json");
let environment_path = output.join("environments.json");
let source_path = output.join("source-provider.json");
let vfs_path = output.join("vfs-seed.json");
let debug_path = output.join("debug-metadata.json");
let manifest_path = output.join("manifest.json");
std::fs::write(&module_path, &wasm.bytes)?;
write_json(&task_path, &json!(tasks))?;
write_json(&entrypoint_path, &json!(entrypoints))?;
write_json(&environment_path, &json!(inspection.metadata.environments))?;
write_json(&source_path, &json!(inspection.source_provider_manifest))?;
write_json(
&vfs_path,
&json!({
"epoch": 0,
"mounts": ["/vfs/artifacts", "/vfs/sources", "/vfs/blobs"],
"large_bytes_embedded": false,
}),
)?;
write_json(&debug_path, &json!(inspection.metadata.debug_metadata))?;
let manifest = json!({
"kind": "disasmer-bundle",
"format_version": 1,
"package": wasm.package,
"target": wasm.target,
"bundle_digest": bundle_digest,
"module": "module.wasm",
"module_size_bytes": wasm.bytes.len(),
"task_descriptors": "task-descriptors.json",
"entrypoints": "entrypoints.json",
"environments": "environments.json",
"source_provider": "source-provider.json",
"vfs_seed": "vfs-seed.json",
"debug_metadata": "debug-metadata.json",
"required_capabilities": tasks.iter().flat_map(|task| {
task["required_capabilities"].as_array().into_iter().flatten().cloned()
}).collect::<Vec<_>>(),
"metadata_identity": inspection.metadata.identity,
"coordinator_receives_source_bytes_by_default": false,
"embeds_full_repository": false,
});
write_json(&manifest_path, &manifest)?;
Ok(json!({
"directory": output,
"manifest": manifest_path,
"module": module_path,
"compiled_module_source": wasm.source_path,
"bundle_digest": bundle_digest,
"module_size_bytes": wasm.bytes.len(),
"task_descriptor_count": tasks.len(),
"entrypoint_count": entrypoints.len(),
"files": [
"manifest.json",
"module.wasm",
"task-descriptors.json",
"entrypoints.json",
"environments.json",
"source-provider.json",
"vfs-seed.json",
"debug-metadata.json",
],
}))
}
fn write_json(path: &Path, value: &Value) -> Result<()> {
let mut bytes = serde_json::to_vec_pretty(value)?;
bytes.push(b'\n');
std::fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
}

View file

@ -0,0 +1,379 @@
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use disasmer_core::{
diagnose_environment_references, discover_source_debug_probes, BundleDebugProbe,
BundleIdentityInputs, BundleMetadata, Digest, EnvironmentReference, ProjectModel,
SelectedInput, SourceProviderKind, SourceProviderManifest,
};
use serde::Serialize;
use crate::BundleInspectArgs;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct BundleInspection {
pub(crate) project: PathBuf,
pub(crate) default_source_providers: Vec<SourceProviderKind>,
pub(crate) source_provider_manifest: SourceProviderManifest,
pub(crate) source_provider_statuses: Vec<SourceProviderStatus>,
pub(crate) environment_diagnostics: Vec<EnvironmentDiagnosticReport>,
pub(crate) pre_schedule_diagnostics: Vec<CliDiagnostic>,
pub(crate) metadata: BundleMetadata,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct SourceProviderStatus {
pub(crate) provider: String,
pub(crate) status: String,
pub(crate) active: bool,
pub(crate) reason: String,
pub(crate) coordinator_checkout_required: bool,
pub(crate) coordinator_receives_source_bytes_by_default: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct EnvironmentDiagnosticReport {
pub(crate) path: String,
pub(crate) reference: EnvironmentReference,
pub(crate) message: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct CliDiagnostic {
pub(crate) severity: String,
pub(crate) category: String,
pub(crate) code: String,
pub(crate) message: String,
pub(crate) next_actions: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct SourceProviderSelection {
active_provider: SourceProviderKind,
statuses: Vec<SourceProviderStatus>,
}
pub(crate) fn discovered_environment_names(inspection: Option<&BundleInspection>) -> Vec<String> {
inspection
.map(|inspection| {
inspection
.metadata
.environments
.iter()
.map(|environment| environment.name.clone())
.collect()
})
.unwrap_or_default()
}
pub(crate) fn bundle_inspection(args: BundleInspectArgs, cwd: PathBuf) -> Result<BundleInspection> {
let project = args.project.unwrap_or(cwd);
let model = ProjectModel::discover_without_config(&project)?;
let selected_inputs = discover_selected_inputs(&project)?;
let debug_probes = discover_debug_probes(&project, &selected_inputs)?;
let provider_selection = source_provider_selection(
&project,
args.source_provider.as_deref(),
&args.disabled_source_providers,
);
let source_provider_manifest = source_provider_manifest(&provider_selection.active_provider);
source_provider_manifest.validate_public_mvp()?;
let environment_diagnostics =
environment_diagnostics_for_inputs(&project, &selected_inputs, &model.environments)?;
let identity_inputs = BundleIdentityInputs {
wasm_code: wasm_source_proxy_digest(&selected_inputs),
task_abi: task_abi_digest(&model),
entrypoints: model.entrypoints.keys().cloned().collect(),
default_entrypoint: model.default_entrypoint.clone(),
environments: model.environments.clone(),
source_provider_manifest: source_provider_manifest.digest.clone(),
source_transfer_policy: source_provider_manifest.transfer_policy.clone(),
selected_inputs,
};
let mut metadata = identity_inputs.inspectable_metadata();
metadata.debug_metadata.probes = debug_probes;
let pre_schedule_diagnostics = pre_schedule_diagnostics(
&provider_selection.statuses,
&environment_diagnostics,
&model.environments,
);
Ok(BundleInspection {
project,
default_source_providers: vec![SourceProviderKind::Filesystem, SourceProviderKind::Git],
source_provider_manifest,
source_provider_statuses: provider_selection.statuses,
environment_diagnostics,
pre_schedule_diagnostics,
metadata,
})
}
fn discover_selected_inputs(project: &Path) -> Result<Vec<SelectedInput>> {
let mut inputs = Vec::new();
for path in [
"Cargo.toml",
"Cargo.lock",
"src/main.rs",
"src/lib.rs",
"src/build.rs",
] {
let absolute = project.join(path);
if !absolute.is_file() {
continue;
}
let bytes = std::fs::read(&absolute)?;
inputs.push(SelectedInput {
path: path.to_owned(),
digest: Digest::from_parts([
b"bundle-selected-input:v1".as_slice(),
path.as_bytes(),
bytes.as_slice(),
]),
});
}
Ok(inputs)
}
fn source_provider_selection(
project: &Path,
requested_provider: Option<&str>,
disabled_providers: &[String],
) -> SourceProviderSelection {
let has_git_checkout = project.join(".git").exists();
let disabled = disabled_providers
.iter()
.map(|provider| provider.trim().to_ascii_lowercase())
.collect::<BTreeSet<_>>();
let requested = requested_provider.map(source_provider_kind_from_id);
let active_provider = requested.clone().unwrap_or_else(|| {
if has_git_checkout && !disabled.contains("git") {
SourceProviderKind::Git
} else {
SourceProviderKind::Filesystem
}
});
let active_provider_id = active_provider.provider_id().to_owned();
let mut statuses = vec![
builtin_source_provider_status(
SourceProviderKind::Filesystem,
true,
disabled.contains("filesystem"),
active_provider_id == "filesystem",
),
builtin_source_provider_status(
SourceProviderKind::Git,
has_git_checkout,
disabled.contains("git"),
active_provider_id == "git",
),
SourceProviderStatus {
provider: "custom".to_owned(),
status: "disabled".to_owned(),
active: false,
reason: "no custom source-provider module was configured for this project".to_owned(),
coordinator_checkout_required: false,
coordinator_receives_source_bytes_by_default: false,
},
];
if let Some(SourceProviderKind::Custom(provider)) = requested {
statuses.push(SourceProviderStatus {
provider,
status: "unsupported".to_owned(),
active: true,
reason: "custom source-provider modules must be supplied by public plugin/API code before use".to_owned(),
coordinator_checkout_required: false,
coordinator_receives_source_bytes_by_default: false,
});
}
SourceProviderSelection {
active_provider,
statuses,
}
}
fn source_provider_kind_from_id(provider: &str) -> SourceProviderKind {
match provider.trim().to_ascii_lowercase().as_str() {
"filesystem" => SourceProviderKind::Filesystem,
"git" => SourceProviderKind::Git,
other => SourceProviderKind::Custom(other.to_owned()),
}
}
fn builtin_source_provider_status(
kind: SourceProviderKind,
available: bool,
disabled: bool,
active: bool,
) -> SourceProviderStatus {
let provider = kind.provider_id().to_owned();
let (status, reason) = if disabled {
(
"disabled",
format!("source provider `{provider}` was disabled by CLI override"),
)
} else if available {
(
"enabled",
format!("source provider `{provider}` is available in this checkout"),
)
} else {
(
"missing",
format!("source provider `{provider}` is not available for this checkout"),
)
};
SourceProviderStatus {
provider,
status: status.to_owned(),
active,
reason,
coordinator_checkout_required: false,
coordinator_receives_source_bytes_by_default: false,
}
}
fn source_provider_manifest(kind: &SourceProviderKind) -> SourceProviderManifest {
SourceProviderManifest::local_first(
kind.clone(),
"default source provider manifest; snapshot creation can be scheduled as a node task",
)
}
fn environment_diagnostics_for_inputs(
project: &Path,
selected_inputs: &[SelectedInput],
environments: &[disasmer_core::EnvironmentResource],
) -> Result<Vec<EnvironmentDiagnosticReport>> {
let mut diagnostics = Vec::new();
for input in selected_inputs {
if !input.path.ends_with(".rs") {
continue;
}
let source = std::fs::read_to_string(project.join(&input.path))
.with_context(|| format!("failed to read {}", project.join(&input.path).display()))?;
diagnostics.extend(
diagnose_environment_references(&source, environments)
.into_iter()
.map(|diagnostic| EnvironmentDiagnosticReport {
path: input.path.clone(),
reference: diagnostic.reference,
message: diagnostic.message,
}),
);
}
Ok(diagnostics)
}
fn discover_debug_probes(
project: &Path,
selected_inputs: &[SelectedInput],
) -> Result<Vec<BundleDebugProbe>> {
let mut probes = Vec::new();
for input in selected_inputs {
if !input.path.ends_with(".rs") {
continue;
}
let source = std::fs::read_to_string(project.join(&input.path))
.with_context(|| format!("failed to read {}", project.join(&input.path).display()))?;
probes.extend(discover_source_debug_probes(&input.path, &source));
}
Ok(probes)
}
fn pre_schedule_diagnostics(
source_provider_statuses: &[SourceProviderStatus],
environment_diagnostics: &[EnvironmentDiagnosticReport],
environments: &[disasmer_core::EnvironmentResource],
) -> Vec<CliDiagnostic> {
let mut diagnostics = Vec::new();
diagnostics.extend(
environment_diagnostics
.iter()
.map(|diagnostic| CliDiagnostic {
severity: "error".to_owned(),
category: "environment".to_owned(),
code: "missing_environment".to_owned(),
message: format!("{} at {}", diagnostic.message, diagnostic.path),
next_actions: vec![
"create the missing envs/<name>/Containerfile or envs/<name>/Dockerfile"
.to_owned(),
"rerun disasmer inspect".to_owned(),
],
}),
);
diagnostics.extend(
source_provider_statuses
.iter()
.filter(|status| {
status.active
&& matches!(
status.status.as_str(),
"missing" | "disabled" | "unsupported"
)
})
.map(|status| CliDiagnostic {
severity: "error".to_owned(),
category: "source_provider".to_owned(),
code: format!("source_provider_{}", status.status),
message: format!(
"active source provider `{}` is {}: {}",
status.provider, status.status, status.reason
),
next_actions: vec![
"choose an available source provider with --source-provider".to_owned(),
"rerun disasmer inspect --json".to_owned(),
],
}),
);
for environment in environments {
if environment.requirements.capabilities.is_empty() {
continue;
}
let mut capabilities = environment
.requirements
.capabilities
.iter()
.map(|capability| format!("{capability:?}"))
.collect::<Vec<_>>();
capabilities.sort();
diagnostics.push(CliDiagnostic {
severity: "info".to_owned(),
category: "capability".to_owned(),
code: "environment_capability_requirements".to_owned(),
message: format!(
"environment `{}` requires node capabilities: {}",
environment.name,
capabilities.join(", ")
),
next_actions: vec![
"attach a node that reports these capabilities before scheduling work".to_owned(),
],
});
}
diagnostics
}
fn wasm_source_proxy_digest(selected_inputs: &[SelectedInput]) -> Digest {
let mut parts = vec![b"wasm-source-proxy:v1".to_vec()];
for input in selected_inputs {
parts.push(input.path.as_bytes().to_vec());
parts.push(input.digest.as_str().as_bytes().to_vec());
}
Digest::from_parts(parts)
}
pub(crate) fn task_abi_digest(model: &ProjectModel) -> Digest {
let mut parts = vec![b"task-abi:v1".to_vec()];
for entrypoint in model.entrypoints.values() {
parts.push(entrypoint.name.as_bytes().to_vec());
parts.push(entrypoint.function.as_bytes().to_vec());
}
Digest::from_parts(parts)
}

View file

@ -0,0 +1,183 @@
use anyhow::{Context, Result};
use disasmer_control::{endpoint_identity, endpoint_is_loopback, ControlSession};
use disasmer_core::coordinator_wire_request;
use serde_json::{json, Value};
use crate::config::StoredCliSession;
use crate::errors::cli_error_summary;
use crate::CliScopeArgs;
pub(crate) struct JsonLineSession {
inner: ControlSession,
}
impl JsonLineSession {
pub(crate) fn connect(addr: &str) -> Result<Self> {
let inner = ControlSession::connect(addr)
.with_context(|| format!("failed to connect to coordinator {addr}"))?;
Ok(Self { inner })
}
pub(crate) fn request(&mut self, value: Value) -> Result<Value> {
let response = self.request_allow_error(value)?;
if response.get("type").and_then(Value::as_str) == Some("error") {
let message = response
.get("message")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| response.to_string());
let machine_error = cli_error_summary(&message);
let category = machine_error
.get("category")
.and_then(Value::as_str)
.unwrap_or("unknown");
let exit_code = machine_error
.get("stable_exit_code")
.and_then(Value::as_i64)
.unwrap_or(1);
anyhow::bail!("coordinator error ({category}, exit {exit_code}): {response}");
}
Ok(response)
}
pub(crate) fn request_allow_error(&mut self, value: Value) -> Result<Value> {
let request_id = format!("cli-{}", self.inner.requests() + 1);
let wire_request = coordinator_wire_request(request_id, value);
self.inner
.request(&wire_request)
.map_err(anyhow::Error::from)
}
pub(crate) fn requests(&self) -> u64 {
self.inner.requests()
}
}
pub(crate) fn control_endpoint_identity(endpoint: &str) -> Result<String> {
endpoint_identity(endpoint).map_err(anyhow::Error::from)
}
pub(crate) fn authenticated_or_local_trusted_request(
coordinator: &str,
stored_session: Option<&StoredCliSession>,
authenticated_request: Value,
local_trusted_request: Value,
) -> Result<Value> {
if let Some(session_secret) = stored_session_for_coordinator(coordinator, stored_session)
.and_then(|session| session.session_secret.as_ref())
{
Ok(json!({
"type": "authenticated",
"session_secret": session_secret,
"request": authenticated_request,
}))
} else if is_loopback_coordinator(coordinator) {
Ok(local_trusted_request)
} else {
anyhow::bail!(
"no authenticated CLI session matches coordinator {coordinator}; run `disasmer login --browser` from the current project"
)
}
}
pub(crate) fn is_loopback_coordinator(coordinator: &str) -> bool {
endpoint_is_loopback(coordinator)
}
pub(crate) fn stored_session_for_coordinator<'a>(
coordinator: &str,
stored_session: Option<&'a StoredCliSession>,
) -> Option<&'a StoredCliSession> {
stored_session.filter(|session| {
session.session_secret.is_some()
&& control_endpoint_identity(&session.coordinator).ok()
== control_endpoint_identity(coordinator).ok()
})
}
pub(crate) fn list_task_events_if_available_with_session(
coordinator: Option<&str>,
scope: &CliScopeArgs,
process: Option<String>,
stored_session: Option<&StoredCliSession>,
) -> Result<Option<Value>> {
let Some(coordinator) = coordinator else {
return Ok(None);
};
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "list_task_events",
"process": process,
}),
json!({
"type": "list_task_events",
"tenant": scope.tenant,
"project": scope.project,
"actor_user": scope.user,
"process": process,
}),
)?)?;
Ok(Some(json!({
"coordinator": coordinator,
"response": response,
"coordinator_session_requests": session.requests(),
})))
}
pub(crate) fn list_attached_nodes_if_available_with_session(
coordinator: Option<&str>,
scope: &CliScopeArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
let Some(coordinator) = coordinator else {
return Ok(json!({
"checked": false,
"source": "no_coordinator",
"count": 0,
"online": 0,
"response": null,
}));
};
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "list_node_descriptors",
}),
json!({
"type": "list_node_descriptors",
"tenant": scope.tenant,
"project": scope.project,
"actor_user": scope.user,
}),
)?)?;
let descriptors = response
.get("descriptors")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let online = descriptors
.iter()
.filter(|descriptor| {
descriptor
.get("online")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.count();
Ok(json!({
"checked": true,
"source": "coordinator",
"coordinator": coordinator,
"count": descriptors.len(),
"online": online,
"response": response,
"coordinator_session_requests": session.requests(),
}))
}

View file

@ -0,0 +1,149 @@
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::CliScopeArgs;
pub(crate) const DEFAULT_HOSTED_COORDINATOR_ENDPOINT: &str = "https://disasmer.michelpaulissen.com";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ProjectConfig {
pub(crate) tenant: String,
pub(crate) project: String,
pub(crate) user: String,
pub(crate) coordinator: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct StoredCliSession {
pub(crate) kind: String,
pub(crate) coordinator: String,
pub(crate) tenant: String,
pub(crate) project: String,
pub(crate) user: String,
pub(crate) cli_session_credential_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) session_secret: Option<String>,
pub(crate) token_expiry_posture: String,
pub(crate) expires_at: Option<String>,
pub(crate) provider_tokens_exposed_to_cli: bool,
pub(crate) provider_tokens_sent_to_nodes: bool,
pub(crate) created_at_unix_seconds: u64,
}
pub(crate) fn default_hosted_coordinator_endpoint() -> String {
DEFAULT_HOSTED_COORDINATOR_ENDPOINT.to_owned()
}
pub(crate) fn project_config_file(project: &Path) -> PathBuf {
project.join(".disasmer").join("project.json")
}
pub(crate) fn session_config_file(project: &Path) -> PathBuf {
project.join(".disasmer").join("session.json")
}
pub(crate) fn read_project_config(project: &Path) -> Result<Option<ProjectConfig>> {
let file = project_config_file(project);
if !file.exists() {
return Ok(None);
}
let bytes =
std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
let config = serde_json::from_slice(&bytes)
.with_context(|| format!("failed to parse {}", file.display()))?;
Ok(Some(config))
}
pub(crate) fn write_project_config(project: &Path, config: &ProjectConfig) -> Result<()> {
let file = project_config_file(project);
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&file, serde_json::to_vec_pretty(config)?)
.with_context(|| format!("failed to write {}", file.display()))
}
pub(crate) fn read_cli_session(project: &Path) -> Result<Option<StoredCliSession>> {
let file = session_config_file(project);
if !file.exists() {
return Ok(None);
}
let bytes =
std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
let session = serde_json::from_slice(&bytes)
.with_context(|| format!("failed to parse {}", file.display()))?;
Ok(Some(session))
}
pub(crate) fn write_cli_session(project: &Path, session: &StoredCliSession) -> Result<PathBuf> {
let file = session_config_file(project);
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let bytes = serde_json::to_vec_pretty(session)?;
let mut options = std::fs::OpenOptions::new();
options.create(true).truncate(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
use std::io::Write;
let mut output = options
.open(&file)
.with_context(|| format!("failed to open {}", file.display()))?;
output
.write_all(&bytes)
.with_context(|| format!("failed to write {}", file.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("failed to secure {}", file.display()))?;
}
Ok(file)
}
pub(crate) fn effective_project_scope(
scope: &CliScopeArgs,
config: Option<&ProjectConfig>,
) -> CliScopeArgs {
CliScopeArgs {
coordinator: scope
.coordinator
.clone()
.or_else(|| config.and_then(|config| config.coordinator.clone())),
tenant: effective_scope_value(
&scope.tenant,
config.map(|config| config.tenant.as_str()),
"tenant",
),
project: effective_scope_value(
&scope.project,
config.map(|config| config.project.as_str()),
"project",
),
user: effective_scope_value(
&scope.user,
config.map(|config| config.user.as_str()),
"user",
),
json: scope.json,
}
}
pub(crate) fn effective_scope_value(
cli_value: &str,
config_value: Option<&str>,
default_value: &str,
) -> String {
if cli_value == default_value {
config_value.unwrap_or(cli_value).to_owned()
} else {
cli_value.to_owned()
}
}

View file

@ -0,0 +1,35 @@
use serde_json::{json, Value};
use crate::errors::cli_error_summary_for_category;
pub(crate) fn confirmation_required_report(
command: &str,
operation: &str,
target: Value,
confirm_command: String,
) -> Value {
let message = format!("{command} requires --yes before {operation}");
let next_actions = json!([
confirm_command,
"review the command target before confirming",
"rerun with --json to inspect the safe failure"
]);
let mut machine_error = cli_error_summary_for_category("policy", &message);
if let Some(object) = machine_error.as_object_mut() {
object.insert("confirmation_required".to_owned(), json!(true));
object.insert("next_actions".to_owned(), next_actions.clone());
}
json!({
"command": command,
"status": "confirmation_required",
"operation": operation,
"target": target,
"requires_confirmation": true,
"confirmation_required": true,
"explicit_user_action_required": true,
"coordinator_request_sent": false,
"safe_failure": true,
"next_actions": next_actions,
"machine_error": machine_error,
})
}

View file

@ -0,0 +1,120 @@
use std::process::Command;
use anyhow::{Context, Result};
use serde_json::{json, Value};
use crate::client::{
authenticated_or_local_trusted_request, stored_session_for_coordinator, JsonLineSession,
};
use crate::config::StoredCliSession;
use crate::tools::dap_binary_path;
use crate::{DapArgs, DebugAttachArgs};
pub(crate) fn dap_plan(args: DapArgs) -> Result<Value> {
Ok(json!({
"command": "dap",
"adapter": dap_binary_path()?.display().to_string(),
"args": args.args,
"private_website_required": false,
}))
}
pub(crate) fn exec_dap(args: DapArgs) -> Result<()> {
let status = Command::new(dap_binary_path()?)
.args(args.args)
.status()
.context("failed to launch disasmer-debug-dap")?;
if !status.success() {
anyhow::bail!("disasmer-debug-dap exited with {status}");
}
Ok(())
}
#[cfg(test)]
pub(crate) fn debug_attach_report_with_dap(args: DebugAttachArgs, dap: String) -> Result<Value> {
debug_attach_report_with_dap_and_session(args, dap, None)
}
pub(crate) fn debug_attach_report_with_dap_and_session(
mut args: DebugAttachArgs,
dap: String,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
if args.scope.coordinator.is_none() {
args.scope.coordinator = stored_session
.filter(|session| session.session_secret.is_some())
.map(|session| session.coordinator.clone());
}
if let Some(bound_session) = args
.scope
.coordinator
.as_deref()
.and_then(|coordinator| stored_session_for_coordinator(coordinator, stored_session))
{
args.scope.tenant = bound_session.tenant.clone();
args.scope.project = bound_session.project.clone();
args.scope.user = bound_session.user.clone();
}
if let Some(coordinator) = &args.scope.coordinator {
let tenant = args.scope.tenant.clone();
let project = args.scope.project.clone();
let user = args.scope.user.clone();
let process = args.process.clone();
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "debug_attach",
"process": process,
}),
json!({
"type": "debug_attach",
"tenant": tenant,
"project": project,
"actor_user": user,
"process": process,
}),
)?)?;
let authorization = response.get("authorization").cloned().unwrap_or_else(
|| json!({"allowed": false, "reason": "missing authorization response"}),
);
return Ok(json!({
"command": "debug attach",
"process": process,
"coordinator": coordinator,
"tenant": tenant,
"project": project,
"user": user,
"dap": dap,
"authorized": authorization
.get("allowed")
.cloned()
.unwrap_or(json!(false)),
"authorization": authorization,
"audit_event": response.get("audit_event").cloned().unwrap_or(Value::Null),
"charged_debug_read_bytes": response
.get("charged_debug_read_bytes")
.cloned()
.unwrap_or_else(|| json!(0)),
"used_debug_read_bytes": response
.get("used_debug_read_bytes")
.cloned()
.unwrap_or_else(|| json!(0)),
"debug_reads_quota_limited": true,
"private_website_required": false,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "debug attach",
"process": args.process,
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"dap": dap,
"authorized": "unknown_without_coordinator",
"debug_reads_quota_limited": "unknown_without_coordinator",
"private_website_required": false,
}))
}

View file

@ -0,0 +1,343 @@
use anyhow::Result;
use clap::Parser;
use super::*;
pub(crate) fn run_cli() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Doctor(args) => {
let json_output = args.scope.json;
let report = doctor_report(args, std::env::current_dir()?)?;
emit_report(&report, json_output)?;
}
Commands::Login(args) => {
let args = crate::auth_scope::login_args_for_project(args, &std::env::current_dir()?)?;
let json_output = args.json;
if args.non_interactive && !args.plan {
let report = non_interactive_browser_login_report(&args);
emit_report(&report, json_output)?;
} else if !args.plan {
let report = execute_interactive_browser_login(args)?;
if json_output {
emit_report(&report, true)?;
} else {
print_browser_login_success(&report);
}
} else {
let plan = login_plan(args);
emit_report(&plan, json_output)?;
}
}
Commands::Logout(args) => {
let json_output = args.scope.json;
let report = logout_report(args, std::env::current_dir()?, "logout")?;
emit_report(&report, json_output)?;
}
Commands::Auth { command } => {
let (report, json_output) = match command {
AuthCommands::Status(args) => {
let json_output = args.scope.json;
(
auth_status_report(args, std::env::current_dir()?)?,
json_output,
)
}
AuthCommands::ConnectSelfHosted(args) => {
let json_output = args.scope.json;
let secret = read_session_secret_from_stdin()?;
(
connect_self_hosted_report(args, std::env::current_dir()?, secret)?,
json_output,
)
}
AuthCommands::Logout(args) => {
let json_output = args.scope.json;
(
auth_logout_report(args, std::env::current_dir()?)?,
json_output,
)
}
};
emit_report(&report, json_output)?;
}
Commands::Agent {
command: AgentCommands::Enroll(args),
} => {
let json_output = args.json;
let plan = agent_enrollment_plan(args);
emit_report(&plan, json_output)?;
}
Commands::Key { command } => {
let cwd = std::env::current_dir()?;
let stored_session = read_cli_session(&cwd)?;
let (report, json_output) = match command {
KeyCommands::Add(args) => {
let json_output = args.scope.json;
(
key_add_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
KeyCommands::List(args) => {
let json_output = args.scope.json;
(
key_list_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
KeyCommands::Revoke(args) => {
let json_output = args.scope.json;
(
key_revoke_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
};
emit_report(&report, json_output)?;
}
Commands::Project { command } => {
let cwd = std::env::current_dir()?;
let (report, json_output) = match command {
ProjectCommands::Init(args) => {
let json_output = args.scope.json;
(project_init_report(args, cwd)?, json_output)
}
ProjectCommands::Status(args) => {
let json_output = args.scope.json;
(project_status_report(args, cwd)?, json_output)
}
ProjectCommands::List(args) => {
let json_output = args.scope.json;
(project_list_report(args, cwd)?, json_output)
}
ProjectCommands::Select(args) => {
let json_output = args.scope.json;
(project_select_report(args, cwd)?, json_output)
}
};
emit_report(&report, json_output)?;
}
Commands::Inspect(args) => {
let json_output = args.json;
let inspection = bundle_inspection(args, std::env::current_dir()?)?;
emit_report(&inspection, json_output)?;
}
Commands::Build(args) => {
let json_output = args.json;
let report = build_report(args, std::env::current_dir()?)?;
emit_report(&report, json_output)?;
}
Commands::Bundle {
command: BundleCommands::Inspect(args),
} => {
let json_output = args.json;
let inspection = bundle_inspection(args, std::env::current_dir()?)?;
emit_report(&inspection, json_output)?;
}
Commands::Run(args) => {
let json_output = args.json;
let cwd = std::env::current_dir()?;
let session_project = args.project.clone().unwrap_or_else(|| cwd.clone());
let session = session_from_sources(&session_project)?;
let report = run_report(args, cwd, session)?;
emit_report(&report, json_output)?;
}
Commands::Node {
command: NodeCommands::Attach(args),
} => {
let json_output = args.json;
if args.coordinator.is_some() {
let report = execute_node_attach(args)?;
emit_report(&report, json_output)?;
} else {
let plan = attach_plan(args);
emit_report(&plan, json_output)?;
}
}
Commands::Node { command } => {
let cwd = std::env::current_dir()?;
let (report, json_output) = match command {
NodeCommands::Enroll(args) => {
let json_output = args.scope.json;
(node_enroll_report(args, cwd.clone())?, json_output)
}
NodeCommands::List(args) => {
let json_output = args.scope.json;
(node_list_report(args, cwd.clone())?, json_output)
}
NodeCommands::Status(args) => {
let json_output = args.scope.json;
(node_status_report(args, cwd.clone())?, json_output)
}
NodeCommands::Revoke(args) => {
let json_output = args.scope.json;
(node_revoke_report(args, cwd)?, json_output)
}
NodeCommands::Attach(_) => unreachable!("node attach is handled above"),
};
emit_report(&report, json_output)?;
}
Commands::Process { command } => {
let cwd = std::env::current_dir()?;
let stored_session = read_cli_session(&cwd)?;
let (report, json_output) = match command {
ProcessCommands::List(args) => {
let json_output = args.scope.json;
(
process_list_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
ProcessCommands::Status(args) => {
let json_output = args.scope.json;
(
process_status_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
ProcessCommands::Restart(args) => {
let json_output = args.scope.json;
(
process_restart_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
ProcessCommands::Cancel(args) => {
let json_output = args.scope.json;
(
process_cancel_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
ProcessCommands::Abort(args) => {
let json_output = args.scope.json;
(
process_abort_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
};
emit_report(&report, json_output)?;
}
Commands::Task { command } => {
let cwd = std::env::current_dir()?;
let stored_session = read_cli_session(&cwd)?;
let (report, json_output) = match command {
TaskCommands::List(args) => {
let json_output = args.scope.json;
(
task_list_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
TaskCommands::Restart(args) => {
let json_output = args.scope.json;
(
task_restart_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
};
emit_report(&report, json_output)?;
}
Commands::Logs(args) => {
let json_output = args.scope.json;
let cwd = std::env::current_dir()?;
let stored_session = read_cli_session(&cwd)?;
emit_report(
&logs_report_with_session(args, stored_session.as_ref())?,
json_output,
)?;
}
Commands::Artifact { command } => {
let cwd = std::env::current_dir()?;
let stored_session = read_cli_session(&cwd)?;
let (report, json_output) = match command {
ArtifactCommands::List(args) => {
let json_output = args.scope.json;
(
artifact_list_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
ArtifactCommands::Download(args) => {
let json_output = args.scope.json;
(
artifact_download_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
ArtifactCommands::Export(args) => {
let json_output = args.scope.json;
(
artifact_export_report_with_session(args, stored_session.as_ref())?,
json_output,
)
}
};
emit_report(&report, json_output)?;
}
Commands::Dap(args) => {
let json_output = args.json;
if args.plan {
emit_report(&dap_plan(args)?, json_output)?;
} else {
return exec_dap(args);
}
}
Commands::Debug {
command: DebugCommands::Attach(args),
} => {
let json_output = args.scope.json;
let cwd = std::env::current_dir()?;
let stored_session = read_cli_session(&cwd)?;
let dap = dap_binary_path()?.display().to_string();
emit_report(
&debug_attach_report_with_dap_and_session(args, dap, stored_session.as_ref())?,
json_output,
)?;
}
Commands::Quota {
command: QuotaCommands::Status(args),
} => {
let json_output = args.scope.json;
emit_report(
&quota_status_report(args, std::env::current_dir()?)?,
json_output,
)?;
}
Commands::Admin { command } => {
let (report, json_output) = match command {
AdminCommands::Status(args) => {
let json_output = args.scope.json;
(admin_status_report(args)?, json_output)
}
AdminCommands::Bootstrap(args) => {
let json_output = args.scope.json;
(
admin_bootstrap_report(args, std::env::current_dir()?)?,
json_output,
)
}
AdminCommands::RevokeNode(args) => {
let json_output = args.scope.json;
(
node_revoke_report(args, std::env::current_dir()?)?,
json_output,
)
}
AdminCommands::StopProcess(args) => {
let json_output = args.scope.json;
(process_cancel_report(args)?, json_output)
}
AdminCommands::SuspendTenant(args) => {
let json_output = args.scope.json;
(admin_suspend_tenant_report(args)?, json_output)
}
};
emit_report(&report, json_output)?;
}
}
Ok(())
}

View file

@ -0,0 +1,163 @@
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
use disasmer_control::ControlSession;
use disasmer_core::{coordinator_wire_request, Capability, NodeCapabilities};
use serde_json::{json, Value};
use crate::auth::auth_state_value;
use crate::config::read_project_config;
use crate::tools::{command_available, sibling_binary};
use crate::DoctorArgs;
const DOCTOR_COORDINATOR_TIMEOUT: Duration = Duration::from_millis(500);
pub(crate) fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result<Value> {
let config = read_project_config(&cwd)?;
let coordinator = args.scope.coordinator.or_else(|| {
config
.as_ref()
.and_then(|config| config.coordinator.clone())
});
let coordinator_reachability = coordinator_reachability(coordinator.as_deref());
let dependencies = json!({
"cargo": command_available("cargo"),
"git": command_available("git"),
"podman": command_available("podman"),
"disasmer-node": command_available("disasmer-node") || sibling_binary("disasmer-node").is_some(),
"disasmer-coordinator": command_available("disasmer-coordinator") || sibling_binary("disasmer-coordinator").is_some(),
"disasmer-debug-dap": command_available("disasmer-debug-dap") || sibling_binary("disasmer-debug-dap").is_some(),
});
let node_readiness = NodeCapabilities::detect_current();
let node_readiness_summary = node_readiness_summary(&node_readiness, &dependencies);
Ok(json!({
"command": "doctor",
"cwd": cwd,
"coordinator": coordinator,
"coordinator_reachability": coordinator_reachability,
"auth": auth_state_value(&cwd)?,
"project": config,
"dependencies": dependencies,
"node_readiness": node_readiness,
"node_readiness_summary": node_readiness_summary,
"next_actions": [
"disasmer login --browser",
"disasmer project init",
"disasmer node attach",
"disasmer run"
]
}))
}
fn node_readiness_summary(capabilities: &NodeCapabilities, dependencies: &Value) -> Value {
let has_command = capabilities.capabilities.contains(&Capability::Command);
let has_vfs_artifacts = capabilities
.capabilities
.contains(&Capability::VfsArtifacts);
let has_source_filesystem = capabilities
.capabilities
.contains(&Capability::SourceFilesystem);
let has_container_backend = capabilities
.environment_backends
.contains(&disasmer_core::EnvironmentBackend::Container);
let podman_available = dependencies
.get("podman")
.and_then(Value::as_bool)
.unwrap_or(false);
let git_available = dependencies
.get("git")
.and_then(Value::as_bool)
.unwrap_or(false);
let disasmer_node_available = dependencies
.get("disasmer-node")
.and_then(Value::as_bool)
.unwrap_or(false);
let mut missing = Vec::new();
if has_container_backend && !podman_available {
missing.push("podman");
}
if capabilities.source_providers.contains("git") && !git_available {
missing.push("git");
}
if !disasmer_node_available {
missing.push("disasmer-node");
}
let basic_runtime_ready = true;
let local_dependencies_ready = missing.is_empty();
let status = if basic_runtime_ready && local_dependencies_ready {
"ready_to_attach"
} else if basic_runtime_ready {
"local_dependencies_missing"
} else {
"limited_capabilities"
};
let next_actions = if status == "ready_to_attach" {
vec![
"disasmer node enroll",
"disasmer node attach",
"disasmer-node --worker",
]
} else {
vec![
"install missing local dependencies",
"rerun disasmer doctor",
]
};
json!({
"status": status,
"explicit_attach_required": true,
"command_execution_capability": has_command,
"wasm_runtime": "wasmtime",
"wasm_runtime_is_baseline": true,
"artifact_capability": has_vfs_artifacts,
"source_filesystem_capability": has_source_filesystem,
"container_backend_reported": has_container_backend,
"podman_binary_available": podman_available,
"git_binary_available": git_available,
"node_binary_available": disasmer_node_available,
"missing_local_dependencies": missing,
"source_providers": capabilities.source_providers.iter().collect::<Vec<_>>(),
"next_actions": next_actions,
})
}
fn coordinator_reachability(coordinator: Option<&str>) -> Value {
let Some(coordinator) = coordinator else {
return json!({
"checked": false,
"status": "not_configured",
"next_action": "run disasmer login --browser or pass --coordinator"
});
};
match ping_coordinator(coordinator, DOCTOR_COORDINATOR_TIMEOUT) {
Ok(response) => json!({
"checked": true,
"status": "reachable",
"coordinator": coordinator,
"response": response
}),
Err(err) => json!({
"checked": true,
"status": "unreachable",
"coordinator": coordinator,
"error": err.to_string(),
"next_action": "check the coordinator URL, network, and service status"
}),
}
}
fn ping_coordinator(coordinator: &str, timeout: Duration) -> Result<Value> {
let mut session = ControlSession::connect_with_timeouts(coordinator, timeout, timeout)
.with_context(|| format!("failed to connect to coordinator {coordinator}"))?;
session
.request(&coordinator_wire_request(
"doctor-1",
json!({ "type": "ping" }),
))
.with_context(|| format!("coordinator ping failed for {coordinator}"))
}

View file

@ -0,0 +1,255 @@
use serde_json::{json, Value};
pub(crate) fn cli_error_summary(message: &str) -> Value {
let category = classify_cli_error_message(message);
cli_error_summary_for_category(category, message)
}
pub(crate) fn cli_error_summary_with_default(
message: &str,
default_category: &'static str,
) -> Value {
let category = classify_cli_error_message(message);
let category = if category == "unknown" {
default_category
} else {
category
};
cli_error_summary_for_category(category, message)
}
pub(crate) fn cli_error_summary_for_category(category: &'static str, message: &str) -> Value {
let mut summary = json!({
"category": category,
"stable_exit_code": cli_error_exit_code(category),
"process_exit_code_applied": false,
"retryable_after_user_action": cli_error_retryable_after_user_action(category),
"message": message,
"safe_failure": true,
"next_actions": cli_error_next_actions(category),
});
if category == "quota" {
if let Some(object) = summary.as_object_mut() {
object.insert(
"resource_category".to_owned(),
json!(
quota_error_resource_category(message).unwrap_or_else(|| "unknown".to_owned())
),
);
object.insert("community_tier_language".to_owned(), json!(true));
object.insert("community_tier_label".to_owned(), json!("community tier"));
object.insert("private_abuse_heuristics_exposed".to_owned(), json!(false));
}
}
summary
}
fn quota_error_resource_category(message: &str) -> Option<String> {
let lower = message.to_ascii_lowercase();
for marker in [
"resource limit exceeded for ",
"quota unavailable for ",
"quota exceeded for ",
"limit exceeded for ",
"metering limit exceeded for ",
"quota limit for ",
"resource category ",
"resource=",
"resource:",
] {
if let Some(index) = lower.find(marker) {
let start = index + marker.len();
let rest = &message[start..];
let value: String = rest
.chars()
.skip_while(|ch| ch.is_ascii_whitespace())
.take_while(|ch| {
ch.is_ascii_alphanumeric()
|| *ch == '_'
|| *ch == '-'
|| *ch == '.'
|| *ch == ':'
})
.collect();
let value = value.trim_matches(|ch: char| ch == '.' || ch == ':' || ch == ',');
if !value.is_empty() {
return Some(value.to_owned());
}
}
}
None
}
fn classify_cli_error_message(message: &str) -> &'static str {
let message = message.to_ascii_lowercase();
if message.contains("already has active virtual process") || message.contains("active process")
{
return "active_process";
}
if message_mentions_locality_failure(&message) {
return "connectivity";
}
if message.contains("no capable node")
|| message.contains("missing capability")
|| message.contains("capability")
|| message.contains("placement failed")
|| message.contains("cmd.run")
|| message.contains("node required")
{
return "capability";
}
if message.contains("quota")
|| message.contains("resource limit")
|| message.contains("limit exceeded")
|| message.contains("metering")
{
return "quota";
}
if message.contains("policy")
|| message.contains("disallowed")
|| message.contains("not allowed")
|| message.contains("suspended")
|| message.contains("blocked by")
|| message.contains("denied")
{
return "policy";
}
if message.contains("unauthenticated")
|| message.contains("not authenticated")
|| message.contains("login required")
|| message.contains("browser login required")
|| message.contains("missing session")
|| message.contains("no cli session")
|| message.contains("session credential has expired")
|| message.contains("session credential has been revoked")
|| message.contains("401")
{
return "authentication";
}
if message.contains("unauthorized")
|| message.contains("not authorized")
|| message.contains("forbidden")
|| message.contains("permission")
|| message.contains("tenant mismatch")
|| message.contains("project mismatch")
{
return "authorization";
}
if message.contains("failed to connect")
|| message.contains("connection refused")
|| message.contains("closed session")
|| message.contains("timed out")
|| message.contains("timeout")
|| message.contains("name resolution")
|| message.contains("network unreachable")
|| message.contains("dns")
{
return "connectivity";
}
if message.contains("missing environment")
|| message.contains("environment mismatch")
|| message.contains("incompatible environment")
|| message.contains("containerfile")
|| message.contains("dockerfile")
|| message.contains("envs/")
{
return "environment";
}
if message.contains("task exited")
|| message.contains("exit status")
|| message.contains("status code")
|| message.contains("stderr")
|| message.contains("panic")
|| message.contains("program error")
|| message.contains("command failed")
{
return "program";
}
"unknown"
}
pub(crate) fn message_mentions_locality_failure(message: &str) -> bool {
message.contains("direct connectivity unavailable")
|| message.contains("direct transfer")
|| message.contains("source snapshot unavailable")
|| (message.contains("required artifact")
&& message.contains("unavailable")
&& message.contains("direct connectivity"))
|| message.contains("locality assumption")
}
fn cli_error_exit_code(category: &str) -> i64 {
match category {
"authentication" => 20,
"authorization" => 21,
"quota" => 22,
"policy" => 23,
"capability" => 24,
"connectivity" => 25,
"environment" => 26,
"program" => 27,
"active_process" => 28,
_ => 1,
}
}
fn cli_error_retryable_after_user_action(category: &str) -> bool {
matches!(
category,
"authentication"
| "authorization"
| "quota"
| "policy"
| "capability"
| "connectivity"
| "environment"
| "program"
| "active_process"
)
}
fn cli_error_next_actions(category: &str) -> Vec<&'static str> {
match category {
"authentication" => vec!["disasmer login --browser", "disasmer auth status"],
"authorization" => vec![
"disasmer auth status",
"check tenant/project selection",
"ask an admin to grant access",
],
"quota" => vec![
"disasmer quota status",
"reduce concurrent work or wait for usage to fall",
],
"policy" => vec![
"disasmer doctor",
"check coordinator policy for this action",
],
"capability" => vec![
"disasmer node list",
"attach a node with the required capabilities",
"check tenant/project on the attached node",
],
"connectivity" => vec![
"disasmer doctor",
"check the coordinator endpoint and network reachability",
],
"environment" => vec![
"disasmer inspect",
"check envs/<name>/Containerfile or envs/<name>/Dockerfile",
],
"program" => vec!["disasmer logs", "fix the program or task command and rerun"],
"active_process" => vec![
"disasmer process list",
"disasmer process status",
"disasmer debug attach",
"disasmer process restart --yes",
"disasmer process cancel --yes",
"disasmer process abort --yes",
"use another Coordinator Project",
],
_ => vec![
"disasmer doctor",
"rerun with --json for machine-readable details",
],
}
}

View file

@ -0,0 +1,271 @@
use anyhow::Result;
use disasmer_core::Digest;
use serde_json::{json, Value};
use crate::client::{authenticated_or_local_trusted_request, JsonLineSession};
use crate::config::StoredCliSession;
use crate::{confirmation_required_report, KeyAddArgs, KeyListArgs, KeyRevokeArgs};
#[cfg(test)]
pub(crate) fn key_add_report(args: KeyAddArgs) -> Result<Value> {
key_add_report_with_session(args, None)
}
pub(crate) fn key_add_report_with_session(
args: KeyAddArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
let coordinator = effective_coordinator(&args.scope.coordinator, stored_session);
if let Some(coordinator) = &coordinator {
let tenant = effective_session_value(stored_session, &args.scope.tenant, |session| {
&session.tenant
});
let project = effective_session_value(stored_session, &args.scope.project, |session| {
&session.project
});
let user =
effective_session_value(stored_session, &args.scope.user, |session| &session.user);
let agent = args.agent.clone();
let public_key_fingerprint = Digest::sha256(&args.public_key);
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "register_agent_public_key",
"agent": agent,
"public_key": args.public_key,
}),
json!({
"type": "register_agent_public_key",
"tenant": tenant,
"project": project,
"user": user,
"agent": agent,
"public_key": args.public_key,
}),
)?)?;
let record = response.get("record").cloned().unwrap_or_else(|| {
json!({
"tenant": tenant,
"project": project,
"user": user,
"agent": agent,
"public_key_fingerprint": public_key_fingerprint,
"scopes": ["project:read", "project:run"],
"human_account_creation_privilege": false,
"browser_interaction_required_each_run": false,
})
});
return Ok(json!({
"command": "key add",
"coordinator": coordinator,
"tenant": tenant,
"project": project,
"user": user,
"agent": agent,
"public_key_fingerprint": record
.get("public_key_fingerprint")
.cloned()
.unwrap_or_else(|| json!(public_key_fingerprint)),
"credential_scope": {
"tenant": tenant,
"project": project,
"actions": record
.get("scopes")
.cloned()
.unwrap_or_else(|| json!(["project:read", "project:run"])),
"human_account_creation_privilege": record
.get("human_account_creation_privilege")
.cloned()
.unwrap_or(json!(false)),
},
"browser_interaction_required_each_run": record
.get("browser_interaction_required_each_run")
.cloned()
.unwrap_or(json!(false)),
"attribution": {
"registered_by_user": user,
"agent": agent,
"credential_kind": "public_key",
},
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "key add",
"status": "planned_without_coordinator",
"agent": args.agent,
"public_key_fingerprint": Digest::sha256(args.public_key),
"browser_interaction_required_each_run": false,
}))
}
#[cfg(test)]
pub(crate) fn key_list_report(args: KeyListArgs) -> Result<Value> {
key_list_report_with_session(args, None)
}
pub(crate) fn key_list_report_with_session(
args: KeyListArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
let coordinator = effective_coordinator(&args.scope.coordinator, stored_session);
if let Some(coordinator) = &coordinator {
let tenant = effective_session_value(stored_session, &args.scope.tenant, |session| {
&session.tenant
});
let project = effective_session_value(stored_session, &args.scope.project, |session| {
&session.project
});
let user =
effective_session_value(stored_session, &args.scope.user, |session| &session.user);
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "list_agent_public_keys",
}),
json!({
"type": "list_agent_public_keys",
"tenant": tenant,
"project": project,
"user": user,
}),
)?)?;
return Ok(json!({
"command": "key list",
"coordinator": coordinator,
"tenant": tenant,
"project": project,
"user": user,
"records": response
.get("records")
.cloned()
.unwrap_or_else(|| json!([])),
"credential_scope": {
"tenant": tenant,
"project": project,
"listed_for_user": user,
"human_account_creation_privilege": false,
},
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "key list",
"status": "requires_coordinator",
"records": [],
}))
}
#[cfg(test)]
pub(crate) fn key_revoke_report(args: KeyRevokeArgs) -> Result<Value> {
key_revoke_report_with_session(args, None)
}
pub(crate) fn key_revoke_report_with_session(
args: KeyRevokeArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
if !args.yes {
return Ok(confirmation_required_report(
"key revoke",
"revoke_agent_public_key",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"user": args.scope.user,
"agent": args.agent,
}),
format!("disasmer key revoke --agent {} --yes", args.agent),
));
}
let coordinator = effective_coordinator(&args.scope.coordinator, stored_session);
if let Some(coordinator) = &coordinator {
let tenant = effective_session_value(stored_session, &args.scope.tenant, |session| {
&session.tenant
});
let project = effective_session_value(stored_session, &args.scope.project, |session| {
&session.project
});
let user =
effective_session_value(stored_session, &args.scope.user, |session| &session.user);
let agent = args.agent.clone();
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "revoke_agent_public_key",
"agent": agent,
}),
json!({
"type": "revoke_agent_public_key",
"tenant": tenant,
"project": project,
"user": user,
"agent": agent,
}),
)?)?;
return Ok(json!({
"command": "key revoke",
"coordinator": coordinator,
"requires_confirmation": !args.yes,
"tenant": tenant,
"project": project,
"user": user,
"agent": agent,
"revoked": response
.pointer("/record/revoked")
.cloned()
.unwrap_or(json!(true)),
"credential_scope": {
"tenant": tenant,
"project": project,
"revoked_for_user": user,
"human_account_creation_privilege": false,
},
"attribution": {
"revoked_by_user": user,
"agent": agent,
"credential_kind": "public_key",
},
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "key revoke",
"status": "requires_coordinator",
"requires_confirmation": !args.yes,
"agent": args.agent,
}))
}
fn effective_coordinator(
requested: &Option<String>,
stored_session: Option<&StoredCliSession>,
) -> Option<String> {
requested.clone().or_else(|| {
stored_session
.filter(|session| session.session_secret.is_some())
.map(|session| session.coordinator.clone())
})
}
fn effective_session_value(
stored_session: Option<&StoredCliSession>,
requested: &str,
value: impl FnOnce(&StoredCliSession) -> &String,
) -> String {
stored_session
.filter(|session| session.session_secret.is_some())
.map(value)
.cloned()
.unwrap_or_else(|| requested.to_owned())
}

View file

@ -0,0 +1,127 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde_json::{json, Value};
use crate::client::JsonLineSession;
use crate::config::{read_cli_session, session_config_file, StoredCliSession};
use crate::confirm::confirmation_required_report;
use crate::errors::cli_error_summary;
use crate::AuthLogoutArgs;
pub(crate) fn auth_logout_report(args: AuthLogoutArgs, cwd: PathBuf) -> Result<Value> {
logout_report(args, cwd, "auth logout")
}
pub(crate) fn logout_report(args: AuthLogoutArgs, cwd: PathBuf, command: &str) -> Result<Value> {
let session_file = session_config_file(&cwd);
if !args.yes {
return Ok(confirmation_required_report(
command,
"delete_cli_session",
json!({
"session_file": session_file,
"node_credentials_untouched": true,
}),
format!("disasmer {command} --yes"),
));
}
let stored_session = read_cli_session(&cwd).ok().flatten();
let coordinator_revocation = revoke_stored_cli_session_if_possible(stored_session.as_ref());
let existed = session_file.exists();
if existed {
std::fs::remove_file(&session_file)
.with_context(|| format!("failed to remove {}", session_file.display()))?;
}
Ok(json!({
"command": command,
"requires_confirmation": !args.yes,
"removed_cli_session_file": existed,
"server_session_revocation": coordinator_revocation,
"node_credentials_untouched": true,
"session_file": session_file,
}))
}
fn revoke_stored_cli_session_if_possible(stored_session: Option<&StoredCliSession>) -> Value {
let Some(session) = stored_session else {
return json!({
"attempted": false,
"revoked": false,
"reason": "no_parseable_cli_session",
});
};
let Some(session_secret) = session.session_secret.as_deref() else {
return json!({
"attempted": false,
"revoked": false,
"reason": "no_cli_session_secret",
"coordinator": session.coordinator,
});
};
// A CLI session credential is authority issued by one coordinator. Never
// redirect it to a command-line endpoint override.
let coordinator = session.coordinator.as_str();
let mut coordinator_session = match JsonLineSession::connect(coordinator) {
Ok(session) => session,
Err(error) => {
let message = error.to_string();
return json!({
"attempted": true,
"revoked": false,
"reachable": false,
"coordinator": coordinator,
"error": message,
"machine_error": cli_error_summary(&message),
"next_actions": ["disasmer login --browser"],
});
}
};
let response = match coordinator_session.request_allow_error(json!({
"type": "authenticated",
"session_secret": session_secret,
"request": {
"type": "revoke_cli_session",
},
})) {
Ok(response) => response,
Err(error) => {
let message = error.to_string();
return json!({
"attempted": true,
"revoked": false,
"reachable": false,
"coordinator": coordinator,
"error": message,
"machine_error": cli_error_summary(&message),
"coordinator_session_requests": coordinator_session.requests(),
"next_actions": ["disasmer login --browser"],
});
}
};
let revoked = response.get("type").and_then(Value::as_str) == Some("cli_session_revoked");
if !revoked {
let message = response
.get("message")
.and_then(Value::as_str)
.unwrap_or("coordinator did not revoke CLI session");
return json!({
"attempted": true,
"revoked": false,
"reachable": true,
"coordinator": coordinator,
"coordinator_response_type": response.get("type").and_then(Value::as_str).unwrap_or("unknown"),
"machine_error": cli_error_summary(message),
"coordinator_session_requests": coordinator_session.requests(),
"next_actions": ["disasmer login --browser"],
});
}
json!({
"attempted": true,
"revoked": true,
"reachable": true,
"coordinator": coordinator,
"coordinator_response_type": "cli_session_revoked",
"coordinator_session_requests": coordinator_session.requests(),
})
}

View file

@ -0,0 +1,34 @@
use anyhow::Result;
use serde_json::{json, Value};
use crate::client::list_task_events_if_available_with_session;
use crate::config::StoredCliSession;
use crate::process_events::log_entries;
use crate::LogsArgs;
#[cfg(test)]
pub(crate) fn logs_report(args: LogsArgs) -> Result<Value> {
logs_report_with_session(args, None)
}
pub(crate) fn logs_report_with_session(
args: LogsArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
let events = list_task_events_if_available_with_session(
args.scope.coordinator.as_deref(),
&args.scope,
args.process.clone(),
stored_session,
)?;
let log_entries = log_entries(events.as_ref(), args.task.as_deref());
Ok(json!({
"command": "logs",
"process": args.process,
"task": args.task,
"log_entries": log_entries,
"logs_are_capped": true,
"secret_redaction_policy": "configured-redaction-boundary",
"events": events,
}))
}

View file

@ -0,0 +1,689 @@
#[cfg(test)]
use std::io::{BufRead, BufReader, Write};
#[cfg(test)]
use std::net::TcpListener;
#[cfg(test)]
use std::path::Path;
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
#[cfg(test)]
use disasmer_core::{Capability, Digest, ProjectModel, SourceProviderKind};
#[cfg(test)]
use serde_json::json;
use serde_json::Value;
mod admin;
mod agent;
mod artifact;
mod auth;
mod auth_scope;
mod build;
mod bundle;
mod client;
mod config;
mod confirm;
mod debug;
mod dispatch;
mod doctor;
mod errors;
mod key;
mod logout;
mod logs;
mod node;
mod output;
mod process;
mod process_events;
mod project;
mod quota;
mod run;
mod task;
mod tools;
use admin::{admin_bootstrap_report, admin_status_report, admin_suspend_tenant_report};
use agent::agent_enrollment_plan;
#[cfg(test)]
use artifact::{
artifact_download_report, artifact_export_report, artifact_list_report,
artifact_stream_summary, DEFAULT_ARTIFACT_EXPORT_MAX_BYTES,
};
use artifact::{
artifact_download_report_with_session, artifact_export_report_with_session,
artifact_list_report_with_session,
};
use auth::{
auth_status_report, connect_self_hosted_report, execute_interactive_browser_login, login_plan,
non_interactive_browser_login_report, print_browser_login_success,
read_session_secret_from_stdin,
};
#[cfg(test)]
use auth::{contains_provider_token_field, stored_cli_session_from_login_response, LoginFlowPlan};
#[cfg(test)]
use auth_scope::login_args_for_project;
use build::build_report;
#[cfg(test)]
use bundle::task_abi_digest;
pub(crate) use bundle::{bundle_inspection, discovered_environment_names};
#[cfg(test)]
use client::control_endpoint_identity;
use config::{default_hosted_coordinator_endpoint, read_cli_session};
#[cfg(test)]
use config::{
read_project_config, session_config_file, write_cli_session, write_project_config,
ProjectConfig, StoredCliSession, DEFAULT_HOSTED_COORDINATOR_ENDPOINT,
};
pub(crate) use confirm::confirmation_required_report;
#[cfg(test)]
use debug::debug_attach_report_with_dap;
use debug::{dap_plan, debug_attach_report_with_dap_and_session, exec_dap};
use doctor::doctor_report;
use errors::cli_error_summary;
#[cfg(test)]
use errors::cli_error_summary_for_category;
#[cfg(test)]
use key::{key_add_report, key_list_report, key_revoke_report};
use key::{
key_add_report_with_session, key_list_report_with_session, key_revoke_report_with_session,
};
use logout::{auth_logout_report, logout_report};
#[cfg(test)]
use logs::logs_report;
use logs::logs_report_with_session;
use node::{
attach_plan, execute_node_attach, node_enroll_report, node_list_report, node_revoke_report,
node_status_report,
};
use output::emit_report;
#[cfg(test)]
use output::{apply_command_report_exit_code, human_report};
use process::{
process_abort_report_with_session, process_cancel_report, process_cancel_report_with_session,
process_list_report_with_session, process_restart_report_with_session,
process_status_report_with_session,
};
#[cfg(test)]
use process::{process_restart_report, process_status_report};
#[cfg(test)]
use process_events::task_summaries;
#[cfg(test)]
use process_events::{
artifact_download_session_summary, artifact_export_plan_summary, log_entries,
};
use project::{
project_init_report, project_list_report, project_select_report, project_status_report,
};
use quota::quota_status_report;
#[cfg(test)]
use run::{
agent_session_from_keys, run_plan, run_start_summary, should_execute_local_node, CliSession,
CoordinatorSelection,
};
use run::{run_report, session_from_sources};
#[cfg(test)]
use task::{task_list_report, task_restart_report};
use task::{task_list_report_with_session, task_restart_report_with_session};
use tools::dap_binary_path;
#[derive(Clone, Debug, Parser)]
#[command(
name = "disasmer",
version,
arg_required_else_help = true,
about = "Disasmer distributed Wasm runtime CLI.",
after_help = "Primary workflow:
1. disasmer login --browser
2. disasmer project init
3. disasmer node enroll; disasmer node attach; disasmer-node --worker
4. disasmer run [entry] --project <path>
5. Debug with VS Code \"Disasmer: Launch Virtual Process\" or disasmer dap
6. Inspect with disasmer process list, disasmer process status, task list, logs, and artifact list
7. Request cooperative shutdown with process cancel; force termination with process abort
Use --json on primary commands for scriptable output. Hosted account creation happens in the browser login flow."
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Clone, Debug, Subcommand)]
enum Commands {
Doctor(DoctorArgs),
Login(LoginArgs),
Logout(AuthLogoutArgs),
Auth {
#[command(subcommand)]
command: AuthCommands,
},
Agent {
#[command(subcommand)]
command: AgentCommands,
},
Key {
#[command(subcommand)]
command: KeyCommands,
},
Project {
#[command(subcommand)]
command: ProjectCommands,
},
Inspect(BundleInspectArgs),
Build(BuildArgs),
Bundle {
#[command(subcommand)]
command: BundleCommands,
},
Run(RunArgs),
Node {
#[command(subcommand)]
command: NodeCommands,
},
Process {
#[command(subcommand)]
command: ProcessCommands,
},
Task {
#[command(subcommand)]
command: TaskCommands,
},
Logs(LogsArgs),
Artifact {
#[command(subcommand)]
command: ArtifactCommands,
},
Dap(DapArgs),
Debug {
#[command(subcommand)]
command: DebugCommands,
},
Quota {
#[command(subcommand)]
command: QuotaCommands,
},
Admin {
#[command(subcommand)]
command: AdminCommands,
},
}
#[derive(Clone, Debug, Parser)]
struct DoctorArgs {
#[command(flatten)]
scope: CliScopeArgs,
}
#[derive(Clone, Debug, Parser)]
struct LoginArgs {
#[arg(long = "browser")]
_browser: bool,
#[arg(long)]
non_interactive: bool,
#[arg(long)]
plan: bool,
#[arg(long)]
json: bool,
#[arg(long, default_value_t = default_hosted_coordinator_endpoint())]
coordinator: String,
#[arg(long = "project-id", default_value = "project")]
project: String,
}
#[derive(Clone, Debug, Subcommand)]
enum AuthCommands {
Status(AuthStatusArgs),
ConnectSelfHosted(ConnectSelfHostedArgs),
Logout(AuthLogoutArgs),
}
#[derive(Clone, Debug, Parser)]
struct AuthStatusArgs {
#[command(flatten)]
scope: CliScopeArgs,
}
#[derive(Clone, Debug, Parser)]
struct ConnectSelfHostedArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, required = true)]
session_secret_stdin: bool,
}
#[derive(Clone, Debug, Parser)]
struct AuthLogoutArgs {
#[arg(long)]
yes: bool,
#[command(flatten)]
scope: CliScopeArgs,
}
#[derive(Clone, Debug, Subcommand)]
enum AgentCommands {
Enroll(AgentEnrollArgs),
}
#[derive(Clone, Debug, Subcommand)]
enum KeyCommands {
Add(KeyAddArgs),
List(KeyListArgs),
Revoke(KeyRevokeArgs),
}
#[derive(Clone, Debug, Subcommand)]
enum BundleCommands {
Inspect(BundleInspectArgs),
}
#[derive(Clone, Debug, Parser)]
struct AgentEnrollArgs {
#[arg(long)]
json: bool,
#[arg(long = "public-key")]
public_key: String,
}
#[derive(Clone, Debug, Parser)]
struct KeyAddArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, default_value = "agent")]
agent: String,
#[arg(long = "public-key")]
public_key: String,
}
#[derive(Clone, Debug, Parser)]
struct KeyListArgs {
#[command(flatten)]
scope: CliScopeArgs,
}
#[derive(Clone, Debug, Parser)]
struct KeyRevokeArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, default_value = "agent")]
agent: String,
#[arg(long)]
yes: bool,
}
#[derive(Clone, Debug, Subcommand)]
enum ProjectCommands {
Init(ProjectInitArgs),
Status(ProjectStatusArgs),
List(ProjectListArgs),
Select(ProjectSelectArgs),
}
#[derive(Clone, Debug, Parser)]
struct ProjectInitArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, default_value = "project")]
new_project: String,
#[arg(long, default_value = "Disasmer Project")]
name: String,
#[arg(long)]
yes: bool,
}
#[derive(Clone, Debug, Parser)]
struct ProjectStatusArgs {
#[command(flatten)]
scope: CliScopeArgs,
}
#[derive(Clone, Debug, Parser)]
struct ProjectListArgs {
#[command(flatten)]
scope: CliScopeArgs,
}
#[derive(Clone, Debug, Parser)]
struct ProjectSelectArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(value_name = "PROJECT")]
selected_project: String,
}
#[derive(Clone, Debug, Parser)]
struct BundleInspectArgs {
#[arg(long)]
project: Option<PathBuf>,
#[arg(long = "source-provider")]
source_provider: Option<String>,
#[arg(long = "disable-source-provider")]
disabled_source_providers: Vec<String>,
#[arg(long)]
json: bool,
}
#[derive(Clone, Debug, Parser)]
struct BuildArgs {
#[arg(long)]
project: Option<PathBuf>,
#[arg(long = "source-provider")]
source_provider: Option<String>,
#[arg(long = "disable-source-provider")]
disabled_source_providers: Vec<String>,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Clone, Debug, Parser)]
struct RunArgs {
entry: Option<String>,
#[arg(long)]
project: Option<PathBuf>,
#[arg(long)]
coordinator: Option<String>,
#[arg(long)]
local: bool,
#[arg(long)]
non_interactive: bool,
#[arg(long)]
json: bool,
}
#[derive(Clone, Debug, Subcommand)]
enum NodeCommands {
Attach(AttachArgs),
Enroll(NodeEnrollArgs),
List(NodeListArgs),
Status(NodeStatusArgs),
Revoke(NodeRevokeArgs),
}
#[derive(Clone, Debug, Parser)]
struct AttachArgs {
#[arg(long)]
coordinator: Option<String>,
#[arg(long, default_value = "tenant")]
tenant: String,
#[arg(long = "project-id", default_value = "project")]
project: String,
#[arg(long)]
node: Option<String>,
#[arg(long = "cap")]
caps: Vec<String>,
#[arg(long = "enrollment-grant")]
enrollment_grant: Option<String>,
#[arg(long = "public-key")]
public_key: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Clone, Debug, Parser)]
struct NodeEnrollArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, default_value_t = 900)]
ttl_seconds: u64,
}
#[derive(Clone, Debug, Parser)]
struct NodeListArgs {
#[command(flatten)]
scope: CliScopeArgs,
}
#[derive(Clone, Debug, Parser)]
struct NodeStatusArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long)]
node: Option<String>,
}
#[derive(Clone, Debug, Parser)]
struct NodeRevokeArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long)]
node: String,
#[arg(long)]
yes: bool,
}
#[derive(Clone, Debug, Subcommand)]
enum ProcessCommands {
List(ProcessListArgs),
Status(ProcessStatusArgs),
Restart(ProcessRestartArgs),
Cancel(ProcessCancelArgs),
Abort(ProcessAbortArgs),
}
#[derive(Clone, Debug, Parser)]
struct ProcessListArgs {
#[command(flatten)]
scope: CliScopeArgs,
}
#[derive(Clone, Debug, Parser)]
struct ProcessStatusArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, default_value = "vp-current")]
process: String,
}
#[derive(Clone, Debug, Parser)]
struct ProcessRestartArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, default_value = "vp-current")]
process: String,
#[arg(long)]
yes: bool,
}
#[derive(Clone, Debug, Parser)]
struct ProcessCancelArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, default_value = "vp-current")]
process: String,
#[arg(long)]
node: Option<String>,
#[arg(long)]
task: Option<String>,
#[arg(long)]
yes: bool,
}
#[derive(Clone, Debug, Parser)]
struct ProcessAbortArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, default_value = "vp-current")]
process: String,
#[arg(long)]
yes: bool,
}
#[derive(Clone, Debug, Subcommand)]
enum TaskCommands {
List(TaskListArgs),
Restart(TaskRestartArgs),
}
#[derive(Clone, Debug, Parser)]
struct TaskListArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long)]
process: Option<String>,
}
#[derive(Clone, Debug, Parser)]
struct TaskRestartArgs {
#[command(flatten)]
scope: CliScopeArgs,
task: String,
#[arg(long, default_value = "vp-current")]
process: String,
#[arg(long)]
yes: bool,
}
#[derive(Clone, Debug, Parser)]
struct LogsArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long)]
process: Option<String>,
#[arg(long)]
task: Option<String>,
}
#[derive(Clone, Debug, Subcommand)]
enum ArtifactCommands {
List(ArtifactListArgs),
Download(ArtifactDownloadArgs),
Export(ArtifactExportArgs),
}
#[derive(Clone, Debug, Parser)]
struct ArtifactListArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long)]
process: Option<String>,
}
#[derive(Clone, Debug, Parser)]
struct ArtifactDownloadArgs {
#[command(flatten)]
scope: CliScopeArgs,
artifact: String,
/// Write the verified artifact bytes to this local path.
#[arg(long)]
to: Option<PathBuf>,
#[arg(long, default_value_t = 64 * 1024 * 1024)]
max_bytes: u64,
}
#[derive(Clone, Debug, Parser)]
struct ArtifactExportArgs {
#[command(flatten)]
scope: CliScopeArgs,
artifact: String,
#[arg(long)]
to: PathBuf,
#[arg(long, default_value = "node-local")]
receiver_node: String,
}
#[derive(Clone, Debug, Parser)]
struct DapArgs {
#[arg(long)]
plan: bool,
#[arg(long)]
json: bool,
#[arg(last = true)]
args: Vec<String>,
}
#[derive(Clone, Debug, Subcommand)]
enum DebugCommands {
Attach(DebugAttachArgs),
}
#[derive(Clone, Debug, Parser)]
struct DebugAttachArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, default_value = "vp-current")]
process: String,
}
#[derive(Clone, Debug, Subcommand)]
enum QuotaCommands {
Status(QuotaStatusArgs),
}
#[derive(Clone, Debug, Parser)]
struct QuotaStatusArgs {
#[command(flatten)]
scope: CliScopeArgs,
}
#[derive(Clone, Debug, Subcommand)]
enum AdminCommands {
Status(AdminStatusArgs),
Bootstrap(AdminBootstrapArgs),
RevokeNode(NodeRevokeArgs),
StopProcess(ProcessCancelArgs),
SuspendTenant(AdminSuspendTenantArgs),
}
#[derive(Clone, Debug, Parser)]
struct AdminStatusArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long)]
admin_token: Option<String>,
}
#[derive(Clone, Debug, Parser)]
struct AdminBootstrapArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long, default_value = "Disasmer Project")]
name: String,
#[arg(long)]
yes: bool,
}
#[derive(Clone, Debug, Parser)]
struct AdminSuspendTenantArgs {
#[command(flatten)]
scope: CliScopeArgs,
#[arg(long = "target-tenant")]
target_tenant: Option<String>,
#[arg(long)]
admin_token: Option<String>,
#[arg(long)]
yes: bool,
}
#[derive(Clone, Debug, Args)]
struct CliScopeArgs {
#[arg(long)]
coordinator: Option<String>,
#[arg(long, default_value = "tenant")]
tenant: String,
#[arg(long = "project-id", default_value = "project")]
project: String,
#[arg(long, default_value = "user")]
user: String,
#[arg(long)]
json: bool,
}
fn main() {
if let Err(error) = dispatch::run_cli() {
let message = error.to_string();
let machine_error = cli_error_summary(&message);
let category = machine_error
.get("category")
.and_then(Value::as_str)
.unwrap_or("unknown");
let exit_code = machine_error
.get("stable_exit_code")
.and_then(Value::as_i64)
.and_then(|code| i32::try_from(code).ok())
.unwrap_or(1);
eprintln!("Error ({category}, exit {exit_code}): {error:#}");
std::process::exit(exit_code);
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,837 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use disasmer_core::{
generate_ed25519_private_key, node_ed25519_public_key_from_private_key, sign_node_request,
signed_request_payload_digest, Capability, Digest, EnvironmentBackend, NodeCapabilities,
NodeId,
};
use serde::Serialize;
use serde_json::{json, Value};
use crate::client::{authenticated_or_local_trusted_request, JsonLineSession};
use crate::config::{effective_scope_value, read_cli_session, StoredCliSession};
use crate::tools::{command_available, command_nonce, unix_timestamp_seconds};
use crate::{
confirmation_required_report, AttachArgs, CliScopeArgs, NodeEnrollArgs, NodeListArgs,
NodeRevokeArgs, NodeStatusArgs,
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct NodeAttachPlan {
pub(crate) node: String,
pub(crate) coordinator: Option<String>,
pub(crate) capabilities: NodeCapabilities,
pub(crate) detection: NodeAttachDetectionEvidence,
pub(crate) grant_disclosures: Vec<CapabilityGrantDisclosure>,
pub(crate) enrollment: Option<NodeEnrollmentPlan>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub(crate) struct NodeAttachReport {
pub(crate) command: String,
pub(crate) node: String,
pub(crate) plan: NodeAttachPlan,
pub(crate) grant_disclosures: Vec<CapabilityGrantDisclosure>,
pub(crate) boundary: NodeAttachBoundaryEvidence,
pub(crate) coordinator_response: Value,
pub(crate) heartbeat_response: Value,
pub(crate) capability_response: Value,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct NodeAttachBoundaryEvidence {
pub(crate) cli_contacted_coordinator: bool,
pub(crate) coordinator_address: String,
pub(crate) used_enrollment_exchange: bool,
pub(crate) coordinator_session_requests: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct CapabilityGrantDisclosure {
pub(crate) capability: Capability,
pub(crate) grant: String,
pub(crate) description: String,
pub(crate) risk: String,
pub(crate) coordinator_policy_limited: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct NodeAttachDetectionEvidence {
pub(crate) auto_detected: bool,
pub(crate) os: disasmer_core::Os,
pub(crate) arch: String,
pub(crate) command_backend: String,
pub(crate) command_backend_available: bool,
pub(crate) container_backend: Option<String>,
pub(crate) container_backend_reported: bool,
pub(crate) container_backend_available: bool,
pub(crate) source_provider_backends: Vec<SourceProviderBackendStatus>,
pub(crate) manual_capability_overrides_allowed: bool,
pub(crate) manual_capability_overrides: Vec<String>,
pub(crate) recognized_capability_overrides: Vec<Capability>,
pub(crate) unrecognized_capability_overrides: Vec<String>,
pub(crate) os_arch_capabilities_require_manual_flags: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct SourceProviderBackendStatus {
pub(crate) provider: String,
pub(crate) detected: bool,
pub(crate) available: bool,
pub(crate) reason: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct NodeEnrollmentPlan {
pub(crate) grant: String,
pub(crate) public_key_fingerprint: Digest,
pub(crate) exchanges_short_lived_grant_for_long_lived_node_identity: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct StoredNodeCredential {
kind: String,
node: String,
private_key: String,
public_key: String,
credential_scope: String,
}
pub(crate) fn node_enroll_report(args: NodeEnrollArgs, cwd: PathBuf) -> Result<Value> {
let stored_session = read_cli_session(&cwd)?;
let coordinator = args.scope.coordinator.clone().or_else(|| {
stored_session
.as_ref()
.map(|session| session.coordinator.clone())
});
if let Some(coordinator) = &coordinator {
let tenant = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.tenant,
|session| session.tenant.as_str(),
"tenant",
);
let project = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.project,
|session| session.project.as_str(),
"project",
);
let user = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.user,
|session| session.user.as_str(),
"user",
);
let ttl_seconds = args.ttl_seconds;
let mut session = JsonLineSession::connect(coordinator)?;
let request = authenticated_or_local_trusted_request(
coordinator,
stored_session.as_ref(),
json!({
"type": "create_node_enrollment_grant",
"ttl_seconds": ttl_seconds,
}),
json!({
"type": "create_node_enrollment_grant",
"tenant": tenant.clone(),
"project": project.clone(),
"actor_user": user.clone(),
"ttl_seconds": ttl_seconds,
}),
)?;
let response = session.request(request)?;
let enrollment_grant =
node_enrollment_grant_summary(&response, &tenant, &project, &user, ttl_seconds)?;
return Ok(json!({
"command": "node enroll",
"status": "created",
"coordinator": coordinator,
"tenant": tenant,
"project": project,
"user": user,
"private_website_required": false,
"enrollment_grant": enrollment_grant,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "node enroll",
"status": "requires_coordinator",
"private_website_required": false,
"requested_ttl_seconds": args.ttl_seconds,
"enrollment_grant": null,
"reason": "enrollment grants are generated by the coordinator and cannot be planned client-side",
}))
}
fn node_enrollment_grant_summary(
response: &Value,
tenant: &str,
project: &str,
user: &str,
ttl_seconds: u64,
) -> Result<Value> {
let grant = response
.get("grant")
.and_then(Value::as_str)
.context("coordinator did not return its generated enrollment grant")?;
Ok(json!({
"grant": grant,
"tenant": response
.get("tenant")
.cloned()
.unwrap_or_else(|| json!(tenant)),
"project": response
.get("project")
.cloned()
.unwrap_or_else(|| json!(project)),
"user": user,
"scope": response
.get("scope")
.cloned()
.unwrap_or_else(|| json!("node:attach")),
"ttl_seconds": ttl_seconds,
"expires_at_epoch_seconds": response
.get("expires_at_epoch_seconds")
.cloned()
.unwrap_or(Value::Null),
"short_lived": true,
"exchange_for_persistent_node_identity": true,
"node_credentials_separate_from_user_session": true,
}))
}
pub(crate) fn node_list_report(args: NodeListArgs, cwd: PathBuf) -> Result<Value> {
node_descriptors_report("node list", args.scope, None, cwd)
}
pub(crate) fn node_status_report(args: NodeStatusArgs, cwd: PathBuf) -> Result<Value> {
node_descriptors_report("node status", args.scope, args.node, cwd)
}
fn node_descriptors_report(
command: &str,
scope: CliScopeArgs,
node: Option<String>,
cwd: PathBuf,
) -> Result<Value> {
let stored_session = read_cli_session(&cwd)?;
let coordinator = scope.coordinator.clone().or_else(|| {
stored_session
.as_ref()
.map(|session| session.coordinator.clone())
});
if let Some(coordinator) = &coordinator {
let tenant = session_or_effective_scope_value(
stored_session.as_ref(),
&scope.tenant,
|session| session.tenant.as_str(),
"tenant",
);
let project = session_or_effective_scope_value(
stored_session.as_ref(),
&scope.project,
|session| session.project.as_str(),
"project",
);
let user = session_or_effective_scope_value(
stored_session.as_ref(),
&scope.user,
|session| session.user.as_str(),
"user",
);
let mut session = JsonLineSession::connect(coordinator)?;
let request = authenticated_or_local_trusted_request(
coordinator,
stored_session.as_ref(),
json!({
"type": "list_node_descriptors",
}),
json!({
"type": "list_node_descriptors",
"tenant": tenant.clone(),
"project": project.clone(),
"actor_user": user.clone(),
}),
)?;
let response = session.request(request)?;
return Ok(json!({
"command": command,
"coordinator": coordinator,
"node": node,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": command,
"status": "local_capability_snapshot",
"node": node.unwrap_or_else(default_node_id),
"capabilities": NodeCapabilities::detect_current(),
}))
}
pub(crate) fn node_revoke_report(args: NodeRevokeArgs, cwd: PathBuf) -> Result<Value> {
if !args.yes {
return Ok(confirmation_required_report(
"node revoke",
"revoke_node_credential",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"user": args.scope.user,
"node": args.node,
}),
format!("disasmer node revoke --node {} --yes", args.node),
));
}
let stored_session = read_cli_session(&cwd)?;
let coordinator = args.scope.coordinator.clone().or_else(|| {
stored_session
.as_ref()
.map(|session| session.coordinator.clone())
});
if let Some(coordinator) = &coordinator {
let tenant = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.tenant,
|session| session.tenant.as_str(),
"tenant",
);
let project = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.project,
|session| session.project.as_str(),
"project",
);
let user = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.user,
|session| session.user.as_str(),
"user",
);
let node = args.node.clone();
let mut session = JsonLineSession::connect(coordinator)?;
let request = authenticated_or_local_trusted_request(
coordinator,
stored_session.as_ref(),
json!({
"type": "revoke_node_credential",
"node": node.clone(),
}),
json!({
"type": "revoke_node_credential",
"tenant": tenant.clone(),
"project": project.clone(),
"actor_user": user.clone(),
"node": node.clone(),
}),
)?;
let response = session.request(request)?;
return Ok(json!({
"command": "node revoke",
"coordinator": coordinator,
"requires_confirmation": !args.yes,
"tenant": tenant,
"project": project,
"user": user,
"node": node,
"credential_revoked": response.get("type").and_then(Value::as_str) == Some("node_credential_revoked"),
"descriptor_removed": response
.get("descriptor_removed")
.cloned()
.unwrap_or(json!(false)),
"queued_assignments_removed": response
.get("queued_assignments_removed")
.cloned()
.unwrap_or_else(|| json!(0)),
"node_credentials_separate_from_user_session": true,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "node revoke",
"status": "requires_coordinator",
"requires_confirmation": !args.yes,
"node": args.node,
}))
}
fn session_or_effective_scope_value(
stored_session: Option<&StoredCliSession>,
cli_value: &str,
session_value: impl FnOnce(&StoredCliSession) -> &str,
default_value: &str,
) -> String {
if let Some(session) = stored_session.filter(|session| session.session_secret.is_some()) {
session_value(session).to_owned()
} else {
effective_scope_value(cli_value, stored_session.map(session_value), default_value)
}
}
pub(crate) fn attach_plan(args: AttachArgs) -> NodeAttachPlan {
let mut capabilities = NodeCapabilities::detect_current();
let mut recognized_capability_overrides = Vec::new();
let mut unrecognized_capability_overrides = Vec::new();
for cap in &args.caps {
if let Some(parsed) = parse_capability(cap) {
recognized_capability_overrides.push(parsed.clone());
capabilities.capabilities.insert(parsed);
} else {
unrecognized_capability_overrides.push(cap.clone());
}
}
recognized_capability_overrides.sort();
recognized_capability_overrides.dedup();
let node = args.node.unwrap_or_else(default_node_id);
let public_key = args
.public_key
.unwrap_or_else(|| default_node_public_key_for_plan(&node));
let enrollment = args.enrollment_grant.map(|grant| NodeEnrollmentPlan {
grant,
public_key_fingerprint: Digest::sha256(public_key),
exchanges_short_lived_grant_for_long_lived_node_identity: true,
});
let detection = node_attach_detection_evidence(
&capabilities,
args.caps,
recognized_capability_overrides,
unrecognized_capability_overrides,
);
let grant_disclosures = capability_grant_disclosures(&capabilities);
NodeAttachPlan {
node,
coordinator: args.coordinator,
capabilities,
detection,
grant_disclosures,
enrollment,
}
}
fn node_attach_detection_evidence(
capabilities: &NodeCapabilities,
manual_capability_overrides: Vec<String>,
recognized_capability_overrides: Vec<Capability>,
unrecognized_capability_overrides: Vec<String>,
) -> NodeAttachDetectionEvidence {
let command_backend_available = capabilities.capabilities.contains(&Capability::Command);
let container_backend_reported = capabilities
.environment_backends
.contains(&EnvironmentBackend::Container);
let container_backend = container_backend_reported.then(|| "rootless-podman".to_owned());
let container_backend_available = container_backend_reported
&& capabilities
.capabilities
.contains(&Capability::RootlessPodman)
&& command_available("podman");
NodeAttachDetectionEvidence {
auto_detected: true,
os: capabilities.os.clone(),
arch: capabilities.arch.clone(),
command_backend: if command_backend_available {
"native-command".to_owned()
} else {
"unavailable".to_owned()
},
command_backend_available,
container_backend,
container_backend_reported,
container_backend_available,
source_provider_backends: source_provider_backend_statuses(capabilities),
manual_capability_overrides_allowed: true,
manual_capability_overrides,
recognized_capability_overrides,
unrecognized_capability_overrides,
os_arch_capabilities_require_manual_flags: false,
}
}
fn source_provider_backend_statuses(
capabilities: &NodeCapabilities,
) -> Vec<SourceProviderBackendStatus> {
let mut statuses = BTreeMap::new();
for provider in &capabilities.source_providers {
let available = match provider.as_str() {
"filesystem" => capabilities
.capabilities
.contains(&Capability::SourceFilesystem),
"git" => command_available("git"),
_ => true,
};
statuses.insert(
provider.clone(),
SourceProviderBackendStatus {
provider: provider.clone(),
detected: true,
available,
reason: if available {
"detected by local node capability probe".to_owned()
} else {
format!(
"source provider `{provider}` was detected but its local helper is missing"
)
},
},
);
}
statuses.into_values().collect()
}
fn capability_grant_disclosures(capabilities: &NodeCapabilities) -> Vec<CapabilityGrantDisclosure> {
let mut disclosures = Vec::new();
let mut push = |capability: Capability, grant: &str, description: &str, risk: &str| {
if capabilities.capabilities.contains(&capability) {
disclosures.push(CapabilityGrantDisclosure {
capability,
grant: grant.to_owned(),
description: description.to_owned(),
risk: risk.to_owned(),
coordinator_policy_limited: true,
});
}
};
push(
Capability::Command,
"native_command_execution",
"placed tasks may run native commands on this node",
"local process execution under the node account",
);
push(
Capability::WindowsCommandDev,
"native_command_execution",
"placed tasks may run Windows developer commands on this node",
"local process execution under the node account",
);
push(
Capability::SourceFilesystem,
"source_access",
"placed tasks may read the local project/source checkout exposed by this node",
"broad local source visibility",
);
push(
Capability::SourceGit,
"source_access",
"placed tasks may use Git-backed source access exposed by this node",
"source-provider visibility",
);
push(
Capability::Network,
"network_access",
"placed tasks may use outbound network access from this node",
"network egress from the node environment",
);
push(
Capability::HostFilesystem,
"host_filesystem_access",
"placed tasks may access configured host filesystem mounts",
"host file visibility outside the project checkout",
);
push(
Capability::Secrets,
"secret_access",
"placed tasks may receive configured secret material",
"secret exposure to authorized task code",
);
push(
Capability::InboundPorts,
"inbound_ports",
"placed tasks may expose inbound ports from this node",
"network service exposure from the node environment",
);
push(
Capability::ArbitrarySyscalls,
"arbitrary_syscalls",
"placed tasks may use broader host syscall surface",
"reduced host isolation",
);
disclosures.sort_by(|left, right| {
left.grant
.cmp(&right.grant)
.then_with(|| left.capability.cmp(&right.capability))
});
disclosures
}
pub(crate) fn execute_node_attach(args: AttachArgs) -> Result<NodeAttachReport> {
let coordinator = args
.coordinator
.clone()
.context("node attach execution requires --coordinator")?;
let tenant = args.tenant.clone();
let project = args.project.clone();
let node = args.node.clone().unwrap_or_else(default_node_id);
let node_private_key = node_private_key_for_attach(&node)?;
let derived_public_key =
node_ed25519_public_key_from_private_key(&node_private_key).map_err(anyhow::Error::msg)?;
let public_key = args
.public_key
.clone()
.unwrap_or(derived_public_key.clone());
if public_key != derived_public_key {
anyhow::bail!(
"node attach --public-key must match DISASMER_NODE_PRIVATE_KEY or the stored local node credential"
);
}
let mut plan = attach_plan(args);
if let Some(enrollment) = &mut plan.enrollment {
enrollment.public_key_fingerprint = Digest::sha256(&public_key);
}
let mut session = JsonLineSession::connect(&coordinator)?;
let used_enrollment_exchange = plan.enrollment.is_some();
let coordinator_response = if let Some(enrollment) = &plan.enrollment {
session.request(json!({
"type": "exchange_node_enrollment_grant",
"tenant": &tenant,
"project": &project,
"node": &node,
"public_key": &public_key,
"enrollment_grant": enrollment.grant,
}))?
} else {
session.request(json!({
"type": "attach_node",
"tenant": &tenant,
"project": &project,
"node": &node,
"public_key": &public_key,
}))?
};
let heartbeat_request = json!({
"type": "node_heartbeat",
"node": &plan.node,
});
let heartbeat_signature = sign_node_request(
&node_private_key,
&NodeId::from(plan.node.as_str()),
"node_heartbeat",
&signed_request_payload_digest(&heartbeat_request),
command_nonce("node-heartbeat"),
unix_timestamp_seconds(),
)
.map_err(anyhow::Error::msg)?;
let mut heartbeat_request = heartbeat_request;
heartbeat_request["node_signature"] = json!(heartbeat_signature);
let heartbeat_response = session.request(heartbeat_request)?;
let capability_response = session.request(signed_node_request_json(
&node_private_key,
&plan.node,
"report_node_capabilities",
json!({
"type": "report_node_capabilities",
"tenant": &tenant,
"project": &project,
"node": &plan.node,
"capabilities": &plan.capabilities,
"cached_environment_digests": [],
"dependency_cache_digests": [],
"source_snapshots": [],
"artifact_locations": [],
"direct_connectivity": true,
"online": false,
}),
)?)?;
Ok(NodeAttachReport {
command: "node attach".to_owned(),
node: plan.node.clone(),
grant_disclosures: plan.grant_disclosures.clone(),
plan,
boundary: NodeAttachBoundaryEvidence {
cli_contacted_coordinator: true,
coordinator_address: coordinator,
used_enrollment_exchange,
coordinator_session_requests: session.requests(),
},
coordinator_response,
heartbeat_response,
capability_response,
})
}
fn node_private_key_for_attach(node: &str) -> Result<String> {
if let Ok(private_key) = std::env::var("DISASMER_NODE_PRIVATE_KEY") {
return Ok(private_key);
}
load_or_create_local_node_credential(&std::env::current_dir()?, node)
}
pub(crate) fn load_or_create_local_node_credential(project: &Path, node: &str) -> Result<String> {
let file = local_node_credential_file(project, node);
if credential_file_exists_without_symlink(&file)? {
let bytes =
std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
let credential: StoredNodeCredential = serde_json::from_slice(&bytes)
.with_context(|| format!("failed to parse {}", file.display()))?;
if credential.node != node {
anyhow::bail!(
"stored node credential {} belongs to node `{}` instead of `{}`",
file.display(),
credential.node,
node
);
}
let public_key = node_ed25519_public_key_from_private_key(&credential.private_key)
.map_err(anyhow::Error::msg)?;
if public_key != credential.public_key {
anyhow::bail!(
"stored node credential {} has a public key that does not match its private key",
file.display()
);
}
return Ok(credential.private_key);
}
let private_key = generate_ed25519_private_key().map_err(anyhow::Error::msg)?;
let public_key =
node_ed25519_public_key_from_private_key(&private_key).map_err(anyhow::Error::msg)?;
let credential = StoredNodeCredential {
kind: "disasmer_node_credential".to_owned(),
node: node.to_owned(),
private_key: private_key.clone(),
public_key,
credential_scope: "local_project_node_identity".to_owned(),
};
persist_node_credential(&file, &credential)?;
Ok(private_key)
}
fn credential_file_exists_without_symlink(file: &Path) -> Result<bool> {
match std::fs::symlink_metadata(file) {
Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!(
"refusing to read node credential through symbolic link {}",
file.display()
),
Ok(metadata) if !metadata.is_file() => anyhow::bail!(
"node credential path {} is not a regular file",
file.display()
),
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error).with_context(|| format!("failed to inspect {}", file.display())),
}
}
fn persist_node_credential(file: &Path, credential: &StoredNodeCredential) -> Result<()> {
use std::io::Write;
let parent = file
.parent()
.with_context(|| format!("node credential path {} has no parent", file.display()))?;
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
if std::fs::symlink_metadata(parent)?.file_type().is_symlink() {
anyhow::bail!(
"refusing to store node credential through symbolic-link directory {}",
parent.display()
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("failed to secure {}", parent.display()))?;
}
let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
format!(
"failed to create temporary credential in {}",
parent.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
temporary
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
temporary.write_all(&serde_json::to_vec_pretty(credential)?)?;
temporary.as_file().sync_all()?;
temporary.persist_noclobber(file).map_err(|error| {
anyhow::anyhow!(
"refusing to overwrite node credential {}: {}",
file.display(),
error.error
)
})?;
Ok(())
}
pub(crate) fn local_node_credential_file(project: &Path, node: &str) -> PathBuf {
let digest = Digest::sha256(node);
let file_stem = digest.as_str().trim_start_matches("sha256:");
project
.join(".disasmer")
.join("nodes")
.join(format!("{file_stem}.json"))
}
fn default_node_public_key_for_plan(node: &str) -> String {
let private_key = generate_ed25519_private_key()
.unwrap_or_else(|_| format!("unavailable-random-node-plan-key:{node}"));
node_ed25519_public_key_from_private_key(&private_key)
.unwrap_or_else(|_| format!("{node}-public-key"))
}
fn signed_node_request_json(
node_private_key: &str,
node: &str,
request_kind: &str,
request: Value,
) -> Result<Value> {
let payload_digest = signed_request_payload_digest(&request);
let node_signature = sign_node_request(
node_private_key,
&NodeId::from(node),
request_kind,
&payload_digest,
command_nonce(request_kind),
unix_timestamp_seconds(),
)
.map_err(anyhow::Error::msg)?;
Ok(json!({
"type": "signed_node",
"node": node,
"node_signature": node_signature,
"request": request,
}))
}
pub(crate) fn default_node_id() -> String {
std::env::var("DISASMER_NODE_ID")
.or_else(|_| std::env::var("HOSTNAME"))
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "node-local".to_owned())
}
fn parse_capability(cap: &str) -> Option<Capability> {
match cap {
"command" => Some(Capability::Command),
"containers" => Some(Capability::Containers),
"rootless-podman" => Some(Capability::RootlessPodman),
"source-filesystem" => Some(Capability::SourceFilesystem),
"source-git" => Some(Capability::SourceGit),
"host-filesystem" => Some(Capability::HostFilesystem),
"network" => Some(Capability::Network),
"secrets" => Some(Capability::Secrets),
"inbound-ports" => Some(Capability::InboundPorts),
"arbitrary-syscalls" => Some(Capability::ArbitrarySyscalls),
"vfs-artifacts" => Some(Capability::VfsArtifacts),
"windows-command-dev" => Some(Capability::WindowsCommandDev),
"quic-direct" => Some(Capability::QuicDirect),
_ => None,
}
}

View file

@ -0,0 +1,638 @@
use anyhow::Result;
use serde::Serialize;
use serde_json::{json, Value};
pub(crate) fn emit_report<T: Serialize>(report: &T, json_output: bool) -> Result<()> {
let mut value = serde_json::to_value(report)?;
let exit_code = apply_command_report_exit_code(&mut value);
if json_output {
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
println!("{}", human_report(&value));
}
if let Some(exit_code) = exit_code {
std::process::exit(exit_code);
}
Ok(())
}
pub(crate) fn human_report(value: &Value) -> String {
let mut lines = Vec::new();
let title = value
.get("command")
.and_then(Value::as_str)
.map(|command| format!("Disasmer {command}"))
.or_else(|| {
if value.get("human_flow").is_some() {
Some("Disasmer login".to_owned())
} else if value.get("metadata").is_some()
&& value.get("source_provider_manifest").is_some()
{
Some("Disasmer bundle inspect".to_owned())
} else if value.get("entry").is_some() && value.get("session").is_some() {
Some("Disasmer run".to_owned())
} else if value.get("capabilities").is_some() && value.get("node").is_some() {
Some("Disasmer node attach".to_owned())
} else if value.get("public_key_fingerprint").is_some() {
Some("Disasmer agent enroll".to_owned())
} else {
None
}
})
.unwrap_or_else(|| "Disasmer report".to_owned());
lines.push(title);
push_string_field(&mut lines, value, "status", "status");
push_string_field(&mut lines, value, "coordinator", "coordinator");
push_string_field(&mut lines, value, "active_coordinator", "coordinator");
push_string_field(&mut lines, value, "tenant", "tenant");
push_string_field(&mut lines, value, "project", "project");
push_string_field(&mut lines, value, "process", "process");
push_string_field(&mut lines, value, "active_process", "active process");
push_string_field(&mut lines, value, "node", "node");
push_string_field(&mut lines, value, "artifact", "artifact");
push_string_field(&mut lines, value, "entry", "entry");
push_string_field(&mut lines, value, "quota_tier", "quota tier");
push_string_field(&mut lines, value, "config_file", "config");
push_string_field(&mut lines, value, "adapter", "adapter");
if let Some(project) = value.get("project").and_then(Value::as_str) {
if !lines
.iter()
.any(|line| line == &format!("project: {project}"))
{
lines.push(format!("project: {project}"));
}
}
if let Some(project_config) = value.get("project_config").or_else(|| value.get("project")) {
if project_config.is_object() {
push_nested_string_field(&mut lines, project_config, "tenant", "tenant");
push_nested_string_field(&mut lines, project_config, "project", "project");
push_nested_string_field(&mut lines, project_config, "coordinator", "coordinator");
}
}
if let Some(link) = value.get("current_directory_link") {
if link
.get("links_current_directory")
.and_then(Value::as_bool)
.unwrap_or(false)
{
lines.push("current directory linked: true".to_owned());
}
push_nested_string_field(&mut lines, link, "config_file", "current directory config");
}
if let Some(metadata) = value.get("metadata") {
push_nested_string_field(&mut lines, metadata, "identity", "bundle");
if let Some(environments) = metadata.get("environments").and_then(Value::as_array) {
let names = environments
.iter()
.filter_map(|env| env.get("name").and_then(Value::as_str))
.collect::<Vec<_>>();
if !names.is_empty() {
lines.push(format!("environments: {}", names.join(", ")));
}
}
}
if let Some(bundle) = value.get("bundle") {
if let Some(metadata) = bundle.get("metadata") {
push_nested_string_field(&mut lines, metadata, "identity", "bundle");
}
}
if let Some(environments) = value
.get("discovered_environments")
.and_then(Value::as_array)
{
let names = environments
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
if !names.is_empty() {
lines.push(format!("environments: {}", names.join(", ")));
}
}
if let Some(attached_nodes) = value.get("attached_nodes") {
if let Some(count) = attached_nodes.get("count").and_then(Value::as_u64) {
let online = attached_nodes
.get("online")
.and_then(Value::as_u64)
.unwrap_or(0);
lines.push(format!("attached nodes: {count} ({online} online)"));
}
}
if let Some(current_usage) = value.pointer("/quota_posture/current_usage") {
lines.push(format!("quota usage: {}", compact_json(current_usage)));
}
if let Some(current_task_count) = value.get("current_task_count").and_then(Value::as_u64) {
lines.push(format!("tasks: {current_task_count}"));
}
if let Some(tasks) = value.get("current_tasks").and_then(Value::as_array) {
push_task_placement_reasons(&mut lines, tasks);
push_task_locality_failures(&mut lines, tasks);
}
if let Some(tasks) = value.get("tasks").and_then(Value::as_array) {
lines.push(format!("tasks: {}", tasks.len()));
push_task_placement_reasons(&mut lines, tasks);
push_task_locality_failures(&mut lines, tasks);
}
if let Some(log_entries) = value.get("log_entries").and_then(Value::as_array) {
lines.push(format!("log entries: {}", log_entries.len()));
}
if let Some(artifacts) = value.get("artifacts").and_then(Value::as_array) {
lines.push(format!("artifacts: {}", artifacts.len()));
}
if let Some(statuses) = value
.get("source_provider_statuses")
.and_then(Value::as_array)
{
push_source_provider_statuses(&mut lines, statuses);
}
if let Some(bundle_statuses) = value
.pointer("/bundle/source_provider_statuses")
.and_then(Value::as_array)
{
push_source_provider_statuses(&mut lines, bundle_statuses);
}
if let Some(diagnostics) = value
.get("pre_schedule_diagnostics")
.or_else(|| value.get("diagnostics"))
.and_then(Value::as_array)
{
push_cli_diagnostics(&mut lines, diagnostics);
}
if let Some(bundle_diagnostics) = value
.pointer("/bundle/pre_schedule_diagnostics")
.and_then(Value::as_array)
{
push_cli_diagnostics(&mut lines, bundle_diagnostics);
}
if let Some(current_usage) = value.get("current_usage") {
lines.push(format!("quota usage: {}", compact_json(current_usage)));
}
if let Some(next_blocked_action) = value.get("next_blocked_action") {
if !next_blocked_action.is_null() {
lines.push(format!(
"next blocked action: {}",
compact_json(next_blocked_action)
));
}
}
push_machine_error_line(&mut lines, value.get("machine_error"));
push_machine_error_line(&mut lines, value.pointer("/run_start/machine_error"));
push_machine_error_line(&mut lines, value.pointer("/download_session/machine_error"));
push_machine_error_line(&mut lines, value.pointer("/export_plan/machine_error"));
push_machine_error_line(&mut lines, value.pointer("/local_export/machine_error"));
push_machine_error_line(
&mut lines,
value.pointer("/local_export/download_session/machine_error"),
);
push_machine_error_line(
&mut lines,
value.pointer("/local_export/stream/machine_error"),
);
if let Some(flow) = value.get("human_flow") {
if let Some(browser) = flow.get("Browser") {
lines.push("flow: browser".to_owned());
push_nested_string_field(&mut lines, browser, "authorization_url", "open");
}
}
if let Some(session) = value.get("session") {
lines.push(format!("session: {}", compact_json(session)));
}
if let Some(account) = value.get("coordinator_account_status") {
if let Some(checked) = account.get("checked").and_then(Value::as_bool) {
lines.push(format!("account status checked: {checked}"));
}
push_nested_string_field(&mut lines, account, "account_status", "account status");
if let Some(suspended) = account.get("suspended").and_then(Value::as_bool) {
lines.push(format!("account suspended: {suspended}"));
}
if let Some(disabled) = account.get("disabled").and_then(Value::as_bool) {
lines.push(format!("account disabled: {disabled}"));
}
if let Some(deleted) = account.get("deleted").and_then(Value::as_bool) {
lines.push(format!("account deleted: {deleted}"));
}
if let Some(manual_review) = account.get("manual_review").and_then(Value::as_bool) {
lines.push(format!("account manual review: {manual_review}"));
}
push_nested_string_field(&mut lines, account, "sanitized_reason", "account reason");
if let Some(exposed) = account
.get("private_moderation_details_exposed")
.and_then(Value::as_bool)
{
lines.push(format!("private moderation details exposed: {exposed}"));
}
}
if let Some(coordinator_selection) = value.get("coordinator") {
if coordinator_selection.is_object() {
lines.push(format!(
"coordinator: {}",
compact_json(coordinator_selection)
));
}
}
if let Some(reachability) = value.get("coordinator_reachability") {
if let Some(status) = reachability.get("status").and_then(Value::as_str) {
lines.push(format!("coordinator reachability: {status}"));
}
if let Some(error) = reachability.get("error").and_then(Value::as_str) {
lines.push(format!("coordinator error: {error}"));
}
if let Some(response_type) = reachability
.pointer("/response/type")
.and_then(Value::as_str)
{
lines.push(format!("coordinator ping: {response_type}"));
}
}
if let Some(response) = value
.get("response")
.or_else(|| value.get("coordinator_response"))
{
if let Some(response_type) = response.get("type").and_then(Value::as_str) {
lines.push(format!("coordinator response: {response_type}"));
}
}
if let Some(events) = value
.pointer("/events/response/events")
.and_then(Value::as_array)
{
lines.push(format!("events: {}", events.len()));
}
if let Some(events) = value
.pointer("/coordinator_response/response/events")
.and_then(Value::as_array)
{
lines.push(format!("events: {}", events.len()));
}
if let Some(requires_confirmation) = value.get("requires_confirmation").and_then(Value::as_bool)
{
lines.push(format!(
"confirmation: {}",
if requires_confirmation {
"required"
} else {
"confirmed"
}
));
}
if let Some(flag) = value
.get("private_website_required")
.and_then(Value::as_bool)
{
lines.push(format!("private website required: {flag}"));
}
if let Some(next_actions) = value.get("next_actions").and_then(Value::as_array) {
let actions = next_actions
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
if !actions.is_empty() {
lines.push(format!("next: {}", actions.join("; ")));
}
}
if let Some(auth) = value.get("auth").or_else(|| value.get("session")) {
if let Some(kind) = auth.get("kind").and_then(Value::as_str) {
lines.push(format!("auth: {kind}"));
}
if let Some(authenticated) = auth.get("authenticated").and_then(Value::as_bool) {
lines.push(format!("authenticated: {authenticated}"));
}
if let Some(expires) = auth.get("expires_at").and_then(Value::as_str) {
lines.push(format!("token expiry: {expires}"));
} else if let Some(posture) = auth.get("token_expiry_posture").and_then(Value::as_str) {
lines.push(format!("token expiry: {posture}"));
} else if auth.get("authenticated").is_some() {
lines.push("token expiry: unavailable".to_owned());
}
}
if let Some(dependencies) = value.get("dependencies").and_then(Value::as_object) {
let mut entries = dependencies
.iter()
.map(|(name, available)| {
format!(
"{name}={}",
if available.as_bool().unwrap_or(false) {
"ok"
} else {
"missing"
}
)
})
.collect::<Vec<_>>();
entries.sort();
if !entries.is_empty() {
lines.push(format!("dependencies: {}", entries.join(", ")));
}
}
if let Some(readiness) = value.get("node_readiness") {
push_nested_string_field(&mut lines, readiness, "os", "node os");
push_nested_string_field(&mut lines, readiness, "arch", "node arch");
if let Some(capabilities) = readiness.get("capabilities").and_then(Value::as_array) {
let caps = capabilities
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
if !caps.is_empty() {
lines.push(format!("node capabilities: {}", caps.join(", ")));
}
}
}
if let Some(summary) = value.get("node_readiness_summary") {
push_nested_string_field(&mut lines, summary, "status", "node readiness");
if let Some(missing) = summary
.get("missing_local_dependencies")
.and_then(Value::as_array)
{
let missing = missing.iter().filter_map(Value::as_str).collect::<Vec<_>>();
if !missing.is_empty() {
lines.push(format!("node missing dependencies: {}", missing.join(", ")));
}
}
if let Some(actions) = summary.get("next_actions").and_then(Value::as_array) {
let actions = actions.iter().filter_map(Value::as_str).collect::<Vec<_>>();
if !actions.is_empty() {
lines.push(format!("node next: {}", actions.join("; ")));
}
}
}
if let Some(detection) = value
.pointer("/plan/detection")
.or_else(|| value.get("detection"))
{
push_node_attach_detection(&mut lines, detection);
}
if let Some(disclosures) = value
.get("grant_disclosures")
.and_then(Value::as_array)
.filter(|disclosures| !disclosures.is_empty())
{
for disclosure in disclosures {
let grant = disclosure
.get("grant")
.and_then(Value::as_str)
.unwrap_or("capability");
let description = disclosure
.get("description")
.and_then(Value::as_str)
.unwrap_or("capability grant");
let policy = if disclosure
.get("coordinator_policy_limited")
.and_then(Value::as_bool)
.unwrap_or(false)
{
"policy-limited"
} else {
"unbounded"
};
lines.push(format!("grant {grant}: {description} ({policy})"));
}
}
lines.dedup();
lines.join("\n")
}
fn push_node_attach_detection(lines: &mut Vec<String>, detection: &Value) {
push_nested_string_field(lines, detection, "os", "node os");
push_nested_string_field(lines, detection, "arch", "node arch");
push_nested_string_field(lines, detection, "command_backend", "command backend");
if let Some(backend) = detection.get("container_backend").and_then(Value::as_str) {
let available = detection
.get("container_backend_available")
.and_then(Value::as_bool)
.unwrap_or(false);
lines.push(format!(
"container backend: {backend} ({})",
if available { "available" } else { "reported" }
));
} else if detection
.get("container_backend_reported")
.and_then(Value::as_bool)
.unwrap_or(false)
{
lines.push("container backend: reported".to_owned());
}
if let Some(providers) = detection
.get("source_provider_backends")
.and_then(Value::as_array)
{
let entries = providers
.iter()
.filter_map(|provider| {
let name = provider.get("provider").and_then(Value::as_str)?;
let state = if provider
.get("available")
.and_then(Value::as_bool)
.unwrap_or(false)
{
"available"
} else {
"detected"
};
Some(format!("{name}={state}"))
})
.collect::<Vec<_>>();
if !entries.is_empty() {
lines.push(format!("source providers: {}", entries.join(", ")));
}
}
if let Some(overrides) = detection
.get("manual_capability_overrides")
.and_then(Value::as_array)
{
let overrides = overrides
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
if !overrides.is_empty() {
lines.push(format!("capability overrides: {}", overrides.join(", ")));
}
}
}
fn push_string_field(lines: &mut Vec<String>, value: &Value, key: &str, label: &str) {
if let Some(text) = value.get(key).and_then(Value::as_str) {
lines.push(format!("{label}: {text}"));
} else if let Some(path) = value.get(key).and_then(|value| {
value
.as_object()
.and_then(|object| object.get("display"))
.and_then(Value::as_str)
}) {
lines.push(format!("{label}: {path}"));
}
}
fn push_task_placement_reasons(lines: &mut Vec<String>, tasks: &[Value]) {
for task in tasks {
let Some(placement) = task.get("node_placement") else {
continue;
};
let Some(reasons) = placement.get("reasons").and_then(Value::as_array) else {
continue;
};
let reasons = reasons.iter().filter_map(Value::as_str).collect::<Vec<_>>();
if reasons.is_empty() {
continue;
}
let task_name = task
.get("task")
.and_then(Value::as_str)
.unwrap_or("unknown");
let node = placement
.get("node")
.and_then(Value::as_str)
.unwrap_or("unknown");
lines.push(format!(
"placement {task_name}: {node} ({})",
reasons.join(", ")
));
}
}
fn push_task_locality_failures(lines: &mut Vec<String>, tasks: &[Value]) {
for task in tasks {
let Some(locality) = task.get("locality_failure") else {
continue;
};
if locality.is_null() {
continue;
}
let task_name = task
.get("task")
.and_then(Value::as_str)
.unwrap_or("unknown");
let affected = locality
.get("affected_data")
.and_then(Value::as_str)
.unwrap_or("direct_transfer");
let reason = locality
.get("reason")
.and_then(Value::as_str)
.unwrap_or("direct transfer or locality failed");
lines.push(format!("locality {task_name}: {affected} ({reason})"));
let actions = locality
.get("safe_next_actions")
.and_then(Value::as_array)
.map(|actions| actions.iter().filter_map(Value::as_str).collect::<Vec<_>>())
.unwrap_or_default();
if !actions.is_empty() {
lines.push(format!("locality next {task_name}: {}", actions.join("; ")));
}
}
}
fn push_nested_string_field(lines: &mut Vec<String>, value: &Value, key: &str, label: &str) {
if let Some(text) = value.get(key).and_then(Value::as_str) {
lines.push(format!("{label}: {text}"));
}
}
fn push_machine_error_line(lines: &mut Vec<String>, machine_error: Option<&Value>) {
let Some(machine_error) = machine_error else {
return;
};
if machine_error.is_null() {
return;
}
let category = machine_error
.get("category")
.and_then(Value::as_str)
.unwrap_or("unknown");
let exit_code = machine_error
.get("stable_exit_code")
.and_then(Value::as_i64)
.unwrap_or(1);
lines.push(format!("error category: {category} (exit {exit_code})"));
if let Some(tier) = machine_error
.get("community_tier_label")
.and_then(Value::as_str)
{
lines.push(format!("quota tier: {tier}"));
}
if let Some(next_actions) = machine_error.get("next_actions").and_then(Value::as_array) {
let actions = next_actions
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
if !actions.is_empty() {
lines.push(format!("error next: {}", actions.join("; ")));
}
}
}
fn push_source_provider_statuses(lines: &mut Vec<String>, statuses: &[Value]) {
let entries = statuses
.iter()
.filter_map(|status| {
let provider = status.get("provider").and_then(Value::as_str)?;
let state = status.get("status").and_then(Value::as_str)?;
let active = status
.get("active")
.and_then(Value::as_bool)
.unwrap_or(false);
Some(format!(
"{provider}={state}{}",
if active { "(active)" } else { "" }
))
})
.collect::<Vec<_>>();
if !entries.is_empty() {
lines.push(format!("source providers: {}", entries.join(", ")));
}
}
fn push_cli_diagnostics(lines: &mut Vec<String>, diagnostics: &[Value]) {
if diagnostics.is_empty() {
return;
}
lines.push(format!("diagnostics: {}", diagnostics.len()));
for diagnostic in diagnostics.iter().take(3) {
let severity = diagnostic
.get("severity")
.and_then(Value::as_str)
.unwrap_or("info");
let message = diagnostic
.get("message")
.and_then(Value::as_str)
.unwrap_or("diagnostic");
lines.push(format!("diagnostic {severity}: {message}"));
}
}
fn compact_json(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_owned())
}
pub(crate) fn apply_command_report_exit_code(value: &mut Value) -> Option<i32> {
for pointer in [
"/machine_error",
"/run_start/machine_error",
"/restart_request/machine_error",
"/cancel_request/machine_error",
"/task_restart/machine_error",
"/download_session/machine_error",
"/export_plan/machine_error",
"/local_export/machine_error",
"/local_export/download_session/machine_error",
"/local_export/stream/machine_error",
] {
let Some(machine_error) = value.pointer_mut(pointer) else {
continue;
};
let Some(exit_code) = machine_error
.get("stable_exit_code")
.and_then(Value::as_i64)
.and_then(|code| i32::try_from(code).ok())
.filter(|code| *code != 0)
else {
continue;
};
if let Some(object) = machine_error.as_object_mut() {
object.insert("process_exit_code_applied".to_owned(), json!(true));
}
return Some(exit_code);
}
None
}

View file

@ -0,0 +1,333 @@
use anyhow::Result;
use serde_json::{json, Value};
use crate::client::{
authenticated_or_local_trusted_request, list_task_events_if_available_with_session,
stored_session_for_coordinator, JsonLineSession,
};
use crate::config::StoredCliSession;
use crate::process_events::{
process_cancel_request_summary, process_restart_request_summary, process_state_from_tasks,
task_summaries,
};
use crate::{
confirmation_required_report, CliScopeArgs, ProcessAbortArgs, ProcessCancelArgs,
ProcessListArgs, ProcessRestartArgs, ProcessStatusArgs,
};
fn hydrate_process_scope(scope: &mut CliScopeArgs, stored_session: Option<&StoredCliSession>) {
if scope.coordinator.is_none() {
scope.coordinator = stored_session
.filter(|session| session.session_secret.is_some())
.map(|session| session.coordinator.clone());
}
if let Some(bound_session) = scope
.coordinator
.as_deref()
.and_then(|coordinator| stored_session_for_coordinator(coordinator, stored_session))
{
scope.tenant = bound_session.tenant.clone();
scope.project = bound_session.project.clone();
scope.user = bound_session.user.clone();
}
}
pub(crate) fn process_list_report_with_session(
mut args: ProcessListArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
hydrate_process_scope(&mut args.scope, stored_session);
let Some(coordinator) = &args.scope.coordinator else {
return Ok(json!({
"command": "process list",
"status": "requires_coordinator",
"processes": [],
}));
};
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({ "type": "list_processes" }),
json!({
"type": "list_processes",
"tenant": args.scope.tenant,
"project": args.scope.project,
"actor_user": args.scope.user,
}),
)?)?;
let processes = response
.get("processes")
.cloned()
.unwrap_or_else(|| json!([]));
Ok(json!({
"command": "process list",
"status": "ok",
"coordinator": coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"user": args.scope.user,
"processes": processes,
"response": response,
"coordinator_session_requests": session.requests(),
}))
}
#[cfg(test)]
pub(crate) fn process_status_report(args: ProcessStatusArgs) -> Result<Value> {
process_status_report_with_session(args, None)
}
pub(crate) fn process_status_report_with_session(
mut args: ProcessStatusArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
hydrate_process_scope(&mut args.scope, stored_session);
let live = process_list_report_with_session(
ProcessListArgs {
scope: args.scope.clone(),
},
stored_session,
)?;
let live_process = live
.get("processes")
.and_then(Value::as_array)
.and_then(|processes| {
processes.iter().find(|process| {
process.get("process").and_then(Value::as_str) == Some(args.process.as_str())
})
})
.cloned();
let events = list_task_events_if_available_with_session(
args.scope.coordinator.as_deref(),
&args.scope,
Some(args.process.clone()),
stored_session,
)?;
let current_tasks = task_summaries(events.as_ref());
let current_task_count = current_tasks.as_array().map(Vec::len).unwrap_or(0);
let historical_state = process_state_from_tasks(events.as_ref());
let state = live_process
.as_ref()
.and_then(|process| process.get("state"))
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| {
if live.get("status").and_then(Value::as_str) == Some("ok") {
"not_active".to_owned()
} else if events.is_some() {
historical_state.to_owned()
} else {
"unknown_without_coordinator".to_owned()
}
});
Ok(json!({
"command": "process status",
"process": args.process,
"state": state,
"live_process": live_process,
"started_entrypoint": "unknown_from_task_events",
"current_task_count": current_task_count,
"current_tasks": current_tasks,
"debug_state": {
"frozen": null,
"status": "unknown_from_cli_task_events"
},
"events": events,
}))
}
#[cfg(test)]
pub(crate) fn process_restart_report(args: ProcessRestartArgs) -> Result<Value> {
process_restart_report_with_session(args, None)
}
pub(crate) fn process_restart_report_with_session(
mut args: ProcessRestartArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
hydrate_process_scope(&mut args.scope, stored_session);
if !args.yes {
return Ok(confirmation_required_report(
"process restart",
"restart_virtual_process",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"process": args.process,
}),
format!("disasmer process restart --process {} --yes", args.process),
));
}
if let Some(coordinator) = &args.scope.coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "start_process",
"process": args.process,
"restart": true,
}),
json!({
"type": "start_process",
"tenant": args.scope.tenant,
"project": args.scope.project,
"process": args.process,
"restart": true,
}),
)?)?;
let restart_request = process_restart_request_summary(&response, !args.yes);
return Ok(json!({
"command": "process restart",
"coordinator": coordinator,
"process": args.process,
"requires_confirmation": !args.yes,
"restart_request": restart_request,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "process restart",
"status": "requires_coordinator",
"requires_confirmation": !args.yes,
"process": args.process,
"restart_request": {
"status": "requires_coordinator",
"operation": "restart_virtual_process",
"explicit_user_action": true,
},
}))
}
pub(crate) fn process_cancel_report(args: ProcessCancelArgs) -> Result<Value> {
process_cancel_report_with_session(args, None)
}
pub(crate) fn process_cancel_report_with_session(
mut args: ProcessCancelArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
hydrate_process_scope(&mut args.scope, stored_session);
if !args.yes {
return Ok(confirmation_required_report(
"process cancel",
"cancel_virtual_process",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"process": args.process,
"node": args.node,
"task": args.task,
}),
format!("disasmer process cancel --process {} --yes", args.process),
));
}
if let Some(coordinator) = &args.scope.coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "cancel_process",
"process": args.process,
}),
json!({
"type": "cancel_process",
"tenant": args.scope.tenant,
"project": args.scope.project,
"actor_user": args.scope.user,
"process": args.process,
}),
)?)?;
let cancel_request = process_cancel_request_summary(&response, !args.yes);
return Ok(json!({
"command": "process cancel",
"coordinator": coordinator,
"requires_confirmation": !args.yes,
"process": args.process,
"node": args.node,
"task": args.task,
"cancel_request": cancel_request,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "process cancel",
"status": "requires_coordinator",
"requires_confirmation": !args.yes,
"process": args.process,
"node": args.node,
"task": args.task,
"cancel_request": {
"status": "requires_coordinator",
"operation": "cancel_virtual_process",
"whole_process_cancel_available": true,
"explicit_user_action": true,
},
}))
}
pub(crate) fn process_abort_report_with_session(
mut args: ProcessAbortArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
hydrate_process_scope(&mut args.scope, stored_session);
if !args.yes {
return Ok(confirmation_required_report(
"process abort",
"abort_virtual_process",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"process": args.process,
}),
format!("disasmer process abort --process {} --yes", args.process),
));
}
let Some(coordinator) = &args.scope.coordinator else {
return Ok(json!({
"command": "process abort",
"status": "requires_coordinator",
"process": args.process,
"requires_confirmation": false,
}));
};
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "abort_process",
"process": args.process,
}),
json!({
"type": "abort_process",
"tenant": args.scope.tenant,
"project": args.scope.project,
"actor_user": args.scope.user,
"process": args.process,
}),
)?)?;
Ok(json!({
"command": "process abort",
"status": "aborted",
"coordinator": coordinator,
"process": args.process,
"requires_confirmation": false,
"abort_request": {
"accepted": response.get("type").and_then(Value::as_str) == Some("process_aborted"),
"operation": "abort_virtual_process",
"forced": true,
"cooperative": false,
"process_slot_released": true,
},
"response": response,
"coordinator_session_requests": session.requests(),
}))
}

View file

@ -0,0 +1,666 @@
use std::path::Path;
use serde_json::{json, Value};
use crate::errors::{
cli_error_summary, cli_error_summary_for_category, cli_error_summary_with_default,
message_mentions_locality_failure,
};
fn task_event_values(task_events: Option<&Value>) -> Vec<&Value> {
task_events
.and_then(|task_events| task_events.pointer("/response/events"))
.and_then(Value::as_array)
.map(|events| events.iter().collect())
.unwrap_or_default()
}
fn event_string(event: &Value, field: &str) -> Option<String> {
event.get(field).and_then(Value::as_str).map(str::to_owned)
}
fn event_u64(event: &Value, field: &str) -> Option<u64> {
event.get(field).and_then(Value::as_u64)
}
pub(crate) fn task_summaries(task_events: Option<&Value>) -> Value {
Value::Array(
task_event_values(task_events)
.into_iter()
.map(|event| {
let task = event_string(event, "task").unwrap_or_else(|| "unknown".to_owned());
let terminal_state =
event_string(event, "terminal_state").unwrap_or_else(|| "unknown".to_owned());
let node = event_string(event, "node");
let placement = event.get("placement");
let placement_reasons = placement
.and_then(|placement| placement.get("reasons"))
.cloned()
.unwrap_or_else(|| json!([]));
let placement_score = placement
.and_then(|placement| placement.get("score"))
.cloned()
.unwrap_or(Value::Null);
let failure_reason = task_failure_reason(event);
let locality_failure = task_locality_failure_from_reason(&failure_reason);
let machine_error = task_failure_machine_error_from_reason(event, &failure_reason);
json!({
"process": event_string(event, "process"),
"task": task,
"state": terminal_state,
"environment": event.get("environment").cloned().unwrap_or_else(|| json!("unknown_from_task_event")),
"environment_digest": event.get("environment_digest").cloned().unwrap_or(Value::Null),
"node_placement": {
"node": node,
"source": "coordinator_task_event",
"score": placement_score,
"reasons": placement_reasons,
"explanation_available": placement.is_some(),
},
"failure_reason": failure_reason,
"locality_failure": locality_failure,
"machine_error": machine_error,
"stdout_bytes": event_u64(event, "stdout_bytes").unwrap_or(0),
"stderr_bytes": event_u64(event, "stderr_bytes").unwrap_or(0),
})
})
.collect(),
)
}
fn task_failure_reason(event: &Value) -> Value {
match event.get("terminal_state").and_then(Value::as_str) {
Some("failed") => {
if let Some(stderr) = event.get("stderr_tail").and_then(Value::as_str) {
if !stderr.is_empty() {
return json!(redact_secret_like_text(stderr).0);
}
}
if let Some(status_code) = event.get("status_code").and_then(Value::as_i64) {
return json!(format!("task exited with status {status_code}"));
}
json!("task failed")
}
Some("cancelled") => json!("task cancelled"),
_ => Value::Null,
}
}
fn task_failure_machine_error_from_reason(event: &Value, reason: &Value) -> Value {
match event.get("terminal_state").and_then(Value::as_str) {
Some("failed") => {
let reason = reason.as_str().unwrap_or("task failed").to_owned();
let mut summary = cli_error_summary_with_default(&reason, "program");
if let Some(object) = summary.as_object_mut() {
if message_mentions_locality_failure(&reason.to_ascii_lowercase()) {
object.insert("locality_failure".to_owned(), json!(true));
object.insert(
"next_actions".to_owned(),
json!(locality_failure_next_actions(&reason)),
);
}
}
summary
}
Some("cancelled") => cli_error_summary_for_category("program", "task cancelled"),
_ => Value::Null,
}
}
fn task_locality_failure_from_reason(reason: &Value) -> Value {
let Some(reason) = reason.as_str() else {
return Value::Null;
};
let lower = reason.to_ascii_lowercase();
if !message_mentions_locality_failure(&lower) {
return Value::Null;
}
let affected_data = if lower.contains("source snapshot") {
"source_snapshot"
} else if lower.contains("artifact") {
"artifact"
} else {
"direct_transfer"
};
json!({
"category": "connectivity",
"affected_data": affected_data,
"reason": reason,
"coordinator_bulk_relay_used": false,
"safe_failure": true,
"safe_next_actions": locality_failure_next_actions(reason),
})
}
fn locality_failure_next_actions(reason: &str) -> Vec<&'static str> {
let lower = reason.to_ascii_lowercase();
if lower.contains("source snapshot") {
return vec![
"attach or select a node that already has the required source snapshot",
"rerun source preparation on an attached node",
"restore direct node-to-node connectivity and retry",
"do not rely on coordinator bulk source relay",
];
}
if lower.contains("artifact") {
return vec![
"attach or select a node that already has the required artifact",
"explicitly export or download the artifact before retrying",
"restore direct node-to-node connectivity and retry",
"do not rely on coordinator bulk artifact relay",
];
}
vec![
"check node direct connectivity and NAT traversal",
"attach a node with the needed source/artifact locality",
"retry after node connectivity is restored",
"do not rely on coordinator bulk relay",
]
}
pub(crate) fn process_state_from_tasks(task_events: Option<&Value>) -> &'static str {
let events = task_event_values(task_events);
if events.is_empty() {
return "no_tasks_observed";
}
if events.iter().any(|event| {
event
.get("terminal_state")
.and_then(Value::as_str)
.is_some_and(|state| state == "failed")
}) {
return "has_failed_tasks";
}
if events.iter().any(|event| {
event
.get("terminal_state")
.and_then(Value::as_str)
.is_some_and(|state| state == "cancelled")
}) {
return "has_cancelled_tasks";
}
if events.iter().all(|event| {
event
.get("terminal_state")
.and_then(Value::as_str)
.is_some_and(|state| state == "completed")
}) {
return "completed_tasks_observed";
}
"tasks_observed"
}
pub(crate) fn log_entries(task_events: Option<&Value>, task_filter: Option<&str>) -> Value {
Value::Array(
task_event_values(task_events)
.into_iter()
.filter(|event| {
task_filter.is_none_or( |task_filter| {
event
.get("task")
.and_then(Value::as_str)
.is_some_and(|task| task == task_filter)
})
})
.map(|event| {
let stdout_tail = event
.get("stdout_tail")
.and_then(Value::as_str)
.unwrap_or("");
let stderr_tail = event
.get("stderr_tail")
.and_then(Value::as_str)
.unwrap_or("");
let (stdout_tail, stdout_tail_redacted) = redact_secret_like_text(stdout_tail);
let (stderr_tail, stderr_tail_redacted) = redact_secret_like_text(stderr_tail);
json!({
"process": event_string(event, "process"),
"task": event_string(event, "task"),
"node": event_string(event, "node"),
"stdout_bytes": event_u64(event, "stdout_bytes").unwrap_or(0),
"stderr_bytes": event_u64(event, "stderr_bytes").unwrap_or(0),
"stdout_tail": stdout_tail,
"stderr_tail": stderr_tail,
"stdout_truncated": event.get("stdout_truncated").and_then(Value::as_bool).unwrap_or(false),
"stderr_truncated": event.get("stderr_truncated").and_then(Value::as_bool).unwrap_or(false),
"capped": true,
"secret_like_values_redacted": stdout_tail_redacted || stderr_tail_redacted,
"redacted_fields": redacted_log_fields(stdout_tail_redacted, stderr_tail_redacted),
})
})
.collect(),
)
}
fn redacted_log_fields(
stdout_tail_redacted: bool,
stderr_tail_redacted: bool,
) -> Vec<&'static str> {
let mut fields = Vec::new();
if stdout_tail_redacted {
fields.push("stdout_tail");
}
if stderr_tail_redacted {
fields.push("stderr_tail");
}
fields
}
fn redact_secret_like_text(text: &str) -> (String, bool) {
let markers = [
"access_token=",
"access_token:",
"refresh_token=",
"refresh_token:",
"id_token=",
"id_token:",
"api_key=",
"api_key:",
"api-key=",
"api-key:",
"token=",
"token:",
"secret=",
"secret:",
"password=",
"password:",
"passwd=",
"passwd:",
"bearer ",
];
let mut output = text.to_owned();
let mut redacted = false;
for marker in markers {
let (updated, changed) = redact_marker_values(output, marker);
output = updated;
redacted |= changed;
}
(output, redacted)
}
fn redact_marker_values(mut text: String, marker: &str) -> (String, bool) {
let mut changed = false;
let mut search_start = 0;
loop {
let lower = text.to_ascii_lowercase();
let Some(relative) = lower[search_start..].find(marker) else {
break;
};
let value_start = search_start + relative + marker.len();
let value_end = text[value_start..]
.char_indices()
.find_map(|(offset, character)| {
(character.is_whitespace()
|| matches!(
character,
'&' | '"' | '\'' | '`' | '<' | '>' | ',' | ';' | ')' | ']'
))
.then_some(value_start + offset)
})
.unwrap_or(text.len());
if value_start == value_end {
search_start = value_end;
if search_start >= text.len() {
break;
}
continue;
}
if text[value_start..value_end].starts_with("[redacted") {
search_start = value_end;
if search_start >= text.len() {
break;
}
continue;
}
text.replace_range(value_start..value_end, "[redacted]");
changed = true;
search_start = value_start + "[redacted]".len();
if search_start >= text.len() {
break;
}
}
(text, changed)
}
pub(crate) fn artifact_summaries(task_events: Option<&Value>) -> Value {
Value::Array(
task_event_values(task_events)
.into_iter()
.filter_map(|event| {
let path = event.get("artifact_path").and_then(Value::as_str)?;
let node = event_string(event, "node");
Some(json!({
"artifact": artifact_name_from_path(path),
"path": path,
"producer_task": event_string(event, "task"),
"producer_node": node,
"process": event_string(event, "process"),
"digest": event.get("artifact_digest").cloned().unwrap_or(Value::Null),
"size_bytes": event.get("artifact_size_bytes").cloned().unwrap_or(Value::Null),
"state": if event.get("artifact_digest").is_some() { "metadata_flushed" } else { "metadata_without_digest" },
"known_locations": node.into_iter().collect::<Vec<_>>(),
"durable_storage": false,
}))
})
.collect(),
)
}
fn artifact_name_from_path(path: &str) -> String {
path.rsplit('/').next().unwrap_or(path).to_owned()
}
fn response_error_message(response: &Value, fallback: &str) -> String {
response
.get("message")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| fallback.to_owned())
}
pub(crate) fn artifact_response_machine_error(
response: &Value,
fallback: &str,
default_category: &'static str,
) -> Value {
let message = response_error_message(response, fallback);
cli_error_summary_with_default(&message, default_category)
}
pub(crate) fn process_restart_request_summary(
response: &Value,
requires_confirmation: bool,
) -> Value {
if response.get("type").and_then(Value::as_str) != Some("process_started") {
let message = response_error_message(response, "coordinator rejected process restart");
return json!({
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
"operation": "restart_virtual_process",
"accepted": false,
"requires_confirmation": requires_confirmation,
"explicit_user_action": true,
"error": message,
"machine_error": cli_error_summary(&message),
});
}
json!({
"status": "process_started",
"operation": "restart_virtual_process",
"accepted": true,
"process": response.get("process").cloned().unwrap_or(Value::Null),
"coordinator_epoch": response.get("epoch").cloned().unwrap_or(Value::Null),
"requires_confirmation": requires_confirmation,
"explicit_user_action": true,
"website_required": false,
"single_active_process_boundary": true,
})
}
pub(crate) fn process_cancel_request_summary(
response: &Value,
requires_confirmation: bool,
) -> Value {
if response.get("type").and_then(Value::as_str) != Some("process_cancellation_requested") {
let message = response_error_message(response, "coordinator rejected process cancel");
return json!({
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
"operation": "cancel_virtual_process",
"accepted": false,
"requires_confirmation": requires_confirmation,
"explicit_user_action": true,
"whole_process_cancel_available": true,
"error": message,
"machine_error": cli_error_summary(&message),
});
}
let cancelled_tasks = response
.get("cancelled_tasks")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
json!({
"status": "process_cancellation_requested",
"operation": "cancel_virtual_process",
"accepted": true,
"process": response.get("process").cloned().unwrap_or(Value::Null),
"cancelled_task_count": cancelled_tasks,
"cancelled_tasks": response.get("cancelled_tasks").cloned().unwrap_or_else(|| json!([])),
"affected_nodes": response.get("affected_nodes").cloned().unwrap_or_else(|| json!([])),
"requires_confirmation": requires_confirmation,
"explicit_user_action": true,
"website_required": false,
"whole_process_cancel_available": true,
"node_must_poll_task_control": true,
"new_task_launches_blocked": true,
"surviving_state_visibility": "task and artifact state remains visible after terminal task events are reported",
})
}
pub(crate) fn task_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value {
if response.get("type").and_then(Value::as_str) != Some("task_restart") {
let message = response_error_message(response, "coordinator rejected task restart");
return json!({
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
"operation": "restart_selected_task",
"accepted": false,
"requires_confirmation": requires_confirmation,
"explicit_user_action": true,
"clean_boundary_required": true,
"error": message,
"machine_error": cli_error_summary(&message),
});
}
json!({
"status": "task_restart",
"operation": "restart_selected_task",
"accepted": response.get("accepted").cloned().unwrap_or(json!(false)),
"process": response.get("process").cloned().unwrap_or(Value::Null),
"task": response.get("task").cloned().unwrap_or(Value::Null),
"requires_confirmation": requires_confirmation,
"explicit_user_action": true,
"clean_boundary_required": true,
"clean_boundary_available": response
.get("clean_boundary_available")
.cloned()
.unwrap_or(json!(false)),
"active_task": response
.get("active_task")
.cloned()
.unwrap_or(json!(false)),
"completed_event_observed": response
.get("completed_event_observed")
.cloned()
.unwrap_or(json!(false)),
"requires_whole_process_restart": response
.get("requires_whole_process_restart")
.cloned()
.unwrap_or(json!(true)),
"message": response.get("message").cloned().unwrap_or(Value::Null),
"audit_event": response.get("audit_event").cloned().unwrap_or(Value::Null),
"charged_debug_read_bytes": response
.get("charged_debug_read_bytes")
.cloned()
.unwrap_or_else(|| json!(0)),
"used_debug_read_bytes": response
.get("used_debug_read_bytes")
.cloned()
.unwrap_or_else(|| json!(0)),
"debug_reads_quota_limited": true,
"website_required": false,
})
}
pub(crate) fn artifact_download_session_summary(response: &Value) -> Value {
if response.get("type").and_then(Value::as_str) != Some("artifact_download_link") {
let message = response_error_message(response, "coordinator rejected artifact download");
return json!({
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
"link_issued": false,
"explicit_user_action_required": true,
"error": message.clone(),
"machine_error": cli_error_summary_with_default(&message, "connectivity"),
});
}
let link = response.get("link").unwrap_or(&Value::Null);
json!({
"status": "download_link_issued",
"link_issued": true,
"explicit_user_action_required": true,
"coordinator_preflight": "completed_before_link_issued",
"tenant": link.get("tenant").cloned().unwrap_or(Value::Null),
"project": link.get("project").cloned().unwrap_or(Value::Null),
"process": link.get("process").cloned().unwrap_or(Value::Null),
"artifact": link.get("artifact").cloned().unwrap_or(Value::Null),
"actor": link.get("actor").cloned().unwrap_or(Value::Null),
"source": link.get("source").cloned().unwrap_or(Value::Null),
"url_path": link.get("url_path").cloned().unwrap_or(Value::Null),
"expires_at_epoch_seconds": link.get("expires_at_epoch_seconds").cloned().unwrap_or(Value::Null),
"max_bytes": link.get("max_bytes").cloned().unwrap_or(Value::Null),
"token_material_returned": false,
"scoped_token_digest_present": link.get("scoped_token_digest").is_some(),
"policy_context_digest_present": link.get("policy_context_digest").is_some(),
"authorization_required": true,
"short_lived": link.get("expires_at_epoch_seconds").is_some(),
"guessable_public_url": false,
"cross_tenant_usable": false,
"unauthorized_project_usable": false,
"default_durable_store_assumed": false,
})
}
pub(crate) fn artifact_download_grant_disclosures(response: &Value) -> Value {
if response.get("type").and_then(Value::as_str) != Some("artifact_download_link") {
return json!([]);
}
let link = response.get("link").unwrap_or(&Value::Null);
json!([{
"grant": "artifact_download",
"description": "download scoped artifact bytes to the requesting machine",
"risk": "authorized artifact bytes leave the retaining node or explicit storage through an explicit download/export operation",
"coordinator_policy_limited": true,
"authorization_required": true,
"explicit_user_action_required": true,
"tenant": link.get("tenant").cloned().unwrap_or(Value::Null),
"project": link.get("project").cloned().unwrap_or(Value::Null),
"process": link.get("process").cloned().unwrap_or(Value::Null),
"artifact": link.get("artifact").cloned().unwrap_or(Value::Null),
"actor": link.get("actor").cloned().unwrap_or(Value::Null),
"source": link.get("source").cloned().unwrap_or(Value::Null),
"max_bytes": link.get("max_bytes").cloned().unwrap_or(Value::Null),
"expires_at_epoch_seconds": link.get("expires_at_epoch_seconds").cloned().unwrap_or(Value::Null),
"short_lived": link.get("expires_at_epoch_seconds").is_some(),
"scoped_token_digest_present": link.get("scoped_token_digest").is_some(),
"token_material_returned": false,
"guessable_public_url": false,
"cross_tenant_reuse_allowed": false,
"unauthorized_project_reuse_allowed": false,
"default_durable_store_assumed": false,
"private_website_required": false,
}])
}
pub(crate) fn artifact_export_plan_summary(response: &Value, to: &Path) -> Value {
if response.get("type").and_then(Value::as_str) != Some("artifact_export_plan") {
let message = response_error_message(response, "coordinator rejected artifact export");
return json!({
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
"explicit_user_action": true,
"local_path": to,
"local_bytes_written_by_cli": false,
"default_durable_store_assumed": false,
"error": message.clone(),
"machine_error": cli_error_summary_with_default(&message, "connectivity"),
});
}
let plan = response.get("plan").unwrap_or(&Value::Null);
json!({
"status": "transfer_plan_created",
"explicit_user_action": true,
"local_path": to,
"local_bytes_written_by_cli": false,
"writes_require_data_plane_followup": true,
"default_durable_store_assumed": false,
"artifact_size_bytes": response.get("artifact_size_bytes").cloned().unwrap_or(Value::Null),
"source_node": response.get("source_node").cloned().unwrap_or(Value::Null),
"receiver_node": response.get("receiver_node").cloned().unwrap_or(Value::Null),
"transport": plan.get("transport").cloned().unwrap_or(Value::Null),
"artifact": plan.pointer("/scope/object/Artifact").cloned().unwrap_or(Value::Null),
"tenant": plan.pointer("/scope/tenant").cloned().unwrap_or(Value::Null),
"project": plan.pointer("/scope/project").cloned().unwrap_or(Value::Null),
"process": plan.pointer("/scope/process").cloned().unwrap_or(Value::Null),
"coordinator_assisted_rendezvous": plan.get("coordinator_assisted_rendezvous").cloned().unwrap_or(Value::Null),
"coordinator_bulk_relay_allowed": plan.get("coordinator_bulk_relay_allowed").cloned().unwrap_or(Value::Null),
"authorization_digest_present": plan.get("authorization_digest").is_some(),
})
}
pub(crate) fn task_event_count(task_events: Option<&Value>) -> usize {
task_events
.and_then(|task_events| task_events.pointer("/response/events"))
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0)
}
pub(crate) fn project_quota_posture(attached_nodes: &Value, task_events: Option<&Value>) -> Value {
let current_usage = quota_current_usage(attached_nodes, task_events);
let next_blocked_action = quota_next_blocked_action(&current_usage);
json!({
"source": "cli_project_status_summary",
"current_usage": current_usage,
"limits": quota_limits_value(),
"next_blocked_action": next_blocked_action,
"private_abuse_heuristics_exposed": false,
})
}
pub(crate) fn quota_current_usage(attached_nodes: &Value, task_events: Option<&Value>) -> Value {
let attached_node_count = attached_nodes
.get("count")
.and_then(Value::as_u64)
.unwrap_or(0);
let online_node_count = attached_nodes
.get("online")
.and_then(Value::as_u64)
.unwrap_or(0);
json!({
"attached_nodes": attached_node_count,
"online_nodes": online_node_count,
"observed_task_events": task_event_count(task_events),
"artifact_download_bytes": 0,
"rendezvous_attempts": 0,
"hosted_wasm_processes": 0,
})
}
pub(crate) fn quota_limits_value() -> Value {
json!({
"source": "coordinator",
"configured": false,
"message": "connect to the selected coordinator to read its scoped quota configuration",
})
}
pub(crate) fn quota_next_blocked_action(current_usage: &Value) -> Value {
if current_usage
.get("online_nodes")
.and_then(Value::as_u64)
.unwrap_or(0)
== 0
{
return json!({
"action": "node_work_requires_online_attached_node",
"category": "capability",
"quota_related": false,
"message": "no online attached node is visible for work that requires a user node",
"machine_error": cli_error_summary_for_category(
"capability",
"no online attached node is visible for work that requires a user node"
)
});
}
Value::Null
}

View file

@ -0,0 +1,381 @@
use std::path::PathBuf;
use anyhow::Result;
use serde_json::{json, Value};
use crate::client::{
authenticated_or_local_trusted_request, list_attached_nodes_if_available_with_session,
list_task_events_if_available_with_session, stored_session_for_coordinator, JsonLineSession,
};
use crate::config::{
effective_project_scope, effective_scope_value, project_config_file, read_cli_session,
read_project_config, write_project_config, ProjectConfig, StoredCliSession,
};
use crate::process::process_list_report_with_session;
use crate::process_events::project_quota_posture;
use crate::{
bundle_inspection, discovered_environment_names, BundleInspectArgs, ProcessListArgs,
ProjectInitArgs, ProjectListArgs, ProjectSelectArgs, ProjectStatusArgs,
};
pub(crate) fn project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result<Value> {
let stored_session = read_cli_session(&cwd)?;
let tenant = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.tenant,
|session| session.tenant.as_str(),
"tenant",
);
let project = args.new_project.clone();
let user = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.user,
|session| session.user.as_str(),
"user",
);
let coordinator = args.scope.coordinator.clone().or_else(|| {
stored_session
.as_ref()
.map(|session| session.coordinator.clone())
});
let name = args.name.clone();
let config = ProjectConfig {
tenant: tenant.clone(),
project: project.clone(),
user: user.clone(),
coordinator: coordinator.clone(),
};
let config_file = project_config_file(&cwd);
if config_file.exists() && !args.yes {
anyhow::bail!(
"{} already exists; rerun with --yes to update the project link",
config_file.display()
);
}
let mut coordinator_session_requests = 0;
let coordinator_response = if let Some(coordinator) = &coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let request = authenticated_or_local_trusted_request(
coordinator,
stored_session.as_ref(),
json!({
"type": "create_project",
"project": project,
"name": name,
}),
json!({
"type": "create_project",
"tenant": tenant,
"actor_user": user,
"project": project,
"name": name,
}),
)?;
let response = session.request(request)?;
coordinator_session_requests = session.requests();
Some(response)
} else {
None
};
write_project_config(&cwd, &config)?;
let created_or_linked_project = coordinator_response
.as_ref()
.and_then(|response| response.get("project"))
.cloned()
.unwrap_or_else(|| {
json!({
"id": config.project.clone(),
"tenant": config.tenant.clone(),
"name": args.name.clone(),
})
});
Ok(json!({
"command": "project init",
"source": if coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" },
"private_website_required": false,
"project_config_written": true,
"project_config_write_after_coordinator_acceptance": coordinator.is_some(),
"coordinator_create_before_local_write": coordinator.is_some(),
"coordinator_session_requests": coordinator_session_requests,
"created_or_linked_project": created_or_linked_project,
"current_directory_link": {
"cwd": cwd,
"config_file": config_file,
"config_format": "disasmer_project_config_v1",
"links_current_directory": true,
"writes_current_directory_only": true,
"private_website_required": false,
},
"safe_defaults": {
"tenant": config.tenant.clone(),
"project": config.project.clone(),
"user": config.user.clone(),
"coordinator": config.coordinator.clone(),
"project_name": args.name.clone(),
"default_project_id_used": args.new_project == "project",
"default_project_name_used": args.name == "Disasmer Project",
"browser_interaction_required": false,
"private_website_required": false,
},
"project_config": config,
"config_file": project_config_file(&cwd),
"coordinator_response": coordinator_response,
}))
}
pub(crate) fn project_status_report(args: ProjectStatusArgs, cwd: PathBuf) -> Result<Value> {
let config = read_project_config(&cwd)?;
let stored_session = read_cli_session(&cwd)?;
let mut effective_scope = effective_project_scope(&args.scope, config.as_ref());
if effective_scope.coordinator.is_none() {
effective_scope.coordinator = stored_session
.as_ref()
.filter(|session| session.session_secret.is_some())
.map(|session| session.coordinator.clone());
}
if let (Some(config), Some(session)) = (config.as_ref(), stored_session.as_ref()) {
let same_coordinator = effective_scope
.coordinator
.as_deref()
.is_some_and(|coordinator| {
crate::client::control_endpoint_identity(coordinator).ok()
== crate::client::control_endpoint_identity(&session.coordinator).ok()
});
if same_coordinator
&& (session.tenant != config.tenant
|| session.project != config.project
|| session.user != config.user)
{
anyhow::bail!(
"stored CLI session is for {}/{}/{} but this workspace is configured for {}/{}/{}; run `disasmer login --browser` from this workspace",
session.tenant,
session.project,
session.user,
config.tenant,
config.project,
config.user,
);
}
}
if let Some(bound_session) = effective_scope
.coordinator
.as_deref()
.and_then(|coordinator| {
stored_session_for_coordinator(coordinator, stored_session.as_ref())
})
{
effective_scope.tenant = bound_session.tenant.clone();
effective_scope.project = bound_session.project.clone();
effective_scope.user = bound_session.user.clone();
}
let inspection = bundle_inspection(
BundleInspectArgs {
project: Some(cwd.clone()),
source_provider: None,
disabled_source_providers: Vec::new(),
json: true,
},
cwd.clone(),
)
.ok();
let coordinator = effective_scope.coordinator.clone();
let attached_nodes = list_attached_nodes_if_available_with_session(
coordinator.as_deref(),
&effective_scope,
stored_session.as_ref(),
)?;
let coordinator_response = list_task_events_if_available_with_session(
coordinator.as_deref(),
&effective_scope,
None,
stored_session.as_ref(),
)?;
let process_report = process_list_report_with_session(
ProcessListArgs {
scope: effective_scope.clone(),
},
stored_session.as_ref(),
)?;
let discovered_environments = discovered_environment_names(inspection.as_ref());
let active_process = process_report
.get("processes")
.and_then(Value::as_array)
.and_then(|processes| processes.first())
.and_then(|process| process.get("process"))
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| {
if process_report.get("status").and_then(Value::as_str) == Some("ok") {
"none".to_owned()
} else {
"unknown_without_coordinator".to_owned()
}
});
let quota_posture = project_quota_posture(&attached_nodes, coordinator_response.as_ref());
Ok(json!({
"command": "project status",
"cwd": cwd,
"tenant": effective_scope.tenant,
"project": effective_scope.project,
"user": effective_scope.user,
"coordinator": coordinator,
"project_identity": {
"tenant": effective_scope.tenant,
"project": effective_scope.project,
"user": effective_scope.user,
"source": if config.is_some() { "project_config_with_cli_overrides" } else { "cli_scope" }
},
"project_config": config,
"bundle": inspection,
"discovered_environments": discovered_environments,
"active_process": active_process,
"processes": process_report.get("processes").cloned().unwrap_or_else(|| json!([])),
"attached_nodes": attached_nodes,
"quota_posture": quota_posture,
"coordinator_response": coordinator_response,
}))
}
pub(crate) fn project_list_report(args: ProjectListArgs, cwd: PathBuf) -> Result<Value> {
let stored_session = read_cli_session(&cwd)?;
let coordinator = args.scope.coordinator.clone().or_else(|| {
stored_session
.as_ref()
.map(|session| session.coordinator.clone())
});
let tenant = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.tenant,
|session| session.tenant.as_str(),
"tenant",
);
let user = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.user,
|session| session.user.as_str(),
"user",
);
if let Some(coordinator) = &coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let request = authenticated_or_local_trusted_request(
coordinator,
stored_session.as_ref(),
json!({
"type": "list_projects",
}),
json!({
"type": "list_projects",
"tenant": tenant,
"actor_user": user,
}),
)?;
let response = session.request(request)?;
let projects = response
.get("projects")
.cloned()
.unwrap_or_else(|| json!([]));
let project_count = projects.as_array().map(Vec::len).unwrap_or(0);
return Ok(json!({
"command": "project list",
"source": "public_coordinator_api",
"coordinator": coordinator,
"tenant": tenant,
"user": user,
"projects": projects,
"project_count": project_count,
"private_website_required": false,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
let projects = read_project_config(&cwd)?.into_iter().collect::<Vec<_>>();
let project_count = projects.len();
Ok(json!({
"command": "project list",
"source": "local_project_config",
"projects": projects,
"project_count": project_count,
"private_website_required": false,
}))
}
pub(crate) fn project_select_report(args: ProjectSelectArgs, cwd: PathBuf) -> Result<Value> {
let stored_session = read_cli_session(&cwd)?;
let tenant = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.tenant,
|session| session.tenant.as_str(),
"tenant",
);
let user = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.user,
|session| session.user.as_str(),
"user",
);
let coordinator = args.scope.coordinator.clone().or_else(|| {
stored_session
.as_ref()
.map(|session| session.coordinator.clone())
});
let config = ProjectConfig {
tenant: tenant.clone(),
project: args.selected_project.clone(),
user: user.clone(),
coordinator: coordinator.clone(),
};
let coordinator_response = if let Some(coordinator) = &coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let request = authenticated_or_local_trusted_request(
coordinator,
stored_session.as_ref(),
json!({
"type": "select_project",
"project": args.selected_project,
}),
json!({
"type": "select_project",
"tenant": tenant,
"actor_user": user,
"project": args.selected_project,
}),
)?;
Some(session.request(request)?)
} else {
None
};
write_project_config(&cwd, &config)?;
let selected_project = coordinator_response
.as_ref()
.and_then(|response| response.get("project"))
.cloned()
.unwrap_or_else(|| {
json!({
"id": config.project.clone(),
"tenant": config.tenant.clone(),
"name": config.project.clone(),
})
});
Ok(json!({
"command": "project select",
"source": if coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" },
"selected_project": selected_project,
"project_config_written": true,
"private_website_required": false,
"project_config": config,
"coordinator_response": coordinator_response,
}))
}
fn session_or_effective_scope_value(
stored_session: Option<&StoredCliSession>,
cli_value: &str,
session_value: impl FnOnce(&StoredCliSession) -> &str,
default_value: &str,
) -> String {
if let Some(session) = stored_session.filter(|session| session.session_secret.is_some()) {
session_value(session).to_owned()
} else {
effective_scope_value(cli_value, stored_session.map(session_value), default_value)
}
}

View file

@ -0,0 +1,111 @@
use std::path::PathBuf;
use anyhow::Result;
use serde_json::{json, Value};
use crate::client::{
authenticated_or_local_trusted_request, list_attached_nodes_if_available_with_session,
list_task_events_if_available_with_session, stored_session_for_coordinator, JsonLineSession,
};
use crate::config::{effective_project_scope, read_cli_session, read_project_config};
use crate::process_events::{quota_current_usage, quota_limits_value, quota_next_blocked_action};
use crate::QuotaStatusArgs;
pub(crate) fn quota_status_report(args: QuotaStatusArgs, cwd: PathBuf) -> Result<Value> {
let config = read_project_config(&cwd)?;
let stored_session = read_cli_session(&cwd)?;
let mut effective_scope = effective_project_scope(&args.scope, config.as_ref());
if effective_scope.coordinator.is_none() {
effective_scope.coordinator = stored_session
.as_ref()
.filter(|session| session.session_secret.is_some())
.map(|session| session.coordinator.clone());
}
if let Some(bound_session) = effective_scope
.coordinator
.as_deref()
.and_then(|coordinator| {
stored_session_for_coordinator(coordinator, stored_session.as_ref())
})
{
effective_scope.tenant = bound_session.tenant.clone();
effective_scope.project = bound_session.project.clone();
effective_scope.user = bound_session.user.clone();
}
let coordinator = effective_scope.coordinator.clone();
let attached_nodes = list_attached_nodes_if_available_with_session(
coordinator.as_deref(),
&effective_scope,
stored_session.as_ref(),
)?;
let task_events = list_task_events_if_available_with_session(
coordinator.as_deref(),
&effective_scope,
None,
stored_session.as_ref(),
)?;
let quota_status = if let Some(coordinator) = coordinator.as_deref() {
let mut session = JsonLineSession::connect(coordinator)?;
Some(session.request(authenticated_or_local_trusted_request(
coordinator,
stored_session.as_ref(),
json!({ "type": "quota_status" }),
json!({
"type": "quota_status",
"tenant": effective_scope.tenant,
"project": effective_scope.project,
"actor_user": effective_scope.user,
}),
)?)?)
} else {
None
};
let mut current_usage = quota_current_usage(&attached_nodes, task_events.as_ref());
if let (Some(object), Some(status)) = (current_usage.as_object_mut(), quota_status.as_ref()) {
object.insert(
"scoped_resource_usage".to_owned(),
status.get("usage").cloned().unwrap_or(Value::Null),
);
object.insert(
"window_started_epoch_seconds".to_owned(),
status
.get("window_started_epoch_seconds")
.cloned()
.unwrap_or(Value::Null),
);
}
let limits = quota_status
.as_ref()
.and_then(|status| status.pointer("/limits/limits"))
.cloned()
.unwrap_or_else(quota_limits_value);
let window_seconds = quota_status
.as_ref()
.and_then(|status| status.get("window_seconds"))
.cloned()
.unwrap_or(Value::Null);
let quota_tier = quota_status
.as_ref()
.and_then(|status| status.get("policy_label"))
.cloned()
.unwrap_or(Value::Null);
Ok(json!({
"command": "quota status",
"tenant": effective_scope.tenant,
"project": effective_scope.project,
"user": effective_scope.user,
"coordinator": coordinator,
"project_config": config,
"policy_surface": "generic public quota categories; hosted tuning remains private policy",
"limits": limits,
"window_seconds": window_seconds,
"current_usage": current_usage,
"attached_nodes": attached_nodes,
"task_events": task_events,
"next_blocked_action": quota_next_blocked_action(&current_usage),
"quota_configuration_source": if quota_status.is_some() { "coordinator" } else { "unavailable_offline" },
"quota_tier": quota_tier,
"private_abuse_heuristics_exposed": false,
"quota_response": quota_status,
}))
}

View file

@ -0,0 +1,943 @@
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use disasmer_control::MAX_CONTROL_FRAME_BYTES;
use disasmer_core::{
agent_ed25519_public_key_from_private_key, sign_agent_workflow_request,
signed_request_payload_digest, AgentWorkflowScope, Digest,
};
use serde::Serialize;
use serde_json::{json, Value};
use crate::client::{stored_session_for_coordinator, JsonLineSession};
use crate::config::{
default_hosted_coordinator_endpoint, read_cli_session, read_project_config, StoredCliSession,
};
use crate::errors::{cli_error_summary, cli_error_summary_for_category};
use crate::{BuildArgs, RunArgs};
mod local_services;
use local_services::{LocalCoordinator, LocalNodeWorker};
struct RunBundle {
build_report: Value,
digest: Digest,
module_base64: String,
module_size_bytes: usize,
entry_export: String,
entry_stable_id: String,
}
// The control request contains base64 (4/3 expansion), the authenticated
// envelope, TaskSpec metadata, and user/project identifiers. Reserve a fixed
// worst-case metadata budget so every module at or below this limit fits the
// existing 1 MiB control frame without creating a process first.
const INLINE_BUNDLE_REQUEST_OVERHEAD_BYTES: usize = 96 * 1024;
pub(crate) const MAX_INLINE_WASM_MODULE_BYTES: usize =
((MAX_CONTROL_FRAME_BYTES - INLINE_BUNDLE_REQUEST_OVERHEAD_BYTES) / 4) * 3;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct RunPlan {
pub(crate) project: PathBuf,
pub(crate) entry: String,
pub(crate) coordinator: CoordinatorSelection,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) hosted_coordinator_endpoint: Option<String>,
pub(crate) session: CliSession,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub(crate) struct RunExecutionReport {
pub(crate) plan: RunPlan,
pub(crate) boundary: RunBoundaryEvidence,
pub(crate) node_report: serde_json::Value,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct RunBoundaryEvidence {
pub(crate) cli_process_started_node_process: bool,
pub(crate) cli_process_started_coordinator_process: bool,
pub(crate) coordinator_address: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) coordinator_process_id: Option<u32>,
pub(crate) spawned_node_process_id: u32,
pub(crate) node_session_requests: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) enum CoordinatorSelection {
Hosted,
LocalOverride(String),
LocalOnly,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) enum CliSession {
Anonymous,
HumanSession,
AgentPublicKey {
agent: String,
public_key: String,
public_key_fingerprint: Digest,
#[serde(skip)]
private_key: Option<String>,
browser_interaction_required: bool,
},
}
impl CliSession {
pub(crate) fn is_authenticated(&self) -> bool {
!matches!(self, Self::Anonymous)
}
}
pub(crate) fn run_plan(args: RunArgs, cwd: PathBuf, session: CliSession) -> Result<RunPlan> {
let (coordinator, hosted_coordinator_endpoint) = if let Some(url) = args.coordinator {
(CoordinatorSelection::LocalOverride(url), None)
} else if args.local {
(CoordinatorSelection::LocalOnly, None)
} else if session.is_authenticated() {
(
CoordinatorSelection::Hosted,
Some(default_hosted_coordinator_endpoint()),
)
} else {
(CoordinatorSelection::LocalOnly, None)
};
Ok(RunPlan {
project: args.project.unwrap_or(cwd),
entry: args.entry.unwrap_or_else(|| "build".to_owned()),
coordinator,
hosted_coordinator_endpoint,
session,
})
}
fn non_interactive_run_requires_auth_report(args: RunArgs, cwd: PathBuf) -> Value {
let project = args.project.unwrap_or(cwd);
let entry = args.entry.unwrap_or_else(|| "build".to_owned());
let message = "non-interactive run requires an authenticated human or agent session unless --local or --coordinator is explicit";
let next_actions = vec![
"disasmer login --browser",
"set DISASMER_AGENT_PRIVATE_KEY for automation",
"pass --local to run against local services",
"pass --coordinator for an explicit self-hosted coordinator",
];
let mut machine_error = cli_error_summary_for_category("authentication", message);
if let Some(object) = machine_error.as_object_mut() {
object.insert("next_actions".to_owned(), json!(next_actions.clone()));
object.insert("browser_opened".to_owned(), json!(false));
}
json!({
"command": "run",
"status": "authentication_required",
"project_root": project,
"entry": entry,
"non_interactive": true,
"browser_opened": false,
"safe_failure": true,
"message": message,
"next_actions": next_actions,
"private_website_required": false,
"machine_error": machine_error,
})
}
pub(crate) fn run_report(args: RunArgs, cwd: PathBuf, session: CliSession) -> Result<Value> {
if args.non_interactive
&& !args.local
&& args.coordinator.is_none()
&& !session.is_authenticated()
{
return Ok(non_interactive_run_requires_auth_report(args, cwd));
}
let plan = run_plan(args, cwd, session)?;
if should_execute_local_node(&plan) {
return Ok(serde_json::to_value(execute_local_node_run(plan)?)?);
}
coordinator_run_report(plan)
}
fn coordinator_run_report(plan: RunPlan) -> Result<Value> {
// Build, resolve the requested entrypoint, verify the module digest, and
// enforce the accepted inline transport boundary before mutating the
// coordinator. A failure here therefore cannot leave an active process.
let bundle = build_bundle_for_run(&plan.project, &plan.entry)?;
validate_inline_bundle_size(bundle.module_size_bytes)?;
let config = read_project_config(&plan.project)?;
let stored_session = read_cli_session(&plan.project)?;
let coordinator = run_coordinator_endpoint(&plan, stored_session.as_ref())?;
let bound_session = stored_session_for_coordinator(&coordinator, stored_session.as_ref());
let tenant = bound_session
.map(|session| session.tenant.clone())
.or_else(|| config.as_ref().map(|config| config.tenant.clone()))
.unwrap_or_else(|| "tenant".to_owned());
let project = bound_session
.map(|session| session.project.clone())
.or_else(|| config.as_ref().map(|config| config.project.clone()))
.unwrap_or_else(|| "project".to_owned());
let user = bound_session
.map(|session| session.user.clone())
.or_else(|| config.as_ref().map(|config| config.user.clone()))
.unwrap_or_else(|| "user".to_owned());
let human_session_secret =
human_run_session_secret(&plan, &coordinator, stored_session.as_ref())?;
if matches!(plan.session, CliSession::HumanSession)
&& human_session_secret.is_none()
&& !crate::client::is_loopback_coordinator(&coordinator)
{
anyhow::bail!(
"no authenticated CLI session matches coordinator {coordinator}; run `disasmer login --browser` from the current project"
);
}
let process = "vp-current".to_owned();
let mut session = JsonLineSession::connect(&coordinator)?;
let mut request = json!({
"type": "start_process",
"tenant": tenant.clone(),
"project": project.clone(),
"process": process.clone(),
"restart": false,
});
request = authenticated_human_or_local_trusted_workflow(
request,
&plan.session,
&user,
human_session_secret.as_deref(),
);
let response = session.request_allow_error(request)?;
let run_start = run_start_summary(&response);
let status = run_start
.get("status")
.and_then(Value::as_str)
.unwrap_or("coordinator_response");
let task_definition = bundle.entry_stable_id.clone();
let task_instance = format!("ti:{process}:main");
let artifact_path = format!("/vfs/artifacts/{task_instance}-output.txt");
let launch_task_response = if status == "started" {
let task_spec = json!({
"tenant": tenant,
"project": project,
"process": process,
"task_definition": task_definition,
"task_instance": task_instance,
"dispatch": {
"kind": "coordinator_node_wasm",
"export": bundle.entry_export,
"abi": "entrypoint_v1"
},
"environment_id": null,
"environment": null,
"environment_digest": null,
"required_capabilities": [],
"dependency_cache": null,
"source_snapshot": null,
"required_artifacts": [],
"args": [],
"vfs_epoch": response.get("epoch").and_then(Value::as_u64).unwrap_or_default(),
"bundle_digest": bundle.digest,
});
let mut launch_task_request = json!({
"type": "launch_task",
"tenant": tenant.clone(),
"project": project.clone(),
"task_spec": task_spec,
"wait_for_node": true,
"artifact_path": artifact_path.clone(),
"wasm_module_base64": bundle.module_base64,
});
launch_task_request = authenticated_human_or_local_trusted_workflow(
launch_task_request,
&plan.session,
&user,
human_session_secret.as_deref(),
);
let launch = session.request_allow_error(launch_task_request)?;
if !matches!(
launch.get("type").and_then(Value::as_str),
Some("main_launched")
) {
rollback_failed_process_launch(
&mut session,
&plan.session,
&user,
human_session_secret.as_deref(),
&tenant,
&project,
&process,
&launch,
)?;
}
launch
} else {
Value::Null
};
Ok(json!({
"command": "run",
"status": if launch_task_response.get("type").and_then(Value::as_str) == Some("main_launched") { "main_launched" } else { status },
"project_root": plan.project,
"entry": plan.entry,
"tenant": tenant,
"project": project,
"user": user,
"workflow_actor": response.get("actor").cloned().unwrap_or(Value::Null),
"coordinator": coordinator,
"process": process,
"run_start": run_start,
"task_definition": task_definition,
"task_instance": task_instance,
"bundle_build": bundle.build_report,
"bundle_digest": bundle.digest,
"bundle_module_size_bytes": bundle.module_size_bytes,
"entry_export": bundle.entry_export,
"entry_stable_id": bundle.entry_stable_id,
"entry_abi_version": 1,
"worker_placement_requested": launch_task_response.is_object(),
"task_launch": launch_task_response,
"coordinator_response": response,
"coordinator_session_requests": session.requests(),
"private_website_required": false,
}))
}
fn validate_inline_bundle_size(module_size_bytes: usize) -> Result<()> {
if module_size_bytes <= MAX_INLINE_WASM_MODULE_BYTES {
return Ok(());
}
anyhow::bail!(
"built Wasm module is {module_size_bytes} bytes, but the current {}-byte inline control frame supports at most about {} KiB ({} raw bytes); reduce the MVP bundle's dependencies or release optimization footprint. Larger bundle transport is post-MVP. No virtual process was created",
MAX_CONTROL_FRAME_BYTES,
MAX_INLINE_WASM_MODULE_BYTES / 1024,
MAX_INLINE_WASM_MODULE_BYTES,
)
}
fn rollback_failed_process_launch(
session: &mut JsonLineSession,
cli_session: &CliSession,
fallback_user: &str,
human_session_secret: Option<&str>,
tenant: &str,
project: &str,
process: &str,
launch_response: &Value,
) -> Result<()> {
let rollback = authenticated_human_or_local_trusted_workflow(
json!({
"type": "abort_process",
"tenant": tenant,
"project": project,
"process": process,
}),
cli_session,
fallback_user,
human_session_secret,
);
let rollback_response = session.request_allow_error(rollback)?;
if rollback_response.get("type").and_then(Value::as_str) != Some("process_aborted") {
anyhow::bail!(
"coordinator main launch failed ({launch_response}) and rollback was not acknowledged ({rollback_response}); inspect virtual process {process} before retrying"
);
}
Ok(())
}
fn build_bundle_for_run(project: &Path, entry: &str) -> Result<RunBundle> {
let build_report = crate::build::build_report(
BuildArgs {
project: Some(project.to_path_buf()),
source_provider: None,
disabled_source_providers: Vec::new(),
output: None,
json: true,
},
project.to_path_buf(),
)?;
if build_report.get("status").and_then(Value::as_str) != Some("built") {
let diagnostics = build_report
.get("diagnostics")
.cloned()
.unwrap_or(Value::Null);
anyhow::bail!("Disasmer bundle build was blocked before run: {diagnostics}");
}
let artifact = build_report
.get("bundle_artifact")
.context("bundle build response omitted bundle_artifact")?;
let module_path = artifact
.get("module")
.and_then(Value::as_str)
.context("bundle build response omitted module path")?;
let directory = artifact
.get("directory")
.and_then(Value::as_str)
.context("bundle build response omitted bundle directory")?;
let digest: Digest = serde_json::from_value(
artifact
.get("bundle_digest")
.cloned()
.context("bundle build response omitted bundle digest")?,
)?;
let module = std::fs::read(module_path)
.with_context(|| format!("failed to read built Wasm module {module_path}"))?;
let actual_digest = Digest::sha256(&module);
if actual_digest != digest {
anyhow::bail!(
"built Wasm module digest changed before run: expected {digest}, actual {actual_digest}"
);
}
let descriptors_path = Path::new(directory).join("entrypoints.json");
let descriptors: Vec<Value> = serde_json::from_slice(
&std::fs::read(&descriptors_path)
.with_context(|| format!("failed to read {}", descriptors_path.display()))?,
)?;
let descriptor = descriptors
.iter()
.find(|descriptor| descriptor.get("name").and_then(Value::as_str) == Some(entry))
.with_context(|| {
let available = descriptors
.iter()
.filter_map(|descriptor| descriptor.get("name").and_then(Value::as_str))
.collect::<Vec<_>>();
format!(
"bundle has no Disasmer entrypoint `{entry}`; available entrypoints: {available:?}"
)
})?;
if descriptor.get("abi_version").and_then(Value::as_u64) != Some(1) {
anyhow::bail!("entrypoint `{entry}` does not use supported Disasmer ABI version 1");
}
let entry_export = descriptor
.get("export")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.with_context(|| format!("entrypoint `{entry}` descriptor omitted its Wasm export"))?
.to_owned();
let entry_stable_id = descriptor
.get("stable_id")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.with_context(|| format!("entrypoint `{entry}` descriptor omitted its stable id"))?
.to_owned();
Ok(RunBundle {
build_report,
digest,
module_size_bytes: module.len(),
module_base64: BASE64_STANDARD.encode(module),
entry_export,
entry_stable_id,
})
}
fn human_run_session_secret(
plan: &RunPlan,
coordinator: &str,
stored_session: Option<&StoredCliSession>,
) -> Result<Option<String>> {
if !matches!(plan.session, CliSession::HumanSession) {
return Ok(None);
}
if let Ok(token) = std::env::var("DISASMER_TOKEN") {
if !token.trim().is_empty() {
return Ok(Some(token));
}
}
Ok(stored_session_for_coordinator(coordinator, stored_session)
.and_then(|session| session.session_secret.clone()))
}
fn authenticated_human_or_local_trusted_workflow(
mut request: Value,
session: &CliSession,
fallback_user: &str,
human_session_secret: Option<&str>,
) -> Value {
if matches!(session, CliSession::HumanSession) {
if let Some(session_secret) = human_session_secret {
if let Value::Object(request) = &mut request {
request.remove("tenant");
request.remove("project");
request.remove("actor_user");
request.remove("actor_agent");
request.remove("agent_public_key_fingerprint");
request.remove("agent_signature");
}
return json!({
"type": "authenticated",
"session_secret": session_secret,
"request": request,
});
}
}
add_workflow_actor_fields(&mut request, session, fallback_user);
request
}
fn run_coordinator_endpoint(
plan: &RunPlan,
stored_session: Option<&StoredCliSession>,
) -> Result<String> {
match &plan.coordinator {
CoordinatorSelection::Hosted => Ok(stored_session
.filter(|session| {
matches!(plan.session, CliSession::HumanSession) && session.session_secret.is_some()
})
.map(|session| session.coordinator.clone())
.or_else(|| plan.hosted_coordinator_endpoint.clone())
.unwrap_or_else(default_hosted_coordinator_endpoint)),
CoordinatorSelection::LocalOverride(coordinator) => Ok(coordinator.clone()),
CoordinatorSelection::LocalOnly => {
anyhow::bail!("local-only run should execute through local services")
}
}
}
pub(crate) fn run_start_summary(response: &Value) -> Value {
if response.get("type").and_then(Value::as_str) == Some("process_started") {
return json!({
"status": "started",
"accepted": true,
"process": response.get("process").cloned().unwrap_or(Value::Null),
"coordinator_epoch": response.get("epoch").cloned().unwrap_or(Value::Null),
"actor": response.get("actor").cloned().unwrap_or(Value::Null),
"restart": false,
"single_active_process_boundary": true,
"next_actions": [
"disasmer process status",
"disasmer logs",
"disasmer process cancel"
],
});
}
let message = response
.get("message")
.and_then(Value::as_str)
.unwrap_or("coordinator rejected run");
let active_conflict = message.contains("already has active virtual process");
let machine_error = if active_conflict {
cli_error_summary_for_category("active_process", message)
} else {
cli_error_summary(message)
};
let error_category = machine_error
.get("category")
.cloned()
.unwrap_or_else(|| json!("unknown"));
let stable_exit_code = machine_error
.get("stable_exit_code")
.cloned()
.unwrap_or_else(|| json!(1));
json!({
"status": if active_conflict { "blocked_active_process" } else { "coordinator_rejected" },
"accepted": false,
"category": if active_conflict { "active_process_already_running" } else { "coordinator" },
"error_category": error_category,
"stable_exit_code": stable_exit_code,
"machine_error": machine_error,
"message": message,
"restart": false,
"single_active_process_boundary": true,
"safe_failure": true,
"next_actions": if active_conflict {
json!([
"disasmer process list",
"disasmer process status",
"disasmer debug attach",
"disasmer process restart --yes",
"disasmer process cancel --yes",
"disasmer process abort --yes",
"use another Coordinator Project"
])
} else {
json!(["disasmer doctor", "check coordinator status"])
},
})
}
pub(crate) fn should_execute_local_node(plan: &RunPlan) -> bool {
match &plan.coordinator {
CoordinatorSelection::LocalOnly => true,
CoordinatorSelection::LocalOverride(coordinator) => !coordinator.contains("://"),
CoordinatorSelection::Hosted => false,
}
}
fn execute_local_node_run(plan: RunPlan) -> Result<RunExecutionReport> {
let environments = disasmer_core::discover_environments(&plan.project)?;
let detected = disasmer_core::NodeCapabilities::detect_current();
if environments.iter().any(|environment| {
environment
.requirements
.capabilities
.contains(&disasmer_core::Capability::RootlessPodman)
}) && !detected
.capabilities
.contains(&disasmer_core::Capability::RootlessPodman)
{
anyhow::bail!(
"local project declares a Linux container environment, but rootless Podman is not available; configure rootless Podman or attach a capable user node"
);
}
let local_coordinator = match &plan.coordinator {
CoordinatorSelection::LocalOverride(coordinator) => LocalCoordinator::external(coordinator),
CoordinatorSelection::LocalOnly => LocalCoordinator::start_ephemeral()?,
CoordinatorSelection::Hosted => anyhow::bail!("local node execution requires local mode"),
};
let coordinator_address = local_coordinator.address.clone();
let mut coordinator_plan = plan.clone();
coordinator_plan.coordinator =
CoordinatorSelection::LocalOverride(format!("disasmer+tcp://{coordinator_address}"));
let run = coordinator_run_report(coordinator_plan)?;
let launch_type = run.pointer("/task_launch/type").and_then(Value::as_str);
if launch_type != Some("main_launched") {
anyhow::bail!("local coordinator refused the Wasm entrypoint launch: {run}");
}
let process = run
.get("process")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("coordinator run report omitted virtual process id"))?;
let task = run
.pointer("/task_launch/task_instance")
.or_else(|| run.get("task_instance"))
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("coordinator run report omitted entrypoint task id"))?;
let pre_node_process_status = wait_for_local_main_placement(&coordinator_address, process)?;
let enrollment_grant = create_local_node_enrollment_grant(&coordinator_address)?;
let mut worker =
LocalNodeWorker::start(&coordinator_address, &plan.project, &enrollment_grant)?;
let spawned_node_process_id = worker.process_id;
let join = wait_for_local_main_completion(&coordinator_address, process, task)?;
worker.stop();
let node_report = json!({
"node_status": "completed",
"execution_substrate": "wasm",
"task_spawn_host_import": true,
"pre_node_process_status": pre_node_process_status,
"run": run,
"join": join,
});
Ok(RunExecutionReport {
plan,
boundary: RunBoundaryEvidence {
cli_process_started_node_process: true,
cli_process_started_coordinator_process: local_coordinator.process_id.is_some(),
coordinator_address,
coordinator_process_id: local_coordinator.process_id,
spawned_node_process_id,
node_session_requests: 0,
},
node_report,
})
}
fn local_process_status(coordinator: &str) -> Result<Value> {
let mut session = JsonLineSession::connect(coordinator)?;
session.request_allow_error(json!({
"type": "list_processes",
"tenant": "tenant",
"project": "project",
"actor_user": "user",
}))
}
fn create_local_node_enrollment_grant(coordinator: &str) -> Result<String> {
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request_allow_error(json!({
"type": "create_node_enrollment_grant",
"tenant": "tenant",
"project": "project",
"actor_user": "user",
"ttl_seconds": 60,
}))?;
if response.get("type").and_then(Value::as_str) != Some("node_enrollment_grant_created") {
anyhow::bail!("local coordinator refused node enrollment: {response}");
}
response
.get("grant")
.and_then(Value::as_str)
.map(str::to_owned)
.context("local coordinator omitted the node enrollment grant")
}
fn wait_for_local_main_placement(coordinator: &str, process: &str) -> Result<Value> {
let started = Instant::now();
loop {
let status = local_process_status(coordinator)?;
let process_status =
status
.get("processes")
.and_then(Value::as_array)
.and_then(|processes| {
processes.iter().find(|candidate| {
candidate.get("process").and_then(Value::as_str) == Some(process)
})
});
match process_status.and_then(|status| status.get("main_wait_state")) {
Some(Value::String(wait_state)) if wait_state == "waiting_for_node" => {
return Ok(status);
}
_ if started.elapsed() > Duration::from_secs(30) => anyhow::bail!(
"local coordinator main `{process}` did not become observably parked on node placement before the local node launch: {status}"
),
_ => thread::sleep(Duration::from_millis(10)),
}
}
}
fn wait_for_local_main_completion(coordinator: &str, process: &str, task: &str) -> Result<Value> {
let started = Instant::now();
loop {
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request_allow_error(json!({
"type": "join_task",
"tenant": "tenant",
"project": "project",
"actor_user": "user",
"process": process,
"task": task,
}))?;
match response.pointer("/join/state").and_then(Value::as_str) {
Some("Completed") | Some("completed") => return Ok(response),
Some("Failed") | Some("failed") | Some("Cancelled") | Some("cancelled") => {
let events = session.request_allow_error(json!({
"type": "list_task_events",
"tenant": "tenant",
"project": "project",
"actor_user": "user",
"process": process,
}))?;
anyhow::bail!("local Wasm entrypoint failed: {response}; task events: {events}")
}
_ if started.elapsed() > Duration::from_secs(120) => {
anyhow::bail!("timed out waiting for local Wasm entrypoint completion")
}
_ => thread::sleep(Duration::from_millis(20)),
}
}
}
pub(crate) fn session_from_env() -> Result<CliSession> {
if let Some(session) = agent_session_from_keys(
std::env::var("DISASMER_AGENT_ID").unwrap_or_else(|_| "agent".to_owned()),
std::env::var("DISASMER_AGENT_PUBLIC_KEY").ok(),
std::env::var("DISASMER_AGENT_PRIVATE_KEY").ok(),
)? {
return Ok(session);
}
if std::env::var_os("DISASMER_TOKEN").is_some() {
return Ok(CliSession::HumanSession);
}
Ok(CliSession::Anonymous)
}
pub(crate) fn agent_session_from_keys(
agent: String,
configured_public_key: Option<String>,
private_key: Option<String>,
) -> Result<Option<CliSession>> {
if let Some(private_key) = private_key {
let derived_public_key = agent_ed25519_public_key_from_private_key(&private_key)
.map_err(anyhow::Error::msg)
.context("DISASMER_AGENT_PRIVATE_KEY is not a valid Ed25519 private key")?;
if let Some(configured_public_key) = configured_public_key.as_deref() {
if configured_public_key != derived_public_key {
anyhow::bail!(
"DISASMER_AGENT_PUBLIC_KEY does not match DISASMER_AGENT_PRIVATE_KEY"
);
}
}
return Ok(Some(CliSession::AgentPublicKey {
agent,
public_key_fingerprint: Digest::sha256(derived_public_key.as_bytes()),
public_key: derived_public_key,
private_key: Some(private_key),
browser_interaction_required: false,
}));
}
if configured_public_key.is_some() {
anyhow::bail!(
"DISASMER_AGENT_PUBLIC_KEY identifies a registered key but cannot authenticate; set DISASMER_AGENT_PRIVATE_KEY to prove key possession"
);
}
Ok(None)
}
pub(crate) fn session_from_sources(project: &Path) -> Result<CliSession> {
let session = session_from_env()?;
if session.is_authenticated() {
return Ok(session);
}
if read_cli_session(project)?.is_some() {
return Ok(CliSession::HumanSession);
}
Ok(CliSession::Anonymous)
}
fn add_workflow_actor_fields(request: &mut Value, session: &CliSession, fallback_user: &str) {
let Value::Object(map) = request else {
return;
};
match session {
CliSession::AgentPublicKey {
agent,
public_key: _,
public_key_fingerprint,
private_key,
..
} => {
map.insert("actor_agent".to_owned(), json!(agent));
map.insert(
"agent_public_key_fingerprint".to_owned(),
json!(public_key_fingerprint),
);
if let Some(signature) = agent_signature_for_request(map, agent, private_key.as_deref())
{
map.insert("agent_signature".to_owned(), json!(signature));
}
}
CliSession::HumanSession | CliSession::Anonymous => {
map.insert("actor_user".to_owned(), json!(fallback_user));
}
}
}
fn agent_signature_for_request(
request: &serde_json::Map<String, Value>,
agent: &str,
private_key: Option<&str>,
) -> Option<disasmer_core::AgentSignedRequest> {
let private_key = private_key?;
let request_kind = request.get("type")?.as_str()?;
let tenant = request.get("tenant")?.as_str()?;
let project = request.get("project")?.as_str()?;
let task_spec = request.get("task_spec");
let process = request
.get("process")
.or_else(|| task_spec.and_then(|spec| spec.get("process")))?
.as_str()?;
let task = request
.get("task")
.or_else(|| task_spec.and_then(|spec| spec.get("task")))
.and_then(Value::as_str)
.map(disasmer_core::TaskInstanceId::from);
let payload_digest = signed_request_payload_digest(&Value::Object(request.clone()));
sign_agent_workflow_request(
private_key,
AgentWorkflowScope {
tenant: &disasmer_core::TenantId::from(tenant),
project: &disasmer_core::ProjectId::from(project),
agent: &disasmer_core::AgentId::from(agent),
request_kind,
process: &disasmer_core::ProcessId::from(process),
task: task.as_ref(),
},
&payload_digest,
crate::tools::command_nonce("agent-signature"),
crate::tools::unix_timestamp_seconds(),
)
.ok()
}
#[cfg(test)]
mod transactional_launch_tests {
use std::io::{BufRead as _, ErrorKind, Write as _};
use std::net::TcpListener;
use super::*;
#[test]
fn inline_module_limit_is_derived_from_the_control_frame() {
assert_eq!(MAX_CONTROL_FRAME_BYTES, 1024 * 1024);
assert_eq!(MAX_INLINE_WASM_MODULE_BYTES % 3, 0);
assert!(MAX_INLINE_WASM_MODULE_BYTES >= 690 * 1024);
assert!(MAX_INLINE_WASM_MODULE_BYTES <= 700 * 1024);
validate_inline_bundle_size(MAX_INLINE_WASM_MODULE_BYTES).unwrap();
let error = validate_inline_bundle_size(MAX_INLINE_WASM_MODULE_BYTES + 1)
.unwrap_err()
.to_string();
assert!(error.contains("No virtual process was created"));
assert!(error.contains("post-MVP"));
}
#[test]
fn build_failure_occurs_before_any_coordinator_connection() {
let project = tempfile::tempdir().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let plan = RunPlan {
project: project.path().to_path_buf(),
entry: "build".to_owned(),
coordinator: CoordinatorSelection::LocalOverride(
listener.local_addr().unwrap().to_string(),
),
hosted_coordinator_endpoint: None,
session: CliSession::Anonymous,
};
let error = coordinator_run_report(plan).unwrap_err().to_string();
assert!(
error.contains("Cargo.toml") || error.contains("project"),
"unexpected build error: {error}"
);
assert_eq!(listener.accept().unwrap_err().kind(), ErrorKind::WouldBlock);
}
#[test]
fn failed_main_launch_uses_explicit_abort_rollback() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut line = String::new();
std::io::BufReader::new(stream.try_clone().unwrap())
.read_line(&mut line)
.unwrap();
let wire: Value = serde_json::from_str(&line).unwrap();
let payload = wire.get("payload").unwrap();
assert_eq!(
payload.get("type").and_then(Value::as_str),
Some("abort_process")
);
assert_eq!(
payload.get("tenant").and_then(Value::as_str),
Some("tenant-a")
);
assert_eq!(
payload.get("project").and_then(Value::as_str),
Some("project-a")
);
assert_eq!(
payload.get("process").and_then(Value::as_str),
Some("vp-current")
);
writeln!(
stream,
"{}",
json!({
"type": "process_aborted",
"process": "vp-current",
"aborted_tasks": [],
"affected_nodes": []
})
)
.unwrap();
});
let mut session = JsonLineSession::connect(&address.to_string()).unwrap();
rollback_failed_process_launch(
&mut session,
&CliSession::Anonymous,
"user-a",
None,
"tenant-a",
"project-a",
"vp-current",
&json!({"type": "error", "message": "main launch failed"}),
)
.unwrap();
server.join().unwrap();
}
}

View file

@ -0,0 +1,177 @@
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use anyhow::{Context, Result};
use serde_json::Value;
pub(super) struct LocalNodeWorker {
pub(super) process_id: u32,
child: Option<Child>,
}
impl LocalNodeWorker {
pub(super) fn start(coordinator: &str, project: &Path, enrollment_grant: &str) -> Result<Self> {
let mut command = node_command()?;
command.args([
"--coordinator",
coordinator,
"--tenant",
"tenant",
"--project-id",
"project",
"--node",
"node-cli-local",
"--enrollment-grant",
enrollment_grant,
"--worker",
"--project-root",
]);
command.arg(project);
command.args(["--assignment-poll-ms", "20"]);
command.stdout(Stdio::null());
command.stderr(Stdio::inherit());
let child = command.spawn().context("failed to spawn node process")?;
let process_id = child.id();
Ok(Self {
process_id,
child: Some(child),
})
}
pub(super) fn stop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
impl Drop for LocalNodeWorker {
fn drop(&mut self) {
self.stop();
}
}
pub(super) struct LocalCoordinator {
pub(super) address: String,
pub(super) process_id: Option<u32>,
child: Option<Child>,
}
impl LocalCoordinator {
pub(super) fn external(address: &str) -> Self {
Self {
address: address.to_owned(),
process_id: None,
child: None,
}
}
pub(super) fn start_ephemeral() -> Result<Self> {
let mut command = coordinator_command()?;
command.args(["--listen", "127.0.0.1:0", "--allow-local-trusted-loopback"]);
command.stdout(Stdio::piped());
command.stderr(Stdio::inherit());
let mut child = command
.spawn()
.context("failed to spawn local coordinator process")?;
let process_id = child.id();
let address = match read_coordinator_ready_address(&mut child) {
Ok(address) => address,
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
};
Ok(Self {
address,
process_id: Some(process_id),
child: Some(child),
})
}
}
impl Drop for LocalCoordinator {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn read_coordinator_ready_address(child: &mut Child) -> Result<String> {
let stdout = child
.stdout
.take()
.context("local coordinator stdout was not captured")?;
let mut ready_line = String::new();
BufReader::new(stdout)
.read_line(&mut ready_line)
.context("failed to read local coordinator ready line")?;
if ready_line.trim().is_empty() {
anyhow::bail!("local coordinator exited before reporting its listen address");
}
let ready: Value =
serde_json::from_str(&ready_line).context("local coordinator ready line was not JSON")?;
ready
.get("listen")
.and_then(Value::as_str)
.map(str::to_owned)
.context("local coordinator did not report a listen address")
}
fn node_command() -> Result<Command> {
if let Some(path) = std::env::var_os("DISASMER_NODE_BIN") {
return Ok(Command::new(path));
}
let mut sibling = std::env::current_exe().context("cannot locate current executable")?;
sibling.set_file_name(format!("disasmer-node{}", std::env::consts::EXE_SUFFIX));
if sibling.is_file() {
return Ok(Command::new(sibling));
}
let mut command = Command::new("cargo");
command.args([
"run",
"-q",
"-p",
"disasmer-node",
"--bin",
"disasmer-node",
"--",
]);
Ok(command)
}
fn coordinator_command() -> Result<Command> {
if let Some(path) = std::env::var_os("DISASMER_COORDINATOR_BIN") {
return Ok(Command::new(path));
}
let mut sibling = std::env::current_exe().context("cannot locate current executable")?;
sibling.set_file_name(format!(
"disasmer-coordinator{}",
std::env::consts::EXE_SUFFIX
));
if sibling.is_file() {
return Ok(Command::new(sibling));
}
let mut command = Command::new("cargo");
command.args([
"run",
"-q",
"-p",
"disasmer-coordinator",
"--bin",
"disasmer-coordinator",
"--",
]);
Ok(command)
}

View file

@ -0,0 +1,106 @@
use anyhow::Result;
use serde_json::{json, Value};
use crate::client::{
authenticated_or_local_trusted_request, list_task_events_if_available_with_session,
JsonLineSession,
};
use crate::config::StoredCliSession;
use crate::process_events::{task_restart_request_summary, task_summaries};
use crate::{confirmation_required_report, TaskListArgs, TaskRestartArgs};
#[cfg(test)]
pub(crate) fn task_list_report(args: TaskListArgs) -> Result<Value> {
task_list_report_with_session(args, None)
}
pub(crate) fn task_list_report_with_session(
args: TaskListArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
let events = list_task_events_if_available_with_session(
args.scope.coordinator.as_deref(),
&args.scope,
args.process.clone(),
stored_session,
)?;
let tasks = task_summaries(events.as_ref());
Ok(json!({
"command": "task list",
"process": args.process,
"tasks": tasks,
"events": events,
}))
}
#[cfg(test)]
pub(crate) fn task_restart_report(args: TaskRestartArgs) -> Result<Value> {
task_restart_report_with_session(args, None)
}
pub(crate) fn task_restart_report_with_session(
args: TaskRestartArgs,
stored_session: Option<&StoredCliSession>,
) -> Result<Value> {
if !args.yes {
return Ok(confirmation_required_report(
"task restart",
"restart_selected_task",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"process": args.process,
"task": args.task,
}),
format!(
"disasmer task restart {} --process {} --yes",
args.task, args.process
),
));
}
if let Some(coordinator) = &args.scope.coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request_allow_error(authenticated_or_local_trusted_request(
coordinator,
stored_session,
json!({
"type": "restart_task",
"process": args.process,
"task": args.task,
}),
json!({
"type": "restart_task",
"tenant": args.scope.tenant,
"project": args.scope.project,
"actor_user": args.scope.user,
"process": args.process,
"task": args.task,
}),
)?)?;
let restart_request = task_restart_request_summary(&response, !args.yes);
return Ok(json!({
"command": "task restart",
"coordinator": coordinator,
"process": args.process,
"task": args.task,
"requires_confirmation": !args.yes,
"restart_request": restart_request,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
Ok(json!({
"command": "task restart",
"status": "requires_coordinator",
"requires_confirmation": !args.yes,
"process": args.process,
"task": args.task,
"restart_request": {
"status": "requires_coordinator",
"operation": "restart_selected_task",
"explicit_user_action": true,
"clean_boundary_required": true,
},
}))
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,63 @@
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
pub(crate) fn command_available(command: &str) -> bool {
Command::new(command)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
}
pub(crate) fn command_nonce(prefix: &str) -> String {
let now = unix_timestamp_nanos();
format!("{prefix}-{now}-{}", std::process::id())
}
pub(crate) fn unix_timestamp_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}
fn unix_timestamp_nanos() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default()
}
pub(crate) fn sibling_binary(name: &str) -> Option<PathBuf> {
let mut sibling = std::env::current_exe().ok()?;
sibling.set_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX));
sibling.is_file().then_some(sibling)
}
pub(crate) fn dap_binary_path() -> Result<PathBuf> {
if let Some(path) = std::env::var_os("DISASMER_DAP_BIN") {
return Ok(PathBuf::from(path));
}
if let Some(path) = sibling_binary("disasmer-debug-dap") {
return Ok(path);
}
let release = PathBuf::from("target/release").join(format!(
"disasmer-debug-dap{}",
std::env::consts::EXE_SUFFIX
));
if release.is_file() {
return Ok(release);
}
let debug = PathBuf::from("target/debug").join(format!(
"disasmer-debug-dap{}",
std::env::consts::EXE_SUFFIX
));
if debug.is_file() {
return Ok(debug);
}
anyhow::bail!("could not locate disasmer-debug-dap; set DISASMER_DAP_BIN")
}

View file

@ -0,0 +1,13 @@
[package]
name = "disasmer-control"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
serde_json.workspace = true
thiserror.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ureq.workspace = true

View file

@ -0,0 +1,262 @@
#[cfg(not(target_arch = "wasm32"))]
use std::io::{BufRead, BufReader, Read, Write};
#[cfg(not(target_arch = "wasm32"))]
use std::net::TcpStream;
use std::net::{IpAddr, ToSocketAddrs};
use std::time::Duration;
use serde_json::Value;
use thiserror::Error;
pub const CONTROL_API_PATH: &str = "/api/v1/control";
pub const MAX_CONTROL_FRAME_BYTES: usize = 1024 * 1024;
#[derive(Debug, Error)]
pub enum ControlTransportError {
#[error("invalid coordinator endpoint: {0}")]
InvalidEndpoint(String),
#[error("insecure remote coordinator endpoint is forbidden: {0}")]
InsecureRemote(String),
#[error("coordinator transport I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("coordinator transport JSON failed: {0}")]
Json(#[from] serde_json::Error),
#[error("coordinator HTTP request failed: {0}")]
Http(String),
#[error("coordinator control frame exceeds {MAX_CONTROL_FRAME_BYTES} bytes")]
FrameTooLarge,
#[error("coordinator closed the local control session without a response")]
Closed,
#[error("coordinator network transport is unavailable inside a Wasm guest")]
UnavailableInWasm,
}
enum ControlTransport {
#[cfg(not(target_arch = "wasm32"))]
Https { agent: ureq::Agent, url: String },
#[cfg(not(target_arch = "wasm32"))]
LoopbackJsonLine {
writer: TcpStream,
reader: BufReader<TcpStream>,
},
#[cfg(target_arch = "wasm32")]
#[allow(dead_code)]
Unavailable,
}
pub struct ControlSession {
transport: ControlTransport,
requests: u64,
}
impl ControlSession {
pub fn connect(endpoint: &str) -> Result<Self, ControlTransportError> {
Self::connect_with_timeouts(endpoint, Duration::from_secs(10), Duration::from_secs(30))
}
pub fn connect_with_timeouts(
endpoint: &str,
connect_timeout: Duration,
io_timeout: Duration,
) -> Result<Self, ControlTransportError> {
#[cfg(target_arch = "wasm32")]
{
let _ = (endpoint, connect_timeout, io_timeout);
return Err(ControlTransportError::UnavailableInWasm);
}
#[cfg(not(target_arch = "wasm32"))]
{
let endpoint = endpoint.trim();
if endpoint.starts_with("https://") || endpoint.starts_with("http://") {
let url = control_api_url(endpoint)?;
if endpoint.starts_with("http://") && !endpoint_is_loopback(endpoint) {
return Err(ControlTransportError::InsecureRemote(endpoint.to_owned()));
}
let agent = ureq::AgentBuilder::new()
.timeout_connect(connect_timeout)
.timeout_read(io_timeout)
.timeout_write(io_timeout)
.build();
return Ok(Self {
transport: ControlTransport::Https { agent, url },
requests: 0,
});
}
let loopback_address = endpoint.strip_prefix("disasmer+tcp://").unwrap_or(endpoint);
if !endpoint_is_loopback(loopback_address) {
return Err(ControlTransportError::InsecureRemote(endpoint.to_owned()));
}
let writer = TcpStream::connect(loopback_address)?;
writer.set_read_timeout(Some(io_timeout))?;
writer.set_write_timeout(Some(io_timeout))?;
let reader = BufReader::new(writer.try_clone()?);
Ok(Self {
transport: ControlTransport::LoopbackJsonLine { writer, reader },
requests: 0,
})
}
}
pub fn request(&mut self, value: &Value) -> Result<Value, ControlTransportError> {
#[cfg(target_arch = "wasm32")]
{
let _ = (&self.transport, self.requests, value);
return Err(ControlTransportError::UnavailableInWasm);
}
#[cfg(not(target_arch = "wasm32"))]
{
let encoded = serde_json::to_vec(value)?;
if encoded.len() > MAX_CONTROL_FRAME_BYTES {
return Err(ControlTransportError::FrameTooLarge);
}
let response = match &mut self.transport {
#[cfg(not(target_arch = "wasm32"))]
ControlTransport::Https { agent, url } => {
let response = agent
.post(url)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.send_bytes(&encoded)
.map_err(|error| ControlTransportError::Http(error.to_string()))?;
if response.status() != 200 {
return Err(ControlTransportError::Http(format!(
"coordinator returned HTTP {} {}",
response.status(),
response.status_text()
)));
}
let mut bytes = Vec::new();
response
.into_reader()
.take((MAX_CONTROL_FRAME_BYTES + 1) as u64)
.read_to_end(&mut bytes)?;
if bytes.len() > MAX_CONTROL_FRAME_BYTES {
return Err(ControlTransportError::FrameTooLarge);
}
serde_json::from_slice(&bytes)?
}
ControlTransport::LoopbackJsonLine { writer, reader } => {
writer.write_all(&encoded)?;
writer.write_all(b"\n")?;
writer.flush()?;
let mut bytes = Vec::new();
reader
.take((MAX_CONTROL_FRAME_BYTES + 1) as u64)
.read_until(b'\n', &mut bytes)?;
if bytes.is_empty() {
return Err(ControlTransportError::Closed);
}
if bytes.len() > MAX_CONTROL_FRAME_BYTES {
return Err(ControlTransportError::FrameTooLarge);
}
serde_json::from_slice(&bytes)?
}
};
self.requests += 1;
Ok(response)
}
}
pub fn requests(&self) -> u64 {
self.requests
}
}
pub fn control_api_url(endpoint: &str) -> Result<String, ControlTransportError> {
let endpoint = endpoint.trim().trim_end_matches('/');
if !(endpoint.starts_with("https://") || endpoint.starts_with("http://")) {
return Err(ControlTransportError::InvalidEndpoint(endpoint.to_owned()));
}
if endpoint.ends_with(CONTROL_API_PATH) {
Ok(endpoint.to_owned())
} else {
Ok(format!("{endpoint}{CONTROL_API_PATH}"))
}
}
pub fn endpoint_identity(endpoint: &str) -> Result<String, ControlTransportError> {
let endpoint = endpoint.trim();
if endpoint.starts_with("https://") || endpoint.starts_with("http://") {
if endpoint.starts_with("http://") && !endpoint_is_loopback(endpoint) {
return Err(ControlTransportError::InsecureRemote(endpoint.to_owned()));
}
return control_api_url(endpoint);
}
let loopback_address = endpoint.strip_prefix("disasmer+tcp://").unwrap_or(endpoint);
if endpoint_is_loopback(loopback_address) {
return Ok(format!("disasmer+tcp://{loopback_address}"));
}
Err(ControlTransportError::InsecureRemote(endpoint.to_owned()))
}
pub fn endpoint_is_loopback(endpoint: &str) -> bool {
let authority = endpoint
.trim()
.strip_prefix("https://")
.or_else(|| endpoint.trim().strip_prefix("http://"))
.or_else(|| endpoint.trim().strip_prefix("disasmer+tcp://"))
.unwrap_or(endpoint.trim())
.split('/')
.next()
.unwrap_or_default();
let host = if authority.starts_with('[') {
authority
.strip_prefix('[')
.and_then(|value| value.split_once(']'))
.map(|(host, _)| host)
.unwrap_or(authority)
} else {
authority
.rsplit_once(':')
.map(|(host, _)| host)
.unwrap_or(authority)
};
if host.eq_ignore_ascii_case("localhost") {
return true;
}
if host
.parse::<IpAddr>()
.is_ok_and(|address| address.is_loopback())
{
return true;
}
authority
.to_socket_addrs()
.ok()
.is_some_and(|mut addresses| addresses.all(|address| address.ip().is_loopback()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosted_endpoints_are_real_https_api_urls() {
assert_eq!(
control_api_url("https://disasmer.example").unwrap(),
"https://disasmer.example/api/v1/control"
);
assert_eq!(
endpoint_identity("https://disasmer.example/api/v1/control").unwrap(),
"https://disasmer.example/api/v1/control"
);
}
#[test]
fn plaintext_transport_is_restricted_to_loopback() {
assert!(endpoint_is_loopback("127.0.0.1:7999"));
assert!(endpoint_is_loopback("disasmer+tcp://127.0.0.1:7999"));
assert!(endpoint_is_loopback("http://[::1]:7999"));
assert!(matches!(
ControlSession::connect("http://example.com:7999"),
Err(ControlTransportError::InsecureRemote(_))
));
assert!(matches!(
ControlSession::connect("example.com:7999"),
Err(ControlTransportError::InsecureRemote(_))
));
}
}

View file

@ -0,0 +1,18 @@
[package]
name = "disasmer-coordinator"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
base64.workspace = true
disasmer-core = { path = "../disasmer-core" }
disasmer-wasm-runtime = { path = "../disasmer-wasm-runtime" }
postgres.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
thiserror.workspace = true
wasmparser.workspace = true

View file

@ -0,0 +1,174 @@
use disasmer_core::{
verify_agent_workflow_signature, Actor, AgentId, AgentSignedRequest, AgentWorkflowScope,
AuthContext, CredentialKind, Digest, ProjectId, TenantId, UserId,
};
use crate::{
AgentPublicKeyRecord, Coordinator, CoordinatorError, CredentialRecord, ProjectPermissionRecord,
};
impl Coordinator {
pub fn grant_project_debug(&mut self, tenant: TenantId, project: ProjectId, user: UserId) {
self.durable.project_permissions.insert(
(tenant.clone(), project.clone(), user.clone()),
ProjectPermissionRecord {
tenant,
project,
user,
can_debug: true,
},
);
}
pub fn register_agent_public_key(
&mut self,
tenant: TenantId,
project: ProjectId,
user: UserId,
agent: AgentId,
public_key: impl Into<String>,
) -> AgentPublicKeyRecord {
let key = (tenant.clone(), project.clone(), agent.clone());
let version = self
.durable
.agent_public_keys
.get(&key)
.map_or(1, |record| record.version.saturating_add(1));
let public_key = public_key.into();
let public_key_fingerprint = Digest::sha256(&public_key);
let record = AgentPublicKeyRecord {
tenant: tenant.clone(),
project: project.clone(),
user: user.clone(),
agent: agent.clone(),
public_key,
public_key_fingerprint: public_key_fingerprint.clone(),
version,
revoked: false,
scopes: vec!["project:read".to_owned(), "project:run".to_owned()],
human_account_creation_privilege: false,
browser_interaction_required_each_run: false,
};
self.durable.agent_public_keys.insert(key, record.clone());
let subject = format!("agent:{tenant}:{project}:{agent}");
self.durable.credentials.insert(
subject.clone(),
CredentialRecord {
subject,
tenant,
project: Some(project),
kind: CredentialKind::PublicKey,
public_key_fingerprint: Some(public_key_fingerprint),
},
);
record
}
pub fn list_agent_public_keys(&self, context: &AuthContext) -> Vec<AgentPublicKeyRecord> {
self.durable
.agent_public_keys
.values()
.filter(|record| record.tenant == context.tenant && record.project == context.project)
.filter(|record| match &context.actor {
Actor::User(user) => &record.user == user,
Actor::Agent(agent) => &record.agent == agent,
Actor::Node(_) | Actor::Task(_) => false,
})
.cloned()
.collect()
}
pub fn revoke_agent_public_key(
&mut self,
context: &AuthContext,
agent: &AgentId,
) -> Result<AgentPublicKeyRecord, CoordinatorError> {
let key = (
context.tenant.clone(),
context.project.clone(),
agent.clone(),
);
let record = self
.durable
.agent_public_keys
.get_mut(&key)
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"agent public key is not registered for this project".to_owned(),
)
})?;
match &context.actor {
Actor::User(user) if &record.user == user => {}
Actor::User(_) => {
return Err(CoordinatorError::Unauthorized(
"agent public key is outside the signed-in user scope".to_owned(),
));
}
_ => {
return Err(CoordinatorError::Unauthorized(
"agent public-key revocation requires a user identity".to_owned(),
));
}
}
record.revoked = true;
let subject = format!(
"agent:{}:{}:{}",
context.tenant, context.project, record.agent
);
self.durable.credentials.remove(&subject);
Ok(record.clone())
}
pub fn authorize_agent_project_run(
&self,
scope: AgentWorkflowScope<'_>,
public_key_fingerprint: Option<&Digest>,
payload_digest: &Digest,
signature: &AgentSignedRequest,
now_epoch_seconds: u64,
) -> Result<AgentPublicKeyRecord, CoordinatorError> {
let record = self
.durable
.agent_public_keys
.get(&(
scope.tenant.clone(),
scope.project.clone(),
scope.agent.clone(),
))
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"agent public key is not registered for this tenant/project".to_owned(),
)
})?;
if record.revoked {
return Err(CoordinatorError::Unauthorized(
"agent public key has been revoked".to_owned(),
));
}
if let Some(public_key_fingerprint) = public_key_fingerprint {
if &record.public_key_fingerprint != public_key_fingerprint {
return Err(CoordinatorError::Unauthorized(
"agent public key fingerprint does not match the registered key".to_owned(),
));
}
}
let max_signature_skew_seconds = 300;
if signature
.issued_at_epoch_seconds
.abs_diff(now_epoch_seconds)
> max_signature_skew_seconds
{
return Err(CoordinatorError::Unauthorized(
"agent signed request is expired or outside the allowed clock skew".to_owned(),
));
}
if !record.scopes.iter().any(|scope| scope == "project:run") {
return Err(CoordinatorError::Unauthorized(
"agent public key is not scoped for project runs".to_owned(),
));
}
verify_agent_workflow_signature(&record.public_key, scope, payload_digest, signature)
.map_err(CoordinatorError::Unauthorized)?;
Ok(record.clone())
}
}

View file

@ -0,0 +1,145 @@
use std::collections::BTreeMap;
use disasmer_core::{
AgentId, CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TenantRecord {
pub id: TenantId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserRecord {
pub id: UserId,
pub tenant: TenantId,
pub credential_kind: CredentialKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectRecord {
pub id: ProjectId,
pub tenant: TenantId,
pub name: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeIdentityRecord {
pub id: NodeId,
pub tenant: TenantId,
pub project: ProjectId,
pub public_key: String,
pub enrollment_scope: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CredentialRecord {
pub subject: String,
pub tenant: TenantId,
pub project: Option<ProjectId>,
pub kind: CredentialKind,
pub public_key_fingerprint: Option<Digest>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliSessionRecord {
pub session_digest: Digest,
pub tenant: TenantId,
pub project: ProjectId,
pub user: UserId,
pub credential_kind: CredentialKind,
pub expires_at_epoch_seconds: Option<u64>,
pub revoked: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceProviderConfigRecord {
pub tenant: TenantId,
pub project: ProjectId,
pub provider: SourceProviderKind,
pub manifest_digest: Digest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServicePolicyRecord {
pub tenant: TenantId,
pub name: String,
pub digest: Digest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountPolicyState {
pub account_status: String,
pub suspended: bool,
pub disabled: bool,
pub deleted: bool,
pub manual_review: bool,
pub sanitized_reason: Option<String>,
pub next_actions: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectPermissionRecord {
pub tenant: TenantId,
pub project: ProjectId,
pub user: UserId,
pub can_debug: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentPublicKeyRecord {
pub tenant: TenantId,
pub project: ProjectId,
pub user: UserId,
pub agent: AgentId,
pub public_key: String,
pub public_key_fingerprint: Digest,
pub version: u64,
pub revoked: bool,
pub scopes: Vec<String>,
pub human_account_creation_privilege: bool,
pub browser_interaction_required_each_run: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct DurableState {
pub tenants: BTreeMap<TenantId, TenantRecord>,
pub users: BTreeMap<UserId, UserRecord>,
pub projects: BTreeMap<ProjectId, ProjectRecord>,
pub node_identities: BTreeMap<NodeId, NodeIdentityRecord>,
pub credentials: BTreeMap<String, CredentialRecord>,
pub cli_sessions: BTreeMap<Digest, CliSessionRecord>,
pub source_provider_configs:
BTreeMap<(TenantId, ProjectId, String), SourceProviderConfigRecord>,
pub service_policy_records: BTreeMap<(TenantId, String), ServicePolicyRecord>,
pub project_permissions: BTreeMap<(TenantId, ProjectId, UserId), ProjectPermissionRecord>,
pub agent_public_keys: BTreeMap<(TenantId, ProjectId, AgentId), AgentPublicKeyRecord>,
}
pub trait DurableStore {
fn load(&self) -> DurableState;
fn save(&mut self, state: DurableState);
}
pub trait FallibleDurableStore {
type Error;
fn load_state(&mut self) -> Result<DurableState, Self::Error>;
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error>;
}
#[derive(Clone, Debug, Default)]
pub struct InMemoryDurableStore {
state: DurableState,
}
impl DurableStore for InMemoryDurableStore {
fn load(&self) -> DurableState {
self.state.clone()
}
fn save(&mut self, state: DurableState) {
self.state = state;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,62 @@
use std::io::Write;
use disasmer_coordinator::{service::bind_listener, CoordinatorService};
use disasmer_core::{ProjectId, TenantId, UserId};
use serde_json::json;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut listen = "127.0.0.1:0".to_owned();
let mut allow_local_trusted = std::env::var("DISASMER_ALLOW_LOCAL_TRUSTED_LOOPBACK")
.ok()
.as_deref()
== Some("1");
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--listen" {
listen = args.next().ok_or("--listen requires an address")?;
} else if arg == "--allow-local-trusted-loopback" {
allow_local_trusted = true;
}
}
let (listener, addr) = bind_listener(&listen)?;
let database_url = std::env::var("DATABASE_URL").ok();
let mut service = CoordinatorService::new_with_database_url(1, database_url.as_deref())?;
let self_hosted_session_secret = std::env::var("DISASMER_SELF_HOSTED_SESSION_SECRET")
.ok()
.filter(|secret| !secret.trim().is_empty());
if let Some(session_secret) = self_hosted_session_secret.as_deref() {
service.issue_cli_session(
TenantId::new(
std::env::var("DISASMER_SELF_HOSTED_TENANT")
.unwrap_or_else(|_| "tenant".to_owned()),
),
ProjectId::new(
std::env::var("DISASMER_SELF_HOSTED_PROJECT")
.unwrap_or_else(|_| "project".to_owned()),
),
UserId::new(
std::env::var("DISASMER_SELF_HOSTED_USER").unwrap_or_else(|_| "user".to_owned()),
),
session_secret,
None,
)?;
}
println!(
"{}",
json!({
"listen": addr.to_string(),
"client_authority": if allow_local_trusted { "local_trusted_loopback" } else { "strict" },
"self_hosted_session_bootstrapped": self_hosted_session_secret.is_some(),
"durable_store": service.durable_store_kind(),
})
);
std::io::stdout().flush()?;
if allow_local_trusted {
service.serve_tcp_local_trusted(listener)?;
} else {
service.serve_tcp(listener)?;
}
Ok(())
}

View file

@ -0,0 +1,576 @@
use postgres::{Client, NoTls};
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
use crate::{
AgentPublicKeyRecord, CliSessionRecord, CredentialRecord, DurableState, FallibleDurableStore,
NodeIdentityRecord, ProjectPermissionRecord, ProjectRecord, ServicePolicyRecord,
SourceProviderConfigRecord, TenantRecord, UserRecord,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PostgresTable {
pub name: &'static str,
pub durable_record: &'static str,
pub restart_surviving: bool,
}
pub const POSTGRES_DURABLE_TABLES: &[PostgresTable] = &[
PostgresTable {
name: "disasmer_tenants",
durable_record: "tenants",
restart_surviving: true,
},
PostgresTable {
name: "disasmer_users",
durable_record: "users",
restart_surviving: true,
},
PostgresTable {
name: "disasmer_projects",
durable_record: "projects",
restart_surviving: true,
},
PostgresTable {
name: "disasmer_node_identities",
durable_record: "node identities",
restart_surviving: true,
},
PostgresTable {
name: "disasmer_credentials",
durable_record: "credentials",
restart_surviving: true,
},
PostgresTable {
name: "disasmer_cli_sessions",
durable_record: "CLI sessions",
restart_surviving: true,
},
PostgresTable {
name: "disasmer_agent_public_keys",
durable_record: "agent public keys",
restart_surviving: true,
},
PostgresTable {
name: "disasmer_source_provider_configs",
durable_record: "source-provider configuration",
restart_surviving: true,
},
PostgresTable {
name: "disasmer_service_policy_records",
durable_record: "durable service policy records",
restart_surviving: true,
},
PostgresTable {
name: "disasmer_project_permissions",
durable_record: "explicit project permissions",
restart_surviving: true,
},
];
#[derive(Debug, Error)]
pub enum PostgresStoreError {
#[error("postgres durable store error: {0}")]
Postgres(#[from] postgres::Error),
#[error("durable state serialization error: {0}")]
Serialization(#[from] serde_json::Error),
}
pub struct PostgresDurableStore {
client: Client,
}
impl PostgresDurableStore {
pub fn connect(connection_string: &str) -> Result<Self, PostgresStoreError> {
let mut store = Self {
client: Client::connect(connection_string, NoTls)?,
};
store.migrate()?;
Ok(store)
}
pub fn from_client(client: Client) -> Result<Self, PostgresStoreError> {
let mut store = Self { client };
store.migrate()?;
Ok(store)
}
pub fn schema_sql() -> &'static str {
POSTGRES_SCHEMA_SQL
}
pub fn durable_tables() -> &'static [PostgresTable] {
POSTGRES_DURABLE_TABLES
}
pub fn migrate(&mut self) -> Result<(), PostgresStoreError> {
self.client.batch_execute(Self::schema_sql())?;
Ok(())
}
fn query_records<T: DeserializeOwned>(
&mut self,
sql: &str,
) -> Result<Vec<T>, PostgresStoreError> {
self.client
.query(sql, &[])?
.into_iter()
.map(|row| {
let value: serde_json::Value = row.get("record");
Ok(serde_json::from_value(value)?)
})
.collect()
}
fn record_value(record: &impl Serialize) -> Result<serde_json::Value, PostgresStoreError> {
Ok(serde_json::to_value(record)?)
}
}
impl FallibleDurableStore for PostgresDurableStore {
type Error = PostgresStoreError;
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
let mut state = DurableState::default();
for record in self.query_records::<TenantRecord>(
"SELECT record FROM disasmer_tenants ORDER BY tenant_id",
)? {
state.tenants.insert(record.id.clone(), record);
}
for record in
self.query_records::<UserRecord>("SELECT record FROM disasmer_users ORDER BY user_id")?
{
state.users.insert(record.id.clone(), record);
}
for record in self.query_records::<ProjectRecord>(
"SELECT record FROM disasmer_projects ORDER BY project_id",
)? {
state.projects.insert(record.id.clone(), record);
}
for record in self.query_records::<NodeIdentityRecord>(
"SELECT record FROM disasmer_node_identities ORDER BY node_id",
)? {
state.node_identities.insert(record.id.clone(), record);
}
for record in self.query_records::<CredentialRecord>(
"SELECT record FROM disasmer_credentials ORDER BY subject",
)? {
state.credentials.insert(record.subject.clone(), record);
}
for record in self.query_records::<CliSessionRecord>(
"SELECT record FROM disasmer_cli_sessions ORDER BY session_digest",
)? {
state
.cli_sessions
.insert(record.session_digest.clone(), record);
}
for record in self.query_records::<AgentPublicKeyRecord>(
"SELECT record FROM disasmer_agent_public_keys ORDER BY tenant_id, project_id, agent_id",
)? {
state.agent_public_keys.insert(
(
record.tenant.clone(),
record.project.clone(),
record.agent.clone(),
),
record,
);
}
for record in self.query_records::<SourceProviderConfigRecord>(
"SELECT record FROM disasmer_source_provider_configs ORDER BY tenant_id, project_id, provider_key",
)? {
let provider_key = format!("{:?}", record.provider);
state.source_provider_configs.insert(
(record.tenant.clone(), record.project.clone(), provider_key),
record,
);
}
for record in self.query_records::<ServicePolicyRecord>(
"SELECT record FROM disasmer_service_policy_records ORDER BY tenant_id, name",
)? {
state
.service_policy_records
.insert((record.tenant.clone(), record.name.clone()), record);
}
for record in self.query_records::<ProjectPermissionRecord>(
"SELECT record FROM disasmer_project_permissions ORDER BY tenant_id, project_id, user_id",
)? {
state.project_permissions.insert(
(
record.tenant.clone(),
record.project.clone(),
record.user.clone(),
),
record,
);
}
Ok(state)
}
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
let mut tx = self.client.transaction()?;
tx.batch_execute(
"
DELETE FROM disasmer_project_permissions;
DELETE FROM disasmer_service_policy_records;
DELETE FROM disasmer_source_provider_configs;
DELETE FROM disasmer_agent_public_keys;
DELETE FROM disasmer_cli_sessions;
DELETE FROM disasmer_credentials;
DELETE FROM disasmer_node_identities;
DELETE FROM disasmer_projects;
DELETE FROM disasmer_users;
DELETE FROM disasmer_tenants;
",
)?;
for record in state.tenants.values() {
let value = Self::record_value(record)?;
tx.execute(
"INSERT INTO disasmer_tenants (tenant_id, record) VALUES ($1, $2)",
&[&record.id.as_str(), &value],
)?;
}
for record in state.users.values() {
let value = Self::record_value(record)?;
tx.execute(
"INSERT INTO disasmer_users (user_id, tenant_id, record) VALUES ($1, $2, $3)",
&[&record.id.as_str(), &record.tenant.as_str(), &value],
)?;
}
for record in state.projects.values() {
let value = Self::record_value(record)?;
tx.execute(
"INSERT INTO disasmer_projects (project_id, tenant_id, record) VALUES ($1, $2, $3)",
&[&record.id.as_str(), &record.tenant.as_str(), &value],
)?;
}
for record in state.node_identities.values() {
let value = Self::record_value(record)?;
tx.execute(
"INSERT INTO disasmer_node_identities (node_id, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)",
&[
&record.id.as_str(),
&record.tenant.as_str(),
&record.project.as_str(),
&value,
],
)?;
}
for record in state.credentials.values() {
let value = Self::record_value(record)?;
let project_id = record.project.as_ref().map(|project| project.as_str());
tx.execute(
"INSERT INTO disasmer_credentials (subject, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)",
&[&record.subject.as_str(), &record.tenant.as_str(), &project_id, &value],
)?;
}
for record in state.cli_sessions.values() {
let value = Self::record_value(record)?;
tx.execute(
"INSERT INTO disasmer_cli_sessions (session_digest, tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4, $5)",
&[
&record.session_digest.as_str(),
&record.tenant.as_str(),
&record.project.as_str(),
&record.user.as_str(),
&value,
],
)?;
}
for record in state.agent_public_keys.values() {
let value = Self::record_value(record)?;
tx.execute(
"INSERT INTO disasmer_agent_public_keys (tenant_id, project_id, user_id, agent_id, record) VALUES ($1, $2, $3, $4, $5)",
&[
&record.tenant.as_str(),
&record.project.as_str(),
&record.user.as_str(),
&record.agent.as_str(),
&value,
],
)?;
}
for ((_, _, provider_key), record) in &state.source_provider_configs {
let value = Self::record_value(record)?;
tx.execute(
"INSERT INTO disasmer_source_provider_configs (tenant_id, project_id, provider_key, record) VALUES ($1, $2, $3, $4)",
&[
&record.tenant.as_str(),
&record.project.as_str(),
&provider_key.as_str(),
&value,
],
)?;
}
for record in state.service_policy_records.values() {
let value = Self::record_value(record)?;
tx.execute(
"INSERT INTO disasmer_service_policy_records (tenant_id, name, record) VALUES ($1, $2, $3)",
&[&record.tenant.as_str(), &record.name.as_str(), &value],
)?;
}
for record in state.project_permissions.values() {
let value = Self::record_value(record)?;
tx.execute(
"INSERT INTO disasmer_project_permissions (tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4)",
&[
&record.tenant.as_str(),
&record.project.as_str(),
&record.user.as_str(),
&value,
],
)?;
}
tx.commit()?;
Ok(())
}
}
const POSTGRES_SCHEMA_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS disasmer_tenants (
tenant_id TEXT PRIMARY KEY,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS disasmer_users (
user_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS disasmer_projects (
project_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS disasmer_node_identities (
node_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS disasmer_credentials (
subject TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS disasmer_cli_sessions (
session_digest TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS disasmer_agent_public_keys (
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE,
agent_id TEXT NOT NULL,
record JSONB NOT NULL,
PRIMARY KEY (tenant_id, project_id, agent_id)
);
CREATE TABLE IF NOT EXISTS disasmer_source_provider_configs (
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
provider_key TEXT NOT NULL,
record JSONB NOT NULL,
PRIMARY KEY (tenant_id, project_id, provider_key)
);
CREATE TABLE IF NOT EXISTS disasmer_service_policy_records (
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
name TEXT NOT NULL,
record JSONB NOT NULL,
PRIMARY KEY (tenant_id, name)
);
CREATE TABLE IF NOT EXISTS disasmer_project_permissions (
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE,
record JSONB NOT NULL,
PRIMARY KEY (tenant_id, project_id, user_id)
);
"#;
#[cfg(test)]
mod tests {
use disasmer_core::{
CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId,
};
use super::*;
use crate::{Coordinator, DurableStore, FallibleDurableStore, InMemoryDurableStore};
#[test]
fn postgres_schema_contains_only_restart_surviving_durable_tables() {
let names = PostgresDurableStore::durable_tables()
.iter()
.map(|table| table.name)
.collect::<Vec<_>>();
assert_eq!(names.len(), 10);
assert!(names.contains(&"disasmer_tenants"));
assert!(names.contains(&"disasmer_users"));
assert!(names.contains(&"disasmer_projects"));
assert!(names.contains(&"disasmer_node_identities"));
assert!(names.contains(&"disasmer_credentials"));
assert!(names.contains(&"disasmer_cli_sessions"));
assert!(names.contains(&"disasmer_agent_public_keys"));
assert!(names.contains(&"disasmer_source_provider_configs"));
assert!(names.contains(&"disasmer_service_policy_records"));
assert!(names.contains(&"disasmer_project_permissions"));
assert!(PostgresDurableStore::durable_tables()
.iter()
.all(|table| table.restart_surviving));
for runtime_only in [
"active_process",
"virtual_thread",
"scheduler_state",
"debug_epoch",
"vfs_manifest",
"transient_artifact_location",
] {
assert!(
!PostgresDurableStore::schema_sql().contains(runtime_only),
"{runtime_only} must remain outside Postgres durable state"
);
}
}
#[test]
fn fallible_store_boot_uses_durable_state_and_still_drops_live_processes() {
#[derive(Default)]
struct FallibleMemoryStore {
inner: InMemoryDurableStore,
}
impl FallibleDurableStore for FallibleMemoryStore {
type Error = std::convert::Infallible;
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
Ok(self.inner.load())
}
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
self.inner.save(state.clone());
Ok(())
}
}
let mut store = FallibleMemoryStore::default();
let mut first = Coordinator::try_boot(&mut store, 1).unwrap();
first.upsert_tenant(TenantId::from("tenant"));
first.upsert_user(
TenantId::from("tenant"),
UserId::from("user"),
CredentialKind::CliDeviceSession,
);
first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo");
first.enroll_node(
TenantId::from("tenant"),
ProjectId::from("project"),
NodeId::from("node"),
"public-key",
"node:attach",
);
first.upsert_source_provider_config(
TenantId::from("tenant"),
ProjectId::from("project"),
SourceProviderKind::Git,
Digest::sha256("git-manifest"),
);
first.start_process(
TenantId::from("tenant"),
ProjectId::from("project"),
disasmer_core::ProcessId::from("process"),
);
first.try_persist(&mut store).unwrap();
let restarted = Coordinator::try_boot(&mut store, 2).unwrap();
assert!(restarted.project(&ProjectId::from("project")).is_some());
assert!(restarted.node_identity(&NodeId::from("node")).is_some());
assert_eq!(restarted.active_process_count(), 0);
}
#[test]
fn postgres_round_trip_runs_when_dsn_is_configured() {
let Ok(dsn) = std::env::var("DISASMER_TEST_POSTGRES") else {
return;
};
let mut store = PostgresDurableStore::connect(&dsn).unwrap();
let mut state = DurableState::default();
state.tenants.insert(
TenantId::from("tenant"),
TenantRecord {
id: TenantId::from("tenant"),
},
);
state.projects.insert(
ProjectId::from("project"),
ProjectRecord {
id: ProjectId::from("project"),
tenant: TenantId::from("tenant"),
name: "demo".to_owned(),
},
);
state.users.insert(
UserId::from("user"),
UserRecord {
id: UserId::from("user"),
tenant: TenantId::from("tenant"),
credential_kind: CredentialKind::CliDeviceSession,
},
);
let session_digests = [
Digest::sha256("postgres-round-trip-session-one"),
Digest::sha256("postgres-round-trip-session-two"),
];
for session_digest in &session_digests {
state.cli_sessions.insert(
session_digest.clone(),
CliSessionRecord {
session_digest: session_digest.clone(),
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
user: UserId::from("user"),
credential_kind: CredentialKind::CliDeviceSession,
expires_at_epoch_seconds: None,
revoked: false,
},
);
let subject = format!("cli-session:{}", session_digest.as_str());
state.credentials.insert(
subject.clone(),
CredentialRecord {
subject,
tenant: TenantId::from("tenant"),
project: Some(ProjectId::from("project")),
kind: CredentialKind::CliDeviceSession,
public_key_fingerprint: None,
},
);
}
store.save_state(&state).unwrap();
let loaded = store.load_state().unwrap();
assert!(loaded.projects.contains_key(&ProjectId::from("project")));
assert!(session_digests
.iter()
.all(|session_digest| loaded.cli_sessions.contains_key(session_digest)));
assert_eq!(loaded.credentials.len(), 2);
}
}

View file

@ -0,0 +1,423 @@
// Request handlers intentionally spell out their deserialized protocol fields.
// Keeping those authority and payload values explicit at this boundary is safer
// than passing an unvalidated wire request deeper into the service.
#![allow(clippy::too_many_arguments)]
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::time::{SystemTime, UNIX_EPOCH};
use disasmer_core::{
Actor, AgentId, ArtifactRegistry, CapabilityReportError, CredentialKind, Digest, LimitError,
NativeQuicTransport, NodeDescriptor, NodeId, PanelState, Placement, ProcessId, ProjectId,
RateLimit, TenantId, TransportError, UserId,
};
use thiserror::Error;
use crate::{Coordinator, CoordinatorError};
mod admin;
mod artifacts;
mod authenticated;
mod authorization;
mod debug;
mod debug_requests;
mod durable_runtime;
mod keys;
mod logs;
mod main_runtime;
mod nodes;
mod panels;
mod process_launch;
mod processes;
mod protocol;
mod quota;
mod routing;
mod signed_nodes;
mod tcp;
mod wire_protocol;
use authorization::authorize_authenticated_user_operation;
use durable_runtime::RuntimeDurableStore;
use keys::{
artifact_id_from_path, enrollment_grant_key, EnrollmentGrantKey, PanelStopKey,
ProcessControlKey, TaskAssignmentKey, TaskControlKey, TaskRestartKey,
};
pub use protocol::{
ArtifactTransferAssignment, AuthenticatedCoordinatorRequest, CoordinatorRequest,
CoordinatorResponse, DebugAcknowledgementState, DebugAuditEvent,
DebugParticipantAcknowledgement, SourcePreparationDisposition, SourcePreparationStatus,
TaskAssignment, TaskCancellationTarget, TaskCompletionEvent, TaskExecutor,
TaskReplacementBundle, TaskTerminalState, VirtualProcessStatus, WorkflowActor,
};
pub use quota::CoordinatorQuotaConfiguration;
pub use tcp::{bind_listener, ClientAuthorityMode};
pub use wire_protocol::CoordinatorWireRequest;
const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024;
const DEBUG_CONTROL_READ_BYTES: u64 = 1024;
const MAX_REPLAY_NONCES_PER_AUTHORITY: usize = 1_024;
const NODE_SIGNATURE_WINDOW_SECONDS: u64 = 30;
const MAX_NODE_REPLAY_NONCES_PER_AUTHORITY: usize = 4_096;
const MAX_ENROLLMENT_GRANTS_PER_PROJECT: usize = 64;
const MAX_TASK_EVENTS_PER_PROCESS: usize = 128;
const MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS: usize = 256;
const MAX_RESTART_CHECKPOINTS_PER_PROCESS: usize = 128;
const MAX_IN_FLIGHT_TASKS_PER_PROCESS: usize = 256;
const MAX_NODE_REPORTED_OBJECTS_PER_KIND: usize = 1_024;
fn bounded_ttl(requested: u64, maximum: u64) -> u64 {
requested.clamp(1, maximum)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CoordinatorAdmission {
pub workflow_placement_allowed: bool,
pub max_node_enrollment_ttl_seconds: u64,
pub max_artifact_download_ttl_seconds: u64,
}
impl Default for CoordinatorAdmission {
fn default() -> Self {
Self {
workflow_placement_allowed: true,
max_node_enrollment_ttl_seconds: 15 * 60,
max_artifact_download_ttl_seconds: 15 * 60,
}
}
}
#[derive(Debug, Error)]
pub enum CoordinatorServiceError {
#[error("coordinator protocol I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("coordinator protocol JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("coordinator protocol error: {0}")]
Protocol(String),
#[error("coordinator request failed: {0}")]
Coordinator(#[from] CoordinatorError),
#[error("artifact download request failed: {0}")]
Download(#[from] disasmer_core::DownloadError),
#[error("scheduler placement failed: {0}")]
Scheduler(#[from] disasmer_core::PlacementError),
#[error("transport request failed: {0}")]
Transport(#[from] TransportError),
#[error("resource limit failed: {0}")]
Resource(#[from] LimitError),
#[error("operator panel request failed: {0}")]
Panel(#[from] disasmer_core::PanelError),
#[error("invalid node capability report: {0}")]
CapabilityReport(#[from] CapabilityReportError),
#[error("invalid VFS artifact path reported by node: {0}")]
InvalidArtifactPath(String),
#[error("invalid task log tail reported by node: {0}")]
InvalidTaskLogTail(String),
#[error("durable coordinator state failed: {0}")]
Durable(String),
}
pub struct CoordinatorService {
coordinator: Coordinator,
store: RuntimeDurableStore,
node_descriptors: BTreeMap<NodeId, NodeDescriptor>,
enrollment_grants: BTreeMap<EnrollmentGrantKey, disasmer_core::EnrollmentGrant>,
task_events: VecDeque<TaskCompletionEvent>,
debug_audit_events: VecDeque<DebugAuditEvent>,
debug_epochs: BTreeMap<ProcessControlKey, u64>,
debug_epoch_runtime: BTreeMap<ProcessControlKey, debug::DebugEpochRuntime>,
debug_breakpoints: BTreeMap<ProcessControlKey, debug::DebugBreakpointPlan>,
debug_commands: BTreeMap<TaskControlKey, debug::DebugPendingCommand>,
task_assignments: BTreeMap<TaskAssignmentKey, VecDeque<TaskAssignment>>,
task_restart_checkpoints: BTreeMap<TaskRestartKey, processes::TaskRestartCheckpoint>,
task_restart_checkpoint_order: VecDeque<TaskRestartKey>,
main_runtime: main_runtime::CoordinatorMainRuntime,
pending_task_launches: VecDeque<processes::PendingTaskLaunch>,
task_placements: BTreeMap<TaskControlKey, Placement>,
active_tasks: BTreeSet<TaskControlKey>,
task_cancellations: BTreeSet<TaskControlKey>,
task_aborts: BTreeSet<TaskControlKey>,
process_cancellations: BTreeSet<ProcessControlKey>,
process_aborts: BTreeSet<ProcessControlKey>,
agent_replay_nonces: BTreeMap<(TenantId, ProjectId, AgentId, String), u64>,
node_replay_nonces: BTreeMap<(NodeId, String), u64>,
panel_snapshots: BTreeMap<PanelStopKey, PanelState>,
stopped_panels: BTreeSet<PanelStopKey>,
panel_event_limits: BTreeMap<(TenantId, ProjectId, ProcessId, String), RateLimit>,
artifact_registry: ArtifactRegistry,
artifact_reverse_transfers: BTreeMap<String, artifacts::ArtifactReverseTransfer>,
artifact_transfer_by_token: BTreeMap<Digest, String>,
transport: NativeQuicTransport,
quota: quota::CoordinatorQuota,
admission: CoordinatorAdmission,
#[cfg(test)]
server_time_override: Option<u64>,
admin_token_digest: Option<Digest>,
admin_replay_nonces: BTreeMap<String, u64>,
}
impl CoordinatorService {
pub(super) fn authorize_node_for_process_or_termination(
&self,
node: &NodeId,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
) -> Result<(), CoordinatorServiceError> {
let process_key = keys::process_control_key(tenant, project, process);
if self.process_cancellations.contains(&process_key)
|| self.process_aborts.contains(&process_key)
{
let identity = self
.coordinator
.node_identity(node)
.ok_or(CoordinatorError::UnknownNode)?;
if &identity.tenant != tenant || &identity.project != project {
return Err(CoordinatorError::Unauthorized(
"node process-control request is outside its enrolled tenant/project scope"
.to_owned(),
)
.into());
}
return Ok(());
}
self.coordinator
.authorize_node_for_process(node, tenant, project, process)?;
Ok(())
}
pub fn new(coordinator_epoch: u64) -> Self {
Self::new_with_optional_admin_token_and_admission(
coordinator_epoch,
std::env::var("DISASMER_ADMIN_TOKEN").ok(),
CoordinatorAdmission::default(),
)
}
pub fn new_with_admin_token(coordinator_epoch: u64, admin_token: impl Into<String>) -> Self {
Self::new_with_optional_admin_token_and_admission(
coordinator_epoch,
Some(admin_token.into()),
CoordinatorAdmission::default(),
)
}
pub fn new_with_admission(coordinator_epoch: u64, admission: CoordinatorAdmission) -> Self {
Self::new_with_optional_admin_token_and_admission(
coordinator_epoch,
std::env::var("DISASMER_ADMIN_TOKEN").ok(),
admission,
)
}
fn new_with_optional_admin_token_and_admission(
coordinator_epoch: u64,
admin_token: Option<String>,
admission: CoordinatorAdmission,
) -> Self {
Self::try_new_with_optional_admin_token_admission_and_database_url(
coordinator_epoch,
admin_token,
admission,
None,
CoordinatorQuotaConfiguration::default(),
)
.expect("in-memory durable coordinator store initialization cannot fail")
}
pub fn new_with_database_url(
coordinator_epoch: u64,
database_url: Option<&str>,
) -> Result<Self, CoordinatorServiceError> {
Self::try_new_with_optional_admin_token_admission_and_database_url(
coordinator_epoch,
std::env::var("DISASMER_ADMIN_TOKEN").ok(),
CoordinatorAdmission::default(),
database_url,
CoordinatorQuotaConfiguration::default(),
)
}
pub fn new_with_admin_token_and_database_url(
coordinator_epoch: u64,
admin_token: impl Into<String>,
database_url: Option<&str>,
) -> Result<Self, CoordinatorServiceError> {
Self::new_with_admin_token_database_url_and_quota(
coordinator_epoch,
admin_token,
database_url,
CoordinatorQuotaConfiguration::default(),
)
}
pub fn new_with_admin_token_database_url_and_quota(
coordinator_epoch: u64,
admin_token: impl Into<String>,
database_url: Option<&str>,
quota_configuration: CoordinatorQuotaConfiguration,
) -> Result<Self, CoordinatorServiceError> {
Self::try_new_with_optional_admin_token_admission_and_database_url(
coordinator_epoch,
Some(admin_token.into()),
CoordinatorAdmission::default(),
database_url,
quota_configuration,
)
}
fn try_new_with_optional_admin_token_admission_and_database_url(
coordinator_epoch: u64,
admin_token: Option<String>,
admission: CoordinatorAdmission,
database_url: Option<&str>,
quota_configuration: CoordinatorQuotaConfiguration,
) -> Result<Self, CoordinatorServiceError> {
let mut store = RuntimeDurableStore::from_database_url(database_url)
.map_err(CoordinatorServiceError::Durable)?;
let coordinator = Coordinator::try_boot(&mut store, coordinator_epoch)
.map_err(CoordinatorServiceError::Durable)?;
let admin_token_digest = admin_token
.filter(|token| !token.trim().is_empty())
.map(Digest::sha256);
Ok(Self {
coordinator,
store,
node_descriptors: BTreeMap::new(),
enrollment_grants: BTreeMap::new(),
task_events: VecDeque::new(),
debug_audit_events: VecDeque::new(),
debug_epochs: BTreeMap::new(),
debug_epoch_runtime: BTreeMap::new(),
debug_breakpoints: BTreeMap::new(),
debug_commands: BTreeMap::new(),
task_assignments: BTreeMap::new(),
task_restart_checkpoints: BTreeMap::new(),
task_restart_checkpoint_order: VecDeque::new(),
main_runtime: main_runtime::CoordinatorMainRuntime::default(),
pending_task_launches: VecDeque::new(),
task_placements: BTreeMap::new(),
active_tasks: BTreeSet::new(),
task_cancellations: BTreeSet::new(),
task_aborts: BTreeSet::new(),
process_cancellations: BTreeSet::new(),
process_aborts: BTreeSet::new(),
agent_replay_nonces: BTreeMap::new(),
node_replay_nonces: BTreeMap::new(),
panel_snapshots: BTreeMap::new(),
stopped_panels: BTreeSet::new(),
panel_event_limits: BTreeMap::new(),
artifact_registry: ArtifactRegistry::default(),
artifact_reverse_transfers: BTreeMap::new(),
artifact_transfer_by_token: BTreeMap::new(),
transport: NativeQuicTransport,
quota: quota::CoordinatorQuota::new(quota_configuration),
admission,
#[cfg(test)]
server_time_override: None,
admin_token_digest,
admin_replay_nonces: BTreeMap::new(),
})
}
pub fn durable_store_kind(&self) -> &'static str {
self.store.kind()
}
fn persist_durable_state(&mut self) -> Result<(), CoordinatorServiceError> {
self.coordinator
.try_persist(&mut self.store)
.map_err(CoordinatorServiceError::Durable)
}
fn current_epoch_seconds(&self) -> Result<u64, CoordinatorServiceError> {
#[cfg(test)]
if let Some(now) = self.server_time_override {
return Ok(now);
}
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|err| CoordinatorServiceError::Protocol(format!("system clock error: {err}")))
}
fn handle_quota_status(
&self,
tenant: String,
project: String,
actor_user: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let durable_project = self.coordinator.project(&project).ok_or_else(|| {
CoordinatorError::Unauthorized("quota status requires an existing project".to_owned())
})?;
if durable_project.tenant != tenant {
return Err(CoordinatorError::Unauthorized(
"quota status project is outside the tenant scope".to_owned(),
)
.into());
}
let now_epoch_seconds = self.current_epoch_seconds()?;
let status = self
.quota
.project_status(&tenant, &project, now_epoch_seconds);
Ok(CoordinatorResponse::QuotaStatus {
tenant,
project,
actor,
policy_label: status.policy_label,
limits: status.limits,
window_seconds: status.window_seconds,
usage: status.usage,
window_started_epoch_seconds: status.window_started_epoch_seconds,
})
}
#[cfg(test)]
fn set_server_time(&mut self, now_epoch_seconds: u64) {
self.server_time_override = Some(now_epoch_seconds);
}
pub fn issue_cli_session(
&mut self,
tenant: TenantId,
project: ProjectId,
user: UserId,
session_secret: &str,
expires_at_epoch_seconds: Option<u64>,
) -> Result<crate::CliSessionRecord, CoordinatorServiceError> {
if let Some(existing) = self.coordinator.project(&project) {
if existing.tenant != tenant {
return Err(CoordinatorError::Unauthorized(
"CLI session project belongs to a different tenant".to_owned(),
)
.into());
}
} else {
self.coordinator
.upsert_project(tenant.clone(), project.clone(), "Session project");
}
self.coordinator
.grant_project_debug(tenant.clone(), project.clone(), user.clone());
let record = self.coordinator.issue_cli_session(
tenant,
project,
user,
session_secret,
expires_at_epoch_seconds,
);
self.persist_durable_state()?;
Ok(record)
}
pub fn revoke_cli_session(
&mut self,
session_secret: &str,
) -> Result<crate::CliSessionRecord, CoordinatorServiceError> {
let record = self.coordinator.revoke_cli_session(session_secret)?;
self.persist_durable_state()?;
Ok(record)
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,147 @@
use std::time::{SystemTime, UNIX_EPOCH};
use disasmer_core::{
admin_request_proof_from_token_digest, CredentialKind, Digest, TenantId, UserId,
};
use crate::CoordinatorError;
use super::{CoordinatorResponse, CoordinatorService, CoordinatorServiceError};
const ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS: u64 = 300;
impl CoordinatorService {
pub(super) fn handle_admin_status(
&mut self,
tenant: String,
actor_user: String,
admin_proof: Digest,
admin_nonce: String,
issued_at_epoch_seconds: u64,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
self.verify_admin_request(
"admin_status",
&tenant,
&actor_user,
&tenant,
&admin_proof,
&admin_nonce,
issued_at_epoch_seconds,
)?;
let tenant = TenantId::new(tenant);
let actor = UserId::new(actor_user);
Ok(CoordinatorResponse::AdminStatus {
suspended: self.coordinator.tenant_suspended(&tenant),
tenant,
actor,
safe_default: "read_only".to_owned(),
})
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_suspend_tenant(
&mut self,
tenant: String,
actor_user: String,
target_tenant: String,
admin_proof: Digest,
admin_nonce: String,
issued_at_epoch_seconds: u64,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
self.verify_admin_request(
"suspend_tenant",
&tenant,
&actor_user,
&target_tenant,
&admin_proof,
&admin_nonce,
issued_at_epoch_seconds,
)?;
let actor_tenant = TenantId::new(tenant);
let actor = UserId::new(actor_user);
let target_tenant = TenantId::new(target_tenant);
self.coordinator.upsert_tenant(actor_tenant.clone());
self.coordinator.upsert_user(
actor_tenant,
actor.clone(),
CredentialKind::CliDeviceSession,
);
let policy = self
.coordinator
.suspend_tenant(target_tenant.clone(), actor.clone());
self.persist_durable_state()?;
Ok(CoordinatorResponse::TenantSuspended {
tenant: target_tenant,
actor,
policy,
})
}
#[allow(clippy::too_many_arguments)]
fn verify_admin_request(
&mut self,
operation: &str,
tenant: &str,
actor_user: &str,
target_tenant: &str,
admin_proof: &Digest,
admin_nonce: &str,
issued_at_epoch_seconds: u64,
) -> Result<(), CoordinatorServiceError> {
let expected = self.admin_token_digest.as_ref().ok_or_else(|| {
CoordinatorError::Unauthorized(
"self-hosted admin credential is not configured".to_owned(),
)
})?;
if admin_nonce.trim().is_empty() || admin_nonce.len() > 256 {
return Err(CoordinatorError::Unauthorized(
"admin request nonce is missing or invalid".to_owned(),
)
.into());
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
if now.abs_diff(issued_at_epoch_seconds) > ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS {
return Err(CoordinatorError::Unauthorized(
"admin request timestamp is outside the allowed 300-second window".to_owned(),
)
.into());
}
let expected_proof = admin_request_proof_from_token_digest(
expected,
operation,
tenant,
actor_user,
target_tenant,
admin_nonce,
issued_at_epoch_seconds,
);
if admin_proof != &expected_proof {
return Err(CoordinatorError::Unauthorized(
"admin request proof is invalid".to_owned(),
)
.into());
}
self.admin_replay_nonces.retain(|_, issued_at| {
now <= issued_at.saturating_add(ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS)
});
if self.admin_replay_nonces.contains_key(admin_nonce) {
return Err(CoordinatorError::Unauthorized(
"admin request nonce was already used".to_owned(),
)
.into());
}
if self.admin_replay_nonces.len() >= super::MAX_REPLAY_NONCES_PER_AUTHORITY {
return Err(CoordinatorError::Unauthorized(
"admin request replay window is full; retry after the bounded signature window advances"
.to_owned(),
)
.into());
}
self.admin_replay_nonces
.insert(admin_nonce.to_owned(), issued_at_epoch_seconds);
Ok(())
}
}

View file

@ -0,0 +1,649 @@
use std::io::{Read, Seek, SeekFrom, Write};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use disasmer_core::{
generate_opaque_token, Actor, ArtifactId, AuthContext, DataPlaneObject, DataPlaneScope, Digest,
DownloadPolicy, NodeEndpoint, NodeId, ProjectId, RendezvousRequest, ResourceLimits,
ResourceMeter, StorageLocation, TenantId, UserId,
};
use sha2::{Digest as _, Sha256};
use crate::CoordinatorError;
use super::{
bounded_ttl, ArtifactTransferAssignment, CoordinatorResponse, CoordinatorService,
CoordinatorServiceError,
};
pub(super) const MAX_ARTIFACT_REVERSE_CHUNK_BYTES: u64 = 256 * 1024;
const MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS: usize = 4;
#[derive(Debug)]
pub(super) struct ArtifactReverseTransfer {
transfer_id: String,
token_digest: Digest,
tenant: TenantId,
project: ProjectId,
source_node: NodeId,
artifact: ArtifactId,
expected_digest: Digest,
expected_size_bytes: u64,
expires_at_epoch_seconds: u64,
spool: tempfile::NamedTempFile,
received_bytes: u64,
content_hasher: Sha256,
delivered_offset: u64,
error: Option<String>,
}
impl CoordinatorService {
pub(super) fn handle_create_artifact_download_link(
&mut self,
tenant: String,
project: String,
actor_user: String,
artifact: String,
max_bytes: u64,
ttl_seconds: u64,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let context = user_context(tenant, project, actor_user);
let artifact = ArtifactId::new(artifact);
let policy = DownloadPolicy { max_bytes };
let action = self
.artifact_registry
.download_action(&context, &artifact, &policy)?;
self.ensure_download_source_connectivity(&action.source)?;
let downloadable_size = self
.artifact_registry
.downloadable_size(&context, &artifact, &policy)?;
let now_epoch_seconds = self.current_epoch_seconds()?;
self.quota.can_charge_download(
&context.tenant,
&context.project,
downloadable_size,
now_epoch_seconds,
)?;
let token_nonce = generate_opaque_token("artifact_download")
.map_err(CoordinatorServiceError::Protocol)?;
let ttl_seconds = bounded_ttl(
ttl_seconds,
self.admission.max_artifact_download_ttl_seconds,
);
let link = self.artifact_registry.create_download_link(
&context,
&artifact,
&policy,
&token_nonce,
now_epoch_seconds,
ttl_seconds,
)?;
Ok(CoordinatorResponse::ArtifactDownloadLink { link })
}
pub(super) fn handle_open_artifact_download_stream(
&mut self,
tenant: String,
project: String,
actor_user: String,
artifact: String,
max_bytes: u64,
token_digest: Digest,
chunk_bytes: u64,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let context = user_context(tenant, project, actor_user);
let artifact = ArtifactId::new(artifact);
let policy = DownloadPolicy { max_bytes };
let now_epoch_seconds = self.current_epoch_seconds()?;
self.artifact_registry
.expire_download_links(now_epoch_seconds);
let downloadable_size = self
.artifact_registry
.downloadable_size(&context, &artifact, &policy)?;
let validation_limits = ResourceLimits::unlimited();
let mut validation_meter = ResourceMeter::default();
let mut stream = self.artifact_registry.open_download_stream(
disasmer_core::DownloadStreamRequest {
context: &context,
artifact: &artifact,
policy: &policy,
presented_token_digest: &token_digest,
now_epoch_seconds,
limits: &validation_limits,
},
&mut validation_meter,
)?;
self.ensure_download_source_connectivity(&stream.link.source)?;
self.expire_artifact_reverse_transfers(now_epoch_seconds);
if let Some(transfer_id) = self.artifact_transfer_by_token.get(&token_digest).cloned() {
if let Some(message) = self
.artifact_reverse_transfers
.get(&transfer_id)
.and_then(|transfer| transfer.error.clone())
{
self.artifact_reverse_transfers.remove(&transfer_id);
self.artifact_transfer_by_token.remove(&token_digest);
return Err(CoordinatorServiceError::Protocol(format!(
"retaining node could not stream artifact: {message}"
)));
}
let (content_offset, content, end, complete) = {
let transfer = self
.artifact_reverse_transfers
.get_mut(&transfer_id)
.ok_or_else(|| {
CoordinatorServiceError::Protocol(
"artifact reverse transfer index is inconsistent".to_owned(),
)
})?;
if transfer.tenant != context.tenant
|| transfer.project != context.project
|| transfer.artifact != artifact
|| transfer.token_digest != token_digest
{
return Err(disasmer_core::DownloadError::InvalidToken.into());
}
if transfer.received_bytes != transfer.expected_size_bytes {
return Ok(CoordinatorResponse::ArtifactDownloadStream {
link: stream.link,
streamed_bytes: 0,
charged_download_bytes: self.quota.used_download_bytes(
&context.tenant,
&context.project,
now_epoch_seconds,
),
content_bytes_available: false,
content_offset: None,
content_eof: false,
content_base64: None,
content_source: Some("retaining_node_reverse_stream_pending".to_owned()),
});
}
if chunk_bytes == 0 && downloadable_size != 0 {
return Err(CoordinatorServiceError::Protocol(
"artifact download chunk_bytes must be greater than zero".to_owned(),
));
}
let requested = chunk_bytes
.min(downloadable_size)
.min(MAX_ARTIFACT_REVERSE_CHUNK_BYTES);
let start = transfer.delivered_offset;
let end = start.saturating_add(requested).min(transfer.received_bytes);
let length = usize::try_from(end.saturating_sub(start)).map_err(|_| {
CoordinatorServiceError::Protocol(
"artifact download chunk length does not fit memory bounds".to_owned(),
)
})?;
let mut content = vec![0_u8; length];
let mut spool = transfer.spool.reopen().map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"open bounded artifact transfer spool: {error}"
))
})?;
spool.seek(SeekFrom::Start(start)).map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"seek bounded artifact transfer spool: {error}"
))
})?;
spool.read_exact(&mut content).map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"read bounded artifact transfer spool: {error}"
))
})?;
(start, content, end, end == transfer.received_bytes)
};
let streamed_bytes = content.len() as u64;
self.artifact_registry.stream_download_chunk(
&mut stream,
&validation_limits,
&mut validation_meter,
streamed_bytes,
)?;
let charged_download_bytes = self.quota.charge_download(
&context.tenant,
&context.project,
streamed_bytes,
now_epoch_seconds,
)?;
if let Some(transfer) = self.artifact_reverse_transfers.get_mut(&transfer_id) {
transfer.delivered_offset = end;
}
if complete {
self.artifact_reverse_transfers.remove(&transfer_id);
self.artifact_transfer_by_token.remove(&token_digest);
}
return Ok(CoordinatorResponse::ArtifactDownloadStream {
link: stream.link,
streamed_bytes,
charged_download_bytes,
content_bytes_available: true,
content_offset: Some(content_offset),
content_eof: complete,
content_base64: Some(BASE64_STANDARD.encode(content)),
content_source: Some("retaining_node_reverse_stream".to_owned()),
});
}
if self.artifact_reverse_transfers.len() >= MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS {
return Err(CoordinatorServiceError::Protocol(format!(
"artifact reverse transfer concurrency limit of {MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS} reached"
)));
}
let StorageLocation::RetainedNode(source_node) = &stream.link.source else {
return Err(disasmer_core::DownloadError::Unavailable.into());
};
let metadata = self
.artifact_registry
.metadata(&artifact)
.ok_or(disasmer_core::DownloadError::NotFound)?;
let transfer_id = generate_opaque_token("artifact_transfer")
.map_err(CoordinatorServiceError::Protocol)?;
let spool = tempfile::Builder::new()
.prefix("disasmer-artifact-transfer-")
.tempfile()
.map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"create bounded artifact transfer spool: {error}"
))
})?;
let transfer = ArtifactReverseTransfer {
transfer_id: transfer_id.clone(),
token_digest: token_digest.clone(),
tenant: context.tenant.clone(),
project: context.project.clone(),
source_node: source_node.clone(),
artifact,
expected_digest: metadata.digest.clone(),
expected_size_bytes: metadata.size,
expires_at_epoch_seconds: stream.link.expires_at_epoch_seconds,
spool,
received_bytes: 0,
content_hasher: Sha256::new(),
delivered_offset: 0,
error: None,
};
self.artifact_reverse_transfers
.insert(transfer_id.clone(), transfer);
self.artifact_transfer_by_token
.insert(token_digest, transfer_id);
Ok(CoordinatorResponse::ArtifactDownloadStream {
link: stream.link,
streamed_bytes: 0,
charged_download_bytes: self.quota.used_download_bytes(
&context.tenant,
&context.project,
now_epoch_seconds,
),
content_bytes_available: false,
content_offset: None,
content_eof: false,
content_base64: None,
content_source: Some("retaining_node_reverse_stream_pending".to_owned()),
})
}
pub(super) fn handle_revoke_artifact_download_link(
&mut self,
tenant: String,
project: String,
actor_user: String,
artifact: String,
token_digest: Digest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let context = user_context(tenant, project, actor_user);
let now_epoch_seconds = self.current_epoch_seconds()?;
self.artifact_registry
.expire_download_links(now_epoch_seconds);
let link = self.artifact_registry.revoke_download_link(
&context,
&ArtifactId::new(artifact),
&token_digest,
)?;
if let Some(transfer_id) = self.artifact_transfer_by_token.remove(&token_digest) {
self.artifact_reverse_transfers.remove(&transfer_id);
}
Ok(CoordinatorResponse::ArtifactDownloadLinkRevoked { link })
}
pub(super) fn handle_export_artifact_to_node(
&mut self,
tenant: String,
project: String,
actor_user: String,
artifact: String,
receiver_node: String,
direct_connectivity: bool,
failure_reason: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let context = user_context(tenant, project, actor_user);
let artifact = ArtifactId::new(artifact);
let receiver_node = NodeId::new(receiver_node);
let action = self.artifact_registry.download_action(
&context,
&artifact,
&DownloadPolicy {
max_bytes: u64::MAX,
},
)?;
let StorageLocation::RetainedNode(source_node) = action.source else {
return Err(disasmer_core::DownloadError::Unavailable.into());
};
let metadata = self
.artifact_registry
.metadata(&artifact)
.ok_or(disasmer_core::DownloadError::NotFound)?;
let source = self.export_endpoint(&source_node, &context.tenant, &context.project)?;
let destination =
self.export_endpoint(&receiver_node, &context.tenant, &context.project)?;
let plan = self.transport.plan_authenticated_direct_bulk_transfer(
RendezvousRequest {
scope: DataPlaneScope {
tenant: context.tenant.clone(),
project: context.project.clone(),
process: metadata.process.clone(),
object: DataPlaneObject::Artifact(artifact.clone()),
authorization_subject: format!(
"artifact-export:{}-to-{}",
source_node, receiver_node
),
},
source,
destination,
},
direct_connectivity,
failure_reason,
)?;
Ok(CoordinatorResponse::ArtifactExportPlan {
plan,
source_node,
receiver_node,
artifact_size_bytes: metadata.size,
})
}
pub(super) fn handle_poll_artifact_transfer(
&mut self,
tenant: String,
project: String,
node: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let node = NodeId::new(node);
self.authorize_artifact_transfer_node(&tenant, &project, &node)?;
let transfer = self.artifact_reverse_transfers.values().find(|transfer| {
transfer.tenant == tenant
&& transfer.project == project
&& transfer.source_node == node
&& transfer.error.is_none()
&& transfer.received_bytes < transfer.expected_size_bytes
});
Ok(CoordinatorResponse::ArtifactTransferAssignment {
transfer: transfer.map(|transfer| ArtifactTransferAssignment {
transfer_id: transfer.transfer_id.clone(),
artifact: transfer.artifact.clone(),
expected_digest: transfer.expected_digest.clone(),
expected_size_bytes: transfer.expected_size_bytes,
offset: transfer.received_bytes,
max_chunk_bytes: MAX_ARTIFACT_REVERSE_CHUNK_BYTES,
}),
})
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_upload_artifact_transfer_chunk(
&mut self,
tenant: String,
project: String,
node: String,
transfer_id: String,
artifact: String,
offset: u64,
content_base64: String,
chunk_digest: Digest,
eof: bool,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let node = NodeId::new(node);
self.authorize_artifact_transfer_node(&tenant, &project, &node)?;
let transfer = self
.artifact_reverse_transfers
.get_mut(&transfer_id)
.ok_or_else(|| {
CoordinatorServiceError::Protocol("unknown artifact reverse transfer".to_owned())
})?;
if transfer.tenant != tenant
|| transfer.project != project
|| transfer.source_node != node
|| transfer.artifact.as_str() != artifact
{
return Err(CoordinatorError::Unauthorized(
"artifact reverse transfer is outside the signed node scope".to_owned(),
)
.into());
}
if transfer.received_bytes != offset {
return Err(CoordinatorServiceError::Protocol(format!(
"artifact reverse transfer expected offset {}, received {offset}",
transfer.received_bytes
)));
}
let content = BASE64_STANDARD.decode(content_base64).map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"artifact reverse transfer chunk is not valid base64: {error}"
))
})?;
if content.len() as u64 > MAX_ARTIFACT_REVERSE_CHUNK_BYTES {
return Err(CoordinatorServiceError::Protocol(
"artifact reverse transfer chunk exceeds the control-plane limit".to_owned(),
));
}
if Digest::sha256(&content) != chunk_digest {
return Err(CoordinatorServiceError::Protocol(
"artifact reverse transfer chunk digest mismatch".to_owned(),
));
}
let next_offset = offset.saturating_add(content.len() as u64);
if next_offset > transfer.expected_size_bytes {
return Err(CoordinatorServiceError::Protocol(
"artifact reverse transfer exceeds retained metadata size".to_owned(),
));
}
let complete = next_offset == transfer.expected_size_bytes;
if eof != complete {
return Err(CoordinatorServiceError::Protocol(
"artifact reverse transfer EOF does not match retained metadata size".to_owned(),
));
}
transfer
.spool
.as_file_mut()
.seek(SeekFrom::Start(offset))
.and_then(|_| transfer.spool.as_file_mut().write_all(&content))
.map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"write bounded artifact transfer spool: {error}"
))
})?;
transfer.content_hasher.update(&content);
transfer.received_bytes = next_offset;
if complete {
transfer.spool.as_file().sync_all().map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"sync bounded artifact transfer spool: {error}"
))
})?;
let digest_hex = format!("{:x}", transfer.content_hasher.clone().finalize());
let digest =
Digest::from_sha256_hex(&digest_hex).map_err(CoordinatorServiceError::Protocol)?;
if digest != transfer.expected_digest {
transfer.spool.as_file_mut().set_len(0).map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"reset invalid artifact transfer spool: {error}"
))
})?;
transfer.received_bytes = 0;
transfer.content_hasher = Sha256::new();
return Err(CoordinatorServiceError::Protocol(
"artifact reverse transfer content digest mismatch".to_owned(),
));
}
}
Ok(CoordinatorResponse::ArtifactTransferChunkAccepted {
transfer_id,
next_offset,
complete,
})
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_fail_artifact_transfer(
&mut self,
tenant: String,
project: String,
node: String,
transfer_id: String,
artifact: String,
message: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let node = NodeId::new(node);
self.authorize_artifact_transfer_node(&tenant, &project, &node)?;
let transfer = self
.artifact_reverse_transfers
.get_mut(&transfer_id)
.ok_or_else(|| {
CoordinatorServiceError::Protocol("unknown artifact reverse transfer".to_owned())
})?;
if transfer.tenant != tenant
|| transfer.project != project
|| transfer.source_node != node
|| transfer.artifact.as_str() != artifact
{
return Err(CoordinatorError::Unauthorized(
"artifact reverse transfer failure is outside the signed node scope".to_owned(),
)
.into());
}
let message = message.trim();
transfer.error = Some(if message.is_empty() {
"retained artifact bytes are unavailable".to_owned()
} else {
message.chars().take(1024).collect()
});
Ok(CoordinatorResponse::ArtifactTransferFailed { transfer_id })
}
fn authorize_artifact_transfer_node(
&self,
tenant: &TenantId,
project: &ProjectId,
node: &NodeId,
) -> Result<(), CoordinatorServiceError> {
let identity = self
.coordinator
.node_identity(node)
.ok_or(CoordinatorError::UnknownNode)?;
if &identity.tenant != tenant || &identity.project != project {
return Err(CoordinatorError::Unauthorized(
"artifact reverse transfer node is outside its enrolled tenant/project scope"
.to_owned(),
)
.into());
}
Ok(())
}
fn expire_artifact_reverse_transfers(&mut self, now_epoch_seconds: u64) {
let expired = self
.artifact_reverse_transfers
.iter()
.filter(|(_, transfer)| transfer.expires_at_epoch_seconds < now_epoch_seconds)
.map(|(id, transfer)| (id.clone(), transfer.token_digest.clone()))
.collect::<Vec<_>>();
for (id, token) in expired {
self.artifact_reverse_transfers.remove(&id);
self.artifact_transfer_by_token.remove(&token);
}
}
fn export_endpoint(
&self,
node: &NodeId,
tenant: &TenantId,
project: &ProjectId,
) -> Result<NodeEndpoint, CoordinatorServiceError> {
let identity = self
.coordinator
.node_identity(node)
.ok_or(CoordinatorError::UnknownNode)?;
if &identity.tenant != tenant || &identity.project != project {
return Err(CoordinatorError::Unauthorized(
"artifact export node is outside the tenant/project scope".to_owned(),
)
.into());
}
let descriptor = self.node_descriptors.get(node).ok_or_else(|| {
disasmer_core::DownloadError::DirectConnectivityUnavailable(format!(
"node {node} has not reported export connectivity"
))
})?;
if descriptor.tenant != *tenant || descriptor.project != *project {
return Err(CoordinatorError::Unauthorized(
"artifact export node descriptor is outside the tenant/project scope".to_owned(),
)
.into());
}
if !descriptor.online {
return Err(
disasmer_core::DownloadError::DirectConnectivityUnavailable(format!(
"node {node} is offline for artifact export"
))
.into(),
);
}
if !descriptor.direct_connectivity {
return Err(
disasmer_core::DownloadError::DirectConnectivityUnavailable(format!(
"direct connectivity unavailable to node {node} for artifact export"
))
.into(),
);
}
Ok(NodeEndpoint {
node: node.clone(),
advertised_addr: format!("{node}.mesh.invalid:4433"),
public_key_fingerprint: Digest::sha256(&identity.public_key),
})
}
fn ensure_download_source_connectivity(
&self,
source: &StorageLocation,
) -> Result<(), disasmer_core::DownloadError> {
let StorageLocation::RetainedNode(node) = source else {
return Ok(());
};
let descriptor = self.node_descriptors.get(node).ok_or_else(|| {
disasmer_core::DownloadError::DirectConnectivityUnavailable(format!(
"retaining node {node} has not reported online status for artifact download"
))
})?;
if !descriptor.online {
return Err(disasmer_core::DownloadError::DirectConnectivityUnavailable(
format!("retaining node {node} is offline for artifact download"),
));
}
Ok(())
}
}
fn user_context(tenant: String, project: String, actor_user: String) -> AuthContext {
AuthContext {
tenant: TenantId::new(tenant),
project: ProjectId::new(project),
actor: Actor::User(UserId::new(actor_user)),
}
}

View file

@ -0,0 +1,139 @@
use disasmer_core::{AuthContext, UserId};
use super::{
AuthenticatedCoordinatorRequest, CoordinatorRequest, CoordinatorResponse, CoordinatorService,
CoordinatorServiceError,
};
impl CoordinatorService {
pub(super) fn handle_authenticated_agent_key_request(
&mut self,
context: &AuthContext,
actor: &UserId,
request: AuthenticatedCoordinatorRequest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let request = match request {
AuthenticatedCoordinatorRequest::RegisterAgentPublicKey { agent, public_key } => {
CoordinatorRequest::RegisterAgentPublicKey {
tenant: context.tenant.as_str().to_owned(),
project: context.project.as_str().to_owned(),
user: actor.as_str().to_owned(),
agent,
public_key,
}
}
AuthenticatedCoordinatorRequest::ListAgentPublicKeys => {
CoordinatorRequest::ListAgentPublicKeys {
tenant: context.tenant.as_str().to_owned(),
project: context.project.as_str().to_owned(),
user: actor.as_str().to_owned(),
}
}
AuthenticatedCoordinatorRequest::RotateAgentPublicKey { agent, public_key } => {
CoordinatorRequest::RotateAgentPublicKey {
tenant: context.tenant.as_str().to_owned(),
project: context.project.as_str().to_owned(),
user: actor.as_str().to_owned(),
agent,
public_key,
}
}
AuthenticatedCoordinatorRequest::RevokeAgentPublicKey { agent } => {
CoordinatorRequest::RevokeAgentPublicKey {
tenant: context.tenant.as_str().to_owned(),
project: context.project.as_str().to_owned(),
user: actor.as_str().to_owned(),
agent,
}
}
_ => unreachable!("caller filters authenticated agent key operations"),
};
self.handle_request(request)
}
pub(super) fn handle_authenticated_launch_task(
&mut self,
context: &AuthContext,
actor: &UserId,
request: AuthenticatedCoordinatorRequest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let AuthenticatedCoordinatorRequest::LaunchTask {
task_spec,
wait_for_node,
artifact_path,
wasm_module_base64,
} = request
else {
unreachable!("caller filters authenticated task launches");
};
self.handle_launch_task(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
Some(actor.as_str().to_owned()),
None,
None,
None,
None,
*task_spec,
wait_for_node,
artifact_path,
wasm_module_base64,
)
}
pub(super) fn handle_authenticated_schedule_task(
&mut self,
context: &AuthContext,
request: AuthenticatedCoordinatorRequest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let AuthenticatedCoordinatorRequest::ScheduleTask {
environment,
environment_digest,
required_capabilities,
dependency_cache,
source_snapshot,
required_artifacts,
prefer_node,
} = request
else {
unreachable!("caller filters authenticated task scheduling");
};
self.handle_schedule_task(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
environment,
environment_digest,
required_capabilities,
dependency_cache,
source_snapshot,
required_artifacts,
prefer_node,
)
}
pub(super) fn handle_authenticated_artifact_export(
&mut self,
context: &AuthContext,
actor: &UserId,
request: AuthenticatedCoordinatorRequest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let AuthenticatedCoordinatorRequest::ExportArtifactToNode {
artifact,
receiver_node,
direct_connectivity,
failure_reason,
} = request
else {
unreachable!("caller filters authenticated artifact exports");
};
self.handle_export_artifact_to_node(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
artifact,
receiver_node,
direct_connectivity,
failure_reason,
)
}
}

View file

@ -0,0 +1,198 @@
use disasmer_core::{Actor, AuthContext, UserId};
use crate::CoordinatorError;
use super::{AuthenticatedCoordinatorRequest, CoordinatorServiceError};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum PublicUserOperation {
AuthStatus,
RevokeCliSession,
CreateProject,
SelectProject,
ListProjects,
RegisterAgentPublicKey,
ListAgentPublicKeys,
RotateAgentPublicKey,
RevokeAgentPublicKey,
CreateNodeEnrollmentGrant,
ListNodeDescriptors,
RevokeNodeCredential,
StartProcess,
ScheduleTask,
LaunchTask,
CancelProcess,
AbortProcess,
ListProcesses,
QuotaStatus,
RestartTask,
DebugAttach,
SetDebugBreakpoints,
InspectDebugBreakpoints,
CreateDebugEpoch,
ResumeDebugEpoch,
InspectDebugEpoch,
ListTaskEvents,
JoinTask,
CreateArtifactDownloadLink,
OpenArtifactDownloadStream,
RevokeArtifactDownloadLink,
ExportArtifactToNode,
}
impl PublicUserOperation {
pub(super) fn as_str(self) -> &'static str {
match self {
Self::AuthStatus => "auth_status",
Self::RevokeCliSession => "revoke_cli_session",
Self::CreateProject => "create_project",
Self::SelectProject => "select_project",
Self::ListProjects => "list_projects",
Self::RegisterAgentPublicKey => "register_agent_public_key",
Self::ListAgentPublicKeys => "list_agent_public_keys",
Self::RotateAgentPublicKey => "rotate_agent_public_key",
Self::RevokeAgentPublicKey => "revoke_agent_public_key",
Self::CreateNodeEnrollmentGrant => "create_node_enrollment_grant",
Self::ListNodeDescriptors => "list_node_descriptors",
Self::RevokeNodeCredential => "revoke_node_credential",
Self::StartProcess => "start_process",
Self::ScheduleTask => "schedule_task",
Self::LaunchTask => "launch_task",
Self::CancelProcess => "cancel_process",
Self::AbortProcess => "abort_process",
Self::ListProcesses => "list_processes",
Self::QuotaStatus => "quota_status",
Self::RestartTask => "restart_task",
Self::DebugAttach => "debug_attach",
Self::SetDebugBreakpoints => "set_debug_breakpoints",
Self::InspectDebugBreakpoints => "inspect_debug_breakpoints",
Self::CreateDebugEpoch => "create_debug_epoch",
Self::ResumeDebugEpoch => "resume_debug_epoch",
Self::InspectDebugEpoch => "inspect_debug_epoch",
Self::ListTaskEvents => "list_task_events",
Self::JoinTask => "join_task",
Self::CreateArtifactDownloadLink => "create_artifact_download_link",
Self::OpenArtifactDownloadStream => "open_artifact_download_stream",
Self::RevokeArtifactDownloadLink => "revoke_artifact_download_link",
Self::ExportArtifactToNode => "export_artifact_to_node",
}
}
}
impl From<&AuthenticatedCoordinatorRequest> for PublicUserOperation {
fn from(request: &AuthenticatedCoordinatorRequest) -> Self {
match request {
AuthenticatedCoordinatorRequest::AuthStatus => Self::AuthStatus,
AuthenticatedCoordinatorRequest::RevokeCliSession => Self::RevokeCliSession,
AuthenticatedCoordinatorRequest::CreateProject { .. } => Self::CreateProject,
AuthenticatedCoordinatorRequest::SelectProject { .. } => Self::SelectProject,
AuthenticatedCoordinatorRequest::ListProjects => Self::ListProjects,
AuthenticatedCoordinatorRequest::RegisterAgentPublicKey { .. } => {
Self::RegisterAgentPublicKey
}
AuthenticatedCoordinatorRequest::ListAgentPublicKeys => Self::ListAgentPublicKeys,
AuthenticatedCoordinatorRequest::RotateAgentPublicKey { .. } => {
Self::RotateAgentPublicKey
}
AuthenticatedCoordinatorRequest::RevokeAgentPublicKey { .. } => {
Self::RevokeAgentPublicKey
}
AuthenticatedCoordinatorRequest::CreateNodeEnrollmentGrant { .. } => {
Self::CreateNodeEnrollmentGrant
}
AuthenticatedCoordinatorRequest::ListNodeDescriptors => Self::ListNodeDescriptors,
AuthenticatedCoordinatorRequest::RevokeNodeCredential { .. } => {
Self::RevokeNodeCredential
}
AuthenticatedCoordinatorRequest::StartProcess { .. } => Self::StartProcess,
AuthenticatedCoordinatorRequest::ScheduleTask { .. } => Self::ScheduleTask,
AuthenticatedCoordinatorRequest::LaunchTask { .. } => Self::LaunchTask,
AuthenticatedCoordinatorRequest::CancelProcess { .. } => Self::CancelProcess,
AuthenticatedCoordinatorRequest::AbortProcess { .. } => Self::AbortProcess,
AuthenticatedCoordinatorRequest::ListProcesses => Self::ListProcesses,
AuthenticatedCoordinatorRequest::QuotaStatus => Self::QuotaStatus,
AuthenticatedCoordinatorRequest::RestartTask { .. } => Self::RestartTask,
AuthenticatedCoordinatorRequest::DebugAttach { .. } => Self::DebugAttach,
AuthenticatedCoordinatorRequest::SetDebugBreakpoints { .. } => {
Self::SetDebugBreakpoints
}
AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { .. } => {
Self::InspectDebugBreakpoints
}
AuthenticatedCoordinatorRequest::CreateDebugEpoch { .. } => Self::CreateDebugEpoch,
AuthenticatedCoordinatorRequest::ResumeDebugEpoch { .. } => Self::ResumeDebugEpoch,
AuthenticatedCoordinatorRequest::InspectDebugEpoch { .. } => Self::InspectDebugEpoch,
AuthenticatedCoordinatorRequest::ListTaskEvents { .. } => Self::ListTaskEvents,
AuthenticatedCoordinatorRequest::JoinTask { .. } => Self::JoinTask,
AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink { .. } => {
Self::CreateArtifactDownloadLink
}
AuthenticatedCoordinatorRequest::OpenArtifactDownloadStream { .. } => {
Self::OpenArtifactDownloadStream
}
AuthenticatedCoordinatorRequest::RevokeArtifactDownloadLink { .. } => {
Self::RevokeArtifactDownloadLink
}
AuthenticatedCoordinatorRequest::ExportArtifactToNode { .. } => {
Self::ExportArtifactToNode
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct AuthorizedPublicUser {
pub(super) actor: UserId,
pub(super) operation: PublicUserOperation,
}
pub(super) fn authorize_authenticated_user_operation(
context: &AuthContext,
request: &AuthenticatedCoordinatorRequest,
) -> Result<AuthorizedPublicUser, CoordinatorServiceError> {
let operation = PublicUserOperation::from(request);
let actor = match &context.actor {
Actor::User(user) => user.clone(),
_ => {
return Err(CoordinatorError::Unauthorized(format!(
"authenticated {} request requires a user CLI session",
operation.as_str()
))
.into());
}
};
Ok(AuthorizedPublicUser { actor, operation })
}
#[cfg(test)]
mod tests {
use disasmer_core::{AgentId, ProjectId, TenantId};
use super::*;
#[test]
fn authenticated_public_authorization_requires_user_context_and_names_operation() {
let request = AuthenticatedCoordinatorRequest::DebugAttach {
process: "vp".to_owned(),
};
let agent_context = AuthContext {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
actor: Actor::Agent(AgentId::from("agent-ci")),
};
let denied = authorize_authenticated_user_operation(&agent_context, &request).unwrap_err();
assert!(denied
.to_string()
.contains("authenticated debug_attach request requires a user CLI session"));
let user_context = AuthContext {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
actor: Actor::User(UserId::from("user")),
};
let authorized = authorize_authenticated_user_operation(&user_context, &request).unwrap();
assert_eq!(authorized.actor, UserId::from("user"));
assert_eq!(authorized.operation, PublicUserOperation::DebugAttach);
}
}

View file

@ -0,0 +1,995 @@
use std::collections::{BTreeMap, BTreeSet};
use disasmer_core::{
Actor, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, UserId,
WasmHostDebugProbeResult, WASM_TASK_ABI_VERSION,
};
use crate::{CoordinatorError, CoordinatorServiceError};
use super::keys::{process_control_key, task_control_key, task_restart_key, TaskControlKey};
use super::{
CoordinatorResponse, CoordinatorService, DebugAcknowledgementState, DebugAuditEvent,
DebugParticipantAcknowledgement, TaskCancellationTarget, DEBUG_CONTROL_READ_BYTES,
};
mod validation;
use validation::{runtime_all_in_state, validate_debug_snapshot, validate_probe_symbols};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct DebugPendingCommand {
pub(super) epoch: u64,
pub(super) command: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct DebugEpochRuntime {
pub(super) epoch: u64,
pub(super) command: String,
pub(super) expected: BTreeSet<TaskControlKey>,
pub(super) acknowledgements: BTreeMap<TaskControlKey, DebugParticipantAcknowledgement>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct DebugBreakpointPlan {
pub(super) actor: UserId,
pub(super) probe_symbols: BTreeSet<String>,
pub(super) hit_epoch: Option<u64>,
pub(super) hit_task: Option<TaskInstanceId>,
pub(super) hit_probe_symbol: Option<String>,
}
impl CoordinatorService {
pub(super) fn handle_debug_attach(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let process = ProcessId::new(process);
let context = disasmer_core::AuthContext {
tenant: tenant.clone(),
project: project.clone(),
actor: Actor::User(actor.clone()),
};
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
let audit_event = self.record_debug_audit_event(
tenant,
project,
process.clone(),
None,
actor.clone(),
"debug_attach",
authorization.allowed,
authorization.reason.clone(),
)?;
Ok(CoordinatorResponse::DebugAttach {
process,
actor,
authorization,
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
used_debug_read_bytes: audit_event.used_debug_read_bytes,
audit_event,
})
}
pub(super) fn handle_set_debug_breakpoints(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
probe_symbols: Vec<String>,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let probe_symbols = validate_probe_symbols(probe_symbols)?;
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let process = ProcessId::new(process);
let context = disasmer_core::AuthContext {
tenant: tenant.clone(),
project: project.clone(),
actor: Actor::User(actor.clone()),
};
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
let audit_event = self.record_debug_audit_event(
tenant.clone(),
project.clone(),
process.clone(),
None,
actor.clone(),
"set_debug_breakpoints",
authorization.allowed,
authorization.reason.clone(),
)?;
if !authorization.allowed {
return Err(CoordinatorError::Unauthorized(format!(
"set_debug_breakpoints denied: {}",
authorization.reason
))
.into());
}
self.debug_breakpoints.insert(
process_control_key(&tenant, &project, &process),
DebugBreakpointPlan {
actor: actor.clone(),
probe_symbols: probe_symbols.iter().cloned().collect(),
hit_epoch: None,
hit_task: None,
hit_probe_symbol: None,
},
);
Ok(CoordinatorResponse::DebugBreakpoints {
process,
actor,
probe_symbols,
hit_epoch: None,
hit_task: None,
hit_probe_symbol: None,
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
used_debug_read_bytes: audit_event.used_debug_read_bytes,
audit_event,
})
}
pub(super) fn handle_inspect_debug_breakpoints(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let process = ProcessId::new(process);
let context = disasmer_core::AuthContext {
tenant: tenant.clone(),
project: project.clone(),
actor: Actor::User(actor.clone()),
};
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
let plan = self
.debug_breakpoints
.get(&process_control_key(&tenant, &project, &process))
.cloned()
.ok_or_else(|| {
CoordinatorServiceError::Protocol(format!(
"no debug breakpoints are configured for {process}"
))
})?;
if plan.actor != actor {
return Err(CoordinatorError::Unauthorized(
"debug breakpoint inspection belongs to another authenticated user".to_owned(),
)
.into());
}
let audit_event = self.record_debug_audit_event(
tenant,
project,
process.clone(),
plan.hit_task.clone(),
actor.clone(),
"inspect_debug_breakpoints",
authorization.allowed,
authorization.reason.clone(),
)?;
if !authorization.allowed {
return Err(CoordinatorError::Unauthorized(format!(
"inspect_debug_breakpoints denied: {}",
authorization.reason
))
.into());
}
Ok(CoordinatorResponse::DebugBreakpoints {
process,
actor,
probe_symbols: plan.probe_symbols.into_iter().collect(),
hit_epoch: plan.hit_epoch,
hit_task: plan.hit_task,
hit_probe_symbol: plan.hit_probe_symbol,
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
used_debug_read_bytes: audit_event.used_debug_read_bytes,
audit_event,
})
}
pub(super) fn handle_create_debug_epoch(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
stopped_task: String,
reason: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
self.handle_debug_epoch_control(
tenant,
project,
actor_user,
process,
Some(stopped_task),
None,
"create_debug_epoch",
"freeze",
true,
reason,
)
}
pub(super) fn handle_resume_debug_epoch(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
epoch: u64,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
self.handle_debug_epoch_control(
tenant,
project,
actor_user,
process,
None,
Some(epoch),
"resume_debug_epoch",
"resume",
false,
"continue requested by debugger",
)
}
pub(super) fn handle_poll_debug_command(
&mut self,
tenant: String,
project: String,
process: String,
node: String,
task: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let node = NodeId::new(node);
let task = TaskInstanceId::new(task);
self.coordinator
.authorize_node_for_process(&node, &tenant, &project, &process)?;
let pending = self
.debug_commands
.remove(&task_control_key(&tenant, &project, &process, &node, &task));
Ok(CoordinatorResponse::DebugCommand {
process,
task,
epoch: pending.as_ref().map(|command| command.epoch),
command: pending.map(|command| command.command),
})
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_report_debug_state(
&mut self,
tenant: String,
project: String,
process: String,
node: String,
task: String,
epoch: u64,
state: DebugAcknowledgementState,
stack_frames: Vec<String>,
local_values: Vec<(String, String)>,
task_args: Vec<(String, String)>,
handles: Vec<(String, String)>,
command_status: Option<String>,
recent_output: Vec<String>,
message: Option<String>,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
validate_debug_snapshot(
&stack_frames,
&local_values,
&task_args,
&handles,
command_status.as_deref(),
&recent_output,
message.as_deref(),
)?;
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let node = NodeId::new(node);
let task = TaskInstanceId::new(task);
self.coordinator
.authorize_node_for_process(&node, &tenant, &project, &process)?;
let participant_key = task_control_key(&tenant, &project, &process, &node, &task);
let process_key = process_control_key(&tenant, &project, &process);
let runtime = self
.debug_epoch_runtime
.get_mut(&process_key)
.ok_or_else(|| {
CoordinatorServiceError::Protocol(format!(
"cannot acknowledge debug epoch {epoch} for {process}: no active debug epoch"
))
})?;
if runtime.epoch != epoch {
return Err(CoordinatorServiceError::Protocol(format!(
"cannot acknowledge debug epoch {epoch} for {process}: current debug epoch is {}",
runtime.epoch
)));
}
if !runtime.expected.contains(&participant_key) {
return Err(CoordinatorError::Unauthorized(
"debug acknowledgement is not from an expected active task participant".to_owned(),
)
.into());
}
let task_definition = self
.task_restart_checkpoints
.get(&task_restart_key(&tenant, &project, &process, &task))
.map(|checkpoint| checkpoint.assignment.task_spec.task_definition.clone())
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"debug acknowledgement does not name a coordinator-issued task instance"
.to_owned(),
)
})?;
let valid_state = matches!(
(runtime.command.as_str(), &state),
("freeze", DebugAcknowledgementState::Frozen)
| ("resume", DebugAcknowledgementState::Running)
| (_, DebugAcknowledgementState::Failed)
);
if !valid_state {
return Err(CoordinatorServiceError::Protocol(format!(
"debug epoch {epoch} command `{}` cannot be acknowledged as {state:?}",
runtime.command
)));
}
runtime.acknowledgements.insert(
participant_key,
DebugParticipantAcknowledgement {
node: node.clone(),
task_definition,
task: task.clone(),
epoch,
state: state.clone(),
stack_frames,
local_values,
task_args,
handles,
command_status,
recent_output,
message,
},
);
Ok(CoordinatorResponse::DebugStateRecorded {
process,
node,
task,
epoch,
state,
})
}
pub(super) fn handle_report_debug_probe_hit(
&mut self,
tenant: String,
project: String,
process: String,
node: String,
task: String,
probe_symbol: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let probe_symbol = validate_probe_symbols(vec![probe_symbol])?
.into_iter()
.next()
.expect("one validated probe symbol remains one symbol");
let tenant_id = TenantId::new(tenant.clone());
let project_id = ProjectId::new(project.clone());
let process_id = ProcessId::new(process.clone());
let node_id = NodeId::new(node.clone());
let task_id = TaskInstanceId::new(task.clone());
self.coordinator.authorize_node_for_process(
&node_id,
&tenant_id,
&project_id,
&process_id,
)?;
let participant_key =
task_control_key(&tenant_id, &project_id, &process_id, &node_id, &task_id);
if !self.active_tasks.contains(&participant_key) {
return Err(CoordinatorError::Unauthorized(
"debug probe hit is not from an active task participant".to_owned(),
)
.into());
}
let process_key = process_control_key(&tenant_id, &project_id, &process_id);
let Some(plan) = self.debug_breakpoints.get(&process_key).cloned() else {
return Ok(CoordinatorResponse::DebugProbeHit {
process: process_id,
node: node_id,
task: task_id,
probe_symbol,
breakpoint_matched: false,
debug_epoch: None,
});
};
if !plan.probe_symbols.contains(&probe_symbol) {
return Ok(CoordinatorResponse::DebugProbeHit {
process: process_id,
node: node_id,
task: task_id,
probe_symbol,
breakpoint_matched: false,
debug_epoch: None,
});
}
let response = self.handle_debug_epoch_control(
tenant,
project,
plan.actor.as_str().to_owned(),
process,
Some(task.clone()),
None,
"wasm_debug_probe_hit",
"freeze",
true,
format!("executing Wasm reached probe `{probe_symbol}`"),
)?;
let CoordinatorResponse::DebugEpoch { epoch, .. } = response else {
unreachable!("debug epoch control always returns DebugEpoch")
};
if let Some(plan) = self.debug_breakpoints.get_mut(&process_key) {
plan.hit_epoch = Some(epoch);
plan.hit_task = Some(task_id.clone());
plan.hit_probe_symbol = Some(probe_symbol.clone());
}
Ok(CoordinatorResponse::DebugProbeHit {
process: process_id,
node: node_id,
task: task_id,
probe_symbol,
breakpoint_matched: true,
debug_epoch: Some(epoch),
})
}
pub(super) fn handle_coordinator_main_debug_probe(
&mut self,
tenant: TenantId,
project: ProjectId,
process: ProcessId,
task: TaskInstanceId,
probe_symbol: String,
) -> Result<WasmHostDebugProbeResult, CoordinatorServiceError> {
let probe_symbol = validate_probe_symbols(vec![probe_symbol])?
.into_iter()
.next()
.expect("one validated probe symbol remains one symbol");
let process_key = process_control_key(&tenant, &project, &process);
let main_matches = self
.main_runtime
.controls
.get(&process_key)
.is_some_and(|control| control.task_instance == task);
if !main_matches {
return Err(CoordinatorError::Unauthorized(
"debug probe does not belong to the active coordinator main participant".to_owned(),
)
.into());
}
let Some(plan) = self.debug_breakpoints.get(&process_key).cloned() else {
return Ok(WasmHostDebugProbeResult {
abi_version: WASM_TASK_ABI_VERSION,
breakpoint_matched: false,
debug_epoch: None,
});
};
if !plan.probe_symbols.contains(&probe_symbol) {
return Ok(WasmHostDebugProbeResult {
abi_version: WASM_TASK_ABI_VERSION,
breakpoint_matched: false,
debug_epoch: None,
});
}
if let Some(control) = self.main_runtime.controls.get_mut(&process_key) {
control.stopped_probe_symbol = Some(probe_symbol.clone());
}
let response = self.handle_debug_epoch_control(
tenant.as_str().to_owned(),
project.as_str().to_owned(),
plan.actor.as_str().to_owned(),
process.as_str().to_owned(),
Some(task.as_str().to_owned()),
None,
"wasm_debug_probe_hit",
"freeze",
true,
format!("coordinator main reached probe `{probe_symbol}`"),
)?;
let CoordinatorResponse::DebugEpoch { epoch, .. } = response else {
unreachable!("debug epoch control always returns DebugEpoch")
};
if let Some(plan) = self.debug_breakpoints.get_mut(&process_key) {
plan.hit_epoch = Some(epoch);
plan.hit_task = Some(task);
plan.hit_probe_symbol = Some(probe_symbol);
}
Ok(WasmHostDebugProbeResult {
abi_version: WASM_TASK_ABI_VERSION,
breakpoint_matched: true,
debug_epoch: Some(epoch),
})
}
pub(super) fn handle_inspect_debug_epoch(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
epoch: u64,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let process = ProcessId::new(process);
let context = disasmer_core::AuthContext {
tenant: tenant.clone(),
project: project.clone(),
actor: Actor::User(actor.clone()),
};
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
let process_key = process_control_key(&tenant, &project, &process);
self.sync_coordinator_main_debug_acknowledgement(&process_key, epoch);
let runtime = self
.debug_epoch_runtime
.get(&process_key)
.filter(|runtime| runtime.epoch == epoch)
.cloned()
.ok_or_else(|| {
CoordinatorServiceError::Protocol(format!(
"debug epoch {epoch} is not active for {process}"
))
})?;
let audit_event = self.record_debug_audit_event(
tenant,
project,
process.clone(),
None,
actor.clone(),
"inspect_debug_epoch",
authorization.allowed,
authorization.reason.clone(),
)?;
if !authorization.allowed {
return Err(CoordinatorError::Unauthorized(format!(
"inspect_debug_epoch denied: {}",
authorization.reason
))
.into());
}
let acknowledgements = runtime
.acknowledgements
.values()
.cloned()
.collect::<Vec<_>>();
let all_acknowledged = !runtime.expected.is_empty()
&& runtime
.expected
.iter()
.all(|key| runtime.acknowledgements.contains_key(key));
let fully_frozen = runtime.command == "freeze"
&& all_acknowledged
&& acknowledgements
.iter()
.all(|ack| ack.state == DebugAcknowledgementState::Frozen);
let fully_resumed = runtime.command == "resume"
&& all_acknowledged
&& acknowledgements
.iter()
.all(|ack| ack.state == DebugAcknowledgementState::Running);
let failed = acknowledgements
.iter()
.any(|ack| ack.state == DebugAcknowledgementState::Failed);
let failure_messages = acknowledgements
.iter()
.filter(|ack| ack.state == DebugAcknowledgementState::Failed)
.map(|ack| {
ack.message
.clone()
.unwrap_or_else(|| format!("task {} failed debug control", ack.task))
})
.collect();
let expected_tasks = runtime
.expected
.into_iter()
.map(|(_, _, process, node, task)| TaskCancellationTarget {
process,
node,
task,
})
.collect();
Ok(CoordinatorResponse::DebugEpochStatus {
process,
actor,
epoch,
command: runtime.command,
expected_tasks,
acknowledgements,
fully_frozen,
fully_resumed,
failed,
failure_messages,
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
used_debug_read_bytes: audit_event.used_debug_read_bytes,
audit_event,
})
}
pub(super) fn clear_debug_state_for_process(
&mut self,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
) {
self.debug_epochs
.remove(&process_control_key(tenant, project, process));
self.debug_epoch_runtime
.remove(&process_control_key(tenant, project, process));
self.debug_breakpoints
.remove(&process_control_key(tenant, project, process));
self.debug_commands
.retain(|(task_tenant, task_project, task_process, _, _), _| {
task_tenant != tenant || task_project != project || task_process != process
});
}
fn handle_debug_epoch_control(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
stopped_task: Option<String>,
expected_epoch: Option<u64>,
operation: &'static str,
command: &'static str,
all_stop_requested: bool,
reason: impl Into<String>,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let process = ProcessId::new(process);
let context = disasmer_core::AuthContext {
tenant: tenant.clone(),
project: project.clone(),
actor: Actor::User(actor.clone()),
};
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
let reason = reason.into();
let audit_event = self.record_debug_audit_event(
tenant.clone(),
project.clone(),
process.clone(),
stopped_task.as_ref().map(TaskInstanceId::new),
actor.clone(),
operation,
authorization.allowed,
if authorization.allowed {
reason
} else {
authorization.reason.clone()
},
)?;
if !authorization.allowed {
return Err(CoordinatorError::Unauthorized(format!(
"{operation} denied: {}",
authorization.reason
))
.into());
}
let process_key = process_control_key(&tenant, &project, &process);
let epoch = match expected_epoch {
Some(expected) => {
let current = self
.debug_epochs
.get(&process_key)
.copied()
.ok_or_else(|| {
CoordinatorServiceError::Protocol(format!(
"cannot resume debug epoch {expected} for {process}: no active debug epoch"
))
})?;
if current != expected {
return Err(CoordinatorServiceError::Protocol(format!(
"cannot resume debug epoch {expected} for {process}: current debug epoch is {current}"
)));
}
let runtime = self.debug_epoch_runtime.get(&process_key).ok_or_else(|| {
CoordinatorServiceError::Protocol(format!(
"cannot resume debug epoch {expected} for {process}: participant state is unavailable"
))
})?;
if runtime.command != "freeze"
|| !runtime_all_in_state(runtime, DebugAcknowledgementState::Frozen)
{
return Err(CoordinatorServiceError::Protocol(format!(
"cannot resume debug epoch {expected} for {process}: all expected participants have not acknowledged frozen state"
)));
}
current
}
None => {
if let Some(runtime) = self.debug_epoch_runtime.get(&process_key) {
let previous_complete = runtime.command == "resume"
&& runtime_all_in_state(runtime, DebugAcknowledgementState::Running);
if !previous_complete {
return Err(CoordinatorServiceError::Protocol(format!(
"cannot create a new debug epoch for {process}: epoch {} has not fully resumed",
runtime.epoch
)));
}
}
let next = self.debug_epochs.get(&process_key).copied().unwrap_or(0) + 1;
self.debug_epochs.insert(process_key.clone(), next);
next
}
};
let mut affected_tasks = self
.enqueue_debug_command_for_active_tasks(&tenant, &project, &process, epoch, command);
let mut expected = self
.active_tasks
.iter()
.filter(|(task_tenant, task_project, task_process, _, _)| {
task_tenant == &tenant && task_project == &project && task_process == &process
})
.cloned()
.collect::<BTreeSet<_>>();
if let Some(control) = self
.main_runtime
.controls
.get(&process_key)
.filter(|control| matches!(control.state.as_str(), "running" | "stopping"))
{
let key = task_control_key(
&tenant,
&project,
&process,
&NodeId::from("coordinator-main"),
&control.task_instance,
);
expected.insert(key);
affected_tasks.push(TaskCancellationTarget {
process: process.clone(),
node: NodeId::from("coordinator-main"),
task: control.task_instance.clone(),
});
}
self.debug_epoch_runtime.insert(
process_key.clone(),
DebugEpochRuntime {
epoch,
command: command.to_owned(),
expected,
acknowledgements: BTreeMap::new(),
},
);
if let Some(control) = self
.main_runtime
.controls
.get(&process_key)
.filter(|control| matches!(control.state.as_str(), "running" | "stopping"))
{
if command == "freeze" {
control.debug.request_freeze(epoch);
} else {
control.debug.request_resume(epoch);
}
}
self.sync_coordinator_main_debug_acknowledgement(&process_key, epoch);
Ok(CoordinatorResponse::DebugEpoch {
process,
actor,
epoch,
command: command.to_owned(),
affected_tasks,
all_stop_requested,
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
used_debug_read_bytes: audit_event.used_debug_read_bytes,
audit_event,
})
}
fn sync_coordinator_main_debug_acknowledgement(
&mut self,
process_key: &super::keys::ProcessControlKey,
epoch: u64,
) {
let Some(control) = self.main_runtime.controls.get(process_key) else {
return;
};
let Some(runtime) = self.debug_epoch_runtime.get_mut(process_key) else {
return;
};
if runtime.epoch != epoch {
return;
}
let state = if runtime.command == "freeze" && control.debug.frozen_epoch() == Some(epoch) {
Some(DebugAcknowledgementState::Frozen)
} else if runtime.command == "resume"
&& control.debug.resume_requested(epoch)
&& control.debug.frozen_epoch() != Some(epoch)
{
Some(DebugAcknowledgementState::Running)
} else {
None
};
let Some(state) = state else {
return;
};
let handles = control
.handles
.lock()
.map(|handles| {
let mut snapshot = handles
.iter()
.map(|(handle_id, spec)| {
(
format!("task_handle_{handle_id}"),
format!(
"definition={} instance={} state=active",
spec.task_definition, spec.task_instance
),
)
})
.collect::<Vec<_>>();
snapshot.sort_by(|left, right| left.0.cmp(&right.0));
snapshot
})
.unwrap_or_else(|_| {
vec![(
"handle-registry-diagnostic".to_owned(),
"coordinator main handle registry was unavailable".to_owned(),
)]
});
let node = NodeId::from("coordinator-main");
let key = task_control_key(
&process_key.0,
&process_key.1,
&process_key.2,
&node,
&control.task_instance,
);
if !runtime.expected.contains(&key) {
return;
}
runtime.acknowledgements.insert(
key,
DebugParticipantAcknowledgement {
node,
task_definition: control.task_definition.clone(),
task: control.task_instance.clone(),
epoch,
state,
stack_frames: vec![control
.stopped_probe_symbol
.as_deref()
.and_then(|symbol| symbol.strip_prefix("disasmer.probe."))
.map(|function| format!("{function}::wasm"))
.unwrap_or_else(|| {
format!("coordinator_main::wasm ({})", control.task_definition)
})],
local_values: Vec::new(),
task_args: Vec::new(),
handles,
command_status: None,
recent_output: vec![
"coordinator-hosted capless main acknowledged all-stop control".to_owned(),
],
message: None,
},
);
}
fn enqueue_debug_command_for_active_tasks(
&mut self,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
epoch: u64,
command: &str,
) -> Vec<TaskCancellationTarget> {
let task_keys = self
.active_tasks
.iter()
.filter(|(task_tenant, task_project, task_process, _, _)| {
task_tenant == tenant && task_project == project && task_process == process
})
.cloned()
.collect::<Vec<_>>();
for key in &task_keys {
self.debug_commands.insert(
key.clone(),
DebugPendingCommand {
epoch,
command: command.to_owned(),
},
);
}
task_keys
.into_iter()
.map(|(_, _, process, node, task)| TaskCancellationTarget {
process,
task,
node,
})
.collect()
}
pub(super) fn record_debug_audit_event(
&mut self,
tenant: TenantId,
project: ProjectId,
process: ProcessId,
task: Option<TaskInstanceId>,
actor: UserId,
operation: &'static str,
allowed: bool,
reason: impl Into<String>,
) -> Result<DebugAuditEvent, CoordinatorServiceError> {
let now_epoch_seconds = self.current_epoch_seconds()?;
let charged_debug_read_bytes = if allowed {
self.quota.charge_debug_read(
&tenant,
&project,
DEBUG_CONTROL_READ_BYTES,
now_epoch_seconds,
)?;
DEBUG_CONTROL_READ_BYTES
} else {
0
};
let used_debug_read_bytes =
self.quota
.used_debug_read_bytes(&tenant, &project, now_epoch_seconds);
let event = DebugAuditEvent {
tenant,
project,
process,
task,
actor,
operation: operation.to_owned(),
allowed,
reason: reason.into(),
charged_debug_read_bytes,
used_debug_read_bytes,
};
while self
.debug_audit_events
.iter()
.filter(|retained| {
retained.tenant == event.tenant
&& retained.project == event.project
&& retained.process == event.process
})
.count()
>= super::MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS
{
let Some(index) = self.debug_audit_events.iter().position(|retained| {
retained.tenant == event.tenant
&& retained.project == event.project
&& retained.process == event.process
}) else {
break;
};
self.debug_audit_events.remove(index);
}
self.debug_audit_events.push_back(event.clone());
Ok(event)
}
}

View file

@ -0,0 +1,92 @@
use std::collections::BTreeSet;
use crate::CoordinatorServiceError;
use super::{DebugAcknowledgementState, DebugEpochRuntime};
pub(super) fn runtime_all_in_state(
runtime: &DebugEpochRuntime,
state: DebugAcknowledgementState,
) -> bool {
!runtime.expected.is_empty()
&& runtime.expected.iter().all(|key| {
runtime
.acknowledgements
.get(key)
.is_some_and(|ack| ack.state == state)
})
}
pub(super) fn validate_probe_symbols(
probe_symbols: Vec<String>,
) -> Result<Vec<String>, CoordinatorServiceError> {
if probe_symbols.len() > 128 {
return Err(CoordinatorServiceError::Protocol(
"debug breakpoint request exceeds 128 probe symbols".to_owned(),
));
}
let mut unique = BTreeSet::new();
for symbol in probe_symbols {
if symbol.trim().is_empty()
|| symbol.len() > 256
|| !symbol
.chars()
.all(|character| character.is_ascii_alphanumeric() || "._:-/".contains(character))
{
return Err(CoordinatorServiceError::Protocol(
"debug breakpoint probe symbol is invalid".to_owned(),
));
}
unique.insert(symbol);
}
Ok(unique.into_iter().collect())
}
pub(super) fn validate_debug_snapshot(
stack_frames: &[String],
local_values: &[(String, String)],
task_args: &[(String, String)],
handles: &[(String, String)],
command_status: Option<&str>,
recent_output: &[String],
message: Option<&str>,
) -> Result<(), CoordinatorServiceError> {
const MAX_ITEMS: usize = 128;
const MAX_TEXT_BYTES: usize = 16 * 1024;
if [
stack_frames.len(),
local_values.len(),
task_args.len(),
handles.len(),
recent_output.len(),
]
.into_iter()
.any(|count| count > MAX_ITEMS)
{
return Err(CoordinatorServiceError::Protocol(
"debug participant snapshot exceeds the 128-item field limit".to_owned(),
));
}
let text_bytes = stack_frames.iter().map(String::len).sum::<usize>()
+ local_values
.iter()
.map(|(name, value)| name.len() + value.len())
.sum::<usize>()
+ task_args
.iter()
.map(|(name, value)| name.len() + value.len())
.sum::<usize>()
+ handles
.iter()
.map(|(name, value)| name.len() + value.len())
.sum::<usize>()
+ recent_output.iter().map(String::len).sum::<usize>()
+ command_status.map(str::len).unwrap_or(0)
+ message.map(str::len).unwrap_or(0);
if text_bytes > MAX_TEXT_BYTES {
return Err(CoordinatorServiceError::Protocol(format!(
"debug participant snapshot is {text_bytes} bytes; maximum is {MAX_TEXT_BYTES}"
)));
}
Ok(())
}

View file

@ -0,0 +1,470 @@
use std::collections::BTreeSet;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use disasmer_core::{
Actor, Capability, CredentialKind, Digest, EnvironmentResource, ProcessId, ProjectId,
RestartDecision, RestartPolicy, RestartRequest, TaskDispatch, TaskInstanceId, TenantId, UserId,
WasmExportAbi,
};
use crate::{CoordinatorError, CoordinatorServiceError};
use super::keys::task_restart_key;
use super::{
AuthenticatedCoordinatorRequest, CoordinatorRequest, CoordinatorResponse, CoordinatorService,
TaskReplacementBundle, WorkflowActor,
};
impl CoordinatorService {
pub(super) fn handle_debug_request(
&mut self,
request: CoordinatorRequest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
match request {
CoordinatorRequest::DebugAttach {
tenant,
project,
actor_user,
process,
} => self.handle_debug_attach(tenant, project, actor_user, process),
CoordinatorRequest::SetDebugBreakpoints {
tenant,
project,
actor_user,
process,
probe_symbols,
} => self.handle_set_debug_breakpoints(
tenant,
project,
actor_user,
process,
probe_symbols,
),
CoordinatorRequest::InspectDebugBreakpoints {
tenant,
project,
actor_user,
process,
} => self.handle_inspect_debug_breakpoints(tenant, project, actor_user, process),
CoordinatorRequest::CreateDebugEpoch {
tenant,
project,
actor_user,
process,
stopped_task,
reason,
} => self.handle_create_debug_epoch(
tenant,
project,
actor_user,
process,
stopped_task,
reason,
),
CoordinatorRequest::ResumeDebugEpoch {
tenant,
project,
actor_user,
process,
epoch,
} => self.handle_resume_debug_epoch(tenant, project, actor_user, process, epoch),
CoordinatorRequest::InspectDebugEpoch {
tenant,
project,
actor_user,
process,
epoch,
} => self.handle_inspect_debug_epoch(tenant, project, actor_user, process, epoch),
_ => unreachable!("handle_debug_request only accepts debug coordinator requests"),
}
}
pub(super) fn handle_authenticated_debug_request(
&mut self,
tenant: &TenantId,
project: &ProjectId,
actor: &UserId,
request: AuthenticatedCoordinatorRequest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
match request {
AuthenticatedCoordinatorRequest::DebugAttach { process } => self.handle_debug_attach(
tenant.as_str().to_owned(),
project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
),
AuthenticatedCoordinatorRequest::SetDebugBreakpoints {
process,
probe_symbols,
} => self.handle_set_debug_breakpoints(
tenant.as_str().to_owned(),
project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
probe_symbols,
),
AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { process } => self
.handle_inspect_debug_breakpoints(
tenant.as_str().to_owned(),
project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
),
AuthenticatedCoordinatorRequest::CreateDebugEpoch {
process,
stopped_task,
reason,
} => self.handle_create_debug_epoch(
tenant.as_str().to_owned(),
project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
stopped_task,
reason,
),
AuthenticatedCoordinatorRequest::ResumeDebugEpoch { process, epoch } => self
.handle_resume_debug_epoch(
tenant.as_str().to_owned(),
project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
epoch,
),
AuthenticatedCoordinatorRequest::InspectDebugEpoch { process, epoch } => self
.handle_inspect_debug_epoch(
tenant.as_str().to_owned(),
project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
epoch,
),
_ => unreachable!(
"handle_authenticated_debug_request only accepts debug coordinator requests"
),
}
}
pub(super) fn handle_restart_task(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
task: String,
replacement_bundle: Option<TaskReplacementBundle>,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let process = ProcessId::new(process);
let task = TaskInstanceId::new(task);
let context = disasmer_core::AuthContext {
tenant: tenant.clone(),
project: project.clone(),
actor: Actor::User(actor.clone()),
};
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
if !authorization.allowed {
let _ = self.record_debug_audit_event(
tenant,
project,
process,
Some(task),
actor,
"restart_task",
false,
authorization.reason.clone(),
)?;
return Err(CoordinatorError::Unauthorized(format!(
"task restart denied: {}",
authorization.reason
))
.into());
}
let active_key = self
.active_tasks
.iter()
.find(|(task_tenant, task_project, task_process, _, task_id)| {
task_tenant == &tenant
&& task_project == &project
&& task_process == &process
&& task_id == &task
})
.cloned();
let process_key = super::keys::process_control_key(&tenant, &project, &process);
let active_main = self
.main_runtime
.controls
.get(&process_key)
.is_some_and(|control| {
control.task_instance == task
&& matches!(control.state.as_str(), "running" | "stopping")
});
let active_task = active_key.is_some() || active_main;
let completed_event_observed = self.task_events.iter().any(|event| {
event.tenant == tenant
&& event.project == project
&& event.process == process
&& event.task == task
});
let checkpoint_key = task_restart_key(&tenant, &project, &process, &task);
let checkpoint = self.task_restart_checkpoints.get(&checkpoint_key).cloned();
let mut accepted = false;
let mut restarted_task_instance = None;
let mut clean_boundary_available = false;
let mut requires_whole_process_restart = true;
let message = if active_main {
"selected coordinator main is still active; restart the whole virtual process to rerun its capless entry boundary".to_owned()
} else if active_task {
"selected task is still active; wait for its terminal event or abort the whole virtual process before restarting from its clean entry boundary".to_owned()
} else if !completed_event_observed {
"selected task is not known in the active process; restart the whole virtual process or inspect task list".to_owned()
} else if let Some(checkpoint) = checkpoint {
let vfs_available =
checkpoint
.checkpoint
.vfs_manifest
.objects
.iter()
.all(|(path, object)| {
let artifact = super::keys::artifact_id_from_path(path);
self.artifact_registry
.metadata(&artifact)
.is_some_and(|metadata| {
metadata.tenant == tenant
&& metadata.project == project
&& metadata.digest == object.digest
&& metadata.size == object.size
&& !metadata.retaining_nodes.is_empty()
})
});
if !vfs_available {
"selected task checkpoint references VFS artifacts that are no longer retained; restart the whole virtual process".to_owned()
} else {
let replacement = replacement_bundle
.as_ref()
.map(|bundle| {
validate_task_replacement(
bundle,
&checkpoint.assignment.task_spec.task_definition,
checkpoint.assignment.task_spec.environment_id.as_deref(),
)
})
.transpose()?;
let request = RestartRequest {
task: task.clone(),
entrypoint: replacement.as_ref().map_or_else(
|| checkpoint.checkpoint.boundary.task_entrypoint.clone(),
|replacement| replacement.export.clone(),
),
serialized_args: checkpoint.checkpoint.boundary.serialized_args.clone(),
environment_digest: replacement.as_ref().map_or_else(
|| checkpoint.checkpoint.boundary.environment_digest.clone(),
|replacement| replacement.environment_digest.clone(),
),
task_abi: replacement.as_ref().map_or_else(
|| checkpoint.checkpoint.boundary.task_abi.clone(),
|replacement| replacement.restart_compatibility.clone(),
),
source_edited: replacement.is_some(),
};
match RestartPolicy.decide(&checkpoint.checkpoint, &request) {
RestartDecision::RestartTask { from_vfs_epoch, .. } => {
clean_boundary_available = true;
let mut assignment = checkpoint.assignment;
let new_task_instance = disasmer_core::TaskInstanceId::new(
disasmer_core::generate_opaque_token("ti")
.map_err(CoordinatorServiceError::Protocol)?,
);
assignment.task = new_task_instance.clone();
assignment.task_spec.task_instance = new_task_instance.clone();
assignment.artifact_path =
format!("/vfs/artifacts/{}-result.json", new_task_instance.as_str());
if let Some(replacement) = replacement {
assignment.task_spec.dispatch = TaskDispatch::CoordinatorNodeWasm {
export: Some(replacement.export),
abi: WasmExportAbi::TaskV1,
};
assignment.task_spec.environment = replacement
.environment
.map(|environment| environment.requirements);
assignment.task_spec.environment_digest =
Some(replacement.environment_digest);
assignment.task_spec.required_capabilities =
replacement.required_capabilities;
assignment.task_spec.bundle_digest =
Some(replacement.bundle_digest.clone());
assignment.wasm_module_base64 = replacement.wasm_module_base64;
}
let launch = self.handle_launch_task_with_actor(
tenant.clone(),
project.clone(),
WorkflowActor {
kind: "user".to_owned(),
user: Some(actor.clone()),
agent: None,
credential_kind: CredentialKind::CliDeviceSession,
public_key_fingerprint: None,
authenticated_without_browser: false,
scopes: vec!["process:restart-task".to_owned()],
},
assignment.task_spec,
true,
assignment.artifact_path,
assignment.wasm_module_base64,
)?;
accepted = matches!(
launch,
CoordinatorResponse::TaskLaunched { .. }
| CoordinatorResponse::TaskQueued { .. }
);
if accepted {
restarted_task_instance = Some(new_task_instance.clone());
}
requires_whole_process_restart = !accepted;
format!(
"selected task restarted as new instance {new_task_instance} from clean VFS entry boundary epoch {from_vfs_epoch}; unflushed task changes were discarded"
)
}
RestartDecision::RestartWholeVirtualProcess { message } => message,
}
}
} else {
"selected task has terminal metadata but no captured clean VFS entry boundary; restart the whole virtual process".to_owned()
};
let audit_event = self.record_debug_audit_event(
tenant,
project,
process.clone(),
Some(task.clone()),
actor.clone(),
"restart_task",
true,
&message,
)?;
Ok(CoordinatorResponse::TaskRestart {
process,
task,
restarted_task_instance,
actor,
accepted,
clean_boundary_available,
active_task,
completed_event_observed,
requires_whole_process_restart,
message,
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
used_debug_read_bytes: audit_event.used_debug_read_bytes,
audit_event,
})
}
}
struct ValidatedTaskReplacement {
bundle_digest: Digest,
wasm_module_base64: String,
export: String,
restart_compatibility: Digest,
environment: Option<EnvironmentResource>,
environment_digest: Digest,
required_capabilities: BTreeSet<Capability>,
}
fn validate_task_replacement(
replacement: &TaskReplacementBundle,
task_definition: &disasmer_core::TaskDefinitionId,
environment_id: Option<&str>,
) -> Result<ValidatedTaskReplacement, CoordinatorServiceError> {
let module = BASE64_STANDARD
.decode(&replacement.wasm_module_base64)
.map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"replacement task bundle is not valid base64: {error}"
))
})?;
let actual_digest = Digest::sha256(&module);
if actual_digest != replacement.bundle_digest {
return Err(CoordinatorServiceError::Protocol(format!(
"replacement task bundle digest mismatch: expected {}, actual {actual_digest}",
replacement.bundle_digest
)));
}
let descriptors = super::main_runtime::task_descriptors(&module)?;
let descriptor = descriptors.get(task_definition.as_str()).ok_or_else(|| {
CoordinatorServiceError::Protocol(format!(
"replacement bundle has no task definition `{task_definition}`"
))
})?;
if descriptor
.get("abi_version")
.and_then(serde_json::Value::as_u64)
!= Some(disasmer_core::WASM_TASK_ABI_VERSION as u64)
{
return Err(CoordinatorServiceError::Protocol(format!(
"replacement task `{task_definition}` uses an unsupported task ABI"
)));
}
let export = descriptor
.get("export")
.and_then(serde_json::Value::as_str)
.filter(|export| !export.is_empty())
.ok_or_else(|| {
CoordinatorServiceError::Protocol(format!(
"replacement task `{task_definition}` omitted its export"
))
})?
.to_owned();
let restart_compatibility = serde_json::from_value(
descriptor
.get("restart_compatibility_hash")
.cloned()
.ok_or_else(|| {
CoordinatorServiceError::Protocol(format!(
"replacement task `{task_definition}` omitted restart compatibility metadata"
))
})?,
)?;
let mut required_capabilities = descriptor
.get("required_capabilities")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.map(|capability| {
super::main_runtime::capability_from_descriptor(capability.as_str().ok_or_else(
|| {
CoordinatorServiceError::Protocol(
"replacement task capability is not a string".to_owned(),
)
},
)?)
.map_err(CoordinatorServiceError::Protocol)
})
.collect::<Result<BTreeSet<_>, _>>()?;
let environment = environment_id
.map(|environment_id| {
super::main_runtime::bundle_environments(&module)?
.remove(environment_id)
.ok_or_else(|| {
CoordinatorServiceError::Protocol(format!(
"replacement bundle has no environment `{environment_id}`"
))
})
})
.transpose()?;
if let Some(environment) = &environment {
required_capabilities.extend(environment.requirements.capabilities.iter().cloned());
}
let environment_digest = environment.as_ref().map_or_else(
|| Digest::sha256("disasmer.environment.unconstrained.v1"),
|environment| environment.digest.clone(),
);
Ok(ValidatedTaskReplacement {
bundle_digest: replacement.bundle_digest.clone(),
wasm_module_base64: replacement.wasm_module_base64.clone(),
export,
restart_compatibility,
environment,
environment_digest,
required_capabilities,
})
}

View file

@ -0,0 +1,47 @@
use crate::{
DurableState, DurableStore, FallibleDurableStore, InMemoryDurableStore, PostgresDurableStore,
};
pub(super) enum RuntimeDurableStore {
InMemory(InMemoryDurableStore),
Postgres(PostgresDurableStore),
}
impl RuntimeDurableStore {
pub(super) fn from_database_url(database_url: Option<&str>) -> Result<Self, String> {
match database_url.map(str::trim).filter(|url| !url.is_empty()) {
Some(url) => PostgresDurableStore::connect(url)
.map(Self::Postgres)
.map_err(|error| error.to_string()),
None => Ok(Self::InMemory(InMemoryDurableStore::default())),
}
}
pub(super) fn kind(&self) -> &'static str {
match self {
Self::InMemory(_) => "in_memory",
Self::Postgres(_) => "postgres",
}
}
}
impl FallibleDurableStore for RuntimeDurableStore {
type Error = String;
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
match self {
Self::InMemory(store) => Ok(store.load()),
Self::Postgres(store) => store.load_state().map_err(|error| error.to_string()),
}
}
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
match self {
Self::InMemory(store) => {
store.save(state.clone());
Ok(())
}
Self::Postgres(store) => store.save_state(state).map_err(|error| error.to_string()),
}
}
}

View file

@ -0,0 +1,71 @@
use disasmer_core::{ArtifactId, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, VfsPath};
pub(super) type TaskControlKey = (TenantId, ProjectId, ProcessId, NodeId, TaskInstanceId);
pub(super) type TaskRestartKey = (TenantId, ProjectId, ProcessId, TaskInstanceId);
pub(super) type TaskAssignmentKey = (TenantId, ProjectId, NodeId);
pub(super) type PanelStopKey = (TenantId, ProjectId, ProcessId);
pub(super) type EnrollmentGrantKey = (TenantId, ProjectId, String);
pub(super) type ProcessControlKey = (TenantId, ProjectId, ProcessId);
pub(super) fn task_control_key(
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
node: &NodeId,
task: &TaskInstanceId,
) -> TaskControlKey {
(
tenant.clone(),
project.clone(),
process.clone(),
node.clone(),
task.clone(),
)
}
pub(super) fn task_restart_key(
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
task: &TaskInstanceId,
) -> TaskRestartKey {
(
tenant.clone(),
project.clone(),
process.clone(),
task.clone(),
)
}
pub(super) fn process_control_key(
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
) -> ProcessControlKey {
(tenant.clone(), project.clone(), process.clone())
}
pub(super) fn panel_stop_key(
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
) -> PanelStopKey {
(tenant.clone(), project.clone(), process.clone())
}
pub(super) fn enrollment_grant_key(
tenant: &TenantId,
project: &ProjectId,
grant: &str,
) -> EnrollmentGrantKey {
(tenant.clone(), project.clone(), grant.to_owned())
}
pub(super) fn artifact_id_from_path(path: &VfsPath) -> ArtifactId {
let value = path
.as_str()
.strip_prefix("/vfs/artifacts/")
.unwrap_or(path.as_str())
.replace('/', ":");
ArtifactId::new(value)
}

View file

@ -0,0 +1,535 @@
use disasmer_core::{
ArtifactFlush, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskBoundaryValue,
TaskInstanceId, TaskJoinResult, TaskJoinState, TenantId, UserId, VfsPath,
};
use crate::CoordinatorError;
use super::keys::{process_control_key, task_control_key};
use super::{
artifact_id_from_path, CoordinatorResponse, CoordinatorService, CoordinatorServiceError,
TaskCompletionEvent, TaskTerminalState, MAX_TASK_LOG_TAIL_BYTES,
};
impl CoordinatorService {
pub(super) fn handle_report_task_log(
&mut self,
tenant: String,
project: String,
process: String,
node: String,
task: String,
stdout_bytes: u64,
stderr_bytes: u64,
stdout_tail: String,
stderr_tail: String,
stdout_truncated: bool,
stderr_truncated: bool,
backpressured: bool,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let node = NodeId::new(node);
let task = TaskInstanceId::new(task);
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
validate_task_log_tail("stdout_tail", &stdout_tail)?;
validate_task_log_tail("stderr_tail", &stderr_tail)?;
let reported_bytes = checked_reported_log_bytes(stdout_bytes, stderr_bytes)?;
let now_epoch_seconds = self.current_epoch_seconds()?;
self.quota
.can_charge_log_bytes(&tenant, &project, reported_bytes, now_epoch_seconds)?;
self.quota
.charge_log_bytes(&tenant, &project, reported_bytes, now_epoch_seconds)?;
Ok(CoordinatorResponse::TaskLogRecorded {
process,
task,
stdout_bytes,
stderr_bytes,
stdout_tail: if stdout_truncated {
format!("{stdout_tail}\n... truncated")
} else {
stdout_tail
},
stderr_tail: if stderr_truncated {
format!("{stderr_tail}\n... truncated")
} else {
stderr_tail
},
backpressured,
})
}
pub(super) fn handle_report_vfs_metadata(
&mut self,
tenant: String,
project: String,
process: String,
node: String,
task: String,
artifact_path: Option<String>,
artifact_digest: Option<Digest>,
artifact_size_bytes: Option<u64>,
large_bytes_uploaded: bool,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let artifact_path = artifact_path
.map(VfsPath::new)
.transpose()
.map_err(|err| CoordinatorServiceError::InvalidArtifactPath(err.to_string()))?;
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let node = NodeId::new(node);
let task = TaskInstanceId::new(task);
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
if let (Some(path), Some(digest)) = (&artifact_path, artifact_digest) {
self.flush_artifact_metadata(ArtifactFlush {
id: artifact_id_from_path(path),
tenant,
project,
process: process.clone(),
producer_task: task.clone(),
retaining_node: node,
digest,
size: artifact_size_bytes.unwrap_or_default(),
})?;
}
Ok(CoordinatorResponse::VfsMetadataRecorded {
process,
task,
artifact_path,
large_bytes_uploaded,
})
}
pub(super) fn handle_task_completed(
&mut self,
tenant: String,
project: String,
process: String,
node: String,
task: String,
terminal_state: Option<TaskTerminalState>,
status_code: Option<i32>,
stdout_bytes: u64,
stderr_bytes: u64,
stdout_tail: String,
stderr_tail: String,
stdout_truncated: bool,
stderr_truncated: bool,
artifact_path: Option<String>,
artifact_digest: Option<Digest>,
artifact_size_bytes: Option<u64>,
result: Option<TaskBoundaryValue>,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
validate_task_log_tail("stdout_tail", &stdout_tail)?;
validate_task_log_tail("stderr_tail", &stderr_tail)?;
let artifact_path = artifact_path
.map(VfsPath::new)
.transpose()
.map_err(|err| CoordinatorServiceError::InvalidArtifactPath(err.to_string()))?;
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let node = NodeId::new(node);
let task = TaskInstanceId::new(task);
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
let checkpoint = self
.task_restart_checkpoints
.get(&super::keys::task_restart_key(
&tenant, &project, &process, &task,
))
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"signed node task completion does not name a coordinator-issued task instance"
.to_owned(),
)
})?;
if checkpoint.assignment.node != node {
return Err(CoordinatorError::Unauthorized(
"signed node task completion came from a node other than the assigned node"
.to_owned(),
)
.into());
}
let mut event = TaskCompletionEvent {
tenant,
project,
process,
node,
executor: super::TaskExecutor::Node,
task_definition: checkpoint.assignment.task_spec.task_definition.clone(),
task,
placement: None,
terminal_state: terminal_state
.unwrap_or_else(|| TaskTerminalState::from_status_code(status_code)),
status_code,
stdout_bytes,
stderr_bytes,
stdout_tail,
stderr_tail,
stdout_truncated,
stderr_truncated,
artifact_path,
artifact_digest: artifact_digest.clone(),
artifact_size_bytes,
result,
};
let reported_bytes = checked_reported_log_bytes(event.stdout_bytes, event.stderr_bytes)?;
let now_epoch_seconds = self.current_epoch_seconds()?;
self.quota.can_charge_log_bytes(
&event.tenant,
&event.project,
reported_bytes,
now_epoch_seconds,
)?;
self.quota.charge_log_bytes(
&event.tenant,
&event.project,
reported_bytes,
now_epoch_seconds,
)?;
let task_key = task_control_key(
&event.tenant,
&event.project,
&event.process,
&event.node,
&event.task,
);
let process_key = process_control_key(&event.tenant, &event.project, &event.process);
let process_was_aborted = self.process_aborts.contains(&process_key);
event.placement = self.task_placements.remove(&task_key);
if let (Some(path), Some(digest)) = (&event.artifact_path, artifact_digest) {
self.flush_artifact_metadata(ArtifactFlush {
id: artifact_id_from_path(path),
tenant: event.tenant.clone(),
project: event.project.clone(),
process: event.process.clone(),
producer_task: event.task.clone(),
retaining_node: event.node.clone(),
digest,
size: artifact_size_bytes.unwrap_or(stdout_bytes),
})?;
}
self.task_cancellations.remove(&task_key);
self.task_aborts.remove(&task_key);
self.debug_commands.remove(&task_key);
self.active_tasks.remove(&task_key);
if !self
.active_tasks
.iter()
.any(|(task_tenant, task_project, task_process, _, _)| {
task_tenant == &event.tenant
&& task_project == &event.project
&& task_process == &event.process
})
{
self.process_aborts.remove(&process_key);
}
if process_was_aborted {
let checkpoint_key = super::keys::task_restart_key(
&event.tenant,
&event.project,
&event.process,
&event.task,
);
self.task_restart_checkpoints.remove(&checkpoint_key);
self.task_restart_checkpoint_order
.retain(|retained| retained != &checkpoint_key);
}
self.record_task_completion_event(event.clone());
self.notify_coordinator_main_waiters(&event);
Ok(CoordinatorResponse::TaskRecorded {
process: event.process,
task: event.task,
events_recorded: self.task_events.len(),
})
}
pub(super) fn handle_list_task_events(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: Option<String>,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let _actor = UserId::new(actor_user);
let process = process.map(ProcessId::new);
if let Some(process) = &process {
self.authorize_task_event_process_scope(&tenant, &project, process)?;
}
let events = self
.task_events
.iter()
.filter(|event| {
event.tenant == tenant
&& event.project == project
&& process
.as_ref()
.is_none_or(|process| event.process == *process)
})
.cloned()
.collect();
Ok(CoordinatorResponse::TaskEvents { events })
}
fn authorize_task_event_process_scope(
&self,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
) -> Result<(), CoordinatorServiceError> {
let active_in_scope = self
.coordinator
.active_process(tenant, project, process)
.is_some();
let historical_in_scope = self.task_events.iter().any(|event| {
event.tenant == *tenant && event.project == *project && event.process == *process
});
let process_exists_outside_scope = self
.coordinator
.active_process_exists_outside_scope(tenant, project, process)
|| self.task_events.iter().any(|event| {
event.process == *process && (event.tenant != *tenant || event.project != *project)
});
if !active_in_scope && !historical_in_scope && process_exists_outside_scope {
return Err(CoordinatorError::Unauthorized(
"task event access is outside the virtual process tenant/project scope".to_owned(),
)
.into());
}
Ok(())
}
pub(super) fn handle_join_task(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
task: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let _actor = UserId::new(actor_user);
let process = ProcessId::new(process);
let task = TaskInstanceId::new(task);
Ok(CoordinatorResponse::TaskJoined {
join: self.task_join_result(tenant, project, process, task),
})
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_join_child_task(
&mut self,
tenant: String,
project: String,
process: String,
node: String,
parent_task: String,
task: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let node = NodeId::new(node);
let parent_task = TaskInstanceId::new(parent_task);
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
if !self.active_tasks.contains(&super::keys::task_control_key(
&tenant,
&project,
&process,
&node,
&parent_task,
)) {
return Err(CoordinatorError::Unauthorized(
"child task join requires a currently active parent task on the signed node"
.to_owned(),
)
.into());
}
Ok(CoordinatorResponse::TaskJoined {
join: self.task_join_result(tenant, project, process, TaskInstanceId::new(task)),
})
}
pub(super) fn task_join_result(
&self,
tenant: TenantId,
project: ProjectId,
process: ProcessId,
task: TaskInstanceId,
) -> TaskJoinResult {
let event = self.task_events.iter().rev().find(|event| {
event.tenant == tenant
&& event.project == project
&& event.process == process
&& event.task == task
});
if let Some(event) = event {
TaskJoinResult::from_remote_completion(
event.process.clone(),
event.task.clone(),
event.node.clone(),
join_state_for_terminal(&event.terminal_state),
event.result.clone(),
event.status_code,
join_message_for_event(event),
)
} else {
let known = self.task_is_known_or_active(&tenant, &project, &process, &task);
TaskJoinResult::pending(
process,
task,
if known {
"waiting for signed node task_completed event before join returns"
} else {
"no signed node completion event has been observed for this task"
},
)
}
}
pub(super) fn record_task_completion_event(&mut self, mut event: TaskCompletionEvent) {
event.stdout_tail = bounded_log_tail(event.stdout_tail, &mut event.stdout_truncated);
event.stderr_tail = bounded_log_tail(event.stderr_tail, &mut event.stderr_truncated);
while self
.task_events
.iter()
.filter(|retained| {
retained.tenant == event.tenant
&& retained.project == event.project
&& retained.process == event.process
})
.count()
>= super::MAX_TASK_EVENTS_PER_PROCESS
{
let Some(index) = self.task_events.iter().position(|retained| {
retained.tenant == event.tenant
&& retained.project == event.project
&& retained.process == event.process
}) else {
break;
};
self.task_events.remove(index);
}
self.task_events.push_back(event);
}
fn flush_artifact_metadata(
&mut self,
flush: ArtifactFlush,
) -> Result<(), CoordinatorServiceError> {
let now_epoch_seconds = self.current_epoch_seconds()?;
self.artifact_registry
.expire_download_links(now_epoch_seconds);
let pinned = self
.task_restart_checkpoints
.values()
.flat_map(|checkpoint| checkpoint.assignment.task_spec.required_artifacts.iter())
.chain(
self.pending_task_launches
.iter()
.flat_map(|pending| pending.task_spec.required_artifacts.iter()),
)
.cloned()
.collect::<std::collections::BTreeSet<ArtifactId>>();
self.artifact_registry
.flush_metadata_bounded(flush, &pinned)
.map(|_| ())
.map_err(CoordinatorServiceError::Protocol)
}
fn task_is_known_or_active(
&self,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
task: &TaskInstanceId,
) -> bool {
self.active_tasks
.iter()
.any(|(task_tenant, task_project, task_process, _, task_id)| {
task_tenant == tenant
&& task_project == project
&& task_process == process
&& task_id == task
})
|| self.pending_task_launches.iter().any(|pending| {
&pending.tenant == tenant
&& &pending.project == project
&& &pending.process == process
&& &pending.task == task
})
|| self.task_assignments.values().any(|assignments| {
assignments.iter().any(|assignment| {
&assignment.tenant == tenant
&& &assignment.project == project
&& &assignment.process == process
&& &assignment.task == task
})
})
}
}
fn checked_reported_log_bytes(
stdout_bytes: u64,
stderr_bytes: u64,
) -> Result<u64, CoordinatorServiceError> {
stdout_bytes.checked_add(stderr_bytes).ok_or_else(|| {
CoordinatorServiceError::Protocol(
"reported task log byte counts exceed the supported range".to_owned(),
)
})
}
fn validate_task_log_tail(kind: &str, value: &str) -> Result<(), CoordinatorServiceError> {
if value.len() > MAX_TASK_LOG_TAIL_BYTES {
return Err(CoordinatorServiceError::InvalidTaskLogTail(format!(
"{kind} is {} bytes; max is {MAX_TASK_LOG_TAIL_BYTES}",
value.len()
)));
}
Ok(())
}
fn bounded_log_tail(mut value: String, truncated: &mut bool) -> String {
if value.len() <= MAX_TASK_LOG_TAIL_BYTES {
return value;
}
let mut boundary = MAX_TASK_LOG_TAIL_BYTES;
while !value.is_char_boundary(boundary) {
boundary -= 1;
}
value.truncate(boundary);
*truncated = true;
value
}
fn join_state_for_terminal(terminal: &TaskTerminalState) -> TaskJoinState {
match terminal {
TaskTerminalState::Completed => TaskJoinState::Completed,
TaskTerminalState::Failed => TaskJoinState::Failed,
TaskTerminalState::Cancelled => TaskJoinState::Cancelled,
}
}
fn join_message_for_event(event: &TaskCompletionEvent) -> String {
match event.terminal_state {
TaskTerminalState::Completed => {
"joined result from signed node task_completed event".to_owned()
}
TaskTerminalState::Failed => {
let stderr = event.stderr_tail.trim();
if stderr.is_empty() {
"remote task failed".to_owned()
} else {
format!("remote task failed: {stderr}")
}
}
TaskTerminalState::Cancelled => "remote task was cancelled".to_owned(),
}
}

View file

@ -0,0 +1,999 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError};
use std::sync::{Arc, Mutex};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use disasmer_core::{
Capability, CredentialKind, Digest, EnvironmentResource, NodeId, ProcessId, ProjectId,
TaskBoundaryValue, TaskDefinitionId, TaskDispatch, TaskInstanceId, TaskJoinState, TaskSpec,
TenantId, WasmExportAbi, WasmHostCommandRequest, WasmHostCommandResult,
WasmHostDebugProbeRequest, WasmHostDebugProbeResult, WasmHostSourceSnapshotRequest,
WasmHostSourceSnapshotResult, WasmHostTaskControlRequest, WasmHostTaskControlResult,
WasmHostTaskHandle, WasmHostTaskJoinRequest, WasmHostTaskJoinResult, WasmHostTaskStartRequest,
WasmHostVfsRequest, WasmHostVfsResult, WasmTaskInvocation, WasmTaskOutcome, WasmTaskResult,
};
use disasmer_wasm_runtime::{WasmDebugControl, WasmTaskHost, WasmtimeTaskRuntime};
use wasmparser::{Parser, Payload};
use crate::{CoordinatorError, CoordinatorServiceError};
use super::keys::{process_control_key, task_restart_key, ProcessControlKey, TaskRestartKey};
use super::{
CoordinatorResponse, CoordinatorService, TaskCompletionEvent, TaskExecutor, TaskTerminalState,
WorkflowActor,
};
#[derive(Clone)]
struct MainScope {
tenant: TenantId,
project: ProjectId,
process: ProcessId,
task_definition: TaskDefinitionId,
task_instance: TaskInstanceId,
epoch: u64,
launch_id: u64,
}
enum MainCommand {
StartTask {
scope: MainScope,
handle_id: u64,
task_spec: TaskSpec,
wasm_module_base64: String,
response: SyncSender<Result<WasmHostTaskHandle, String>>,
},
JoinTask {
scope: MainScope,
task_instance: TaskInstanceId,
response: SyncSender<Result<WasmHostTaskJoinResult, String>>,
},
DebugProbe {
scope: MainScope,
request: WasmHostDebugProbeRequest,
response: SyncSender<Result<WasmHostDebugProbeResult, String>>,
},
Finished {
scope: MainScope,
result: Result<WasmTaskResult, String>,
},
}
pub(super) struct CoordinatorMainControl {
pub(super) task_definition: TaskDefinitionId,
pub(super) task_instance: TaskInstanceId,
pub(super) abort: Arc<AtomicBool>,
pub(super) debug: Arc<WasmDebugControl>,
pub(super) state: String,
pub(super) stopped_probe_symbol: Option<String>,
pub(super) handles: Arc<Mutex<HashMap<u64, TaskSpec>>>,
pub(super) launch_id: u64,
}
pub(super) struct CoordinatorMainRuntime {
sender: Sender<MainCommand>,
receiver: Receiver<MainCommand>,
pub(super) controls: BTreeMap<ProcessControlKey, CoordinatorMainControl>,
join_waiters: BTreeMap<TaskRestartKey, Vec<SyncSender<Result<WasmHostTaskJoinResult, String>>>>,
next_launch_id: u64,
}
impl Default for CoordinatorMainRuntime {
fn default() -> Self {
let (sender, receiver) = mpsc::channel();
Self {
sender,
receiver,
controls: BTreeMap::new(),
join_waiters: BTreeMap::new(),
next_launch_id: 1,
}
}
}
impl CoordinatorMainRuntime {
pub(super) fn is_waiting_for_task(
&self,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
) -> bool {
self.join_waiters
.keys()
.any(|(waiter_tenant, waiter_project, waiter_process, _)| {
waiter_tenant == tenant && waiter_project == project && waiter_process == process
})
}
fn drain_commands(&self) -> Vec<MainCommand> {
let mut commands = Vec::new();
loop {
match self.receiver.try_recv() {
Ok(command) => commands.push(command),
Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
}
}
commands
}
fn launch(
&mut self,
mut scope: MainScope,
export: String,
module: Vec<u8>,
wasm_module_base64: String,
bundle_digest: Digest,
task_descriptors: HashMap<String, serde_json::Value>,
environments: BTreeMap<String, EnvironmentResource>,
) -> Result<(), CoordinatorServiceError> {
let process_key = process_control_key(&scope.tenant, &scope.project, &scope.process);
if self.controls.contains_key(&process_key) {
return Err(CoordinatorServiceError::Protocol(format!(
"virtual process {} already has a coordinator main instance",
scope.process
)));
}
scope.launch_id = self.next_launch_id;
self.next_launch_id = self.next_launch_id.saturating_add(1);
let abort = Arc::new(AtomicBool::new(false));
let debug = Arc::new(WasmDebugControl::default());
let handles = Arc::new(Mutex::new(HashMap::new()));
self.controls.insert(
process_key,
CoordinatorMainControl {
task_definition: scope.task_definition.clone(),
task_instance: scope.task_instance.clone(),
abort: Arc::clone(&abort),
debug: Arc::clone(&debug),
state: "running".to_owned(),
stopped_probe_symbol: None,
handles: Arc::clone(&handles),
launch_id: scope.launch_id,
},
);
let sender = self.sender.clone();
let invocation = WasmTaskInvocation::new(
scope.task_definition.clone(),
scope.task_instance.clone(),
Vec::new(),
);
std::thread::Builder::new()
.name(format!("disasmer-main-{}", scope.process))
.spawn(move || {
let host = CoordinatorMainHost {
scope: scope.clone(),
sender: sender.clone(),
abort,
debug,
task_descriptors,
environments,
bundle_digest: bundle_digest.clone(),
wasm_module_base64,
next_handle_id: 1,
handles,
};
let result = WasmtimeTaskRuntime::new()
.and_then(|runtime| {
runtime.run_task_export_verified_with_task_host(
&module,
&bundle_digest,
&export,
&invocation,
Box::new(host),
)
})
.map_err(|error| error.to_string());
let _ = sender.send(MainCommand::Finished { scope, result });
})
.map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"start coordinator main runtime thread: {error}"
))
})?;
Ok(())
}
pub(super) fn interrupt_process(
&mut self,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
reason: &str,
) {
let process_key = process_control_key(tenant, project, process);
if let Some(control) = self.controls.get_mut(&process_key) {
control.abort.store(true, Ordering::Release);
control.state = "stopping".to_owned();
}
let waiter_keys = self
.join_waiters
.keys()
.filter(|(waiter_tenant, waiter_project, waiter_process, _)| {
waiter_tenant == tenant && waiter_project == project && waiter_process == process
})
.cloned()
.collect::<Vec<_>>();
for key in waiter_keys {
if let Some(waiters) = self.join_waiters.remove(&key) {
for waiter in waiters {
let _ = waiter.send(Err(reason.to_owned()));
}
}
}
}
fn is_current_scope(&self, scope: &MainScope) -> bool {
self.controls
.get(&process_control_key(
&scope.tenant,
&scope.project,
&scope.process,
))
.is_some_and(|control| control.launch_id == scope.launch_id)
}
}
struct CoordinatorMainHost {
scope: MainScope,
sender: Sender<MainCommand>,
abort: Arc<AtomicBool>,
debug: Arc<WasmDebugControl>,
task_descriptors: HashMap<String, serde_json::Value>,
environments: BTreeMap<String, EnvironmentResource>,
bundle_digest: Digest,
wasm_module_base64: String,
next_handle_id: u64,
handles: Arc<Mutex<HashMap<u64, TaskSpec>>>,
}
impl WasmTaskHost for CoordinatorMainHost {
fn abort_signal(&self) -> Option<Arc<AtomicBool>> {
Some(Arc::clone(&self.abort))
}
fn debug_control(&self) -> Option<Arc<WasmDebugControl>> {
Some(Arc::clone(&self.debug))
}
fn start_task(
&mut self,
request: WasmHostTaskStartRequest,
) -> Result<WasmHostTaskHandle, String> {
request.validate()?;
if self.abort.load(Ordering::Acquire) {
return Err("coordinator main is stopping".to_owned());
}
let descriptor = self
.task_descriptors
.get(request.task_definition.as_str())
.ok_or_else(|| {
format!(
"bundle has no task descriptor named `{}`",
request.task_definition
)
})?;
let export = descriptor
.get("export")
.and_then(serde_json::Value::as_str)
.filter(|export| !export.trim().is_empty())
.ok_or_else(|| {
format!(
"task `{}` descriptor omitted its Wasm export",
request.task_definition
)
})?;
let mut required_capabilities = descriptor
.get("required_capabilities")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.map(|value| {
capability_from_descriptor(
value
.as_str()
.ok_or("task capability descriptor is not a string")?,
)
})
.collect::<Result<BTreeSet<_>, _>>()?;
let selected_environment = request
.environment_id
.as_deref()
.map(|environment| {
self.environments.get(environment).cloned().ok_or_else(|| {
format!("bundle environment manifest has no environment `{environment}`")
})
})
.transpose()?;
let environment = selected_environment
.as_ref()
.map(|environment| environment.requirements.clone());
let environment_digest = selected_environment
.as_ref()
.map(|environment| environment.digest.clone());
if let Some(environment) = &environment {
required_capabilities.extend(environment.capabilities.iter().cloned());
}
let required_artifacts = request
.args
.iter()
.flat_map(TaskBoundaryValue::required_artifacts)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let source_snapshots = request
.args
.iter()
.flat_map(TaskBoundaryValue::source_snapshots)
.collect::<BTreeSet<_>>();
if source_snapshots.len() > 1 {
return Err(
"one task invocation cannot require multiple distinct source snapshots".to_owned(),
);
}
if self
.handles
.lock()
.map_err(|_| "coordinator main handle registry is unavailable".to_owned())?
.len()
>= super::MAX_IN_FLIGHT_TASKS_PER_PROCESS
{
return Err(format!(
"coordinator main task-handle limit of {} reached",
super::MAX_IN_FLIGHT_TASKS_PER_PROCESS
));
}
let handle_id = self.next_handle_id;
let task_instance =
TaskInstanceId::new(format!("{}:child:{handle_id}", self.scope.task_instance));
let task_spec = TaskSpec {
tenant: self.scope.tenant.clone(),
project: self.scope.project.clone(),
process: self.scope.process.clone(),
task_definition: request.task_definition,
task_instance,
dispatch: TaskDispatch::CoordinatorNodeWasm {
export: Some(export.to_owned()),
abi: WasmExportAbi::TaskV1,
},
environment_id: request.environment_id,
environment,
environment_digest,
required_capabilities,
dependency_cache: None,
source_snapshot: source_snapshots.into_iter().next(),
required_artifacts,
args: request.args,
vfs_epoch: self.scope.epoch,
bundle_digest: Some(self.bundle_digest.clone()),
};
let (response, receiver) = mpsc::sync_channel(1);
self.sender
.send(MainCommand::StartTask {
scope: self.scope.clone(),
handle_id,
task_spec: task_spec.clone(),
wasm_module_base64: self.wasm_module_base64.clone(),
response,
})
.map_err(|_| "coordinator main command channel closed".to_owned())?;
let handle = receiver
.recv()
.map_err(|_| "coordinator main task-start response channel closed".to_owned())??;
self.handles
.lock()
.map_err(|_| "coordinator main handle registry is unavailable".to_owned())?
.insert(handle_id, task_spec);
self.next_handle_id = self.next_handle_id.saturating_add(1);
Ok(handle)
}
fn join_task(
&mut self,
request: WasmHostTaskJoinRequest,
) -> Result<WasmHostTaskJoinResult, String> {
let task_spec = self
.handles
.lock()
.map_err(|_| "coordinator main handle registry is unavailable".to_owned())?
.get(&request.handle_id)
.cloned()
.ok_or_else(|| format!("unknown Wasm task handle {}", request.handle_id))?;
let (response, receiver) = mpsc::sync_channel(1);
self.sender
.send(MainCommand::JoinTask {
scope: self.scope.clone(),
task_instance: task_spec.task_instance,
response,
})
.map_err(|_| "coordinator main command channel closed".to_owned())?;
let joined = receiver
.recv()
.map_err(|_| "coordinator main task-join response channel closed".to_owned())?;
self.handles
.lock()
.map_err(|_| "coordinator main handle registry is unavailable".to_owned())?
.remove(&request.handle_id);
joined
}
fn run_command(
&mut self,
_request: WasmHostCommandRequest,
) -> Result<WasmHostCommandResult, String> {
Err("coordinator main is capless and cannot run native commands".to_owned())
}
fn poll_task_control(
&mut self,
request: WasmHostTaskControlRequest,
) -> Result<WasmHostTaskControlResult, String> {
request.validate()?;
Ok(WasmHostTaskControlResult {
abi_version: disasmer_core::WASM_TASK_ABI_VERSION,
cancellation_requested: self.abort.load(Ordering::Acquire),
})
}
fn debug_probe(
&mut self,
request: WasmHostDebugProbeRequest,
) -> Result<WasmHostDebugProbeResult, String> {
request.validate()?;
let (response, receiver) = mpsc::sync_channel(1);
self.sender
.send(MainCommand::DebugProbe {
scope: self.scope.clone(),
request,
response,
})
.map_err(|_| "coordinator main command channel closed".to_owned())?;
receiver
.recv()
.map_err(|_| "coordinator main debug-probe response channel closed".to_owned())?
}
fn vfs_operation(&mut self, _request: WasmHostVfsRequest) -> Result<WasmHostVfsResult, String> {
Err("coordinator main is capless and cannot access task VFS files".to_owned())
}
fn snapshot_source(
&mut self,
_request: WasmHostSourceSnapshotRequest,
) -> Result<WasmHostSourceSnapshotResult, String> {
Err("coordinator main is capless and cannot access source checkouts".to_owned())
}
}
impl CoordinatorService {
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_launch_coordinator_main(
&mut self,
tenant: String,
project: String,
actor_user: Option<String>,
actor_agent: Option<String>,
agent_public_key_fingerprint: Option<Digest>,
agent_signature: Option<disasmer_core::AgentSignedRequest>,
request_payload_digest: Option<&Digest>,
task_spec: TaskSpec,
wasm_module_base64: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = task_spec.process.clone();
let task_instance = task_spec.task_instance.clone();
let actor = self.workflow_actor(
&tenant,
&project,
actor_user,
actor_agent,
agent_public_key_fingerprint,
agent_signature,
request_payload_digest,
"launch_task",
&process,
Some(&task_instance),
)?;
let active = self
.coordinator
.active_process(&tenant, &project, &process)
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"coordinator main launch requires an active virtual process".to_owned(),
)
})?;
debug_assert_eq!(active.tenant, tenant);
debug_assert_eq!(active.project, project);
if task_spec.tenant != tenant || task_spec.project != project {
return Err(CoordinatorError::Unauthorized(
"coordinator main TaskSpec is outside the authenticated scope".to_owned(),
)
.into());
}
let export = match &task_spec.dispatch {
TaskDispatch::CoordinatorNodeWasm {
export: Some(export),
abi: WasmExportAbi::EntrypointV1,
} => export.clone(),
_ => {
return Err(CoordinatorServiceError::Protocol(
"coordinator main requires an explicit EntrypointV1 Wasm export".to_owned(),
))
}
};
if task_spec.environment_id.is_some()
|| task_spec.environment.is_some()
|| task_spec.environment_digest.is_some()
|| !task_spec.required_capabilities.is_empty()
|| task_spec.dependency_cache.is_some()
|| task_spec.source_snapshot.is_some()
|| !task_spec.required_artifacts.is_empty()
|| !task_spec.args.is_empty()
{
return Err(CoordinatorError::Unauthorized(
"coordinator main must be capless and may not receive environment, source, artifact, cache, or argument authority"
.to_owned(),
)
.into());
}
let bundle_digest = task_spec.bundle_digest.clone().ok_or_else(|| {
CoordinatorServiceError::Protocol(
"coordinator main TaskSpec omitted bundle digest".to_owned(),
)
})?;
let module = BASE64_STANDARD
.decode(&wasm_module_base64)
.map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"coordinator main module is not valid base64: {error}"
))
})?;
let actual_digest = Digest::sha256(&module);
if actual_digest != bundle_digest {
return Err(CoordinatorError::Unauthorized(format!(
"coordinator main module digest mismatch: expected {bundle_digest}, actual {actual_digest}"
))
.into());
}
WasmTaskInvocation::new(
task_spec.task_definition.clone(),
task_instance.clone(),
Vec::new(),
)
.validate()
.map_err(CoordinatorServiceError::Protocol)?;
let descriptors = task_descriptors(&module)?;
let environments = bundle_environments(&module)?;
let scope = MainScope {
tenant: tenant.clone(),
project: project.clone(),
process: process.clone(),
task_definition: task_spec.task_definition.clone(),
task_instance: task_instance.clone(),
epoch: task_spec.vfs_epoch,
launch_id: 0,
};
self.main_runtime.launch(
scope,
export,
module,
wasm_module_base64,
bundle_digest,
descriptors,
environments,
)?;
Ok(CoordinatorResponse::MainLaunched {
process,
task_definition: task_spec.task_definition,
task_instance,
actor,
state: "running".to_owned(),
})
}
pub(super) fn pump_main_runtime_commands(&mut self) {
let commands = self.main_runtime.drain_commands();
for command in commands {
match command {
MainCommand::StartTask {
scope,
handle_id,
task_spec,
wasm_module_base64,
response,
} => {
if !self.main_runtime.is_current_scope(&scope) {
let _ = response.send(Err(
"coordinator main process incarnation was replaced".to_owned(),
));
continue;
}
let actor = WorkflowActor {
kind: "task".to_owned(),
user: None,
agent: None,
credential_kind: CredentialKind::TaskCredential,
public_key_fingerprint: None,
authenticated_without_browser: true,
scopes: vec!["process:spawn-child".to_owned()],
};
let result = self
.handle_launch_task_with_actor(
scope.tenant,
scope.project,
actor,
task_spec.clone(),
true,
format!("/vfs/artifacts/{}-result.json", task_spec.task_instance),
wasm_module_base64,
)
.and_then(|launch| match launch {
CoordinatorResponse::TaskLaunched { .. }
| CoordinatorResponse::TaskQueued { .. } => Ok(WasmHostTaskHandle {
abi_version: disasmer_core::WASM_TASK_ABI_VERSION,
handle_id,
task_spec,
}),
other => Err(CoordinatorServiceError::Protocol(format!(
"unexpected coordinator-main child launch response: {other:?}"
))),
})
.map_err(|error| error.to_string());
let _ = response.send(result);
}
MainCommand::JoinTask {
scope,
task_instance,
response,
} => {
if !self.main_runtime.is_current_scope(&scope) {
let _ = response.send(Err(
"coordinator main process incarnation was replaced".to_owned(),
));
continue;
}
let join = self.task_join_result(
scope.tenant.clone(),
scope.project.clone(),
scope.process.clone(),
task_instance.clone(),
);
match join.state {
TaskJoinState::Completed => {
let result = join
.result
.ok_or_else(|| {
"completed child task omitted its boundary result".to_owned()
})
.map(|result| WasmHostTaskJoinResult {
abi_version: disasmer_core::WASM_TASK_ABI_VERSION,
task_instance,
result,
});
let _ = response.send(result);
}
TaskJoinState::Failed | TaskJoinState::Cancelled => {
let _ = response.send(Err(join.message));
}
TaskJoinState::Pending => {
self.main_runtime
.join_waiters
.entry(task_restart_key(
&scope.tenant,
&scope.project,
&scope.process,
&task_instance,
))
.or_default()
.push(response);
}
}
}
MainCommand::DebugProbe {
scope,
request,
response,
} => {
if !self.main_runtime.is_current_scope(&scope) {
let _ = response.send(Err(
"coordinator main process incarnation was replaced".to_owned(),
));
continue;
}
let result = self
.handle_coordinator_main_debug_probe(
scope.tenant,
scope.project,
scope.process,
scope.task_instance,
request.symbol,
)
.map_err(|error| error.to_string());
let _ = response.send(result);
}
MainCommand::Finished { scope, result } => {
if !self.main_runtime.is_current_scope(&scope) {
continue;
}
self.record_coordinator_main_completion(scope, result);
}
}
}
}
pub(super) fn notify_coordinator_main_waiters(&mut self, event: &TaskCompletionEvent) {
let key = task_restart_key(&event.tenant, &event.project, &event.process, &event.task);
let Some(waiters) = self.main_runtime.join_waiters.remove(&key) else {
return;
};
let result = match event.terminal_state {
TaskTerminalState::Completed => event
.result
.clone()
.ok_or_else(|| "completed child task omitted its boundary result".to_owned())
.map(|result| WasmHostTaskJoinResult {
abi_version: disasmer_core::WASM_TASK_ABI_VERSION,
task_instance: event.task.clone(),
result,
}),
TaskTerminalState::Failed | TaskTerminalState::Cancelled => {
Err(event.stderr_tail.clone())
}
};
for waiter in waiters {
let _ = waiter.send(result.clone());
}
}
fn record_coordinator_main_completion(
&mut self,
scope: MainScope,
result: Result<WasmTaskResult, String>,
) {
let (terminal_state, boundary, error) = match result {
Ok(result) if result.outcome == WasmTaskOutcome::Completed => {
(TaskTerminalState::Completed, result.result, String::new())
}
Ok(result) => (
TaskTerminalState::Failed,
None,
result
.error
.unwrap_or_else(|| "coordinator main failed without an error".to_owned()),
),
Err(error) => (TaskTerminalState::Failed, None, error),
};
let main_state = match terminal_state {
TaskTerminalState::Completed => "completed",
TaskTerminalState::Failed => "failed",
TaskTerminalState::Cancelled => "cancelled",
};
let event = TaskCompletionEvent {
tenant: scope.tenant.clone(),
project: scope.project.clone(),
process: scope.process.clone(),
node: NodeId::from("coordinator-main"),
executor: TaskExecutor::CoordinatorMain,
task_definition: scope.task_definition,
task: scope.task_instance,
placement: None,
terminal_state,
status_code: if error.is_empty() { Some(0) } else { None },
stdout_bytes: 0,
stderr_bytes: error.len() as u64,
stdout_tail: String::new(),
stderr_tail: error,
stdout_truncated: false,
stderr_truncated: false,
artifact_path: None,
artifact_digest: None,
artifact_size_bytes: None,
result: boundary,
};
self.record_task_completion_event(event);
if let Some(control) = self.main_runtime.controls.get_mut(&process_control_key(
&scope.tenant,
&scope.project,
&scope.process,
)) {
control.state = main_state.to_owned();
control.stopped_probe_symbol = None;
if let Ok(mut handles) = control.handles.lock() {
handles.clear();
}
}
// Completion keeps the virtual process and its final debug handshake
// inspectable. In particular, the DAP client may still be waiting for
// every participant's resume acknowledgement when the main Wasm task
// exits. A new process incarnation or an explicit abort clears this
// ephemeral state.
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completed_main_keeps_process_and_debug_handshake_visible_until_explicit_abort() {
let mut service = CoordinatorService::new(7);
let tenant = TenantId::from("tenant");
let project = ProjectId::from("project");
let process = ProcessId::from("vp-current");
let main_task = TaskInstanceId::from("ti:vp-current:main");
let scope = MainScope {
tenant: tenant.clone(),
project: project.clone(),
process: process.clone(),
task_definition: TaskDefinitionId::from("build"),
task_instance: main_task.clone(),
epoch: 7,
launch_id: 1,
};
service
.coordinator
.start_process(tenant.clone(), project.clone(), process.clone());
let process_key = process_control_key(&tenant, &project, &process);
service.main_runtime.controls.insert(
process_key.clone(),
CoordinatorMainControl {
task_definition: scope.task_definition.clone(),
task_instance: main_task.clone(),
abort: Arc::new(AtomicBool::new(false)),
debug: Arc::new(WasmDebugControl::default()),
state: "running".to_owned(),
stopped_probe_symbol: None,
handles: Arc::new(Mutex::new(HashMap::new())),
launch_id: 1,
},
);
service.debug_epochs.insert(process_key.clone(), 2);
service.debug_epoch_runtime.insert(
process_key.clone(),
super::super::debug::DebugEpochRuntime {
epoch: 2,
command: "resume".to_owned(),
expected: BTreeSet::new(),
acknowledgements: BTreeMap::new(),
},
);
service.record_coordinator_main_completion(
scope,
Ok(WasmTaskResult::completed(
main_task,
TaskBoundaryValue::SmallJson(serde_json::Value::Null),
)),
);
assert!(service
.coordinator
.active_process(&tenant, &project, &process)
.is_some());
let CoordinatorResponse::ProcessStatuses { processes, .. } = service
.handle_list_processes(
tenant.as_str().to_owned(),
project.as_str().to_owned(),
"user".to_owned(),
)
.unwrap()
else {
panic!("expected process statuses");
};
assert_eq!(processes.len(), 1);
assert_eq!(processes[0].state, "completed");
assert_eq!(processes[0].main_state.as_deref(), Some("completed"));
assert_eq!(service.debug_epochs.get(&process_key), Some(&2));
assert_eq!(
service
.debug_epoch_runtime
.get(&process_key)
.map(|runtime| runtime.command.as_str()),
Some("resume")
);
service
.handle_abort_process(
tenant.as_str().to_owned(),
project.as_str().to_owned(),
"user".to_owned(),
process.as_str().to_owned(),
)
.unwrap();
assert!(service
.coordinator
.active_process(&tenant, &project, &process)
.is_none());
assert!(!service.main_runtime.controls.contains_key(&process_key));
assert!(!service.debug_epochs.contains_key(&process_key));
assert!(!service.debug_epoch_runtime.contains_key(&process_key));
}
}
pub(super) fn task_descriptors(
module: &[u8],
) -> Result<HashMap<String, serde_json::Value>, CoordinatorServiceError> {
let mut descriptors = HashMap::new();
for payload in Parser::new(0).parse_all(module) {
let Payload::CustomSection(section) = payload.map_err(|error| {
CoordinatorServiceError::Protocol(format!("parse coordinator main bundle: {error}"))
})?
else {
continue;
};
if section.name() != "disasmer.tasks" {
continue;
}
for record in section
.data()
.split(|byte| *byte == b'\n' || *byte == 0)
.filter(|record| !record.is_empty())
{
let descriptor: serde_json::Value = serde_json::from_slice(record)?;
let name = descriptor
.get("name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
CoordinatorServiceError::Protocol("task descriptor omitted its name".to_owned())
})?
.to_owned();
descriptors.insert(name, descriptor);
}
}
Ok(descriptors)
}
pub(super) fn bundle_environments(
module: &[u8],
) -> Result<BTreeMap<String, EnvironmentResource>, CoordinatorServiceError> {
for payload in Parser::new(0).parse_all(module) {
let Payload::CustomSection(section) = payload.map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"parse coordinator main environment manifest: {error}"
))
})?
else {
continue;
};
if section.name() != "disasmer.environments" {
continue;
}
let environments: Vec<EnvironmentResource> = serde_json::from_slice(section.data())
.map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"bundle environment manifest is invalid: {error}"
))
})?;
let mut by_name = BTreeMap::new();
for environment in environments {
if by_name
.insert(environment.name.clone(), environment)
.is_some()
{
return Err(CoordinatorServiceError::Protocol(
"bundle environment manifest contains duplicate names".to_owned(),
));
}
}
return Ok(by_name);
}
Ok(BTreeMap::new())
}
pub(super) fn capability_from_descriptor(capability: &str) -> Result<Capability, String> {
match capability.to_ascii_lowercase().as_str() {
"command" => Ok(Capability::Command),
"rootless_podman" => Ok(Capability::RootlessPodman),
"source_git" => Ok(Capability::SourceGit),
"source_filesystem" => Ok(Capability::SourceFilesystem),
"network" => Ok(Capability::Network),
"host_filesystem" => Ok(Capability::HostFilesystem),
"secrets" => Ok(Capability::Secrets),
"inbound_ports" => Ok(Capability::InboundPorts),
"arbitrary_syscalls" => Ok(Capability::ArbitrarySyscalls),
"vfs_artifacts" => Ok(Capability::VfsArtifacts),
"windows_command_dev" => Ok(Capability::WindowsCommandDev),
"quic_direct" => Ok(Capability::QuicDirect),
other => Err(format!("unknown task capability `{other}`")),
}
}

View file

@ -0,0 +1,407 @@
use std::collections::BTreeSet;
use std::time::{SystemTime, UNIX_EPOCH};
use disasmer_core::{
generate_opaque_token, verify_node_request_signature, Actor, ArtifactId, CredentialKind,
Digest, NodeCapabilities, NodeDescriptor, NodeId, NodeSignedRequest, ProjectId,
SourceProviderKind, TenantId, UserId,
};
use crate::CoordinatorError;
use super::{
bounded_ttl, enrollment_grant_key, CoordinatorResponse, CoordinatorService,
CoordinatorServiceError,
};
impl CoordinatorService {
pub(super) fn handle_attach_node(
&mut self,
tenant: String,
project: String,
node: String,
public_key: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let node = NodeId::new(node);
self.coordinator.ensure_tenant_active(&tenant)?;
self.coordinator.upsert_tenant(tenant.clone());
self.coordinator.upsert_user(
tenant.clone(),
UserId::from("local-user"),
CredentialKind::CliDeviceSession,
);
self.coordinator
.upsert_project(tenant.clone(), project.clone(), "local");
self.coordinator.upsert_source_provider_config(
tenant.clone(),
project.clone(),
SourceProviderKind::Filesystem,
Digest::sha256("local-filesystem"),
);
self.coordinator.enroll_node(
tenant.clone(),
project.clone(),
node.clone(),
public_key,
"node:attach",
);
self.persist_durable_state()?;
Ok(CoordinatorResponse::NodeAttached {
node,
tenant,
project,
})
}
pub(super) fn handle_create_node_enrollment_grant(
&mut self,
tenant: String,
project: String,
actor_user: String,
ttl_seconds: u64,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
self.coordinator.ensure_tenant_active(&tenant)?;
self.coordinator.upsert_tenant(tenant.clone());
self.coordinator
.upsert_user(tenant.clone(), actor, CredentialKind::CliDeviceSession);
self.coordinator
.upsert_project(tenant.clone(), project.clone(), "local");
self.coordinator.upsert_source_provider_config(
tenant.clone(),
project.clone(),
SourceProviderKind::Filesystem,
Digest::sha256("local-filesystem"),
);
let now_epoch_seconds = self.current_epoch_seconds()?;
self.enrollment_grants.retain(|_, grant| {
!grant.consumed && grant.expires_at_epoch_seconds >= now_epoch_seconds
});
if self
.enrollment_grants
.values()
.filter(|grant| grant.tenant == tenant && grant.project == project)
.count()
>= super::MAX_ENROLLMENT_GRANTS_PER_PROJECT
{
return Err(CoordinatorServiceError::Protocol(
"node enrollment grant limit reached for this project; consume a grant or wait for one to expire"
.to_owned(),
));
}
let grant =
generate_opaque_token("node_grant").map_err(CoordinatorServiceError::Protocol)?;
let ttl_seconds = bounded_ttl(ttl_seconds, self.admission.max_node_enrollment_ttl_seconds);
let scope = "node:attach".to_owned();
let expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds);
let enrollment = self.coordinator.create_node_enrollment_grant(
tenant.clone(),
project.clone(),
grant.clone(),
scope.clone(),
expires_at_epoch_seconds,
);
self.enrollment_grants
.insert(enrollment_grant_key(&tenant, &project, &grant), enrollment);
self.persist_durable_state()?;
Ok(CoordinatorResponse::NodeEnrollmentGrantCreated {
tenant,
project,
grant,
scope,
expires_at_epoch_seconds,
})
}
pub(super) fn handle_exchange_node_enrollment_grant(
&mut self,
tenant: String,
project: String,
node: String,
public_key: String,
enrollment_grant: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let node = NodeId::new(node);
let now_epoch_seconds = self.current_epoch_seconds()?;
self.enrollment_grants.retain(|_, grant| {
!grant.consumed && grant.expires_at_epoch_seconds >= now_epoch_seconds
});
self.coordinator.ensure_tenant_active(&tenant)?;
let grant_key = enrollment_grant_key(&tenant, &project, &enrollment_grant);
let grant =
self.enrollment_grants
.get_mut(&grant_key)
.ok_or(CoordinatorError::Enrollment(
disasmer_core::EnrollmentError::Expired,
))?;
let credential = self.coordinator.exchange_node_enrollment_grant(
grant,
node.clone(),
&public_key,
"node:attach",
now_epoch_seconds,
)?;
self.enrollment_grants.remove(&grant_key);
self.persist_durable_state()?;
Ok(CoordinatorResponse::NodeEnrollmentExchanged {
node,
tenant,
project,
credential,
})
}
pub(super) fn handle_node_heartbeat(
&mut self,
node: String,
node_signature: Option<NodeSignedRequest>,
payload_digest: &Digest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let node = NodeId::new(node);
self.authenticate_node_request(&node, node_signature, "node_heartbeat", payload_digest)?;
Ok(CoordinatorResponse::NodeHeartbeat {
node,
epoch: self.coordinator.coordinator_epoch(),
})
}
pub(super) fn handle_report_node_capabilities(
&mut self,
tenant: String,
project: String,
node: String,
capabilities: NodeCapabilities,
cached_environment_digests: Vec<Digest>,
dependency_cache_digests: Vec<Digest>,
source_snapshots: Vec<Digest>,
artifact_locations: Vec<String>,
direct_connectivity: bool,
online: bool,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let node = NodeId::new(node);
let identity = self
.coordinator
.node_identity(&node)
.ok_or(CoordinatorError::UnknownNode)?;
if identity.tenant != tenant || identity.project != project {
return Err(CoordinatorError::Unauthorized(
"node capability report is outside the enrolled tenant/project scope".to_owned(),
)
.into());
}
capabilities.validate_public_report()?;
for (kind, count) in [
("cached environments", cached_environment_digests.len()),
("dependency caches", dependency_cache_digests.len()),
("source snapshots", source_snapshots.len()),
("artifact locations", artifact_locations.len()),
] {
if count > super::MAX_NODE_REPORTED_OBJECTS_PER_KIND {
return Err(CoordinatorServiceError::Protocol(format!(
"node capability report contains {count} {kind}; limit is {}",
super::MAX_NODE_REPORTED_OBJECTS_PER_KIND
)));
}
}
if cached_environment_digests
.iter()
.chain(&dependency_cache_digests)
.chain(&source_snapshots)
.any(|digest| !digest.is_valid_sha256())
{
return Err(CoordinatorServiceError::Protocol(
"node capability report contains an invalid digest".to_owned(),
));
}
if artifact_locations.iter().any(|artifact| {
artifact.trim().is_empty()
|| artifact.len() > 256
|| artifact
.chars()
.any(|character| matches!(character, '/' | '\\' | '\0'))
}) {
return Err(CoordinatorServiceError::Protocol(
"node capability report contains an invalid artifact id".to_owned(),
));
}
let artifact_locations = artifact_locations
.into_iter()
.map(ArtifactId::new)
.collect::<BTreeSet<_>>();
self.artifact_registry
.reconcile_node_retention(&node, &artifact_locations);
self.node_descriptors.insert(
node.clone(),
NodeDescriptor {
id: node.clone(),
tenant,
project,
capabilities,
cached_environments: cached_environment_digests.into_iter().collect(),
dependency_caches: dependency_cache_digests.into_iter().collect(),
source_snapshots: source_snapshots.into_iter().collect(),
artifact_locations,
direct_connectivity,
online,
},
);
Ok(CoordinatorResponse::NodeCapabilitiesRecorded {
node,
node_descriptors: self.node_descriptors.len(),
})
}
pub(super) fn handle_list_node_descriptors(
&mut self,
tenant: String,
project: String,
actor_user: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let descriptors = self
.node_descriptors
.values()
.filter(|descriptor| descriptor.tenant == tenant && descriptor.project == project)
.cloned()
.collect();
Ok(CoordinatorResponse::NodeDescriptors { descriptors, actor })
}
pub(super) fn handle_revoke_node_credential(
&mut self,
tenant: String,
project: String,
actor_user: String,
node: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let node = NodeId::new(node);
let context = disasmer_core::AuthContext {
tenant: tenant.clone(),
project: project.clone(),
actor: Actor::User(actor.clone()),
};
self.coordinator.revoke_node_credential(&context, &node)?;
let descriptor_removed = self.node_descriptors.remove(&node).is_some();
self.artifact_registry.garbage_collect_node(&node);
let queued_assignments_removed = self
.task_assignments
.remove(&(tenant.clone(), project.clone(), node.clone()))
.map_or(0, |assignments| assignments.len());
self.active_tasks
.retain(|(task_tenant, task_project, _, task_node, _)| {
task_tenant != &tenant || task_project != &project || task_node != &node
});
self.task_cancellations
.retain(|(task_tenant, task_project, _, task_node, _)| {
task_tenant != &tenant || task_project != &project || task_node != &node
});
self.task_aborts
.retain(|(task_tenant, task_project, _, task_node, _)| {
task_tenant != &tenant || task_project != &project || task_node != &node
});
self.task_placements
.retain(|(task_tenant, task_project, _, task_node, _), _| {
task_tenant != &tenant || task_project != &project || task_node != &node
});
self.persist_durable_state()?;
Ok(CoordinatorResponse::NodeCredentialRevoked {
node,
tenant,
project,
actor,
descriptor_removed,
queued_assignments_removed,
})
}
pub(super) fn authenticate_node_request(
&mut self,
node: &NodeId,
node_signature: Option<NodeSignedRequest>,
request_kind: &str,
payload_digest: &Digest,
) -> Result<(), CoordinatorServiceError> {
let identity = self
.coordinator
.node_identity(node)
.ok_or(CoordinatorError::UnknownNode)?;
let signature = node_signature.ok_or_else(|| {
CoordinatorError::Unauthorized(
"node request requires a signed proof of enrolled private-key possession"
.to_owned(),
)
})?;
if signature.nonce.trim().is_empty() || signature.nonce.len() > 256 {
return Err(CoordinatorError::Unauthorized(
"node signed request nonce is missing or invalid".to_owned(),
)
.into());
}
let now_epoch_seconds = unix_timestamp_seconds();
if signature
.issued_at_epoch_seconds
.abs_diff(now_epoch_seconds)
> super::NODE_SIGNATURE_WINDOW_SECONDS
{
return Err(CoordinatorError::Unauthorized(
"node signed request is expired or outside the allowed clock skew".to_owned(),
)
.into());
}
let replay_key = (node.clone(), signature.nonce.clone());
self.node_replay_nonces.retain(|_, accepted_at| {
now_epoch_seconds <= accepted_at.saturating_add(super::NODE_SIGNATURE_WINDOW_SECONDS)
});
if self.node_replay_nonces.contains_key(&replay_key) {
return Err(CoordinatorError::Unauthorized(
"node signed request nonce has already been used".to_owned(),
)
.into());
}
verify_node_request_signature(
&identity.public_key,
node,
request_kind,
payload_digest,
&signature,
)
.map_err(CoordinatorError::Unauthorized)?;
if self
.node_replay_nonces
.keys()
.filter(|(retained_node, _)| retained_node == node)
.count()
>= super::MAX_NODE_REPLAY_NONCES_PER_AUTHORITY
{
return Err(CoordinatorError::Unauthorized(
"node signed request replay window is full; retry after the bounded signature window advances"
.to_owned(),
)
.into());
}
self.node_replay_nonces
.insert(replay_key, now_epoch_seconds);
Ok(())
}
}
fn unix_timestamp_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}

View file

@ -0,0 +1,307 @@
use disasmer_core::{
Actor, DownloadPolicy, PanelEvent, PanelEventKind, PanelState, PanelWidget, PanelWidgetKind,
ProcessId, ProjectId, RateLimit, TenantId, UserId,
};
use crate::CoordinatorError;
use super::artifact_id_from_path;
use super::keys::panel_stop_key;
use super::{CoordinatorResponse, CoordinatorService, CoordinatorServiceError};
impl CoordinatorService {
pub(super) fn clear_operator_panel_state(
&mut self,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
) {
let stop_key = panel_stop_key(tenant, project, process);
self.panel_snapshots.remove(&stop_key);
self.stopped_panels.remove(&stop_key);
self.panel_event_limits
.retain(|(event_tenant, event_project, event_process, _), _| {
event_tenant != tenant || event_project != project || event_process != process
});
}
pub(super) fn handle_render_operator_panel(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
max_download_bytes: u64,
stopped: bool,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let stop_key = panel_stop_key(&tenant, &project, &process);
let panel = if stopped {
self.ensure_operator_panel_scope(&tenant, &project, &process)?;
self.stopped_panels.insert(stop_key);
let stop_key = panel_stop_key(&tenant, &project, &process);
let panel = match self.panel_snapshots.get(&stop_key).cloned() {
Some(panel) => stopped_panel_snapshot(panel),
None => self.render_operator_panel(
tenant.clone(),
project.clone(),
process.clone(),
UserId::new(actor_user),
max_download_bytes,
true,
)?,
};
self.panel_snapshots.insert(stop_key, panel.clone());
panel
} else {
self.stopped_panels.remove(&stop_key);
let panel = self.render_operator_panel(
tenant.clone(),
project.clone(),
process.clone(),
UserId::new(actor_user),
max_download_bytes,
false,
)?;
self.panel_snapshots.insert(stop_key, panel.clone());
panel
};
Ok(CoordinatorResponse::OperatorPanel { panel })
}
pub(super) fn handle_submit_panel_event(
&mut self,
tenant: String,
project: String,
process: String,
widget_id: String,
kind: PanelEventKind,
max_events: u64,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let stop_key = panel_stop_key(&tenant, &project, &process);
let stopped = self.stopped_panels.contains(&stop_key);
let panel = if stopped {
match self.panel_snapshots.get(&stop_key).cloned() {
Some(panel) => stopped_panel_snapshot(panel),
None => {
let panel = self.render_operator_panel(
tenant.clone(),
project.clone(),
process.clone(),
UserId::from("panel-user"),
self.quota.download_limit(),
true,
)?;
self.panel_snapshots.insert(stop_key.clone(), panel.clone());
panel
}
}
} else {
let panel = self.render_operator_panel(
tenant.clone(),
project.clone(),
process.clone(),
UserId::from("panel-user"),
self.quota.download_limit(),
false,
)?;
self.panel_snapshots.insert(stop_key, panel.clone());
panel
};
let event = PanelEvent {
tenant: tenant.clone(),
project: project.clone(),
process: process.clone(),
widget_id: widget_id.clone(),
kind,
};
let limit_key = (tenant, project, process, widget_id);
let mut limit = self
.panel_event_limits
.get(&limit_key)
.cloned()
.unwrap_or(RateLimit {
max_events,
used_events: 0,
});
limit.max_events = max_events;
panel.accept_event(&event, &mut limit)?;
self.panel_event_limits.insert(limit_key, limit.clone());
Ok(CoordinatorResponse::PanelEventAccepted {
used_events: limit.used_events,
max_events: limit.max_events,
})
}
fn render_operator_panel(
&self,
tenant: TenantId,
project: ProjectId,
process: ProcessId,
actor_user: UserId,
max_download_bytes: u64,
stopped: bool,
) -> Result<PanelState, CoordinatorServiceError> {
self.ensure_operator_panel_scope(&tenant, &project, &process)?;
let events = self
.task_events
.iter()
.filter(|event| {
event.tenant == tenant && event.project == project && event.process == process
})
.collect::<Vec<_>>();
let completed = events
.iter()
.filter(|event| event.status_code == Some(0))
.count() as u64;
let total = events.len().max(1) as u64;
let stdout_bytes = events.iter().map(|event| event.stdout_bytes).sum::<u64>();
let stderr_bytes = events.iter().map(|event| event.stderr_bytes).sum::<u64>();
let last_task = events.last().map(|event| event.task.clone());
let mut panel = PanelState::new(tenant.clone(), project.clone(), process.clone());
if stopped {
panel.freeze_program_ui_events();
}
panel.add_widget(PanelWidget {
id: "process-status".to_owned(),
label: "Process Status".to_owned(),
kind: PanelWidgetKind::Text {
value: if stopped {
"stopped".to_owned()
} else {
"running".to_owned()
},
},
})?;
panel.add_widget(PanelWidget {
id: "task-progress".to_owned(),
label: "Tasks".to_owned(),
kind: PanelWidgetKind::Progress {
current: completed,
total,
},
})?;
panel.add_widget(PanelWidget {
id: "task-summary".to_owned(),
label: "Task Summary".to_owned(),
kind: PanelWidgetKind::Text {
value: if events.is_empty() {
"no task events recorded".to_owned()
} else {
events
.iter()
.map(|event| {
format!(
"{} [{}]:{:?}:{}",
event.task_definition, event.task, event.status_code, event.node
)
})
.collect::<Vec<_>>()
.join(", ")
},
},
})?;
panel.add_widget(PanelWidget {
id: "recent-logs".to_owned(),
label: "Recent Logs".to_owned(),
kind: PanelWidgetKind::Text {
value: format!("stdout={stdout_bytes} stderr={stderr_bytes}"),
},
})?;
panel.add_widget(PanelWidget {
id: "debug-process".to_owned(),
label: "Debug Process".to_owned(),
kind: PanelWidgetKind::Button {
action: "debug-process".to_owned(),
},
})?;
panel.add_widget(PanelWidget {
id: "cancel-process".to_owned(),
label: "Cancel Process".to_owned(),
kind: PanelWidgetKind::Button {
action: "cancel-process".to_owned(),
},
})?;
if last_task.is_some() {
panel.add_widget(PanelWidget {
id: "restart-selected-task".to_owned(),
label: "Restart Selected Task".to_owned(),
kind: PanelWidgetKind::Button {
action: "restart-task".to_owned(),
},
})?;
}
let mut actions = vec![
disasmer_core::ControlPlaneAction::DebugProcess,
disasmer_core::ControlPlaneAction::CancelProcess,
];
if let Some(task) = last_task.clone() {
actions.push(disasmer_core::ControlPlaneAction::RestartTask(task));
}
panel.set_control_plane_actions(actions);
if let Some(path) = events
.iter()
.rev()
.find_map(|event| event.artifact_path.as_ref())
{
let artifact = artifact_id_from_path(path);
let context = disasmer_core::AuthContext {
tenant,
project,
actor: Actor::User(actor_user),
};
panel.add_download_widget_from_action(
"download-artifact",
"Download Artifact",
self.artifact_registry.download_action(
&context,
&artifact,
&DownloadPolicy {
max_bytes: max_download_bytes,
},
),
)?;
}
Ok(panel)
}
fn ensure_operator_panel_scope(
&self,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
) -> Result<(), CoordinatorServiceError> {
let active = self
.coordinator
.active_process(tenant, project, process)
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"operator panel requires an active virtual process".to_owned(),
)
})?;
debug_assert_eq!(active.tenant, *tenant);
debug_assert_eq!(active.project, *project);
Ok(())
}
}
fn stopped_panel_snapshot(mut panel: PanelState) -> PanelState {
panel.freeze_program_ui_events();
if let Some(status) = panel.widgets.get_mut("process-status") {
status.kind = PanelWidgetKind::Text {
value: "stopped".to_owned(),
};
}
panel
}

View file

@ -0,0 +1,555 @@
use std::collections::BTreeMap;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use disasmer_core::{
AgentSignedRequest, ArtifactId, CheckpointBoundary, CredentialKind, DefaultScheduler, Digest,
NodeId, PlacementRequest, ProcessId, ProjectId, Scheduler, TaskCheckpoint, TaskDispatch,
TaskInstanceId, TaskSpec, TenantId, VfsManifest, VfsObject, VfsPath, WasmTaskInvocation,
};
use crate::CoordinatorError;
use super::keys::{process_control_key, task_control_key, task_restart_key};
use super::{
CoordinatorResponse, CoordinatorService, CoordinatorServiceError, TaskAssignment, WorkflowActor,
};
use super::processes::*;
impl CoordinatorService {
pub(super) fn capture_task_restart_checkpoint(
&mut self,
assignment: &TaskAssignment,
) -> Result<(), CoordinatorServiceError> {
let task_spec = &assignment.task_spec;
let environment_digest = task_spec.environment_digest.clone().unwrap_or_else(|| {
task_spec.environment.as_ref().map_or_else(
|| Digest::sha256("disasmer.environment.unconstrained.v1"),
|environment| {
Digest::sha256(
serde_json::to_vec(environment)
.expect("serializable environment requirements"),
)
},
)
});
let task_entrypoint = match &task_spec.dispatch {
disasmer_core::TaskDispatch::CoordinatorNodeWasm { export, .. } => export
.clone()
.or_else(|| assignment_task_descriptor(assignment)?.get("export")?.as_str().map(str::to_owned))
.ok_or_else(|| {
CoordinatorServiceError::Protocol(format!(
"cannot capture restart checkpoint for task `{}`: bundle descriptor omitted its Wasm export",
task_spec.task_definition
))
})?,
};
let mut objects = BTreeMap::new();
let mut missing_required_artifact = false;
for artifact in &task_spec.required_artifacts {
let Some(metadata) = self.artifact_registry.metadata(artifact) else {
missing_required_artifact = true;
continue;
};
if metadata.tenant != assignment.tenant
|| metadata.project != assignment.project
|| metadata.retaining_nodes.is_empty()
{
missing_required_artifact = true;
continue;
}
let path = VfsPath::new(format!("/vfs/artifacts/{artifact}"))
.map_err(|error| CoordinatorServiceError::InvalidArtifactPath(error.to_string()))?;
objects.insert(
path.clone(),
VfsObject {
path,
digest: metadata.digest.clone(),
size: metadata.size,
producer: metadata.producer_task.clone(),
node: metadata.producer_node.clone(),
},
);
}
let checkpoint = TaskCheckpoint {
task: assignment.task.clone(),
boundary: CheckpointBoundary {
task_entrypoint,
serialized_args: Digest::sha256(serde_json::to_vec(&task_spec.args)?),
environment_digest,
vfs_epoch: task_spec.vfs_epoch,
task_abi: assignment_task_compatibility(assignment)
.unwrap_or(Digest::sha256(serde_json::to_vec(&task_spec.dispatch)?)),
},
vfs_manifest: VfsManifest {
epoch: task_spec.vfs_epoch,
producer: assignment.task.clone(),
node: assignment.node.clone(),
objects,
large_bytes_uploaded: false,
},
depends_on_live_stack: false,
depends_on_live_socket: false,
depends_on_ephemeral_artifact_durability: missing_required_artifact,
};
let key = task_restart_key(
&assignment.tenant,
&assignment.project,
&assignment.process,
&assignment.task,
);
self.task_restart_checkpoint_order
.retain(|retained| retained != &key);
self.task_restart_checkpoints.insert(
key.clone(),
TaskRestartCheckpoint {
checkpoint,
assignment: assignment.clone(),
},
);
self.task_restart_checkpoint_order.push_back(key);
while self
.task_restart_checkpoint_order
.iter()
.filter(|(tenant, project, process, _)| {
tenant == &assignment.tenant
&& project == &assignment.project
&& process == &assignment.process
})
.count()
> super::MAX_RESTART_CHECKPOINTS_PER_PROCESS
{
let Some(index) = self.task_restart_checkpoint_order.iter().position(
|(tenant, project, process, _)| {
tenant == &assignment.tenant
&& project == &assignment.project
&& process == &assignment.process
},
) else {
break;
};
if let Some(expired) = self.task_restart_checkpoint_order.remove(index) {
self.task_restart_checkpoints.remove(&expired);
}
}
Ok(())
}
pub(super) fn handle_schedule_task(
&mut self,
tenant: String,
project: String,
environment: Option<disasmer_core::EnvironmentRequirements>,
environment_digest: Option<Digest>,
required_capabilities: Vec<disasmer_core::Capability>,
dependency_cache: Option<Digest>,
source_snapshot: Option<Digest>,
required_artifacts: Vec<String>,
prefer_node: Option<String>,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let now_epoch_seconds = self.current_epoch_seconds()?;
let request = PlacementRequest {
tenant: tenant.clone(),
project: project.clone(),
environment,
environment_digest,
required_capabilities: required_capabilities.into_iter().collect(),
dependency_cache,
source_snapshot,
required_artifacts: required_artifacts
.into_iter()
.map(ArtifactId::new)
.collect(),
quota_available: self
.quota
.can_charge_workflow_spawn(&tenant, &project, now_epoch_seconds)
.is_ok(),
policy_allowed: self.admission.workflow_placement_allowed,
prefer_node: prefer_node.map(NodeId::new),
};
let nodes = self.node_descriptors.values().cloned().collect::<Vec<_>>();
let placement = DefaultScheduler.place(&nodes, &request)?;
Ok(CoordinatorResponse::TaskPlacement { placement })
}
pub(super) fn handle_launch_task(
&mut self,
tenant: String,
project: String,
actor_user: Option<String>,
actor_agent: Option<String>,
agent_public_key_fingerprint: Option<Digest>,
agent_signature: Option<AgentSignedRequest>,
request_payload_digest: Option<&Digest>,
task_spec: TaskSpec,
wait_for_node: bool,
artifact_path: String,
wasm_module_base64: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
if matches!(
&task_spec.dispatch,
TaskDispatch::CoordinatorNodeWasm {
abi: disasmer_core::WasmExportAbi::EntrypointV1,
..
}
) {
return self.handle_launch_coordinator_main(
tenant,
project,
actor_user,
actor_agent,
agent_public_key_fingerprint,
agent_signature,
request_payload_digest,
task_spec,
wasm_module_base64,
);
}
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = task_spec.process.clone();
let task = task_spec.task_instance.clone();
let actor = self.workflow_actor(
&tenant,
&project,
actor_user,
actor_agent,
agent_public_key_fingerprint,
agent_signature,
request_payload_digest,
"launch_task",
&process,
Some(&task),
)?;
self.handle_launch_task_with_actor(
tenant,
project,
actor,
task_spec,
wait_for_node,
artifact_path,
wasm_module_base64,
)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_launch_child_task(
&mut self,
tenant: String,
project: String,
process: String,
node: String,
parent_task: String,
task_spec: TaskSpec,
wait_for_node: bool,
artifact_path: String,
wasm_module_base64: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let node = NodeId::new(node);
let parent_task = TaskInstanceId::new(parent_task);
if task_spec.process != process {
return Err(CoordinatorError::Unauthorized(
"child task must remain in its parent virtual process".to_owned(),
)
.into());
}
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
let parent_key = task_control_key(&tenant, &project, &process, &node, &parent_task);
if !self.active_tasks.contains(&parent_key) {
return Err(CoordinatorError::Unauthorized(
"child task launch requires a currently active parent task on the signed node"
.to_owned(),
)
.into());
}
let actor = WorkflowActor {
kind: "task".to_owned(),
user: None,
agent: None,
credential_kind: CredentialKind::TaskCredential,
public_key_fingerprint: None,
authenticated_without_browser: true,
scopes: vec!["process:spawn-child".to_owned()],
};
self.handle_launch_task_with_actor(
tenant,
project,
actor,
task_spec,
wait_for_node,
artifact_path,
wasm_module_base64,
)
}
pub(super) fn handle_launch_task_with_actor(
&mut self,
tenant: TenantId,
project: ProjectId,
actor: WorkflowActor,
task_spec: TaskSpec,
wait_for_node: bool,
artifact_path: String,
wasm_module_base64: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
if task_spec.tenant != tenant || task_spec.project != project {
return Err(CoordinatorError::Unauthorized(
"task specification is outside the authenticated tenant/project scope".to_owned(),
)
.into());
}
if !task_spec.product_mode_uses_remote_dispatch() {
return Err(CoordinatorError::Unauthorized(
"task specification must use the Wasm coordinator/node dispatch ABI".to_owned(),
)
.into());
}
if task_spec
.environment_id
.as_deref()
.is_some_and(|environment| environment.trim().is_empty() || environment.len() > 128)
{
return Err(CoordinatorError::Unauthorized(
"task specification environment id is invalid".to_owned(),
)
.into());
}
let process = task_spec.process.clone();
let task = task_spec.task_instance.clone();
let active = self
.coordinator
.active_process(&tenant, &project, &process)
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"task launch requires an active coordinator-side virtual process".to_owned(),
)
})?;
debug_assert_eq!(active.tenant, tenant);
debug_assert_eq!(active.project, project);
if self
.process_cancellations
.contains(&process_control_key(&tenant, &project, &process))
{
return Err(CoordinatorError::Unauthorized(
"task launch is blocked because the virtual process is cancelling".to_owned(),
)
.into());
}
if self.task_instance_exists(&tenant, &project, &process, &task) {
return Err(CoordinatorServiceError::Protocol(format!(
"task instance {task} already exists in virtual process {process}; every spawn must use a fresh task-instance id"
)));
}
let in_flight = self
.active_tasks
.iter()
.filter(|(task_tenant, task_project, task_process, _, _)| {
task_tenant == &tenant && task_project == &project && task_process == &process
})
.count()
+ self
.pending_task_launches
.iter()
.filter(|pending| {
pending.tenant == tenant
&& pending.project == project
&& pending.process == process
})
.count();
if in_flight >= super::MAX_IN_FLIGHT_TASKS_PER_PROCESS {
return Err(CoordinatorServiceError::Protocol(format!(
"virtual process task limit of {} reached; join or cancel existing work before spawning more",
super::MAX_IN_FLIGHT_TASKS_PER_PROCESS
)));
}
if task_spec.vfs_epoch != active.coordinator_epoch {
return Err(CoordinatorError::Unauthorized(format!(
"task specification VFS epoch {} does not match active process epoch {}",
task_spec.vfs_epoch, active.coordinator_epoch
))
.into());
}
let bundle_digest = task_spec.bundle_digest.as_ref().ok_or_else(|| {
CoordinatorError::Unauthorized(
"Wasm task specification is missing its bundle digest".to_owned(),
)
})?;
if !bundle_digest.is_valid_sha256() {
return Err(CoordinatorError::Unauthorized(
"Wasm task specification has an invalid bundle digest".to_owned(),
)
.into());
}
let module = BASE64_STANDARD
.decode(&wasm_module_base64)
.map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"Wasm task module is not valid base64: {error}"
))
})?;
let actual_digest = Digest::sha256(&module);
if &actual_digest != bundle_digest {
return Err(CoordinatorError::Unauthorized(format!(
"Wasm task module digest does not match bundle digest: expected {bundle_digest}, actual {actual_digest}"
))
.into());
}
WasmTaskInvocation::new(
task_spec.task_definition.clone(),
task.clone(),
task_spec.args.clone(),
)
.validate()
.map_err(CoordinatorServiceError::Protocol)?;
for artifact in &task_spec.required_artifacts {
let metadata = self.artifact_registry.metadata(artifact).ok_or_else(|| {
CoordinatorError::Unauthorized(format!(
"required artifact {artifact} is unavailable or has expired"
))
})?;
if metadata.tenant != tenant || metadata.project != project {
return Err(CoordinatorError::Unauthorized(format!(
"required artifact {artifact} is outside the task tenant/project scope"
))
.into());
}
if metadata.retaining_nodes.is_empty() {
return Err(CoordinatorError::Unauthorized(format!(
"required artifact {artifact} has no retaining node"
))
.into());
}
}
VfsPath::new(&artifact_path)
.map_err(|error| CoordinatorServiceError::InvalidArtifactPath(error.to_string()))?;
let now_epoch_seconds = self.current_epoch_seconds()?;
self.quota
.can_charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?;
let request = PlacementRequest {
tenant: tenant.clone(),
project: project.clone(),
environment: task_spec.environment.clone(),
environment_digest: task_spec.environment_digest.clone(),
required_capabilities: task_spec.required_capabilities.clone(),
dependency_cache: task_spec.dependency_cache.clone(),
source_snapshot: task_spec.source_snapshot.clone(),
required_artifacts: task_spec.required_artifacts.iter().cloned().collect(),
quota_available: self
.quota
.can_charge_workflow_spawn(&tenant, &project, now_epoch_seconds)
.is_ok(),
policy_allowed: self.admission.workflow_placement_allowed,
prefer_node: None,
};
let nodes = self.node_descriptors.values().cloned().collect::<Vec<_>>();
let placement = match DefaultScheduler.place(&nodes, &request) {
Ok(placement) => placement,
Err(err) if wait_for_node => {
let reason = if err.message.is_empty() {
"waiting for any capable node".to_owned()
} else {
err.message
};
let charged_spawns =
self.quota
.charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?;
self.pending_task_launches.push_back(PendingTaskLaunch {
tenant: tenant.clone(),
project: project.clone(),
process: process.clone(),
task: task.clone(),
request,
epoch: active.coordinator_epoch,
artifact_path,
task_spec,
wasm_module_base64,
});
return Ok(CoordinatorResponse::TaskQueued {
process,
task,
actor,
reason,
charged_spawns,
queued_tasks: self.pending_task_launches.len(),
});
}
Err(err) => return Err(err.into()),
};
let charged_spawns =
self.quota
.charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?;
let assignment = TaskAssignment {
tenant: tenant.clone(),
project: project.clone(),
process: process.clone(),
task: task.clone(),
node: placement.node.clone(),
epoch: active.coordinator_epoch,
artifact_path,
task_spec,
wasm_module_base64,
};
self.capture_task_restart_checkpoint(&assignment)?;
let task_key = task_control_key(&tenant, &project, &process, &placement.node, &task);
self.task_placements
.insert(task_key.clone(), placement.clone());
self.active_tasks.insert(task_key);
self.task_assignments
.entry((tenant, project, placement.node.clone()))
.or_default()
.push_back(assignment.clone());
Ok(CoordinatorResponse::TaskLaunched {
process,
task,
actor,
placement,
assignment: Box::new(assignment),
charged_spawns,
})
}
fn task_instance_exists(
&self,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
task_instance: &disasmer_core::TaskInstanceId,
) -> bool {
self.active_tasks.iter().any(
|(task_tenant, task_project, task_process, _, existing_instance)| {
task_tenant == tenant
&& task_project == project
&& task_process == process
&& existing_instance == task_instance
},
) || self.pending_task_launches.iter().any(|pending| {
&pending.tenant == tenant
&& &pending.project == project
&& &pending.process == process
&& &pending.task == task_instance
}) || self.task_events.iter().any(|event| {
&event.tenant == tenant
&& &event.project == project
&& &event.process == process
&& &event.task == task_instance
})
}
}
fn assignment_task_compatibility(assignment: &TaskAssignment) -> Option<Digest> {
let descriptor = assignment_task_descriptor(assignment)?;
serde_json::from_value(descriptor.get("restart_compatibility_hash")?.clone()).ok()
}
fn assignment_task_descriptor(assignment: &TaskAssignment) -> Option<serde_json::Value> {
let module = BASE64_STANDARD
.decode(&assignment.wasm_module_base64)
.ok()?;
let mut descriptors = super::main_runtime::task_descriptors(&module).ok()?;
descriptors.remove(assignment.task_spec.task_definition.as_str())
}

View file

@ -0,0 +1,788 @@
use std::collections::{BTreeSet, VecDeque};
use std::time::{SystemTime, UNIX_EPOCH};
use disasmer_core::{
AgentId, AgentSignedRequest, CredentialKind, DefaultScheduler, Digest, NodeId,
PlacementRequest, ProcessId, ProjectId, RendezvousRequest, Scheduler, SourcePreparation,
TaskCheckpoint, TaskInstanceId, TaskSpec, TenantId, UserId,
};
use crate::CoordinatorError;
use super::keys::{process_control_key, task_control_key};
use super::{
CoordinatorResponse, CoordinatorService, CoordinatorServiceError, SourcePreparationDisposition,
SourcePreparationStatus, TaskAssignment, TaskCancellationTarget, VirtualProcessStatus,
WorkflowActor,
};
#[derive(Clone, Debug)]
pub(super) struct PendingTaskLaunch {
pub(super) tenant: TenantId,
pub(super) project: ProjectId,
pub(super) process: ProcessId,
pub(super) task: TaskInstanceId,
pub(super) request: PlacementRequest,
pub(super) epoch: u64,
pub(super) artifact_path: String,
pub(super) task_spec: TaskSpec,
pub(super) wasm_module_base64: String,
}
#[derive(Clone, Debug)]
pub(super) struct TaskRestartCheckpoint {
pub(super) checkpoint: TaskCheckpoint,
pub(super) assignment: TaskAssignment,
}
impl CoordinatorService {
pub(super) fn handle_poll_task_assignment(
&mut self,
tenant: String,
project: String,
node: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let node = NodeId::new(node);
let identity = self
.coordinator
.node_identity(&node)
.ok_or(CoordinatorError::UnknownNode)?;
if identity.tenant != tenant || identity.project != project {
return Err(CoordinatorError::Unauthorized(
"task assignment poll is outside the enrolled tenant/project scope".to_owned(),
)
.into());
}
let assignment_key = (tenant.clone(), project.clone(), node.clone());
let assignment = self
.task_assignments
.get_mut(&assignment_key)
.and_then(VecDeque::pop_front);
if assignment.is_some() {
return Ok(CoordinatorResponse::TaskAssignment {
assignment: assignment.map(Box::new),
});
}
let assignment = self.assign_pending_task_to_node(&tenant, &project, &node)?;
Ok(CoordinatorResponse::TaskAssignment {
assignment: assignment.map(Box::new),
})
}
fn assign_pending_task_to_node(
&mut self,
tenant: &TenantId,
project: &ProjectId,
node: &NodeId,
) -> Result<Option<TaskAssignment>, CoordinatorServiceError> {
let Some(descriptor) = self.node_descriptors.get(node).cloned() else {
return Ok(None);
};
let mut remaining = VecDeque::new();
let mut selected = None;
while let Some(pending) = self.pending_task_launches.pop_front() {
if selected.is_some() {
remaining.push_back(pending);
continue;
}
if &pending.tenant != tenant || &pending.project != project {
remaining.push_back(pending);
continue;
}
if self.process_cancellations.contains(&process_control_key(
&pending.tenant,
&pending.project,
&pending.process,
)) {
continue;
}
let Some(active) = self.coordinator.active_process(
&pending.tenant,
&pending.project,
&pending.process,
) else {
continue;
};
if active.tenant != pending.tenant
|| active.project != pending.project
|| active.coordinator_epoch != pending.epoch
{
continue;
}
let Ok(placement) =
DefaultScheduler.place(std::slice::from_ref(&descriptor), &pending.request)
else {
remaining.push_back(pending);
continue;
};
let assignment = TaskAssignment {
tenant: pending.tenant.clone(),
project: pending.project.clone(),
process: pending.process.clone(),
task: pending.task.clone(),
node: placement.node.clone(),
epoch: pending.epoch,
artifact_path: pending.artifact_path,
task_spec: pending.task_spec,
wasm_module_base64: pending.wasm_module_base64,
};
self.capture_task_restart_checkpoint(&assignment)?;
let task_key = task_control_key(
&pending.tenant,
&pending.project,
&pending.process,
&placement.node,
&pending.task,
);
self.task_placements.insert(task_key.clone(), placement);
self.active_tasks.insert(task_key);
selected = Some(assignment);
}
self.pending_task_launches = remaining;
Ok(selected)
}
pub(super) fn handle_request_rendezvous(
&mut self,
scope: disasmer_core::DataPlaneScope,
source: disasmer_core::NodeEndpoint,
destination: disasmer_core::NodeEndpoint,
direct_connectivity: bool,
failure_reason: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let now_epoch_seconds = self.current_epoch_seconds()?;
let charged_rendezvous_attempts = self.quota.charge_rendezvous_attempt(
&scope.tenant,
&scope.project,
now_epoch_seconds,
)?;
let plan = self.transport.plan_authenticated_direct_bulk_transfer(
RendezvousRequest {
scope,
source,
destination,
},
direct_connectivity,
failure_reason,
)?;
Ok(CoordinatorResponse::RendezvousPlan {
plan,
charged_rendezvous_attempts,
})
}
pub(super) fn handle_request_source_preparation(
&mut self,
tenant: String,
project: String,
provider: disasmer_core::SourceProviderKind,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let preparation = SourcePreparation::node_task(tenant.clone(), project.clone(), provider);
let request = PlacementRequest {
tenant,
project,
environment: None,
environment_digest: None,
required_capabilities: preparation.required_capabilities.clone(),
dependency_cache: None,
source_snapshot: None,
required_artifacts: Default::default(),
quota_available: true,
policy_allowed: true,
prefer_node: None,
};
let nodes = self.node_descriptors.values().cloned().collect::<Vec<_>>();
let disposition = match DefaultScheduler.place(&nodes, &request) {
Ok(placement) => SourcePreparationDisposition::Assigned {
node: placement.node,
},
Err(err) => SourcePreparationDisposition::Pending {
reason: if err.message.is_empty() {
"waiting for any capable node to prepare source".to_owned()
} else {
err.message
},
},
};
Ok(CoordinatorResponse::SourcePreparation {
status: SourcePreparationStatus {
preparation,
disposition,
},
})
}
pub(super) fn handle_complete_source_preparation(
&mut self,
tenant: String,
project: String,
node: String,
provider: disasmer_core::SourceProviderKind,
source_snapshot: Digest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let node = NodeId::new(node);
let identity = self
.coordinator
.node_identity(&node)
.ok_or(CoordinatorError::UnknownNode)?;
if identity.tenant != tenant || identity.project != project {
return Err(CoordinatorError::Unauthorized(
"source preparation completion is outside the enrolled tenant/project scope"
.to_owned(),
)
.into());
}
let descriptor = self.node_descriptors.get_mut(&node).ok_or_else(|| {
CoordinatorError::Unauthorized(
"source preparation completion requires a node capability report".to_owned(),
)
})?;
if !descriptor.source_snapshots.contains(&source_snapshot)
&& descriptor.source_snapshots.len() >= super::MAX_NODE_REPORTED_OBJECTS_PER_KIND
{
return Err(CoordinatorServiceError::Protocol(format!(
"node source snapshot retention limit of {} reached; refresh the node capability report",
super::MAX_NODE_REPORTED_OBJECTS_PER_KIND
)));
}
descriptor.source_snapshots.insert(source_snapshot.clone());
Ok(CoordinatorResponse::SourcePreparationCompleted {
node,
provider,
source_snapshot,
})
}
pub(super) fn handle_start_process(
&mut self,
tenant: String,
project: String,
actor_user: Option<String>,
actor_agent: Option<String>,
agent_public_key_fingerprint: Option<Digest>,
agent_signature: Option<AgentSignedRequest>,
request_payload_digest: Option<&Digest>,
process: String,
restart: bool,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
self.coordinator.ensure_tenant_active(&tenant)?;
let actor = self.workflow_actor(
&tenant,
&project,
actor_user,
actor_agent,
agent_public_key_fingerprint,
agent_signature,
request_payload_digest,
"start_process",
&process,
None,
)?;
let replacing_existing = if let Some(active) = self
.coordinator
.active_process_for_project(&tenant, &project)
{
if active.id != process || !restart {
return Err(CoordinatorError::Unauthorized(format!(
"project already has active virtual process {}; attach to or restart it, request cooperative cancellation, abort it, or use another Coordinator Project",
active.id
))
.into());
}
true
} else {
false
};
if replacing_existing {
self.main_runtime.interrupt_process(
&tenant,
&project,
&process,
"virtual process incarnation replaced",
);
self.main_runtime
.controls
.remove(&process_control_key(&tenant, &project, &process));
}
let now_epoch_seconds = self.current_epoch_seconds()?;
let charged_spawns =
self.quota
.charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?;
self.process_cancellations
.remove(&process_control_key(&tenant, &project, &process));
self.process_aborts
.remove(&process_control_key(&tenant, &project, &process));
self.clear_debug_state_for_process(&tenant, &project, &process);
self.clear_operator_panel_state(&tenant, &project, &process);
self.task_cancellations
.retain(|(task_tenant, task_project, task_process, _, _)| {
task_tenant != &tenant || task_project != &project || task_process != &process
});
self.task_aborts
.retain(|(task_tenant, task_project, task_process, _, _)| {
task_tenant != &tenant || task_project != &project || task_process != &process
});
self.active_tasks
.retain(|(task_tenant, task_project, task_process, _, _)| {
task_tenant != &tenant || task_project != &project || task_process != &process
});
self.task_placements
.retain(|(task_tenant, task_project, task_process, _, _), _| {
task_tenant != &tenant || task_project != &project || task_process != &process
});
self.task_assignments.retain(|_, assignments| {
assignments.retain(|assignment| {
assignment.tenant != tenant
|| assignment.project != project
|| assignment.process != process
});
!assignments.is_empty()
});
self.pending_task_launches.retain(|pending| {
pending.tenant != tenant || pending.project != project || pending.process != process
});
self.task_restart_checkpoints.retain(
|(checkpoint_tenant, checkpoint_project, checkpoint_process, _), _| {
checkpoint_tenant != &tenant
|| checkpoint_project != &project
|| checkpoint_process != &process
},
);
self.task_restart_checkpoint_order.retain(
|(checkpoint_tenant, checkpoint_project, checkpoint_process, _)| {
checkpoint_tenant != &tenant
|| checkpoint_project != &project
|| checkpoint_process != &process
},
);
self.task_events.retain(|event| {
event.tenant != tenant || event.project != project || event.process != process
});
self.debug_audit_events.retain(|event| {
event.tenant != tenant || event.project != project || event.process != process
});
self.coordinator
.start_process(tenant, project, process.clone());
Ok(CoordinatorResponse::ProcessStarted {
process,
epoch: self.coordinator.coordinator_epoch(),
actor,
charged_spawns,
})
}
pub(super) fn handle_reconnect_node(
&mut self,
node: String,
process: String,
epoch: u64,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let node = NodeId::new(node);
let process = ProcessId::new(process);
self.coordinator
.reconnect_node(&node, Some((&process, epoch)))?;
Ok(CoordinatorResponse::NodeReconnected { node, process })
}
pub(super) fn handle_cancel_task(
&mut self,
tenant: String,
project: String,
process: String,
node: String,
task: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let node = NodeId::new(node);
let task = TaskInstanceId::new(task);
self.coordinator
.authorize_node_for_process(&node, &tenant, &project, &process)?;
let active = self
.coordinator
.active_process(&tenant, &project, &process)
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"task cancellation requires an active virtual process".to_owned(),
)
})?;
if !active.connected_nodes.contains(&node) {
return Err(CoordinatorError::Unauthorized(
"task cancellation target node is not connected to the virtual process".to_owned(),
)
.into());
}
self.task_cancellations
.insert(task_control_key(&tenant, &project, &process, &node, &task));
Ok(CoordinatorResponse::TaskCancellationRequested {
process,
task,
node,
})
}
pub(super) fn handle_cancel_process(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let _actor_user = actor_user;
let active = self
.coordinator
.active_process(&tenant, &project, &process)
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"process cancellation requires an active virtual process".to_owned(),
)
})?;
debug_assert_eq!(active.tenant, tenant);
debug_assert_eq!(active.project, project);
self.process_cancellations
.insert(process_control_key(&tenant, &project, &process));
self.main_runtime.interrupt_process(
&tenant,
&project,
&process,
"virtual process cancellation requested",
);
self.clear_debug_state_for_process(&tenant, &project, &process);
self.pending_task_launches.retain(|pending| {
pending.tenant != tenant || pending.project != project || pending.process != process
});
let mut cancelled_tasks = Vec::new();
let mut affected_nodes = BTreeSet::new();
for (task_tenant, task_project, task_process, node, task) in self.active_tasks.iter() {
if task_tenant == &tenant && task_project == &project && task_process == &process {
self.task_cancellations
.insert(task_control_key(&tenant, &project, &process, node, task));
affected_nodes.insert(node.clone());
cancelled_tasks.push(TaskCancellationTarget {
process: process.clone(),
task: task.clone(),
node: node.clone(),
});
}
}
Ok(CoordinatorResponse::ProcessCancellationRequested {
process,
cancelled_tasks,
affected_nodes: affected_nodes.into_iter().collect(),
})
}
pub(super) fn handle_abort_process(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let _actor_user = actor_user;
let active = self
.coordinator
.active_process(&tenant, &project, &process)
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"process abort requires an active virtual process".to_owned(),
)
})?;
debug_assert_eq!(active.tenant, tenant);
debug_assert_eq!(active.project, project);
let process_key = process_control_key(&tenant, &project, &process);
self.process_cancellations.remove(&process_key);
self.task_cancellations
.retain(|(task_tenant, task_project, task_process, _, _)| {
task_tenant != &tenant || task_project != &project || task_process != &process
});
self.process_aborts.insert(process_key);
self.main_runtime
.interrupt_process(&tenant, &project, &process, "virtual process aborted");
self.main_runtime
.controls
.remove(&process_control_key(&tenant, &project, &process));
self.clear_debug_state_for_process(&tenant, &project, &process);
self.clear_operator_panel_state(&tenant, &project, &process);
self.pending_task_launches.retain(|pending| {
pending.tenant != tenant || pending.project != project || pending.process != process
});
self.task_assignments.retain(|_, assignments| {
assignments.retain(|assignment| {
assignment.tenant != tenant
|| assignment.project != project
|| assignment.process != process
});
!assignments.is_empty()
});
let mut aborted_tasks = Vec::new();
let mut affected_nodes = BTreeSet::new();
for (task_tenant, task_project, task_process, node, task) in self.active_tasks.iter() {
if task_tenant == &tenant && task_project == &project && task_process == &process {
self.task_aborts
.insert(task_control_key(&tenant, &project, &process, node, task));
affected_nodes.insert(node.clone());
aborted_tasks.push(TaskCancellationTarget {
process: process.clone(),
task: task.clone(),
node: node.clone(),
});
}
}
self.coordinator
.abort_process(&tenant, &project, &process)?;
let active_restart_tasks = aborted_tasks
.iter()
.map(|target| target.task.clone())
.collect::<BTreeSet<_>>();
self.task_restart_checkpoints.retain(
|(checkpoint_tenant, checkpoint_project, checkpoint_process, checkpoint_task), _| {
checkpoint_tenant != &tenant
|| checkpoint_project != &project
|| checkpoint_process != &process
|| active_restart_tasks.contains(checkpoint_task)
},
);
self.task_restart_checkpoint_order.retain(
|(checkpoint_tenant, checkpoint_project, checkpoint_process, checkpoint_task)| {
checkpoint_tenant != &tenant
|| checkpoint_project != &project
|| checkpoint_process != &process
|| active_restart_tasks.contains(checkpoint_task)
},
);
if aborted_tasks.is_empty() {
self.process_aborts
.remove(&process_control_key(&tenant, &project, &process));
}
Ok(CoordinatorResponse::ProcessAborted {
process,
aborted_tasks,
affected_nodes: affected_nodes.into_iter().collect(),
})
}
pub(super) fn handle_list_processes(
&mut self,
tenant: String,
project: String,
actor_user: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let processes = self
.coordinator
.active_processes_for_project(&tenant, &project)
.into_iter()
.map(|active| {
let process_key = process_control_key(&active.tenant, &active.project, &active.id);
let main = self.main_runtime.controls.get(&process_key);
let state = if self.process_cancellations.contains(&process_key) {
"cancelling"
} else {
main.map_or("running", |main| main.state.as_str())
};
let main_wait_state = main.and_then(|main| {
if main.state != "running" {
return None;
}
if self.pending_task_launches.iter().any(|pending| {
pending.tenant == active.tenant
&& pending.project == active.project
&& pending.process == active.id
}) {
Some("waiting_for_node".to_owned())
} else if self.main_runtime.is_waiting_for_task(
&active.tenant,
&active.project,
&active.id,
) {
Some("waiting_for_task".to_owned())
} else {
Some("executing".to_owned())
}
});
VirtualProcessStatus {
process: active.id,
state: state.to_owned(),
main_task_definition: main.map(|main| main.task_definition.clone()),
main_task_instance: main.map(|main| main.task_instance.clone()),
main_state: main.map(|main| main.state.clone()),
main_wait_state,
main_debug_epoch: main.and_then(|main| main.debug.requested_epoch()),
connected_nodes: active.connected_nodes.into_iter().collect(),
coordinator_epoch: active.coordinator_epoch,
}
})
.collect();
Ok(CoordinatorResponse::ProcessStatuses { processes, actor })
}
pub(super) fn handle_poll_task_control(
&mut self,
tenant: String,
project: String,
process: String,
node: String,
task: String,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let process = ProcessId::new(process);
let node = NodeId::new(node);
let task = TaskInstanceId::new(task);
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
let cancel_requested = self
.task_cancellations
.contains(&task_control_key(&tenant, &project, &process, &node, &task))
|| self
.process_cancellations
.contains(&process_control_key(&tenant, &project, &process));
let abort_requested = self
.task_aborts
.contains(&task_control_key(&tenant, &project, &process, &node, &task))
|| self
.process_aborts
.contains(&process_control_key(&tenant, &project, &process));
Ok(CoordinatorResponse::TaskControl {
process,
task,
cancel_requested,
abort_requested,
})
}
pub(super) fn workflow_actor(
&mut self,
tenant: &TenantId,
project: &ProjectId,
actor_user: Option<String>,
actor_agent: Option<String>,
agent_public_key_fingerprint: Option<Digest>,
agent_signature: Option<AgentSignedRequest>,
request_payload_digest: Option<&Digest>,
request_kind: &str,
process: &ProcessId,
task: Option<&TaskInstanceId>,
) -> Result<WorkflowActor, CoordinatorServiceError> {
if let Some(agent) = actor_agent {
let agent = AgentId::new(agent);
let signature = agent_signature.ok_or_else(|| {
CoordinatorError::Unauthorized(
"agent workflow dispatch requires a signed request proving private-key possession"
.to_owned(),
)
})?;
let request_payload_digest = request_payload_digest.ok_or_else(|| {
CoordinatorError::Unauthorized(
"agent workflow dispatch requires a canonical signed request payload"
.to_owned(),
)
})?;
if signature.nonce.trim().is_empty() || signature.nonce.len() > 256 {
return Err(CoordinatorError::Unauthorized(
"agent signed request nonce is missing or invalid".to_owned(),
)
.into());
}
let now_epoch_seconds = unix_timestamp_seconds();
let replay_key = (
tenant.clone(),
project.clone(),
agent.clone(),
signature.nonce.clone(),
);
const AGENT_SIGNATURE_WINDOW_SECONDS: u64 = 300;
self.agent_replay_nonces.retain(|_, issued_at| {
now_epoch_seconds <= issued_at.saturating_add(AGENT_SIGNATURE_WINDOW_SECONDS)
});
if self.agent_replay_nonces.contains_key(&replay_key) {
return Err(CoordinatorError::Unauthorized(
"agent signed request nonce has already been used".to_owned(),
)
.into());
}
let record = self.coordinator.authorize_agent_project_run(
disasmer_core::AgentWorkflowScope {
tenant,
project,
agent: &agent,
request_kind,
process,
task,
},
agent_public_key_fingerprint.as_ref(),
request_payload_digest,
&signature,
now_epoch_seconds,
)?;
if self
.agent_replay_nonces
.keys()
.filter(|(retained_tenant, retained_project, retained_agent, _)| {
retained_tenant == tenant
&& retained_project == project
&& retained_agent == &agent
})
.count()
>= super::MAX_REPLAY_NONCES_PER_AUTHORITY
{
return Err(CoordinatorError::Unauthorized(
"agent signed request replay window is full; retry after the bounded signature window advances"
.to_owned(),
)
.into());
}
self.agent_replay_nonces
.insert(replay_key, signature.issued_at_epoch_seconds);
return Ok(WorkflowActor {
kind: "agent".to_owned(),
user: Some(record.user),
agent: Some(agent),
credential_kind: CredentialKind::PublicKey,
public_key_fingerprint: Some(record.public_key_fingerprint),
authenticated_without_browser: true,
scopes: record.scopes,
});
}
let actor = UserId::new(actor_user.unwrap_or_else(|| "user".to_owned()));
Ok(WorkflowActor {
kind: "user".to_owned(),
user: Some(actor),
agent: None,
credential_kind: CredentialKind::BrowserSession,
public_key_fingerprint: None,
authenticated_without_browser: false,
scopes: vec!["project:read".to_owned(), "project:run".to_owned()],
})
}
}
fn unix_timestamp_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}

View file

@ -0,0 +1,644 @@
use std::collections::BTreeMap;
use disasmer_core::{
AgentId, AgentSignedRequest, ArtifactId, Authorization, Capability, CredentialKind,
DataPlaneScope, Digest, DirectBulkTransferPlan, DownloadLink, EnvironmentRequirements,
LimitKind, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId, NodeSignedRequest,
PanelEventKind, PanelState, Placement, ProcessId, ProjectId, ResourceLimits, SourcePreparation,
SourceProviderKind, TaskBoundaryValue, TaskInstanceId, TaskJoinResult, TaskSpec, TenantId,
UserId, VfsPath,
};
use serde::{Deserialize, Serialize};
mod responses;
pub use responses::*;
use crate::{AgentPublicKeyRecord, ProjectRecord};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaskReplacementBundle {
pub bundle_digest: Digest,
pub wasm_module_base64: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum CoordinatorRequest {
Ping,
Authenticated {
session_secret: String,
request: AuthenticatedCoordinatorRequest,
},
AuthStatus {
tenant: String,
project: String,
actor_user: String,
},
AdminStatus {
tenant: String,
actor_user: String,
admin_proof: Digest,
admin_nonce: String,
issued_at_epoch_seconds: u64,
},
SuspendTenant {
tenant: String,
actor_user: String,
target_tenant: String,
admin_proof: Digest,
admin_nonce: String,
issued_at_epoch_seconds: u64,
},
CreateProject {
tenant: String,
actor_user: String,
project: String,
name: String,
},
SelectProject {
tenant: String,
actor_user: String,
project: String,
},
ListProjects {
tenant: String,
actor_user: String,
},
RegisterAgentPublicKey {
tenant: String,
project: String,
user: String,
agent: String,
public_key: String,
},
ListAgentPublicKeys {
tenant: String,
project: String,
user: String,
},
RotateAgentPublicKey {
tenant: String,
project: String,
user: String,
agent: String,
public_key: String,
},
RevokeAgentPublicKey {
tenant: String,
project: String,
user: String,
agent: String,
},
AttachNode {
tenant: String,
project: String,
node: String,
public_key: String,
},
CreateNodeEnrollmentGrant {
tenant: String,
project: String,
actor_user: String,
#[serde(default = "default_node_enrollment_ttl_seconds")]
ttl_seconds: u64,
},
ExchangeNodeEnrollmentGrant {
tenant: String,
project: String,
node: String,
public_key: String,
enrollment_grant: String,
},
NodeHeartbeat {
node: String,
#[serde(default)]
node_signature: Option<NodeSignedRequest>,
},
SignedNode {
node: String,
node_signature: NodeSignedRequest,
request: Box<CoordinatorRequest>,
},
ReportNodeCapabilities {
tenant: String,
project: String,
node: String,
capabilities: NodeCapabilities,
cached_environment_digests: Vec<Digest>,
#[serde(default)]
dependency_cache_digests: Vec<Digest>,
source_snapshots: Vec<Digest>,
artifact_locations: Vec<String>,
direct_connectivity: bool,
online: bool,
},
ListNodeDescriptors {
tenant: String,
project: String,
actor_user: String,
},
RevokeNodeCredential {
tenant: String,
project: String,
actor_user: String,
node: String,
},
ScheduleTask {
tenant: String,
project: String,
environment: Option<EnvironmentRequirements>,
environment_digest: Option<Digest>,
required_capabilities: Vec<Capability>,
#[serde(default)]
dependency_cache: Option<Digest>,
source_snapshot: Option<Digest>,
required_artifacts: Vec<String>,
prefer_node: Option<String>,
},
LaunchTask {
tenant: String,
project: String,
#[serde(default)]
actor_user: Option<String>,
#[serde(default)]
actor_agent: Option<String>,
#[serde(default)]
agent_public_key_fingerprint: Option<Digest>,
#[serde(default)]
agent_signature: Option<AgentSignedRequest>,
task_spec: TaskSpec,
#[serde(default)]
wait_for_node: bool,
artifact_path: String,
wasm_module_base64: String,
},
LaunchChildTask {
tenant: String,
project: String,
process: String,
node: String,
parent_task: String,
task_spec: TaskSpec,
#[serde(default)]
wait_for_node: bool,
artifact_path: String,
wasm_module_base64: String,
},
JoinChildTask {
tenant: String,
project: String,
process: String,
node: String,
parent_task: String,
task: String,
},
PollTaskAssignment {
tenant: String,
project: String,
node: String,
},
PollArtifactTransfer {
tenant: String,
project: String,
node: String,
},
UploadArtifactTransferChunk {
tenant: String,
project: String,
node: String,
transfer_id: String,
artifact: String,
offset: u64,
content_base64: String,
chunk_digest: Digest,
eof: bool,
},
FailArtifactTransfer {
tenant: String,
project: String,
node: String,
transfer_id: String,
artifact: String,
message: String,
},
RequestRendezvous {
scope: DataPlaneScope,
source: NodeEndpoint,
destination: NodeEndpoint,
direct_connectivity: bool,
failure_reason: String,
},
RequestSourcePreparation {
tenant: String,
project: String,
provider: SourceProviderKind,
},
CompleteSourcePreparation {
tenant: String,
project: String,
node: String,
provider: SourceProviderKind,
source_snapshot: Digest,
},
StartProcess {
tenant: String,
project: String,
#[serde(default)]
actor_user: Option<String>,
#[serde(default)]
actor_agent: Option<String>,
#[serde(default)]
agent_public_key_fingerprint: Option<Digest>,
#[serde(default)]
agent_signature: Option<AgentSignedRequest>,
process: String,
#[serde(default)]
restart: bool,
},
ReconnectNode {
node: String,
process: String,
epoch: u64,
},
CancelTask {
tenant: String,
project: String,
process: String,
node: String,
task: String,
},
CancelProcess {
tenant: String,
project: String,
actor_user: String,
process: String,
},
AbortProcess {
tenant: String,
project: String,
actor_user: String,
process: String,
},
ListProcesses {
tenant: String,
project: String,
actor_user: String,
},
QuotaStatus {
tenant: String,
project: String,
actor_user: String,
},
PollTaskControl {
tenant: String,
project: String,
process: String,
node: String,
task: String,
},
RestartTask {
tenant: String,
project: String,
actor_user: String,
process: String,
task: String,
#[serde(default)]
replacement_bundle: Option<TaskReplacementBundle>,
},
DebugAttach {
tenant: String,
project: String,
actor_user: String,
process: String,
},
SetDebugBreakpoints {
tenant: String,
project: String,
actor_user: String,
process: String,
probe_symbols: Vec<String>,
},
InspectDebugBreakpoints {
tenant: String,
project: String,
actor_user: String,
process: String,
},
CreateDebugEpoch {
tenant: String,
project: String,
actor_user: String,
process: String,
stopped_task: String,
reason: String,
},
ResumeDebugEpoch {
tenant: String,
project: String,
actor_user: String,
process: String,
epoch: u64,
},
InspectDebugEpoch {
tenant: String,
project: String,
actor_user: String,
process: String,
epoch: u64,
},
PollDebugCommand {
tenant: String,
project: String,
process: String,
node: String,
task: String,
},
ReportDebugState {
tenant: String,
project: String,
process: String,
node: String,
task: String,
epoch: u64,
state: DebugAcknowledgementState,
#[serde(default)]
stack_frames: Vec<String>,
#[serde(default)]
local_values: Vec<(String, String)>,
#[serde(default)]
task_args: Vec<(String, String)>,
#[serde(default)]
handles: Vec<(String, String)>,
#[serde(default)]
command_status: Option<String>,
#[serde(default)]
recent_output: Vec<String>,
#[serde(default)]
message: Option<String>,
},
ReportDebugProbeHit {
tenant: String,
project: String,
process: String,
node: String,
task: String,
probe_symbol: String,
},
ReportTaskLog {
tenant: String,
project: String,
process: String,
node: String,
task: String,
stdout_bytes: u64,
stderr_bytes: u64,
#[serde(default)]
stdout_tail: String,
#[serde(default)]
stderr_tail: String,
stdout_truncated: bool,
stderr_truncated: bool,
backpressured: bool,
},
ReportVfsMetadata {
tenant: String,
project: String,
process: String,
node: String,
task: String,
artifact_path: Option<String>,
artifact_digest: Option<Digest>,
artifact_size_bytes: Option<u64>,
large_bytes_uploaded: bool,
},
TaskCompleted {
tenant: String,
project: String,
process: String,
node: String,
task: String,
#[serde(default)]
terminal_state: Option<TaskTerminalState>,
status_code: Option<i32>,
stdout_bytes: u64,
stderr_bytes: u64,
#[serde(default)]
stdout_tail: String,
#[serde(default)]
stderr_tail: String,
#[serde(default)]
stdout_truncated: bool,
#[serde(default)]
stderr_truncated: bool,
artifact_path: Option<String>,
artifact_digest: Option<Digest>,
artifact_size_bytes: Option<u64>,
#[serde(default)]
result: Option<TaskBoundaryValue>,
},
ListTaskEvents {
tenant: String,
project: String,
actor_user: String,
#[serde(default)]
process: Option<String>,
},
JoinTask {
tenant: String,
project: String,
actor_user: String,
process: String,
task: String,
},
RenderOperatorPanel {
tenant: String,
project: String,
process: String,
actor_user: String,
max_download_bytes: u64,
stopped: bool,
},
SubmitPanelEvent {
tenant: String,
project: String,
process: String,
widget_id: String,
kind: PanelEventKind,
max_events: u64,
},
CreateArtifactDownloadLink {
tenant: String,
project: String,
actor_user: String,
artifact: String,
max_bytes: u64,
#[serde(default = "default_download_ttl_seconds")]
ttl_seconds: u64,
},
OpenArtifactDownloadStream {
tenant: String,
project: String,
actor_user: String,
artifact: String,
max_bytes: u64,
token_digest: Digest,
chunk_bytes: u64,
},
RevokeArtifactDownloadLink {
tenant: String,
project: String,
actor_user: String,
artifact: String,
token_digest: Digest,
},
ExportArtifactToNode {
tenant: String,
project: String,
actor_user: String,
artifact: String,
receiver_node: String,
direct_connectivity: bool,
failure_reason: String,
},
}
impl CoordinatorRequest {
pub fn operation(&self) -> Result<String, String> {
serde_json::to_value(self)
.map_err(|err| format!("failed to encode coordinator request operation: {err}"))
.map(|value| disasmer_core::coordinator_payload_operation(&value))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum AuthenticatedCoordinatorRequest {
AuthStatus,
RevokeCliSession,
CreateProject {
project: String,
name: String,
},
SelectProject {
project: String,
},
ListProjects,
RegisterAgentPublicKey {
agent: String,
public_key: String,
},
ListAgentPublicKeys,
RotateAgentPublicKey {
agent: String,
public_key: String,
},
RevokeAgentPublicKey {
agent: String,
},
CreateNodeEnrollmentGrant {
#[serde(default = "default_node_enrollment_ttl_seconds")]
ttl_seconds: u64,
},
ListNodeDescriptors,
RevokeNodeCredential {
node: String,
},
StartProcess {
process: String,
#[serde(default)]
restart: bool,
},
ScheduleTask {
environment: Option<EnvironmentRequirements>,
environment_digest: Option<Digest>,
required_capabilities: Vec<Capability>,
#[serde(default)]
dependency_cache: Option<Digest>,
source_snapshot: Option<Digest>,
required_artifacts: Vec<String>,
prefer_node: Option<String>,
},
LaunchTask {
task_spec: Box<TaskSpec>,
#[serde(default)]
wait_for_node: bool,
artifact_path: String,
wasm_module_base64: String,
},
CancelProcess {
process: String,
},
AbortProcess {
process: String,
},
ListProcesses,
QuotaStatus,
RestartTask {
process: String,
task: String,
#[serde(default)]
replacement_bundle: Option<TaskReplacementBundle>,
},
DebugAttach {
process: String,
},
SetDebugBreakpoints {
process: String,
probe_symbols: Vec<String>,
},
InspectDebugBreakpoints {
process: String,
},
CreateDebugEpoch {
process: String,
stopped_task: String,
reason: String,
},
ResumeDebugEpoch {
process: String,
epoch: u64,
},
InspectDebugEpoch {
process: String,
epoch: u64,
},
ListTaskEvents {
#[serde(default)]
process: Option<String>,
},
JoinTask {
process: String,
task: String,
},
CreateArtifactDownloadLink {
artifact: String,
max_bytes: u64,
#[serde(default = "default_download_ttl_seconds")]
ttl_seconds: u64,
},
OpenArtifactDownloadStream {
artifact: String,
max_bytes: u64,
token_digest: Digest,
chunk_bytes: u64,
},
RevokeArtifactDownloadLink {
artifact: String,
token_digest: Digest,
},
ExportArtifactToNode {
artifact: String,
receiver_node: String,
direct_connectivity: bool,
failure_reason: String,
},
}
fn default_download_ttl_seconds() -> u64 {
900
}
fn default_node_enrollment_ttl_seconds() -> u64 {
900
}

View file

@ -0,0 +1,492 @@
use super::*;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskTerminalState {
Completed,
Failed,
Cancelled,
}
impl TaskTerminalState {
pub(crate) fn from_status_code(status_code: Option<i32>) -> Self {
match status_code {
Some(0) => Self::Completed,
_ => Self::Failed,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskExecutor {
CoordinatorMain,
Node,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskCompletionEvent {
pub tenant: TenantId,
pub project: ProjectId,
pub process: ProcessId,
pub node: NodeId,
pub executor: TaskExecutor,
pub task_definition: disasmer_core::TaskDefinitionId,
pub task: TaskInstanceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placement: Option<Placement>,
pub terminal_state: TaskTerminalState,
pub status_code: Option<i32>,
pub stdout_bytes: u64,
pub stderr_bytes: u64,
pub stdout_tail: String,
pub stderr_tail: String,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
pub artifact_path: Option<VfsPath>,
pub artifact_digest: Option<Digest>,
pub artifact_size_bytes: Option<u64>,
pub result: Option<TaskBoundaryValue>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugAuditEvent {
pub tenant: TenantId,
pub project: ProjectId,
pub process: ProcessId,
pub task: Option<TaskInstanceId>,
pub actor: UserId,
pub operation: String,
pub allowed: bool,
pub reason: String,
pub charged_debug_read_bytes: u64,
pub used_debug_read_bytes: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowActor {
pub kind: String,
pub user: Option<UserId>,
pub agent: Option<AgentId>,
pub credential_kind: CredentialKind,
pub public_key_fingerprint: Option<Digest>,
pub authenticated_without_browser: bool,
pub scopes: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskAssignment {
pub tenant: TenantId,
pub project: ProjectId,
pub process: ProcessId,
pub task: TaskInstanceId,
pub node: NodeId,
pub epoch: u64,
pub artifact_path: String,
pub task_spec: TaskSpec,
pub wasm_module_base64: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactTransferAssignment {
pub transfer_id: String,
pub artifact: ArtifactId,
pub expected_digest: Digest,
pub expected_size_bytes: u64,
pub offset: u64,
pub max_chunk_bytes: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskCancellationTarget {
pub process: ProcessId,
pub task: TaskInstanceId,
pub node: NodeId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DebugAcknowledgementState {
Frozen,
Running,
Failed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugParticipantAcknowledgement {
pub node: NodeId,
pub task_definition: disasmer_core::TaskDefinitionId,
pub task: TaskInstanceId,
pub epoch: u64,
pub state: DebugAcknowledgementState,
pub stack_frames: Vec<String>,
pub local_values: Vec<(String, String)>,
pub task_args: Vec<(String, String)>,
pub handles: Vec<(String, String)>,
pub command_status: Option<String>,
pub recent_output: Vec<String>,
pub message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VirtualProcessStatus {
pub process: ProcessId,
pub state: String,
pub main_task_definition: Option<disasmer_core::TaskDefinitionId>,
pub main_task_instance: Option<TaskInstanceId>,
pub main_state: Option<String>,
pub main_wait_state: Option<String>,
pub main_debug_epoch: Option<u64>,
pub connected_nodes: Vec<NodeId>,
pub coordinator_epoch: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SourcePreparationDisposition {
Pending { reason: String },
Assigned { node: NodeId },
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourcePreparationStatus {
pub preparation: SourcePreparation,
pub disposition: SourcePreparationDisposition,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CoordinatorResponse {
Pong {
epoch: u64,
},
AuthStatus {
tenant: TenantId,
project: ProjectId,
actor: UserId,
authenticated: bool,
account_status: String,
suspended: bool,
disabled: bool,
deleted: bool,
manual_review: bool,
sanitized_reason: Option<String>,
next_actions: Vec<String>,
private_moderation_details_exposed: bool,
signup_failure_details_exposed: bool,
},
AdminStatus {
tenant: TenantId,
actor: UserId,
suspended: bool,
safe_default: String,
},
TenantSuspended {
tenant: TenantId,
actor: UserId,
policy: crate::ServicePolicyRecord,
},
ProjectCreated {
project: ProjectRecord,
actor: UserId,
},
ProjectSelected {
project: ProjectRecord,
actor: UserId,
},
Projects {
projects: Vec<ProjectRecord>,
actor: UserId,
},
CliSessionRevoked {
tenant: TenantId,
project: ProjectId,
actor: UserId,
},
AgentPublicKey {
record: AgentPublicKeyRecord,
actor: UserId,
},
AgentPublicKeys {
records: Vec<AgentPublicKeyRecord>,
actor: UserId,
},
NodeAttached {
node: NodeId,
tenant: TenantId,
project: ProjectId,
},
NodeEnrollmentGrantCreated {
tenant: TenantId,
project: ProjectId,
grant: String,
scope: String,
expires_at_epoch_seconds: u64,
},
NodeEnrollmentExchanged {
node: NodeId,
tenant: TenantId,
project: ProjectId,
credential: disasmer_core::NodeCredential,
},
NodeHeartbeat {
node: NodeId,
epoch: u64,
},
NodeCapabilitiesRecorded {
node: NodeId,
node_descriptors: usize,
},
NodeDescriptors {
descriptors: Vec<NodeDescriptor>,
actor: UserId,
},
NodeCredentialRevoked {
node: NodeId,
tenant: TenantId,
project: ProjectId,
actor: UserId,
descriptor_removed: bool,
queued_assignments_removed: usize,
},
TaskPlacement {
placement: Placement,
},
TaskLaunched {
process: ProcessId,
task: TaskInstanceId,
actor: WorkflowActor,
placement: Placement,
assignment: Box<TaskAssignment>,
charged_spawns: u64,
},
MainLaunched {
process: ProcessId,
task_definition: disasmer_core::TaskDefinitionId,
task_instance: TaskInstanceId,
actor: WorkflowActor,
state: String,
},
TaskQueued {
process: ProcessId,
task: TaskInstanceId,
actor: WorkflowActor,
reason: String,
charged_spawns: u64,
queued_tasks: usize,
},
TaskAssignment {
assignment: Option<Box<TaskAssignment>>,
},
ArtifactTransferAssignment {
transfer: Option<ArtifactTransferAssignment>,
},
ArtifactTransferChunkAccepted {
transfer_id: String,
next_offset: u64,
complete: bool,
},
ArtifactTransferFailed {
transfer_id: String,
},
RendezvousPlan {
plan: DirectBulkTransferPlan,
charged_rendezvous_attempts: u64,
},
SourcePreparation {
status: SourcePreparationStatus,
},
SourcePreparationCompleted {
node: NodeId,
provider: SourceProviderKind,
source_snapshot: Digest,
},
ProcessStarted {
process: ProcessId,
epoch: u64,
actor: WorkflowActor,
charged_spawns: u64,
},
NodeReconnected {
node: NodeId,
process: ProcessId,
},
TaskCancellationRequested {
process: ProcessId,
task: TaskInstanceId,
node: NodeId,
},
ProcessCancellationRequested {
process: ProcessId,
cancelled_tasks: Vec<TaskCancellationTarget>,
affected_nodes: Vec<NodeId>,
},
ProcessAborted {
process: ProcessId,
aborted_tasks: Vec<TaskCancellationTarget>,
affected_nodes: Vec<NodeId>,
},
ProcessStatuses {
processes: Vec<VirtualProcessStatus>,
actor: UserId,
},
QuotaStatus {
tenant: TenantId,
project: ProjectId,
actor: UserId,
#[serde(default, skip_serializing_if = "Option::is_none")]
policy_label: Option<String>,
limits: ResourceLimits,
window_seconds: BTreeMap<LimitKind, u64>,
usage: BTreeMap<LimitKind, u64>,
window_started_epoch_seconds: BTreeMap<LimitKind, u64>,
},
TaskControl {
process: ProcessId,
task: TaskInstanceId,
cancel_requested: bool,
abort_requested: bool,
},
TaskRestart {
process: ProcessId,
task: TaskInstanceId,
restarted_task_instance: Option<disasmer_core::TaskInstanceId>,
actor: UserId,
accepted: bool,
clean_boundary_available: bool,
active_task: bool,
completed_event_observed: bool,
requires_whole_process_restart: bool,
message: String,
audit_event: DebugAuditEvent,
charged_debug_read_bytes: u64,
used_debug_read_bytes: u64,
},
DebugCommand {
process: ProcessId,
task: TaskInstanceId,
epoch: Option<u64>,
command: Option<String>,
},
DebugStateRecorded {
process: ProcessId,
node: NodeId,
task: TaskInstanceId,
epoch: u64,
state: DebugAcknowledgementState,
},
DebugAttach {
process: ProcessId,
actor: UserId,
authorization: Authorization,
audit_event: DebugAuditEvent,
charged_debug_read_bytes: u64,
used_debug_read_bytes: u64,
},
DebugBreakpoints {
process: ProcessId,
actor: UserId,
probe_symbols: Vec<String>,
hit_epoch: Option<u64>,
hit_task: Option<TaskInstanceId>,
hit_probe_symbol: Option<String>,
audit_event: DebugAuditEvent,
charged_debug_read_bytes: u64,
used_debug_read_bytes: u64,
},
DebugProbeHit {
process: ProcessId,
node: NodeId,
task: TaskInstanceId,
probe_symbol: String,
breakpoint_matched: bool,
debug_epoch: Option<u64>,
},
DebugEpoch {
process: ProcessId,
actor: UserId,
epoch: u64,
command: String,
affected_tasks: Vec<TaskCancellationTarget>,
all_stop_requested: bool,
audit_event: DebugAuditEvent,
charged_debug_read_bytes: u64,
used_debug_read_bytes: u64,
},
DebugEpochStatus {
process: ProcessId,
actor: UserId,
epoch: u64,
command: String,
expected_tasks: Vec<TaskCancellationTarget>,
acknowledgements: Vec<DebugParticipantAcknowledgement>,
fully_frozen: bool,
fully_resumed: bool,
failed: bool,
failure_messages: Vec<String>,
audit_event: DebugAuditEvent,
charged_debug_read_bytes: u64,
used_debug_read_bytes: u64,
},
TaskLogRecorded {
process: ProcessId,
task: TaskInstanceId,
stdout_bytes: u64,
stderr_bytes: u64,
stdout_tail: String,
stderr_tail: String,
backpressured: bool,
},
VfsMetadataRecorded {
process: ProcessId,
task: TaskInstanceId,
artifact_path: Option<VfsPath>,
large_bytes_uploaded: bool,
},
TaskRecorded {
process: ProcessId,
task: TaskInstanceId,
events_recorded: usize,
},
TaskEvents {
events: Vec<TaskCompletionEvent>,
},
TaskJoined {
join: TaskJoinResult,
},
OperatorPanel {
panel: PanelState,
},
PanelEventAccepted {
used_events: u64,
max_events: u64,
},
ArtifactDownloadLink {
link: DownloadLink,
},
ArtifactDownloadLinkRevoked {
link: DownloadLink,
},
ArtifactDownloadStream {
link: DownloadLink,
streamed_bytes: u64,
charged_download_bytes: u64,
content_bytes_available: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_offset: Option<u64>,
#[serde(default)]
content_eof: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_source: Option<String>,
},
ArtifactExportPlan {
plan: DirectBulkTransferPlan,
source_node: NodeId,
receiver_node: NodeId,
artifact_size_bytes: u64,
},
Error {
message: String,
},
}

View file

@ -0,0 +1,421 @@
use std::collections::BTreeMap;
use disasmer_core::{LimitError, LimitKind, ProjectId, ResourceLimits, ResourceMeter, TenantId};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CoordinatorQuotaConfiguration {
pub limits: ResourceLimits,
pub window_seconds: BTreeMap<LimitKind, u64>,
pub policy_label: Option<String>,
}
pub(super) struct CoordinatorQuotaStatus {
pub(super) policy_label: Option<String>,
pub(super) limits: ResourceLimits,
pub(super) window_seconds: BTreeMap<LimitKind, u64>,
pub(super) usage: BTreeMap<LimitKind, u64>,
pub(super) window_started_epoch_seconds: BTreeMap<LimitKind, u64>,
}
impl CoordinatorQuotaConfiguration {
pub fn new(
limits: ResourceLimits,
window_seconds: impl IntoIterator<Item = (LimitKind, u64)>,
) -> Result<Self, String> {
let window_seconds = window_seconds.into_iter().collect::<BTreeMap<_, _>>();
if window_seconds.values().any(|seconds| *seconds == 0) {
return Err("quota windows must be at least one second".to_owned());
}
Ok(Self {
limits,
window_seconds,
policy_label: None,
})
}
pub fn with_policy_label(mut self, label: impl Into<String>) -> Self {
let label = label.into();
self.policy_label = (!label.trim().is_empty()).then_some(label);
self
}
pub fn unlimited() -> Self {
Self {
limits: ResourceLimits::unlimited(),
window_seconds: LimitKind::ALL
.into_iter()
.map(|kind| (kind, u64::MAX))
.collect(),
policy_label: None,
}
}
pub fn window_seconds(&self, kind: LimitKind) -> u64 {
self.window_seconds
.get(&kind)
.copied()
.unwrap_or(u64::MAX)
.max(1)
}
}
impl Default for CoordinatorQuotaConfiguration {
fn default() -> Self {
Self::unlimited()
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct ProjectQuotaScope {
tenant: TenantId,
project: ProjectId,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct MeterKey {
scope: ProjectQuotaScope,
kind: LimitKind,
window: u64,
}
#[derive(Clone, Debug)]
pub(super) struct CoordinatorQuota {
configuration: CoordinatorQuotaConfiguration,
meters: BTreeMap<MeterKey, ResourceMeter>,
}
impl Default for CoordinatorQuota {
fn default() -> Self {
Self::new(CoordinatorQuotaConfiguration::default())
}
}
impl CoordinatorQuota {
pub(super) fn new(configuration: CoordinatorQuotaConfiguration) -> Self {
Self {
configuration,
meters: BTreeMap::new(),
}
}
fn key(
&self,
tenant: &TenantId,
project: &ProjectId,
kind: LimitKind,
now_epoch_seconds: u64,
) -> MeterKey {
MeterKey {
scope: ProjectQuotaScope {
tenant: tenant.clone(),
project: project.clone(),
},
kind,
window: now_epoch_seconds / self.configuration.window_seconds(kind),
}
}
fn meter(
&self,
tenant: &TenantId,
project: &ProjectId,
kind: LimitKind,
now_epoch_seconds: u64,
) -> Option<&ResourceMeter> {
self.meters
.get(&self.key(tenant, project, kind, now_epoch_seconds))
}
fn meter_mut(
&mut self,
tenant: &TenantId,
project: &ProjectId,
kind: LimitKind,
now_epoch_seconds: u64,
) -> &mut ResourceMeter {
let key = self.key(tenant, project, kind, now_epoch_seconds);
self.meters.retain(|existing, _| {
existing.scope != key.scope || existing.kind != kind || existing.window == key.window
});
self.meters.entry(key).or_default()
}
fn can_charge(
&self,
tenant: &TenantId,
project: &ProjectId,
kind: LimitKind,
amount: u64,
now_epoch_seconds: u64,
) -> Result<(), LimitError> {
self.meter(tenant, project, kind, now_epoch_seconds)
.cloned()
.unwrap_or_default()
.can_charge(&self.configuration.limits, kind, amount)
}
fn charge(
&mut self,
tenant: &TenantId,
project: &ProjectId,
kind: LimitKind,
amount: u64,
now_epoch_seconds: u64,
) -> Result<u64, LimitError> {
let limits = self.configuration.limits.clone();
let meter = self.meter_mut(tenant, project, kind, now_epoch_seconds);
meter.charge(&limits, kind, amount)?;
Ok(meter.used(&kind))
}
fn used(
&self,
tenant: &TenantId,
project: &ProjectId,
kind: LimitKind,
now_epoch_seconds: u64,
) -> u64 {
self.meter(tenant, project, kind, now_epoch_seconds)
.map_or(0, |meter| meter.used(&kind))
}
pub(super) fn can_charge_workflow_spawn(
&self,
tenant: &TenantId,
project: &ProjectId,
now_epoch_seconds: u64,
) -> Result<(), LimitError> {
self.can_charge(tenant, project, LimitKind::Spawn, 1, now_epoch_seconds)
}
pub(super) fn charge_api_call(
&mut self,
tenant: &TenantId,
project: &ProjectId,
now_epoch_seconds: u64,
) -> Result<u64, LimitError> {
self.charge(tenant, project, LimitKind::ApiCall, 1, now_epoch_seconds)
}
pub(super) fn can_charge_log_bytes(
&self,
tenant: &TenantId,
project: &ProjectId,
bytes: u64,
now_epoch_seconds: u64,
) -> Result<(), LimitError> {
self.can_charge(
tenant,
project,
LimitKind::LogBytes,
bytes,
now_epoch_seconds,
)
}
pub(super) fn charge_log_bytes(
&mut self,
tenant: &TenantId,
project: &ProjectId,
bytes: u64,
now_epoch_seconds: u64,
) -> Result<u64, LimitError> {
self.charge(
tenant,
project,
LimitKind::LogBytes,
bytes,
now_epoch_seconds,
)
}
pub(super) fn charge_workflow_spawn(
&mut self,
tenant: &TenantId,
project: &ProjectId,
now_epoch_seconds: u64,
) -> Result<u64, LimitError> {
self.charge(tenant, project, LimitKind::Spawn, 1, now_epoch_seconds)
}
#[cfg(test)]
pub(super) fn used_workflow_spawns(
&self,
tenant: &TenantId,
project: &ProjectId,
now_epoch_seconds: u64,
) -> u64 {
self.used(tenant, project, LimitKind::Spawn, now_epoch_seconds)
}
#[cfg(test)]
pub(super) fn used_api_calls(
&self,
tenant: &TenantId,
project: &ProjectId,
now_epoch_seconds: u64,
) -> u64 {
self.used(tenant, project, LimitKind::ApiCall, now_epoch_seconds)
}
#[cfg(test)]
pub(super) fn used_log_bytes(
&self,
tenant: &TenantId,
project: &ProjectId,
now_epoch_seconds: u64,
) -> u64 {
self.used(tenant, project, LimitKind::LogBytes, now_epoch_seconds)
}
#[cfg(test)]
pub(super) fn active_meter_count(&self) -> usize {
self.meters.len()
}
pub(super) fn charge_rendezvous_attempt(
&mut self,
tenant: &TenantId,
project: &ProjectId,
now_epoch_seconds: u64,
) -> Result<u64, LimitError> {
self.charge(
tenant,
project,
LimitKind::RendezvousAttempt,
1,
now_epoch_seconds,
)
}
pub(super) fn can_charge_download(
&self,
tenant: &TenantId,
project: &ProjectId,
bytes: u64,
now_epoch_seconds: u64,
) -> Result<(), LimitError> {
self.can_charge(
tenant,
project,
LimitKind::ArtifactDownloadBytes,
bytes,
now_epoch_seconds,
)
}
pub(super) fn charge_download(
&mut self,
tenant: &TenantId,
project: &ProjectId,
bytes: u64,
now_epoch_seconds: u64,
) -> Result<u64, LimitError> {
self.charge(
tenant,
project,
LimitKind::ArtifactDownloadBytes,
bytes,
now_epoch_seconds,
)
}
pub(super) fn download_limit(&self) -> u64 {
self.configuration
.limits
.limit(&LimitKind::ArtifactDownloadBytes)
}
pub(super) fn used_download_bytes(
&self,
tenant: &TenantId,
project: &ProjectId,
now_epoch_seconds: u64,
) -> u64 {
self.used(
tenant,
project,
LimitKind::ArtifactDownloadBytes,
now_epoch_seconds,
)
}
pub(super) fn charge_debug_read(
&mut self,
tenant: &TenantId,
project: &ProjectId,
bytes: u64,
now_epoch_seconds: u64,
) -> Result<u64, LimitError> {
self.charge(
tenant,
project,
LimitKind::DebugReadBytes,
bytes,
now_epoch_seconds,
)
}
pub(super) fn used_debug_read_bytes(
&self,
tenant: &TenantId,
project: &ProjectId,
now_epoch_seconds: u64,
) -> u64 {
self.used(
tenant,
project,
LimitKind::DebugReadBytes,
now_epoch_seconds,
)
}
pub(super) fn project_status(
&self,
tenant: &TenantId,
project: &ProjectId,
now_epoch_seconds: u64,
) -> CoordinatorQuotaStatus {
let mut usage = BTreeMap::new();
let mut window_starts = BTreeMap::new();
for kind in LimitKind::ALL {
let seconds = self.configuration.window_seconds(kind);
usage.insert(kind, self.used(tenant, project, kind, now_epoch_seconds));
window_starts.insert(kind, (now_epoch_seconds / seconds).saturating_mul(seconds));
}
CoordinatorQuotaStatus {
policy_label: self.configuration.policy_label.clone(),
limits: self.configuration.limits.clone(),
window_seconds: self.configuration.window_seconds.clone(),
usage,
window_started_epoch_seconds: window_starts,
}
}
#[cfg(test)]
pub(super) fn set_workflow_limits(&mut self, limits: ResourceLimits) {
self.configuration
.limits
.limits
.insert(LimitKind::Spawn, limits.limit(&LimitKind::Spawn));
self.meters.clear();
}
#[cfg(test)]
pub(super) fn set_download_limits(&mut self, limits: ResourceLimits) {
self.configuration.limits.limits.insert(
LimitKind::ArtifactDownloadBytes,
limits.limit(&LimitKind::ArtifactDownloadBytes),
);
self.meters.clear();
}
#[cfg(test)]
pub(super) fn set_rendezvous_limits(&mut self, limits: ResourceLimits) {
self.configuration.limits.limits.insert(
LimitKind::RendezvousAttempt,
limits.limit(&LimitKind::RendezvousAttempt),
);
self.meters.clear();
}
}

View file

@ -0,0 +1,778 @@
use super::*;
impl CoordinatorService {
pub fn handle_request(
&mut self,
request: CoordinatorRequest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
self.pump_main_runtime_commands();
let request_payload = serde_json::to_value(&request).map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"failed to canonicalize coordinator request for authentication: {error}"
))
})?;
let request_payload_digest = disasmer_core::signed_request_payload_digest(&request_payload);
match request {
CoordinatorRequest::Ping => Ok(CoordinatorResponse::Pong {
epoch: self.coordinator.coordinator_epoch(),
}),
CoordinatorRequest::Authenticated {
session_secret,
request,
} => self.handle_authenticated_request(session_secret, request),
CoordinatorRequest::AuthStatus {
tenant,
project,
actor_user,
} => {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let account_state = self.coordinator.account_policy_state(&tenant);
Ok(CoordinatorResponse::AuthStatus {
tenant,
project,
actor,
authenticated: true,
account_status: account_state.account_status,
suspended: account_state.suspended,
disabled: account_state.disabled,
deleted: account_state.deleted,
manual_review: account_state.manual_review,
sanitized_reason: account_state.sanitized_reason,
next_actions: account_state.next_actions,
private_moderation_details_exposed: false,
signup_failure_details_exposed: false,
})
}
CoordinatorRequest::AdminStatus {
tenant,
actor_user,
admin_proof,
admin_nonce,
issued_at_epoch_seconds,
} => self.handle_admin_status(
tenant,
actor_user,
admin_proof,
admin_nonce,
issued_at_epoch_seconds,
),
CoordinatorRequest::SuspendTenant {
tenant,
actor_user,
target_tenant,
admin_proof,
admin_nonce,
issued_at_epoch_seconds,
} => self.handle_suspend_tenant(
tenant,
actor_user,
target_tenant,
admin_proof,
admin_nonce,
issued_at_epoch_seconds,
),
CoordinatorRequest::CreateProject {
tenant,
actor_user,
project,
name,
} => {
let tenant = TenantId::new(tenant);
let actor = UserId::new(actor_user);
let project = ProjectId::new(project);
self.coordinator.ensure_tenant_active(&tenant)?;
if let Some(existing) = self.coordinator.project(&project) {
if existing.tenant != tenant {
return Err(CoordinatorError::Unauthorized(
"project id is outside the signed-in tenant scope".to_owned(),
)
.into());
}
}
self.coordinator.upsert_tenant(tenant.clone());
self.coordinator.upsert_user(
tenant.clone(),
actor.clone(),
CredentialKind::BrowserSession,
);
self.coordinator
.upsert_project(tenant.clone(), project.clone(), name);
self.coordinator.grant_project_debug(
tenant.clone(),
project.clone(),
actor.clone(),
);
self.persist_durable_state()?;
let project = self
.coordinator
.project(&project)
.expect("project was just created")
.clone();
Ok(CoordinatorResponse::ProjectCreated { project, actor })
}
CoordinatorRequest::SelectProject {
tenant,
actor_user,
project,
} => {
let tenant = TenantId::new(tenant);
let actor = UserId::new(actor_user);
let project_id = ProjectId::new(project);
let project = self
.coordinator
.project(&project_id)
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"project is not visible to the signed-in user".to_owned(),
)
})?
.clone();
if project.tenant != tenant {
return Err(CoordinatorError::Unauthorized(
"project is outside the signed-in tenant scope".to_owned(),
)
.into());
}
self.coordinator
.upsert_user(tenant, actor.clone(), CredentialKind::BrowserSession);
self.persist_durable_state()?;
Ok(CoordinatorResponse::ProjectSelected { project, actor })
}
CoordinatorRequest::ListProjects { tenant, actor_user } => {
let tenant = TenantId::new(tenant);
let actor = UserId::new(actor_user);
let context = disasmer_core::AuthContext {
tenant: tenant.clone(),
project: ProjectId::from("__project_listing__"),
actor: Actor::User(actor.clone()),
};
self.coordinator
.upsert_user(tenant, actor.clone(), CredentialKind::BrowserSession);
self.persist_durable_state()?;
Ok(CoordinatorResponse::Projects {
projects: self.coordinator.list_projects(&context),
actor,
})
}
CoordinatorRequest::RegisterAgentPublicKey {
tenant,
project,
user,
agent,
public_key,
}
| CoordinatorRequest::RotateAgentPublicKey {
tenant,
project,
user,
agent,
public_key,
} => {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(user);
let agent = AgentId::new(agent);
self.coordinator.ensure_tenant_active(&tenant)?;
if let Some(existing) = self.coordinator.project(&project) {
if existing.tenant != tenant {
return Err(CoordinatorError::Unauthorized(
"project id is outside the signed-in tenant scope".to_owned(),
)
.into());
}
}
self.coordinator.upsert_tenant(tenant.clone());
self.coordinator.upsert_user(
tenant.clone(),
actor.clone(),
CredentialKind::CliDeviceSession,
);
self.coordinator
.upsert_project(tenant.clone(), project.clone(), "local");
let record = self.coordinator.register_agent_public_key(
tenant,
project,
actor.clone(),
agent,
public_key,
);
self.persist_durable_state()?;
Ok(CoordinatorResponse::AgentPublicKey { record, actor })
}
CoordinatorRequest::ListAgentPublicKeys {
tenant,
project,
user,
} => {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(user);
let context = disasmer_core::AuthContext {
tenant,
project,
actor: Actor::User(actor.clone()),
};
Ok(CoordinatorResponse::AgentPublicKeys {
records: self.coordinator.list_agent_public_keys(&context),
actor,
})
}
CoordinatorRequest::RevokeAgentPublicKey {
tenant,
project,
user,
agent,
} => {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(user);
let agent = AgentId::new(agent);
let context = disasmer_core::AuthContext {
tenant,
project,
actor: Actor::User(actor.clone()),
};
let record = self.coordinator.revoke_agent_public_key(&context, &agent)?;
self.persist_durable_state()?;
Ok(CoordinatorResponse::AgentPublicKey { record, actor })
}
CoordinatorRequest::AttachNode {
tenant,
project,
node,
public_key,
} => self.handle_attach_node(tenant, project, node, public_key),
CoordinatorRequest::CreateNodeEnrollmentGrant {
tenant,
project,
actor_user,
ttl_seconds,
} => self.handle_create_node_enrollment_grant(tenant, project, actor_user, ttl_seconds),
CoordinatorRequest::ExchangeNodeEnrollmentGrant {
tenant,
project,
node,
public_key,
enrollment_grant,
} => self.handle_exchange_node_enrollment_grant(
tenant,
project,
node,
public_key,
enrollment_grant,
),
CoordinatorRequest::NodeHeartbeat {
node,
node_signature,
} => self.handle_node_heartbeat(node, node_signature, &request_payload_digest),
CoordinatorRequest::SignedNode {
node,
node_signature,
request,
} => self.handle_signed_node_request(node, node_signature, *request),
CoordinatorRequest::ReportNodeCapabilities { .. } => {
self.reject_unsigned_node_request()
}
CoordinatorRequest::ListNodeDescriptors {
tenant,
project,
actor_user,
} => self.handle_list_node_descriptors(tenant, project, actor_user),
CoordinatorRequest::RevokeNodeCredential {
tenant,
project,
actor_user,
node,
} => self.handle_revoke_node_credential(tenant, project, actor_user, node),
CoordinatorRequest::ScheduleTask {
tenant,
project,
environment,
environment_digest,
required_capabilities,
dependency_cache,
source_snapshot,
required_artifacts,
prefer_node,
} => self.handle_schedule_task(
tenant,
project,
environment,
environment_digest,
required_capabilities,
dependency_cache,
source_snapshot,
required_artifacts,
prefer_node,
),
CoordinatorRequest::LaunchTask {
tenant,
project,
actor_user,
actor_agent,
agent_public_key_fingerprint,
agent_signature,
task_spec,
wait_for_node,
artifact_path,
wasm_module_base64,
} => self.handle_launch_task(
tenant,
project,
actor_user,
actor_agent,
agent_public_key_fingerprint,
agent_signature,
Some(&request_payload_digest),
task_spec,
wait_for_node,
artifact_path,
wasm_module_base64,
),
CoordinatorRequest::LaunchChildTask { .. } => self.reject_unsigned_node_request(),
CoordinatorRequest::JoinChildTask { .. } => self.reject_unsigned_node_request(),
CoordinatorRequest::PollTaskAssignment { .. } => self.reject_unsigned_node_request(),
CoordinatorRequest::RequestRendezvous {
scope,
source,
destination,
direct_connectivity,
failure_reason,
} => self.handle_request_rendezvous(
scope,
source,
destination,
direct_connectivity,
failure_reason,
),
CoordinatorRequest::RequestSourcePreparation {
tenant,
project,
provider,
} => self.handle_request_source_preparation(tenant, project, provider),
CoordinatorRequest::CompleteSourcePreparation { .. } => {
self.reject_unsigned_node_request()
}
CoordinatorRequest::StartProcess {
tenant,
project,
actor_user,
actor_agent,
agent_public_key_fingerprint,
agent_signature,
process,
restart,
} => self.handle_start_process(
tenant,
project,
actor_user,
actor_agent,
agent_public_key_fingerprint,
agent_signature,
Some(&request_payload_digest),
process,
restart,
),
CoordinatorRequest::ReconnectNode { .. } => self.reject_unsigned_node_request(),
CoordinatorRequest::CancelTask {
tenant,
project,
process,
node,
task,
} => self.handle_cancel_task(tenant, project, process, node, task),
CoordinatorRequest::CancelProcess {
tenant,
project,
actor_user,
process,
} => self.handle_cancel_process(tenant, project, actor_user, process),
CoordinatorRequest::AbortProcess {
tenant,
project,
actor_user,
process,
} => self.handle_abort_process(tenant, project, actor_user, process),
CoordinatorRequest::ListProcesses {
tenant,
project,
actor_user,
} => self.handle_list_processes(tenant, project, actor_user),
CoordinatorRequest::QuotaStatus {
tenant,
project,
actor_user,
} => self.handle_quota_status(tenant, project, actor_user),
CoordinatorRequest::PollTaskControl { .. } => self.reject_unsigned_node_request(),
CoordinatorRequest::PollArtifactTransfer { .. }
| CoordinatorRequest::UploadArtifactTransferChunk { .. }
| CoordinatorRequest::FailArtifactTransfer { .. } => {
self.reject_unsigned_node_request()
}
CoordinatorRequest::RestartTask {
tenant,
project,
actor_user,
process,
task,
replacement_bundle,
} => self.handle_restart_task(
tenant,
project,
actor_user,
process,
task,
replacement_bundle,
),
request @ (CoordinatorRequest::DebugAttach { .. }
| CoordinatorRequest::SetDebugBreakpoints { .. }
| CoordinatorRequest::InspectDebugBreakpoints { .. }
| CoordinatorRequest::CreateDebugEpoch { .. }
| CoordinatorRequest::ResumeDebugEpoch { .. }
| CoordinatorRequest::InspectDebugEpoch { .. }) => self.handle_debug_request(request),
CoordinatorRequest::PollDebugCommand { .. }
| CoordinatorRequest::ReportDebugState { .. }
| CoordinatorRequest::ReportDebugProbeHit { .. } => self.reject_unsigned_node_request(),
CoordinatorRequest::ReportTaskLog { .. } => self.reject_unsigned_node_request(),
CoordinatorRequest::ReportVfsMetadata { .. } => self.reject_unsigned_node_request(),
CoordinatorRequest::TaskCompleted { .. } => self.reject_unsigned_node_request(),
CoordinatorRequest::ListTaskEvents {
tenant,
project,
actor_user,
process,
} => self.handle_list_task_events(tenant, project, actor_user, process),
CoordinatorRequest::JoinTask {
tenant,
project,
actor_user,
process,
task,
} => self.handle_join_task(tenant, project, actor_user, process, task),
CoordinatorRequest::RenderOperatorPanel {
tenant,
project,
process,
actor_user,
max_download_bytes,
stopped,
} => self.handle_render_operator_panel(
tenant,
project,
actor_user,
process,
max_download_bytes,
stopped,
),
CoordinatorRequest::SubmitPanelEvent {
tenant,
project,
process,
widget_id,
kind,
max_events,
} => self
.handle_submit_panel_event(tenant, project, process, widget_id, kind, max_events),
CoordinatorRequest::CreateArtifactDownloadLink {
tenant,
project,
actor_user,
artifact,
max_bytes,
ttl_seconds,
} => self.handle_create_artifact_download_link(
tenant,
project,
actor_user,
artifact,
max_bytes,
ttl_seconds,
),
CoordinatorRequest::OpenArtifactDownloadStream {
tenant,
project,
actor_user,
artifact,
max_bytes,
token_digest,
chunk_bytes,
} => self.handle_open_artifact_download_stream(
tenant,
project,
actor_user,
artifact,
max_bytes,
token_digest,
chunk_bytes,
),
CoordinatorRequest::RevokeArtifactDownloadLink {
tenant,
project,
actor_user,
artifact,
token_digest,
} => self.handle_revoke_artifact_download_link(
tenant,
project,
actor_user,
artifact,
token_digest,
),
CoordinatorRequest::ExportArtifactToNode {
tenant,
project,
actor_user,
artifact,
receiver_node,
direct_connectivity,
failure_reason,
} => self.handle_export_artifact_to_node(
tenant,
project,
actor_user,
artifact,
receiver_node,
direct_connectivity,
failure_reason,
),
}
}
fn handle_authenticated_request(
&mut self,
session_secret: String,
request: AuthenticatedCoordinatorRequest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let context = self.coordinator.authenticate_cli_session(&session_secret)?;
let authorized = authorize_authenticated_user_operation(&context, &request)?;
let now_epoch_seconds = self.current_epoch_seconds()?;
self.quota
.charge_api_call(&context.tenant, &context.project, now_epoch_seconds)?;
let _authorized_operation = authorized.operation;
let actor = authorized.actor;
match request {
AuthenticatedCoordinatorRequest::AuthStatus => {
let account_state = self.coordinator.account_policy_state(&context.tenant);
Ok(CoordinatorResponse::AuthStatus {
tenant: context.tenant,
project: context.project,
actor,
authenticated: true,
account_status: account_state.account_status,
suspended: account_state.suspended,
disabled: account_state.disabled,
deleted: account_state.deleted,
manual_review: account_state.manual_review,
sanitized_reason: account_state.sanitized_reason,
next_actions: account_state.next_actions,
private_moderation_details_exposed: false,
signup_failure_details_exposed: false,
})
}
AuthenticatedCoordinatorRequest::RevokeCliSession => {
self.coordinator.revoke_cli_session(&session_secret)?;
self.persist_durable_state()?;
Ok(CoordinatorResponse::CliSessionRevoked {
tenant: context.tenant,
project: context.project,
actor,
})
}
AuthenticatedCoordinatorRequest::CreateProject { project, name } => {
let project = ProjectId::new(project);
self.coordinator.ensure_tenant_active(&context.tenant)?;
if let Some(existing) = self.coordinator.project(&project) {
if existing.tenant != context.tenant {
return Err(CoordinatorError::Unauthorized(
"project id is outside the authenticated tenant scope".to_owned(),
)
.into());
}
}
self.coordinator
.upsert_project(context.tenant.clone(), project.clone(), name);
self.coordinator.grant_project_debug(
context.tenant.clone(),
project.clone(),
actor.clone(),
);
self.persist_durable_state()?;
let project = self
.coordinator
.project(&project)
.expect("project was just created")
.clone();
Ok(CoordinatorResponse::ProjectCreated { project, actor })
}
AuthenticatedCoordinatorRequest::SelectProject { project } => {
let project_id = ProjectId::new(project);
let project = self
.coordinator
.project(&project_id)
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"project is not visible to the authenticated user".to_owned(),
)
})?
.clone();
if project.tenant != context.tenant {
return Err(CoordinatorError::Unauthorized(
"project is outside the authenticated tenant scope".to_owned(),
)
.into());
}
Ok(CoordinatorResponse::ProjectSelected { project, actor })
}
AuthenticatedCoordinatorRequest::ListProjects => Ok(CoordinatorResponse::Projects {
projects: self.coordinator.list_projects(&context),
actor,
}),
request @ (AuthenticatedCoordinatorRequest::RegisterAgentPublicKey { .. }
| AuthenticatedCoordinatorRequest::ListAgentPublicKeys
| AuthenticatedCoordinatorRequest::RotateAgentPublicKey { .. }
| AuthenticatedCoordinatorRequest::RevokeAgentPublicKey { .. }) => {
self.handle_authenticated_agent_key_request(&context, &actor, request)
}
AuthenticatedCoordinatorRequest::CreateNodeEnrollmentGrant { ttl_seconds } => self
.handle_create_node_enrollment_grant(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
ttl_seconds,
),
AuthenticatedCoordinatorRequest::ListNodeDescriptors => self
.handle_list_node_descriptors(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
),
AuthenticatedCoordinatorRequest::RevokeNodeCredential { node } => self
.handle_revoke_node_credential(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
node,
),
AuthenticatedCoordinatorRequest::StartProcess { process, restart } => self
.handle_start_process(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
Some(actor.as_str().to_owned()),
None,
None,
None,
None,
process,
restart,
),
request @ AuthenticatedCoordinatorRequest::ScheduleTask { .. } => {
self.handle_authenticated_schedule_task(&context, request)
}
request @ AuthenticatedCoordinatorRequest::LaunchTask { .. } => {
self.handle_authenticated_launch_task(&context, &actor, request)
}
AuthenticatedCoordinatorRequest::CancelProcess { process } => self
.handle_cancel_process(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
),
AuthenticatedCoordinatorRequest::AbortProcess { process } => self.handle_abort_process(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
),
AuthenticatedCoordinatorRequest::ListProcesses => self.handle_list_processes(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
),
AuthenticatedCoordinatorRequest::QuotaStatus => self.handle_quota_status(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
),
AuthenticatedCoordinatorRequest::RestartTask {
process,
task,
replacement_bundle,
} => self.handle_restart_task(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
task,
replacement_bundle,
),
request @ (AuthenticatedCoordinatorRequest::DebugAttach { .. }
| AuthenticatedCoordinatorRequest::SetDebugBreakpoints { .. }
| AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { .. }
| AuthenticatedCoordinatorRequest::CreateDebugEpoch { .. }
| AuthenticatedCoordinatorRequest::ResumeDebugEpoch { .. }
| AuthenticatedCoordinatorRequest::InspectDebugEpoch { .. }) => self
.handle_authenticated_debug_request(
&context.tenant,
&context.project,
&actor,
request,
),
AuthenticatedCoordinatorRequest::ListTaskEvents { process } => self
.handle_list_task_events(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
),
AuthenticatedCoordinatorRequest::JoinTask { process, task } => self.handle_join_task(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
task,
),
AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink {
artifact,
max_bytes,
ttl_seconds,
} => self.handle_create_artifact_download_link(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
artifact,
max_bytes,
ttl_seconds,
),
AuthenticatedCoordinatorRequest::OpenArtifactDownloadStream {
artifact,
max_bytes,
token_digest,
chunk_bytes,
} => self.handle_open_artifact_download_stream(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
artifact,
max_bytes,
token_digest,
chunk_bytes,
),
AuthenticatedCoordinatorRequest::RevokeArtifactDownloadLink {
artifact,
token_digest,
} => self.handle_revoke_artifact_download_link(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
artifact,
token_digest,
),
request @ AuthenticatedCoordinatorRequest::ExportArtifactToNode { .. } => {
self.handle_authenticated_artifact_export(&context, &actor, request)
}
}
}
}

View file

@ -0,0 +1,365 @@
use disasmer_core::{NodeId, NodeSignedRequest};
use crate::CoordinatorError;
use super::{CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError};
impl CoordinatorService {
pub(super) fn handle_signed_node_request(
&mut self,
signed_node: String,
node_signature: NodeSignedRequest,
request: CoordinatorRequest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let request_kind = signed_node_request_kind(&request)?;
let request_node = signed_node_request_node(&request)?;
let request_payload = serde_json::to_value(&request).map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"failed to canonicalize signed node request: {error}"
))
})?;
let payload_digest = disasmer_core::signed_request_payload_digest(&request_payload);
let signed_node = NodeId::new(signed_node);
if request_node != signed_node {
return Err(CoordinatorError::Unauthorized(
"signed node request node does not match the wrapped request node".to_owned(),
)
.into());
}
self.authenticate_node_request(
&signed_node,
Some(node_signature),
request_kind,
&payload_digest,
)?;
match request {
CoordinatorRequest::ReportNodeCapabilities {
tenant,
project,
node,
capabilities,
cached_environment_digests,
dependency_cache_digests,
source_snapshots,
artifact_locations,
direct_connectivity,
online,
} => self.handle_report_node_capabilities(
tenant,
project,
node,
capabilities,
cached_environment_digests,
dependency_cache_digests,
source_snapshots,
artifact_locations,
direct_connectivity,
online,
),
CoordinatorRequest::PollTaskAssignment {
tenant,
project,
node,
} => self.handle_poll_task_assignment(tenant, project, node),
CoordinatorRequest::PollArtifactTransfer {
tenant,
project,
node,
} => self.handle_poll_artifact_transfer(tenant, project, node),
CoordinatorRequest::UploadArtifactTransferChunk {
tenant,
project,
node,
transfer_id,
artifact,
offset,
content_base64,
chunk_digest,
eof,
} => self.handle_upload_artifact_transfer_chunk(
tenant,
project,
node,
transfer_id,
artifact,
offset,
content_base64,
chunk_digest,
eof,
),
CoordinatorRequest::FailArtifactTransfer {
tenant,
project,
node,
transfer_id,
artifact,
message,
} => self.handle_fail_artifact_transfer(
tenant,
project,
node,
transfer_id,
artifact,
message,
),
CoordinatorRequest::LaunchChildTask {
tenant,
project,
process,
node,
parent_task,
task_spec,
wait_for_node,
artifact_path,
wasm_module_base64,
} => self.handle_launch_child_task(
tenant,
project,
process,
node,
parent_task,
task_spec,
wait_for_node,
artifact_path,
wasm_module_base64,
),
CoordinatorRequest::JoinChildTask {
tenant,
project,
process,
node,
parent_task,
task,
} => self.handle_join_child_task(tenant, project, process, node, parent_task, task),
CoordinatorRequest::CompleteSourcePreparation {
tenant,
project,
node,
provider,
source_snapshot,
} => self.handle_complete_source_preparation(
tenant,
project,
node,
provider,
source_snapshot,
),
CoordinatorRequest::ReconnectNode {
node,
process,
epoch,
} => self.handle_reconnect_node(node, process, epoch),
CoordinatorRequest::PollTaskControl {
tenant,
project,
process,
node,
task,
} => self.handle_poll_task_control(tenant, project, process, node, task),
CoordinatorRequest::PollDebugCommand {
tenant,
project,
process,
node,
task,
} => self.handle_poll_debug_command(tenant, project, process, node, task),
CoordinatorRequest::ReportDebugState {
tenant,
project,
process,
node,
task,
epoch,
state,
stack_frames,
local_values,
task_args,
handles,
command_status,
recent_output,
message,
} => self.handle_report_debug_state(
tenant,
project,
process,
node,
task,
epoch,
state,
stack_frames,
local_values,
task_args,
handles,
command_status,
recent_output,
message,
),
CoordinatorRequest::ReportDebugProbeHit {
tenant,
project,
process,
node,
task,
probe_symbol,
} => self.handle_report_debug_probe_hit(
tenant,
project,
process,
node,
task,
probe_symbol,
),
CoordinatorRequest::ReportTaskLog {
tenant,
project,
process,
node,
task,
stdout_bytes,
stderr_bytes,
stdout_tail,
stderr_tail,
stdout_truncated,
stderr_truncated,
backpressured,
} => self.handle_report_task_log(
tenant,
project,
process,
node,
task,
stdout_bytes,
stderr_bytes,
stdout_tail,
stderr_tail,
stdout_truncated,
stderr_truncated,
backpressured,
),
CoordinatorRequest::ReportVfsMetadata {
tenant,
project,
process,
node,
task,
artifact_path,
artifact_digest,
artifact_size_bytes,
large_bytes_uploaded,
} => self.handle_report_vfs_metadata(
tenant,
project,
process,
node,
task,
artifact_path,
artifact_digest,
artifact_size_bytes,
large_bytes_uploaded,
),
CoordinatorRequest::TaskCompleted {
tenant,
project,
process,
node,
task,
terminal_state,
status_code,
stdout_bytes,
stderr_bytes,
stdout_tail,
stderr_tail,
stdout_truncated,
stderr_truncated,
artifact_path,
artifact_digest,
artifact_size_bytes,
result,
} => self.handle_task_completed(
tenant,
project,
process,
node,
task,
terminal_state,
status_code,
stdout_bytes,
stderr_bytes,
stdout_tail,
stderr_tail,
stdout_truncated,
stderr_truncated,
artifact_path,
artifact_digest,
artifact_size_bytes,
result,
),
_ => self.reject_unsigned_node_request(),
}
}
pub(super) fn reject_unsigned_node_request(
&self,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
Err(CoordinatorError::Unauthorized(
"node-originated request requires signed_node envelope proof".to_owned(),
)
.into())
}
}
fn signed_node_request_kind(
request: &CoordinatorRequest,
) -> Result<&'static str, CoordinatorServiceError> {
match request {
CoordinatorRequest::ReportNodeCapabilities { .. } => Ok("report_node_capabilities"),
CoordinatorRequest::PollTaskAssignment { .. } => Ok("poll_task_assignment"),
CoordinatorRequest::PollArtifactTransfer { .. } => Ok("poll_artifact_transfer"),
CoordinatorRequest::UploadArtifactTransferChunk { .. } => {
Ok("upload_artifact_transfer_chunk")
}
CoordinatorRequest::FailArtifactTransfer { .. } => Ok("fail_artifact_transfer"),
CoordinatorRequest::LaunchChildTask { .. } => Ok("launch_child_task"),
CoordinatorRequest::JoinChildTask { .. } => Ok("join_child_task"),
CoordinatorRequest::CompleteSourcePreparation { .. } => Ok("complete_source_preparation"),
CoordinatorRequest::ReconnectNode { .. } => Ok("reconnect_node"),
CoordinatorRequest::PollTaskControl { .. } => Ok("poll_task_control"),
CoordinatorRequest::PollDebugCommand { .. } => Ok("poll_debug_command"),
CoordinatorRequest::ReportDebugState { .. } => Ok("report_debug_state"),
CoordinatorRequest::ReportDebugProbeHit { .. } => Ok("report_debug_probe_hit"),
CoordinatorRequest::ReportTaskLog { .. } => Ok("report_task_log"),
CoordinatorRequest::ReportVfsMetadata { .. } => Ok("report_vfs_metadata"),
CoordinatorRequest::TaskCompleted { .. } => Ok("task_completed"),
_ => Err(CoordinatorError::Unauthorized(
"signed_node envelope only accepts node-originated coordinator requests".to_owned(),
)
.into()),
}
}
fn signed_node_request_node(
request: &CoordinatorRequest,
) -> Result<NodeId, CoordinatorServiceError> {
match request {
CoordinatorRequest::ReportNodeCapabilities { node, .. }
| CoordinatorRequest::PollTaskAssignment { node, .. }
| CoordinatorRequest::PollArtifactTransfer { node, .. }
| CoordinatorRequest::UploadArtifactTransferChunk { node, .. }
| CoordinatorRequest::FailArtifactTransfer { node, .. }
| CoordinatorRequest::LaunchChildTask { node, .. }
| CoordinatorRequest::JoinChildTask { node, .. }
| CoordinatorRequest::CompleteSourcePreparation { node, .. }
| CoordinatorRequest::ReconnectNode { node, .. }
| CoordinatorRequest::PollTaskControl { node, .. }
| CoordinatorRequest::PollDebugCommand { node, .. }
| CoordinatorRequest::ReportDebugState { node, .. }
| CoordinatorRequest::ReportDebugProbeHit { node, .. }
| CoordinatorRequest::ReportTaskLog { node, .. }
| CoordinatorRequest::ReportVfsMetadata { node, .. }
| CoordinatorRequest::TaskCompleted { node, .. } => Ok(NodeId::new(node.clone())),
_ => Err(CoordinatorError::Unauthorized(
"signed_node envelope only accepts node-originated coordinator requests".to_owned(),
)
.into()),
}
}

View file

@ -0,0 +1,200 @@
use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use super::{CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClientAuthorityMode {
Strict,
LocalTrustedLoopback,
}
impl CoordinatorService {
pub fn serve_tcp(self, listener: TcpListener) -> Result<(), CoordinatorServiceError> {
if !listener.local_addr()?.ip().is_loopback() {
return Err(CoordinatorServiceError::Protocol(
"the native coordinator transport is plaintext and restricted to loopback; expose a remote coordinator only through a secure transport"
.to_owned(),
));
}
self.serve_tcp_with_authority(listener, ClientAuthorityMode::Strict)
}
pub fn serve_tcp_local_trusted(
self,
listener: TcpListener,
) -> Result<(), CoordinatorServiceError> {
if !listener.local_addr()?.ip().is_loopback() {
return Err(CoordinatorServiceError::Protocol(
"local trusted request mode is restricted to a loopback listener".to_owned(),
));
}
self.serve_tcp_with_authority(listener, ClientAuthorityMode::LocalTrustedLoopback)
}
fn serve_tcp_with_authority(
self,
listener: TcpListener,
authority_mode: ClientAuthorityMode,
) -> Result<(), CoordinatorServiceError> {
let shared = Arc::new(Mutex::new(self));
for stream in listener.incoming() {
let stream = stream?;
let service = Arc::clone(&shared);
std::thread::spawn(move || {
if let Err(err) = handle_shared_stream(service, stream, authority_mode) {
eprintln!("coordinator stream failed: {err}");
}
});
}
Ok(())
}
pub fn handle_stream(&mut self, stream: TcpStream) -> Result<(), CoordinatorServiceError> {
self.handle_stream_with_authority(stream, ClientAuthorityMode::Strict)
}
#[cfg(test)]
pub(super) fn handle_stream_local_trusted(
&mut self,
stream: TcpStream,
) -> Result<(), CoordinatorServiceError> {
self.handle_stream_with_authority(stream, ClientAuthorityMode::LocalTrustedLoopback)
}
fn handle_stream_with_authority(
&mut self,
stream: TcpStream,
authority_mode: ClientAuthorityMode,
) -> Result<(), CoordinatorServiceError> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
loop {
let mut line = String::new();
if reader.read_line(&mut line)? == 0 {
return Ok(());
}
if line.trim().is_empty() {
continue;
}
let response = match decode_wire_request(&line) {
Ok(request) => match authorize_client_request(&request, authority_mode)
.and_then(|()| self.handle_request(request))
{
Ok(response) => response,
Err(err) => CoordinatorResponse::Error {
message: err.to_string(),
},
},
Err(err) => CoordinatorResponse::Error {
message: err.to_string(),
},
};
serde_json::to_writer(&mut writer, &response)?;
writer.write_all(b"\n")?;
writer.flush()?;
}
}
}
fn handle_shared_stream(
service: Arc<Mutex<CoordinatorService>>,
stream: TcpStream,
authority_mode: ClientAuthorityMode,
) -> Result<(), CoordinatorServiceError> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
loop {
let mut line = String::new();
if reader.read_line(&mut line)? == 0 {
return Ok(());
}
if line.trim().is_empty() {
continue;
}
let response = match decode_wire_request(&line) {
Ok(request) => match authorize_client_request(&request, authority_mode) {
Ok(()) => match service.lock() {
Ok(mut service) => match service.handle_request(request) {
Ok(response) => response,
Err(err) => CoordinatorResponse::Error {
message: err.to_string(),
},
},
Err(_) => CoordinatorResponse::Error {
message: "coordinator service lock poisoned".to_owned(),
},
},
Err(err) => CoordinatorResponse::Error {
message: err.to_string(),
},
},
Err(err) => CoordinatorResponse::Error {
message: err.to_string(),
},
};
serde_json::to_writer(&mut writer, &response)?;
writer.write_all(b"\n")?;
writer.flush()?;
}
}
pub fn bind_listener(addr: &str) -> Result<(TcpListener, SocketAddr), CoordinatorServiceError> {
let listener = TcpListener::bind(addr)?;
let addr = listener.local_addr()?;
Ok((listener, addr))
}
fn decode_wire_request(line: &str) -> Result<CoordinatorRequest, CoordinatorServiceError> {
serde_json::from_str::<super::CoordinatorWireRequest>(line)?
.into_request()
.map_err(CoordinatorServiceError::Protocol)
}
fn authorize_client_request(
request: &CoordinatorRequest,
authority_mode: ClientAuthorityMode,
) -> Result<(), CoordinatorServiceError> {
if authority_mode == ClientAuthorityMode::LocalTrustedLoopback {
return Ok(());
}
match request {
CoordinatorRequest::Ping
| CoordinatorRequest::Authenticated { .. }
| CoordinatorRequest::ExchangeNodeEnrollmentGrant { .. }
| CoordinatorRequest::SignedNode { .. }
| CoordinatorRequest::NodeHeartbeat {
node_signature: Some(_),
..
}
| CoordinatorRequest::StartProcess {
actor_agent: Some(_),
agent_signature: Some(_),
..
}
| CoordinatorRequest::LaunchTask {
actor_agent: Some(_),
agent_signature: Some(_),
..
}
| CoordinatorRequest::AdminStatus { .. }
| CoordinatorRequest::SuspendTenant { .. } => Ok(()),
_ => Err(CoordinatorServiceError::Protocol(
"strict Core Client authority requires an authenticated CLI session, signed Agent, signed Node, enrollment grant exchange, or admin credential; request-body identity fields are not authority"
.to_owned(),
)),
}
}
#[cfg(test)]
mod transport_boundary_tests {
use super::*;
#[test]
fn native_plaintext_service_refuses_non_loopback_listener() {
let (listener, _) = bind_listener("0.0.0.0:0").unwrap();
let error = CoordinatorService::new(1).serve_tcp(listener).unwrap_err();
assert!(error.to_string().contains("restricted to loopback"));
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
use disasmer_core::{COORDINATOR_PROTOCOL_VERSION, COORDINATOR_WIRE_REQUEST_TYPE};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::CoordinatorRequest;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CoordinatorWireRequest {
Envelope(CoordinatorRequestEnvelope),
}
impl CoordinatorWireRequest {
pub fn into_request(self) -> Result<CoordinatorRequest, String> {
match self {
Self::Envelope(envelope) => envelope.into_request(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CoordinatorRequestEnvelope {
#[serde(rename = "type")]
pub envelope_type: String,
pub protocol_version: u64,
pub request_id: String,
pub operation: String,
#[serde(default)]
pub authentication: Option<Value>,
pub payload: CoordinatorRequest,
}
impl CoordinatorRequestEnvelope {
pub fn into_request(self) -> Result<CoordinatorRequest, String> {
if self.envelope_type != COORDINATOR_WIRE_REQUEST_TYPE {
return Err(format!(
"unsupported coordinator wire request type {}; expected {}",
self.envelope_type, COORDINATOR_WIRE_REQUEST_TYPE
));
}
if self.protocol_version != COORDINATOR_PROTOCOL_VERSION {
return Err(format!(
"unsupported coordinator protocol version {}; expected {}",
self.protocol_version, COORDINATOR_PROTOCOL_VERSION
));
}
if self.request_id.trim().is_empty() {
return Err("coordinator wire request_id must be non-empty".to_owned());
}
let payload_operation = self.payload.operation()?;
if self.operation != payload_operation {
return Err(format!(
"coordinator wire operation {} does not match payload operation {}",
self.operation, payload_operation
));
}
Ok(self.payload)
}
}

View file

@ -0,0 +1,167 @@
use std::time::{SystemTime, UNIX_EPOCH};
use disasmer_core::{Actor, AuthContext, CredentialKind, Digest, ProjectId, TenantId, UserId};
use crate::{CliSessionRecord, Coordinator, CoordinatorError, CredentialRecord};
impl Coordinator {
pub fn issue_cli_session(
&mut self,
tenant: TenantId,
project: ProjectId,
user: UserId,
session_secret: &str,
expires_at_epoch_seconds: Option<u64>,
) -> CliSessionRecord {
self.upsert_tenant(tenant.clone());
self.upsert_user(
tenant.clone(),
user.clone(),
CredentialKind::CliDeviceSession,
);
let session_digest = Digest::sha256(session_secret);
let record = CliSessionRecord {
session_digest: session_digest.clone(),
tenant: tenant.clone(),
project: project.clone(),
user: user.clone(),
credential_kind: CredentialKind::CliDeviceSession,
expires_at_epoch_seconds,
revoked: false,
};
self.durable
.cli_sessions
.insert(session_digest.clone(), record.clone());
let credential_subject = format!("cli-session:{}", session_digest.as_str());
self.durable.credentials.insert(
credential_subject.clone(),
CredentialRecord {
subject: credential_subject,
tenant,
project: Some(project),
kind: CredentialKind::CliDeviceSession,
public_key_fingerprint: None,
},
);
record
}
pub fn authenticate_cli_session(
&self,
session_secret: &str,
) -> Result<AuthContext, CoordinatorError> {
self.authenticate_cli_session_at(session_secret, unix_timestamp_seconds())
}
pub fn authenticate_cli_session_at(
&self,
session_secret: &str,
now_epoch_seconds: u64,
) -> Result<AuthContext, CoordinatorError> {
if session_secret.trim().is_empty() {
return Err(CoordinatorError::Unauthorized(
"CLI session credential is missing".to_owned(),
));
}
let session_digest = Digest::sha256(session_secret);
let record = self
.durable
.cli_sessions
.get(&session_digest)
.ok_or_else(|| {
CoordinatorError::Unauthorized(
"CLI session credential is not recognized".to_owned(),
)
})?;
if record.revoked {
return Err(CoordinatorError::Unauthorized(
"CLI session credential has been revoked".to_owned(),
));
}
if record
.expires_at_epoch_seconds
.is_some_and(|expires_at| expires_at <= now_epoch_seconds)
{
return Err(CoordinatorError::Unauthorized(
"CLI session credential has expired; run disasmer login --browser again".to_owned(),
));
}
if record.credential_kind != CredentialKind::CliDeviceSession {
return Err(CoordinatorError::Unauthorized(
"credential is not a CLI session".to_owned(),
));
}
self.ensure_tenant_active(&record.tenant)?;
Ok(AuthContext {
tenant: record.tenant.clone(),
project: record.project.clone(),
actor: Actor::User(record.user.clone()),
})
}
pub fn revoke_cli_session(
&mut self,
session_secret: &str,
) -> Result<CliSessionRecord, CoordinatorError> {
self.authenticate_cli_session(session_secret)?;
let session_digest = Digest::sha256(session_secret);
let record = self
.durable
.cli_sessions
.get_mut(&session_digest)
.expect("authenticated session must exist");
record.revoked = true;
Ok(record.clone())
}
}
fn unix_timestamp_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use crate::InMemoryDurableStore;
use super::*;
#[test]
fn repeated_logins_for_one_user_create_distinct_session_credentials() {
let store = InMemoryDurableStore::default();
let mut coordinator = Coordinator::boot(&store, 1);
let tenant = TenantId::from("tenant");
let user = UserId::from("user");
coordinator.issue_cli_session(
tenant.clone(),
ProjectId::from("project-one"),
user.clone(),
"session-one",
None,
);
coordinator.issue_cli_session(
tenant,
ProjectId::from("project-two"),
user,
"session-two",
None,
);
let subjects = coordinator
.durable
.credentials
.values()
.map(|credential| credential.subject.clone())
.collect::<BTreeSet<_>>();
assert_eq!(coordinator.durable.cli_sessions.len(), 2);
assert_eq!(subjects.len(), 2);
assert!(subjects
.iter()
.all(|subject| subject.starts_with("cli-session:sha256:")));
}
}

View file

@ -0,0 +1,22 @@
[package]
name = "disasmer-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
base64.workspace = true
ed25519-dalek.workspace = true
serde.workspace = true
serde_json.workspace = true
hex.workspace = true
sha2.workspace = true
syn.workspace = true
thiserror.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
getrandom.workspace = true
[dev-dependencies]
tempfile.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,720 @@
#[cfg(not(target_arch = "wasm32"))]
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
AgentId, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, UserId,
};
pub fn admin_request_proof(
admin_token: &str,
operation: &str,
tenant: &str,
actor_user: &str,
target_tenant: &str,
nonce: &str,
issued_at_epoch_seconds: u64,
) -> Digest {
admin_request_proof_from_token_digest(
&Digest::sha256(admin_token),
operation,
tenant,
actor_user,
target_tenant,
nonce,
issued_at_epoch_seconds,
)
}
pub fn admin_request_proof_from_token_digest(
admin_token_digest: &Digest,
operation: &str,
tenant: &str,
actor_user: &str,
target_tenant: &str,
nonce: &str,
issued_at_epoch_seconds: u64,
) -> Digest {
let issued_at_epoch_seconds = issued_at_epoch_seconds.to_string();
Digest::from_parts([
b"disasmer-admin-request-proof:v1".as_slice(),
admin_token_digest.as_str().as_bytes(),
operation.as_bytes(),
tenant.as_bytes(),
actor_user.as_bytes(),
target_tenant.as_bytes(),
nonce.as_bytes(),
issued_at_epoch_seconds.as_bytes(),
])
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Actor {
User(UserId),
Agent(AgentId),
Node(NodeId),
Task(TaskInstanceId),
}
impl Actor {
pub fn kind(&self) -> IdentityKind {
match self {
Self::User(_) => IdentityKind::User,
Self::Agent(_) => IdentityKind::Agent,
Self::Node(_) => IdentityKind::Node,
Self::Task(_) => IdentityKind::Task,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum IdentityKind {
User,
Agent,
Node,
Project,
Task,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CredentialKind {
BrowserSession,
CliDeviceSession,
PublicKey,
NodeCredential,
TaskCredential,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthContext {
pub tenant: TenantId,
pub project: ProjectId,
pub actor: Actor,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Action {
CreateProject,
AttachNode,
CreateNodeEnrollmentGrant,
ExchangeNodeEnrollmentGrant,
LoginBrowser,
LoginCli,
EnrollAgent,
List,
Inspect,
Mutate,
ClaimTask,
DebugAttach,
DebugRead,
DownloadArtifact,
PublishArtifact,
RunNativeCommand,
RunContainer,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowserLoginFlow {
pub authorization_url: String,
pub callback_path: String,
pub state: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicKeyIdentity {
pub subject: Actor,
pub public_key: String,
pub fingerprint: Digest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentSignedRequest {
pub nonce: String,
pub issued_at_epoch_seconds: u64,
pub signature: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeSignedRequest {
pub nonce: String,
pub issued_at_epoch_seconds: u64,
pub signature: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnrollmentGrant {
pub tenant: TenantId,
pub project: ProjectId,
pub grant_id: String,
pub scope: String,
pub expires_at_epoch_seconds: u64,
pub consumed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeCredential {
pub node: NodeId,
pub tenant: TenantId,
pub project: ProjectId,
pub public_key_fingerprint: Digest,
pub scope: String,
pub capability_policy_digest: Digest,
pub credential_kind: CredentialKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EnrollmentError {
Expired,
AlreadyConsumed,
WrongScope,
}
impl EnrollmentGrant {
pub fn exchange_for_node_identity(
&mut self,
node: NodeId,
public_key: &str,
requested_scope: &str,
now_epoch_seconds: u64,
) -> Result<NodeCredential, EnrollmentError> {
if self.consumed {
return Err(EnrollmentError::AlreadyConsumed);
}
if now_epoch_seconds > self.expires_at_epoch_seconds {
return Err(EnrollmentError::Expired);
}
if requested_scope != self.scope {
return Err(EnrollmentError::WrongScope);
}
self.consumed = true;
let capability_policy_digest =
node_capability_policy_digest(&self.tenant, &self.project, &self.scope);
Ok(NodeCredential {
node,
tenant: self.tenant.clone(),
project: self.project.clone(),
public_key_fingerprint: Digest::sha256(public_key),
scope: self.scope.clone(),
capability_policy_digest,
credential_kind: CredentialKind::NodeCredential,
})
}
}
pub fn node_capability_policy_digest(
tenant: &TenantId,
project: &ProjectId,
scope: &str,
) -> Digest {
Digest::from_parts([
b"node-capability-policy:v1".as_slice(),
tenant.as_str().as_bytes(),
project.as_str().as_bytes(),
scope.as_bytes(),
])
}
pub fn agent_ed25519_public_key_from_private_key(private_key: &str) -> Result<String, String> {
let private_key = decode_ed25519_key(private_key, 32, "agent private key")?;
let private_key: [u8; 32] = private_key
.try_into()
.map_err(|_| "agent private key must be 32 bytes".to_owned())?;
let signing_key = SigningKey::from_bytes(&private_key);
Ok(format!(
"ed25519:{}",
STANDARD.encode(signing_key.verifying_key().to_bytes())
))
}
pub fn node_ed25519_public_key_from_private_key(private_key: &str) -> Result<String, String> {
agent_ed25519_public_key_from_private_key(private_key)
}
pub fn derive_ed25519_private_key_from_seed(seed: &str) -> String {
let digest = Digest::sha256(seed);
let hex = digest.as_str().trim_start_matches("sha256:");
let bytes = hex::decode(hex).expect("sha256 digest hex should decode");
format!("ed25519:{}", STANDARD.encode(bytes))
}
/// Generates a new Ed25519 private key from the operating system CSPRNG.
///
/// Seed-derived keys remain available for deterministic test fixtures, but
/// must not be used for persisted user, Agent, or Node credentials.
#[cfg(not(target_arch = "wasm32"))]
pub fn generate_ed25519_private_key() -> Result<String, String> {
let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes)
.map_err(|err| format!("operating system random source failed: {err}"))?;
Ok(format!("ed25519:{}", STANDARD.encode(bytes)))
}
/// Generates an opaque, URL-safe 256-bit token suitable for one-time grants,
/// session secrets, and nonces. The label is non-secret domain separation.
#[cfg(not(target_arch = "wasm32"))]
pub fn generate_opaque_token(label: &str) -> Result<String, String> {
let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes)
.map_err(|err| format!("operating system random source failed: {err}"))?;
Ok(format!("{label}_{}", URL_SAFE_NO_PAD.encode(bytes)))
}
#[derive(Clone, Copy, Debug)]
pub struct AgentWorkflowScope<'a> {
pub tenant: &'a TenantId,
pub project: &'a ProjectId,
pub agent: &'a AgentId,
pub request_kind: &'a str,
pub process: &'a ProcessId,
pub task: Option<&'a TaskInstanceId>,
}
pub fn sign_agent_workflow_request(
private_key: &str,
scope: AgentWorkflowScope<'_>,
payload_digest: &Digest,
nonce: String,
issued_at_epoch_seconds: u64,
) -> Result<AgentSignedRequest, String> {
let private_key = decode_ed25519_key(private_key, 32, "agent private key")?;
let private_key: [u8; 32] = private_key
.try_into()
.map_err(|_| "agent private key must be 32 bytes".to_owned())?;
let signing_key = SigningKey::from_bytes(&private_key);
let message =
agent_workflow_signature_message(scope, payload_digest, &nonce, issued_at_epoch_seconds);
let signature: Signature = signing_key.sign(&message);
Ok(AgentSignedRequest {
nonce,
issued_at_epoch_seconds,
signature: format!("ed25519:{}", STANDARD.encode(signature.to_bytes())),
})
}
pub fn verify_agent_workflow_signature(
public_key: &str,
scope: AgentWorkflowScope<'_>,
payload_digest: &Digest,
signed_request: &AgentSignedRequest,
) -> Result<(), String> {
let public_key = decode_ed25519_key(public_key, 32, "agent public key")?;
let public_key: [u8; 32] = public_key
.try_into()
.map_err(|_| "agent public key must be 32 bytes".to_owned())?;
let verifying_key = VerifyingKey::from_bytes(&public_key)
.map_err(|_| "agent public key is not a valid Ed25519 verifying key".to_owned())?;
let signature = decode_ed25519_key(&signed_request.signature, 64, "agent signature")?;
let signature: [u8; 64] = signature
.try_into()
.map_err(|_| "agent signature must be 64 bytes".to_owned())?;
let signature = Signature::from_bytes(&signature);
let message = agent_workflow_signature_message(
scope,
payload_digest,
&signed_request.nonce,
signed_request.issued_at_epoch_seconds,
);
verifying_key
.verify(&message, &signature)
.map_err(|_| "agent signature does not verify against the registered public key".to_owned())
}
pub fn sign_node_request(
private_key: &str,
node: &NodeId,
request_kind: &str,
payload_digest: &Digest,
nonce: String,
issued_at_epoch_seconds: u64,
) -> Result<NodeSignedRequest, String> {
let private_key = decode_ed25519_key(private_key, 32, "node private key")?;
let private_key: [u8; 32] = private_key
.try_into()
.map_err(|_| "node private key must be 32 bytes".to_owned())?;
let signing_key = SigningKey::from_bytes(&private_key);
let message = node_request_signature_message(
node,
request_kind,
payload_digest,
&nonce,
issued_at_epoch_seconds,
);
let signature: Signature = signing_key.sign(&message);
Ok(NodeSignedRequest {
nonce,
issued_at_epoch_seconds,
signature: format!("ed25519:{}", STANDARD.encode(signature.to_bytes())),
})
}
pub fn verify_node_request_signature(
public_key: &str,
node: &NodeId,
request_kind: &str,
payload_digest: &Digest,
signed_request: &NodeSignedRequest,
) -> Result<(), String> {
let public_key = decode_ed25519_key(public_key, 32, "node public key")?;
let public_key: [u8; 32] = public_key
.try_into()
.map_err(|_| "node public key must be 32 bytes".to_owned())?;
let verifying_key = VerifyingKey::from_bytes(&public_key)
.map_err(|_| "node public key is not a valid Ed25519 verifying key".to_owned())?;
let signature = decode_ed25519_key(&signed_request.signature, 64, "node signature")?;
let signature: [u8; 64] = signature
.try_into()
.map_err(|_| "node signature must be 64 bytes".to_owned())?;
let signature = Signature::from_bytes(&signature);
let message = node_request_signature_message(
node,
request_kind,
payload_digest,
&signed_request.nonce,
signed_request.issued_at_epoch_seconds,
);
verifying_key
.verify(&message, &signature)
.map_err(|_| "node signature does not verify against the enrolled public key".to_owned())
}
fn decode_ed25519_key(value: &str, expected_len: usize, kind: &str) -> Result<Vec<u8>, String> {
let encoded = value
.strip_prefix("ed25519:")
.ok_or_else(|| format!("{kind} must use ed25519:<base64> encoding"))?;
let bytes = STANDARD
.decode(encoded)
.map_err(|_| format!("{kind} is not valid base64"))?;
if bytes.len() != expected_len {
return Err(format!("{kind} must be {expected_len} bytes"));
}
Ok(bytes)
}
fn agent_workflow_signature_message(
scope: AgentWorkflowScope<'_>,
payload_digest: &Digest,
nonce: &str,
issued_at_epoch_seconds: u64,
) -> Vec<u8> {
let issued_at = issued_at_epoch_seconds.to_string();
let task = scope.task.map(TaskInstanceId::as_str).unwrap_or("");
let parts = [
"disasmer-agent-workflow-signature:v2",
scope.tenant.as_str(),
scope.project.as_str(),
scope.agent.as_str(),
scope.request_kind,
scope.process.as_str(),
task,
payload_digest.as_str(),
nonce,
&issued_at,
];
let mut message = Vec::new();
for part in parts {
message.extend_from_slice(part.len().to_string().as_bytes());
message.push(b':');
message.extend_from_slice(part.as_bytes());
message.push(b'\n');
}
message
}
fn node_request_signature_message(
node: &NodeId,
request_kind: &str,
payload_digest: &Digest,
nonce: &str,
issued_at_epoch_seconds: u64,
) -> Vec<u8> {
let issued_at = issued_at_epoch_seconds.to_string();
let parts = [
"disasmer-node-request-signature:v2",
node.as_str(),
request_kind,
payload_digest.as_str(),
nonce,
&issued_at,
];
let mut message = Vec::new();
for part in parts {
message.extend_from_slice(part.len().to_string().as_bytes());
message.push(b':');
message.extend_from_slice(part.as_bytes());
message.push(b'\n');
}
message
}
/// Computes the stable digest covered by node and agent request signatures.
///
/// The proof field itself is excluded, and explicit JSON nulls are normalized
/// with omitted optional fields because the wire protocol deserializes both to
/// the same request. Every semantically meaningful key and value remains bound
/// by the signature.
pub fn signed_request_payload_digest(value: &Value) -> Digest {
fn canonicalize(value: &Value, top_level: bool) -> Value {
match value {
Value::Object(object) => Value::Object(
object
.iter()
.filter(|(key, value)| {
!value.is_null()
&& (!top_level
|| !matches!(key.as_str(), "agent_signature" | "node_signature"))
})
.map(|(key, value)| (key.clone(), canonicalize(value, false)))
.collect(),
),
Value::Array(values) => Value::Array(
values
.iter()
.map(|value| canonicalize(value, false))
.collect(),
),
value => value.clone(),
}
}
let canonical = canonicalize(value, true);
let bytes = serde_json::to_vec(&canonical)
.expect("canonical JSON request values are always serializable");
Digest::sha256(bytes)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Scope {
pub tenant: TenantId,
pub project: ProjectId,
pub process: Option<ProcessId>,
pub task: Option<TaskInstanceId>,
pub node: Option<NodeId>,
pub artifact: Option<ArtifactId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Authorization {
pub allowed: bool,
pub reason: String,
}
impl Authorization {
pub fn allow(reason: impl Into<String>) -> Self {
Self {
allowed: true,
reason: reason.into(),
}
}
pub fn deny(reason: impl Into<String>) -> Self {
Self {
allowed: false,
reason: reason.into(),
}
}
}
pub fn same_tenant_project(context: &AuthContext, scope: &Scope) -> Authorization {
if context.tenant != scope.tenant {
return Authorization::deny("tenant mismatch");
}
if context.project != scope.project {
return Authorization::deny("project mismatch");
}
Authorization::allow("same tenant and project")
}
pub fn task_credentials_do_not_contain_user_session(
task: &Actor,
credentials: &[CredentialKind],
) -> Authorization {
if !matches!(task, Actor::Task(_)) {
return Authorization::deny("credential check requires task actor");
}
if credentials.iter().any(|credential| {
matches!(
credential,
CredentialKind::BrowserSession | CredentialKind::CliDeviceSession
)
}) {
return Authorization::deny(
"user OAuth/session tokens must not be passed to nodes as task credentials",
);
}
Authorization::allow("task credentials are scoped runtime credentials")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tenant_project_scope_denies_cross_tenant_access() {
let context = AuthContext {
tenant: TenantId::from("tenant-a"),
project: ProjectId::from("project-a"),
actor: Actor::User(UserId::from("user-a")),
};
let scope = Scope {
tenant: TenantId::from("tenant-b"),
project: ProjectId::from("project-a"),
process: None,
task: None,
node: None,
artifact: None,
};
assert!(!same_tenant_project(&context, &scope).allowed);
}
#[test]
fn node_enrollment_exchanges_short_lived_grant_once() {
let mut grant = EnrollmentGrant {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
grant_id: "grant".to_owned(),
scope: "node:attach".to_owned(),
expires_at_epoch_seconds: 100,
consumed: false,
};
let credential = grant
.exchange_for_node_identity(NodeId::from("node"), "public-key", "node:attach", 99)
.unwrap();
assert_eq!(credential.credential_kind, CredentialKind::NodeCredential);
assert_eq!(credential.tenant, TenantId::from("tenant"));
assert_eq!(credential.project, ProjectId::from("project"));
assert_eq!(credential.node, NodeId::from("node"));
assert_eq!(credential.scope, "node:attach");
assert_eq!(
credential.capability_policy_digest,
node_capability_policy_digest(
&TenantId::from("tenant"),
&ProjectId::from("project"),
"node:attach"
)
);
assert_eq!(
grant.exchange_for_node_identity(
NodeId::from("node2"),
"public-key",
"node:attach",
99
),
Err(EnrollmentError::AlreadyConsumed)
);
}
#[test]
fn node_capability_policy_digest_is_scoped() {
let base = node_capability_policy_digest(
&TenantId::from("tenant"),
&ProjectId::from("project"),
"node:attach",
);
let other_project = node_capability_policy_digest(
&TenantId::from("tenant"),
&ProjectId::from("other"),
"node:attach",
);
let other_scope = node_capability_policy_digest(
&TenantId::from("tenant"),
&ProjectId::from("project"),
"node:limited",
);
assert!(base.is_valid_sha256());
assert_ne!(base, other_project);
assert_ne!(base, other_scope);
}
#[test]
fn generated_ed25519_private_keys_are_random_and_valid() {
let first = generate_ed25519_private_key().unwrap();
let second = generate_ed25519_private_key().unwrap();
assert_ne!(first, second);
assert!(agent_ed25519_public_key_from_private_key(&first)
.unwrap()
.starts_with("ed25519:"));
assert!(node_ed25519_public_key_from_private_key(&second)
.unwrap()
.starts_with("ed25519:"));
}
#[test]
fn generated_opaque_tokens_are_random_and_url_safe() {
let first = generate_opaque_token("grant").unwrap();
let second = generate_opaque_token("grant").unwrap();
assert_ne!(first, second);
assert!(first.starts_with("grant_"));
assert!(first
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')));
}
#[test]
fn task_credentials_reject_user_session_tokens() {
for credential in [
CredentialKind::BrowserSession,
CredentialKind::CliDeviceSession,
] {
let authz = task_credentials_do_not_contain_user_session(
&Actor::Task(TaskInstanceId::from("task")),
&[CredentialKind::TaskCredential, credential],
);
assert!(!authz.allowed);
assert!(authz.reason.contains("must not be passed"));
}
let scoped = task_credentials_do_not_contain_user_session(
&Actor::Task(TaskInstanceId::from("task")),
&[
CredentialKind::TaskCredential,
CredentialKind::NodeCredential,
],
);
assert!(scoped.allowed);
}
#[test]
fn identities_remain_distinct_for_authorization() {
assert_eq!(Actor::User(UserId::from("user")).kind(), IdentityKind::User);
assert_eq!(
Actor::Agent(AgentId::from("agent")).kind(),
IdentityKind::Agent
);
assert_eq!(Actor::Node(NodeId::from("node")).kind(), IdentityKind::Node);
assert_eq!(
Actor::Task(TaskInstanceId::from("task")).kind(),
IdentityKind::Task
);
let scope = Scope {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
process: Some(ProcessId::from("process")),
task: Some(TaskInstanceId::from("task")),
node: Some(NodeId::from("node")),
artifact: Some(ArtifactId::from("artifact")),
};
assert_eq!(scope.process, Some(ProcessId::from("process")));
assert_eq!(scope.artifact, Some(ArtifactId::from("artifact")));
assert_ne!(
CredentialKind::BrowserSession,
CredentialKind::CliDeviceSession
);
assert_ne!(CredentialKind::PublicKey, CredentialKind::NodeCredential);
assert_ne!(
CredentialKind::NodeCredential,
CredentialKind::TaskCredential
);
}
}

View file

@ -0,0 +1,459 @@
use serde::{Deserialize, Serialize};
use syn::parse::Parser;
use syn::{Expr, Item, Lit, Meta, Token};
use crate::{Digest, EnvironmentResource, SourceTransferPolicy, TaskDefinitionId};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SelectedInput {
pub path: String,
pub digest: Digest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleIdentityInputs {
pub wasm_code: Digest,
pub task_abi: Digest,
pub entrypoints: Vec<String>,
pub default_entrypoint: String,
pub environments: Vec<EnvironmentResource>,
pub source_provider_manifest: Digest,
pub source_transfer_policy: SourceTransferPolicy,
pub selected_inputs: Vec<SelectedInput>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleMetadata {
pub identity: Digest,
pub wasm_code: Digest,
pub task_metadata: BundleTaskMetadata,
pub source_metadata: BundleSourceMetadata,
pub debug_metadata: BundleDebugMetadata,
pub large_input_policy: BundleLargeInputPolicy,
pub restart_compatibility: BundleRestartCompatibility,
pub environments: Vec<EnvironmentResource>,
pub selected_inputs: Vec<SelectedInput>,
pub embeds_full_container_images: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleTaskMetadata {
pub task_abi: Digest,
pub entrypoints: Vec<String>,
pub default_entrypoint: String,
pub boundary: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleSourceMetadata {
pub source_provider_manifest: Digest,
pub transfer_policy: SourceTransferPolicy,
pub selected_inputs: Vec<SelectedInput>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleDebugMetadata {
pub available: bool,
pub source_level_breakpoints: bool,
pub dap_virtual_process: bool,
pub variables_pane_supported: bool,
pub probes: Vec<BundleDebugProbe>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleDebugProbe {
pub id: String,
pub source_path: String,
pub line_start: u32,
pub line_end: u32,
pub function: String,
pub task: TaskDefinitionId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleLargeInputPolicy {
pub selected_inputs_are_content_digests: bool,
pub selected_input_bytes_included: bool,
pub full_repository_bytes_included: bool,
pub silent_task_argument_serialization: bool,
pub supported_handle_types: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleRestartCompatibility {
pub source_edits_can_restart_from_clean_task_boundary: bool,
pub requires_clean_checkpoint_boundary: bool,
pub compares_task_abi: Digest,
pub compares_environment_digests: bool,
pub compares_serialized_args: bool,
pub discards_unflushed_task_local_changes: bool,
pub incompatible_changes_require_whole_process_restart: bool,
}
impl BundleIdentityInputs {
pub fn identity(&self) -> Digest {
let mut parts = vec![
b"bundle:v1".to_vec(),
self.wasm_code.as_str().as_bytes().to_vec(),
self.task_abi.as_str().as_bytes().to_vec(),
self.source_provider_manifest.as_str().as_bytes().to_vec(),
self.default_entrypoint.as_bytes().to_vec(),
format!("{:?}", self.source_transfer_policy).into_bytes(),
];
let mut entrypoints = self.entrypoints.clone();
entrypoints.sort();
for entrypoint in entrypoints {
parts.push(entrypoint.into_bytes());
}
let mut environments = self.environments.clone();
environments.sort_by(|left, right| left.name.cmp(&right.name));
for environment in environments {
parts.push(environment.name.as_bytes().to_vec());
parts.push(format!("{:?}", environment.kind).into_bytes());
parts.push(environment.digest.as_str().as_bytes().to_vec());
}
let mut inputs = self.selected_inputs.clone();
inputs.sort_by(|left, right| left.path.cmp(&right.path));
for input in inputs {
parts.push(input.path.into_bytes());
parts.push(input.digest.as_str().as_bytes().to_vec());
}
Digest::from_parts(parts)
}
pub fn inspectable_metadata(&self) -> BundleMetadata {
let mut entrypoints = self.entrypoints.clone();
entrypoints.sort();
BundleMetadata {
identity: self.identity(),
wasm_code: self.wasm_code.clone(),
task_metadata: BundleTaskMetadata {
task_abi: self.task_abi.clone(),
entrypoints,
default_entrypoint: self.default_entrypoint.clone(),
boundary: "disasmer_task_exports".to_owned(),
},
source_metadata: BundleSourceMetadata {
source_provider_manifest: self.source_provider_manifest.clone(),
transfer_policy: self.source_transfer_policy.clone(),
selected_inputs: self.selected_inputs.clone(),
},
debug_metadata: BundleDebugMetadata {
available: true,
source_level_breakpoints: true,
dap_virtual_process: true,
variables_pane_supported: true,
probes: Vec::new(),
},
large_input_policy: BundleLargeInputPolicy {
selected_inputs_are_content_digests: true,
selected_input_bytes_included: false,
full_repository_bytes_included: false,
silent_task_argument_serialization: false,
supported_handle_types: vec![
"SourceSnapshot".to_owned(),
"Blob".to_owned(),
"Artifact".to_owned(),
"VFS".to_owned(),
],
},
restart_compatibility: BundleRestartCompatibility {
source_edits_can_restart_from_clean_task_boundary: true,
requires_clean_checkpoint_boundary: true,
compares_task_abi: self.task_abi.clone(),
compares_environment_digests: true,
compares_serialized_args: true,
discards_unflushed_task_local_changes: true,
incompatible_changes_require_whole_process_restart: true,
},
environments: self.environments.clone(),
selected_inputs: self.selected_inputs.clone(),
embeds_full_container_images: false,
}
}
}
pub fn discover_source_debug_probes(
source_path: impl Into<String>,
source: &str,
) -> Vec<BundleDebugProbe> {
let source_path = source_path.into();
let lines = source.lines().collect::<Vec<_>>();
let function_starts = lines
.iter()
.enumerate()
.filter_map(|(index, line)| {
parse_rust_function_name(line).map(|function| (index, function))
})
.collect::<Vec<_>>();
let Ok(file) = syn::parse_file(source) else {
return Vec::new();
};
file.items
.iter()
.filter_map(|item| {
let Item::Fn(function) = item else {
return None;
};
let function_name = function.sig.ident.to_string();
let task = disasmer_probe_task(function)?;
let (function_index, (line_index, _)) = function_starts
.iter()
.enumerate()
.find(|(_, (_, candidate))| candidate == &function_name)?;
let line_start = (*line_index + 1) as u32;
let next_start = function_starts
.get(function_index + 1)
.map(|(next_index, _)| *next_index)
.unwrap_or(lines.len());
let line_end = next_start.max(*line_index + 1) as u32;
Some(debug_probe(
&source_path,
line_start,
line_end,
&function_name,
task,
))
})
.collect()
}
fn debug_probe(
source_path: &str,
line_start: u32,
line_end: u32,
function: &str,
task: TaskDefinitionId,
) -> BundleDebugProbe {
let id = Digest::from_parts([
b"bundle-debug-probe:v1".as_slice(),
source_path.as_bytes(),
function.as_bytes(),
task.as_str().as_bytes(),
line_start.to_string().as_bytes(),
line_end.to_string().as_bytes(),
])
.as_str()
.to_owned();
BundleDebugProbe {
id,
source_path: source_path.to_owned(),
line_start,
line_end,
function: function.to_owned(),
task,
}
}
fn parse_rust_function_name(line: &str) -> Option<String> {
let start = line.find("fn ")? + 3;
let rest = &line[start..];
let name = rest
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
.collect::<String>();
(!name.is_empty()).then_some(name)
}
fn disasmer_probe_task(function: &syn::ItemFn) -> Option<TaskDefinitionId> {
for attribute in &function.attrs {
let mut segments = attribute.path().segments.iter();
let Some(namespace) = segments.next() else {
continue;
};
let Some(kind) = segments.next() else {
continue;
};
if namespace.ident != "disasmer" || segments.next().is_some() {
continue;
}
let function_name = function.sig.ident.to_string();
let default_name = match kind.ident.to_string().as_str() {
"main" => function_name
.strip_suffix("_main")
.unwrap_or(&function_name)
.to_owned(),
"task" => function_name,
_ => continue,
};
let declared_name = match &attribute.meta {
Meta::List(list) => {
let parser = syn::punctuated::Punctuated::<Meta, Token![,]>::parse_terminated;
parser
.parse2(list.tokens.clone())
.ok()
.and_then(|items| {
items.into_iter().find_map(|item| {
let Meta::NameValue(name_value) = item else {
return None;
};
if !name_value.path.is_ident("name") {
return None;
}
let Expr::Lit(value) = name_value.value else {
return None;
};
let Lit::Str(value) = value.lit else {
return None;
};
Some(value.value())
})
})
.unwrap_or(default_name)
}
_ => default_name,
};
return Some(TaskDefinitionId::new(declared_name));
}
None
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::{EnvironmentKind, EnvironmentRequirements};
use super::*;
fn env(digest: &str) -> EnvironmentResource {
EnvironmentResource {
name: "linux".to_owned(),
kind: EnvironmentKind::Containerfile,
recipe_path: PathBuf::from("envs/linux/Containerfile"),
context_path: PathBuf::from("envs/linux"),
digest: Digest::sha256(digest),
requirements: EnvironmentRequirements::linux_container(),
}
}
#[test]
fn bundle_identity_changes_when_environment_recipe_changes() {
let base = BundleIdentityInputs {
wasm_code: Digest::sha256("wasm"),
task_abi: Digest::sha256("abi"),
entrypoints: vec!["build".to_owned()],
default_entrypoint: "build".to_owned(),
environments: vec![env("recipe-a")],
source_provider_manifest: Digest::sha256("source"),
source_transfer_policy: SourceTransferPolicy::local_first_snapshot_chunks(),
selected_inputs: vec![],
};
let mut changed = base.clone();
changed.environments = vec![env("recipe-b")];
assert_ne!(base.identity(), changed.identity());
}
#[test]
fn bundle_metadata_is_inspectable_and_does_not_vendor_images_by_default() {
let inputs = BundleIdentityInputs {
wasm_code: Digest::sha256("wasm"),
task_abi: Digest::sha256("abi"),
entrypoints: vec!["build".to_owned(), "test".to_owned()],
default_entrypoint: "build".to_owned(),
environments: vec![env("recipe")],
source_provider_manifest: Digest::sha256("source"),
source_transfer_policy: SourceTransferPolicy::local_first_snapshot_chunks(),
selected_inputs: vec![SelectedInput {
path: "inputs/config.json".to_owned(),
digest: Digest::sha256("config"),
}],
};
let metadata = inputs.inspectable_metadata();
assert!(metadata.wasm_code.as_str().starts_with("sha256:"));
assert_eq!(metadata.task_metadata.default_entrypoint, "build");
assert!(metadata
.task_metadata
.entrypoints
.contains(&"test".to_owned()));
assert!(metadata.debug_metadata.dap_virtual_process);
assert!(
metadata
.source_metadata
.transfer_policy
.local_source_bytes_remain_node_local
);
assert!(
metadata
.large_input_policy
.selected_inputs_are_content_digests
);
assert!(!metadata.large_input_policy.selected_input_bytes_included);
assert!(!metadata.large_input_policy.full_repository_bytes_included);
assert!(
!metadata
.large_input_policy
.silent_task_argument_serialization
);
assert!(metadata
.large_input_policy
.supported_handle_types
.contains(&"Artifact".to_owned()));
assert!(
metadata
.restart_compatibility
.source_edits_can_restart_from_clean_task_boundary
);
assert!(
metadata
.restart_compatibility
.requires_clean_checkpoint_boundary
);
assert_eq!(
metadata.restart_compatibility.compares_task_abi,
inputs.task_abi
);
assert!(
metadata
.restart_compatibility
.incompatible_changes_require_whole_process_restart
);
assert_eq!(metadata.environments.len(), 1);
assert!(!metadata.embeds_full_container_images);
}
#[test]
fn source_debug_probe_metadata_maps_function_ranges_to_tasks() {
let probes = discover_source_debug_probes(
"src/build.rs",
r#"#[disasmer::main]
fn build_main() {
let linux = compile_linux();
}
#[disasmer::task]
fn compile_linux() {
println!("linux");
}
fn helper_without_runtime_probe() {}
#[disasmer::task(name = "release")]
fn package_release() {
println!("package");
}
"#,
);
assert_eq!(probes.len(), 3);
assert_eq!(probes[0].source_path, "src/build.rs");
assert_eq!(probes[0].function, "build_main");
assert_eq!(probes[0].task, TaskDefinitionId::from("build"));
assert_eq!(probes[0].line_start, 2);
assert_eq!(probes[0].line_end, 6);
assert_eq!(probes[1].function, "compile_linux");
assert_eq!(probes[1].task, TaskDefinitionId::from("compile_linux"));
assert_eq!(probes[2].function, "package_release");
assert_eq!(probes[2].task, TaskDefinitionId::from("release"));
assert!(probes.iter().all(|probe| probe.id.starts_with("sha256:")));
}
}

View file

@ -0,0 +1,218 @@
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Capability {
Command,
Containers,
RootlessPodman,
SourceFilesystem,
SourceGit,
HostFilesystem,
Network,
Secrets,
InboundPorts,
ArbitrarySyscalls,
VfsArtifacts,
WindowsCommandDev,
QuicDirect,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum EnvironmentBackend {
Container,
NixFlake,
WindowsCommandDev,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Os {
Linux,
Windows,
Macos,
Other(String),
}
impl Os {
pub fn current() -> Self {
match std::env::consts::OS {
"linux" => Self::Linux,
"windows" => Self::Windows,
"macos" => Self::Macos,
other => Self::Other(other.to_owned()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeCapabilities {
pub os: Os,
pub arch: String,
pub capabilities: BTreeSet<Capability>,
pub environment_backends: BTreeSet<EnvironmentBackend>,
pub source_providers: BTreeSet<String>,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum CapabilityReportError {
#[error("node architecture `{0}` is invalid")]
InvalidArchitecture(String),
#[error("node OS label `{0}` is invalid")]
InvalidOsLabel(String),
#[error("source provider id `{0}` is invalid")]
InvalidSourceProvider(String),
}
impl NodeCapabilities {
pub fn detect_current() -> Self {
let os = Os::current();
let mut capabilities = BTreeSet::from([
Capability::Command,
Capability::SourceFilesystem,
Capability::VfsArtifacts,
]);
let mut environment_backends = BTreeSet::new();
match os {
Os::Linux => {
if rootless_podman_available() {
capabilities.insert(Capability::Containers);
capabilities.insert(Capability::RootlessPodman);
environment_backends.insert(EnvironmentBackend::Container);
}
}
Os::Windows => {
capabilities.insert(Capability::WindowsCommandDev);
environment_backends.insert(EnvironmentBackend::WindowsCommandDev);
}
Os::Macos | Os::Other(_) => {}
}
Self {
os,
arch: std::env::consts::ARCH.to_owned(),
capabilities,
environment_backends,
source_providers: BTreeSet::from(["filesystem".to_owned(), "git".to_owned()]),
}
}
pub fn with_capability(mut self, capability: Capability) -> Self {
self.capabilities.insert(capability);
self
}
pub fn has_all(&self, required: &BTreeSet<Capability>) -> bool {
required
.iter()
.all(|capability| self.capabilities.contains(capability))
}
pub fn validate_public_report(&self) -> Result<(), CapabilityReportError> {
if !valid_capability_label(&self.arch) {
return Err(CapabilityReportError::InvalidArchitecture(
self.arch.clone(),
));
}
if let Os::Other(label) = &self.os {
if !valid_capability_label(label) {
return Err(CapabilityReportError::InvalidOsLabel(label.clone()));
}
}
for provider in &self.source_providers {
if !valid_source_provider_id(provider) {
return Err(CapabilityReportError::InvalidSourceProvider(
provider.clone(),
));
}
}
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
fn rootless_podman_available() -> bool {
const ATTEMPTS: usize = 3;
for attempt in 0..ATTEMPTS {
match std::process::Command::new("podman")
.args(["info", "--format", "{{.Host.Security.Rootless}}"])
.output()
{
Ok(output) if rootless_podman_probe_succeeded(&output) => return true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
Ok(_) | Err(_) if attempt + 1 < ATTEMPTS => {
std::thread::sleep(std::time::Duration::from_millis(250));
}
Ok(_) | Err(_) => {}
}
}
false
}
#[cfg(not(target_arch = "wasm32"))]
fn rootless_podman_probe_succeeded(output: &std::process::Output) -> bool {
output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true"
}
#[cfg(target_arch = "wasm32")]
fn rootless_podman_available() -> bool {
false
}
fn valid_capability_label(label: &str) -> bool {
!label.is_empty()
&& label.len() <= 64
&& label.bytes().all(
|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.'),
)
}
fn valid_source_provider_id(provider: &str) -> bool {
!provider.is_empty()
&& provider.len() <= 64
&& provider
.bytes()
.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
}
#[cfg(test)]
mod tests {
use super::*;
fn capabilities() -> NodeCapabilities {
NodeCapabilities {
os: Os::Linux,
arch: "x86_64".to_owned(),
capabilities: BTreeSet::from([Capability::Command]),
environment_backends: BTreeSet::new(),
source_providers: BTreeSet::from(["filesystem".to_owned(), "git".to_owned()]),
}
}
#[test]
fn capability_reports_validate_hostile_strings() {
assert!(capabilities().validate_public_report().is_ok());
let mut invalid_arch = capabilities();
invalid_arch.arch = "x86_64\nmalicious".to_owned();
assert_eq!(
invalid_arch.validate_public_report(),
Err(CapabilityReportError::InvalidArchitecture(
"x86_64\nmalicious".to_owned()
))
);
let mut invalid_provider = capabilities();
invalid_provider
.source_providers
.insert("../checkout".to_owned());
assert_eq!(
invalid_provider.validate_public_report(),
Err(CapabilityReportError::InvalidSourceProvider(
"../checkout".to_owned()
))
);
}
}

View file

@ -0,0 +1,284 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{Digest, TaskInstanceId, VfsManifest};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckpointBoundary {
pub task_entrypoint: String,
pub serialized_args: Digest,
pub environment_digest: Digest,
pub vfs_epoch: u64,
pub task_abi: Digest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskCheckpoint {
pub task: TaskInstanceId,
pub boundary: CheckpointBoundary,
pub vfs_manifest: VfsManifest,
pub depends_on_live_stack: bool,
pub depends_on_live_socket: bool,
pub depends_on_ephemeral_artifact_durability: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RestartRequest {
pub task: TaskInstanceId,
pub entrypoint: String,
pub serialized_args: Digest,
pub environment_digest: Digest,
pub task_abi: Digest,
pub source_edited: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RestartDecision {
RestartTask {
task: TaskInstanceId,
from_vfs_epoch: u64,
discard_unflushed_changes: bool,
},
RestartWholeVirtualProcess {
message: String,
},
}
#[derive(Clone, Debug, Default)]
pub struct RestartPolicy;
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum CompatibilityFailure {
#[error("task entrypoint changed")]
Entrypoint,
#[error("serialized task arguments changed")]
Args,
#[error("environment digest changed")]
Environment,
#[error("task ABI changed")]
TaskAbi,
#[error("checkpoint depends on unsupported live stack migration")]
LiveStack,
#[error("checkpoint depends on unsupported live socket checkpointing")]
LiveSocket,
#[error("checkpoint incorrectly treats ephemeral artifacts as durable")]
EphemeralArtifactDurability,
}
impl RestartPolicy {
pub fn decide(&self, checkpoint: &TaskCheckpoint, request: &RestartRequest) -> RestartDecision {
match compatibility_failure(checkpoint, request) {
None => RestartDecision::RestartTask {
task: checkpoint.task.clone(),
from_vfs_epoch: checkpoint.boundary.vfs_epoch,
discard_unflushed_changes: true,
},
Some(failure) => RestartDecision::RestartWholeVirtualProcess {
message: format!(
"cannot restart selected task `{}` from checkpoint: {failure}; restart the whole virtual process",
checkpoint.task
),
},
}
}
}
fn compatibility_failure(
checkpoint: &TaskCheckpoint,
request: &RestartRequest,
) -> Option<CompatibilityFailure> {
if checkpoint.depends_on_live_stack {
return Some(CompatibilityFailure::LiveStack);
}
if checkpoint.depends_on_live_socket {
return Some(CompatibilityFailure::LiveSocket);
}
if checkpoint.depends_on_ephemeral_artifact_durability {
return Some(CompatibilityFailure::EphemeralArtifactDurability);
}
if checkpoint.boundary.task_entrypoint != request.entrypoint {
return Some(CompatibilityFailure::Entrypoint);
}
if checkpoint.boundary.serialized_args != request.serialized_args {
return Some(CompatibilityFailure::Args);
}
if checkpoint.boundary.environment_digest != request.environment_digest {
return Some(CompatibilityFailure::Environment);
}
if checkpoint.boundary.task_abi != request.task_abi {
return Some(CompatibilityFailure::TaskAbi);
}
None
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::{
EnvironmentKind, EnvironmentRequirements, EnvironmentResource, NodeId, VfsOverlay, VfsPath,
};
use super::*;
fn env(digest_input: &str) -> EnvironmentResource {
EnvironmentResource {
name: "linux".to_owned(),
kind: EnvironmentKind::Containerfile,
recipe_path: PathBuf::from("envs/linux/Containerfile"),
context_path: PathBuf::from("envs/linux"),
digest: Digest::sha256(digest_input),
requirements: EnvironmentRequirements::linux_container(),
}
}
fn checkpoint() -> (TaskCheckpoint, EnvironmentResource) {
let environment = env("env");
let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node"));
overlay.write(
VfsPath::new("/vfs/artifacts/app").unwrap(),
Digest::sha256("app"),
3,
);
let manifest = overlay.flush();
(
TaskCheckpoint {
task: TaskInstanceId::from("task"),
boundary: CheckpointBoundary {
task_entrypoint: "compile_linux".to_owned(),
serialized_args: Digest::sha256("args"),
environment_digest: environment.digest.clone(),
vfs_epoch: manifest.epoch,
task_abi: Digest::sha256("abi"),
},
vfs_manifest: manifest,
depends_on_live_stack: false,
depends_on_live_socket: false,
depends_on_ephemeral_artifact_durability: false,
},
environment,
)
}
fn restart_request(environment: EnvironmentResource) -> RestartRequest {
RestartRequest {
task: TaskInstanceId::from("task"),
entrypoint: "compile_linux".to_owned(),
serialized_args: Digest::sha256("args"),
environment_digest: environment.digest,
task_abi: Digest::sha256("abi"),
source_edited: true,
}
}
fn assert_whole_process_restart(decision: RestartDecision, expected_reason: &str) {
match decision {
RestartDecision::RestartWholeVirtualProcess { message } => {
assert!(
message.contains(expected_reason),
"restart message `{message}` did not include `{expected_reason}`"
);
assert!(
message.contains("restart the whole virtual process"),
"restart message `{message}` did not direct a whole-process restart"
);
}
RestartDecision::RestartTask { .. } => {
panic!("incompatible checkpoint unexpectedly restarted selected task")
}
}
}
#[test]
fn compatible_restart_uses_task_boundary_and_discards_unflushed_changes() {
let (checkpoint, environment) = checkpoint();
let request = restart_request(environment);
let decision = RestartPolicy.decide(&checkpoint, &request);
assert_eq!(
decision,
RestartDecision::RestartTask {
task: TaskInstanceId::from("task"),
from_vfs_epoch: 1,
discard_unflushed_changes: true
}
);
}
#[test]
fn incompatible_environment_requires_whole_process_restart() {
let (checkpoint, _) = checkpoint();
let request = restart_request(env("changed-env"));
let decision = RestartPolicy.decide(&checkpoint, &request);
assert_whole_process_restart(decision, "environment digest changed");
}
#[test]
fn incompatible_entrypoint_requires_whole_process_restart() {
let (checkpoint, environment) = checkpoint();
let mut request = restart_request(environment);
request.entrypoint = "package_linux".to_owned();
let decision = RestartPolicy.decide(&checkpoint, &request);
assert_whole_process_restart(decision, "task entrypoint changed");
}
#[test]
fn incompatible_serialized_args_require_whole_process_restart() {
let (checkpoint, environment) = checkpoint();
let mut request = restart_request(environment);
request.serialized_args = Digest::sha256("changed-args");
let decision = RestartPolicy.decide(&checkpoint, &request);
assert_whole_process_restart(decision, "serialized task arguments changed");
}
#[test]
fn incompatible_task_abi_requires_whole_process_restart() {
let (checkpoint, environment) = checkpoint();
let mut request = restart_request(environment);
request.task_abi = Digest::sha256("changed-abi");
let decision = RestartPolicy.decide(&checkpoint, &request);
assert_whole_process_restart(decision, "task ABI changed");
}
#[test]
fn restart_never_claims_live_stack_migration() {
let (mut checkpoint, environment) = checkpoint();
checkpoint.depends_on_live_stack = true;
let request = restart_request(environment);
let decision = RestartPolicy.decide(&checkpoint, &request);
assert_whole_process_restart(decision, "live stack");
}
#[test]
fn restart_never_claims_live_socket_checkpointing() {
let (mut checkpoint, environment) = checkpoint();
checkpoint.depends_on_live_socket = true;
let request = restart_request(environment);
let decision = RestartPolicy.decide(&checkpoint, &request);
assert_whole_process_restart(decision, "live socket");
}
#[test]
fn restart_never_depends_on_ephemeral_artifact_durability() {
let (mut checkpoint, environment) = checkpoint();
checkpoint.depends_on_ephemeral_artifact_durability = true;
let request = restart_request(environment);
let decision = RestartPolicy.decide(&checkpoint, &request);
assert_whole_process_restart(decision, "ephemeral artifacts");
}
}

View file

@ -0,0 +1,278 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{ProcessId, TaskInstanceId};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DebugParticipantKind {
WasmTask,
ControlledNativeCommand,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DebugRuntimeState {
Running,
Frozen,
Completed,
Failed(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugParticipant {
pub task: TaskInstanceId,
pub name: String,
pub kind: DebugParticipantKind,
pub can_freeze: bool,
pub state: DebugRuntimeState,
pub stack_frames: Vec<String>,
pub local_values: Vec<(String, String)>,
pub task_args: Vec<(String, String)>,
pub handles: Vec<(String, String)>,
pub command_status: Option<String>,
pub recent_output: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DebugStopReason {
Breakpoint { task: TaskInstanceId, line: u32 },
PauseRequest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThreadInspection {
pub task: TaskInstanceId,
pub name: String,
pub stack_frames: Vec<String>,
pub local_values: Vec<(String, String)>,
pub task_args: Vec<(String, String)>,
pub handles: Vec<(String, String)>,
pub command_status: Option<String>,
pub recent_output: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugEpoch {
pub process: ProcessId,
pub epoch: u64,
pub reason: DebugStopReason,
participants: BTreeMap<TaskInstanceId, DebugParticipant>,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum DebugEpochError {
#[error("participant `{task}` cannot freeze, so all-stop failed")]
CannotFreeze { task: TaskInstanceId },
#[error("participant `{0}` is not part of this debug epoch")]
UnknownParticipant(TaskInstanceId),
}
impl DebugEpoch {
pub fn all_stop(
process: ProcessId,
epoch: u64,
reason: DebugStopReason,
participants: Vec<DebugParticipant>,
) -> Result<Self, DebugEpochError> {
for participant in &participants {
if matches!(
participant.kind,
DebugParticipantKind::WasmTask | DebugParticipantKind::ControlledNativeCommand
) && !participant.can_freeze
{
return Err(DebugEpochError::CannotFreeze {
task: participant.task.clone(),
});
}
}
let participants = participants
.into_iter()
.map(|mut participant| {
if matches!(participant.state, DebugRuntimeState::Running) {
participant.state = DebugRuntimeState::Frozen;
}
(participant.task.clone(), participant)
})
.collect();
Ok(Self {
process,
epoch,
reason,
participants,
})
}
pub fn pause(
process: ProcessId,
epoch: u64,
participants: Vec<DebugParticipant>,
) -> Result<Self, DebugEpochError> {
Self::all_stop(process, epoch, DebugStopReason::PauseRequest, participants)
}
pub fn continue_all(&mut self) {
for participant in self.participants.values_mut() {
if participant.state == DebugRuntimeState::Frozen {
participant.state = DebugRuntimeState::Running;
}
}
}
pub fn inspection(&self, task: &TaskInstanceId) -> Result<ThreadInspection, DebugEpochError> {
let participant = self
.participants
.get(task)
.ok_or_else(|| DebugEpochError::UnknownParticipant(task.clone()))?;
Ok(ThreadInspection {
task: participant.task.clone(),
name: participant.name.clone(),
stack_frames: participant.stack_frames.clone(),
local_values: participant.local_values.clone(),
task_args: participant.task_args.clone(),
handles: participant.handles.clone(),
command_status: participant.command_status.clone(),
recent_output: participant.recent_output.clone(),
})
}
pub fn participant_state(&self, task: &TaskInstanceId) -> Option<&DebugRuntimeState> {
self.participants
.get(task)
.map(|participant| &participant.state)
}
pub fn thread_names(&self) -> Vec<String> {
self.participants
.values()
.map(|participant| participant.name.clone())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn participant(task: &str, kind: DebugParticipantKind, can_freeze: bool) -> DebugParticipant {
DebugParticipant {
task: TaskInstanceId::from(task),
name: task.to_owned(),
kind,
can_freeze,
state: DebugRuntimeState::Running,
stack_frames: vec![format!("{task}::run")],
local_values: vec![("wasm_local_0".to_owned(), "I32(41)".to_owned())],
task_args: vec![("target".to_owned(), "linux".to_owned())],
handles: vec![("artifact".to_owned(), "artifact-1".to_owned())],
command_status: Some("running".to_owned()),
recent_output: vec!["building".to_owned()],
}
}
#[test]
fn breakpoint_creates_all_stop_debug_epoch_for_wasm_and_command_tasks() {
let epoch = DebugEpoch::all_stop(
ProcessId::from("process"),
1,
DebugStopReason::Breakpoint {
task: TaskInstanceId::from("compile-linux"),
line: 42,
},
vec![
participant("main", DebugParticipantKind::WasmTask, true),
participant(
"compile-linux",
DebugParticipantKind::ControlledNativeCommand,
true,
),
],
)
.unwrap();
assert_eq!(
epoch.participant_state(&TaskInstanceId::from("main")),
Some(&DebugRuntimeState::Frozen)
);
assert_eq!(
epoch.participant_state(&TaskInstanceId::from("compile-linux")),
Some(&DebugRuntimeState::Frozen)
);
}
#[test]
fn debug_epoch_reports_freeze_failure_instead_of_claiming_all_stop() {
let error = DebugEpoch::pause(
ProcessId::from("process"),
1,
vec![participant(
"compile-linux",
DebugParticipantKind::ControlledNativeCommand,
false,
)],
)
.unwrap_err();
assert!(matches!(error, DebugEpochError::CannotFreeze { .. }));
}
#[test]
fn continue_resumes_every_frozen_participant() {
let mut epoch = DebugEpoch::pause(
ProcessId::from("process"),
1,
vec![
participant("main", DebugParticipantKind::WasmTask, true),
participant("task", DebugParticipantKind::WasmTask, true),
],
)
.unwrap();
epoch.continue_all();
assert_eq!(
epoch.participant_state(&TaskInstanceId::from("main")),
Some(&DebugRuntimeState::Running)
);
assert_eq!(
epoch.participant_state(&TaskInstanceId::from("task")),
Some(&DebugRuntimeState::Running)
);
}
#[test]
fn inspection_exposes_stack_args_handles_command_status_and_output() {
let epoch = DebugEpoch::pause(
ProcessId::from("process"),
1,
vec![participant(
"compile-linux",
DebugParticipantKind::ControlledNativeCommand,
true,
)],
)
.unwrap();
let inspection = epoch
.inspection(&TaskInstanceId::from("compile-linux"))
.unwrap();
assert_eq!(inspection.stack_frames, vec!["compile-linux::run"]);
assert_eq!(
inspection.local_values[0],
("wasm_local_0".to_owned(), "I32(41)".to_owned())
);
assert_eq!(
inspection.task_args[0],
("target".to_owned(), "linux".to_owned())
);
assert_eq!(
inspection.handles[0],
("artifact".to_owned(), "artifact-1".to_owned())
);
assert_eq!(inspection.command_status, Some("running".to_owned()));
assert_eq!(inspection.recent_output, vec!["building"]);
}
}

View file

@ -0,0 +1,68 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest as ShaDigest, Sha256};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Digest(String);
impl Digest {
pub fn sha256(bytes: impl AsRef<[u8]>) -> Self {
let mut hasher = Sha256::new();
hasher.update(bytes.as_ref());
Self(format!("sha256:{}", hex::encode(hasher.finalize())))
}
pub fn from_parts(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> Self {
let mut hasher = Sha256::new();
for part in parts {
let part = part.as_ref();
hasher.update((part.len() as u64).to_be_bytes());
hasher.update(part);
}
Self(format!("sha256:{}", hex::encode(hasher.finalize())))
}
pub fn from_sha256_hex(hex_digest: impl Into<String>) -> Result<Self, String> {
let digest = Self(format!("sha256:{}", hex_digest.into()));
if digest.is_valid_sha256() {
Ok(digest)
} else {
Err(
"SHA-256 digest must contain exactly 64 lowercase hexadecimal characters"
.to_owned(),
)
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_valid_sha256(&self) -> bool {
let Some(hex) = self.0.strip_prefix("sha256:") else {
return false;
};
hex.len() == 64
&& hex
.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
}
}
impl std::fmt::Display for Digest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn digest_validates_strict_sha256_syntax() {
assert!(Digest::sha256("bytes").is_valid_sha256());
assert!(!Digest("sha1:abc".to_owned()).is_valid_sha256());
assert!(!Digest("sha256:ABCDEF".to_owned()).is_valid_sha256());
assert!(!Digest("sha256:not-hex".to_owned()).is_valid_sha256());
}
}

View file

@ -0,0 +1,288 @@
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{Capability, Digest, Os};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum EnvironmentKind {
Containerfile,
Dockerfile,
NixFlake,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvironmentRequirements {
pub os: Option<Os>,
pub arch: Option<String>,
pub capabilities: BTreeSet<Capability>,
}
impl EnvironmentRequirements {
pub fn linux_container() -> Self {
Self {
os: Some(Os::Linux),
arch: None,
capabilities: BTreeSet::from([Capability::Containers, Capability::RootlessPodman]),
}
}
pub fn windows_command_dev() -> Self {
Self {
os: Some(Os::Windows),
arch: None,
capabilities: BTreeSet::from([Capability::WindowsCommandDev]),
}
}
pub fn unconstrained() -> Self {
Self {
os: None,
arch: None,
capabilities: BTreeSet::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvironmentResource {
pub name: String,
pub kind: EnvironmentKind,
pub recipe_path: PathBuf,
pub context_path: PathBuf,
pub digest: Digest,
pub requirements: EnvironmentRequirements,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvironmentReference {
pub name: String,
pub byte_offset: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvironmentDiagnostic {
pub reference: EnvironmentReference,
pub message: String,
}
#[derive(Debug, Error)]
pub enum EnvironmentError {
#[error("failed to read environment resources under {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub fn discover_environments(
project_root: &Path,
) -> Result<Vec<EnvironmentResource>, EnvironmentError> {
let envs_dir = project_root.join("envs");
if !envs_dir.exists() {
return Ok(Vec::new());
}
let mut resources = Vec::new();
let entries = fs::read_dir(&envs_dir).map_err(|source| EnvironmentError::Read {
path: envs_dir.clone(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| EnvironmentError::Read {
path: envs_dir.clone(),
source,
})?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if let Some(resource) = discover_one(project_root, name, &path)? {
resources.push(resource);
}
}
resources.sort_by(|left, right| left.name.cmp(&right.name));
Ok(resources)
}
pub fn diagnose_environment_references(
source: &str,
environments: &[EnvironmentResource],
) -> Vec<EnvironmentDiagnostic> {
let known = environments
.iter()
.map(|environment| environment.name.as_str())
.collect::<BTreeSet<_>>();
find_env_macro_references(source)
.into_iter()
.filter(|reference| !known.contains(reference.name.as_str()))
.map(|reference| EnvironmentDiagnostic {
message: format!(
"missing Disasmer environment `{}`; expected envs/{}/Containerfile or envs/{}/Dockerfile",
reference.name, reference.name, reference.name
),
reference,
})
.collect()
}
fn discover_one(
project_root: &Path,
name: &str,
env_dir: &Path,
) -> Result<Option<EnvironmentResource>, EnvironmentError> {
let candidates = [
("Containerfile", EnvironmentKind::Containerfile),
("Dockerfile", EnvironmentKind::Dockerfile),
("flake.nix", EnvironmentKind::NixFlake),
];
for (file_name, kind) in candidates {
let recipe_path = env_dir.join(file_name);
if !recipe_path.exists() {
continue;
}
let recipe_bytes = fs::read(&recipe_path).map_err(|source| EnvironmentError::Read {
path: recipe_path.clone(),
source,
})?;
let relative_recipe = recipe_path
.strip_prefix(project_root)
.unwrap_or(&recipe_path)
.to_string_lossy();
let digest = Digest::from_parts([
b"environment:v1".as_slice(),
name.as_bytes(),
format!("{kind:?}").as_bytes(),
relative_recipe.as_bytes(),
recipe_bytes.as_slice(),
]);
let requirements = match kind {
EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile
if name.eq_ignore_ascii_case("windows") =>
{
EnvironmentRequirements::windows_command_dev()
}
EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => {
EnvironmentRequirements::linux_container()
}
EnvironmentKind::NixFlake => EnvironmentRequirements::unconstrained(),
};
return Ok(Some(EnvironmentResource {
name: name.to_owned(),
kind,
recipe_path,
context_path: env_dir.to_path_buf(),
digest,
requirements,
}));
}
Ok(None)
}
fn find_env_macro_references(source: &str) -> Vec<EnvironmentReference> {
let mut references = Vec::new();
let mut cursor = 0;
while let Some(index) = source[cursor..].find("env!(") {
let start = cursor + index;
let mut pos = start + "env!(".len();
while source[pos..].starts_with(char::is_whitespace) {
pos += source[pos..]
.chars()
.next()
.map(char::len_utf8)
.unwrap_or(1);
}
if !source[pos..].starts_with('"') {
cursor = pos;
continue;
}
pos += 1;
let name_start = pos;
while pos < source.len() && !source[pos..].starts_with('"') {
pos += source[pos..]
.chars()
.next()
.map(char::len_utf8)
.unwrap_or(1);
}
if pos < source.len() {
references.push(EnvironmentReference {
name: source[name_start..pos].to_owned(),
byte_offset: start,
});
}
cursor = pos.saturating_add(1);
}
references
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[test]
fn discovers_containerfile_environments_by_logical_name() {
let temp = tempfile::tempdir().unwrap();
let linux = temp.path().join("envs/linux");
fs::create_dir_all(&linux).unwrap();
fs::write(linux.join("Containerfile"), "FROM alpine\n").unwrap();
let envs = discover_environments(temp.path()).unwrap();
assert_eq!(envs.len(), 1);
assert_eq!(envs[0].name, "linux");
assert_eq!(envs[0].kind, EnvironmentKind::Containerfile);
assert!(!envs[0].digest.as_str().is_empty());
}
#[test]
fn missing_env_macro_reference_reports_clear_diagnostic() {
let source = r#"fn main() { let _ = env!("windows"); }"#;
let diagnostics = diagnose_environment_references(source, &[]);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0]
.message
.contains("envs/windows/Containerfile"));
}
#[test]
fn windows_environment_name_uses_windows_development_requirements() {
let temp = tempfile::tempdir().unwrap();
let windows = temp.path().join("envs/windows");
fs::create_dir_all(&windows).unwrap();
fs::write(
windows.join("Dockerfile"),
"# user-attached windows dev contract\n",
)
.unwrap();
let envs = discover_environments(temp.path()).unwrap();
assert_eq!(envs[0].name, "windows");
assert_eq!(envs[0].requirements.os, Some(Os::Windows));
assert!(envs[0]
.requirements
.capabilities
.contains(&Capability::WindowsCommandDev));
}
}

View file

@ -0,0 +1,764 @@
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::{
ArtifactHandle, ArtifactId, Capability, Digest, EnvironmentRequirements, EnvironmentResource,
NodeId, ProcessId, ProjectId, TaskDefinitionId, TaskInstanceId, TenantId,
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum GuestRuntimeKind {
Wasmtime,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CommandBackendKind {
LinuxRootlessPodman,
WindowsCommandDev,
StubbedWindowsSandbox,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandNetworkPolicy {
Disabled,
Enabled,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandInvocation {
pub program: String,
pub args: Vec<String>,
pub working_directory: String,
pub environment_variables: BTreeMap<String, String>,
pub timeout_ms: u64,
pub network: CommandNetworkPolicy,
pub env: Option<EnvironmentResource>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandPlan {
pub guest_runtime: GuestRuntimeKind,
pub backend: CommandBackendKind,
pub required_capability: Capability,
pub user_attached_development_execution: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeCommandPolicy {
pub hosted_control_plane: bool,
pub node_has_command_capability: bool,
}
impl NativeCommandPolicy {
pub fn authorize(&self) -> Result<(), String> {
if self.hosted_control_plane {
return Err("hosted coordinator control plane cannot run native commands".to_owned());
}
if !self.node_has_command_capability {
return Err("selected node or task lacks native command capability".to_owned());
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskBoundaryValue {
SmallJson(serde_json::Value),
Structured(StructuredTaskBoundary),
SourceSnapshot(crate::Digest),
Blob(crate::Digest),
Artifact(ArtifactHandle),
VfsManifest(crate::Digest),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum TaskBoundaryHandle {
SourceSnapshot(crate::Digest),
Blob(crate::Digest),
Artifact(crate::ArtifactId),
VfsManifest(crate::Digest),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StructuredTaskBoundary {
pub value: serde_json::Value,
pub handles: Vec<TaskBoundaryHandle>,
}
impl TaskBoundaryValue {
pub fn required_artifacts(&self) -> Vec<ArtifactId> {
match self {
Self::Artifact(artifact) => vec![artifact.id.clone()],
Self::Structured(structured) => structured
.handles
.iter()
.filter_map(|handle| match handle {
TaskBoundaryHandle::Artifact(artifact) => Some(artifact.clone()),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
pub fn source_snapshots(&self) -> Vec<Digest> {
match self {
Self::SourceSnapshot(snapshot) => vec![snapshot.clone()],
Self::Structured(structured) => structured
.handles
.iter()
.filter_map(|handle| match handle {
TaskBoundaryHandle::SourceSnapshot(snapshot) => Some(snapshot.clone()),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
}
pub const WASM_TASK_ABI_VERSION: u32 = 1;
pub const MAX_WASM_TASK_ENVELOPE_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostTaskStartRequest {
pub abi_version: u32,
pub task_definition: TaskDefinitionId,
pub environment_id: Option<String>,
pub args: Vec<TaskBoundaryValue>,
}
impl WasmHostTaskStartRequest {
pub fn validate(&self) -> Result<(), String> {
WasmTaskInvocation {
abi_version: self.abi_version,
task_definition: self.task_definition.clone(),
task_instance: TaskInstanceId::from("validation-only-instance"),
args: self.args.clone(),
}
.validate()?;
if self
.environment_id
.as_deref()
.is_some_and(|environment| environment.trim().is_empty() || environment.len() > 128)
{
return Err("Wasm child task environment id is invalid".to_owned());
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostTaskHandle {
pub abi_version: u32,
pub handle_id: u64,
pub task_spec: TaskSpec,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostTaskJoinRequest {
pub abi_version: u32,
pub handle_id: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostTaskJoinResult {
pub abi_version: u32,
pub task_instance: TaskInstanceId,
pub result: TaskBoundaryValue,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostCommandRequest {
pub abi_version: u32,
pub program: String,
pub args: Vec<String>,
pub working_directory: String,
pub environment_variables: BTreeMap<String, String>,
pub timeout_ms: u64,
pub network: CommandNetworkPolicy,
}
impl WasmHostCommandRequest {
pub fn validate(&self) -> Result<(), String> {
if self.abi_version != WASM_TASK_ABI_VERSION {
return Err(format!(
"unsupported Wasm command ABI version {}; expected {}",
self.abi_version, WASM_TASK_ABI_VERSION
));
}
if self.program.trim().is_empty() || self.program.len() > 4096 {
return Err("Wasm command program is invalid".to_owned());
}
if self.args.len() > 1024 || self.args.iter().any(|arg| arg.len() > 16 * 1024) {
return Err("Wasm command argument list exceeds ABI limits".to_owned());
}
validate_command_working_directory(&self.working_directory)?;
if self.environment_variables.len() > 128 {
return Err("Wasm command environment exceeds 128 variables".to_owned());
}
let environment_bytes =
self.environment_variables
.iter()
.try_fold(0_usize, |total, (name, value)| {
if !valid_environment_name(name) || value.contains('\0') {
return Err(
"Wasm command environment contains an invalid variable".to_owned()
);
}
if name.len() > 128 || value.len() > 16 * 1024 {
return Err(
"Wasm command environment variable exceeds ABI limits".to_owned()
);
}
total
.checked_add(name.len() + value.len())
.ok_or_else(|| "Wasm command environment size overflowed".to_owned())
})?;
if environment_bytes > 32 * 1024 {
return Err("Wasm command environment exceeds the 32 KiB limit".to_owned());
}
if !(1_000..=60 * 60 * 1_000).contains(&self.timeout_ms) {
return Err("Wasm command timeout must be between 1 second and 1 hour".to_owned());
}
let encoded = serde_json::to_vec(self).map_err(|error| error.to_string())?;
if encoded.len() > MAX_WASM_TASK_ENVELOPE_BYTES {
return Err("Wasm command request exceeds the task ABI limit".to_owned());
}
Ok(())
}
}
fn validate_command_working_directory(path: &str) -> Result<(), String> {
let permitted_root = path == "/workspace"
|| path.starts_with("/workspace/")
|| path == "/disasmer/output"
|| path.starts_with("/disasmer/output/");
if !permitted_root
|| path.len() > 4096
|| path.contains('\0')
|| path.contains('\\')
|| path.split('/').any(|component| component == "..")
{
return Err(
"Wasm command working directory must stay under /workspace or /disasmer/output"
.to_owned(),
);
}
Ok(())
}
fn valid_environment_name(name: &str) -> bool {
let mut characters = name.chars();
characters
.next()
.is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
&& characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostCommandResult {
pub abi_version: u32,
pub status_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostTaskControlRequest {
pub abi_version: u32,
}
impl WasmHostTaskControlRequest {
pub fn validate(&self) -> Result<(), String> {
if self.abi_version != WASM_TASK_ABI_VERSION {
return Err(format!(
"unsupported Wasm task-control ABI version {}; expected {}",
self.abi_version, WASM_TASK_ABI_VERSION
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostTaskControlResult {
pub abi_version: u32,
pub cancellation_requested: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostDebugProbeRequest {
pub abi_version: u32,
pub symbol: String,
}
impl WasmHostDebugProbeRequest {
pub fn validate(&self) -> Result<(), String> {
if self.abi_version != WASM_TASK_ABI_VERSION {
return Err(format!(
"unsupported Wasm debug-probe ABI version {}; expected {}",
self.abi_version, WASM_TASK_ABI_VERSION
));
}
if self.symbol.trim().is_empty()
|| self.symbol.len() > 256
|| !self
.symbol
.chars()
.all(|character| character.is_ascii_alphanumeric() || "._:-/".contains(character))
{
return Err("Wasm debug probe symbol is invalid".to_owned());
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostDebugProbeResult {
pub abi_version: u32,
pub breakpoint_matched: bool,
pub debug_epoch: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostVfsRequest {
pub abi_version: u32,
pub operation: WasmHostVfsOperation,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WasmHostVfsOperation {
FlushOutput {
relative_path: String,
},
MaterializeArtifact {
artifact: ArtifactHandle,
relative_path: String,
},
}
impl WasmHostVfsRequest {
pub fn validate(&self) -> Result<(), String> {
if self.abi_version != WASM_TASK_ABI_VERSION {
return Err(format!(
"unsupported Wasm VFS ABI version {}; expected {}",
self.abi_version, WASM_TASK_ABI_VERSION
));
}
let relative_path = match &self.operation {
WasmHostVfsOperation::FlushOutput { relative_path }
| WasmHostVfsOperation::MaterializeArtifact { relative_path, .. } => relative_path,
};
validate_task_relative_path(relative_path)?;
if let WasmHostVfsOperation::MaterializeArtifact { artifact, .. } = &self.operation {
if !artifact.digest.is_valid_sha256() {
return Err("Wasm VFS artifact digest is invalid".to_owned());
}
}
let encoded = serde_json::to_vec(self).map_err(|error| error.to_string())?;
if encoded.len() > MAX_WASM_TASK_ENVELOPE_BYTES {
return Err("Wasm VFS request exceeds the task ABI limit".to_owned());
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostVfsResult {
pub abi_version: u32,
pub artifact: ArtifactHandle,
pub relative_path: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostSourceSnapshotRequest {
pub abi_version: u32,
}
impl WasmHostSourceSnapshotRequest {
pub fn validate(&self) -> Result<(), String> {
if self.abi_version != WASM_TASK_ABI_VERSION {
return Err(format!(
"unsupported Wasm source-snapshot ABI version {}; expected {}",
self.abi_version, WASM_TASK_ABI_VERSION
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmHostSourceSnapshotResult {
pub abi_version: u32,
pub snapshot: Digest,
}
fn validate_task_relative_path(path: &str) -> Result<(), String> {
if path.is_empty()
|| path.len() > 240
|| path.starts_with('/')
|| path.starts_with('\\')
|| path.split('/').any(|component| {
component.is_empty()
|| component == "."
|| component == ".."
|| !component
.chars()
.all(|character| character.is_ascii_alphanumeric() || "._-".contains(character))
})
{
return Err("Wasm VFS path must be a safe task-output-relative path".to_owned());
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmTaskInvocation {
pub abi_version: u32,
pub task_definition: TaskDefinitionId,
pub task_instance: TaskInstanceId,
pub args: Vec<TaskBoundaryValue>,
}
impl WasmTaskInvocation {
pub fn new(
task_definition: TaskDefinitionId,
task_instance: TaskInstanceId,
args: Vec<TaskBoundaryValue>,
) -> Self {
Self {
abi_version: WASM_TASK_ABI_VERSION,
task_definition,
task_instance,
args,
}
}
pub fn validate(&self) -> Result<(), String> {
if self.abi_version != WASM_TASK_ABI_VERSION {
return Err(format!(
"unsupported Wasm task ABI version {}; expected {}",
self.abi_version, WASM_TASK_ABI_VERSION
));
}
if self.task_definition.as_str().len() > 128 {
return Err("Wasm task invocation has an invalid task-definition id".to_owned());
}
if self.task_instance.as_str().len() > 192 {
return Err("Wasm task invocation has an invalid task-instance id".to_owned());
}
let encoded = serde_json::to_vec(self).map_err(|error| error.to_string())?;
if encoded.len() > MAX_WASM_TASK_ENVELOPE_BYTES {
return Err(format!(
"Wasm task invocation is {} bytes; maximum is {}",
encoded.len(),
MAX_WASM_TASK_ENVELOPE_BYTES
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WasmTaskOutcome {
Completed,
Failed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WasmTaskResult {
pub abi_version: u32,
pub task_instance: TaskInstanceId,
pub outcome: WasmTaskOutcome,
pub result: Option<TaskBoundaryValue>,
pub error: Option<String>,
}
impl WasmTaskResult {
pub fn completed(task_instance: TaskInstanceId, result: TaskBoundaryValue) -> Self {
Self {
abi_version: WASM_TASK_ABI_VERSION,
task_instance,
outcome: WasmTaskOutcome::Completed,
result: Some(result),
error: None,
}
}
pub fn failed(task_instance: TaskInstanceId, error: impl Into<String>) -> Self {
Self {
abi_version: WASM_TASK_ABI_VERSION,
task_instance,
outcome: WasmTaskOutcome::Failed,
result: None,
error: Some(error.into()),
}
}
pub fn validate_for(&self, expected_instance: &TaskInstanceId) -> Result<(), String> {
if self.abi_version != WASM_TASK_ABI_VERSION {
return Err(format!(
"unsupported Wasm task result ABI version {}; expected {}",
self.abi_version, WASM_TASK_ABI_VERSION
));
}
if &self.task_instance != expected_instance {
return Err(format!(
"Wasm task result belongs to {} instead of {}",
self.task_instance, expected_instance
));
}
match (&self.outcome, &self.result, &self.error) {
(WasmTaskOutcome::Completed, Some(_), None)
| (WasmTaskOutcome::Failed, None, Some(_)) => Ok(()),
_ => Err("Wasm task result has inconsistent outcome fields".to_owned()),
}
}
}
impl TaskBoundaryValue {
pub fn reject_host_only(type_name: &str) -> Result<Self, String> {
Err(format!(
"task boundary value `{type_name}` is host-only; use small serialized data or handles"
))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WasmExportAbi {
EntrypointV1,
TaskV1,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TaskDispatch {
CoordinatorNodeWasm {
export: Option<String>,
abi: WasmExportAbi,
},
}
impl TaskDispatch {
pub fn is_product_remote_dispatch(&self) -> bool {
matches!(self, Self::CoordinatorNodeWasm { .. })
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskSpec {
pub tenant: TenantId,
pub project: ProjectId,
pub process: ProcessId,
pub task_definition: TaskDefinitionId,
pub task_instance: TaskInstanceId,
pub dispatch: TaskDispatch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment_id: Option<String>,
pub environment: Option<EnvironmentRequirements>,
pub environment_digest: Option<Digest>,
pub required_capabilities: BTreeSet<Capability>,
pub dependency_cache: Option<Digest>,
pub source_snapshot: Option<Digest>,
pub required_artifacts: Vec<ArtifactId>,
pub args: Vec<TaskBoundaryValue>,
pub vfs_epoch: u64,
pub bundle_digest: Option<Digest>,
}
impl TaskSpec {
pub fn product_mode_uses_remote_dispatch(&self) -> bool {
self.dispatch.is_product_remote_dispatch()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskJoinState {
Pending,
Completed,
Failed,
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskJoinResult {
pub process: ProcessId,
pub task_instance: TaskInstanceId,
pub node: Option<NodeId>,
pub state: TaskJoinState,
pub result: Option<TaskBoundaryValue>,
pub status_code: Option<i32>,
pub remote_completion_observed: bool,
pub message: String,
}
impl TaskJoinResult {
pub fn pending(
process: ProcessId,
task_instance: TaskInstanceId,
message: impl Into<String>,
) -> Self {
Self {
process,
task_instance,
node: None,
state: TaskJoinState::Pending,
result: None,
status_code: None,
remote_completion_observed: false,
message: message.into(),
}
}
pub fn from_remote_completion(
process: ProcessId,
task_instance: TaskInstanceId,
node: NodeId,
state: TaskJoinState,
result: Option<TaskBoundaryValue>,
status_code: Option<i32>,
message: impl Into<String>,
) -> Self {
Self {
process,
task_instance,
node: Some(node),
state,
result,
status_code,
remote_completion_observed: true,
message: message.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosted_control_plane_cannot_authorize_native_command() {
let policy = NativeCommandPolicy {
hosted_control_plane: true,
node_has_command_capability: true,
};
assert!(policy
.authorize()
.unwrap_err()
.contains("hosted coordinator"));
}
#[test]
fn raw_pointer_style_task_argument_is_rejected() {
let error = TaskBoundaryValue::reject_host_only("*const u8").unwrap_err();
assert!(error.contains("host-only"));
}
#[test]
fn product_task_spec_has_no_local_function_dispatch_variant() {
let spec = TaskSpec {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
process: ProcessId::from("process"),
task_definition: TaskDefinitionId::from("compile-linux"),
task_instance: TaskInstanceId::from("compile-linux-1"),
dispatch: TaskDispatch::CoordinatorNodeWasm {
export: Some("compile_linux".to_owned()),
abi: WasmExportAbi::TaskV1,
},
environment_id: Some("linux".to_owned()),
environment: None,
environment_digest: None,
required_capabilities: BTreeSet::from([Capability::Command]),
dependency_cache: None,
source_snapshot: None,
required_artifacts: Vec::new(),
args: vec![TaskBoundaryValue::SmallJson(serde_json::json!("src"))],
vfs_epoch: 7,
bundle_digest: Some(Digest::sha256("bundle")),
};
assert!(spec.product_mode_uses_remote_dispatch());
}
#[test]
fn task_join_result_marks_remote_completion_as_observed() {
let joined = TaskJoinResult::from_remote_completion(
ProcessId::from("process"),
TaskInstanceId::from("compile-linux-1"),
NodeId::from("linux-a"),
TaskJoinState::Completed,
Some(TaskBoundaryValue::Artifact(ArtifactHandle {
id: ArtifactId::from("app.tar.gz"),
digest: Digest::sha256("artifact"),
size_bytes: 8,
})),
Some(0),
"completed from signed node event",
);
assert!(joined.remote_completion_observed);
assert!(matches!(
joined.result,
Some(TaskBoundaryValue::Artifact(_))
));
}
#[test]
fn structured_command_request_bounds_cwd_environment_timeout_and_network_policy() {
let mut request = WasmHostCommandRequest {
abi_version: WASM_TASK_ABI_VERSION,
program: "cc".to_owned(),
args: vec!["source.c".to_owned()],
working_directory: "/workspace/crate".to_owned(),
environment_variables: BTreeMap::from([
("BUILD_MODE".to_owned(), "release".to_owned()),
("SOURCE_DATE_EPOCH".to_owned(), "0".to_owned()),
]),
timeout_ms: 120_000,
network: CommandNetworkPolicy::Disabled,
};
request.validate().unwrap();
request.working_directory = "/workspace/../host".to_owned();
assert!(request
.validate()
.unwrap_err()
.contains("working directory"));
request.working_directory = "/workspace".to_owned();
request.environment_variables = BTreeMap::from([("BAD-NAME".to_owned(), "x".to_owned())]);
assert!(request.validate().unwrap_err().contains("invalid variable"));
request.environment_variables.clear();
request.timeout_ms = 0;
assert!(request.validate().unwrap_err().contains("timeout"));
}
}

View file

@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
macro_rules! id_type {
($name:ident) => {
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Self {
let value = value.into();
assert!(
!value.trim().is_empty(),
concat!(stringify!($name), " cannot be empty")
);
Self(value)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for $name {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
};
}
id_type!(AgentId);
id_type!(ArtifactId);
id_type!(NodeId);
id_type!(ProcessId);
id_type!(ProjectId);
id_type!(TaskDefinitionId);
id_type!(TaskInstanceId);
id_type!(TenantId);
id_type!(UserId);

View file

@ -0,0 +1,102 @@
pub mod artifact;
pub mod auth;
pub mod bundle;
pub mod capability;
pub mod checkpoint;
pub mod debug;
pub mod digest;
pub mod environment;
pub mod execution;
pub mod ids;
pub mod limits;
pub mod operator_panel;
pub mod policy;
pub mod project;
pub mod scheduler;
pub mod source;
pub mod transport;
pub mod vfs;
pub mod wire;
pub use artifact::{
ArtifactDownloadStream, ArtifactFlush, ArtifactHandle, ArtifactMetadata, ArtifactRegistry,
ArtifactUnavailable, DownloadAction, DownloadError, DownloadLink, DownloadPolicy,
DownloadStreamRequest, RetentionPolicy, StorageLocation,
};
pub use auth::{
admin_request_proof, admin_request_proof_from_token_digest,
agent_ed25519_public_key_from_private_key, derive_ed25519_private_key_from_seed,
node_capability_policy_digest, node_ed25519_public_key_from_private_key,
sign_agent_workflow_request, sign_node_request, signed_request_payload_digest,
verify_agent_workflow_signature, verify_node_request_signature, Action, Actor,
AgentSignedRequest, AgentWorkflowScope, AuthContext, Authorization, BrowserLoginFlow,
CredentialKind, EnrollmentError, EnrollmentGrant, IdentityKind, NodeCredential,
NodeSignedRequest, PublicKeyIdentity, Scope,
};
#[cfg(not(target_arch = "wasm32"))]
pub use auth::{generate_ed25519_private_key, generate_opaque_token};
pub use bundle::{
discover_source_debug_probes, BundleDebugMetadata, BundleDebugProbe, BundleIdentityInputs,
BundleLargeInputPolicy, BundleMetadata, BundleRestartCompatibility, BundleSourceMetadata,
BundleTaskMetadata, SelectedInput,
};
pub use capability::{Capability, CapabilityReportError, EnvironmentBackend, NodeCapabilities, Os};
pub use checkpoint::{
CheckpointBoundary, CompatibilityFailure, RestartDecision, RestartPolicy, RestartRequest,
TaskCheckpoint,
};
pub use debug::{
DebugEpoch, DebugEpochError, DebugParticipant, DebugParticipantKind, DebugRuntimeState,
DebugStopReason, ThreadInspection,
};
pub use digest::Digest;
pub use environment::{
diagnose_environment_references, discover_environments, EnvironmentDiagnostic, EnvironmentKind,
EnvironmentReference, EnvironmentRequirements, EnvironmentResource,
};
pub use execution::{
CommandBackendKind, CommandInvocation, CommandNetworkPolicy, CommandPlan, GuestRuntimeKind,
NativeCommandPolicy, StructuredTaskBoundary, TaskBoundaryHandle, TaskBoundaryValue,
TaskDispatch, TaskJoinResult, TaskJoinState, TaskSpec, WasmExportAbi, WasmHostCommandRequest,
WasmHostCommandResult, WasmHostDebugProbeRequest, WasmHostDebugProbeResult,
WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, WasmHostTaskControlRequest,
WasmHostTaskControlResult, WasmHostTaskHandle, WasmHostTaskJoinRequest, WasmHostTaskJoinResult,
WasmHostTaskStartRequest, WasmHostVfsOperation, WasmHostVfsRequest, WasmHostVfsResult,
WasmTaskInvocation, WasmTaskOutcome, WasmTaskResult, MAX_WASM_TASK_ENVELOPE_BYTES,
WASM_TASK_ABI_VERSION,
};
pub use ids::{
AgentId, ArtifactId, NodeId, ProcessId, ProjectId, TaskDefinitionId, TaskInstanceId, TenantId,
UserId,
};
pub use limits::{
LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits,
ResourceMeter, TaskArgumentBudget, MIN_SIGNED_NODE_POLL_INTERVAL_MS,
};
pub use operator_panel::{
ControlPlaneAction, PanelError, PanelEvent, PanelEventKind, PanelState, PanelWidget,
PanelWidgetKind, RateLimit,
};
pub use policy::{
CapabilityPolicy, Decision, LocalTrustedPolicy, PolicyReason, ResourceRequest, ServicePolicy,
};
pub use project::{Entrypoint, ProjectModel, ProjectModelError};
pub use scheduler::{
DefaultScheduler, NodeDescriptor, Placement, PlacementError, PlacementRequest, Scheduler,
};
pub use source::{
SourceManifestError, SourcePreparation, SourceProviderKind, SourceProviderManifest,
SourceProviderModule, SourceTransferMode, SourceTransferPolicy,
};
pub use transport::{
BulkTransferDecision, DataPlaneObject, DataPlaneScope, DirectBulkTransferPlan,
NativeQuicTransport, NodeEndpoint, RendezvousRequest, Transport, TransportError, TransportKind,
};
pub use vfs::{
ReuseDecision, SyncPolicy, VfsError, VfsManifest, VfsObject, VfsOverlay, VfsPath,
VfsSyncDecision,
};
pub use wire::{
coordinator_authentication_metadata, coordinator_payload_operation, coordinator_wire_request,
COORDINATOR_PROTOCOL_VERSION, COORDINATOR_WIRE_REQUEST_TYPE,
};

View file

@ -0,0 +1,273 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::TaskInstanceId;
/// Fastest supported interval for a node's signed artifact/assignment polling loop.
/// The coordinator's bounded replay window is sized against this protocol limit.
pub const MIN_SIGNED_NODE_POLL_INTERVAL_MS: u64 = 20;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum LimitKind {
ApiCall,
Spawn,
LogBytes,
MetadataBytes,
DebugReadBytes,
UiEvent,
RendezvousAttempt,
ArtifactDownloadBytes,
HostedFuel,
HostedMemoryBytes,
HostedWallClockMs,
HostedStateBytes,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceLimits {
pub limits: BTreeMap<LimitKind, u64>,
}
impl ResourceLimits {
pub fn new(limits: impl IntoIterator<Item = (LimitKind, u64)>) -> Self {
Self {
limits: limits.into_iter().collect(),
}
}
pub fn unlimited() -> Self {
Self::new(LimitKind::ALL.into_iter().map(|kind| (kind, u64::MAX)))
}
pub fn limit(&self, kind: &LimitKind) -> u64 {
*self.limits.get(kind).unwrap_or(&0)
}
}
impl Default for ResourceLimits {
fn default() -> Self {
Self::unlimited()
}
}
impl LimitKind {
pub const ALL: [Self; 12] = [
Self::ApiCall,
Self::Spawn,
Self::LogBytes,
Self::MetadataBytes,
Self::DebugReadBytes,
Self::UiEvent,
Self::RendezvousAttempt,
Self::ArtifactDownloadBytes,
Self::HostedFuel,
Self::HostedMemoryBytes,
Self::HostedWallClockMs,
Self::HostedStateBytes,
];
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceMeter {
used: BTreeMap<LimitKind, u64>,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum LimitError {
#[error(
"resource limit exceeded for {kind:?}: requested {requested}, used {used}, limit {limit}"
)]
Exceeded {
kind: LimitKind,
requested: u64,
used: u64,
limit: u64,
},
#[error("task argument is too large: {size} bytes exceeds {limit} bytes")]
LargeTaskArgument { size: u64, limit: u64 },
}
impl ResourceMeter {
pub fn can_charge(
&self,
limits: &ResourceLimits,
kind: LimitKind,
amount: u64,
) -> Result<(), LimitError> {
let used = self.used.get(&kind).copied().unwrap_or(0);
let limit = limits.limit(&kind);
if used.saturating_add(amount) > limit {
return Err(LimitError::Exceeded {
kind,
requested: amount,
used,
limit,
});
}
Ok(())
}
pub fn charge(
&mut self,
limits: &ResourceLimits,
kind: LimitKind,
amount: u64,
) -> Result<(), LimitError> {
self.can_charge(limits, kind, amount)?;
let used = self.used.get(&kind).copied().unwrap_or(0);
self.used.insert(kind, used + amount);
Ok(())
}
pub fn used(&self, kind: &LimitKind) -> u64 {
self.used.get(kind).copied().unwrap_or(0)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogRecord {
pub task: TaskInstanceId,
pub bytes: Vec<u8>,
pub truncated: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogBuffer {
max_bytes: usize,
used_bytes: usize,
records: Vec<LogRecord>,
backpressured: bool,
}
impl LogBuffer {
pub fn new(max_bytes: usize) -> Self {
Self {
max_bytes,
used_bytes: 0,
records: Vec::new(),
backpressured: false,
}
}
pub fn push(&mut self, task: TaskInstanceId, bytes: impl AsRef<[u8]>) {
let bytes = bytes.as_ref();
let remaining = self.max_bytes.saturating_sub(self.used_bytes);
let truncated = bytes.len() > remaining;
let stored = bytes[..bytes.len().min(remaining)].to_vec();
self.used_bytes += stored.len();
if truncated {
self.backpressured = true;
}
self.records.push(LogRecord {
task,
bytes: stored,
truncated,
});
}
pub fn records(&self) -> &[LogRecord] {
&self.records
}
pub fn backpressured(&self) -> bool {
self.backpressured
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LargeArgumentPolicy {
Allow,
Warn,
Reject,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskArgumentBudget {
pub max_inline_bytes: u64,
pub policy: LargeArgumentPolicy,
}
impl TaskArgumentBudget {
pub fn validate(&self, size: u64) -> Result<Option<String>, LimitError> {
if size <= self.max_inline_bytes {
return Ok(None);
}
match self.policy {
LargeArgumentPolicy::Allow => Ok(None),
LargeArgumentPolicy::Warn => Ok(Some(format!(
"task argument is {size} bytes; prefer SourceSnapshot, Blob, Artifact, or VFS handles"
))),
LargeArgumentPolicy::Reject => Err(LimitError::LargeTaskArgument {
size,
limit: self.max_inline_bytes,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_meter_rejects_usage_before_work_starts() {
let limits = ResourceLimits {
limits: BTreeMap::from([(LimitKind::Spawn, 1)]),
};
let mut meter = ResourceMeter::default();
meter.charge(&limits, LimitKind::Spawn, 1).unwrap();
let error = meter.charge(&limits, LimitKind::Spawn, 1).unwrap_err();
assert!(matches!(error, LimitError::Exceeded { .. }));
}
#[test]
fn resource_meter_can_check_limits_without_consuming() {
let limits = ResourceLimits {
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 4)]),
};
let mut meter = ResourceMeter::default();
meter
.can_charge(&limits, LimitKind::ArtifactDownloadBytes, 4)
.unwrap();
assert_eq!(meter.used(&LimitKind::ArtifactDownloadBytes), 0);
meter
.charge(&limits, LimitKind::ArtifactDownloadBytes, 3)
.unwrap();
assert!(matches!(
meter.can_charge(&limits, LimitKind::ArtifactDownloadBytes, 2),
Err(LimitError::Exceeded { .. })
));
}
#[test]
fn log_buffer_caps_backpressures_and_keeps_task_association() {
let mut logs = LogBuffer::new(4);
logs.push(TaskInstanceId::from("task-a"), b"abcdef");
assert!(logs.backpressured());
assert_eq!(logs.records()[0].task, TaskInstanceId::from("task-a"));
assert_eq!(logs.records()[0].bytes, b"abcd");
assert!(logs.records()[0].truncated);
}
#[test]
fn large_task_arguments_are_rejected_or_warned() {
let reject = TaskArgumentBudget {
max_inline_bytes: 4,
policy: LargeArgumentPolicy::Reject,
};
assert!(reject.validate(5).is_err());
let warn = TaskArgumentBudget {
max_inline_bytes: 4,
policy: LargeArgumentPolicy::Warn,
};
assert!(warn.validate(5).unwrap().unwrap().contains("Artifact"));
}
}

View file

@ -0,0 +1,377 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
ArtifactId, DownloadAction, DownloadError, ProcessId, ProjectId, TaskInstanceId, TenantId,
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PanelWidgetKind {
Text {
value: String,
},
Progress {
current: u64,
total: u64,
},
Button {
action: String,
},
Toggle {
value: bool,
},
Select {
options: Vec<String>,
selected: String,
},
ArtifactDownload {
artifact: ArtifactId,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelWidget {
pub id: String,
pub label: String,
pub kind: PanelWidgetKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ControlPlaneAction {
RestartTask(TaskInstanceId),
CancelProcess,
DebugProcess,
DownloadArtifact(ArtifactId),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelState {
pub tenant: TenantId,
pub project: ProjectId,
pub process: ProcessId,
pub widgets: BTreeMap<String, PanelWidget>,
pub program_ui_events_enabled: bool,
pub control_plane_actions: Vec<ControlPlaneAction>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PanelEventKind {
ButtonClicked,
ToggleChanged(bool),
SelectChanged(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelEvent {
pub tenant: TenantId,
pub project: ProjectId,
pub process: ProcessId,
pub widget_id: String,
pub kind: PanelEventKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RateLimit {
pub max_events: u64,
pub used_events: u64,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum PanelError {
#[error("custom HTML or JavaScript is not supported in operator panels")]
CustomContentDenied,
#[error(
"operator panel widget `{0}` is not allowed to collect secrets or OAuth-like credentials"
)]
CredentialCollectionDenied(String),
#[error("panel event scope does not match tenant/project/process")]
ScopeMismatch,
#[error("program UI events are disabled while debug process is stopped")]
ProgramEventsDisabled,
#[error("panel event rate limit exceeded")]
RateLimited,
#[error("unknown panel widget `{0}`")]
UnknownWidget(String),
#[error("artifact download action is unavailable: {0}")]
DownloadUnavailable(String),
}
impl PanelState {
pub fn new(tenant: TenantId, project: ProjectId, process: ProcessId) -> Self {
Self {
tenant,
project,
process,
widgets: BTreeMap::new(),
program_ui_events_enabled: true,
control_plane_actions: Vec::new(),
}
}
pub fn add_widget(&mut self, widget: PanelWidget) -> Result<(), PanelError> {
validate_widget(&widget)?;
self.widgets.insert(widget.id.clone(), widget);
Ok(())
}
pub fn add_download_widget_from_action(
&mut self,
widget_id: impl Into<String>,
label: impl Into<String>,
action: Result<DownloadAction, DownloadError>,
) -> Result<(), PanelError> {
let action = action.map_err(|err| PanelError::DownloadUnavailable(err.to_string()))?;
let artifact = action.artifact;
self.add_widget(PanelWidget {
id: widget_id.into(),
label: label.into(),
kind: PanelWidgetKind::ArtifactDownload {
artifact: artifact.clone(),
},
})?;
self.control_plane_actions
.push(ControlPlaneAction::DownloadArtifact(artifact));
Ok(())
}
pub fn reject_custom_content(_html_or_js: &str) -> Result<(), PanelError> {
Err(PanelError::CustomContentDenied)
}
pub fn freeze_program_ui_events(&mut self) {
self.program_ui_events_enabled = false;
}
pub fn set_control_plane_actions(&mut self, actions: Vec<ControlPlaneAction>) {
self.control_plane_actions = actions;
}
pub fn accept_event(
&self,
event: &PanelEvent,
limit: &mut RateLimit,
) -> Result<(), PanelError> {
if !self.program_ui_events_enabled {
return Err(PanelError::ProgramEventsDisabled);
}
if self.tenant != event.tenant
|| self.project != event.project
|| self.process != event.process
{
return Err(PanelError::ScopeMismatch);
}
if !self.widgets.contains_key(&event.widget_id) {
return Err(PanelError::UnknownWidget(event.widget_id.clone()));
}
if limit.used_events >= limit.max_events {
return Err(PanelError::RateLimited);
}
limit.used_events += 1;
Ok(())
}
pub fn control_plane_actions_available(&self) -> &[ControlPlaneAction] {
&self.control_plane_actions
}
}
fn validate_widget(widget: &PanelWidget) -> Result<(), PanelError> {
let mut checked_text = vec![widget.id.as_str(), widget.label.as_str()];
match &widget.kind {
PanelWidgetKind::Button { action } => checked_text.push(action),
PanelWidgetKind::Select { options, selected } => {
checked_text.push(selected);
checked_text.extend(options.iter().map(String::as_str));
}
PanelWidgetKind::Text { .. }
| PanelWidgetKind::Progress { .. }
| PanelWidgetKind::Toggle { .. }
| PanelWidgetKind::ArtifactDownload { .. } => {}
}
let combined = checked_text.join(" ").to_ascii_lowercase();
if combined.contains("password")
|| combined.contains("token")
|| combined.contains("oauth")
|| combined.contains("secret")
{
return Err(PanelError::CredentialCollectionDenied(widget.id.clone()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn panel() -> PanelState {
PanelState::new(
TenantId::from("tenant"),
ProjectId::from("project"),
ProcessId::from("process"),
)
}
#[test]
fn panel_uses_typed_widgets_and_rejects_custom_content() {
let mut panel = panel();
panel
.add_widget(PanelWidget {
id: "progress".to_owned(),
label: "Build".to_owned(),
kind: PanelWidgetKind::Progress {
current: 1,
total: 2,
},
})
.unwrap();
assert!(PanelState::reject_custom_content("<script>alert(1)</script>").is_err());
assert!(panel.widgets.contains_key("progress"));
}
#[test]
fn panel_rejects_password_or_oauth_collection_widgets() {
let mut panel = panel();
let error = panel
.add_widget(PanelWidget {
id: "oauth_token".to_owned(),
label: "OAuth Token".to_owned(),
kind: PanelWidgetKind::Text {
value: String::new(),
},
})
.unwrap_err();
assert!(matches!(error, PanelError::CredentialCollectionDenied(_)));
}
#[test]
fn panel_rejects_credential_collection_in_interactive_fields() {
let mut panel = panel();
let button_error = panel
.add_widget(PanelWidget {
id: "continue".to_owned(),
label: "Continue".to_owned(),
kind: PanelWidgetKind::Button {
action: "collect-secret".to_owned(),
},
})
.unwrap_err();
assert!(matches!(
button_error,
PanelError::CredentialCollectionDenied(_)
));
let select_error = panel
.add_widget(PanelWidget {
id: "auth-mode".to_owned(),
label: "Auth Mode".to_owned(),
kind: PanelWidgetKind::Select {
options: vec!["password".to_owned(), "public key".to_owned()],
selected: "public key".to_owned(),
},
})
.unwrap_err();
assert!(matches!(
select_error,
PanelError::CredentialCollectionDenied(_)
));
}
#[test]
fn panel_events_are_scoped_and_rate_limited() {
let mut panel = panel();
panel
.add_widget(PanelWidget {
id: "restart".to_owned(),
label: "Restart".to_owned(),
kind: PanelWidgetKind::Button {
action: "restart".to_owned(),
},
})
.unwrap();
let event = PanelEvent {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
process: ProcessId::from("process"),
widget_id: "restart".to_owned(),
kind: PanelEventKind::ButtonClicked,
};
let mut limit = RateLimit {
max_events: 1,
used_events: 0,
};
panel.accept_event(&event, &mut limit).unwrap();
assert_eq!(
panel.accept_event(&event, &mut limit),
Err(PanelError::RateLimited)
);
}
#[test]
fn stopped_debug_process_keeps_control_plane_actions_available() {
let mut panel = panel();
panel.freeze_program_ui_events();
panel.set_control_plane_actions(vec![
ControlPlaneAction::RestartTask(TaskInstanceId::from("task")),
ControlPlaneAction::DownloadArtifact(ArtifactId::from("artifact")),
]);
let event = PanelEvent {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
process: ProcessId::from("process"),
widget_id: "missing".to_owned(),
kind: PanelEventKind::ButtonClicked,
};
let mut limit = RateLimit {
max_events: 1,
used_events: 0,
};
assert_eq!(
panel.accept_event(&event, &mut limit),
Err(PanelError::ProgramEventsDisabled)
);
assert_eq!(panel.control_plane_actions_available().len(), 2);
}
#[test]
fn download_widget_is_only_created_from_available_action() {
let mut panel = panel();
let action = Ok(DownloadAction {
artifact: ArtifactId::from("artifact"),
source: crate::StorageLocation::RetainedNode(crate::NodeId::from("node")),
scoped_token_subject: "tenant/project/process/artifact".to_owned(),
});
panel
.add_download_widget_from_action("download-artifact", "Download", action)
.unwrap();
assert!(matches!(
panel.widgets["download-artifact"].kind,
PanelWidgetKind::ArtifactDownload { .. }
));
assert!(matches!(
panel.control_plane_actions_available()[0],
ControlPlaneAction::DownloadArtifact(_)
));
let before = panel.widgets.len();
let error = panel
.add_download_widget_from_action(
"missing-download",
"Download",
Err(DownloadError::Unavailable),
)
.unwrap_err();
assert_eq!(panel.widgets.len(), before);
assert!(matches!(error, PanelError::DownloadUnavailable(_)));
}
}

View file

@ -0,0 +1,134 @@
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::{Action, AuthContext, Capability, Scope};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PolicyReason {
Allowed,
MissingCapability(Capability),
HostedNativeComputeDenied,
HostedContainerDenied,
QuotaExceeded(String),
Unauthorized(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decision {
pub allowed: bool,
pub reason: PolicyReason,
}
impl Decision {
pub fn allow() -> Self {
Self {
allowed: true,
reason: PolicyReason::Allowed,
}
}
pub fn deny(reason: PolicyReason) -> Self {
Self {
allowed: false,
reason,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceRequest {
pub action: Action,
pub required_capabilities: BTreeSet<Capability>,
pub hosted_control_plane: bool,
}
pub trait CapabilityPolicy {
fn decide(&self, context: &AuthContext, scope: &Scope, request: &ResourceRequest) -> Decision;
}
pub trait ServicePolicy: CapabilityPolicy + Send + Sync {}
impl<T> ServicePolicy for T where T: CapabilityPolicy + Send + Sync {}
#[derive(Clone, Debug, Default)]
pub struct LocalTrustedPolicy;
impl CapabilityPolicy for LocalTrustedPolicy {
fn decide(&self, context: &AuthContext, scope: &Scope, request: &ResourceRequest) -> Decision {
let authz = crate::auth::same_tenant_project(context, scope);
if !authz.allowed {
return Decision::deny(PolicyReason::Unauthorized(authz.reason));
}
if request.hosted_control_plane && request.action == Action::RunNativeCommand {
return Decision::deny(PolicyReason::HostedNativeComputeDenied);
}
if request.hosted_control_plane && request.action == Action::RunContainer {
return Decision::deny(PolicyReason::HostedContainerDenied);
}
Decision::allow()
}
}
#[cfg(test)]
mod tests {
use crate::{Actor, ProjectId, TenantId, UserId};
use super::*;
#[test]
fn public_policy_interface_denies_hosted_native_compute() {
let policy = LocalTrustedPolicy;
let context = AuthContext {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
actor: Actor::User(UserId::from("user")),
};
let scope = Scope {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
process: None,
task: None,
node: None,
artifact: None,
};
let request = ResourceRequest {
action: Action::RunNativeCommand,
required_capabilities: BTreeSet::new(),
hosted_control_plane: true,
};
let decision = policy.decide(&context, &scope, &request);
assert!(!decision.allowed);
assert_eq!(decision.reason, PolicyReason::HostedNativeComputeDenied);
}
#[test]
fn local_trusted_policy_allows_owner_controlled_native_capability_request() {
let policy = LocalTrustedPolicy;
let context = AuthContext {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
actor: Actor::User(UserId::from("owner")),
};
let scope = Scope {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
process: None,
task: None,
node: None,
artifact: None,
};
let request = ResourceRequest {
action: Action::RunNativeCommand,
required_capabilities: BTreeSet::from([Capability::Command]),
hosted_control_plane: false,
};
let decision = policy.decide(&context, &scope, &request);
assert!(decision.allowed);
assert_eq!(decision.reason, PolicyReason::Allowed);
}
}

View file

@ -0,0 +1,303 @@
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use syn::{punctuated::Punctuated, Expr, Item, Lit, Meta, Token};
use thiserror::Error;
use crate::{discover_environments, environment::EnvironmentError, EnvironmentResource};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entrypoint {
pub name: String,
pub function: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectModel {
pub root: PathBuf,
pub environments: Vec<EnvironmentResource>,
pub entrypoints: BTreeMap<String, Entrypoint>,
pub default_entrypoint: String,
pub required_config_file: Option<PathBuf>,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ProjectModelError {
#[error("environment discovery failed: {0}")]
Environment(String),
#[error("Disasmer entrypoint discovery failed: {0}")]
EntrypointDiscovery(String),
#[error(
"no Disasmer entrypoint is declared; add `#[disasmer::main]` to a function under src/"
)]
NoEntrypoints,
#[error("unknown Disasmer entrypoint `{name}`; available entrypoints: {available:?}")]
UnknownEntrypoint {
name: String,
available: Vec<String>,
},
}
impl ProjectModel {
pub fn discover_without_config(root: &Path) -> Result<Self, ProjectModelError> {
let environments = discover_environments(root).map_err(|err| {
ProjectModelError::Environment(match err {
EnvironmentError::Read { path, source } => {
format!("failed to read {}: {source}", path.display())
}
})
})?;
let entrypoints = discover_entrypoints(root)?;
let default_entrypoint = if entrypoints.contains_key("build") {
"build".to_owned()
} else {
entrypoints.keys().next().cloned().unwrap_or_default()
};
Ok(Self {
root: root.to_path_buf(),
environments,
entrypoints,
default_entrypoint,
required_config_file: None,
})
}
pub fn select_entrypoint(&self, name: Option<&str>) -> Result<&Entrypoint, ProjectModelError> {
if self.entrypoints.is_empty() {
return Err(ProjectModelError::NoEntrypoints);
}
let name = name.unwrap_or(&self.default_entrypoint);
self.entrypoints
.get(name)
.ok_or_else(|| ProjectModelError::UnknownEntrypoint {
name: name.to_owned(),
available: self.entrypoints.keys().cloned().collect(),
})
}
}
fn discover_entrypoints(root: &Path) -> Result<BTreeMap<String, Entrypoint>, ProjectModelError> {
let source_root = root.join("src");
if !source_root.is_dir() {
return Ok(BTreeMap::new());
}
let mut source_files = Vec::new();
collect_rust_sources(&source_root, &mut source_files)?;
source_files.sort();
let mut entrypoints = BTreeMap::new();
for path in source_files {
let source = fs::read_to_string(&path).map_err(|error| {
ProjectModelError::EntrypointDiscovery(format!(
"failed to read {}: {error}",
path.display()
))
})?;
let syntax = syn::parse_file(&source).map_err(|error| {
ProjectModelError::EntrypointDiscovery(format!(
"failed to parse {}: {error}",
path.display()
))
})?;
collect_entrypoint_items(&syntax.items, &path, &mut entrypoints)?;
}
Ok(entrypoints)
}
fn collect_rust_sources(
directory: &Path,
files: &mut Vec<PathBuf>,
) -> Result<(), ProjectModelError> {
let entries = fs::read_dir(directory).map_err(|error| {
ProjectModelError::EntrypointDiscovery(format!(
"failed to read {}: {error}",
directory.display()
))
})?;
for entry in entries {
let entry = entry.map_err(|error| {
ProjectModelError::EntrypointDiscovery(format!(
"failed to inspect {}: {error}",
directory.display()
))
})?;
let path = entry.path();
let file_type = entry.file_type().map_err(|error| {
ProjectModelError::EntrypointDiscovery(format!(
"failed to inspect {}: {error}",
path.display()
))
})?;
if file_type.is_dir() {
collect_rust_sources(&path, files)?;
} else if file_type.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
files.push(path);
}
}
Ok(())
}
fn collect_entrypoint_items(
items: &[Item],
path: &Path,
entrypoints: &mut BTreeMap<String, Entrypoint>,
) -> Result<(), ProjectModelError> {
for item in items {
match item {
Item::Fn(function) => {
let Some(attribute) = function.attrs.iter().find(|attribute| {
let segments = attribute
.path()
.segments
.iter()
.map(|segment| segment.ident.to_string())
.collect::<Vec<_>>();
segments.as_slice() == ["disasmer", "main"]
}) else {
continue;
};
let function_name = function.sig.ident.to_string();
let default_name = function_name
.strip_suffix("_main")
.unwrap_or(&function_name);
let name = entrypoint_name(attribute, default_name);
let entrypoint = Entrypoint {
name: name.clone(),
function: function_name,
};
if let Some(existing) = entrypoints.insert(name.clone(), entrypoint) {
return Err(ProjectModelError::EntrypointDiscovery(format!(
"duplicate entrypoint `{name}` in {}; it was already declared by `{}`",
path.display(),
existing.function
)));
}
}
Item::Mod(module) => {
if let Some((_, nested)) = &module.content {
collect_entrypoint_items(nested, path, entrypoints)?;
}
}
_ => {}
}
}
Ok(())
}
fn entrypoint_name(attribute: &syn::Attribute, default: &str) -> String {
let Meta::List(_) = &attribute.meta else {
return default.to_owned();
};
let Ok(arguments) = attribute.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)
else {
return default.to_owned();
};
arguments
.into_iter()
.find_map(|meta| {
let Meta::NameValue(name_value) = meta else {
return None;
};
if !name_value.path.is_ident("name") {
return None;
}
let Expr::Lit(expression) = name_value.value else {
return None;
};
let Lit::Str(value) = expression.lit else {
return None;
};
Some(value.value())
})
.unwrap_or_else(|| default.to_owned())
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[test]
fn project_works_without_hand_written_configuration_file() {
let temp = tempfile::tempdir().unwrap();
fs::create_dir_all(temp.path().join("envs/linux")).unwrap();
fs::create_dir_all(temp.path().join("src")).unwrap();
fs::write(
temp.path().join("envs/linux/Containerfile"),
"FROM alpine\n",
)
.unwrap();
fs::write(
temp.path().join("src/main.rs"),
"#[disasmer::main]\npub fn build_main() {}\n",
)
.unwrap();
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
assert_eq!(model.required_config_file, None);
assert_eq!(model.environments[0].name, "linux");
assert_eq!(model.select_entrypoint(None).unwrap().name, "build");
}
#[test]
fn project_can_define_multiple_default_entrypoints() {
let temp = tempfile::tempdir().unwrap();
fs::create_dir_all(temp.path().join("src/nested")).unwrap();
fs::write(
temp.path().join("src/lib.rs"),
"#[disasmer::main(name = \"check\")]\npub fn test_main() {}\n",
)
.unwrap();
fs::write(
temp.path().join("src/nested/release.rs"),
"#[disasmer::main]\npub fn release_main() {}\n",
)
.unwrap();
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
assert_eq!(
model.select_entrypoint(Some("check")).unwrap().function,
"test_main"
);
assert_eq!(
model.select_entrypoint(Some("release")).unwrap().function,
"release_main"
);
}
#[test]
fn unknown_entrypoint_lists_available_choices() {
let temp = tempfile::tempdir().unwrap();
fs::create_dir_all(temp.path().join("src")).unwrap();
fs::write(
temp.path().join("src/main.rs"),
"#[disasmer::main]\npub fn build_main() {}\n",
)
.unwrap();
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
let error = model.select_entrypoint(Some("deploy")).unwrap_err();
assert!(matches!(error, ProjectModelError::UnknownEntrypoint { .. }));
}
#[test]
fn project_without_declared_entrypoint_does_not_invent_product_surfaces() {
let temp = tempfile::tempdir().unwrap();
fs::create_dir_all(temp.path().join("src")).unwrap();
fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
assert!(model.entrypoints.is_empty());
assert_eq!(model.default_entrypoint, "");
assert_eq!(
model.select_entrypoint(None).unwrap_err(),
ProjectModelError::NoEntrypoints
);
}
}

View file

@ -0,0 +1,429 @@
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
ArtifactId, Capability, Digest, EnvironmentRequirements, NodeCapabilities, NodeId, ProjectId,
TenantId,
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeDescriptor {
pub id: NodeId,
pub tenant: TenantId,
pub project: ProjectId,
pub capabilities: NodeCapabilities,
pub cached_environments: BTreeSet<Digest>,
pub dependency_caches: BTreeSet<Digest>,
pub source_snapshots: BTreeSet<Digest>,
pub artifact_locations: BTreeSet<ArtifactId>,
pub direct_connectivity: bool,
pub online: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlacementRequest {
pub tenant: TenantId,
pub project: ProjectId,
pub environment: Option<EnvironmentRequirements>,
pub environment_digest: Option<Digest>,
pub required_capabilities: BTreeSet<Capability>,
pub dependency_cache: Option<Digest>,
pub source_snapshot: Option<Digest>,
pub required_artifacts: BTreeSet<ArtifactId>,
pub quota_available: bool,
pub policy_allowed: bool,
pub prefer_node: Option<NodeId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Placement {
pub node: NodeId,
pub score: i64,
pub reasons: Vec<String>,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[error("no capable node for placement: {message}")]
pub struct PlacementError {
pub message: String,
}
pub trait Scheduler {
fn place(
&self,
nodes: &[NodeDescriptor],
request: &PlacementRequest,
) -> Result<Placement, PlacementError>;
}
#[derive(Clone, Debug, Default)]
pub struct DefaultScheduler;
impl Scheduler for DefaultScheduler {
fn place(
&self,
nodes: &[NodeDescriptor],
request: &PlacementRequest,
) -> Result<Placement, PlacementError> {
let mut scored = Vec::new();
let mut rejection_counts = BTreeMap::<String, usize>::new();
for node in nodes {
match compatibility(node, request) {
Ok(mut placement) => {
locality_score(node, request, &mut placement);
scored.push(placement);
}
Err(reasons) => {
for reason in reasons {
*rejection_counts.entry(reason).or_default() += 1;
}
}
}
}
scored
.into_iter()
.max_by_key(|placement| placement.score)
.ok_or_else(|| PlacementError {
message: rejection_counts
.into_iter()
.map(|(reason, count)| format!("{reason} ({count} node(s))"))
.collect::<Vec<_>>()
.join("; "),
})
}
}
fn compatibility(
node: &NodeDescriptor,
request: &PlacementRequest,
) -> Result<Placement, Vec<String>> {
let mut reasons = Vec::new();
if !node.online {
reasons.push("node offline".to_owned());
}
if node.tenant != request.tenant {
reasons.push("tenant mismatch".to_owned());
}
if node.project != request.project {
reasons.push("project mismatch".to_owned());
}
if !request.quota_available {
reasons.push("quota unavailable for placement".to_owned());
}
if !request.policy_allowed {
reasons.push("policy denied placement".to_owned());
}
for capability in &request.required_capabilities {
if !node.capabilities.capabilities.contains(capability) {
reasons.push(format!("missing capability {capability:?}"));
}
}
if let Some(environment) = &request.environment {
if let Some(required_os) = &environment.os {
if &node.capabilities.os != required_os {
reasons.push(format!("environment requires os {required_os:?}"));
}
}
if let Some(required_arch) = &environment.arch {
if &node.capabilities.arch != required_arch {
reasons.push(format!("environment requires arch {required_arch}"));
}
}
for capability in &environment.capabilities {
if !node.capabilities.capabilities.contains(capability) {
reasons.push(format!("environment requires capability {capability:?}"));
}
}
}
let source_transfer_required = request
.source_snapshot
.as_ref()
.is_some_and(|digest| !node.source_snapshots.contains(digest));
if source_transfer_required && !node.direct_connectivity {
reasons.push("source snapshot unavailable and direct connectivity unavailable".to_owned());
}
let missing_artifacts = request
.required_artifacts
.iter()
.filter(|artifact| !node.artifact_locations.contains(*artifact))
.count();
if missing_artifacts > 0 && !node.direct_connectivity {
reasons.push(format!(
"{missing_artifacts} required artifact(s) unavailable and direct connectivity unavailable"
));
}
if reasons.is_empty() {
Ok(Placement {
node: node.id.clone(),
score: 0,
reasons: Vec::new(),
})
} else {
Err(reasons)
}
}
fn locality_score(node: &NodeDescriptor, request: &PlacementRequest, placement: &mut Placement) {
if request.prefer_node.as_ref() == Some(&node.id) {
placement.score += 100;
placement.reasons.push("preferred node".to_owned());
}
if request
.environment_digest
.as_ref()
.is_some_and(|digest| node.cached_environments.contains(digest))
{
placement.score += 50;
placement.reasons.push("warm environment cache".to_owned());
}
if request
.source_snapshot
.as_ref()
.is_some_and(|digest| node.source_snapshots.contains(digest))
{
placement.score += 40;
placement
.reasons
.push("source snapshot already local".to_owned());
}
if request
.dependency_cache
.as_ref()
.is_some_and(|digest| node.dependency_caches.contains(digest))
{
placement.score += 30;
placement.reasons.push("warm dependency cache".to_owned());
}
let artifact_hits = request
.required_artifacts
.iter()
.filter(|artifact| node.artifact_locations.contains(*artifact))
.count() as i64;
if artifact_hits > 0 {
placement.score += 10 * artifact_hits;
placement.reasons.push(format!(
"{artifact_hits} required artifact(s) already local"
));
}
}
#[cfg(test)]
mod tests {
use crate::{EnvironmentBackend, Os};
use super::*;
fn node(id: &str, cached_source: bool) -> NodeDescriptor {
let source = Digest::sha256("source");
NodeDescriptor {
id: NodeId::from(id),
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
capabilities: NodeCapabilities {
os: Os::Linux,
arch: "x86_64".to_owned(),
capabilities: BTreeSet::from([
Capability::Command,
Capability::Containers,
Capability::RootlessPodman,
]),
environment_backends: BTreeSet::from([EnvironmentBackend::Container]),
source_providers: BTreeSet::from(["filesystem".to_owned()]),
},
cached_environments: BTreeSet::from([Digest::sha256("env")]),
dependency_caches: if cached_source {
BTreeSet::from([Digest::sha256("deps")])
} else {
BTreeSet::new()
},
source_snapshots: if cached_source {
BTreeSet::from([source])
} else {
BTreeSet::new()
},
artifact_locations: BTreeSet::new(),
direct_connectivity: true,
online: true,
}
}
#[test]
fn scheduler_prefers_warm_source_and_environment() {
let request = PlacementRequest {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
environment: Some(EnvironmentRequirements::linux_container()),
environment_digest: Some(Digest::sha256("env")),
required_capabilities: BTreeSet::from([Capability::Command]),
dependency_cache: Some(Digest::sha256("deps")),
source_snapshot: Some(Digest::sha256("source")),
required_artifacts: BTreeSet::new(),
quota_available: true,
policy_allowed: true,
prefer_node: None,
};
let placement = DefaultScheduler
.place(&[node("cold", false), node("warm", true)], &request)
.unwrap();
assert_eq!(placement.node, NodeId::from("warm"));
assert!(placement
.reasons
.iter()
.any(|reason| reason.contains("source")));
assert!(placement
.reasons
.iter()
.any(|reason| reason.contains("dependency")));
}
#[test]
fn scheduler_failure_names_missing_constraint() {
let mut request = PlacementRequest {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
environment: None,
environment_digest: None,
required_capabilities: BTreeSet::from([Capability::WindowsCommandDev]),
dependency_cache: None,
source_snapshot: None,
required_artifacts: BTreeSet::new(),
quota_available: true,
policy_allowed: true,
prefer_node: None,
};
request.required_capabilities.insert(Capability::Command);
let error = DefaultScheduler
.place(&[node("linux", false)], &request)
.unwrap_err();
assert!(error.message.contains("WindowsCommandDev"));
}
#[test]
fn scheduler_failure_names_environment_constraint() {
let request = PlacementRequest {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
environment: Some(EnvironmentRequirements::windows_command_dev()),
environment_digest: None,
required_capabilities: BTreeSet::new(),
dependency_cache: None,
source_snapshot: None,
required_artifacts: BTreeSet::new(),
quota_available: true,
policy_allowed: true,
prefer_node: None,
};
let error = DefaultScheduler
.place(&[node("linux", false)], &request)
.unwrap_err();
assert!(error.message.contains("environment requires os Windows"));
assert!(error
.message
.contains("environment requires capability WindowsCommandDev"));
}
#[test]
fn scheduler_requires_direct_connectivity_when_transfer_is_needed() {
let mut disconnected = node("disconnected", false);
disconnected.direct_connectivity = false;
let mut local = node("local", true);
local.direct_connectivity = false;
let request = PlacementRequest {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
environment: None,
environment_digest: None,
required_capabilities: BTreeSet::from([Capability::Command]),
dependency_cache: None,
source_snapshot: Some(Digest::sha256("source")),
required_artifacts: BTreeSet::new(),
quota_available: true,
policy_allowed: true,
prefer_node: None,
};
let placement = DefaultScheduler
.place(&[disconnected, local], &request)
.unwrap();
assert_eq!(placement.node, NodeId::from("local"));
let mut disconnected = node("disconnected", false);
disconnected.direct_connectivity = false;
let error = DefaultScheduler
.place(&[disconnected], &request)
.unwrap_err();
assert!(error
.message
.contains("source snapshot unavailable and direct connectivity unavailable"));
}
#[test]
fn scheduler_failure_names_required_artifact_transfer_constraint() {
let mut disconnected = node("disconnected", true);
disconnected.direct_connectivity = false;
let request = PlacementRequest {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
environment: None,
environment_digest: None,
required_capabilities: BTreeSet::from([Capability::Command]),
dependency_cache: None,
source_snapshot: None,
required_artifacts: BTreeSet::from([ArtifactId::from("cache")]),
quota_available: true,
policy_allowed: true,
prefer_node: None,
};
let error = DefaultScheduler
.place(&[disconnected], &request)
.unwrap_err();
assert!(error
.message
.contains("1 required artifact(s) unavailable and direct connectivity unavailable"));
}
#[test]
fn scheduler_failure_names_quota_and_policy_constraints() {
let mut request = PlacementRequest {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
environment: None,
environment_digest: None,
required_capabilities: BTreeSet::from([Capability::Command]),
dependency_cache: None,
source_snapshot: None,
required_artifacts: BTreeSet::new(),
quota_available: false,
policy_allowed: true,
prefer_node: None,
};
let error = DefaultScheduler
.place(&[node("linux", false)], &request)
.unwrap_err();
assert!(error.message.contains("quota unavailable for placement"));
request.quota_available = true;
request.policy_allowed = false;
let error = DefaultScheduler
.place(&[node("linux", false)], &request)
.unwrap_err();
assert!(error.message.contains("policy denied placement"));
}
}

View file

@ -0,0 +1,324 @@
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{Capability, Digest, ProjectId, TenantId};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum SourceProviderKind {
Filesystem,
Git,
Custom(String),
}
impl SourceProviderKind {
pub fn provider_id(&self) -> &str {
match self {
SourceProviderKind::Filesystem => "filesystem",
SourceProviderKind::Git => "git",
SourceProviderKind::Custom(provider) => provider,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum SourceTransferMode {
RequiredContent,
ExplicitSnapshotChunks,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceTransferPolicy {
pub local_source_bytes_remain_node_local: bool,
pub coordinator_receives_source_bytes_by_default: bool,
pub default_full_repo_tarball: bool,
pub allowed_remote_transfer: BTreeSet<SourceTransferMode>,
}
impl SourceTransferPolicy {
pub fn local_first_snapshot_chunks() -> Self {
Self {
local_source_bytes_remain_node_local: true,
coordinator_receives_source_bytes_by_default: false,
default_full_repo_tarball: false,
allowed_remote_transfer: BTreeSet::from([
SourceTransferMode::RequiredContent,
SourceTransferMode::ExplicitSnapshotChunks,
]),
}
}
}
impl Default for SourceTransferPolicy {
fn default() -> Self {
Self::local_first_snapshot_chunks()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceProviderManifest {
pub kind: SourceProviderKind,
pub digest: Digest,
pub description: String,
#[serde(default)]
pub coordinator_requires_checkout_access: bool,
#[serde(default)]
pub transfer_policy: SourceTransferPolicy,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum SourceManifestError {
#[error("source provider manifest digest is not a valid sha256 digest: {0}")]
InvalidDigest(String),
#[error("custom source provider id `{0}` is invalid")]
InvalidProviderId(String),
#[error("source provider manifest description must be non-empty")]
EmptyDescription,
#[error("source provider manifest description is too long")]
DescriptionTooLong,
#[error("source provider manifest description contains control characters")]
DescriptionControlCharacter,
#[error("source provider manifest would require coordinator checkout access")]
CoordinatorCheckoutAccess,
#[error("source provider manifest would send source bytes to the coordinator by default")]
CoordinatorReceivesSourceBytes,
#[error("source provider manifest would default to a full-repo tarball")]
DefaultFullRepoTarball,
#[error("source provider manifest has no allowed remote transfer mode")]
MissingRemoteTransferMode,
}
pub trait SourceProviderModule {
fn kind(&self) -> SourceProviderKind;
fn manifest(&self) -> SourceProviderManifest;
}
impl SourceProviderManifest {
pub fn local_first(kind: SourceProviderKind, description: impl Into<String>) -> Self {
let transfer_policy = SourceTransferPolicy::local_first_snapshot_chunks();
let digest = Self::digest_for(&kind, false, &transfer_policy);
Self {
kind,
digest,
description: description.into(),
coordinator_requires_checkout_access: false,
transfer_policy,
}
}
pub fn validate_public_mvp(&self) -> Result<(), SourceManifestError> {
self.validate_shape()?;
if self.coordinator_requires_checkout_access {
return Err(SourceManifestError::CoordinatorCheckoutAccess);
}
if self
.transfer_policy
.coordinator_receives_source_bytes_by_default
{
return Err(SourceManifestError::CoordinatorReceivesSourceBytes);
}
if self.transfer_policy.default_full_repo_tarball {
return Err(SourceManifestError::DefaultFullRepoTarball);
}
if self.transfer_policy.allowed_remote_transfer.is_empty() {
return Err(SourceManifestError::MissingRemoteTransferMode);
}
Ok(())
}
fn validate_shape(&self) -> Result<(), SourceManifestError> {
if !self.digest.is_valid_sha256() {
return Err(SourceManifestError::InvalidDigest(
self.digest.as_str().to_owned(),
));
}
if let SourceProviderKind::Custom(provider) = &self.kind {
if !valid_provider_id(provider) {
return Err(SourceManifestError::InvalidProviderId(provider.clone()));
}
}
if self.description.trim().is_empty() {
return Err(SourceManifestError::EmptyDescription);
}
if self.description.len() > 256 {
return Err(SourceManifestError::DescriptionTooLong);
}
if self.description.chars().any(char::is_control) {
return Err(SourceManifestError::DescriptionControlCharacter);
}
Ok(())
}
fn digest_for(
kind: &SourceProviderKind,
coordinator_requires_checkout_access: bool,
transfer_policy: &SourceTransferPolicy,
) -> Digest {
let mut modes = transfer_policy
.allowed_remote_transfer
.iter()
.map(|mode| format!("{mode:?}"))
.collect::<Vec<_>>();
modes.sort();
let mut parts = vec![
b"source-provider-manifest:v2".to_vec(),
kind.provider_id().as_bytes().to_vec(),
coordinator_requires_checkout_access
.to_string()
.into_bytes(),
transfer_policy
.local_source_bytes_remain_node_local
.to_string()
.into_bytes(),
transfer_policy
.coordinator_receives_source_bytes_by_default
.to_string()
.into_bytes(),
transfer_policy
.default_full_repo_tarball
.to_string()
.into_bytes(),
];
parts.extend(modes.into_iter().map(String::into_bytes));
Digest::from_parts(parts)
}
}
fn valid_provider_id(provider: &str) -> bool {
!provider.is_empty()
&& provider.len() <= 64
&& provider
.bytes()
.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourcePreparation {
pub tenant: TenantId,
pub project: ProjectId,
pub provider: SourceProviderKind,
pub required_capabilities: BTreeSet<Capability>,
pub coordinator_requires_checkout_access: bool,
}
impl SourcePreparation {
pub fn node_task(tenant: TenantId, project: ProjectId, provider: SourceProviderKind) -> Self {
let capability = match provider {
SourceProviderKind::Filesystem => Capability::SourceFilesystem,
SourceProviderKind::Git => Capability::SourceGit,
SourceProviderKind::Custom(_) => Capability::SourceFilesystem,
};
Self {
tenant,
project,
provider,
required_capabilities: BTreeSet::from([capability]),
coordinator_requires_checkout_access: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_preparation_can_be_scheduled_as_node_task() {
let prep = SourcePreparation::node_task(
TenantId::from("tenant"),
ProjectId::from("project"),
SourceProviderKind::Git,
);
assert!(!prep.coordinator_requires_checkout_access);
assert!(prep.required_capabilities.contains(&Capability::SourceGit));
}
#[test]
fn local_first_source_manifest_rejects_bulk_coordinator_paths() {
let manifest = SourceProviderManifest::local_first(
SourceProviderKind::Git,
"node-side Git snapshot provider",
);
assert!(manifest.validate_public_mvp().is_ok());
assert!(!manifest.coordinator_requires_checkout_access);
assert!(
!manifest
.transfer_policy
.coordinator_receives_source_bytes_by_default
);
assert!(!manifest.transfer_policy.default_full_repo_tarball);
assert!(manifest
.transfer_policy
.allowed_remote_transfer
.contains(&SourceTransferMode::ExplicitSnapshotChunks));
}
#[test]
fn source_manifest_validation_treats_manifest_as_hostile_input() {
let mut manifest = SourceProviderManifest::local_first(
SourceProviderKind::Custom("gitlab-lfs".to_owned()),
"custom provider",
);
assert!(manifest.validate_public_mvp().is_ok());
manifest.kind = SourceProviderKind::Custom("../checkout".to_owned());
assert_eq!(
manifest.validate_public_mvp(),
Err(SourceManifestError::InvalidProviderId(
"../checkout".to_owned()
))
);
manifest.kind = SourceProviderKind::Git;
manifest.digest = Digest::sha256("valid");
manifest.coordinator_requires_checkout_access = true;
assert_eq!(
manifest.validate_public_mvp(),
Err(SourceManifestError::CoordinatorCheckoutAccess)
);
manifest.coordinator_requires_checkout_access = false;
manifest
.transfer_policy
.coordinator_receives_source_bytes_by_default = true;
assert_eq!(
manifest.validate_public_mvp(),
Err(SourceManifestError::CoordinatorReceivesSourceBytes)
);
manifest
.transfer_policy
.coordinator_receives_source_bytes_by_default = false;
manifest.transfer_policy.default_full_repo_tarball = true;
assert_eq!(
manifest.validate_public_mvp(),
Err(SourceManifestError::DefaultFullRepoTarball)
);
}
#[test]
fn source_manifest_rejects_malformed_digest_from_json() {
let mut manifest = SourceProviderManifest::local_first(
SourceProviderKind::Filesystem,
"filesystem provider",
);
let value = serde_json::to_value(&manifest).unwrap();
let mut object = value.as_object().unwrap().clone();
object.insert(
"digest".to_owned(),
serde_json::Value::String("sha256:not-a-real-digest".to_owned()),
);
manifest = serde_json::from_value(serde_json::Value::Object(object)).unwrap();
assert_eq!(
manifest.validate_public_mvp(),
Err(SourceManifestError::InvalidDigest(
"sha256:not-a-real-digest".to_owned()
))
);
}
}

View file

@ -0,0 +1,243 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{ArtifactId, Digest, NodeId, ProcessId, ProjectId, TenantId};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransportKind {
NativeQuic,
}
pub trait Transport {
fn kind(&self) -> TransportKind;
fn authenticated_direct_connections(&self) -> bool;
}
#[derive(Clone, Debug, Default)]
pub struct NativeQuicTransport;
impl Transport for NativeQuicTransport {
fn kind(&self) -> TransportKind {
TransportKind::NativeQuic
}
fn authenticated_direct_connections(&self) -> bool {
true
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DataPlaneScope {
pub tenant: TenantId,
pub project: ProjectId,
pub process: ProcessId,
pub object: DataPlaneObject,
pub authorization_subject: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataPlaneObject {
Artifact(ArtifactId),
Blob(Digest),
SourceSnapshot(Digest),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeEndpoint {
pub node: NodeId,
pub advertised_addr: String,
pub public_key_fingerprint: Digest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RendezvousRequest {
pub scope: DataPlaneScope,
pub source: NodeEndpoint,
pub destination: NodeEndpoint,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DirectBulkTransferPlan {
pub transport: TransportKind,
pub scope: DataPlaneScope,
pub source: NodeEndpoint,
pub destination: NodeEndpoint,
pub authorization_digest: Digest,
pub coordinator_assisted_rendezvous: bool,
pub coordinator_bulk_relay_allowed: bool,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum TransportError {
#[error(
"direct node-to-node connectivity is unavailable for scoped data-plane transfer: {reason}; coordinator bulk relay is disabled"
)]
DirectConnectivityUnavailable { reason: String },
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BulkTransferDecision {
DirectAuthenticated { scope: DataPlaneScope },
FailClear { message: String },
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[error("bulk relay through coordinator is not allowed by default")]
pub struct BulkRelayDenied;
impl NativeQuicTransport {
pub fn plan_authenticated_direct_bulk_transfer(
&self,
request: RendezvousRequest,
direct_connectivity: bool,
failure_reason: impl Into<String>,
) -> Result<DirectBulkTransferPlan, TransportError> {
if !direct_connectivity {
return Err(TransportError::DirectConnectivityUnavailable {
reason: failure_reason.into(),
});
}
let authorization_digest = data_plane_authorization_digest(&request);
Ok(DirectBulkTransferPlan {
transport: self.kind(),
scope: request.scope,
source: request.source,
destination: request.destination,
authorization_digest,
coordinator_assisted_rendezvous: true,
coordinator_bulk_relay_allowed: false,
})
}
}
pub fn direct_bulk_transfer_or_error(
scope: DataPlaneScope,
direct_connectivity: bool,
) -> BulkTransferDecision {
if direct_connectivity {
BulkTransferDecision::DirectAuthenticated { scope }
} else {
BulkTransferDecision::FailClear {
message:
"direct node-to-node connectivity is unavailable; coordinator bulk relay is disabled"
.to_owned(),
}
}
}
fn data_plane_authorization_digest(request: &RendezvousRequest) -> Digest {
let object = match &request.scope.object {
DataPlaneObject::Artifact(artifact) => format!("artifact:{artifact}"),
DataPlaneObject::Blob(digest) => format!("blob:{}", digest.as_str()),
DataPlaneObject::SourceSnapshot(digest) => format!("source:{}", digest.as_str()),
};
Digest::from_parts([
b"dataplane-auth:v1".as_slice(),
request.scope.tenant.as_str().as_bytes(),
request.scope.project.as_str().as_bytes(),
request.scope.process.as_str().as_bytes(),
object.as_bytes(),
request.scope.authorization_subject.as_bytes(),
request.source.node.as_str().as_bytes(),
request.source.public_key_fingerprint.as_str().as_bytes(),
request.destination.node.as_str().as_bytes(),
request
.destination
.public_key_fingerprint
.as_str()
.as_bytes(),
])
}
#[cfg(test)]
mod tests {
use super::*;
fn endpoint(name: &str) -> NodeEndpoint {
NodeEndpoint {
node: NodeId::from(name),
advertised_addr: format!("{name}.mesh.invalid:4433"),
public_key_fingerprint: Digest::sha256(format!("{name}-public-key")),
}
}
fn scope(project: &str) -> DataPlaneScope {
DataPlaneScope {
tenant: TenantId::from("tenant"),
project: ProjectId::from(project),
process: ProcessId::from("process"),
object: DataPlaneObject::Artifact(ArtifactId::from("artifact")),
authorization_subject: "node-a-to-node-b".to_owned(),
}
}
#[test]
fn failed_direct_transfer_does_not_silently_relay() {
let decision = direct_bulk_transfer_or_error(scope("project"), false);
assert!(matches!(decision, BulkTransferDecision::FailClear { .. }));
}
#[test]
fn native_quic_rendezvous_plan_is_scoped_and_disallows_coordinator_bulk_relay() {
let transport = NativeQuicTransport;
let request = RendezvousRequest {
scope: scope("project"),
source: endpoint("node-a"),
destination: endpoint("node-b"),
};
let plan = transport
.plan_authenticated_direct_bulk_transfer(request.clone(), true, "")
.unwrap();
let changed_scope_plan = transport
.plan_authenticated_direct_bulk_transfer(
RendezvousRequest {
scope: scope("other-project"),
..request
},
true,
"",
)
.unwrap();
assert_eq!(plan.transport, TransportKind::NativeQuic);
assert_eq!(plan.scope.tenant, TenantId::from("tenant"));
assert_eq!(plan.scope.project, ProjectId::from("project"));
assert_eq!(plan.scope.process, ProcessId::from("process"));
assert_eq!(
plan.scope.object,
DataPlaneObject::Artifact(ArtifactId::from("artifact"))
);
assert_eq!(plan.source.node, NodeId::from("node-a"));
assert_eq!(plan.destination.node, NodeId::from("node-b"));
assert!(plan.coordinator_assisted_rendezvous);
assert!(!plan.coordinator_bulk_relay_allowed);
assert_ne!(
plan.authorization_digest,
changed_scope_plan.authorization_digest
);
}
#[test]
fn failed_direct_rendezvous_reports_clear_error_instead_of_relaying() {
let error = NativeQuicTransport
.plan_authenticated_direct_bulk_transfer(
RendezvousRequest {
scope: scope("project"),
source: endpoint("node-a"),
destination: endpoint("node-b"),
},
false,
"nat traversal failed",
)
.unwrap_err();
assert!(error.to_string().contains("nat traversal failed"));
assert!(error
.to_string()
.contains("coordinator bulk relay is disabled"));
}
}

View file

@ -0,0 +1,241 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{Digest, NodeId, TaskInstanceId};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct VfsPath(String);
impl VfsPath {
pub fn new(path: impl Into<String>) -> Result<Self, VfsError> {
let path = path.into();
if !path.starts_with("/vfs/") {
return Err(VfsError::InvalidPath(path));
}
Ok(Self(path))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VfsObject {
pub path: VfsPath,
pub digest: Digest,
pub size: u64,
pub producer: TaskInstanceId,
pub node: NodeId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VfsManifest {
pub epoch: u64,
pub producer: TaskInstanceId,
pub node: NodeId,
pub objects: BTreeMap<VfsPath, VfsObject>,
pub large_bytes_uploaded: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SyncPolicy {
MetadataOnly,
ExplicitNode(NodeId),
ExplicitStore(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum VfsSyncDecision {
NoBytesMoved,
MoveBytesToNode(NodeId),
MoveBytesToStore(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReuseDecision {
SameNodeZeroCopy,
NeedsTransfer { from: NodeId, to: NodeId },
Unavailable,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum VfsError {
#[error("VFS path must start with /vfs/: {0}")]
InvalidPath(String),
#[error("path is not visible in the published VFS manifest: {0}")]
NotVisible(String),
}
#[derive(Clone, Debug)]
pub struct VfsOverlay {
task: TaskInstanceId,
node: NodeId,
epoch: u64,
pending: BTreeMap<VfsPath, VfsObject>,
published: BTreeMap<VfsPath, VfsObject>,
}
impl VfsOverlay {
pub fn new(task: TaskInstanceId, node: NodeId) -> Self {
Self {
task,
node,
epoch: 0,
pending: BTreeMap::new(),
published: BTreeMap::new(),
}
}
pub fn write(&mut self, path: VfsPath, digest: Digest, size: u64) -> VfsObject {
let object = VfsObject {
path: path.clone(),
digest,
size,
producer: self.task.clone(),
node: self.node.clone(),
};
self.pending.insert(path, object.clone());
object
}
pub fn flush(&mut self) -> VfsManifest {
self.epoch += 1;
self.published.append(&mut self.pending);
VfsManifest {
epoch: self.epoch,
producer: self.task.clone(),
node: self.node.clone(),
objects: self.published.clone(),
large_bytes_uploaded: false,
}
}
pub fn sync(&self, policy: SyncPolicy) -> VfsSyncDecision {
match policy {
SyncPolicy::MetadataOnly => VfsSyncDecision::NoBytesMoved,
SyncPolicy::ExplicitNode(node) => VfsSyncDecision::MoveBytesToNode(node),
SyncPolicy::ExplicitStore(store) => VfsSyncDecision::MoveBytesToStore(store),
}
}
pub fn read_published<'a>(
manifest: &'a VfsManifest,
path: &VfsPath,
) -> Result<&'a VfsObject, VfsError> {
manifest
.objects
.get(path)
.ok_or_else(|| VfsError::NotVisible(path.as_str().to_owned()))
}
pub fn reuse_for_consumer(
manifest: &VfsManifest,
path: &VfsPath,
consumer_node: &NodeId,
) -> ReuseDecision {
let Some(object) = manifest.objects.get(path) else {
return ReuseDecision::Unavailable;
};
if &object.node == consumer_node {
ReuseDecision::SameNodeZeroCopy
} else {
ReuseDecision::NeedsTransfer {
from: object.node.clone(),
to: consumer_node.clone(),
}
}
}
pub fn discard_unflushed(&mut self) {
self.pending.clear();
}
pub fn pending_len(&self) -> usize {
self.pending.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn path() -> VfsPath {
VfsPath::new("/vfs/artifacts/app").unwrap()
}
#[test]
fn flush_publishes_manifest_without_large_byte_upload() {
let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a"));
overlay.write(path(), Digest::sha256("binary"), 6);
let manifest = overlay.flush();
assert_eq!(manifest.epoch, 1);
assert!(!manifest.large_bytes_uploaded);
assert!(manifest.objects.contains_key(&path()));
}
#[test]
fn downstream_task_can_read_after_flush_but_not_before() {
let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a"));
overlay.write(path(), Digest::sha256("binary"), 6);
let empty = VfsManifest {
epoch: 0,
producer: TaskInstanceId::from("task"),
node: NodeId::from("node-a"),
objects: BTreeMap::new(),
large_bytes_uploaded: false,
};
assert!(VfsOverlay::read_published(&empty, &path()).is_err());
let manifest = overlay.flush();
assert!(VfsOverlay::read_published(&manifest, &path()).is_ok());
}
#[test]
fn sync_is_explicit_and_policy_driven() {
let overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a"));
assert_eq!(
overlay.sync(SyncPolicy::MetadataOnly),
VfsSyncDecision::NoBytesMoved
);
assert_eq!(
overlay.sync(SyncPolicy::ExplicitStore("s3://bucket/app".to_owned())),
VfsSyncDecision::MoveBytesToStore("s3://bucket/app".to_owned())
);
}
#[test]
fn same_node_reuse_avoids_transfer() {
let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a"));
overlay.write(path(), Digest::sha256("binary"), 6);
let manifest = overlay.flush();
assert_eq!(
VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-a")),
ReuseDecision::SameNodeZeroCopy
);
assert_eq!(
VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-b")),
ReuseDecision::NeedsTransfer {
from: NodeId::from("node-a"),
to: NodeId::from("node-b")
}
);
}
#[test]
fn unflushed_task_local_changes_can_be_discarded() {
let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a"));
overlay.write(path(), Digest::sha256("binary"), 6);
overlay.discard_unflushed();
assert_eq!(overlay.pending_len(), 0);
}
}

View file

@ -0,0 +1,114 @@
use serde_json::{json, Value};
pub const COORDINATOR_PROTOCOL_VERSION: u64 = 1;
pub const COORDINATOR_WIRE_REQUEST_TYPE: &str = "coordinator_request";
pub fn coordinator_wire_request(request_id: impl Into<String>, payload: Value) -> Value {
let operation = coordinator_payload_operation(&payload);
let authentication = coordinator_authentication_metadata(&payload);
json!({
"type": COORDINATOR_WIRE_REQUEST_TYPE,
"protocol_version": COORDINATOR_PROTOCOL_VERSION,
"request_id": request_id.into(),
"operation": operation,
"authentication": authentication,
"payload": payload,
})
}
pub fn coordinator_payload_operation(payload: &Value) -> String {
payload
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned()
}
pub fn coordinator_authentication_metadata(payload: &Value) -> Value {
let operation = coordinator_payload_operation(payload);
match operation.as_str() {
"authenticated" => json!({
"kind": "cli_session",
"session": true,
"request_operation": payload
.get("request")
.map(coordinator_payload_operation)
.unwrap_or_else(|| "unknown".to_owned()),
}),
"signed_node" => json!({
"kind": "node_signature",
"node": payload.get("node").and_then(Value::as_str),
}),
"node_heartbeat" if payload.get("node_signature").is_some() => json!({
"kind": "node_signature",
"node": payload.get("node").and_then(Value::as_str),
}),
"start_process" | "launch_task" if payload.get("agent_signature").is_some() => json!({
"kind": "agent_signature",
"agent": payload.get("actor_agent").and_then(Value::as_str),
"fingerprint": payload.get("agent_public_key_fingerprint").and_then(Value::as_str),
}),
"admin_status" | "suspend_tenant" if payload.get("admin_proof").is_some() => json!({
"kind": "admin_proof",
"actor": payload.get("actor_user").and_then(Value::as_str),
"nonce": payload.get("admin_nonce").and_then(Value::as_str),
"issued_at_epoch_seconds": payload.get("issued_at_epoch_seconds").and_then(Value::as_u64),
}),
"exchange_node_enrollment_grant" => json!({
"kind": "node_enrollment_grant",
"node": payload.get("node").and_then(Value::as_str),
}),
_ => json!({
"kind": "none",
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coordinator_wire_request_wraps_payload_without_exposing_secret_metadata() {
let envelope = coordinator_wire_request(
"cli-1",
json!({
"type": "authenticated",
"session_secret": "secret-value",
"request": { "type": "list_projects" },
}),
);
assert_eq!(envelope["type"], COORDINATOR_WIRE_REQUEST_TYPE);
assert_eq!(envelope["protocol_version"], COORDINATOR_PROTOCOL_VERSION);
assert_eq!(envelope["request_id"], "cli-1");
assert_eq!(envelope["operation"], "authenticated");
assert_eq!(envelope["authentication"]["kind"], "cli_session");
assert_eq!(
envelope["authentication"]["request_operation"],
"list_projects"
);
assert_eq!(envelope["authentication"].get("session_secret"), None);
assert_eq!(envelope["payload"]["session_secret"], "secret-value");
}
#[test]
fn coordinator_wire_request_describes_signature_metadata() {
let envelope = coordinator_wire_request(
"node-1",
json!({
"type": "signed_node",
"node": "node-a",
"node_signature": {
"nonce": "nonce",
"issued_at_epoch_seconds": 1,
"signature": "ed25519:sig"
},
"request": { "type": "poll_task_assignment", "node": "node-a" },
}),
);
assert_eq!(envelope["authentication"]["kind"], "node_signature");
assert_eq!(envelope["authentication"]["node"], "node-a");
}
}

View file

@ -0,0 +1,21 @@
[package]
name = "disasmer-dap"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "disasmer-debug-dap"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
base64.workspace = true
disasmer-core = { path = "../disasmer-core" }
disasmer-control = { path = "../disasmer-control" }
disasmer-node = { path = "../disasmer-node" }
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true

View file

@ -0,0 +1,814 @@
use std::io::{self, BufReader};
use anyhow::Result;
use disasmer_core::DebugRuntimeState;
use serde_json::{json, Value};
use crate::breakpoints::{
freeze_all, freeze_all_at_line, next_breakpoint_after, position_confirmed_breakpoint_stop,
request_thread, request_thread_id, resolve_breakpoints_for_source,
restart_requires_whole_process, simulated_freeze_failure_thread, step_thread,
stopped_thread_for_breakpoint,
};
use crate::dap_protocol::{initialize_capabilities, read_message, DapWriter};
use crate::demo_backend::{
is_explicit_demo_backend, start_simulated_backend, LINUX_THREAD, MAIN_THREAD,
};
use crate::runtime_client::{
attach_services_runtime, create_debug_epoch, relaunch_services_main_runtime, restart_task,
resume_debug_epoch, run_live_services_runtime, run_local_services_runtime,
wait_for_debug_epoch_frozen, wait_for_debug_epoch_resumed, wait_for_services_runtime_outcome,
RuntimeContinuationOutcome,
};
use crate::variables::variables_response;
use crate::virtual_model::{AdapterState, DapSessionMode, RuntimeBackend};
pub(crate) fn run_adapter() -> Result<()> {
let mut writer = DapWriter::new();
let mut reader = BufReader::new(io::stdin());
let mut state = AdapterState::default();
let mut _local_runtime_session = None;
while let Some(request) = read_message(&mut reader)? {
let command = request
.get("command")
.and_then(Value::as_str)
.unwrap_or_default();
match command {
"initialize" => writer.response(&request, true, initialize_capabilities())?,
"launch" | "attach" => {
let args = request
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
state.entry = args
.get("entry")
.and_then(Value::as_str)
.unwrap_or("build")
.to_owned();
let project = args
.get("project")
.and_then(Value::as_str)
.unwrap_or(".")
.to_owned();
let runtime_backend = match runtime_backend_from_launch_arg(
args.get("runtimeBackend").and_then(Value::as_str),
) {
Ok(runtime_backend) => runtime_backend,
Err(message) => {
writer.error_response(&request, message)?;
continue;
}
};
let coordinator_endpoint = args
.get("coordinatorEndpoint")
.and_then(Value::as_str)
.map(str::to_owned);
let source_path = args
.get("sourcePath")
.and_then(Value::as_str)
.map(str::to_owned);
if let Err(err) = state.configure_launch(
project,
state.entry.clone(),
runtime_backend,
coordinator_endpoint,
source_path,
) {
writer.error_response(&request, err.to_string())?;
continue;
}
state.restart_existing = args
.get("restartExisting")
.and_then(Value::as_bool)
.unwrap_or(false);
if let Some(process_id) = args.get("processId").and_then(Value::as_str) {
state.process = disasmer_core::ProcessId::new(process_id);
}
if command == "attach" {
state.session_mode = DapSessionMode::Attach;
state.command_status =
format!("attached to existing virtual process {}", state.process);
}
writer.response(&request, true, json!({}))?;
writer.event("initialized", json!({}))?;
}
"setBreakpoints" => {
let requested_source_path = request
.get("arguments")
.and_then(|value| value.get("source"))
.and_then(|value| value.get("path"))
.and_then(Value::as_str);
let requested_lines = request
.get("arguments")
.and_then(|value| value.get("breakpoints"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.get("line").and_then(Value::as_i64))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let response = resolve_breakpoints_for_source(
&mut state,
requested_source_path,
requested_lines,
)
.iter()
.map(|breakpoint| breakpoint.to_dap())
.collect::<Vec<_>>();
writer.response(&request, true, json!({ "breakpoints": response }))?;
}
"setExceptionBreakpoints" => {
writer.response(&request, true, json!({ "breakpoints": [] }))?;
}
"configurationDone" => {
if state.session_mode == DapSessionMode::Attach {
match state.runtime_backend {
RuntimeBackend::Simulated => {
state.command_status =
format!("attached to existing virtual process {}", state.process);
}
RuntimeBackend::LocalServices | RuntimeBackend::LiveServices => {
match attach_services_runtime(&state) {
Ok(record) => state.apply_attach_record(record),
Err(err) => {
writer.error_response(
&request,
format!("services runtime attach failed: {err:#}"),
)?;
continue;
}
}
}
}
} else {
match state.runtime_backend {
RuntimeBackend::LocalServices => match run_local_services_runtime(&state) {
Ok((record, session)) => {
state.apply_runtime_record(record);
_local_runtime_session = Some(session);
}
Err(err) => {
writer.error_response(
&request,
format!("local services runtime launch failed: {err:#}"),
)?;
continue;
}
},
RuntimeBackend::LiveServices => match run_live_services_runtime(&state) {
Ok(record) => {
state.apply_runtime_record(record);
}
Err(err) => {
writer.error_response(
&request,
format!("live services runtime launch failed: {err:#}"),
)?;
continue;
}
},
RuntimeBackend::Simulated => {
start_simulated_backend(&mut state);
}
}
}
writer.response(&request, true, json!({}))?;
let session_message = if state.session_mode == DapSessionMode::Attach {
format!(
"Attached to Disasmer virtual process {} with {:?} runtime\n",
state.process, state.runtime_backend
)
} else {
format!(
"Disasmer bundle refreshed for entry `{}`; starting virtual process {} with {:?} runtime\n",
state.entry, state.process, state.runtime_backend
)
};
writer.output("console", session_message)?;
if !state.breakpoints.is_empty() {
let stopped_thread = stopped_thread_for_breakpoint(&state);
if matches!(
state.runtime_backend,
RuntimeBackend::LocalServices | RuntimeBackend::LiveServices
) && state.coordinator_debug_epoch.is_some()
{
position_confirmed_breakpoint_stop(&mut state, stopped_thread);
writer.event(
"stopped",
json!({
"reason": "breakpoint",
"description": "Disasmer Debug Epoch all-stop confirmed by every active participant",
"threadId": stopped_thread,
"allThreadsStopped": true,
}),
)?;
continue;
}
match freeze_all(&mut state, stopped_thread, None) {
Ok(()) => {
if let Err(err) = record_coordinator_debug_epoch(
&mut state,
stopped_thread,
"breakpoint",
) {
let message = format!("coordinator debug epoch failed: {err:#}");
writer.output("stderr", format!("{message}\n"))?;
writer.error_response(&request, message)?;
continue;
}
writer.event(
"stopped",
json!({
"reason": "breakpoint",
"description": "Disasmer Debug Epoch all-stop",
"threadId": stopped_thread,
"allThreadsStopped": true,
}),
)?;
}
Err(failure) => {
let message = failure.message();
writer.output("stderr", format!("{message}\n"))?;
writer.error_response(&request, message)?;
}
}
} else {
writer.event("terminated", json!({}))?;
}
}
"threads" => {
let threads = state
.threads
.values()
.map(|thread| {
json!({
"id": thread.id,
"name": format!("{}/{}", state.process, thread.name),
})
})
.collect::<Vec<_>>();
writer.response(&request, true, json!({ "threads": threads }))?;
}
"stackTrace" => {
let thread = request_thread(&request, &state);
let source_path = crate::source::stack_source_path(&state);
let frame_name = thread
.runtime_stack_frames
.first()
.cloned()
.unwrap_or_else(|| format!("{}::run", thread.name));
writer.response(
&request,
true,
json!({
"stackFrames": [{
"id": thread.frame_id,
"name": frame_name,
"line": thread.line,
"column": 1,
"source": {
"name": crate::source::source_name(&source_path),
"path": source_path,
"presentationHint": "normal"
}
}],
"totalFrames": 1
}),
)?;
}
"source" => match crate::source::source_response(&state, &request) {
Ok(body) => writer.response(&request, true, body)?,
Err(err) => writer.error_response(&request, err.to_string())?,
},
"scopes" => {
let frame_id = request
.get("arguments")
.and_then(|value| value.get("frameId"))
.and_then(Value::as_i64)
.unwrap_or(1000 + crate::demo_backend::MAIN_THREAD);
let thread = state
.threads
.values()
.find(|thread| thread.frame_id == frame_id)
.cloned()
.unwrap_or_else(|| state.threads[&crate::demo_backend::MAIN_THREAD].clone());
writer.response(
&request,
true,
json!({
"scopes": [
{
"name": "Source Locals",
"variablesReference": thread.locals_ref,
"expensive": false,
"presentationHint": "locals"
},
{
"name": "Wasm Frame Locals",
"variablesReference": thread.wasm_locals_ref,
"expensive": false,
"presentationHint": "locals"
},
{
"name": "Task Args and Handles",
"variablesReference": thread.args_ref,
"expensive": false,
"presentationHint": "arguments"
},
{
"name": "Disasmer Runtime",
"variablesReference": thread.runtime_ref,
"expensive": false
},
{
"name": "Recent Output",
"variablesReference": thread.output_ref,
"expensive": false
}
]
}),
)?;
}
"variables" => {
let reference = request
.get("arguments")
.and_then(|value| value.get("variablesReference"))
.and_then(Value::as_i64)
.unwrap_or(0);
writer.response(&request, true, variables_response(&state, reference))?;
}
"evaluate" => {
let expression = request
.get("arguments")
.and_then(|value| value.get("expression"))
.and_then(Value::as_str)
.unwrap_or_default();
let result = match expression {
"virtual_process_id" => state.process.to_string(),
"debug_epoch" => state.epoch.to_string(),
"command_status" => state.command_status.clone(),
_ => "not available".to_owned(),
};
writer.response(
&request,
true,
json!({ "result": result, "variablesReference": 0 }),
)?;
}
"pause" => {
let stopped_thread = default_thread_id(&state);
match freeze_all(
&mut state,
stopped_thread,
simulated_freeze_failure_thread(&request),
) {
Ok(()) => {
if let Err(err) =
record_coordinator_debug_epoch(&mut state, stopped_thread, "pause")
{
let message = format!("coordinator debug epoch failed: {err:#}");
writer.output("stderr", format!("{message}\n"))?;
writer.error_response(&request, message)?;
continue;
}
writer.response(&request, true, json!({}))?;
writer.event(
"stopped",
json!({
"reason": "pause",
"description": "Disasmer Debug Epoch pause",
"threadId": stopped_thread,
"allThreadsStopped": true,
}),
)?;
}
Err(failure) => {
let message = failure.message();
writer.output("stderr", format!("{message}\n"))?;
writer.error_response(&request, message)?;
}
}
}
"continue" => {
let thread_id =
request_thread_id(&request).unwrap_or_else(|| default_thread_id(&state));
let next_breakpoint = next_breakpoint_after(&state, thread_id);
let previous_debug_epoch = state.coordinator_debug_epoch.unwrap_or(state.epoch);
if let Err(err) = resume_coordinator_epoch(&mut state) {
let message = format!("coordinator debug epoch resume failed: {err:#}");
writer.output("stderr", format!("{message}\n"))?;
writer.error_response(&request, message)?;
continue;
}
for thread in state.threads.values_mut() {
if thread.state == DebugRuntimeState::Frozen {
thread.state = DebugRuntimeState::Running;
}
}
writer.response(&request, true, json!({ "allThreadsContinued": true }))?;
writer.event(
"continued",
json!({
"threadId": thread_id,
"allThreadsContinued": true,
}),
)?;
if matches!(
state.runtime_backend,
RuntimeBackend::LocalServices | RuntimeBackend::LiveServices
) {
match wait_for_services_runtime_outcome(&state, previous_debug_epoch) {
Ok(RuntimeContinuationOutcome::Breakpoint(record)) => {
state.apply_runtime_record(record);
let stopped_thread = stopped_thread_for_breakpoint(&state);
position_confirmed_breakpoint_stop(&mut state, stopped_thread);
writer.event(
"stopped",
json!({
"reason": "breakpoint",
"description": "Disasmer Debug Epoch all-stop confirmed by every active participant",
"threadId": stopped_thread,
"allThreadsStopped": true,
}),
)?;
}
Ok(RuntimeContinuationOutcome::Terminal(record)) => {
state.apply_runtime_record(record);
if state.last_task_failed {
writer.output("stderr", format!("{}\n", state.command_status))?;
}
writer.event("terminated", json!({}))?;
}
Err(err) => {
let message = format!("continued runtime observation failed: {err:#}");
writer.output("stderr", format!("{message}\n"))?;
writer.event(
"stopped",
json!({
"reason": "exception",
"description": message,
"threadId": thread_id,
"allThreadsStopped": false,
}),
)?;
}
}
continue;
}
if let Some((stopped_thread, line)) = next_breakpoint {
match freeze_all_at_line(&mut state, stopped_thread, line, None) {
Ok(()) => {
if let Err(err) = record_coordinator_debug_epoch(
&mut state,
stopped_thread,
"breakpoint",
) {
let message = format!("coordinator debug epoch failed: {err:#}");
writer.output("stderr", format!("{message}\n"))?;
writer.error_response(&request, message)?;
continue;
}
writer.event(
"stopped",
json!({
"reason": "breakpoint",
"description": "Disasmer Debug Epoch all-stop",
"threadId": stopped_thread,
"allThreadsStopped": true,
}),
)?;
}
Err(failure) => {
let message = failure.message();
writer.output("stderr", format!("{message}\n"))?;
writer.error_response(&request, message)?;
}
}
} else {
writer.event("terminated", json!({}))?;
}
}
"next" | "stepIn" | "stepOut" => {
if state.runtime_backend != RuntimeBackend::Simulated {
writer.error_response(
&request,
"source stepping is not yet available from the executing node; use continue or pause instead of a synthetic step",
)?;
continue;
}
let thread_id = request_thread_id(&request).unwrap_or(LINUX_THREAD);
let description = match command {
"next" => "step over",
"stepIn" => "step in",
"stepOut" => "step out",
_ => "step",
};
step_thread(&mut state, thread_id, description);
writer.response(&request, true, json!({}))?;
writer.event(
"stopped",
json!({
"reason": "step",
"description": format!("Disasmer Debug Epoch {description}"),
"threadId": thread_id,
"allThreadsStopped": true,
}),
)?;
}
"restart" | "restartFrame" => {
let thread_id = request_thread_id(&request)
.or_else(|| {
request
.get("arguments")
.and_then(|arguments| arguments.get("frameId"))
.and_then(Value::as_i64)
.and_then(|frame_id| {
state
.threads
.values()
.find(|thread| thread.frame_id == frame_id)
.map(|thread| thread.id)
})
})
.unwrap_or_else(|| default_thread_id(&state));
if restart_requires_whole_process(&request) {
let message = format!(
"Incompatible source edit requires whole virtual-process restart for {}",
state.process
);
writer.output("stderr", format!("{message}\n"))?;
writer.error_response(&request, message)?;
continue;
}
let restarting_failed_task = state
.threads
.get(&thread_id)
.is_some_and(|thread| matches!(thread.state, DebugRuntimeState::Failed(_)));
if state.runtime_backend != RuntimeBackend::Simulated {
if thread_id == MAIN_THREAD && restarting_failed_task {
match relaunch_services_main_runtime(&state) {
Ok(record) => {
state.apply_runtime_record(record);
let stopped_thread = stopped_thread_for_breakpoint(&state);
position_confirmed_breakpoint_stop(&mut state, stopped_thread);
writer.response(&request, true, json!({}))?;
writer.output(
"console",
"Rebuilt the current bundle and restarted the failed coordinator main from its entry boundary\n",
)?;
writer.event(
"stopped",
json!({
"reason": "breakpoint",
"description": "Restarted main from the rebuilt bundle; every active participant confirmed all-stop",
"threadId": stopped_thread,
"allThreadsStopped": true,
}),
)?;
}
Err(err) => {
let message = format!("coordinator main restart failed: {err:#}");
writer.output("stderr", format!("{message}\n"))?;
writer.error_response(&request, message)?;
}
}
continue;
}
match restart_task_through_coordinator(&mut state, thread_id) {
Ok(message) => {
let previous_debug_epoch =
state.coordinator_debug_epoch.unwrap_or(state.epoch);
if let Some(thread) = state.threads.get_mut(&thread_id) {
thread.state = DebugRuntimeState::Running;
thread.recent_output.push(message.clone());
}
state.last_task_failed = false;
state.command_status = message.clone();
writer.response(&request, true, json!({}))?;
writer.output("console", format!("{message}\n"))?;
if matches!(
state.runtime_backend,
RuntimeBackend::LocalServices | RuntimeBackend::LiveServices
) {
match wait_for_services_runtime_outcome(
&state,
previous_debug_epoch,
) {
Ok(RuntimeContinuationOutcome::Breakpoint(record)) => {
state.apply_runtime_record(record);
let stopped_thread = stopped_thread_for_breakpoint(&state);
position_confirmed_breakpoint_stop(
&mut state,
stopped_thread,
);
writer.event(
"stopped",
json!({
"reason": "breakpoint",
"description": "Restarted task reached a Wasm probe and every active participant confirmed all-stop",
"threadId": stopped_thread,
"allThreadsStopped": true,
}),
)?;
}
Ok(RuntimeContinuationOutcome::Terminal(record)) => {
state.apply_runtime_record(record);
writer.event("terminated", json!({}))?;
}
Err(err) => {
writer.event(
"stopped",
json!({
"reason": "exception",
"description": format!("restarted runtime observation failed: {err:#}"),
"threadId": thread_id,
"allThreadsStopped": false,
}),
)?;
}
}
}
}
Err(err) => {
let message = format!("coordinator task restart failed: {err:#}");
writer.output("stderr", format!("{message}\n"))?;
writer.error_response(&request, message)?;
}
}
continue;
}
if let Some(thread) = state.threads.get_mut(&thread_id) {
thread.state = DebugRuntimeState::Running;
thread
.recent_output
.push("task restarted from VFS checkpoint".to_owned());
}
if restarting_failed_task {
state.last_task_failed = false;
}
state.command_status = format!(
"{} task restarted from compatible VFS checkpoint",
if restarting_failed_task {
"failed"
} else {
"selected"
}
);
writer.response(&request, true, json!({}))?;
writer.output(
"console",
format!(
"Restarted {} task from compatible VFS checkpoint on thread {thread_id}\n",
if restarting_failed_task {
"failed"
} else {
"selected"
}
),
)?;
}
"disconnect" | "terminate" => {
writer.response(&request, true, json!({}))?;
writer.event("terminated", json!({}))?;
break;
}
_ => writer.error_response(&request, format!("unsupported DAP command: {command}"))?,
}
}
Ok(())
}
fn restart_task_through_coordinator(state: &mut AdapterState, thread_id: i64) -> Result<String> {
let task = state
.threads
.get(&thread_id)
.or_else(|| state.threads.get(&default_thread_id(state)))
.map(|thread| thread.task.clone())
.ok_or_else(|| anyhow::anyhow!("selected debug thread does not map to a virtual task"))?;
let record = restart_task(state, &task)?;
if !record.accepted {
let mut message = format!(
"coordinator refused task restart for `{task}`: {}",
record.message
);
if record.requires_whole_process_restart {
message.push_str("; whole virtual-process restart required");
}
if record.active_task {
message.push_str("; selected task is still active");
}
if record.completed_event_observed {
message.push_str("; only terminal task-event metadata is available");
}
return Err(anyhow::anyhow!(message));
}
if !record.clean_boundary_available {
return Err(anyhow::anyhow!(
"coordinator accepted task restart for `{task}` without a clean checkpoint boundary"
));
}
let restarted_task = record.restarted_task_instance.ok_or_else(|| {
anyhow::anyhow!("coordinator accepted task restart without a new task-instance ID")
})?;
if let Some(thread) = state.threads.get_mut(&thread_id) {
thread.task = restarted_task.clone();
}
state.debug_probes =
crate::breakpoints::load_bundle_debug_probes(&state.project, &state.source_path);
Ok(format!(
"Coordinator restarted task `{task}` as `{restarted_task}` from the rebuilt bundle and a clean runtime checkpoint boundary"
))
}
fn record_coordinator_debug_epoch(
state: &mut AdapterState,
stopped_thread: i64,
reason: &str,
) -> Result<()> {
if state.runtime_backend == RuntimeBackend::Simulated {
return Ok(());
}
let stopped_task = state
.threads
.get(&stopped_thread)
.map(|thread| thread.task.clone())
.unwrap_or_else(|| state.threads[&default_thread_id(state)].task.clone());
let record = create_debug_epoch(state, &stopped_task, reason)?;
let status = wait_for_debug_epoch_frozen(state, record.epoch)?;
state.epoch = record.epoch;
state.coordinator_debug_epoch = Some(record.epoch);
let previous_status = state.command_status.clone();
state.command_status = format!(
"{previous_status}; debug epoch {} {} after {}/{} signed participant freeze acknowledgements",
status.epoch,
status.command,
status.acknowledgements.len(),
status.expected_tasks
);
if let Some(thread) = state.threads.get_mut(&stopped_thread) {
thread.recent_output.push(format!(
"coordinator debug epoch {} reached all-stop after {}/{} signed participant acknowledgements",
status.epoch,
status.acknowledgements.len(),
status.expected_tasks
));
}
Ok(())
}
fn default_thread_id(state: &AdapterState) -> i64 {
if state.runtime_backend == RuntimeBackend::Simulated
&& state.threads.contains_key(&LINUX_THREAD)
{
LINUX_THREAD
} else {
MAIN_THREAD
}
}
fn resume_coordinator_epoch(state: &mut AdapterState) -> Result<()> {
let Some(epoch) = state.coordinator_debug_epoch else {
return Ok(());
};
if state.runtime_backend == RuntimeBackend::Simulated {
return Ok(());
}
let record = resume_debug_epoch(state, epoch)?;
let status = wait_for_debug_epoch_resumed(state, epoch)?;
state.coordinator_debug_epoch = None;
state.command_status = format!(
"debug epoch {} resumed after {}/{} signed participant acknowledgements",
status.epoch,
status.acknowledgements.len(),
status.expected_tasks
);
let status_thread = default_thread_id(state);
if let Some(thread) = state.threads.get_mut(&status_thread) {
thread.recent_output.push(format!(
"coordinator debug epoch {} resumed with {} after {}/{} acknowledgements ({} affected task(s))",
status.epoch,
status.command,
status.acknowledgements.len(),
status.expected_tasks,
record.affected_tasks
));
}
Ok(())
}
pub(crate) fn runtime_backend_from_launch_arg(
value: Option<&str>,
) -> Result<RuntimeBackend, String> {
match value {
None | Some("local-services") => Ok(RuntimeBackend::LocalServices),
Some("live-services") => Ok(RuntimeBackend::LiveServices),
Some(value) if is_explicit_demo_backend(value) => Ok(RuntimeBackend::Simulated),
Some(value) => Err(format!(
"unsupported Disasmer runtimeBackend `{value}`; use local-services, live-services, or explicit demo"
)),
}
}

View file

@ -0,0 +1,340 @@
use std::fs;
use disasmer_core::{
discover_source_debug_probes, BundleDebugProbe, DebugRuntimeState, TaskInstanceId,
};
use serde_json::{json, Value};
use crate::demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD};
use crate::virtual_model::{AdapterState, VirtualThread};
pub(crate) fn request_thread<'a>(request: &Value, state: &'a AdapterState) -> &'a VirtualThread {
let thread_id = request_thread_id(request).unwrap_or(MAIN_THREAD);
state
.threads
.get(&thread_id)
.unwrap_or_else(|| &state.threads[&MAIN_THREAD])
}
pub(crate) fn request_thread_id(request: &Value) -> Option<i64> {
request
.get("arguments")
.and_then(|value| value.get("threadId"))
.and_then(Value::as_i64)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ResolvedBreakpoint {
pub(crate) id: usize,
pub(crate) line: i64,
pub(crate) verified: bool,
pub(crate) message: String,
}
impl ResolvedBreakpoint {
pub(crate) fn to_dap(&self) -> Value {
json!({
"id": self.id,
"verified": self.verified,
"line": self.line,
"message": self.message,
})
}
}
pub(crate) fn load_bundle_debug_probes(project: &str, source_path: &str) -> Vec<BundleDebugProbe> {
let source = fs::read_to_string(crate::source::resolve_source_path(project, source_path));
source
.map(|source| discover_source_debug_probes(source_path, &source))
.unwrap_or_default()
}
pub(crate) fn resolve_breakpoints(
state: &mut AdapterState,
requested_lines: Vec<i64>,
) -> Vec<ResolvedBreakpoint> {
let resolved = requested_lines
.into_iter()
.enumerate()
.map(|(index, line)| {
let probe = debug_probe_for_line(state, line);
let verified = probe.is_some()
|| (state.debug_probes.is_empty()
&& source_function_name_at_line(state, line).is_some());
let message = match probe {
Some(probe) => format!(
"Mapped to Disasmer debug probe {} for task {}",
probe.id, probe.task
),
None if verified => "Mapped to Disasmer virtual source location".to_owned(),
None => "No Disasmer debug probe metadata covers this source line".to_owned(),
};
ResolvedBreakpoint {
id: index + 1,
line,
verified,
message,
}
})
.collect::<Vec<_>>();
state.breakpoints = resolved
.iter()
.filter(|breakpoint| breakpoint.verified)
.map(|breakpoint| breakpoint.line)
.collect();
resolved
}
pub(crate) fn resolve_breakpoints_for_source(
state: &mut AdapterState,
requested_source_path: Option<&str>,
requested_lines: Vec<i64>,
) -> Vec<ResolvedBreakpoint> {
if requested_source_path.is_none_or(|requested_source_path| {
crate::source::source_paths_match(&state.project, &state.source_path, requested_source_path)
}) {
return resolve_breakpoints(state, requested_lines);
}
requested_lines
.into_iter()
.enumerate()
.map(|(index, line)| ResolvedBreakpoint {
id: index + 1,
line,
verified: false,
message: format!(
"This debug session is configured for `{}`; breakpoints from another source file are not applied",
state.source_path
),
})
.collect()
}
pub(crate) fn restart_requires_whole_process(request: &Value) -> bool {
let Some(arguments) = request.get("arguments") else {
return false;
};
arguments
.get("requiresWholeProcessRestart")
.and_then(Value::as_bool)
.unwrap_or(false)
|| [
"compatibility",
"sourceCompatibility",
"sourceEditCompatibility",
"taskCompatibility",
]
.iter()
.filter_map(|field| arguments.get(field).and_then(Value::as_str))
.any(is_incompatible_restart)
|| arguments
.get("sourceEdit")
.and_then(|value| value.get("compatibility"))
.and_then(Value::as_str)
.is_some_and(is_incompatible_restart)
}
fn is_incompatible_restart(value: &str) -> bool {
value.eq_ignore_ascii_case("incompatible")
|| value.eq_ignore_ascii_case("whole-process")
|| value.eq_ignore_ascii_case("whole_process")
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct FreezeFailure {
pub(crate) thread_id: i64,
pub(crate) task: TaskInstanceId,
}
impl FreezeFailure {
pub(crate) fn message(&self) -> String {
format!(
"debug all-stop failed: participant `{}` on thread {} could not freeze",
self.task, self.thread_id
)
}
}
pub(crate) fn simulated_freeze_failure_thread(request: &Value) -> Option<i64> {
request
.get("arguments")
.and_then(|arguments| arguments.get("simulateFreezeFailure"))
.and_then(Value::as_bool)
.unwrap_or(false)
.then_some(PACKAGE_THREAD)
}
pub(crate) fn freeze_all(
state: &mut AdapterState,
stopped_thread: i64,
forced_failure_thread: Option<i64>,
) -> Result<(), FreezeFailure> {
let line = state
.breakpoints
.iter()
.copied()
.find(|line| thread_for_source_line(state, *line) == stopped_thread)
.or_else(|| state.breakpoints.first().copied())
.unwrap_or_else(|| state.threads[&stopped_thread].line);
freeze_all_at_line(state, stopped_thread, line, forced_failure_thread)
}
pub(crate) fn freeze_all_at_line(
state: &mut AdapterState,
stopped_thread: i64,
line: i64,
forced_failure_thread: Option<i64>,
) -> Result<(), FreezeFailure> {
if let Some(failure) = state.threads.values().find(|thread| {
thread.state == DebugRuntimeState::Running
&& (!thread.freeze_supported || forced_failure_thread == Some(thread.id))
}) {
return Err(FreezeFailure {
thread_id: failure.id,
task: failure.task.clone(),
});
}
state.epoch += 1;
for thread in state.threads.values_mut() {
if thread.state == DebugRuntimeState::Running {
thread.state = DebugRuntimeState::Frozen;
}
}
if let Some(thread) = state.threads.get_mut(&stopped_thread) {
thread.line = line;
thread
.recent_output
.push(format!("debug epoch {} all-stop", state.epoch));
}
Ok(())
}
pub(crate) fn stopped_thread_for_breakpoint(state: &AdapterState) -> i64 {
if let Some(stopped_task) = state.stopped_task.as_ref() {
if let Some(thread) = state
.threads
.values()
.find(|thread| &thread.task == stopped_task)
{
return thread.id;
}
}
state
.breakpoints
.first()
.map(|line| thread_for_source_line(state, *line))
.unwrap_or(MAIN_THREAD)
}
pub(crate) fn position_confirmed_breakpoint_stop(state: &mut AdapterState, stopped_thread: i64) {
let line = state
.breakpoints
.iter()
.copied()
.find(|line| thread_for_source_line(state, *line) == stopped_thread)
.or_else(|| state.breakpoints.first().copied());
if let (Some(line), Some(thread)) = (line, state.threads.get_mut(&stopped_thread)) {
thread.line = line;
thread.recent_output.push(format!(
"debug epoch {} confirmed frozen by all participants",
state.epoch
));
}
}
pub(crate) fn next_breakpoint_after(state: &AdapterState, thread_id: i64) -> Option<(i64, i64)> {
let current_line = state.threads.get(&thread_id)?.line;
state
.breakpoints
.iter()
.copied()
.filter(|line| *line > current_line)
.min()
.map(|line| (thread_for_source_line(state, line), line))
}
fn thread_for_source_line(state: &AdapterState, line: i64) -> i64 {
if let Some(probe) = debug_probe_for_line(state, line) {
return state
.threads
.values()
.find(|thread| thread.task_definition == probe.task)
.map(|thread| thread.id)
.unwrap_or(MAIN_THREAD);
}
if let Some(function_name) = source_function_name_at_line(state, line) {
let lower = function_name.to_ascii_lowercase();
if lower.contains("linux") {
return LINUX_THREAD;
}
if lower.contains("windows") {
return WINDOWS_THREAD;
}
if lower.contains("package") {
return PACKAGE_THREAD;
}
return MAIN_THREAD;
}
state
.threads
.values()
.find(|thread| thread.line == line)
.map(|thread| thread.id)
.unwrap_or(MAIN_THREAD)
}
fn debug_probe_for_line(state: &AdapterState, line: i64) -> Option<&BundleDebugProbe> {
let line = u32::try_from(line).ok()?;
state
.debug_probes
.iter()
.find(|probe| probe.line_start <= line && line <= probe.line_end)
}
pub(crate) fn source_function_name_at_line(state: &AdapterState, line: i64) -> Option<String> {
let source = fs::read_to_string(crate::source::resolve_source_path(
&state.project,
&state.source_path,
))
.ok()?;
let lines = source.lines().collect::<Vec<_>>();
let mut index = usize::try_from(line).ok()?.saturating_sub(1);
if index >= lines.len() {
index = lines.len().saturating_sub(1);
}
lines[..=index]
.iter()
.rev()
.find_map(|line| parse_rust_function_name(line))
}
pub(crate) fn parse_rust_function_name(line: &str) -> Option<String> {
let start = line.find("fn ")? + 3;
let rest = &line[start..];
let name = rest
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
.collect::<String>();
(!name.is_empty()).then_some(name)
}
pub(crate) fn step_thread(state: &mut AdapterState, thread_id: i64, description: &str) {
state.epoch += 1;
let resolved_thread = if state.threads.contains_key(&thread_id) {
thread_id
} else {
LINUX_THREAD
};
if let Some(thread) = state.threads.get_mut(&resolved_thread) {
thread.state = DebugRuntimeState::Frozen;
thread.line += 1;
thread
.recent_output
.push(format!("debug epoch {} {description}", state.epoch));
}
}

View file

@ -0,0 +1,129 @@
use std::io::{self, BufRead, Write};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
#[derive(Clone)]
pub(crate) struct DapWriter {
seq: i64,
}
impl DapWriter {
pub(crate) fn new() -> Self {
Self { seq: 1 }
}
pub(crate) fn response(
&mut self,
request: &Value,
success: bool,
body: Value,
) -> io::Result<()> {
let command = request
.get("command")
.and_then(Value::as_str)
.unwrap_or("<unknown>");
let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0);
let seq = self.next_seq();
self.write(json!({
"seq": seq,
"type": "response",
"request_seq": request_seq,
"success": success,
"command": command,
"body": body,
}))
}
pub(crate) fn error_response(
&mut self,
request: &Value,
message: impl Into<String>,
) -> io::Result<()> {
let command = request
.get("command")
.and_then(Value::as_str)
.unwrap_or("<unknown>");
let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0);
let seq = self.next_seq();
self.write(json!({
"seq": seq,
"type": "response",
"request_seq": request_seq,
"success": false,
"command": command,
"message": message.into(),
}))
}
pub(crate) fn event(&mut self, event: &str, body: Value) -> io::Result<()> {
let seq = self.next_seq();
self.write(json!({
"seq": seq,
"type": "event",
"event": event,
"body": body,
}))
}
pub(crate) fn output(&mut self, category: &str, output: impl Into<String>) -> io::Result<()> {
self.event(
"output",
json!({
"category": category,
"output": output.into(),
}),
)
}
fn next_seq(&mut self) -> i64 {
let seq = self.seq;
self.seq += 1;
seq
}
fn write(&mut self, message: Value) -> io::Result<()> {
let payload = serde_json::to_vec(&message)?;
let mut out = io::stdout().lock();
write!(out, "Content-Length: {}\r\n\r\n", payload.len())?;
out.write_all(&payload)?;
out.flush()
}
}
pub(crate) fn initialize_capabilities() -> Value {
json!({
"supportsConfigurationDoneRequest": true,
"supportsTerminateRequest": true,
"supportsRestartRequest": true,
"supportsRestartFrame": true,
"supportsEvaluateForHovers": true,
"supportsStepBack": false,
})
}
pub(crate) fn read_message<R: BufRead>(reader: &mut R) -> Result<Option<Value>> {
let mut content_length = None;
loop {
let mut line = String::new();
let bytes = reader.read_line(&mut line)?;
if bytes == 0 {
return Ok(None);
}
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = Some(value.trim().parse::<usize>()?);
}
}
let length = content_length.ok_or_else(|| anyhow!("DAP message missing Content-Length"))?;
let mut payload = vec![0; length];
reader.read_exact(&mut payload)?;
Ok(Some(serde_json::from_slice(&payload)?))
}

View file

@ -0,0 +1,63 @@
use std::collections::BTreeMap;
use disasmer_core::{DebugRuntimeState, TaskInstanceId};
use crate::virtual_model::{AdapterState, VirtualThread};
pub(crate) const MAIN_THREAD: i64 = 1;
pub(crate) const LINUX_THREAD: i64 = 2;
pub(crate) const WINDOWS_THREAD: i64 = 3;
pub(crate) const PACKAGE_THREAD: i64 = 4;
pub(crate) fn is_explicit_demo_backend(value: &str) -> bool {
value == "simulated" || value == "demo"
}
pub(crate) fn launch_threads(entry: &str) -> BTreeMap<i64, VirtualThread> {
[
thread(MAIN_THREAD, "main", &format!("{entry} virtual process"), 12),
thread(LINUX_THREAD, "compile-linux", "compile linux", 42),
thread(WINDOWS_THREAD, "compile-windows", "compile windows", 52),
thread(PACKAGE_THREAD, "package-release", "package artifacts", 64),
]
.into_iter()
.map(|thread| (thread.id, thread))
.collect()
}
pub(crate) fn start_simulated_backend(state: &mut AdapterState) {
state.command_status = "running under simulated debug adapter state".to_owned();
}
fn thread(id: i64, task: &str, name: &str, line: i64) -> VirtualThread {
VirtualThread {
id,
frame_id: 1000 + id,
locals_ref: 5000 + id,
wasm_locals_ref: 9000 + id,
args_ref: 2000 + id,
runtime_ref: 3000 + id,
output_ref: 4000 + id,
target_ref: 6000 + id,
vfs_ref: 7000 + id,
command_ref: 8000 + id,
task: TaskInstanceId::from(task),
task_definition: disasmer_core::TaskDefinitionId::from(task),
name: name.to_owned(),
line,
state: DebugRuntimeState::Running,
freeze_supported: true,
recent_output: vec![format!("{name}: waiting")],
stdout_bytes: 0,
stderr_bytes: 0,
stdout_tail: String::new(),
stderr_tail: String::new(),
stdout_truncated: false,
stderr_truncated: false,
runtime_stack_frames: Vec::new(),
wasm_local_values: Vec::new(),
runtime_task_args: Vec::new(),
runtime_handles: Vec::new(),
runtime_command_status: None,
}
}

View file

@ -0,0 +1,43 @@
use anyhow::Result;
#[cfg(test)]
use std::path::Path;
mod adapter;
mod breakpoints;
mod dap_protocol;
mod demo_backend;
mod runtime_client;
mod source;
mod variables;
mod view_state;
mod virtual_model;
#[cfg(test)]
use adapter::runtime_backend_from_launch_arg;
#[cfg(test)]
use breakpoints::{
freeze_all, resolve_breakpoints, resolve_breakpoints_for_source,
restart_requires_whole_process, stopped_thread_for_breakpoint,
};
#[cfg(test)]
use dap_protocol::{initialize_capabilities, read_message};
#[cfg(test)]
use runtime_client::{client_user_request, parse_task_restart_response};
#[cfg(test)]
use variables::variables_response;
#[cfg(test)]
use virtual_model::{AdapterState, RuntimeLaunchRecord};
#[cfg(test)]
use demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD};
#[cfg(test)]
use disasmer_core::{BundleDebugProbe, DebugRuntimeState, TaskInstanceId};
#[cfg(test)]
use virtual_model::{process_id, RuntimeBackend};
fn main() -> Result<()> {
adapter::run_adapter()
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,974 @@
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Child, Stdio};
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use disasmer_core::TaskInstanceId;
use serde_json::{json, Value};
use crate::virtual_model::{AdapterState, RuntimeLaunchRecord};
mod debug_protocol;
mod local_tools;
mod transport;
pub(crate) use debug_protocol::parse_task_restart_response;
use debug_protocol::{
coordinator_debug_epoch_request, parse_debug_epoch_response, wait_for_debug_epoch_state,
};
use local_tools::{child_stderr_suffix, local_tool_command};
pub(crate) use transport::client_user_request;
use transport::coordinator_request;
pub(crate) struct LocalRuntimeSession {
coordinator: Option<Child>,
worker: Option<Child>,
}
impl Drop for LocalRuntimeSession {
fn drop(&mut self) {
for child in [&mut self.worker, &mut self.coordinator] {
if let Some(mut child) = child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
}
struct DebugBundle {
module_base64: String,
digest: String,
entry_export: String,
entry_definition: String,
}
struct ChildGuard(Option<Child>);
impl ChildGuard {
fn new(child: Child) -> Self {
Self(Some(child))
}
fn child_mut(&mut self) -> &mut Child {
self.0.as_mut().expect("guarded child is present")
}
fn take(&mut self) -> Child {
self.0.take().expect("guarded child is present")
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(mut child) = self.0.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DebugEpochRecord {
pub(crate) epoch: u64,
pub(crate) command: String,
pub(crate) affected_tasks: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DebugEpochStatusRecord {
pub(crate) epoch: u64,
pub(crate) command: String,
pub(crate) expected_tasks: usize,
pub(crate) acknowledgements: Vec<Value>,
pub(crate) fully_frozen: bool,
pub(crate) fully_resumed: bool,
pub(crate) failed: bool,
pub(crate) failure_messages: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct TaskRestartRecord {
pub(crate) accepted: bool,
pub(crate) restarted_task_instance: Option<TaskInstanceId>,
pub(crate) clean_boundary_available: bool,
pub(crate) requires_whole_process_restart: bool,
pub(crate) active_task: bool,
pub(crate) completed_event_observed: bool,
pub(crate) message: String,
}
pub(crate) enum RuntimeContinuationOutcome {
Breakpoint(RuntimeLaunchRecord),
Terminal(RuntimeLaunchRecord),
}
pub(crate) fn run_local_services_runtime(
state: &AdapterState,
) -> Result<(RuntimeLaunchRecord, LocalRuntimeSession)> {
let repo = std::env::current_dir()?;
let mut coordinator_command = local_tool_command(
"DISASMER_COORDINATOR_BIN",
"disasmer-coordinator",
"disasmer-coordinator",
&repo,
);
let mut coordinator = coordinator_command
.args(["--listen", "127.0.0.1:0", "--allow-local-trusted-loopback"])
.current_dir(&repo)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let result = (|| {
let stdout = coordinator
.stdout
.take()
.ok_or_else(|| anyhow!("coordinator stdout was not captured"))?;
let mut ready_line = String::new();
BufReader::new(stdout).read_line(&mut ready_line)?;
let ready: Value = serde_json::from_str(&ready_line)?;
let listen = ready
.get("listen")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("coordinator did not report a listen address"))?
.to_owned();
coordinator_request(
&listen,
json!({
"type": "create_project",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"name": "DAP local services project",
}),
)?;
let enrollment = coordinator_request(
&listen,
json!({
"type": "create_node_enrollment_grant",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"ttl_seconds": 60,
}),
)?;
let enrollment_grant = enrollment
.get("grant")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("local coordinator omitted the node enrollment grant"))?
.to_owned();
let mut worker_command =
local_tool_command("DISASMER_NODE_BIN", "disasmer-node", "disasmer-node", &repo);
let mut worker = ChildGuard::new(
worker_command
.args([
"--coordinator",
&listen,
"--tenant",
state.tenant.as_str(),
"--project-id",
state.project_id.as_str(),
"--node",
"dap-node",
"--enrollment-grant",
&enrollment_grant,
"--worker",
"--project-root",
&state.project,
"--assignment-poll-ms",
"20",
])
.current_dir(&repo)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()?,
);
wait_for_local_node(&listen, state, "dap-node", worker.child_mut())?;
let record = launch_services_debug_entrypoint(&listen, state, &repo)?;
Ok((record, worker.take()))
})();
match result {
Ok((record, worker)) => Ok((
record,
LocalRuntimeSession {
coordinator: Some(coordinator),
worker: Some(worker),
},
)),
Err(error) => {
let _ = coordinator.kill();
let _ = coordinator.wait();
Err(error)
}
}
}
fn build_debug_bundle(state: &AdapterState, repo: &Path) -> Result<DebugBundle> {
let mut command = local_tool_command("DISASMER_CLI_BIN", "disasmer", "disasmer-cli", repo);
let output = command
.args(["build", "--project", &state.project, "--json"])
.current_dir(repo)
.output()?;
if !output.status.success() {
return Err(anyhow!(
"Disasmer bundle build failed before debug launch: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let report: Value = serde_json::from_slice(&output.stdout)?;
let module_path = report
.pointer("/bundle_artifact/module")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("bundle build omitted module path"))?;
let module_path = if Path::new(module_path).is_absolute() {
Path::new(module_path).to_path_buf()
} else {
repo.join(module_path)
};
let module = std::fs::read(&module_path)?;
let entrypoints: Value = serde_json::from_slice(&std::fs::read(
module_path
.parent()
.ok_or_else(|| anyhow!("bundle module path has no parent"))?
.join("entrypoints.json"),
)?)?;
let descriptor = entrypoints
.as_array()
.and_then(|descriptors| {
descriptors.iter().find(|descriptor| {
descriptor.get("name").and_then(Value::as_str) == Some(state.entry.as_str())
})
})
.ok_or_else(|| anyhow!("bundle has no entrypoint `{}`", state.entry))?;
let entry_export = descriptor
.get("export")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("entrypoint `{}` omitted its Wasm export", state.entry))?
.to_owned();
let entry_definition = descriptor
.get("stable_id")
.and_then(Value::as_str)
.ok_or_else(|| {
anyhow!(
"entrypoint `{}` omitted its stable definition ID",
state.entry
)
})?
.to_owned();
let digest = report
.pointer("/bundle_artifact/bundle_digest")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("bundle build omitted bundle digest"))?
.to_owned();
Ok(DebugBundle {
module_base64: BASE64_STANDARD.encode(module),
digest,
entry_export,
entry_definition,
})
}
fn launch_services_debug_entrypoint(
coordinator: &str,
state: &AdapterState,
repo: &Path,
) -> Result<RuntimeLaunchRecord> {
let bundle = build_debug_bundle(state, repo)?;
let started = coordinator_request(
coordinator,
client_user_request(
state,
json!({
"type": "start_process",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
"restart": state.restart_existing,
}),
),
)?;
let epoch = started
.get("epoch")
.and_then(Value::as_u64)
.ok_or_else(|| anyhow!("coordinator did not report a process epoch"))?;
let probe_symbols = state.requested_probe_symbols();
if probe_symbols.is_empty() {
return Err(anyhow!(
"no executable Disasmer probe corresponds to the configured source breakpoints"
));
}
coordinator_request(
coordinator,
client_user_request(
state,
json!({
"type": "set_debug_breakpoints",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
"probe_symbols": probe_symbols,
}),
),
)?;
let launch = coordinator_request(
coordinator,
client_user_request(
state,
json!({
"type": "launch_task",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"task_spec": {
"tenant": state.tenant,
"project": state.project_id,
"process": state.process.as_str(),
"task_definition": bundle.entry_definition,
"task_instance": format!("ti:{}:main", state.process),
"dispatch": {
"kind": "coordinator_node_wasm",
"export": bundle.entry_export,
"abi": "entrypoint_v1",
},
"environment_id": null,
"environment": null,
"environment_digest": null,
"required_capabilities": [],
"dependency_cache": null,
"source_snapshot": null,
"required_artifacts": [],
"args": [],
"vfs_epoch": epoch,
"bundle_digest": bundle.digest,
},
"wait_for_node": true,
"artifact_path": "/vfs/artifacts/dap-output.txt",
"wasm_module_base64": bundle.module_base64,
}),
),
)?;
if launch.get("type").and_then(Value::as_str) != Some("main_launched") {
return Err(anyhow!(
"coordinator did not start the capless main runtime: {}",
serde_json::to_string(&launch)?
));
}
let breakpoint = wait_for_breakpoint_hit(coordinator, state)?;
let debug_epoch = breakpoint
.get("hit_epoch")
.and_then(Value::as_u64)
.ok_or_else(|| anyhow!("breakpoint status omitted the hit debug epoch"))?;
let frozen = wait_for_debug_epoch_state_at(coordinator, state, debug_epoch, true)?;
let expected = frozen
.get("expected_tasks")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
let acknowledged = frozen
.get("acknowledgements")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
if expected == 0 || acknowledged != expected {
return Err(anyhow!(
"debug epoch {debug_epoch} claimed frozen without every active participant"
));
}
let process_statuses = coordinator_request(
coordinator,
client_user_request(
state,
json!({
"type": "list_processes",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
}),
),
)?;
let process_status = process_statuses
.get("processes")
.and_then(Value::as_array)
.and_then(|processes| {
processes.iter().find(|process| {
process.get("process").and_then(Value::as_str) == Some(state.process.as_str())
})
})
.cloned();
let node = frozen
.get("acknowledgements")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("node"))
.and_then(Value::as_str)
.unwrap_or("coordinator-main")
.to_owned();
Ok(RuntimeLaunchRecord {
coordinator: coordinator.to_owned(),
node,
node_report: json!({
"process": started,
"task_launch": launch,
"breakpoint": breakpoint,
"debug_epoch": frozen,
"process_status": process_status,
"process_statuses": process_statuses,
}),
task_events: json!({ "events": [] }),
placed_task_launched: true,
status_code: None,
stdout_bytes: 0,
stderr_bytes: 0,
stdout_tail: String::new(),
stderr_tail: String::new(),
stdout_truncated: false,
stderr_truncated: false,
artifact_path: None,
event_count: 0,
debug_epoch: Some(debug_epoch),
stopped_task: breakpoint
.get("hit_task")
.and_then(Value::as_str)
.map(str::to_owned),
stopped_probe_symbol: breakpoint
.get("hit_probe_symbol")
.and_then(Value::as_str)
.map(str::to_owned),
all_participants_frozen: true,
})
}
fn wait_for_local_node(
coordinator: &str,
state: &AdapterState,
node: &str,
worker: &mut Child,
) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(status) = worker.try_wait()? {
return Err(anyhow!(
"local Disasmer worker `{node}` exited before attaching ({status}){}",
child_stderr_suffix(worker)
));
}
let response = coordinator_request(
coordinator,
json!({
"type": "list_node_descriptors",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
}),
)?;
if response
.get("descriptors")
.and_then(Value::as_array)
.is_some_and(|descriptors| {
descriptors
.iter()
.any(|descriptor| descriptor.get("id").and_then(Value::as_str) == Some(node))
})
{
return Ok(());
}
if Instant::now() >= deadline {
let _ = worker.kill();
let _ = worker.wait();
return Err(anyhow!(
"local Disasmer worker `{node}` did not attach within 30 seconds{}",
child_stderr_suffix(worker)
));
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn wait_for_breakpoint_hit(coordinator: &str, state: &AdapterState) -> Result<Value> {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let response = coordinator_request(
coordinator,
client_user_request(
state,
json!({
"type": "inspect_debug_breakpoints",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
}),
),
)?;
if response.get("hit_epoch").and_then(Value::as_u64).is_some() {
return Ok(response);
}
if Instant::now() >= deadline {
return Err(anyhow!(
"executing Wasm did not reach any configured source breakpoint within 30 seconds"
));
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn wait_for_debug_epoch_state_at(
coordinator: &str,
state: &AdapterState,
epoch: u64,
frozen: bool,
) -> Result<Value> {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let response = coordinator_request(
coordinator,
client_user_request(
state,
json!({
"type": "inspect_debug_epoch",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
"epoch": epoch,
}),
),
)?;
if response.get("failed").and_then(Value::as_bool) == Some(true) {
return Err(anyhow!(
"debug epoch {epoch} participant failed: {}",
response
.get("failure_messages")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join("; ")
));
}
let ready_field = if frozen {
"fully_frozen"
} else {
"fully_resumed"
};
if response.get(ready_field).and_then(Value::as_bool) == Some(true) {
return Ok(response);
}
if Instant::now() >= deadline {
return Err(anyhow!(
"debug epoch {epoch} did not reach {ready_field} within 60 seconds"
));
}
std::thread::sleep(Duration::from_millis(50));
}
}
pub(crate) fn run_live_services_runtime(state: &AdapterState) -> Result<RuntimeLaunchRecord> {
let coordinator =
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
let repo = std::env::current_dir()?;
launch_services_debug_entrypoint(&coordinator, state, &repo)
}
pub(crate) fn relaunch_services_main_runtime(state: &AdapterState) -> Result<RuntimeLaunchRecord> {
let coordinator =
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
let repo = std::env::current_dir()?;
let mut restart_state = state.clone();
restart_state.restart_existing = true;
launch_services_debug_entrypoint(&coordinator, &restart_state, &repo)
}
pub(crate) fn attach_services_runtime(state: &AdapterState) -> Result<RuntimeLaunchRecord> {
let coordinator =
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
let debug_attach = coordinator_request(
&coordinator,
client_user_request(
state,
json!({
"type": "debug_attach",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
}),
),
)?;
let allowed = debug_attach
.get("authorization")
.and_then(|authorization| authorization.get("allowed"))
.and_then(Value::as_bool)
.unwrap_or(false);
if !allowed {
let reason = debug_attach
.get("authorization")
.and_then(|authorization| authorization.get("reason"))
.and_then(Value::as_str)
.unwrap_or("debug attach was denied by coordinator authorization");
return Err(anyhow!("debug attach denied: {reason}"));
}
let events = coordinator_request(
&coordinator,
client_user_request(
state,
json!({
"type": "list_task_events",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
}),
),
)?;
let event_count = events
.get("events")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
let process_statuses = coordinator_request(
&coordinator,
client_user_request(
state,
json!({
"type": "list_processes",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
}),
),
)?;
let process_status = process_statuses
.get("processes")
.and_then(Value::as_array)
.and_then(|processes| {
processes.iter().find(|process| {
process.get("process").and_then(Value::as_str) == Some(state.process.as_str())
})
})
.cloned();
let node = process_status
.as_ref()
.and_then(|status| status.get("connected_nodes"))
.and_then(Value::as_array)
.and_then(|nodes| nodes.first())
.and_then(Value::as_str)
.unwrap_or("coordinator-main")
.to_owned();
let active_main = process_status
.as_ref()
.and_then(|status| status.get("main_task_instance"))
.and_then(Value::as_str)
.is_some();
Ok(RuntimeLaunchRecord {
coordinator,
node,
node_report: json!({
"debug_attach": debug_attach,
"process_status": process_status,
"process_statuses": process_statuses,
}),
task_events: events,
placed_task_launched: active_main || event_count > 0,
status_code: None,
stdout_bytes: 0,
stderr_bytes: 0,
stdout_tail: String::new(),
stderr_tail: String::new(),
stdout_truncated: false,
stderr_truncated: false,
artifact_path: None,
event_count,
debug_epoch: None,
stopped_task: None,
stopped_probe_symbol: None,
all_participants_frozen: false,
})
}
pub(crate) fn create_debug_epoch(
state: &AdapterState,
stopped_task: &TaskInstanceId,
reason: &str,
) -> Result<DebugEpochRecord> {
let response = coordinator_debug_epoch_request(
state,
client_user_request(
state,
json!({
"type": "create_debug_epoch",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
"stopped_task": stopped_task.as_str(),
"reason": reason,
}),
),
)?;
parse_debug_epoch_response(response)
}
pub(crate) fn resume_debug_epoch(state: &AdapterState, epoch: u64) -> Result<DebugEpochRecord> {
let response = coordinator_debug_epoch_request(
state,
client_user_request(
state,
json!({
"type": "resume_debug_epoch",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
"epoch": epoch,
}),
),
)?;
parse_debug_epoch_response(response)
}
pub(crate) fn wait_for_debug_epoch_frozen(
state: &AdapterState,
epoch: u64,
) -> Result<DebugEpochStatusRecord> {
wait_for_debug_epoch_state(state, epoch, true)
}
pub(crate) fn wait_for_debug_epoch_resumed(
state: &AdapterState,
epoch: u64,
) -> Result<DebugEpochStatusRecord> {
wait_for_debug_epoch_state(state, epoch, false)
}
pub(crate) fn wait_for_services_runtime_outcome(
state: &AdapterState,
previous_debug_epoch: u64,
) -> Result<RuntimeContinuationOutcome> {
let coordinator =
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
let deadline = Instant::now() + Duration::from_secs(120);
loop {
// Terminal task events remain inspectable after the active process slot is
// released. Read them before active-process debug state so a fast main exit
// cannot turn a successful continuation into an authorization error.
let events = coordinator_request(
&coordinator,
client_user_request(
state,
json!({
"type": "list_task_events",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
}),
),
)?;
if let Some(outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
return Ok(outcome);
}
let breakpoint = match coordinator_request(
&coordinator,
client_user_request(
state,
json!({
"type": "inspect_debug_breakpoints",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
}),
),
) {
Ok(breakpoint) => breakpoint,
Err(inspect_error) => {
// Main completion records its terminal event and clears ephemeral
// debug state in one coordinator pump. That pump can occur between
// the event read above and this inspection request. Re-read the
// durable event stream before treating the missing debug state as
// an adapter failure.
let events = coordinator_request(
&coordinator,
client_user_request(
state,
json!({
"type": "list_task_events",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
}),
),
)?;
if let Some(outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
return Ok(outcome);
}
return Err(inspect_error);
}
};
if let Some(epoch) = breakpoint.get("hit_epoch").and_then(Value::as_u64) {
if epoch > previous_debug_epoch {
let frozen = wait_for_debug_epoch_state_at(&coordinator, state, epoch, true)?;
let node = frozen
.get("acknowledgements")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("node"))
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned();
return Ok(RuntimeContinuationOutcome::Breakpoint(
RuntimeLaunchRecord {
coordinator,
node,
node_report: json!({
"breakpoint": breakpoint,
"debug_epoch": frozen,
}),
task_events: json!({ "events": [] }),
placed_task_launched: true,
status_code: None,
stdout_bytes: 0,
stderr_bytes: 0,
stdout_tail: String::new(),
stderr_tail: String::new(),
stdout_truncated: false,
stderr_truncated: false,
artifact_path: None,
event_count: state.runtime_event_count,
debug_epoch: Some(epoch),
stopped_task: breakpoint
.get("hit_task")
.and_then(Value::as_str)
.map(str::to_owned),
stopped_probe_symbol: breakpoint
.get("hit_probe_symbol")
.and_then(Value::as_str)
.map(str::to_owned),
all_participants_frozen: true,
},
));
}
}
if Instant::now() >= deadline {
return Err(anyhow!(
"continued virtual process {} neither reached another executing Wasm breakpoint nor recorded terminal entrypoint state within 120 seconds",
state.process
));
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn terminal_runtime_outcome(
coordinator: &str,
state: &AdapterState,
events: &Value,
) -> Option<RuntimeContinuationOutcome> {
let event = events
.get("events")
.and_then(Value::as_array)?
.get(state.runtime_event_count..)?
.iter()
.rev()
.find(|event| event.get("executor").and_then(Value::as_str) == Some("coordinator_main"))?;
let status_code = event
.get("status_code")
.and_then(Value::as_i64)
.map(|status| status as i32)
.or_else(
|| match event.get("terminal_state").and_then(Value::as_str) {
Some("completed") => Some(0),
Some("failed" | "cancelled") => Some(1),
_ => None,
},
);
Some(RuntimeContinuationOutcome::Terminal(RuntimeLaunchRecord {
coordinator: coordinator.to_owned(),
node: event
.get("node")
.and_then(Value::as_str)
.unwrap_or("coordinator-main")
.to_owned(),
node_report: json!({ "terminal_event": event }),
task_events: events.clone(),
placed_task_launched: true,
status_code,
stdout_bytes: event
.get("stdout_bytes")
.and_then(Value::as_u64)
.unwrap_or(0),
stderr_bytes: event
.get("stderr_bytes")
.and_then(Value::as_u64)
.unwrap_or(0),
stdout_tail: event
.get("stdout_tail")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
stderr_tail: event
.get("stderr_tail")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
stdout_truncated: event
.get("stdout_truncated")
.and_then(Value::as_bool)
.unwrap_or(false),
stderr_truncated: event
.get("stderr_truncated")
.and_then(Value::as_bool)
.unwrap_or(false),
artifact_path: event
.get("artifact_path")
.and_then(Value::as_str)
.map(str::to_owned),
event_count: events
.get("events")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0),
debug_epoch: None,
stopped_task: None,
stopped_probe_symbol: None,
all_participants_frozen: false,
}))
}
pub(crate) fn restart_task(
state: &AdapterState,
task: &TaskInstanceId,
) -> Result<TaskRestartRecord> {
let repo = std::env::current_dir()?;
let replacement = build_debug_bundle(state, &repo)?;
let response = coordinator_debug_epoch_request(
state,
client_user_request(
state,
json!({
"type": "restart_task",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
"task": task.as_str(),
"replacement_bundle": {
"bundle_digest": replacement.digest,
"wasm_module_base64": replacement.module_base64,
},
}),
),
)?;
parse_task_restart_response(response)
}

View file

@ -0,0 +1,177 @@
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use disasmer_core::TaskInstanceId;
use serde_json::{json, Value};
use crate::virtual_model::AdapterState;
use super::{
client_user_request, coordinator_request, DebugEpochRecord, DebugEpochStatusRecord,
TaskRestartRecord,
};
pub(super) fn coordinator_debug_epoch_request(
state: &AdapterState,
payload: Value,
) -> Result<Value> {
let coordinator =
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
coordinator_request(&coordinator, payload)
}
pub(super) fn parse_debug_epoch_response(response: Value) -> Result<DebugEpochRecord> {
let epoch = response
.get("epoch")
.and_then(Value::as_u64)
.ok_or_else(|| anyhow!("coordinator debug epoch response did not include an epoch"))?;
let command = response
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("coordinator debug epoch response did not include a command"))?
.to_owned();
let affected_tasks = response
.get("affected_tasks")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
Ok(DebugEpochRecord {
epoch,
command,
affected_tasks,
})
}
pub(super) fn wait_for_debug_epoch_state(
state: &AdapterState,
epoch: u64,
frozen: bool,
) -> Result<DebugEpochStatusRecord> {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let response = coordinator_debug_epoch_request(
state,
client_user_request(
state,
json!({
"type": "inspect_debug_epoch",
"tenant": state.tenant,
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
"epoch": epoch,
}),
),
)?;
let status = parse_debug_epoch_status(response)?;
if status.failed {
return Err(anyhow!(
"debug epoch {epoch} participant failed: {}",
status.failure_messages.join("; ")
));
}
if (frozen && status.fully_frozen) || (!frozen && status.fully_resumed) {
return Ok(status);
}
if Instant::now() >= deadline {
return Err(anyhow!(
"debug epoch {epoch} did not receive {}/{} signed participant acknowledgements for {} within 60 seconds",
status.acknowledgements.len(),
status.expected_tasks,
if frozen { "frozen state" } else { "resumed state" }
));
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn parse_debug_epoch_status(response: Value) -> Result<DebugEpochStatusRecord> {
let epoch = response
.get("epoch")
.and_then(Value::as_u64)
.ok_or_else(|| anyhow!("coordinator debug epoch status omitted epoch"))?;
let command = response
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("coordinator debug epoch status omitted command"))?
.to_owned();
let expected_tasks = response
.get("expected_tasks")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
let acknowledgements = response
.get("acknowledgements")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let failure_messages = response
.get("failure_messages")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect();
Ok(DebugEpochStatusRecord {
epoch,
command,
expected_tasks,
acknowledgements,
fully_frozen: response
.get("fully_frozen")
.and_then(Value::as_bool)
.unwrap_or(false),
fully_resumed: response
.get("fully_resumed")
.and_then(Value::as_bool)
.unwrap_or(false),
failed: response
.get("failed")
.and_then(Value::as_bool)
.unwrap_or(false),
failure_messages,
})
}
pub(crate) fn parse_task_restart_response(response: Value) -> Result<TaskRestartRecord> {
Ok(TaskRestartRecord {
accepted: response
.get("accepted")
.and_then(Value::as_bool)
.ok_or_else(|| anyhow!("coordinator task restart response did not include accepted"))?,
restarted_task_instance: response
.get("restarted_task_instance")
.and_then(Value::as_str)
.map(TaskInstanceId::new),
clean_boundary_available: response
.get("clean_boundary_available")
.and_then(Value::as_bool)
.ok_or_else(|| {
anyhow!("coordinator task restart response did not include clean_boundary_available")
})?,
requires_whole_process_restart: response
.get("requires_whole_process_restart")
.and_then(Value::as_bool)
.ok_or_else(|| {
anyhow!(
"coordinator task restart response did not include requires_whole_process_restart"
)
})?,
active_task: response
.get("active_task")
.and_then(Value::as_bool)
.ok_or_else(|| anyhow!("coordinator task restart response did not include active_task"))?,
completed_event_observed: response
.get("completed_event_observed")
.and_then(Value::as_bool)
.ok_or_else(|| {
anyhow!("coordinator task restart response did not include completed_event_observed")
})?,
message: response
.get("message")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("coordinator task restart response did not include message"))?
.to_owned(),
})
}

Some files were not shown because too many files have changed in this diff Show more