Public release release-01875e88a3e2
Source commit: 01875e88a3e25379c309489f9f057dd97c0d37de Public tree identity: sha256:8f37a1aa0cc8f408975daf9cabbe193f8f4c169c6d25e8795e67a3ed5083c76b
This commit is contained in:
commit
c1769967c1
210 changed files with 78680 additions and 0 deletions
22
crates/clusterflux-cli/Cargo.toml
Normal file
22
crates/clusterflux-cli/Cargo.toml
Normal 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
|
||||
236
crates/clusterflux-cli/src/admin.rs
Normal file
236
crates/clusterflux-cli/src/admin.rs
Normal 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"
|
||||
)
|
||||
})
|
||||
}
|
||||
17
crates/clusterflux-cli/src/agent.rs
Normal file
17
crates/clusterflux-cli/src/agent.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
547
crates/clusterflux-cli/src/artifact.rs
Normal file
547
crates/clusterflux-cli/src/artifact.rs
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
776
crates/clusterflux-cli/src/auth.rs
Normal file
776
crates/clusterflux-cli/src/auth.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
79
crates/clusterflux-cli/src/auth_scope.rs
Normal file
79
crates/clusterflux-cli/src/auth_scope.rs
Normal 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
|
||||
}
|
||||
322
crates/clusterflux-cli/src/build.rs
Normal file
322
crates/clusterflux-cli/src/build.rs
Normal 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(§ion);
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
379
crates/clusterflux-cli/src/bundle.rs
Normal file
379
crates/clusterflux-cli/src/bundle.rs
Normal 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)
|
||||
}
|
||||
191
crates/clusterflux-cli/src/client.rs
Normal file
191
crates/clusterflux-cli/src/client.rs
Normal 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(),
|
||||
}))
|
||||
}
|
||||
150
crates/clusterflux-cli/src/config.rs
Normal file
150
crates/clusterflux-cli/src/config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
35
crates/clusterflux-cli/src/confirm.rs
Normal file
35
crates/clusterflux-cli/src/confirm.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
120
crates/clusterflux-cli/src/debug.rs
Normal file
120
crates/clusterflux-cli/src/debug.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
343
crates/clusterflux-cli/src/dispatch.rs
Normal file
343
crates/clusterflux-cli/src/dispatch.rs
Normal 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(
|
||||
"a_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(())
|
||||
}
|
||||
163
crates/clusterflux-cli/src/doctor.rs
Normal file
163
crates/clusterflux-cli/src/doctor.rs
Normal 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}"))
|
||||
}
|
||||
258
crates/clusterflux-cli/src/errors.rs
Normal file
258
crates/clusterflux-cli/src/errors.rs
Normal 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",
|
||||
],
|
||||
}
|
||||
}
|
||||
271
crates/clusterflux-cli/src/key.rs
Normal file
271
crates/clusterflux-cli/src/key.rs
Normal 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())
|
||||
}
|
||||
127
crates/clusterflux-cli/src/logout.rs
Normal file
127
crates/clusterflux-cli/src/logout.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
34
crates/clusterflux-cli/src/logs.rs
Normal file
34
crates/clusterflux-cli/src/logs.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
689
crates/clusterflux-cli/src/main.rs
Normal file
689
crates/clusterflux-cli/src/main.rs
Normal 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;
|
||||
837
crates/clusterflux-cli/src/node.rs
Normal file
837
crates/clusterflux-cli/src/node.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
638
crates/clusterflux-cli/src/output.rs
Normal file
638
crates/clusterflux-cli/src/output.rs
Normal 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
|
||||
}
|
||||
339
crates/clusterflux-cli/src/process.rs
Normal file
339
crates/clusterflux-cli/src/process.rs
Normal 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(),
|
||||
}))
|
||||
}
|
||||
666
crates/clusterflux-cli/src/process_events.rs
Normal file
666
crates/clusterflux-cli/src/process_events.rs
Normal 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(¤t_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
|
||||
}
|
||||
381
crates/clusterflux-cli/src/project.rs
Normal file
381
crates/clusterflux-cli/src/project.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
111
crates/clusterflux-cli/src/quota.rs
Normal file
111
crates/clusterflux-cli/src/quota.rs
Normal 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(¤t_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,
|
||||
}))
|
||||
}
|
||||
1071
crates/clusterflux-cli/src/run.rs
Normal file
1071
crates/clusterflux-cli/src/run.rs
Normal file
File diff suppressed because it is too large
Load diff
177
crates/clusterflux-cli/src/run/local_services.rs
Normal file
177
crates/clusterflux-cli/src/run/local_services.rs
Normal 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)
|
||||
}
|
||||
106
crates/clusterflux-cli/src/task.rs
Normal file
106
crates/clusterflux-cli/src/task.rs
Normal 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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
4762
crates/clusterflux-cli/src/tests.rs
Normal file
4762
crates/clusterflux-cli/src/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
63
crates/clusterflux-cli/src/tools.rs
Normal file
63
crates/clusterflux-cli/src/tools.rs
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue