Publish Clusterflux 1d0b6fa filtered source

This commit is contained in:
Michel Paulissen 2026-07-21 20:36:04 +02:00
commit e5d609cfb2
226 changed files with 86613 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/target/
/.clusterflux/
**/.clusterflux/
/vscode-extension/node_modules/
/private/*/Cargo.lock
!/private/hosted-policy/Cargo.lock
/private/*/target/
/scripts/containers-home/

View file

@ -0,0 +1,22 @@
{
"kind": "clusterflux-filtered-public-tree",
"source_commit": "1d0b6fab342bd34e6f539adb649eaa4ed82f0cfa",
"release_name": "release-1d0b6fab342b",
"filtered_out": [
"private/**",
"internal/**",
"experiments/**",
".git",
"target",
"git-ignored source paths",
"root/*.md except README.md",
"**/.clusterflux/**",
".forgejo/**"
],
"public_export": {
"host_neutral": true,
"include_forgejo_workflows": false
},
"forgejo_host": "git.michelpaulissen.com",
"default_hosted_coordinator_endpoint": "https://clusterflux.michelpaulissen.com"
}

2939
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

46
Cargo.toml Normal file
View file

@ -0,0 +1,46 @@
[workspace]
resolver = "2"
members = [
"crates/clusterflux-cli",
"crates/clusterflux-control",
"crates/clusterflux-coordinator",
"crates/clusterflux-core",
"crates/clusterflux-dap",
"crates/clusterflux-macros",
"crates/clusterflux-node",
"crates/clusterflux-sdk",
"crates/clusterflux-wasm-runtime",
"examples/hello-build",
"examples/recovery-build",
"tests/fixtures/runtime-conformance",
]
[workspace.package]
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://git.michelpaulissen.com/michel/clusterflux-public"
[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"

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 Clusterflux 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.

106
README.md Normal file
View file

@ -0,0 +1,106 @@
# Clusterflux
Clusterflux runs a Rust-defined workflow as one distributed virtual process. A
coordinator hosts the async main, while attached nodes execute Wasm tasks,
rootless containers, and native commands. Tasks exchange canonical values and
portable typed handles instead of sharing host memory.
Start with [Getting started](docs/getting-started.md). It takes you through
authentication, project setup, node enrollment, a run, debugging, task restart,
and artifact download.
## Build a real executable
The primary example is intentionally small: [hello-build](examples/hello-build)
snapshots its source project, spawns one container-backed compile task, and
publishes the resulting executable as a retained artifact.
~~~bash
clusterflux bundle inspect --project examples/hello-build
clusterflux run --project examples/hello-build build
clusterflux artifact list --process <process-id>
clusterflux artifact download <artifact-id> --to ./hello-clusterflux
chmod +x ./hello-clusterflux
./hello-clusterflux
~~~
The final command prints `hello from a real Clusterflux build`. The source uses
only the public SDK path: current-project snapshot, `spawn!`, `Command::run`, and
artifact publication. See [recovery-build](examples/recovery-build) for two
same-definition task instances, a real command failure, and operator restart.
## What you get
- One virtual process with distinct task instances and restart attempts.
- Bundle-declared environments resolved by digest.
- Native work only on nodes you attach.
- Metadata-first artifacts whose bytes remain on retaining nodes by default.
- VS Code debugging backed by coordinator task and attempt snapshots.
- Full and partial Debug Epochs with explicit consistency status.
- Human Authentik sessions plus scoped public-key identities for agents and nodes.
- A public coordinator, node runtime, CLI, SDK, and DAP adapter for self-hosting.
## Install from this checkout
~~~bash
cargo install --path crates/clusterflux-cli --bin clusterflux
cargo install --path crates/clusterflux-node --bin clusterflux-node
cargo install --path crates/clusterflux-coordinator --bin clusterflux-coordinator
cargo install --path crates/clusterflux-dap --bin clusterflux-debug-dap
~~~
Rootless Podman is required on Linux nodes that build or run a declared
Containerfile environment. Install VS Code when you want the graphical debug
workflow.
## First run
For the hosted service:
~~~bash
clusterflux login --browser
clusterflux auth status
clusterflux project list
clusterflux node enroll --project-id <hosted-project-id> --json
clusterflux node attach --project-id <hosted-project-id> --node workstation \
--enrollment-grant "$ENROLLMENT_GRANT"
~~~
The attach command creates and stores a local node key by default, then exchanges
the short-lived grant once. Start `clusterflux-node --worker` from the project
directory. See [Nodes](docs/nodes.md) for the complete sequence and the advanced
explicit-key option.
Choose an entrypoint and run it:
~~~bash
clusterflux bundle inspect --project examples/hello-build
clusterflux run --project examples/hello-build build
~~~
Inspect the result:
~~~bash
clusterflux process status
clusterflux task list
clusterflux logs
clusterflux artifact list
# Use the `artifact` value returned by the list command, for example:
clusterflux artifact download hello-clusterflux-4f61c2... --to ./hello-clusterflux
~~~
To run your own coordinator instead, follow [Self-hosting](docs/self-hosting.md).
The hosted website is not required for self-hosted projects.
## Documentation
- [Getting started](docs/getting-started.md)
- [Architecture](docs/architecture.md)
- [Nodes](docs/nodes.md)
- [Environments](docs/environments.md)
- [Artifacts](docs/artifacts.md)
- [Debugging](docs/debugging.md)
- [Task ABI](docs/task-abi.md)
- [Self-hosting](docs/self-hosting.md)
- [Security model](docs/security.md)
- [Security reporting](SECURITY.md)

18
SECURITY.md Normal file
View file

@ -0,0 +1,18 @@
# Security reporting
## Supported versions
Security fixes are applied to the current main branch and the most recent
published Clusterflux release. Older preview releases are not maintained.
## Report a vulnerability
Email security@michelpaulissen.com with a concise description, affected version
or source revision, reproduction steps, and impact. Do not open a public issue
for an unpatched vulnerability or include credentials, session tokens, private
keys, provider tokens, customer data, or operator secrets in a public report.
You should receive an acknowledgement within three business days. We will
coordinate validation, remediation, release timing, and disclosure with you.
Avoid accessing data that is not yours, disrupting the hosted service, or
retaining sensitive data beyond what is necessary to demonstrate the issue.

View file

@ -0,0 +1,22 @@
[package]
name = "clusterflux-cli"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "clusterflux"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
base64.workspace = true
clap.workspace = true
clusterflux-core = { path = "../clusterflux-core" }
clusterflux-control = { path = "../clusterflux-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 clusterflux_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": "clusterflux-coordinator",
"project": "clusterflux project init/status/list/select",
"node": "clusterflux node enroll/list/status/revoke",
"process": "clusterflux run/process status/process restart/process cancel",
"logs": "clusterflux logs",
"artifacts": "clusterflux artifact list/download/export",
"quota": "clusterflux quota status",
"policy": "clusterflux admin status/suspend-tenant",
},
"bootstrap_sequence": [
{
"step": "start_self_hosted_coordinator",
"command": "clusterflux-coordinator --listen 127.0.0.1:0",
"private_website_required": false,
},
{
"step": "create_or_link_project",
"command": "clusterflux project init --yes",
"completed": true,
"private_website_required": false,
},
{
"step": "create_node_enrollment_grant",
"command": format!(
"clusterflux node enroll --coordinator {coordinator} --tenant {} --project-id {}",
tenant, project
),
"private_website_required": false,
},
{
"step": "attach_worker_node",
"command": format!(
"clusterflux node attach --coordinator {coordinator} --tenant {} --project-id {} --worker",
tenant, project
),
"private_website_required": false,
},
{
"step": "run_process",
"command": format!(
"clusterflux run --coordinator {coordinator} --tenant {} --project-id {}",
tenant, project
),
"private_website_required": false,
},
{
"step": "inspect_status_logs_artifacts",
"commands": [
"clusterflux process status",
"clusterflux task list",
"clusterflux logs",
"clusterflux artifact list",
"clusterflux quota status",
],
"private_website_required": false,
},
{
"step": "revoke_access",
"command": "clusterflux 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,
}),
"clusterflux 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("CLUSTERFLUX_ADMIN_TOKEN").ok())
.filter(|token| !token.trim().is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"admin command requires --admin-token or CLUSTERFLUX_ADMIN_TOKEN for coordinator requests"
)
})
}

View file

@ -0,0 +1,17 @@
use clusterflux_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: clusterflux_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(".clusterflux-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 = clusterflux_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,776 @@
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) 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": ["clusterflux 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": ["clusterflux 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": ["clusterflux 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": ["clusterflux doctor", "clusterflux 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",
"clusterflux login --browser --plan",
"use CLUSTERFLUX_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("CLUSTERFLUX_TOKEN_EXPIRES_AT").ok();
Ok(json!({
"kind": "human",
"authenticated": true,
"source": "CLUSTERFLUX_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": "CLUSTERFLUX_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,
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_browser_login(&coordinator)?;
let started = session.request(json!({
"type": "begin_oidc_browser_login",
}))?;
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()),
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 Clusterflux 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("CLUSTERFLUX_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("CLUSTERFLUX_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": ["clusterflux 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 clusterflux_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,
"clusterflux.environments",
&environment_manifest,
);
let bundle_digest = Digest::sha256(&wasm.bytes);
let task_descriptors = descriptor_records(&wasm.bytes, "clusterflux.tasks")?;
let entrypoint_descriptors = descriptor_records(&wasm.bytes, "clusterflux.entrypoints")?;
if task_descriptors.is_empty() {
bail!(
"compiled Wasm module contains no #[clusterflux::task] descriptors; annotate at least one exported task"
);
}
if entrypoint_descriptors.is_empty() {
bail!(
"compiled Wasm module contains no #[clusterflux::main] descriptors; annotate at least one entrypoint"
);
}
let output = args.output.unwrap_or_else(|| {
inspection.project.join(".clusterflux/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("Clusterflux 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": "clusterflux-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 clusterflux_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: &[clusterflux_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: &[clusterflux_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 clusterflux 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 clusterflux 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,191 @@
use anyhow::{Context, Result};
use clusterflux_control::{
endpoint_identity, endpoint_is_loopback, ControlSession, LOGIN_API_PATH,
};
use clusterflux_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 connect_browser_login(addr: &str) -> Result<Self> {
let inner = ControlSession::connect_to_api_path(addr, LOGIN_API_PATH)
.with_context(|| format!("failed to connect to hosted login endpoint {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 `clusterflux 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,150 @@
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://clusterflux.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(".clusterflux").join("project.json")
}
pub(crate) fn session_config_file(project: &Path) -> PathBuf {
project.join(".clusterflux").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 clusterflux-debug-dap")?;
if !status.success() {
anyhow::bail!("clusterflux-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 clusterflux_control::ControlSession;
use clusterflux_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"),
"clusterflux-node": command_available("clusterflux-node") || sibling_binary("clusterflux-node").is_some(),
"clusterflux-coordinator": command_available("clusterflux-coordinator") || sibling_binary("clusterflux-coordinator").is_some(),
"clusterflux-debug-dap": command_available("clusterflux-debug-dap") || sibling_binary("clusterflux-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": [
"clusterflux login --browser",
"clusterflux project init",
"clusterflux node attach",
"clusterflux 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(&clusterflux_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 clusterflux_node_available = dependencies
.get("clusterflux-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 !clusterflux_node_available {
missing.push("clusterflux-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![
"clusterflux node enroll",
"clusterflux node attach",
"clusterflux-node --worker",
]
} else {
vec![
"install missing local dependencies",
"rerun clusterflux 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": clusterflux_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 clusterflux 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,258 @@
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!["clusterflux login --browser", "clusterflux auth status"],
"authorization" => vec![
"clusterflux auth status",
"check tenant/project selection",
"ask an admin to grant access",
],
"quota" => vec![
"clusterflux quota status",
"reduce concurrent work or wait for usage to fall",
],
"policy" => vec![
"clusterflux doctor",
"check coordinator policy for this action",
],
"capability" => vec![
"clusterflux node list",
"attach a node with the required capabilities",
"check tenant/project on the attached node",
],
"connectivity" => vec![
"clusterflux doctor",
"check the coordinator endpoint and network reachability",
],
"environment" => vec![
"clusterflux inspect",
"check envs/<name>/Containerfile or envs/<name>/Dockerfile",
],
"program" => vec![
"clusterflux logs",
"fix the program or task command and rerun",
],
"active_process" => vec![
"clusterflux process list",
"clusterflux process status",
"clusterflux debug attach",
"clusterflux process restart --yes",
"clusterflux process cancel --yes",
"clusterflux process abort --yes",
"use another Coordinator Project",
],
_ => vec![
"clusterflux doctor",
"rerun with --json for machine-readable details",
],
}
}

View file

@ -0,0 +1,271 @@
use anyhow::Result;
use clusterflux_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!("clusterflux 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!("clusterflux {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": ["clusterflux 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": ["clusterflux 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": ["clusterflux 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 clusterflux_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 = "clusterflux",
version,
arg_required_else_help = true,
about = "Clusterflux distributed Wasm runtime CLI.",
after_help = "Primary workflow:
1. clusterflux login --browser
2. clusterflux project init
3. clusterflux node enroll; clusterflux node attach; clusterflux-node --worker
4. clusterflux run [entry] --project <path>
5. Debug with VS Code \"Clusterflux: Launch Virtual Process\" or clusterflux dap
6. Inspect with clusterflux process list, clusterflux 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 = "Clusterflux 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 = "Clusterflux 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 clusterflux_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: clusterflux_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!("clusterflux 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 CLUSTERFLUX_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("CLUSTERFLUX_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: "clusterflux_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(".clusterflux")
.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("CLUSTERFLUX_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!("Clusterflux {command}"))
.or_else(|| {
if value.get("human_flow").is_some() {
Some("Clusterflux login".to_owned())
} else if value.get("metadata").is_some()
&& value.get("source_provider_manifest").is_some()
{
Some("Clusterflux bundle inspect".to_owned())
} else if value.get("entry").is_some() && value.get("session").is_some() {
Some("Clusterflux run".to_owned())
} else if value.get("capabilities").is_some() && value.get("node").is_some() {
Some("Clusterflux node attach".to_owned())
} else if value.get("public_key_fingerprint").is_some() {
Some("Clusterflux agent enroll".to_owned())
} else {
None
}
})
.unwrap_or_else(|| "Clusterflux 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,339 @@
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!(
"clusterflux 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!(
"clusterflux 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!("clusterflux 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": "clusterflux_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 == "Clusterflux 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 `clusterflux 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,
}))
}

File diff suppressed because it is too large Load diff

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("CLUSTERFLUX_NODE_BIN") {
return Ok(Command::new(path));
}
let mut sibling = std::env::current_exe().context("cannot locate current executable")?;
sibling.set_file_name(format!("clusterflux-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",
"clusterflux-node",
"--bin",
"clusterflux-node",
"--",
]);
Ok(command)
}
fn coordinator_command() -> Result<Command> {
if let Some(path) = std::env::var_os("CLUSTERFLUX_COORDINATOR_BIN") {
return Ok(Command::new(path));
}
let mut sibling = std::env::current_exe().context("cannot locate current executable")?;
sibling.set_file_name(format!(
"clusterflux-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",
"clusterflux-coordinator",
"--bin",
"clusterflux-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!(
"clusterflux 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("CLUSTERFLUX_DAP_BIN") {
return Ok(PathBuf::from(path));
}
if let Some(path) = sibling_binary("clusterflux-debug-dap") {
return Ok(path);
}
let release = PathBuf::from("target/release").join(format!(
"clusterflux-debug-dap{}",
std::env::consts::EXE_SUFFIX
));
if release.is_file() {
return Ok(release);
}
let debug = PathBuf::from("target/debug").join(format!(
"clusterflux-debug-dap{}",
std::env::consts::EXE_SUFFIX
));
if debug.is_file() {
return Ok(debug);
}
anyhow::bail!("could not locate clusterflux-debug-dap; set CLUSTERFLUX_DAP_BIN")
}

View file

@ -0,0 +1,13 @@
[package]
name = "clusterflux-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,317 @@
#[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};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use serde_json::Value;
use thiserror::Error;
pub const CONTROL_API_PATH: &str = "/api/v1/control";
pub const LOGIN_API_PATH: &str = "/api/v1/login";
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_to_api_path(
endpoint: &str,
api_path: &str,
) -> Result<Self, ControlTransportError> {
let mut session = Self::connect(endpoint)?;
#[cfg(not(target_arch = "wasm32"))]
if let ControlTransport::Https { url, .. } = &mut session.transport {
*url = endpoint_api_url(endpoint, api_path)?;
}
Ok(session)
}
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("clusterflux+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 inject_response_loss = should_inject_response_loss(value);
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 inject_response_loss {
return Err(ControlTransportError::Closed);
}
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()?;
if inject_response_loss {
return Err(ControlTransportError::Closed);
}
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
}
}
#[cfg(not(target_arch = "wasm32"))]
fn should_inject_response_loss(value: &Value) -> bool {
static INJECTED: AtomicBool = AtomicBool::new(false);
let Ok(expected_operation) = std::env::var("CLUSTERFLUX_TEST_DROP_RESPONSE_AFTER_OPERATION")
else {
return false;
};
let operation = value
.pointer("/payload/request/type")
.or_else(|| value.pointer("/payload/type"))
.or_else(|| value.get("type"))
.and_then(Value::as_str);
operation == Some(expected_operation.as_str())
&& INJECTED
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
pub fn control_api_url(endpoint: &str) -> Result<String, ControlTransportError> {
endpoint_api_url(endpoint, CONTROL_API_PATH)
}
pub fn endpoint_api_url(endpoint: &str, api_path: &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 !api_path.starts_with('/') {
return Err(ControlTransportError::InvalidEndpoint(api_path.to_owned()));
}
if endpoint.ends_with(api_path) {
Ok(endpoint.to_owned())
} else {
let base = endpoint
.strip_suffix(CONTROL_API_PATH)
.or_else(|| endpoint.strip_suffix(LOGIN_API_PATH))
.unwrap_or(endpoint);
Ok(format!("{base}{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("clusterflux+tcp://")
.unwrap_or(endpoint);
if endpoint_is_loopback(loopback_address) {
return Ok(format!("clusterflux+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("clusterflux+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://clusterflux.example").unwrap(),
"https://clusterflux.example/api/v1/control"
);
assert_eq!(
endpoint_identity("https://clusterflux.example/api/v1/control").unwrap(),
"https://clusterflux.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("clusterflux+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 = "clusterflux-coordinator"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
base64.workspace = true
clusterflux-core = { path = "../clusterflux-core" }
clusterflux-wasm-runtime = { path = "../clusterflux-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 clusterflux_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,147 @@
use std::collections::BTreeMap;
use clusterflux_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>,
#[serde(default)]
pub artifact_relay: crate::service::ArtifactRelayDurableState,
}
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 clusterflux_coordinator::{service::bind_listener, CoordinatorService};
use clusterflux_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("CLUSTERFLUX_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("CLUSTERFLUX_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("CLUSTERFLUX_SELF_HOSTED_TENANT")
.unwrap_or_else(|_| "tenant".to_owned()),
),
ProjectId::new(
std::env::var("CLUSTERFLUX_SELF_HOSTED_PROJECT")
.unwrap_or_else(|_| "project".to_owned()),
),
UserId::new(
std::env::var("CLUSTERFLUX_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,604 @@
use postgres::{Client, NoTls};
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
use crate::{
AgentPublicKeyRecord, ArtifactRelayDurableState, 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: "clusterflux_artifact_relay_state",
durable_record: "artifact relay reservations and usage",
restart_surviving: true,
},
PostgresTable {
name: "clusterflux_tenants",
durable_record: "tenants",
restart_surviving: true,
},
PostgresTable {
name: "clusterflux_users",
durable_record: "users",
restart_surviving: true,
},
PostgresTable {
name: "clusterflux_projects",
durable_record: "projects",
restart_surviving: true,
},
PostgresTable {
name: "clusterflux_node_identities",
durable_record: "node identities",
restart_surviving: true,
},
PostgresTable {
name: "clusterflux_credentials",
durable_record: "credentials",
restart_surviving: true,
},
PostgresTable {
name: "clusterflux_cli_sessions",
durable_record: "CLI sessions",
restart_surviving: true,
},
PostgresTable {
name: "clusterflux_agent_public_keys",
durable_record: "agent public keys",
restart_surviving: true,
},
PostgresTable {
name: "clusterflux_source_provider_configs",
durable_record: "source-provider configuration",
restart_surviving: true,
},
PostgresTable {
name: "clusterflux_service_policy_records",
durable_record: "durable service policy records",
restart_surviving: true,
},
PostgresTable {
name: "clusterflux_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 clusterflux_tenants ORDER BY tenant_id",
)? {
state.tenants.insert(record.id.clone(), record);
}
for record in self
.query_records::<UserRecord>("SELECT record FROM clusterflux_users ORDER BY user_id")?
{
state.users.insert(record.id.clone(), record);
}
for record in self.query_records::<ProjectRecord>(
"SELECT record FROM clusterflux_projects ORDER BY project_id",
)? {
state.projects.insert(record.id.clone(), record);
}
for record in self.query_records::<NodeIdentityRecord>(
"SELECT record FROM clusterflux_node_identities ORDER BY node_id",
)? {
state.node_identities.insert(record.id.clone(), record);
}
for record in self.query_records::<CredentialRecord>(
"SELECT record FROM clusterflux_credentials ORDER BY subject",
)? {
state.credentials.insert(record.subject.clone(), record);
}
for record in self.query_records::<CliSessionRecord>(
"SELECT record FROM clusterflux_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 clusterflux_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 clusterflux_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 clusterflux_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 clusterflux_project_permissions ORDER BY tenant_id, project_id, user_id",
)? {
state.project_permissions.insert(
(
record.tenant.clone(),
record.project.clone(),
record.user.clone(),
),
record,
);
}
if let Some(relay) = self
.query_records::<ArtifactRelayDurableState>(
"SELECT record FROM clusterflux_artifact_relay_state WHERE singleton = TRUE",
)?
.into_iter()
.next()
{
state.artifact_relay = relay;
}
Ok(state)
}
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
let mut tx = self.client.transaction()?;
tx.batch_execute(
"
DELETE FROM clusterflux_artifact_relay_state;
DELETE FROM clusterflux_project_permissions;
DELETE FROM clusterflux_service_policy_records;
DELETE FROM clusterflux_source_provider_configs;
DELETE FROM clusterflux_agent_public_keys;
DELETE FROM clusterflux_cli_sessions;
DELETE FROM clusterflux_credentials;
DELETE FROM clusterflux_node_identities;
DELETE FROM clusterflux_projects;
DELETE FROM clusterflux_users;
DELETE FROM clusterflux_tenants;
",
)?;
let relay = Self::record_value(&state.artifact_relay)?;
tx.execute(
"INSERT INTO clusterflux_artifact_relay_state (singleton, record) VALUES (TRUE, $1)",
&[&relay],
)?;
for record in state.tenants.values() {
let value = Self::record_value(record)?;
tx.execute(
"INSERT INTO clusterflux_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 clusterflux_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 clusterflux_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 clusterflux_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 clusterflux_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 clusterflux_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 clusterflux_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 clusterflux_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 clusterflux_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 clusterflux_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 clusterflux_artifact_relay_state (
singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS clusterflux_tenants (
tenant_id TEXT PRIMARY KEY,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS clusterflux_users (
user_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS clusterflux_projects (
project_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS clusterflux_node_identities (
node_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS clusterflux_credentials (
subject TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS clusterflux_cli_sessions (
session_digest TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES clusterflux_users(user_id) ON DELETE CASCADE,
record JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS clusterflux_agent_public_keys (
tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES clusterflux_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 clusterflux_source_provider_configs (
tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES clusterflux_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 clusterflux_service_policy_records (
tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE,
name TEXT NOT NULL,
record JSONB NOT NULL,
PRIMARY KEY (tenant_id, name)
);
CREATE TABLE IF NOT EXISTS clusterflux_project_permissions (
tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES clusterflux_users(user_id) ON DELETE CASCADE,
record JSONB NOT NULL,
PRIMARY KEY (tenant_id, project_id, user_id)
);
"#;
#[cfg(test)]
mod tests {
use clusterflux_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(), 11);
assert!(names.contains(&"clusterflux_artifact_relay_state"));
assert!(names.contains(&"clusterflux_tenants"));
assert!(names.contains(&"clusterflux_users"));
assert!(names.contains(&"clusterflux_projects"));
assert!(names.contains(&"clusterflux_node_identities"));
assert!(names.contains(&"clusterflux_credentials"));
assert!(names.contains(&"clusterflux_cli_sessions"));
assert!(names.contains(&"clusterflux_agent_public_keys"));
assert!(names.contains(&"clusterflux_source_provider_configs"));
assert!(names.contains(&"clusterflux_service_policy_records"));
assert!(names.contains(&"clusterflux_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"),
clusterflux_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("CLUSTERFLUX_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,571 @@
// 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 clusterflux_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 relay;
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, TaskAttemptSnapshot, TaskAttemptState, TaskCancellationTarget,
TaskCompletionEvent, TaskExecutor, TaskFailureResolution, TaskReplacementBundle,
TaskTerminalState, VirtualProcessStatus, WorkflowActor,
};
pub use quota::CoordinatorQuotaConfiguration;
pub use relay::{
ArtifactRelayConfiguration, ArtifactRelayDurableState, ArtifactRelayError, ArtifactRelayUsage,
};
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_TASK_EVENTS_TOTAL: usize = 8_192;
const MAX_DEBUG_AUDIT_EVENTS_TOTAL: usize = 8_192;
const MAX_RESTART_CHECKPOINTS_TOTAL: usize = 4_096;
const MAX_TASK_ATTEMPT_HISTORIES: usize = 4_096;
const MAX_IN_FLIGHT_TASKS_PER_PROCESS: usize = 256;
const MAX_NODE_REPORTED_OBJECTS_PER_KIND: usize = 1_024;
const DEFAULT_NODE_STALE_AFTER_SECONDS: u64 = 30;
fn bounded_ttl(requested: u64, maximum: u64) -> u64 {
requested.clamp(1, maximum)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CoordinatorMainRuntimeConfiguration {
pub fuel_units_per_second: u64,
pub fuel_burst_seconds: u64,
pub memory_bytes: usize,
pub nested_join_timeout_ms: u64,
pub max_active_mains: usize,
pub max_wakeups_per_minute: u64,
pub max_output_bytes: usize,
pub max_state_bytes: usize,
}
impl Default for CoordinatorMainRuntimeConfiguration {
fn default() -> Self {
Self {
fuel_units_per_second: 10_000_000,
fuel_burst_seconds: 60,
memory_bytes: 256 * 1024 * 1024,
nested_join_timeout_ms: 24 * 60 * 60 * 1_000,
max_active_mains: usize::MAX,
max_wakeups_per_minute: 6_000,
max_output_bytes: MAX_TASK_LOG_TAIL_BYTES,
max_state_bytes: clusterflux_core::MAX_WASM_TASK_ENVELOPE_BYTES,
}
}
}
#[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] clusterflux_core::DownloadError),
#[error("scheduler placement failed: {0}")]
Scheduler(#[from] clusterflux_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] clusterflux_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>,
node_last_seen_epoch_seconds: BTreeMap<NodeId, u64>,
node_stale_after_seconds: u64,
debug_freeze_timeout: std::time::Duration,
enrollment_grants: BTreeMap<EnrollmentGrantKey, clusterflux_core::EnrollmentGrant>,
task_events: VecDeque<TaskCompletionEvent>,
process_scope_history: VecDeque<ProcessControlKey>,
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>,
task_attempts: BTreeMap<TaskRestartKey, Vec<protocol::TaskAttemptSnapshot>>,
restart_launches: BTreeSet<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>,
artifact_relay: relay::ArtifactRelayLedger,
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 fn configure_artifact_relay(
&mut self,
configuration: ArtifactRelayConfiguration,
) -> Result<(), CoordinatorServiceError> {
let now_epoch_seconds = self.current_epoch_seconds()?;
let mut candidate = self.artifact_relay.clone();
candidate
.configure(configuration)
.map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?;
candidate.reconcile_after_restart(now_epoch_seconds);
self.commit_artifact_relay(candidate)
}
pub fn artifact_relay_usage(&self) -> ArtifactRelayUsage {
self.artifact_relay.usage()
}
fn commit_artifact_relay(
&mut self,
candidate: relay::ArtifactRelayLedger,
) -> Result<(), CoordinatorServiceError> {
let previous = self.artifact_relay.clone();
let previous_state = previous.durable_state();
self.artifact_relay = candidate;
self.coordinator
.set_artifact_relay_state(self.artifact_relay.durable_state());
if let Err(error) = self.persist_durable_state() {
self.artifact_relay = previous;
self.coordinator.set_artifact_relay_state(previous_state);
return Err(error);
}
Ok(())
}
fn mutate_artifact_relay<T>(
&mut self,
mutation: impl FnOnce(&mut relay::ArtifactRelayLedger) -> Result<T, ArtifactRelayError>,
) -> Result<T, CoordinatorServiceError> {
let mut candidate = self.artifact_relay.clone();
let result = mutation(&mut candidate)
.map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?;
self.commit_artifact_relay(candidate)?;
Ok(result)
}
pub fn set_debug_freeze_timeout(&mut self, timeout: std::time::Duration) {
self.debug_freeze_timeout = timeout.max(std::time::Duration::from_millis(1));
}
pub fn configure_coordinator_main_runtime(
&mut self,
configuration: CoordinatorMainRuntimeConfiguration,
) -> Result<(), CoordinatorServiceError> {
self.main_runtime.configure(configuration)
}
pub fn record_service_policy(
&mut self,
tenant: TenantId,
name: impl Into<String>,
digest: Digest,
) -> Result<crate::ServicePolicyRecord, CoordinatorServiceError> {
let name = name.into();
self.coordinator
.upsert_service_policy_record(tenant.clone(), name.clone(), digest);
self.persist_durable_state()?;
Ok(self
.coordinator
.service_policy_record(&tenant, &name)
.expect("service policy record was persisted immediately after insertion")
.clone())
}
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("CLUSTERFLUX_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("CLUSTERFLUX_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("CLUSTERFLUX_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 artifact_relay_state = coordinator.artifact_relay_state().clone();
let admin_token_digest = admin_token
.filter(|token| !token.trim().is_empty())
.map(Digest::sha256);
Ok(Self {
coordinator,
store,
node_descriptors: BTreeMap::new(),
node_last_seen_epoch_seconds: BTreeMap::new(),
node_stale_after_seconds: std::env::var("CLUSTERFLUX_NODE_STALE_AFTER_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_NODE_STALE_AFTER_SECONDS),
debug_freeze_timeout: std::time::Duration::from_secs(5),
enrollment_grants: BTreeMap::new(),
task_events: VecDeque::new(),
process_scope_history: 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(),
task_attempts: BTreeMap::new(),
restart_launches: BTreeSet::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(),
artifact_relay: relay::ArtifactRelayLedger::from_durable(
ArtifactRelayConfiguration::default(),
artifact_relay_state,
),
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 authenticate_cli_session_context(
&self,
session_secret: &str,
) -> Result<clusterflux_core::AuthContext, CoordinatorServiceError> {
Ok(self.coordinator.authenticate_cli_session(session_secret)?)
}
pub fn authenticate_cli_session_status_context(
&self,
session_secret: &str,
) -> Result<clusterflux_core::AuthContext, CoordinatorServiceError> {
Ok(self
.coordinator
.authenticate_cli_session_for_status(session_secret)?)
}
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 clusterflux_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,799 @@
use std::io::{Read, Seek, SeekFrom, Write};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use clusterflux_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::relay::RelayFinishReason;
use super::{
bounded_ttl, ArtifactTransferAssignment, CoordinatorResponse, CoordinatorService,
CoordinatorServiceError,
};
pub(super) const MAX_ARTIFACT_REVERSE_CHUNK_BYTES: u64 = 256 * 1024;
#[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.mutate_artifact_relay(|ledger| {
ledger.expire(now_epoch_seconds);
Ok(())
})?;
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 expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds);
let account = match &context.actor {
Actor::User(user) => user.clone(),
Actor::Agent(_) | Actor::Node(_) | Actor::Task(_) => {
return Err(CoordinatorServiceError::Protocol(
"artifact relay download requires a user account".to_owned(),
))
}
};
let mut relay_candidate = self.artifact_relay.clone();
relay_candidate
.reserve(
token_nonce.clone(),
context.tenant.clone(),
context.project.clone(),
account,
downloadable_size,
MAX_ARTIFACT_REVERSE_CHUNK_BYTES,
expires_at_epoch_seconds,
now_epoch_seconds,
)
.map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?;
let link = match self.artifact_registry.create_download_link(
&context,
&artifact,
&policy,
&token_nonce,
now_epoch_seconds,
ttl_seconds,
) {
Ok(link) => link,
Err(error) => return Err(error.into()),
};
if let Err(error) =
relay_candidate.rekey(&token_nonce, link.scoped_token_digest.as_str().to_owned())
{
let _ = self.artifact_registry.revoke_download_link(
&context,
&artifact,
&link.scoped_token_digest,
);
return Err(CoordinatorServiceError::Protocol(error.to_string()));
}
if let Err(error) = self.commit_artifact_relay(relay_candidate) {
let _ = self.artifact_registry.revoke_download_link(
&context,
&artifact,
&link.scoped_token_digest,
);
return Err(error);
}
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.mutate_artifact_relay(|ledger| {
ledger.expire(now_epoch_seconds);
Ok(())
})?;
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(
clusterflux_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);
self.mutate_artifact_relay(|ledger| {
ledger.finish(token_digest.as_str(), RelayFinishReason::Failed);
Ok(())
})?;
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(clusterflux_core::DownloadError::InvalidToken.into());
}
if transfer.received_bytes != transfer.expected_size_bytes {
let overhead = self.artifact_relay.framing_overhead_bytes();
self.mutate_artifact_relay(|ledger| {
ledger.charge_egress(token_digest.as_str(), overhead, now_epoch_seconds)
})?;
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,
)?;
let content_base64 = BASE64_STANDARD.encode(content);
let egress_wire_bytes = (content_base64.len() as u64)
.saturating_add(self.artifact_relay.framing_overhead_bytes());
self.mutate_artifact_relay(|ledger| {
ledger.charge_egress(
token_digest.as_str(),
egress_wire_bytes,
now_epoch_seconds,
)?;
if complete {
ledger.finish(token_digest.as_str(), RelayFinishReason::Completed);
}
Ok(())
})?;
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(content_base64),
content_source: Some("retaining_node_reverse_stream".to_owned()),
});
}
let StorageLocation::RetainedNode(source_node) = &stream.link.source else {
return Err(clusterflux_core::DownloadError::Unavailable.into());
};
let metadata = self
.artifact_registry
.metadata(&artifact)
.ok_or(clusterflux_core::DownloadError::NotFound)?;
let transfer_id = generate_opaque_token("artifact_transfer")
.map_err(CoordinatorServiceError::Protocol)?;
let spool = tempfile::Builder::new()
.prefix("clusterflux-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,
};
let overhead = self.artifact_relay.framing_overhead_bytes();
self.mutate_artifact_relay(|ledger| {
ledger.charge_egress(
stream.link.scoped_token_digest.as_str(),
overhead,
now_epoch_seconds,
)
})?;
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);
}
self.mutate_artifact_relay(|ledger| {
ledger.finish(token_digest.as_str(), RelayFinishReason::Cancelled);
Ok(())
})?;
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(clusterflux_core::DownloadError::Unavailable.into());
};
let metadata = self
.artifact_registry
.metadata(&artifact)
.ok_or(clusterflux_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 token_digest = {
let transfer = self
.artifact_reverse_transfers
.get(&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());
}
transfer.token_digest.clone()
};
let now_epoch_seconds = self.current_epoch_seconds()?;
let ingress_wire_bytes = (content_base64.len() as u64)
.saturating_add(self.artifact_relay.framing_overhead_bytes());
self.mutate_artifact_relay(|ledger| {
ledger.charge_ingress(token_digest.as_str(), ingress_wire_bytes, now_epoch_seconds)
})?;
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 token_digest = {
let transfer = self
.artifact_reverse_transfers
.get(&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());
}
transfer.token_digest.clone()
};
let now_epoch_seconds = self.current_epoch_seconds()?;
let failure_wire_bytes =
(message.len() as u64).saturating_add(self.artifact_relay.framing_overhead_bytes());
self.mutate_artifact_relay(|ledger| {
ledger.charge_ingress(token_digest.as_str(), failure_wire_bytes, now_epoch_seconds)
})?;
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()
});
self.mutate_artifact_relay(|ledger| {
ledger.finish(token_digest.as_str(), RelayFinishReason::Failed);
Ok(())
})?;
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,
) -> Result<(), CoordinatorServiceError> {
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);
}
self.mutate_artifact_relay(|ledger| {
for (_, token) in &expired {
ledger.finish(token.as_str(), RelayFinishReason::Expired);
}
ledger.expire(now_epoch_seconds);
Ok(())
})
}
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(|| {
clusterflux_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 !self.node_is_live(node) {
return Err(
clusterflux_core::DownloadError::DirectConnectivityUnavailable(format!(
"node {node} is offline for artifact export"
))
.into(),
);
}
if !descriptor.direct_connectivity {
return Err(
clusterflux_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<(), clusterflux_core::DownloadError> {
let StorageLocation::RetainedNode(node) = source else {
return Ok(());
};
let _descriptor = self.node_descriptors.get(node).ok_or_else(|| {
clusterflux_core::DownloadError::DirectConnectivityUnavailable(format!(
"retaining node {node} has not reported online status for artifact download"
))
})?;
if !self.node_is_live(node) {
return Err(
clusterflux_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 clusterflux_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,204 @@
use clusterflux_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,
ResolveTaskFailure,
DebugAttach,
SetDebugBreakpoints,
InspectDebugBreakpoints,
CreateDebugEpoch,
ResumeDebugEpoch,
InspectDebugEpoch,
ListTaskEvents,
ListTaskSnapshots,
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::ResolveTaskFailure => "resolve_task_failure",
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::ListTaskSnapshots => "list_task_snapshots",
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::ResolveTaskFailure { .. } => Self::ResolveTaskFailure,
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::ListTaskSnapshots { .. } => Self::ListTaskSnapshots,
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 clusterflux_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);
}
}

File diff suppressed because it is too large Load diff

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,594 @@
use std::collections::BTreeSet;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use clusterflux_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::protocol::{TaskAttemptState, TaskFailureResolution};
use super::{
AuthenticatedCoordinatorRequest, CoordinatorRequest, CoordinatorResponse, CoordinatorService,
TaskReplacementBundle, TaskTerminalState, 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 = clusterflux_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 restarted_attempt_id = 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;
assignment.task = task.clone();
assignment.task_spec.task_instance = task.clone();
let next_attempt = self
.task_attempts
.get(&checkpoint_key)
.map_or(1, |attempts| attempts.len() + 1);
assignment.artifact_path = format!(
"/vfs/artifacts/{}-attempt-{next_attempt}-result.json",
task.as_str()
);
if let Some(replacement) = replacement {
if assignment.task_spec.source_snapshot.is_some() {
let replacement_source = replacement_bundle
.as_ref()
.and_then(|bundle| bundle.source_snapshot.clone())
.ok_or_else(|| {
CoordinatorServiceError::Protocol(
"replacement task omitted the current SourceSnapshot for source-bound arguments"
.to_owned(),
)
})?;
assignment
.task_spec
.rebind_source_snapshot(replacement_source)
.map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"replacement task SourceSnapshot is invalid: {error}"
))
})?;
}
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 restart_task_spec = assignment.task_spec.clone();
let restart_artifact_path = assignment.artifact_path.clone();
self.restart_launches.insert(checkpoint_key.clone());
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,
);
self.restart_launches.remove(&checkpoint_key);
let launch = launch?;
let queued = matches!(launch, CoordinatorResponse::TaskQueued { .. });
accepted = matches!(
&launch,
CoordinatorResponse::TaskLaunched { .. }
| CoordinatorResponse::TaskQueued { .. }
);
if accepted {
restarted_task_instance = Some(task.clone());
restarted_attempt_id = self
.task_attempts
.get(&checkpoint_key)
.and_then(|attempts| attempts.last())
.map(|attempt| attempt.attempt_id.clone());
if restarted_attempt_id.is_none() {
restarted_attempt_id = Some(self.begin_task_attempt(
&restart_task_spec,
None,
Some(&restart_artifact_path),
queued,
)?);
}
}
requires_whole_process_restart = !accepted;
format!(
"selected logical task {task} restarted as a new attempt 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,
restarted_attempt_id,
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,
})
}
pub(super) fn handle_resolve_task_failure(
&mut self,
tenant: String,
project: String,
actor_user: String,
process: String,
task: String,
resolution: TaskFailureResolution,
) -> 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 = clusterflux_core::AuthContext {
tenant: tenant.clone(),
project: project.clone(),
actor: Actor::User(actor),
};
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
if !authorization.allowed {
return Err(CoordinatorError::Unauthorized(format!(
"task failure resolution denied: {}",
authorization.reason
))
.into());
}
let key = task_restart_key(&tenant, &project, &process, &task);
let attempt = self
.task_attempts
.get_mut(&key)
.and_then(|attempts| attempts.iter_mut().rev().find(|attempt| attempt.current))
.filter(|attempt| attempt.state == TaskAttemptState::FailedAwaitingAction)
.ok_or_else(|| {
CoordinatorServiceError::Protocol(
"task is not failed awaiting operator action".to_owned(),
)
})?;
attempt.state = match resolution {
TaskFailureResolution::AcceptFailure => TaskAttemptState::Failed,
TaskFailureResolution::Cancel => TaskAttemptState::Cancelled,
};
attempt.command_state = Some(
match resolution {
TaskFailureResolution::AcceptFailure => "failure_accepted",
TaskFailureResolution::Cancel => "cancelled",
}
.to_owned(),
);
let attempt_id = attempt.attempt_id.clone();
let mut event = self
.task_events
.iter()
.rev()
.find(|event| {
event.tenant == tenant
&& event.project == project
&& event.process == process
&& event.task == task
&& event.attempt_id.as_deref() == Some(attempt_id.as_str())
})
.cloned()
.ok_or_else(|| {
CoordinatorServiceError::Protocol(
"failed attempt terminal event is unavailable".to_owned(),
)
})?;
if resolution == TaskFailureResolution::Cancel {
event.terminal_state = TaskTerminalState::Cancelled;
event.stderr_tail = "operator cancelled task after failure".to_owned();
}
self.record_task_completion_event(event.clone());
self.notify_coordinator_main_waiters(&event);
Ok(CoordinatorResponse::TaskFailureResolved {
process,
task,
attempt_id,
resolution,
})
}
}
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: &clusterflux_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(clusterflux_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("clusterflux.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,73 @@
use clusterflux_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,669 @@
use clusterflux_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, task_restart_key};
use super::protocol::TaskAttemptState;
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,
attempt_id: None,
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);
let no_active_tasks =
!self
.active_tasks
.iter()
.any(|(task_tenant, task_project, task_process, _, _)| {
task_tenant == &event.tenant
&& task_project == &event.project
&& task_process == &event.process
});
let completed_after_main = matches!(event.terminal_state, TaskTerminalState::Completed)
&& self.task_events.iter().rev().any(|retained| {
retained.tenant == event.tenant
&& retained.project == event.project
&& retained.process == event.process
&& matches!(retained.executor, super::TaskExecutor::CoordinatorMain)
&& matches!(retained.terminal_state, TaskTerminalState::Completed)
});
if no_active_tasks {
self.process_aborts.remove(&process_key);
if (self.process_cancellations.remove(&process_key) || completed_after_main)
&& !self.main_runtime.controls.contains_key(&process_key)
{
self.coordinator
.abort_process(&event.tenant, &event.project, &event.process)?;
self.clear_debug_state_for_process(&event.tenant, &event.project, &event.process);
self.clear_operator_panel_state(&event.tenant, &event.project, &event.process);
}
}
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);
}
let awaiting_operator = self.finish_task_attempt(&mut event);
self.record_task_completion_event(event.clone());
if !awaiting_operator {
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 })
}
pub(super) fn handle_list_task_snapshots(
&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);
self.authorize_task_event_process_scope(&tenant, &project, &process)?;
let snapshots = self
.task_attempts
.iter()
.filter(
|((attempt_tenant, attempt_project, attempt_process, _), _)| {
attempt_tenant == &tenant
&& attempt_project == &project
&& attempt_process == &process
},
)
.flat_map(|(_, attempts)| attempts.iter().cloned())
.collect();
Ok(CoordinatorResponse::TaskSnapshots { snapshots })
}
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
}) || self.process_scope_history.iter().any(
|(historical_tenant, historical_project, historical_process)| {
historical_tenant == tenant
&& historical_project == project
&& historical_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)
})
|| self.process_scope_history.iter().any(
|(historical_tenant, historical_project, historical_process)| {
historical_process == process
&& (historical_tenant != tenant || historical_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 attempt_key = task_restart_key(&tenant, &project, &process, &task);
if self
.task_attempts
.get(&attempt_key)
.is_some_and(|attempts| {
attempts
.iter()
.rev()
.find(|attempt| attempt.current)
.is_some_and(|attempt| {
matches!(
attempt.state,
TaskAttemptState::Queued
| TaskAttemptState::Running
| TaskAttemptState::FailedAwaitingAction
)
})
})
{
return TaskJoinResult::pending(
process,
task,
"logical task is still running or awaiting operator action",
);
}
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);
let process_scope = (
event.tenant.clone(),
event.project.clone(),
event.process.clone(),
);
self.process_scope_history
.retain(|retained| retained != &process_scope);
while self.process_scope_history.len() >= super::MAX_TASK_EVENTS_TOTAL {
self.process_scope_history.pop_front();
}
self.process_scope_history.push_back(process_scope);
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);
}
while self.task_events.len() >= super::MAX_TASK_EVENTS_TOTAL {
self.task_events.pop_front();
}
self.task_events.push_back(event);
}
fn finish_task_attempt(&mut self, event: &mut TaskCompletionEvent) -> bool {
let key = task_restart_key(&event.tenant, &event.project, &event.process, &event.task);
let Some(attempt) = self
.task_attempts
.get_mut(&key)
.and_then(|attempts| attempts.iter_mut().rev().find(|attempt| attempt.current))
else {
return false;
};
event.attempt_id = Some(attempt.attempt_id.clone());
attempt.status_code = event.status_code;
attempt.artifact_path = event.artifact_path.clone();
attempt.artifact_digest = event.artifact_digest.clone();
attempt.artifact_size_bytes = event.artifact_size_bytes;
attempt.error = (!event.stderr_tail.trim().is_empty()).then(|| event.stderr_tail.clone());
let awaiting_operator = event.terminal_state == TaskTerminalState::Failed
&& attempt.failure_policy == clusterflux_core::TaskFailurePolicy::AwaitOperator;
attempt.state = if awaiting_operator {
TaskAttemptState::FailedAwaitingAction
} else {
match event.terminal_state {
TaskTerminalState::Completed => TaskAttemptState::Completed,
TaskTerminalState::Failed => TaskAttemptState::Failed,
TaskTerminalState::Cancelled => TaskAttemptState::Cancelled,
}
};
attempt.command_state = Some(if awaiting_operator {
"failed_awaiting_action".to_owned()
} else {
format!("{:?}", event.terminal_state).to_ascii_lowercase()
});
awaiting_operator
}
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(),
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,452 @@
use std::collections::BTreeSet;
use std::time::{SystemTime, UNIX_EPOCH};
use clusterflux_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 fn set_node_stale_after_seconds(&mut self, seconds: u64) {
self.node_stale_after_seconds = seconds.max(1);
}
pub(super) fn liveness_now_epoch_seconds(&self) -> u64 {
#[cfg(test)]
if let Some(now) = self.server_time_override {
return now;
}
unix_timestamp_seconds()
}
pub(super) fn node_is_live(&self, node: &NodeId) -> bool {
self.node_last_seen_epoch_seconds
.get(node)
.is_some_and(|last_seen| {
self.liveness_now_epoch_seconds().saturating_sub(*last_seen)
<= self.node_stale_after_seconds
})
}
pub(super) fn live_node_descriptors(&self) -> Vec<NodeDescriptor> {
self.node_descriptors
.values()
.cloned()
.map(|mut descriptor| {
descriptor.online = self.node_is_live(&descriptor.id);
descriptor
})
.collect()
}
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)?;
if self.coordinator.node_identity(&node).is_none() {
self.quota.ensure_node_admission(
&tenant,
self.coordinator.node_identity_count_for_tenant(&tenant),
)?;
}
let grant_key = enrollment_grant_key(&tenant, &project, &enrollment_grant);
let grant =
self.enrollment_grants
.get_mut(&grant_key)
.ok_or(CoordinatorError::Enrollment(
clusterflux_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);
let online = self.node_is_live(&node);
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
.live_node_descriptors()
.into_iter()
.filter(|descriptor| descriptor.tenant == tenant && descriptor.project == project)
.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 = clusterflux_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.node_last_seen_epoch_seconds.remove(&node);
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);
let seen_at = self.liveness_now_epoch_seconds();
self.node_last_seen_epoch_seconds
.insert(node.clone(), seen_at);
if let Some(descriptor) = self.node_descriptors.get_mut(node) {
descriptor.online = true;
}
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 clusterflux_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![
clusterflux_core::ControlPlaneAction::DebugProcess,
clusterflux_core::ControlPlaneAction::CancelProcess,
];
if let Some(task) = last_task.clone() {
actions.push(clusterflux_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 = clusterflux_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,804 @@
use std::collections::BTreeMap;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use clusterflux_core::{
AgentSignedRequest, ArtifactId, CheckpointBoundary, CredentialKind, DefaultScheduler, Digest,
NodeDescriptor, NodeId, Placement, PlacementError, PlacementRequest, ProcessId, ProjectId,
Scheduler, TaskBoundaryValue, 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::*;
use super::protocol::{TaskAttemptSnapshot, TaskAttemptState};
fn select_task_placement(
candidates: &[Placement],
active_by_node: &BTreeMap<NodeId, usize>,
) -> Option<Placement> {
candidates
.iter()
.min_by(|left, right| {
right
.score
.cmp(&left.score)
.then_with(|| {
active_by_node
.get(&left.node)
.copied()
.unwrap_or_default()
.cmp(&active_by_node.get(&right.node).copied().unwrap_or_default())
})
.then_with(|| left.node.cmp(&right.node))
})
.cloned()
}
impl CoordinatorService {
fn place_workflow_task(
&self,
nodes: &[NodeDescriptor],
request: &PlacementRequest,
) -> Result<Placement, PlacementError> {
let candidates = nodes
.iter()
.filter_map(|node| {
DefaultScheduler
.place(std::slice::from_ref(node), request)
.ok()
})
.collect::<Vec<_>>();
if candidates.is_empty() {
return DefaultScheduler.place(nodes, request);
}
let active_by_node = self
.active_tasks
.iter()
.filter(|(tenant, project, _, _, _)| {
tenant == &request.tenant && project == &request.project
})
.fold(BTreeMap::<NodeId, usize>::new(), |mut counts, key| {
*counts.entry(key.3.clone()).or_default() += 1;
counts
});
let mut selected = select_task_placement(&candidates, &active_by_node)
.expect("one or more compatible task-placement candidates should remain");
let selected_load = active_by_node
.get(&selected.node)
.copied()
.unwrap_or_default();
if candidates.iter().any(|candidate| {
candidate.score == selected.score
&& active_by_node
.get(&candidate.node)
.copied()
.unwrap_or_default()
> selected_load
}) {
selected.reasons.push(format!(
"least active equal-locality node ({selected_load} active assignment(s))"
));
}
Ok(selected)
}
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("clusterflux.environment.unconstrained.v1"),
|environment| {
Digest::sha256(
serde_json::to_vec(environment)
.expect("serializable environment requirements"),
)
},
)
});
let task_entrypoint = match &task_spec.dispatch {
clusterflux_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);
}
}
while self.task_restart_checkpoint_order.len() > super::MAX_RESTART_CHECKPOINTS_TOTAL {
if let Some(expired) = self.task_restart_checkpoint_order.pop_front() {
self.task_restart_checkpoints.remove(&expired);
}
}
Ok(())
}
pub(super) fn handle_schedule_task(
&mut self,
tenant: String,
project: String,
environment: Option<clusterflux_core::EnvironmentRequirements>,
environment_digest: Option<Digest>,
required_capabilities: Vec<clusterflux_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,
environment_cache_required: false,
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.live_node_descriptors();
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> {
task_spec
.validate_boundary_authority()
.map_err(CoordinatorServiceError::Protocol)?;
if matches!(
&task_spec.dispatch,
TaskDispatch::CoordinatorNodeWasm {
abi: clusterflux_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,
);
}
Err(CoordinatorError::Unauthorized(
"external callers may launch only EntrypointV1; TaskV1 requires an authenticated live parent runtime or validated restart"
.to_owned(),
)
.into())
}
#[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());
}
if !matches!(
task_spec.dispatch,
TaskDispatch::CoordinatorNodeWasm {
abi: clusterflux_core::WasmExportAbi::TaskV1,
..
}
) {
return Err(CoordinatorError::Unauthorized(
"child task launch requires the TaskV1 ABI".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> {
task_spec
.validate_boundary_authority()
.map_err(CoordinatorServiceError::Protocol)?;
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)
.cloned()
.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(),
environment_cache_required: task_spec.environment_id.is_some()
&& task_spec.environment.is_none(),
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.live_node_descriptors();
let placement = match self.place_workflow_task(&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.begin_task_attempt(&task_spec, None, Some(&artifact_path), true)?;
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.begin_task_attempt(
&assignment.task_spec,
Some(assignment.node.clone()),
Some(&assignment.artifact_path),
false,
)?;
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: &clusterflux_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.restart_launches.contains(&task_restart_key(
tenant,
project,
process,
task_instance,
)) && self.task_events.iter().any(|event| {
&event.tenant == tenant
&& &event.project == project
&& &event.process == process
&& &event.task == task_instance
}))
}
pub(super) fn begin_task_attempt(
&mut self,
task_spec: &TaskSpec,
node: Option<NodeId>,
artifact_path: Option<&str>,
queued: bool,
) -> Result<String, CoordinatorServiceError> {
let key = task_restart_key(
&task_spec.tenant,
&task_spec.project,
&task_spec.process,
&task_spec.task_instance,
);
while !self.task_attempts.contains_key(&key)
&& self.task_attempts.len() >= super::MAX_TASK_ATTEMPT_HISTORIES
{
let removable = self.task_attempts.iter().find_map(|(candidate, attempts)| {
attempts
.iter()
.all(|attempt| {
!matches!(
&attempt.state,
TaskAttemptState::Queued
| TaskAttemptState::Running
| TaskAttemptState::FailedAwaitingAction
)
})
.then(|| candidate.clone())
});
let Some(removable) = removable else {
return Err(CoordinatorServiceError::Protocol(
"task attempt history capacity is exhausted by active attempts".to_owned(),
));
};
self.task_attempts.remove(&removable);
}
let attempts = self.task_attempts.entry(key).or_default();
for attempt in attempts.iter_mut() {
attempt.current = false;
}
let attempt_id = clusterflux_core::generate_opaque_token("ta")
.map_err(CoordinatorServiceError::Protocol)?;
let attempt_number = u32::try_from(attempts.len() + 1).unwrap_or(u32::MAX);
let mut argument_summary = task_spec
.args
.iter()
.map(|argument| {
let mut value = serde_json::to_string(argument)
.unwrap_or_else(|_| "<invalid canonical argument>".to_owned());
value.truncate(value.len().min(1024));
value
})
.collect::<Vec<_>>();
argument_summary.truncate(64);
let mut handle_summary = task_spec
.required_artifacts
.iter()
.map(|artifact| format!("artifact:{artifact}"))
.collect::<Vec<_>>();
for argument in &task_spec.args {
if let TaskBoundaryValue::Structured(boundary) = argument {
handle_summary.extend(boundary.handles.iter().map(|handle| format!("{handle:?}")));
}
}
handle_summary.truncate(256);
attempts.push(TaskAttemptSnapshot {
process: task_spec.process.clone(),
task: task_spec.task_instance.clone(),
attempt_id: attempt_id.clone(),
attempt_number,
task_definition: task_spec.task_definition.clone(),
display_name: task_spec.task_definition.as_str().replace(['_', '-'], " "),
state: if queued {
TaskAttemptState::Queued
} else {
TaskAttemptState::Running
},
current: true,
node,
environment_id: task_spec.environment_id.clone(),
environment_digest: task_spec.environment_digest.clone(),
argument_summary,
handle_summary,
command_state: Some(if queued { "queued" } else { "running" }.to_owned()),
vfs_checkpoint: format!("vfs-epoch:{}", task_spec.vfs_epoch),
probe_symbol: None,
source_path: None,
source_line: None,
restart_compatible: true,
failure_policy: task_spec.failure_policy,
artifact_path: artifact_path.and_then(|path| VfsPath::new(path).ok()),
artifact_digest: None,
artifact_size_bytes: None,
status_code: None,
error: None,
});
if attempts.len() > 128 {
attempts.remove(0);
}
Ok(attempt_id)
}
pub(super) fn assign_task_attempt(&mut self, task_spec: &TaskSpec, node: NodeId) {
let key = task_restart_key(
&task_spec.tenant,
&task_spec.project,
&task_spec.process,
&task_spec.task_instance,
);
if let Some(attempt) = self
.task_attempts
.get_mut(&key)
.and_then(|attempts| attempts.iter_mut().rev().find(|attempt| attempt.current))
{
attempt.node = Some(node);
attempt.state = TaskAttemptState::Running;
attempt.command_state = Some("running".to_owned());
}
}
}
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())
}
#[cfg(test)]
mod placement_tests {
use super::*;
fn placement(node: &str, score: i64) -> Placement {
Placement {
node: NodeId::from(node),
score,
reasons: Vec::new(),
}
}
#[test]
fn equal_locality_prefers_the_least_active_node() {
let candidates = vec![placement("busy", 40), placement("idle", 40)];
let active = BTreeMap::from([(NodeId::from("busy"), 1)]);
assert_eq!(
select_task_placement(&candidates, &active)
.expect("one placement")
.node,
NodeId::from("idle")
);
}
#[test]
fn locality_score_remains_stronger_than_load_balancing() {
let candidates = vec![placement("warm", 50), placement("cold", 40)];
let active = BTreeMap::from([(NodeId::from("warm"), 2)]);
assert_eq!(
select_task_placement(&candidates, &active)
.expect("one placement")
.node,
NodeId::from("warm")
);
}
}

View file

@ -0,0 +1,845 @@
use std::collections::{BTreeSet, VecDeque};
use std::time::{SystemTime, UNIX_EPOCH};
use clusterflux_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.assign_task_attempt(&assignment.task_spec, assignment.node.clone());
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: clusterflux_core::DataPlaneScope,
source: clusterflux_core::NodeEndpoint,
destination: clusterflux_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: clusterflux_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,
environment_cache_required: false,
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.live_node_descriptors();
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: clusterflux_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,
launch_attempt: Option<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.quota.ensure_process_admission(
&tenant,
self.coordinator.active_process_count_for_tenant(&tenant),
)?;
}
let now_epoch_seconds = self.current_epoch_seconds()?;
let charged_spawns =
self.quota
.charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?;
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));
}
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.task_attempts
.retain(|(attempt_tenant, attempt_project, attempt_process, _), _| {
attempt_tenant != &tenant
|| attempt_project != &project
|| attempt_process != &process
});
self.restart_launches
.retain(|(attempt_tenant, attempt_project, attempt_process, _)| {
attempt_tenant != &tenant
|| attempt_project != &project
|| attempt_process != &process
});
self.debug_audit_events.retain(|event| {
event.tenant != tenant || event.project != project || event.process != process
});
let active = self.coordinator.start_process_for_launch_attempt(
tenant,
project,
process.clone(),
launch_attempt.map(clusterflux_core::LaunchAttemptId::new),
);
Ok(CoordinatorResponse::ProcessStarted {
process,
launch_attempt: active
.launch_attempt
.as_ref()
.map(|attempt| attempt.as_str().to_owned()),
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(),
});
}
}
let process_key = process_control_key(&tenant, &project, &process);
if cancelled_tasks.is_empty() && !self.main_runtime.controls.contains_key(&process_key) {
self.coordinator
.abort_process(&tenant, &project, &process)?;
self.clear_operator_panel_state(&tenant, &project, &process);
}
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,
launch_attempt: Option<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 launch_attempt = launch_attempt.map(clusterflux_core::LaunchAttemptId::new);
if let Some(expected) = launch_attempt.as_ref() {
if active.launch_attempt.as_ref() != Some(expected) {
return Err(CoordinatorError::Unauthorized(format!(
"launch rollback denied: attempt {} does not own process {}",
expected.as_str(),
process.as_str()
))
.into());
}
}
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(),
});
}
}
if let Some(launch_attempt) = launch_attempt.as_ref() {
self.coordinator.abort_process_for_launch_attempt(
&tenant,
&project,
&process,
launch_attempt,
)?;
} else {
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 canonical_scope = clusterflux_core::AgentWorkflowRequestScope::new(
tenant.clone(),
project.clone(),
request_kind,
process.clone(),
task.cloned(),
)
.map_err(CoordinatorError::Unauthorized)?;
let record = self.coordinator.authorize_agent_project_run(
canonical_scope.for_agent(&agent),
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,802 @@
use std::collections::BTreeMap;
use clusterflux_core::{
AgentId, AgentSignedRequest, ArtifactId, Authorization, Capability, CredentialKind,
DataPlaneScope, Digest, DirectBulkTransferPlan, DownloadLink, EnvironmentRequirements,
LaunchAttemptId, LimitKind, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId,
NodeSignedRequest, PanelEventKind, PanelState, Placement, ProcessId, ProjectId, RequestId,
ResourceLimits, SourcePreparation, SourceProviderKind, TaskBoundaryValue, TaskDefinitionId,
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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_snapshot: Option<Digest>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskFailureResolution {
AcceptFailure,
Cancel,
}
#[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, skip_serializing_if = "Option::is_none")]
launch_attempt: Option<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,
#[serde(default, skip_serializing_if = "Option::is_none")]
launch_attempt: Option<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>,
},
ResolveTaskFailure {
tenant: String,
project: String,
actor_user: String,
process: String,
task: String,
resolution: TaskFailureResolution,
},
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>,
},
ListTaskSnapshots {
tenant: String,
project: String,
actor_user: String,
process: 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 validate_external_identifiers(&self) -> Result<(), String> {
let value = serde_json::to_value(self).map_err(|error| {
format!("failed to validate coordinator request identifiers: {error}")
})?;
validate_identifier_tree(&value, "request")
}
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| clusterflux_core::coordinator_payload_operation(&value))
}
}
#[derive(Clone, Copy)]
enum ExternalIdentifierKind {
Agent,
Artifact,
LaunchAttempt,
Node,
Process,
Project,
Request,
TaskDefinition,
TaskInstance,
Tenant,
User,
}
fn identifier_kind(field: &str) -> Option<ExternalIdentifierKind> {
match field {
"tenant" | "target_tenant" => Some(ExternalIdentifierKind::Tenant),
"project" => Some(ExternalIdentifierKind::Project),
"actor_user" | "user" => Some(ExternalIdentifierKind::User),
"agent" | "actor_agent" => Some(ExternalIdentifierKind::Agent),
"node" | "prefer_node" | "receiver_node" => Some(ExternalIdentifierKind::Node),
"process" => Some(ExternalIdentifierKind::Process),
"task" | "parent_task" | "stopped_task" | "task_instance" => {
Some(ExternalIdentifierKind::TaskInstance)
}
"task_definition" => Some(ExternalIdentifierKind::TaskDefinition),
"artifact" | "required_artifacts" | "artifact_locations" => {
Some(ExternalIdentifierKind::Artifact)
}
"launch_attempt" => Some(ExternalIdentifierKind::LaunchAttempt),
"request_id" | "session_secret" | "transaction_id" | "polling_secret" | "transfer_id"
| "enrollment_grant" | "widget_id" | "environment_id" | "admin_nonce" => {
Some(ExternalIdentifierKind::Request)
}
_ => None,
}
}
fn validate_identifier_tree(value: &serde_json::Value, path: &str) -> Result<(), String> {
match value {
serde_json::Value::Object(fields) => {
for (field, value) in fields {
let field_path = format!("{path}.{field}");
if let Some(kind) = identifier_kind(field) {
validate_identifier_value(kind, value, &field_path)?;
}
validate_identifier_tree(value, &field_path)?;
}
}
serde_json::Value::Array(values) => {
for (index, value) in values.iter().enumerate() {
validate_identifier_tree(value, &format!("{path}[{index}]"))?;
}
}
_ => {}
}
Ok(())
}
fn validate_identifier_value(
kind: ExternalIdentifierKind,
value: &serde_json::Value,
path: &str,
) -> Result<(), String> {
if value.is_null() {
return Ok(());
}
if let Some(values) = value.as_array() {
for (index, value) in values.iter().enumerate() {
validate_identifier_value(kind, value, &format!("{path}[{index}]"))?;
}
return Ok(());
}
let value = value
.as_str()
.ok_or_else(|| format!("external identifier {path} must be a string"))?
.to_owned();
let result = match kind {
ExternalIdentifierKind::Agent => AgentId::try_new(value).map(|_| ()),
ExternalIdentifierKind::Artifact => ArtifactId::try_new(value).map(|_| ()),
ExternalIdentifierKind::LaunchAttempt => LaunchAttemptId::try_new(value).map(|_| ()),
ExternalIdentifierKind::Node => NodeId::try_new(value).map(|_| ()),
ExternalIdentifierKind::Process => ProcessId::try_new(value).map(|_| ()),
ExternalIdentifierKind::Project => ProjectId::try_new(value).map(|_| ()),
ExternalIdentifierKind::Request => RequestId::try_new(value).map(|_| ()),
ExternalIdentifierKind::TaskDefinition => TaskDefinitionId::try_new(value).map(|_| ()),
ExternalIdentifierKind::TaskInstance => TaskInstanceId::try_new(value).map(|_| ()),
ExternalIdentifierKind::Tenant => TenantId::try_new(value).map(|_| ()),
ExternalIdentifierKind::User => UserId::try_new(value).map(|_| ()),
};
result.map_err(|error| format!("malformed external identifier {path}: {error}"))
}
#[cfg(test)]
mod external_identifier_tests {
use super::*;
#[test]
fn nested_authenticated_identifiers_are_validated() {
let request = CoordinatorRequest::Authenticated {
session_secret: "cli_session_valid".to_owned(),
request: AuthenticatedCoordinatorRequest::AbortProcess {
process: "bad process".to_owned(),
launch_attempt: Some("attempt".to_owned()),
},
};
let error = request.validate_external_identifiers().unwrap_err();
assert!(error.contains("request.request.process"));
}
}
#[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, skip_serializing_if = "Option::is_none")]
launch_attempt: Option<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,
#[serde(default, skip_serializing_if = "Option::is_none")]
launch_attempt: Option<String>,
},
ListProcesses,
QuotaStatus,
RestartTask {
process: String,
task: String,
#[serde(default)]
replacement_bundle: Option<TaskReplacementBundle>,
},
ResolveTaskFailure {
process: String,
task: String,
resolution: TaskFailureResolution,
},
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>,
},
ListTaskSnapshots {
process: 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,547 @@
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: clusterflux_core::TaskDefinitionId,
pub task: TaskInstanceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempt_id: Option<String>,
#[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)]
#[serde(rename_all = "snake_case")]
pub enum TaskAttemptState {
Queued,
Running,
FailedAwaitingAction,
Completed,
Failed,
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskAttemptSnapshot {
pub process: ProcessId,
pub task: TaskInstanceId,
pub attempt_id: String,
pub attempt_number: u32,
pub task_definition: clusterflux_core::TaskDefinitionId,
pub display_name: String,
pub state: TaskAttemptState,
pub current: bool,
pub node: Option<NodeId>,
pub environment_id: Option<String>,
pub environment_digest: Option<Digest>,
pub argument_summary: Vec<String>,
pub handle_summary: Vec<String>,
pub command_state: Option<String>,
pub vfs_checkpoint: String,
pub probe_symbol: Option<String>,
pub source_path: Option<String>,
pub source_line: Option<u32>,
pub restart_compatible: bool,
pub failure_policy: clusterflux_core::TaskFailurePolicy,
pub artifact_path: Option<VfsPath>,
pub artifact_digest: Option<Digest>,
pub artifact_size_bytes: Option<u64>,
pub status_code: Option<i32>,
pub error: Option<String>,
}
#[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: clusterflux_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<clusterflux_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: clusterflux_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: clusterflux_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,
#[serde(default, skip_serializing_if = "Option::is_none")]
launch_attempt: Option<String>,
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<clusterflux_core::TaskInstanceId>,
restarted_attempt_id: Option<String>,
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,
partially_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>,
},
TaskSnapshots {
snapshots: Vec<TaskAttemptSnapshot>,
},
TaskFailureResolved {
process: ProcessId,
task: TaskInstanceId,
attempt_id: String,
resolution: TaskFailureResolution,
},
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,484 @@
use std::collections::BTreeMap;
use clusterflux_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 max_projects_per_tenant: usize,
pub max_nodes_per_tenant: usize,
pub max_active_processes_per_tenant: usize,
}
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,
max_projects_per_tenant: usize::MAX,
max_nodes_per_tenant: usize::MAX,
max_active_processes_per_tenant: usize::MAX,
})
}
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 with_admission_limits(
mut self,
max_projects_per_tenant: usize,
max_nodes_per_tenant: usize,
max_active_processes_per_tenant: usize,
) -> Self {
self.max_projects_per_tenant = max_projects_per_tenant;
self.max_nodes_per_tenant = max_nodes_per_tenant;
self.max_active_processes_per_tenant = max_active_processes_per_tenant;
self
}
pub fn unlimited() -> Self {
Self {
limits: ResourceLimits::unlimited(),
window_seconds: LimitKind::ALL
.into_iter()
.map(|kind| (kind, u64::MAX))
.collect(),
policy_label: None,
max_projects_per_tenant: usize::MAX,
max_nodes_per_tenant: usize::MAX,
max_active_processes_per_tenant: usize::MAX,
}
}
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(),
}
}
pub(super) fn ensure_project_admission(
&self,
tenant: &TenantId,
current: usize,
) -> Result<(), super::CoordinatorServiceError> {
let maximum = self.configuration.max_projects_per_tenant;
if current >= maximum {
return Err(super::CoordinatorServiceError::Protocol(format!(
"admission.project_limit: tenant {tenant} already has the maximum of {maximum} projects"
)));
}
Ok(())
}
pub(super) fn ensure_node_admission(
&self,
tenant: &TenantId,
current: usize,
) -> Result<(), super::CoordinatorServiceError> {
let maximum = self.configuration.max_nodes_per_tenant;
if current >= maximum {
return Err(super::CoordinatorServiceError::Protocol(format!(
"admission.node_limit: tenant {tenant} already has the maximum of {maximum} nodes"
)));
}
Ok(())
}
pub(super) fn ensure_process_admission(
&self,
tenant: &TenantId,
current: usize,
) -> Result<(), super::CoordinatorServiceError> {
let maximum = self.configuration.max_active_processes_per_tenant;
if current >= maximum {
return Err(super::CoordinatorServiceError::Protocol(format!(
"admission.active_process_limit: tenant {tenant} already has the maximum of {maximum} active processes"
)));
}
Ok(())
}
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,590 @@
use std::collections::BTreeMap;
use clusterflux_core::{ProjectId, TenantId, UserId};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactRelayConfiguration {
pub enabled: bool,
pub max_artifact_bytes: u64,
pub max_active_per_project: usize,
pub max_active_per_tenant: usize,
pub max_active_per_account: usize,
pub max_active_global: usize,
pub max_period_bytes_per_project: u64,
pub max_period_bytes_per_tenant: u64,
pub max_period_bytes_per_account: u64,
pub period_seconds: u64,
pub framing_overhead_bytes: u64,
pub max_tracked_scopes: usize,
}
impl ArtifactRelayConfiguration {
pub fn unlimited() -> Self {
Self {
enabled: true,
max_artifact_bytes: u64::MAX,
max_active_per_project: usize::MAX,
max_active_per_tenant: usize::MAX,
max_active_per_account: usize::MAX,
max_active_global: usize::MAX,
max_period_bytes_per_project: u64::MAX,
max_period_bytes_per_tenant: u64::MAX,
max_period_bytes_per_account: u64::MAX,
period_seconds: u64::MAX,
framing_overhead_bytes: 512,
max_tracked_scopes: usize::MAX,
}
}
pub fn validate(&self) -> Result<(), ArtifactRelayError> {
if self.period_seconds == 0
|| self.max_active_per_project == 0
|| self.max_active_per_tenant == 0
|| self.max_active_per_account == 0
|| self.max_active_global == 0
|| self.max_tracked_scopes == 0
{
return Err(ArtifactRelayError::InvalidConfiguration);
}
Ok(())
}
}
impl Default for ArtifactRelayConfiguration {
fn default() -> Self {
Self::unlimited()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ArtifactRelayUsage {
pub active_transfers: usize,
pub ingress_bytes: u64,
pub egress_bytes: u64,
pub abandoned_or_failed_bytes: u64,
pub reserved_bytes: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactRelayDurableState {
pub reservations: BTreeMap<String, ArtifactRelayReservationState>,
pub period: u64,
pub project_used: Vec<(TenantId, ProjectId, u64)>,
pub tenant_used: Vec<(TenantId, u64)>,
pub account_used: Vec<(TenantId, UserId, u64)>,
pub ingress_used: u64,
pub egress_used: u64,
pub abandoned_or_failed_used: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactRelayReservationState {
pub tenant: TenantId,
pub project: ProjectId,
pub account: UserId,
pub remaining_reserved_bytes: u64,
pub ingress_bytes: u64,
pub egress_bytes: u64,
pub expires_at_epoch_seconds: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum RelayFinishReason {
Completed,
Failed,
Cancelled,
Expired,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ArtifactRelayError {
#[error("artifact relay is disabled by hosted policy")]
Disabled,
#[error("artifact exceeds the configured relay size limit")]
ArtifactTooLarge,
#[error("artifact relay concurrency limit reached for {0}")]
Concurrency(&'static str),
#[error("artifact relay is temporarily at process capacity")]
Capacity,
#[error("artifact relay period byte budget exhausted for {0}")]
PeriodBudget(&'static str),
#[error("artifact relay retained scope state limit reached")]
StateLimit,
#[error("artifact relay reservation is missing or expired")]
MissingReservation,
#[error("artifact relay configuration contains a zero bound")]
InvalidConfiguration,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct RelayScope {
tenant: TenantId,
project: ProjectId,
account: UserId,
}
#[derive(Clone, Debug)]
struct RelayReservation {
scope: RelayScope,
remaining_reserved_bytes: u64,
ingress_bytes: u64,
egress_bytes: u64,
expires_at_epoch_seconds: u64,
}
#[derive(Clone, Debug)]
pub(super) struct ArtifactRelayLedger {
configuration: ArtifactRelayConfiguration,
reservations: BTreeMap<String, RelayReservation>,
period: u64,
project_used: BTreeMap<(TenantId, ProjectId), u64>,
tenant_used: BTreeMap<TenantId, u64>,
account_used: BTreeMap<(TenantId, UserId), u64>,
ingress_used: u64,
egress_used: u64,
abandoned_or_failed_used: u64,
}
impl Default for ArtifactRelayLedger {
fn default() -> Self {
Self::new(ArtifactRelayConfiguration::default())
}
}
impl ArtifactRelayLedger {
pub(super) fn new(configuration: ArtifactRelayConfiguration) -> Self {
Self {
configuration,
reservations: BTreeMap::new(),
period: 0,
project_used: BTreeMap::new(),
tenant_used: BTreeMap::new(),
account_used: BTreeMap::new(),
ingress_used: 0,
egress_used: 0,
abandoned_or_failed_used: 0,
}
}
pub(super) fn configure(
&mut self,
configuration: ArtifactRelayConfiguration,
) -> Result<(), ArtifactRelayError> {
configuration.validate()?;
self.configuration = configuration;
Ok(())
}
pub(super) fn from_durable(
configuration: ArtifactRelayConfiguration,
state: ArtifactRelayDurableState,
) -> Self {
Self {
configuration,
reservations: state
.reservations
.into_iter()
.map(|(key, reservation)| {
(
key,
RelayReservation {
scope: RelayScope {
tenant: reservation.tenant,
project: reservation.project,
account: reservation.account,
},
remaining_reserved_bytes: reservation.remaining_reserved_bytes,
ingress_bytes: reservation.ingress_bytes,
egress_bytes: reservation.egress_bytes,
expires_at_epoch_seconds: reservation.expires_at_epoch_seconds,
},
)
})
.collect(),
period: state.period,
project_used: state
.project_used
.into_iter()
.map(|(tenant, project, bytes)| ((tenant, project), bytes))
.collect(),
tenant_used: state.tenant_used.into_iter().collect(),
account_used: state
.account_used
.into_iter()
.map(|(tenant, account, bytes)| ((tenant, account), bytes))
.collect(),
ingress_used: state.ingress_used,
egress_used: state.egress_used,
abandoned_or_failed_used: state.abandoned_or_failed_used,
}
}
pub(super) fn durable_state(&self) -> ArtifactRelayDurableState {
ArtifactRelayDurableState {
reservations: self
.reservations
.iter()
.map(|(key, reservation)| {
(
key.clone(),
ArtifactRelayReservationState {
tenant: reservation.scope.tenant.clone(),
project: reservation.scope.project.clone(),
account: reservation.scope.account.clone(),
remaining_reserved_bytes: reservation.remaining_reserved_bytes,
ingress_bytes: reservation.ingress_bytes,
egress_bytes: reservation.egress_bytes,
expires_at_epoch_seconds: reservation.expires_at_epoch_seconds,
},
)
})
.collect(),
period: self.period,
project_used: self
.project_used
.iter()
.map(|((tenant, project), bytes)| (tenant.clone(), project.clone(), *bytes))
.collect(),
tenant_used: self
.tenant_used
.iter()
.map(|(tenant, bytes)| (tenant.clone(), *bytes))
.collect(),
account_used: self
.account_used
.iter()
.map(|((tenant, account), bytes)| (tenant.clone(), account.clone(), *bytes))
.collect(),
ingress_used: self.ingress_used,
egress_used: self.egress_used,
abandoned_or_failed_used: self.abandoned_or_failed_used,
}
}
pub(super) fn reconcile_after_restart(&mut self, now_epoch_seconds: u64) {
let reservations = self.reservations.keys().cloned().collect::<Vec<_>>();
for key in reservations {
self.finish(&key, RelayFinishReason::Failed);
}
self.prepare_period(now_epoch_seconds);
}
fn prepare_period(&mut self, now_epoch_seconds: u64) {
let period = now_epoch_seconds / self.configuration.period_seconds.max(1);
if self.period != period {
self.period = period;
self.project_used.clear();
self.tenant_used.clear();
self.account_used.clear();
self.ingress_used = 0;
self.egress_used = 0;
self.abandoned_or_failed_used = 0;
}
}
pub(super) fn estimated_wire_bytes(&self, artifact_bytes: u64, chunk_bytes: u64) -> u64 {
let encoded = artifact_bytes
.saturating_add(2)
.saturating_div(3)
.saturating_mul(4);
let chunks = artifact_bytes
.saturating_add(chunk_bytes.saturating_sub(1))
.saturating_div(chunk_bytes.max(1))
.max(1);
encoded.saturating_mul(2).saturating_add(
chunks
.saturating_mul(2)
.saturating_mul(self.configuration.framing_overhead_bytes),
)
}
pub(super) fn framing_overhead_bytes(&self) -> u64 {
self.configuration.framing_overhead_bytes
}
pub(super) fn reserve(
&mut self,
key: String,
tenant: TenantId,
project: ProjectId,
account: UserId,
artifact_bytes: u64,
chunk_bytes: u64,
expires_at_epoch_seconds: u64,
now_epoch_seconds: u64,
) -> Result<(), ArtifactRelayError> {
self.expire(now_epoch_seconds);
self.prepare_period(now_epoch_seconds);
if !self.configuration.enabled {
return Err(ArtifactRelayError::Disabled);
}
if artifact_bytes > self.configuration.max_artifact_bytes {
return Err(ArtifactRelayError::ArtifactTooLarge);
}
let scope = RelayScope {
tenant,
project,
account,
};
self.check_concurrency(&scope)?;
self.check_tracked_scope_capacity(&scope)?;
let reserved = self.estimated_wire_bytes(artifact_bytes, chunk_bytes);
self.check_budget(&scope, reserved)?;
self.reservations.insert(
key,
RelayReservation {
scope,
remaining_reserved_bytes: reserved,
ingress_bytes: 0,
egress_bytes: 0,
expires_at_epoch_seconds,
},
);
Ok(())
}
pub(super) fn rekey(&mut self, from: &str, to: String) -> Result<(), ArtifactRelayError> {
let reservation = self
.reservations
.remove(from)
.ok_or(ArtifactRelayError::MissingReservation)?;
self.reservations.insert(to, reservation);
Ok(())
}
fn check_concurrency(&self, scope: &RelayScope) -> Result<(), ArtifactRelayError> {
if self.reservations.len() >= self.configuration.max_active_global {
return Err(ArtifactRelayError::Capacity);
}
let project = self
.reservations
.values()
.filter(|reservation| {
reservation.scope.tenant == scope.tenant
&& reservation.scope.project == scope.project
})
.count();
if project >= self.configuration.max_active_per_project {
return Err(ArtifactRelayError::Concurrency("project"));
}
let tenant = self
.reservations
.values()
.filter(|reservation| reservation.scope.tenant == scope.tenant)
.count();
if tenant >= self.configuration.max_active_per_tenant {
return Err(ArtifactRelayError::Concurrency("tenant"));
}
let account = self
.reservations
.values()
.filter(|reservation| {
reservation.scope.tenant == scope.tenant
&& reservation.scope.account == scope.account
})
.count();
if account >= self.configuration.max_active_per_account {
return Err(ArtifactRelayError::Concurrency("account"));
}
Ok(())
}
fn check_tracked_scope_capacity(&self, scope: &RelayScope) -> Result<(), ArtifactRelayError> {
let project_key = (scope.tenant.clone(), scope.project.clone());
let account_key = (scope.tenant.clone(), scope.account.clone());
let new_scopes = usize::from(!self.project_used.contains_key(&project_key))
+ usize::from(!self.tenant_used.contains_key(&scope.tenant))
+ usize::from(!self.account_used.contains_key(&account_key));
let tracked = self.project_used.len() + self.tenant_used.len() + self.account_used.len();
if tracked.saturating_add(new_scopes) > self.configuration.max_tracked_scopes {
return Err(ArtifactRelayError::StateLimit);
}
Ok(())
}
fn reserved_for(&self, scope: &RelayScope) -> (u64, u64, u64, u64) {
let mut project = 0_u64;
let mut tenant = 0_u64;
let mut account = 0_u64;
let mut global = 0_u64;
for reservation in self.reservations.values() {
let bytes = reservation.remaining_reserved_bytes;
global = global.saturating_add(bytes);
if reservation.scope.tenant == scope.tenant {
tenant = tenant.saturating_add(bytes);
if reservation.scope.project == scope.project {
project = project.saturating_add(bytes);
}
if reservation.scope.account == scope.account {
account = account.saturating_add(bytes);
}
}
}
(project, tenant, account, global)
}
fn check_budget(&self, scope: &RelayScope, additional: u64) -> Result<(), ArtifactRelayError> {
let (project_reserved, tenant_reserved, account_reserved, _) = self.reserved_for(scope);
let project_used = self
.project_used
.get(&(scope.tenant.clone(), scope.project.clone()))
.copied()
.unwrap_or(0);
let tenant_used = self.tenant_used.get(&scope.tenant).copied().unwrap_or(0);
let account_used = self
.account_used
.get(&(scope.tenant.clone(), scope.account.clone()))
.copied()
.unwrap_or(0);
for (used, reserved, limit, label) in [
(
project_used,
project_reserved,
self.configuration.max_period_bytes_per_project,
"project",
),
(
tenant_used,
tenant_reserved,
self.configuration.max_period_bytes_per_tenant,
"tenant",
),
(
account_used,
account_reserved,
self.configuration.max_period_bytes_per_account,
"account",
),
] {
if used.saturating_add(reserved).saturating_add(additional) > limit {
return Err(ArtifactRelayError::PeriodBudget(label));
}
}
Ok(())
}
fn charge(
&mut self,
key: &str,
bytes: u64,
ingress: bool,
now_epoch_seconds: u64,
) -> Result<(), ArtifactRelayError> {
self.prepare_period(now_epoch_seconds);
let reservation = self
.reservations
.get(key)
.cloned()
.ok_or(ArtifactRelayError::MissingReservation)?;
let additional = bytes.saturating_sub(reservation.remaining_reserved_bytes);
if additional > 0 {
self.check_budget(&reservation.scope, additional)?;
}
let reservation = self
.reservations
.get_mut(key)
.ok_or(ArtifactRelayError::MissingReservation)?;
reservation.remaining_reserved_bytes =
reservation.remaining_reserved_bytes.saturating_sub(bytes);
if ingress {
reservation.ingress_bytes = reservation.ingress_bytes.saturating_add(bytes);
self.ingress_used = self.ingress_used.saturating_add(bytes);
} else {
reservation.egress_bytes = reservation.egress_bytes.saturating_add(bytes);
self.egress_used = self.egress_used.saturating_add(bytes);
}
let project_key = (
reservation.scope.tenant.clone(),
reservation.scope.project.clone(),
);
let account_key = (
reservation.scope.tenant.clone(),
reservation.scope.account.clone(),
);
*self.project_used.entry(project_key).or_default() = self
.project_used
.get(&(
reservation.scope.tenant.clone(),
reservation.scope.project.clone(),
))
.copied()
.unwrap_or(0)
.saturating_add(bytes);
*self
.tenant_used
.entry(reservation.scope.tenant.clone())
.or_default() = self
.tenant_used
.get(&reservation.scope.tenant)
.copied()
.unwrap_or(0)
.saturating_add(bytes);
*self.account_used.entry(account_key).or_default() = self
.account_used
.get(&(
reservation.scope.tenant.clone(),
reservation.scope.account.clone(),
))
.copied()
.unwrap_or(0)
.saturating_add(bytes);
Ok(())
}
pub(super) fn charge_ingress(
&mut self,
key: &str,
bytes: u64,
now_epoch_seconds: u64,
) -> Result<(), ArtifactRelayError> {
self.charge(key, bytes, true, now_epoch_seconds)
}
pub(super) fn charge_egress(
&mut self,
key: &str,
bytes: u64,
now_epoch_seconds: u64,
) -> Result<(), ArtifactRelayError> {
self.charge(key, bytes, false, now_epoch_seconds)
}
pub(super) fn finish(&mut self, key: &str, reason: RelayFinishReason) {
if let Some(reservation) = self.reservations.remove(key) {
if reason != RelayFinishReason::Completed {
self.abandoned_or_failed_used = self
.abandoned_or_failed_used
.saturating_add(reservation.ingress_bytes)
.saturating_add(reservation.egress_bytes);
}
}
}
pub(super) fn expire(&mut self, now_epoch_seconds: u64) {
let expired = self
.reservations
.iter()
.filter(|(_, reservation)| reservation.expires_at_epoch_seconds < now_epoch_seconds)
.map(|(key, _)| key.clone())
.collect::<Vec<_>>();
for key in expired {
self.finish(&key, RelayFinishReason::Expired);
}
}
pub(super) fn usage(&self) -> ArtifactRelayUsage {
ArtifactRelayUsage {
active_transfers: self.reservations.len(),
ingress_bytes: self.ingress_used,
egress_bytes: self.egress_used,
abandoned_or_failed_bytes: self.abandoned_or_failed_used,
reserved_bytes: self
.reservations
.values()
.map(|reservation| reservation.remaining_reserved_bytes)
.fold(0_u64, u64::saturating_add),
}
}
}

View file

@ -0,0 +1,845 @@
use super::*;
impl CoordinatorService {
pub fn handle_request(
&mut self,
request: CoordinatorRequest,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
request
.validate_external_identifiers()
.map_err(CoordinatorServiceError::Protocol)?;
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 =
clusterflux_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());
}
}
if self.coordinator.project(&project).is_none() {
self.quota.ensure_project_admission(
&tenant,
self.coordinator.project_count_for_tenant(&tenant),
)?;
}
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 = clusterflux_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 = clusterflux_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 = clusterflux_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,
launch_attempt,
restart,
} => self.handle_start_process(
tenant,
project,
actor_user,
actor_agent,
agent_public_key_fingerprint,
agent_signature,
Some(&request_payload_digest),
process,
launch_attempt,
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,
launch_attempt,
} => self.handle_abort_process(tenant, project, actor_user, process, launch_attempt),
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,
),
CoordinatorRequest::ResolveTaskFailure {
tenant,
project,
actor_user,
process,
task,
resolution,
} => self.handle_resolve_task_failure(
tenant, project, actor_user, process, task, resolution,
),
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::ListTaskSnapshots {
tenant,
project,
actor_user,
process,
} => self.handle_list_task_snapshots(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 = if matches!(&request, AuthenticatedCoordinatorRequest::AuthStatus) {
self.coordinator
.authenticate_cli_session_for_status(&session_secret)?
} else {
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());
}
}
if self.coordinator.project(&project).is_none() {
self.quota.ensure_project_admission(
&context.tenant,
self.coordinator.project_count_for_tenant(&context.tenant),
)?;
}
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,
launch_attempt,
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,
launch_attempt,
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,
launch_attempt,
} => self.handle_abort_process(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
launch_attempt,
),
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,
),
AuthenticatedCoordinatorRequest::ResolveTaskFailure {
process,
task,
resolution,
} => self.handle_resolve_task_failure(
context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
task,
resolution,
),
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::ListTaskSnapshots { process } => self
.handle_list_task_snapshots(
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 clusterflux_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 = clusterflux_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 clusterflux_core::{RequestId, 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
));
}
RequestId::try_new(self.request_id.clone())
.map_err(|error| format!("malformed coordinator wire request_id: {error}"))?;
self.payload.validate_external_identifiers()?;
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,194 @@
use std::time::{SystemTime, UNIX_EPOCH};
use clusterflux_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_with_tenant_policy(
session_secret,
unix_timestamp_seconds(),
true,
)
}
pub fn authenticate_cli_session_for_status(
&self,
session_secret: &str,
) -> Result<AuthContext, CoordinatorError> {
self.authenticate_cli_session_at_with_tenant_policy(
session_secret,
unix_timestamp_seconds(),
false,
)
}
pub fn authenticate_cli_session_at(
&self,
session_secret: &str,
now_epoch_seconds: u64,
) -> Result<AuthContext, CoordinatorError> {
self.authenticate_cli_session_at_with_tenant_policy(session_secret, now_epoch_seconds, true)
}
fn authenticate_cli_session_at_with_tenant_policy(
&self,
session_secret: &str,
now_epoch_seconds: u64,
require_active_tenant: bool,
) -> 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 clusterflux login --browser again"
.to_owned(),
));
}
if record.credential_kind != CredentialKind::CliDeviceSession {
return Err(CoordinatorError::Unauthorized(
"credential is not a CLI session".to_owned(),
));
}
if require_active_tenant {
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 = "clusterflux-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,829 @@
#[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"clusterflux-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>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AgentWorkflowRequestScope {
pub tenant: TenantId,
pub project: ProjectId,
pub request_kind: String,
pub process: ProcessId,
pub task: Option<TaskInstanceId>,
}
impl AgentWorkflowRequestScope {
pub fn new(
tenant: TenantId,
project: ProjectId,
request_kind: impl Into<String>,
process: ProcessId,
task: Option<TaskInstanceId>,
) -> Result<Self, String> {
let request_kind = request_kind.into();
match request_kind.as_str() {
"start_process" if task.is_some() => {
return Err("start_process agent scope must not contain a task instance".to_owned())
}
"launch_task" if task.is_none() => {
return Err("launch_task agent scope requires a task instance".to_owned())
}
"start_process" | "launch_task" => {}
_ => {
return Err(format!(
"request kind `{request_kind}` is not an agent workflow operation"
))
}
}
Ok(Self {
tenant,
project,
request_kind,
process,
task,
})
}
pub fn for_agent<'a>(&'a self, agent: &'a AgentId) -> AgentWorkflowScope<'a> {
AgentWorkflowScope {
tenant: &self.tenant,
project: &self.project,
agent,
request_kind: &self.request_kind,
process: &self.process,
task: self.task.as_ref(),
}
}
}
pub fn agent_workflow_request_scope_from_payload(
payload: &Value,
) -> Result<AgentWorkflowRequestScope, String> {
let object = payload
.as_object()
.ok_or_else(|| "agent workflow request payload must be an object".to_owned())?;
let request_kind = object
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| "agent workflow request type is missing".to_owned())?;
let tenant = object
.get("tenant")
.and_then(Value::as_str)
.ok_or_else(|| "agent workflow tenant is missing".to_owned())?;
let project = object
.get("project")
.and_then(Value::as_str)
.ok_or_else(|| "agent workflow project is missing".to_owned())?;
let (process, task) = match request_kind {
"start_process" => (
object
.get("process")
.and_then(Value::as_str)
.ok_or_else(|| "start_process agent scope is missing process".to_owned())?,
None,
),
"launch_task" => {
let task_spec = object
.get("task_spec")
.and_then(Value::as_object)
.ok_or_else(|| "launch_task agent scope is missing task_spec".to_owned())?;
let process = task_spec
.get("process")
.and_then(Value::as_str)
.ok_or_else(|| "launch_task agent scope is missing process".to_owned())?;
let task = task_spec
.get("task_instance")
.and_then(Value::as_str)
.ok_or_else(|| "launch_task agent scope is missing task_instance".to_owned())?;
(process, Some(TaskInstanceId::from(task)))
}
_ => {
return Err(format!(
"request kind `{request_kind}` is not an agent workflow operation"
))
}
};
AgentWorkflowRequestScope::new(
TenantId::from(tenant),
ProjectId::from(project),
request_kind,
ProcessId::from(process),
task,
)
}
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 = [
"clusterflux-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 = [
"clusterflux-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: "clusterflux_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 = clusterflux_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 clusterflux_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 != "clusterflux" || 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#"#[clusterflux::main]
fn build_main() {
let linux = compile_linux();
}
#[clusterflux::task]
fn compile_linux() {
println!("linux");
}
fn helper_without_runtime_probe() {}
#[clusterflux::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,337 @@
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("no participant could freeze; `{task}` rejected the freeze request")]
CannotFreeze { task: TaskInstanceId },
#[error("participant `{0}` is not part of this debug epoch")]
UnknownParticipant(TaskInstanceId),
#[error("participant `{0}` did not acknowledge frozen state in this debug epoch")]
ParticipantNotFrozen(TaskInstanceId),
}
impl DebugEpoch {
pub fn all_stop(
process: ProcessId,
epoch: u64,
reason: DebugStopReason,
participants: Vec<DebugParticipant>,
) -> Result<Self, DebugEpochError> {
let first_rejected = participants
.iter()
.find(|participant| {
matches!(participant.state, DebugRuntimeState::Running) && !participant.can_freeze
})
.map(|participant| participant.task.clone());
let participants: BTreeMap<_, _> = participants
.into_iter()
.map(|mut participant| {
if matches!(participant.state, DebugRuntimeState::Running) {
participant.state = if participant.can_freeze {
DebugRuntimeState::Frozen
} else {
DebugRuntimeState::Failed(
"participant did not acknowledge frozen state before the debug deadline"
.to_owned(),
)
};
}
(participant.task.clone(), participant)
})
.collect();
if !participants
.values()
.any(|participant| participant.state == DebugRuntimeState::Frozen)
{
return Err(DebugEpochError::CannotFreeze {
task: first_rejected.unwrap_or_else(|| TaskInstanceId::from("debug-epoch")),
});
}
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()))?;
if participant.state != DebugRuntimeState::Frozen {
return Err(DebugEpochError::ParticipantNotFrozen(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()
}
pub fn all_threads_stopped(&self) -> bool {
!self.participants.is_empty()
&& self
.participants
.values()
.all(|participant| participant.state == DebugRuntimeState::Frozen)
}
pub fn partially_frozen(&self) -> bool {
!self.all_threads_stopped()
&& self
.participants
.values()
.any(|participant| participant.state == DebugRuntimeState::Frozen)
}
}
#[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_failure_when_no_participant_can_freeze() {
let error = DebugEpoch::pause(
ProcessId::from("process"),
1,
vec![participant(
"compile-linux",
DebugParticipantKind::ControlledNativeCommand,
false,
)],
)
.unwrap_err();
assert!(matches!(error, DebugEpochError::CannotFreeze { .. }));
}
#[test]
fn debug_epoch_keeps_frozen_participants_when_another_participant_fails() {
let epoch = DebugEpoch::pause(
ProcessId::from("process"),
1,
vec![
participant("main", DebugParticipantKind::WasmTask, true),
participant(
"native",
DebugParticipantKind::ControlledNativeCommand,
false,
),
],
)
.unwrap();
assert!(epoch.partially_frozen());
assert!(!epoch.all_threads_stopped());
assert!(matches!(
epoch.participant_state(&TaskInstanceId::from("native")),
Some(DebugRuntimeState::Failed(_))
));
assert!(matches!(
epoch.inspection(&TaskInstanceId::from("native")),
Err(DebugEpochError::ParticipantNotFrozen(_))
));
}
#[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 Clusterflux 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));
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,137 @@
use serde::{Deserialize, Serialize};
pub const MAX_EXTERNAL_ID_BYTES: usize = 255;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IdParseError {
id_type: &'static str,
reason: &'static str,
}
impl IdParseError {
fn new(id_type: &'static str, reason: &'static str) -> Self {
Self { id_type, reason }
}
}
impl std::fmt::Display for IdParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} is invalid: {}", self.id_type, self.reason)
}
}
impl std::error::Error for IdParseError {}
fn validate_id(value: &str, id_type: &'static str) -> Result<(), IdParseError> {
if value.is_empty() || value.trim().is_empty() {
return Err(IdParseError::new(
id_type,
"value must not be empty or whitespace-only",
));
}
if value.len() > MAX_EXTERNAL_ID_BYTES {
return Err(IdParseError::new(
id_type,
"value exceeds the 255-byte limit",
));
}
if value.chars().any(char::is_control) {
return Err(IdParseError::new(
id_type,
"control characters are forbidden",
));
}
if !value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(byte, b'-' | b'_' | b'.' | b':' | b'/' | b'@' | b'+')
}) {
return Err(IdParseError::new(
id_type,
"only ASCII letters, digits, '-', '_', '.', ':', '/', '@', and '+' are allowed",
));
}
Ok(())
}
macro_rules! id_type {
($name:ident) => {
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct $name(String);
impl $name {
pub fn try_new(value: impl Into<String>) -> Result<Self, IdParseError> {
let value = value.into();
validate_id(&value, stringify!($name))?;
Ok(Self(value))
}
/// Constructs an identifier from a trusted, internally generated value.
pub fn new(value: impl Into<String>) -> Self {
Self::try_new(value).unwrap_or_else(|error| panic!("{error}"))
}
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!(LaunchAttemptId);
id_type!(ProjectId);
id_type!(TaskDefinitionId);
id_type!(TaskInstanceId);
id_type!(TenantId);
id_type!(UserId);
pub type DebugSessionId = ProcessId;
pub type RequestId = LaunchAttemptId;
#[cfg(test)]
mod tests {
use super::*;
fn assert_hostile_values<T>(parse: impl Fn(String) -> Result<T, IdParseError>) {
for value in [
String::new(),
" ".to_owned(),
"bad\0id".to_owned(),
"bad id".to_owned(),
"x".repeat(MAX_EXTERNAL_ID_BYTES + 1),
] {
assert!(parse(value).is_err());
}
}
#[test]
fn every_external_identifier_type_rejects_hostile_values() {
assert_hostile_values(AgentId::try_new);
assert_hostile_values(ArtifactId::try_new);
assert_hostile_values(DebugSessionId::try_new);
assert_hostile_values(NodeId::try_new);
assert_hostile_values(ProcessId::try_new);
assert_hostile_values(LaunchAttemptId::try_new);
assert_hostile_values(ProjectId::try_new);
assert_hostile_values(RequestId::try_new);
assert_hostile_values(TaskDefinitionId::try_new);
assert_hostile_values(TaskInstanceId::try_new);
assert_hostile_values(TenantId::try_new);
assert_hostile_values(UserId::try_new);
}
}

View file

@ -0,0 +1,103 @@
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, agent_workflow_request_scope_from_payload,
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, AgentWorkflowRequestScope, 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, TaskFailurePolicy, 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, DebugSessionId, IdParseError, LaunchAttemptId, NodeId, ProcessId,
ProjectId, RequestId, TaskDefinitionId, TaskInstanceId, TenantId, UserId,
MAX_EXTERNAL_ID_BYTES,
};
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,300 @@
use std::collections::BTreeMap;
use std::time::Duration;
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,
}
#[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; 8] = [
Self::ApiCall,
Self::Spawn,
Self::LogBytes,
Self::MetadataBytes,
Self::DebugReadBytes,
Self::UiEvent,
Self::RendezvousAttempt,
Self::ArtifactDownloadBytes,
];
}
pub const TASK_JOIN_TIMEOUT_SECONDS_ENV: &str = "CLUSTERFLUX_TASK_JOIN_TIMEOUT_SECONDS";
pub const DEFAULT_TASK_JOIN_TIMEOUT_SECONDS: u64 = 24 * 60 * 60;
pub fn task_join_timeout() -> Duration {
std::env::var(TASK_JOIN_TIMEOUT_SECONDS_ENV)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(DEFAULT_TASK_JOIN_TIMEOUT_SECONDS))
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum TaskJoinError {
#[error(
"timed out after {waited_seconds} seconds waiting for task {task}; the child task was left running"
)]
Timeout {
task: crate::TaskInstanceId,
waited_seconds: u64,
},
#[error("task join for {task} was cancelled; the child task was left running")]
Cancelled { task: crate::TaskInstanceId },
}
impl TaskJoinError {
pub fn timeout(task: crate::TaskInstanceId, waited: Duration) -> Self {
Self::Timeout {
task,
waited_seconds: waited.as_secs(),
}
}
}
#[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("Clusterflux entrypoint discovery failed: {0}")]
EntrypointDiscovery(String),
#[error(
"no Clusterflux entrypoint is declared; add `#[clusterflux::main]` to a function under src/"
)]
NoEntrypoints,
#[error("unknown Clusterflux 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() == ["clusterflux", "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"),
"#[clusterflux::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"),
"#[clusterflux::main(name = \"check\")]\npub fn test_main() {}\n",
)
.unwrap();
fs::write(
temp.path().join("src/nested/release.rs"),
"#[clusterflux::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"),
"#[clusterflux::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,472 @@
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>,
#[serde(default)]
pub environment_cache_required: bool,
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:?}"));
}
}
}
if request.environment_cache_required {
match request.environment_digest.as_ref() {
Some(digest) if !node.cached_environments.contains(digest) => {
reasons.push(format!(
"required named environment cache {digest} is unavailable"
));
}
None => reasons.push("required named environment cache digest is missing".to_owned()),
Some(_) => {}
}
}
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")),
environment_cache_required: false,
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_requires_requested_named_environment_cache() {
let request = PlacementRequest {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
environment: None,
environment_digest: Some(Digest::sha256("missing-environment")),
environment_cache_required: true,
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("uncached", false)], &request)
.unwrap_err();
assert!(error.message.contains("named environment cache"));
}
#[test]
fn scheduler_failure_names_missing_constraint() {
let mut request = PlacementRequest {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
environment: None,
environment_digest: None,
environment_cache_required: false,
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,
environment_cache_required: false,
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,
environment_cache_required: false,
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,
environment_cache_required: false,
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,
environment_cache_required: false,
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 = "clusterflux-dap"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "clusterflux-debug-dap"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
base64.workspace = true
clusterflux-core = { path = "../clusterflux-core" }
clusterflux-control = { path = "../clusterflux-control" }
clusterflux-node = { path = "../clusterflux-node" }
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,375 @@
use std::fs;
use clusterflux_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 Clusterflux debug probe {} for task {}",
probe.id, probe.task
),
None if verified => "Mapped to Clusterflux virtual source location".to_owned(),
None => "No Clusterflux 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
.stopped_probe_symbol
.as_deref()
.and_then(|symbol| symbol.strip_prefix("clusterflux.probe."))
.and_then(|function| {
state
.debug_probes
.iter()
.find(|probe| probe.function == function)
.and_then(|probe| {
state.breakpoints.iter().copied().find(|line| {
*line >= i64::from(probe.line_start) && *line <= i64::from(probe.line_end)
})
})
})
.or_else(|| {
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) {
if state.runtime_backend == crate::virtual_model::RuntimeBackend::Simulated {
let function = probe.function.to_ascii_lowercase();
if function.contains("linux") {
return LINUX_THREAD;
}
if function.contains("windows") {
return WINDOWS_THREAD;
}
if function.contains("package") {
return PACKAGE_THREAD;
}
return MAIN_THREAD;
}
return state
.stopped_task
.as_ref()
.and_then(|task| {
state
.threads
.values()
.find(|thread| &thread.task == 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,68 @@
use std::collections::BTreeMap;
use clusterflux_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),
attempt_id: format!("demo-attempt-{id}"),
task_definition: clusterflux_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,
runtime_node: Some("demo-node".to_owned()),
runtime_environment: Some("demo".to_owned()),
runtime_vfs_checkpoint: Some("demo-vfs".to_owned()),
restart_compatible: Some(true),
}
}

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 clusterflux_core::{BundleDebugProbe, DebugRuntimeState, TaskInstanceId};
#[cfg(test)]
use demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD};
#[cfg(test)]
use virtual_model::{process_id, RuntimeBackend};
fn main() -> Result<()> {
adapter::run_adapter()
}
#[cfg(test)]
mod tests;

File diff suppressed because it is too large Load diff

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