9731 lines
348 KiB
Rust
9731 lines
348 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use anyhow::{Context, Result};
|
|
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
|
use clap::{Args, Parser, Subcommand};
|
|
use disasmer_core::{
|
|
diagnose_environment_references, BrowserLoginFlow, BundleIdentityInputs, BundleMetadata,
|
|
Capability, CliLoginFlow, Digest, EnvironmentBackend, EnvironmentReference, LimitKind,
|
|
NodeCapabilities, ProjectModel, ResourceLimits, SelectedInput, SourceProviderKind,
|
|
SourceProviderManifest,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
|
|
const DEFAULT_OPERATOR_ENDPOINT: &str = "https://disasmer.michelpaulissen.com:9443";
|
|
const DEFAULT_BROWSER_LOGIN_START: &str = "https://disasmer.michelpaulissen.com/auth/browser/start";
|
|
const DEFAULT_OIDC_ISSUER_URL: &str = "https://auth.michelpaulissen.com";
|
|
const BROWSER_CALLBACK_ADDR: &str = "127.0.0.1:45173";
|
|
const BROWSER_CALLBACK_PATH: &str = "/callback";
|
|
const DEFAULT_BROWSER_LOGIN_CALLBACK_TIMEOUT_SECONDS: u64 = 300;
|
|
const DOCTOR_COORDINATOR_TIMEOUT: Duration = Duration::from_millis(500);
|
|
const DEFAULT_ARTIFACT_EXPORT_MAX_BYTES: u64 = 64 * 1024 * 1024;
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
#[command(
|
|
name = "disasmer",
|
|
version,
|
|
arg_required_else_help = true,
|
|
about = "Disasmer distributed Wasm runtime CLI.",
|
|
after_help = "Primary workflow:
|
|
1. disasmer login --browser
|
|
2. disasmer project init
|
|
3. disasmer node enroll; disasmer node attach --worker
|
|
4. disasmer run [entry] --project <path>
|
|
5. Debug with VS Code \"Disasmer: Launch Virtual Process\" or disasmer dap
|
|
6. Inspect with disasmer process status, task list, logs, and artifact list
|
|
|
|
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: bool,
|
|
#[arg(long)]
|
|
non_interactive: bool,
|
|
#[arg(long)]
|
|
plan: bool,
|
|
#[arg(long)]
|
|
json: bool,
|
|
#[arg(long, default_value_t = default_operator_endpoint())]
|
|
coordinator: String,
|
|
#[arg(long = "complete-browser-code")]
|
|
complete_browser_code: 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 = "oidc-issuer-url")]
|
|
oidc_issuer_url: Option<String>,
|
|
#[arg(long = "oidc-client-id", default_value = "disasmer")]
|
|
oidc_client_id: String,
|
|
#[arg(long)]
|
|
state: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Subcommand)]
|
|
enum AuthCommands {
|
|
Status(AuthStatusArgs),
|
|
Logout(AuthLogoutArgs),
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct AuthStatusArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct AuthLogoutArgs {
|
|
#[arg(long)]
|
|
yes: bool,
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Subcommand)]
|
|
enum AgentCommands {
|
|
Enroll(AgentEnrollArgs),
|
|
}
|
|
|
|
#[derive(Clone, Debug, Subcommand)]
|
|
enum KeyCommands {
|
|
Add(KeyAddArgs),
|
|
List(KeyListArgs),
|
|
Revoke(KeyRevokeArgs),
|
|
}
|
|
|
|
#[derive(Clone, Debug, Subcommand)]
|
|
enum BundleCommands {
|
|
Inspect(BundleInspectArgs),
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct AgentEnrollArgs {
|
|
#[arg(long)]
|
|
json: bool,
|
|
#[arg(long = "public-key")]
|
|
public_key: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct KeyAddArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
#[arg(long, default_value = "agent")]
|
|
agent: String,
|
|
#[arg(long = "public-key")]
|
|
public_key: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct KeyListArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct KeyRevokeArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
#[arg(long, default_value = "agent")]
|
|
agent: String,
|
|
#[arg(long)]
|
|
yes: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Subcommand)]
|
|
enum ProjectCommands {
|
|
Init(ProjectInitArgs),
|
|
Status(ProjectStatusArgs),
|
|
List(ProjectListArgs),
|
|
Select(ProjectSelectArgs),
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct ProjectInitArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
#[arg(long, default_value = "project")]
|
|
new_project: String,
|
|
#[arg(long, default_value = "Disasmer Project")]
|
|
name: String,
|
|
#[arg(long)]
|
|
yes: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct ProjectStatusArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct ProjectListArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct ProjectSelectArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
#[arg(value_name = "PROJECT")]
|
|
selected_project: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct BundleInspectArgs {
|
|
#[arg(long)]
|
|
project: Option<PathBuf>,
|
|
#[arg(long = "source-provider")]
|
|
source_provider: Option<String>,
|
|
#[arg(long = "disable-source-provider")]
|
|
disabled_source_providers: Vec<String>,
|
|
#[arg(long)]
|
|
json: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct BuildArgs {
|
|
#[arg(long)]
|
|
project: Option<PathBuf>,
|
|
#[arg(long = "source-provider")]
|
|
source_provider: Option<String>,
|
|
#[arg(long = "disable-source-provider")]
|
|
disabled_source_providers: Vec<String>,
|
|
#[arg(long)]
|
|
output: Option<PathBuf>,
|
|
#[arg(long)]
|
|
json: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct RunArgs {
|
|
entry: Option<String>,
|
|
#[arg(long)]
|
|
project: Option<PathBuf>,
|
|
#[arg(long)]
|
|
coordinator: Option<String>,
|
|
#[arg(long)]
|
|
local: bool,
|
|
#[arg(long)]
|
|
non_interactive: bool,
|
|
#[arg(long)]
|
|
json: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Subcommand)]
|
|
enum NodeCommands {
|
|
Attach(AttachArgs),
|
|
Enroll(NodeEnrollArgs),
|
|
List(NodeListArgs),
|
|
Status(NodeStatusArgs),
|
|
Revoke(NodeRevokeArgs),
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct AttachArgs {
|
|
#[arg(long)]
|
|
coordinator: Option<String>,
|
|
#[arg(long, default_value = "tenant")]
|
|
tenant: String,
|
|
#[arg(long = "project-id", default_value = "project")]
|
|
project: String,
|
|
#[arg(long)]
|
|
node: Option<String>,
|
|
#[arg(long = "cap")]
|
|
caps: Vec<String>,
|
|
#[arg(long = "enrollment-grant")]
|
|
enrollment_grant: Option<String>,
|
|
#[arg(long = "public-key")]
|
|
public_key: Option<String>,
|
|
#[arg(long)]
|
|
json: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct NodeEnrollArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
#[arg(long, default_value = "node-enrollment")]
|
|
grant: String,
|
|
#[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 {
|
|
Status(ProcessStatusArgs),
|
|
Restart(ProcessRestartArgs),
|
|
Cancel(ProcessCancelArgs),
|
|
}
|
|
|
|
#[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, 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,
|
|
#[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,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct AdminBootstrapArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
#[arg(long, default_value = "Disasmer Project")]
|
|
name: String,
|
|
#[arg(long)]
|
|
yes: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Parser)]
|
|
struct AdminSuspendTenantArgs {
|
|
#[command(flatten)]
|
|
scope: CliScopeArgs,
|
|
#[arg(long = "target-tenant")]
|
|
target_tenant: Option<String>,
|
|
#[arg(long)]
|
|
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,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct RunPlan {
|
|
project: PathBuf,
|
|
entry: String,
|
|
coordinator: CoordinatorSelection,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
operator_endpoint: Option<String>,
|
|
session: CliSession,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
struct RunExecutionReport {
|
|
plan: RunPlan,
|
|
boundary: RunBoundaryEvidence,
|
|
node_report: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct RunBoundaryEvidence {
|
|
cli_process_started_node_process: bool,
|
|
cli_process_started_coordinator_process: bool,
|
|
coordinator_address: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
coordinator_process_id: Option<u32>,
|
|
spawned_node_process_id: u32,
|
|
node_session_requests: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
enum CoordinatorSelection {
|
|
Hosted,
|
|
LocalOverride(String),
|
|
LocalOnly,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
enum CliSession {
|
|
Anonymous,
|
|
HumanSession,
|
|
AgentPublicKey {
|
|
agent: String,
|
|
public_key_fingerprint: Digest,
|
|
browser_interaction_required: bool,
|
|
},
|
|
}
|
|
|
|
impl CliSession {
|
|
fn is_authenticated(&self) -> bool {
|
|
!matches!(self, Self::Anonymous)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct LoginPlan {
|
|
coordinator: String,
|
|
human_flow: LoginFlowPlan,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
struct LoginCompletionReport {
|
|
plan: LoginPlan,
|
|
boundary: LoginCompletionBoundaryEvidence,
|
|
coordinator_response: Value,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct LoginCompletionBoundaryEvidence {
|
|
cli_contacted_coordinator: bool,
|
|
coordinator_address: String,
|
|
scoped_cli_session_received: bool,
|
|
local_cli_session_file_written: bool,
|
|
provider_tokens_persisted_locally: bool,
|
|
provider_tokens_exposed_to_cli: bool,
|
|
provider_tokens_sent_to_nodes: bool,
|
|
coordinator_session_requests: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
enum LoginFlowPlan {
|
|
Browser(BrowserLoginFlow),
|
|
Device(CliLoginFlow),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct AgentEnrollmentPlan {
|
|
public_key_fingerprint: Digest,
|
|
browser_interaction_required_each_run: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct BundleInspection {
|
|
project: PathBuf,
|
|
default_source_providers: Vec<SourceProviderKind>,
|
|
source_provider_manifest: SourceProviderManifest,
|
|
source_provider_statuses: Vec<SourceProviderStatus>,
|
|
environment_diagnostics: Vec<EnvironmentDiagnosticReport>,
|
|
pre_schedule_diagnostics: Vec<CliDiagnostic>,
|
|
metadata: BundleMetadata,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct SourceProviderStatus {
|
|
provider: String,
|
|
status: String,
|
|
active: bool,
|
|
reason: String,
|
|
coordinator_checkout_required: bool,
|
|
coordinator_receives_source_bytes_by_default: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct EnvironmentDiagnosticReport {
|
|
path: String,
|
|
reference: EnvironmentReference,
|
|
message: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct CliDiagnostic {
|
|
severity: String,
|
|
category: String,
|
|
code: String,
|
|
message: String,
|
|
next_actions: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct NodeAttachPlan {
|
|
node: String,
|
|
coordinator: Option<String>,
|
|
capabilities: NodeCapabilities,
|
|
detection: NodeAttachDetectionEvidence,
|
|
grant_disclosures: Vec<CapabilityGrantDisclosure>,
|
|
enrollment: Option<NodeEnrollmentPlan>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
struct NodeAttachReport {
|
|
command: String,
|
|
node: String,
|
|
plan: NodeAttachPlan,
|
|
grant_disclosures: Vec<CapabilityGrantDisclosure>,
|
|
boundary: NodeAttachBoundaryEvidence,
|
|
coordinator_response: Value,
|
|
heartbeat_response: Value,
|
|
capability_response: Value,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct NodeAttachBoundaryEvidence {
|
|
cli_contacted_coordinator: bool,
|
|
coordinator_address: String,
|
|
used_enrollment_exchange: bool,
|
|
coordinator_session_requests: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct CapabilityGrantDisclosure {
|
|
capability: Capability,
|
|
grant: String,
|
|
description: String,
|
|
risk: String,
|
|
coordinator_policy_limited: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct NodeAttachDetectionEvidence {
|
|
auto_detected: bool,
|
|
os: disasmer_core::Os,
|
|
arch: String,
|
|
command_backend: String,
|
|
command_backend_available: bool,
|
|
container_backend: Option<String>,
|
|
container_backend_reported: bool,
|
|
container_backend_available: bool,
|
|
source_provider_backends: Vec<SourceProviderBackendStatus>,
|
|
manual_capability_overrides_allowed: bool,
|
|
manual_capability_overrides: Vec<String>,
|
|
recognized_capability_overrides: Vec<Capability>,
|
|
unrecognized_capability_overrides: Vec<String>,
|
|
os_arch_capabilities_require_manual_flags: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct SourceProviderBackendStatus {
|
|
provider: String,
|
|
detected: bool,
|
|
available: bool,
|
|
reason: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct NodeEnrollmentPlan {
|
|
grant: String,
|
|
public_key_fingerprint: Digest,
|
|
exchanges_short_lived_grant_for_long_lived_node_identity: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
struct ProjectConfig {
|
|
tenant: String,
|
|
project: String,
|
|
user: String,
|
|
coordinator: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
struct StoredCliSession {
|
|
kind: String,
|
|
coordinator: String,
|
|
tenant: String,
|
|
project: String,
|
|
user: String,
|
|
cli_session_credential_kind: String,
|
|
token_expiry_posture: String,
|
|
expires_at: Option<String>,
|
|
provider_tokens_exposed_to_cli: bool,
|
|
provider_tokens_sent_to_nodes: bool,
|
|
created_at_unix_seconds: u64,
|
|
}
|
|
|
|
fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result<Value> {
|
|
let config = read_project_config(&cwd)?;
|
|
let coordinator = args.scope.coordinator.or_else(|| {
|
|
config
|
|
.as_ref()
|
|
.and_then(|config| config.coordinator.clone())
|
|
});
|
|
let coordinator_reachability = coordinator_reachability(coordinator.as_deref());
|
|
let dependencies = json!({
|
|
"cargo": command_available("cargo"),
|
|
"git": command_available("git"),
|
|
"podman": command_available("podman"),
|
|
"disasmer-node": command_available("disasmer-node") || sibling_binary("disasmer-node").is_some(),
|
|
"disasmer-coordinator": command_available("disasmer-coordinator") || sibling_binary("disasmer-coordinator").is_some(),
|
|
"disasmer-debug-dap": command_available("disasmer-debug-dap") || sibling_binary("disasmer-debug-dap").is_some(),
|
|
});
|
|
let node_readiness = NodeCapabilities::detect_current();
|
|
let node_readiness_summary = node_readiness_summary(&node_readiness, &dependencies);
|
|
Ok(json!({
|
|
"command": "doctor",
|
|
"cwd": cwd,
|
|
"coordinator": coordinator,
|
|
"coordinator_reachability": coordinator_reachability,
|
|
"auth": auth_state_value(&cwd)?,
|
|
"project": config,
|
|
"dependencies": dependencies,
|
|
"node_readiness": node_readiness,
|
|
"node_readiness_summary": node_readiness_summary,
|
|
"next_actions": [
|
|
"disasmer login --browser",
|
|
"disasmer project init",
|
|
"disasmer node attach",
|
|
"disasmer run"
|
|
]
|
|
}))
|
|
}
|
|
|
|
fn node_readiness_summary(capabilities: &NodeCapabilities, dependencies: &Value) -> Value {
|
|
let has_command = capabilities.capabilities.contains(&Capability::Command);
|
|
let has_wasmtime = capabilities.capabilities.contains(&Capability::Wasmtime);
|
|
let has_vfs_artifacts = capabilities
|
|
.capabilities
|
|
.contains(&Capability::VfsArtifacts);
|
|
let has_source_filesystem = capabilities
|
|
.capabilities
|
|
.contains(&Capability::SourceFilesystem);
|
|
let has_container_backend = capabilities
|
|
.environment_backends
|
|
.contains(&disasmer_core::EnvironmentBackend::Container);
|
|
let podman_available = dependencies
|
|
.get("podman")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
let git_available = dependencies
|
|
.get("git")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
let disasmer_node_available = dependencies
|
|
.get("disasmer-node")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
|
|
let mut missing = Vec::new();
|
|
if has_container_backend && !podman_available {
|
|
missing.push("podman");
|
|
}
|
|
if capabilities.source_providers.contains("git") && !git_available {
|
|
missing.push("git");
|
|
}
|
|
if !disasmer_node_available {
|
|
missing.push("disasmer-node");
|
|
}
|
|
|
|
let basic_runtime_ready =
|
|
has_command && has_wasmtime && has_vfs_artifacts && has_source_filesystem;
|
|
let local_dependencies_ready = missing.is_empty();
|
|
let status = if basic_runtime_ready && local_dependencies_ready {
|
|
"ready_to_attach"
|
|
} else if basic_runtime_ready {
|
|
"local_dependencies_missing"
|
|
} else {
|
|
"limited_capabilities"
|
|
};
|
|
let next_actions = if status == "ready_to_attach" {
|
|
vec!["disasmer node enroll", "disasmer node attach --worker"]
|
|
} else {
|
|
vec![
|
|
"install missing local dependencies",
|
|
"rerun disasmer doctor",
|
|
]
|
|
};
|
|
|
|
json!({
|
|
"status": status,
|
|
"explicit_attach_required": true,
|
|
"command_execution_capability": has_command,
|
|
"wasmtime_capability": has_wasmtime,
|
|
"artifact_capability": has_vfs_artifacts,
|
|
"source_filesystem_capability": has_source_filesystem,
|
|
"container_backend_reported": has_container_backend,
|
|
"podman_binary_available": podman_available,
|
|
"git_binary_available": git_available,
|
|
"node_binary_available": disasmer_node_available,
|
|
"missing_local_dependencies": missing,
|
|
"source_providers": capabilities.source_providers.iter().collect::<Vec<_>>(),
|
|
"next_actions": next_actions,
|
|
})
|
|
}
|
|
|
|
fn coordinator_reachability(coordinator: Option<&str>) -> Value {
|
|
let Some(coordinator) = coordinator else {
|
|
return json!({
|
|
"checked": false,
|
|
"status": "not_configured",
|
|
"next_action": "run disasmer login --browser or pass --coordinator"
|
|
});
|
|
};
|
|
|
|
match ping_coordinator(coordinator, DOCTOR_COORDINATOR_TIMEOUT) {
|
|
Ok(response) => json!({
|
|
"checked": true,
|
|
"status": "reachable",
|
|
"coordinator": coordinator,
|
|
"response": response
|
|
}),
|
|
Err(err) => json!({
|
|
"checked": true,
|
|
"status": "unreachable",
|
|
"coordinator": coordinator,
|
|
"error": err.to_string(),
|
|
"next_action": "check the coordinator URL, network, and service status"
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn ping_coordinator(coordinator: &str, timeout: Duration) -> Result<Value> {
|
|
let transport_addr = json_line_transport_addr(coordinator);
|
|
let socket_addrs = transport_addr
|
|
.to_socket_addrs()
|
|
.with_context(|| format!("failed to resolve {coordinator} via {transport_addr}"))?;
|
|
let mut saw_addr = false;
|
|
let mut last_error = None;
|
|
|
|
for socket_addr in socket_addrs {
|
|
saw_addr = true;
|
|
match TcpStream::connect_timeout(&socket_addr, timeout) {
|
|
Ok(stream) => {
|
|
stream
|
|
.set_read_timeout(Some(timeout))
|
|
.with_context(|| format!("failed to set read timeout for {socket_addr}"))?;
|
|
stream
|
|
.set_write_timeout(Some(timeout))
|
|
.with_context(|| format!("failed to set write timeout for {socket_addr}"))?;
|
|
let mut session = JsonLineSession::from_stream(stream)?;
|
|
return session.request(json!({ "type": "ping" })).with_context(|| {
|
|
format!("coordinator ping failed for {coordinator} via {socket_addr}")
|
|
});
|
|
}
|
|
Err(err) => last_error = Some(err),
|
|
}
|
|
}
|
|
|
|
if !saw_addr {
|
|
anyhow::bail!(
|
|
"failed to resolve any socket address for {coordinator} via {transport_addr}"
|
|
);
|
|
}
|
|
|
|
anyhow::bail!(
|
|
"failed to connect to {coordinator} via {transport_addr}: {}",
|
|
last_error
|
|
.map(|err| err.to_string())
|
|
.unwrap_or_else(|| "no socket addresses attempted".to_owned())
|
|
);
|
|
}
|
|
|
|
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_operator_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 coordinator_account_status = configured_coordinator
|
|
.as_ref()
|
|
.map(|coordinator| {
|
|
coordinator_auth_status_summary(coordinator, &tenant, &project, &principal)
|
|
})
|
|
.unwrap_or_else(|| {
|
|
json!({
|
|
"checked": false,
|
|
"reason": "no project or session coordinator configured",
|
|
"suspension_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)?,
|
|
"coordinator_account_status": coordinator_account_status,
|
|
"project_config": config,
|
|
}))
|
|
}
|
|
|
|
fn coordinator_auth_status_summary(
|
|
coordinator: &str,
|
|
tenant: &str,
|
|
project: &str,
|
|
principal: &str,
|
|
) -> 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,
|
|
"private_moderation_details_exposed": false,
|
|
"signup_failure_details_exposed": false,
|
|
"machine_error": cli_error_summary(&message),
|
|
"error": message,
|
|
"next_actions": ["disasmer doctor", "check coordinator status"],
|
|
"coordinator_session_requests": 0,
|
|
});
|
|
}
|
|
};
|
|
let response = match session.request_allow_error(json!({
|
|
"type": "auth_status",
|
|
"tenant": tenant,
|
|
"project": project,
|
|
"actor_user": principal,
|
|
})) {
|
|
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,
|
|
"private_moderation_details_exposed": false,
|
|
"signup_failure_details_exposed": false,
|
|
"machine_error": cli_error_summary(&message),
|
|
"error": message,
|
|
"next_actions": ["disasmer doctor", "check coordinator status"],
|
|
"coordinator_session_requests": session.requests(),
|
|
});
|
|
}
|
|
};
|
|
let coordinator_session_requests = session.requests();
|
|
if response.get("type").and_then(Value::as_str) == Some("error") {
|
|
let message = response
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("coordinator rejected auth status");
|
|
return json!({
|
|
"checked": true,
|
|
"reachable": true,
|
|
"source": "public_coordinator_api",
|
|
"account_status": "unknown",
|
|
"suspension_known": false,
|
|
"private_moderation_details_exposed": false,
|
|
"signup_failure_details_exposed": false,
|
|
"machine_error": cli_error_summary(message),
|
|
"coordinator_response_type": "error",
|
|
"next_actions": ["disasmer doctor", "disasmer login --browser"],
|
|
"coordinator_session_requests": coordinator_session_requests,
|
|
});
|
|
}
|
|
let suspended = response
|
|
.get("suspended")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
let disabled = response
|
|
.get("disabled")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
let account_status = response
|
|
.get("account_status")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned)
|
|
.unwrap_or_else(|| {
|
|
if suspended {
|
|
"suspended"
|
|
} else if disabled {
|
|
"disabled"
|
|
} 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",
|
|
"account_status": account_status,
|
|
"suspension_known": true,
|
|
"suspended": suspended,
|
|
"disabled": disabled,
|
|
"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,
|
|
})
|
|
}
|
|
|
|
fn auth_logout_report(args: AuthLogoutArgs, cwd: PathBuf) -> Result<Value> {
|
|
logout_report(args, cwd, "auth logout")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fn non_interactive_browser_login_report(args: &LoginArgs) -> Value {
|
|
let message =
|
|
"browser login requires an interactive browser, but non-interactive mode is enabled";
|
|
let next_actions = vec![
|
|
"rerun without --non-interactive to open the browser",
|
|
"disasmer login --browser --plan",
|
|
"use DISASMER_AGENT_PUBLIC_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": non_interactive_auth_machine_error(message, next_actions),
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
fn logout_report(args: AuthLogoutArgs, cwd: PathBuf, command: &str) -> Result<Value> {
|
|
let session_file = session_config_file(&cwd);
|
|
if !args.yes {
|
|
return Ok(confirmation_required_report(
|
|
command,
|
|
"delete_cli_session",
|
|
json!({
|
|
"session_file": session_file,
|
|
"node_credentials_untouched": true,
|
|
}),
|
|
format!("disasmer {command} --yes"),
|
|
));
|
|
}
|
|
let 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,
|
|
"node_credentials_untouched": true,
|
|
"session_file": session_file,
|
|
}))
|
|
}
|
|
|
|
fn key_add_report(args: KeyAddArgs) -> Result<Value> {
|
|
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 agent = args.agent.clone();
|
|
let public_key_fingerprint = Digest::sha256(&args.public_key);
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(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_else(|| json!(false)),
|
|
},
|
|
"browser_interaction_required_each_run": record
|
|
.get("browser_interaction_required_each_run")
|
|
.cloned()
|
|
.unwrap_or_else(|| 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,
|
|
}))
|
|
}
|
|
|
|
fn key_list_report(args: KeyListArgs) -> Result<Value> {
|
|
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 mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(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": [],
|
|
}))
|
|
}
|
|
|
|
fn key_revoke_report(args: KeyRevokeArgs) -> Result<Value> {
|
|
if !args.yes {
|
|
return Ok(confirmation_required_report(
|
|
"key revoke",
|
|
"revoke_agent_public_key",
|
|
json!({
|
|
"coordinator": args.scope.coordinator,
|
|
"tenant": args.scope.tenant,
|
|
"project": args.scope.project,
|
|
"user": args.scope.user,
|
|
"agent": args.agent,
|
|
}),
|
|
format!("disasmer key revoke --agent {} --yes", args.agent),
|
|
));
|
|
}
|
|
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 agent = args.agent.clone();
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(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_else(|| 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 project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result<Value> {
|
|
let tenant = args.scope.tenant.clone();
|
|
let project = args.new_project.clone();
|
|
let user = args.scope.user.clone();
|
|
let coordinator = args.scope.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 response = session.request(json!({
|
|
"type": "create_project",
|
|
"tenant": tenant,
|
|
"actor_user": user,
|
|
"project": project,
|
|
"name": name,
|
|
}))?;
|
|
coordinator_session_requests = session.requests();
|
|
Some(response)
|
|
} else {
|
|
None
|
|
};
|
|
write_project_config(&cwd, &config)?;
|
|
let created_or_linked_project = coordinator_response
|
|
.as_ref()
|
|
.and_then(|response| response.get("project"))
|
|
.cloned()
|
|
.unwrap_or_else(|| {
|
|
json!({
|
|
"id": config.project.clone(),
|
|
"tenant": config.tenant.clone(),
|
|
"name": args.name.clone(),
|
|
})
|
|
});
|
|
Ok(json!({
|
|
"command": "project init",
|
|
"source": if coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" },
|
|
"private_website_required": false,
|
|
"project_config_written": true,
|
|
"project_config_write_after_coordinator_acceptance": coordinator.is_some(),
|
|
"coordinator_create_before_local_write": coordinator.is_some(),
|
|
"coordinator_session_requests": coordinator_session_requests,
|
|
"created_or_linked_project": created_or_linked_project,
|
|
"current_directory_link": {
|
|
"cwd": cwd,
|
|
"config_file": config_file,
|
|
"config_format": "disasmer_project_config_v1",
|
|
"links_current_directory": true,
|
|
"writes_current_directory_only": true,
|
|
"private_website_required": false,
|
|
},
|
|
"safe_defaults": {
|
|
"tenant": config.tenant.clone(),
|
|
"project": config.project.clone(),
|
|
"user": config.user.clone(),
|
|
"coordinator": config.coordinator.clone(),
|
|
"project_name": args.name.clone(),
|
|
"default_project_id_used": args.new_project == "project",
|
|
"default_project_name_used": args.name == "Disasmer Project",
|
|
"browser_interaction_required": false,
|
|
"private_website_required": false,
|
|
},
|
|
"project_config": config,
|
|
"config_file": project_config_file(&cwd),
|
|
"coordinator_response": coordinator_response,
|
|
}))
|
|
}
|
|
|
|
fn project_status_report(args: ProjectStatusArgs, cwd: PathBuf) -> Result<Value> {
|
|
let config = read_project_config(&cwd)?;
|
|
let effective_scope = effective_project_scope(&args.scope, config.as_ref());
|
|
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(coordinator.as_deref(), &effective_scope)?;
|
|
let coordinator_response =
|
|
list_task_events_if_available(coordinator.as_deref(), &effective_scope, None)?;
|
|
let discovered_environments = discovered_environment_names(inspection.as_ref());
|
|
let active_process = active_process_from_task_events(coordinator_response.as_ref())
|
|
.unwrap_or_else(|| {
|
|
if coordinator.is_some() {
|
|
"none_observed".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,
|
|
"attached_nodes": attached_nodes,
|
|
"quota_posture": quota_posture,
|
|
"coordinator_response": coordinator_response,
|
|
}))
|
|
}
|
|
|
|
fn project_list_report(args: ProjectListArgs, cwd: PathBuf) -> Result<Value> {
|
|
if let Some(coordinator) = &args.scope.coordinator {
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(json!({
|
|
"type": "list_projects",
|
|
"tenant": args.scope.tenant,
|
|
"actor_user": args.scope.user,
|
|
}))?;
|
|
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": args.scope.tenant,
|
|
"user": args.scope.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,
|
|
}))
|
|
}
|
|
|
|
fn project_select_report(args: ProjectSelectArgs, cwd: PathBuf) -> Result<Value> {
|
|
let config = ProjectConfig {
|
|
tenant: args.scope.tenant.clone(),
|
|
project: args.selected_project.clone(),
|
|
user: args.scope.user.clone(),
|
|
coordinator: args.scope.coordinator.clone(),
|
|
};
|
|
let coordinator_response = if let Some(coordinator) = &args.scope.coordinator {
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
Some(session.request(json!({
|
|
"type": "select_project",
|
|
"tenant": args.scope.tenant,
|
|
"actor_user": args.scope.user,
|
|
"project": args.selected_project,
|
|
}))?)
|
|
} 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 args.scope.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 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();
|
|
let status = if blocking_diagnostic.is_some() {
|
|
"blocked_before_schedule"
|
|
} else {
|
|
"built"
|
|
};
|
|
let mut report = json!({
|
|
"command": "build",
|
|
"status": status,
|
|
"bundle": inspection,
|
|
"diagnostics": diagnostics,
|
|
"contains_full_repository_upload": false,
|
|
"content_addressed": true,
|
|
"debug_metadata_available": true,
|
|
"scheduled_work": false,
|
|
});
|
|
if let Some(diagnostic) = blocking_diagnostic.as_ref() {
|
|
let machine_category = match diagnostic.category.as_str() {
|
|
"environment" => "environment",
|
|
"source_provider" | "capability" => "capability",
|
|
_ => "unknown",
|
|
};
|
|
if let Some(report) = report.as_object_mut() {
|
|
report.insert(
|
|
"machine_error".to_owned(),
|
|
cli_error_summary_for_category(machine_category, &diagnostic.message),
|
|
);
|
|
}
|
|
}
|
|
if let Some(output) = args.output {
|
|
if let Some(parent) = output.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
std::fs::write(&output, serde_json::to_vec_pretty(&report)?)?;
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
fn node_enroll_report(args: NodeEnrollArgs) -> Result<Value> {
|
|
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 requested_grant = args.grant.clone();
|
|
let ttl_seconds = args.ttl_seconds;
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(json!({
|
|
"type": "create_node_enrollment_grant",
|
|
"tenant": tenant,
|
|
"project": project,
|
|
"actor_user": user,
|
|
"grant": requested_grant,
|
|
"now_epoch_seconds": 0,
|
|
"ttl_seconds": ttl_seconds,
|
|
}))?;
|
|
let enrollment_grant = node_enrollment_grant_summary(
|
|
&response,
|
|
&tenant,
|
|
&project,
|
|
&user,
|
|
&requested_grant,
|
|
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(),
|
|
}));
|
|
}
|
|
let enrollment_grant = json!({
|
|
"grant": args.grant,
|
|
"tenant": args.scope.tenant,
|
|
"project": args.scope.project,
|
|
"user": args.scope.user,
|
|
"scope": "node:attach",
|
|
"ttl_seconds": args.ttl_seconds,
|
|
"short_lived": true,
|
|
"exchange_for_persistent_node_identity": true,
|
|
"node_credentials_separate_from_user_session": true,
|
|
});
|
|
Ok(json!({
|
|
"command": "node enroll",
|
|
"status": "planned_without_coordinator",
|
|
"private_website_required": false,
|
|
"enrollment_grant": enrollment_grant,
|
|
}))
|
|
}
|
|
|
|
fn node_enrollment_grant_summary(
|
|
response: &Value,
|
|
tenant: &str,
|
|
project: &str,
|
|
user: &str,
|
|
requested_grant: &str,
|
|
ttl_seconds: u64,
|
|
) -> Value {
|
|
json!({
|
|
"grant": response
|
|
.get("grant")
|
|
.cloned()
|
|
.unwrap_or_else(|| json!(requested_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,
|
|
})
|
|
}
|
|
|
|
fn node_list_report(args: NodeListArgs) -> Result<Value> {
|
|
node_descriptors_report("node list", args.scope, None)
|
|
}
|
|
|
|
fn node_status_report(args: NodeStatusArgs) -> Result<Value> {
|
|
node_descriptors_report("node status", args.scope, args.node)
|
|
}
|
|
|
|
fn node_descriptors_report(
|
|
command: &str,
|
|
scope: CliScopeArgs,
|
|
node: Option<String>,
|
|
) -> Result<Value> {
|
|
if let Some(coordinator) = &scope.coordinator {
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(json!({
|
|
"type": "list_node_descriptors",
|
|
"tenant": scope.tenant,
|
|
"project": scope.project,
|
|
"actor_user": scope.user,
|
|
}))?;
|
|
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(),
|
|
}))
|
|
}
|
|
|
|
fn node_revoke_report(args: NodeRevokeArgs) -> Result<Value> {
|
|
if !args.yes {
|
|
return Ok(confirmation_required_report(
|
|
"node revoke",
|
|
"revoke_node_credential",
|
|
json!({
|
|
"coordinator": args.scope.coordinator,
|
|
"tenant": args.scope.tenant,
|
|
"project": args.scope.project,
|
|
"user": args.scope.user,
|
|
"node": args.node,
|
|
}),
|
|
format!("disasmer node revoke --node {} --yes", args.node),
|
|
));
|
|
}
|
|
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 node = args.node.clone();
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(json!({
|
|
"type": "revoke_node_credential",
|
|
"tenant": tenant,
|
|
"project": project,
|
|
"actor_user": user,
|
|
"node": node,
|
|
}))?;
|
|
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_else(|| 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 process_status_report(args: ProcessStatusArgs) -> Result<Value> {
|
|
let events = list_task_events_if_available(
|
|
args.scope.coordinator.as_deref(),
|
|
&args.scope,
|
|
Some(args.process.clone()),
|
|
)?;
|
|
let current_tasks = task_summaries(events.as_ref());
|
|
let current_task_count = current_tasks.as_array().map(Vec::len).unwrap_or(0);
|
|
let state = process_state_from_tasks(events.as_ref());
|
|
Ok(json!({
|
|
"command": "process status",
|
|
"process": args.process,
|
|
"state": if events.is_some() { state } else { "unknown_without_coordinator" },
|
|
"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,
|
|
}))
|
|
}
|
|
|
|
fn process_restart_report(args: ProcessRestartArgs) -> Result<Value> {
|
|
if !args.yes {
|
|
return Ok(confirmation_required_report(
|
|
"process restart",
|
|
"restart_virtual_process",
|
|
json!({
|
|
"coordinator": args.scope.coordinator,
|
|
"tenant": args.scope.tenant,
|
|
"project": args.scope.project,
|
|
"process": args.process,
|
|
}),
|
|
format!("disasmer process restart --process {} --yes", args.process),
|
|
));
|
|
}
|
|
if let Some(coordinator) = &args.scope.coordinator {
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(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,
|
|
},
|
|
}))
|
|
}
|
|
|
|
fn process_cancel_report(args: ProcessCancelArgs) -> Result<Value> {
|
|
if !args.yes {
|
|
return Ok(confirmation_required_report(
|
|
"process cancel",
|
|
"cancel_virtual_process",
|
|
json!({
|
|
"coordinator": args.scope.coordinator,
|
|
"tenant": args.scope.tenant,
|
|
"project": args.scope.project,
|
|
"process": args.process,
|
|
"node": args.node,
|
|
"task": args.task,
|
|
}),
|
|
format!("disasmer process cancel --process {} --yes", args.process),
|
|
));
|
|
}
|
|
if let Some(coordinator) = &args.scope.coordinator {
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(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,
|
|
},
|
|
}))
|
|
}
|
|
|
|
fn task_list_report(args: TaskListArgs) -> Result<Value> {
|
|
let events = list_task_events_if_available(
|
|
args.scope.coordinator.as_deref(),
|
|
&args.scope,
|
|
args.process.clone(),
|
|
)?;
|
|
let tasks = task_summaries(events.as_ref());
|
|
Ok(json!({
|
|
"command": "task list",
|
|
"process": args.process,
|
|
"tasks": tasks,
|
|
"events": events,
|
|
}))
|
|
}
|
|
|
|
fn task_restart_report(args: TaskRestartArgs) -> Result<Value> {
|
|
if !args.yes {
|
|
return Ok(confirmation_required_report(
|
|
"task restart",
|
|
"restart_selected_task",
|
|
json!({
|
|
"coordinator": args.scope.coordinator,
|
|
"tenant": args.scope.tenant,
|
|
"project": args.scope.project,
|
|
"process": args.process,
|
|
"task": args.task,
|
|
}),
|
|
format!(
|
|
"disasmer task restart {} --process {} --yes",
|
|
args.task, args.process
|
|
),
|
|
));
|
|
}
|
|
if let Some(coordinator) = &args.scope.coordinator {
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request_allow_error(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,
|
|
},
|
|
}))
|
|
}
|
|
|
|
fn logs_report(args: LogsArgs) -> Result<Value> {
|
|
let events = list_task_events_if_available(
|
|
args.scope.coordinator.as_deref(),
|
|
&args.scope,
|
|
args.process.clone(),
|
|
)?;
|
|
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,
|
|
}))
|
|
}
|
|
|
|
fn artifact_list_report(args: ArtifactListArgs) -> Result<Value> {
|
|
let events = list_task_events_if_available(
|
|
args.scope.coordinator.as_deref(),
|
|
&args.scope,
|
|
args.process.clone(),
|
|
)?;
|
|
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,
|
|
}))
|
|
}
|
|
|
|
fn artifact_download_report(args: ArtifactDownloadArgs) -> Result<Value> {
|
|
if let Some(coordinator) = &args.scope.coordinator {
|
|
let nonce = command_nonce("artifact-download");
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(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,
|
|
"token_nonce": nonce,
|
|
"now_epoch_seconds": 0,
|
|
}))?;
|
|
let download_session = artifact_download_session_summary(&response);
|
|
let grant_disclosures = artifact_download_grant_disclosures(&response);
|
|
return Ok(json!({
|
|
"command": "artifact download",
|
|
"coordinator": coordinator,
|
|
"artifact": args.artifact,
|
|
"max_bytes": args.max_bytes,
|
|
"download_session": download_session,
|
|
"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,
|
|
"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": [],
|
|
}))
|
|
}
|
|
|
|
fn artifact_export_report(args: ArtifactExportArgs) -> Result<Value> {
|
|
if let Some(coordinator) = &args.scope.coordinator {
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(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, &args, &response)?
|
|
} 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,
|
|
args: &ArtifactExportArgs,
|
|
export_response: &Value,
|
|
) -> Result<Value> {
|
|
let artifact_size = export_response
|
|
.get("artifact_size_bytes")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(DEFAULT_ARTIFACT_EXPORT_MAX_BYTES);
|
|
let nonce = command_nonce("artifact-export");
|
|
let link_response = session.request(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,
|
|
"token_nonce": nonce,
|
|
"now_epoch_seconds": 0,
|
|
}))?;
|
|
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,
|
|
}));
|
|
}
|
|
|
|
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 stream_response = session.request(json!({
|
|
"type": "open_artifact_download_stream",
|
|
"tenant": args.scope.tenant,
|
|
"project": args.scope.project,
|
|
"actor_user": args.scope.user,
|
|
"artifact": args.artifact,
|
|
"max_bytes": DEFAULT_ARTIFACT_EXPORT_MAX_BYTES,
|
|
"token_nonce": nonce,
|
|
"token_digest": token_digest,
|
|
"now_epoch_seconds": 1,
|
|
"chunk_bytes": artifact_size,
|
|
}))?;
|
|
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": &args.to,
|
|
"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 Some(content_base64) = stream_response
|
|
.get("content_base64")
|
|
.and_then(Value::as_str)
|
|
else {
|
|
return Ok(json!({
|
|
"status": "download_stream_opened_without_content",
|
|
"explicit_user_action": true,
|
|
"local_path": &args.to,
|
|
"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",
|
|
"artifact download stream opened without content"
|
|
),
|
|
"stream": artifact_stream_summary(&stream_response),
|
|
}));
|
|
};
|
|
let bytes = BASE64_STANDARD
|
|
.decode(content_base64)
|
|
.context("artifact download stream returned invalid base64 content")?;
|
|
if let Some(parent) = args.to.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
|
}
|
|
std::fs::write(&args.to, &bytes)
|
|
.with_context(|| format!("failed to write {}", args.to.display()))?;
|
|
|
|
Ok(json!({
|
|
"status": "local_bytes_written",
|
|
"explicit_user_action": true,
|
|
"local_path": &args.to,
|
|
"local_bytes_written_by_cli": true,
|
|
"bytes_written": bytes.len(),
|
|
"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),
|
|
}))
|
|
}
|
|
|
|
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_else(|| 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_else(|| 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_else(|| 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),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
}))
|
|
}
|
|
|
|
fn exec_dap(args: DapArgs) -> Result<()> {
|
|
let status = Command::new(dap_binary_path()?)
|
|
.args(args.args)
|
|
.status()
|
|
.context("failed to launch disasmer-debug-dap")?;
|
|
if !status.success() {
|
|
anyhow::bail!("disasmer-debug-dap exited with {status}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn debug_attach_report(args: DebugAttachArgs) -> Result<Value> {
|
|
let dap = dap_binary_path()?.display().to_string();
|
|
debug_attach_report_with_dap(args, dap)
|
|
}
|
|
|
|
fn debug_attach_report_with_dap(args: DebugAttachArgs, dap: String) -> Result<Value> {
|
|
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(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_else(|| 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,
|
|
}))
|
|
}
|
|
|
|
fn quota_status_report(args: QuotaStatusArgs, cwd: PathBuf) -> Result<Value> {
|
|
let config = read_project_config(&cwd)?;
|
|
let effective_scope = effective_project_scope(&args.scope, config.as_ref());
|
|
let coordinator = effective_scope.coordinator.clone();
|
|
let attached_nodes =
|
|
list_attached_nodes_if_available(coordinator.as_deref(), &effective_scope)?;
|
|
let task_events =
|
|
list_task_events_if_available(coordinator.as_deref(), &effective_scope, None)?;
|
|
let current_usage = quota_current_usage(&attached_nodes, task_events.as_ref());
|
|
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": quota_limits_value(),
|
|
"current_usage": current_usage,
|
|
"attached_nodes": attached_nodes,
|
|
"task_events": task_events,
|
|
"next_blocked_action": quota_next_blocked_action(¤t_usage),
|
|
"quota_tier": "community tier",
|
|
"community_tier_label": "community tier",
|
|
"community_tier_language": true,
|
|
"private_abuse_heuristics_exposed": false,
|
|
}))
|
|
}
|
|
|
|
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 mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(json!({
|
|
"type": "admin_status",
|
|
"tenant": tenant,
|
|
"actor_user": user,
|
|
}))?;
|
|
return Ok(json!({
|
|
"command": "admin status",
|
|
"coordinator": coordinator,
|
|
"tenant": tenant,
|
|
"user": user,
|
|
"suspended": response
|
|
.get("suspended")
|
|
.cloned()
|
|
.unwrap_or_else(|| 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,
|
|
}))
|
|
}
|
|
|
|
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_else(|| json!(false)),
|
|
"project_init": project_init,
|
|
"admin_surfaces": {
|
|
"coordinator": "disasmer-coordinator",
|
|
"project": "disasmer project init/status/list/select",
|
|
"node": "disasmer node enroll/list/status/revoke",
|
|
"process": "disasmer run/process status/process restart/process cancel",
|
|
"logs": "disasmer logs",
|
|
"artifacts": "disasmer artifact list/download/export",
|
|
"quota": "disasmer quota status",
|
|
"policy": "disasmer admin status/suspend-tenant",
|
|
},
|
|
"bootstrap_sequence": [
|
|
{
|
|
"step": "start_self_hosted_coordinator",
|
|
"command": "disasmer-coordinator --listen 127.0.0.1:0",
|
|
"private_website_required": false,
|
|
},
|
|
{
|
|
"step": "create_or_link_project",
|
|
"command": "disasmer project init --yes",
|
|
"completed": true,
|
|
"private_website_required": false,
|
|
},
|
|
{
|
|
"step": "create_node_enrollment_grant",
|
|
"command": format!(
|
|
"disasmer node enroll --coordinator {coordinator} --tenant {} --project-id {}",
|
|
tenant, project
|
|
),
|
|
"private_website_required": false,
|
|
},
|
|
{
|
|
"step": "attach_worker_node",
|
|
"command": format!(
|
|
"disasmer node attach --coordinator {coordinator} --tenant {} --project-id {} --worker",
|
|
tenant, project
|
|
),
|
|
"private_website_required": false,
|
|
},
|
|
{
|
|
"step": "run_process",
|
|
"command": format!(
|
|
"disasmer run --coordinator {coordinator} --tenant {} --project-id {}",
|
|
tenant, project
|
|
),
|
|
"private_website_required": false,
|
|
},
|
|
{
|
|
"step": "inspect_status_logs_artifacts",
|
|
"commands": [
|
|
"disasmer process status",
|
|
"disasmer task list",
|
|
"disasmer logs",
|
|
"disasmer artifact list",
|
|
"disasmer quota status",
|
|
],
|
|
"private_website_required": false,
|
|
},
|
|
{
|
|
"step": "revoke_access",
|
|
"command": "disasmer admin revoke-node --node <node-id> --yes",
|
|
"private_website_required": false,
|
|
}
|
|
],
|
|
}))
|
|
}
|
|
|
|
fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result<Value> {
|
|
let tenant = args
|
|
.target_tenant
|
|
.unwrap_or_else(|| args.scope.tenant.clone());
|
|
if !args.yes {
|
|
return Ok(confirmation_required_report(
|
|
"admin suspend-tenant",
|
|
"suspend_tenant",
|
|
json!({
|
|
"coordinator": args.scope.coordinator,
|
|
"actor_tenant": args.scope.tenant,
|
|
"actor_user": args.scope.user,
|
|
"target_tenant": tenant,
|
|
}),
|
|
"disasmer admin suspend-tenant --yes".to_owned(),
|
|
));
|
|
}
|
|
if let Some(coordinator) = &args.scope.coordinator {
|
|
let actor_tenant = args.scope.tenant.clone();
|
|
let actor_user = args.scope.user.clone();
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(json!({
|
|
"type": "suspend_tenant",
|
|
"tenant": actor_tenant,
|
|
"actor_user": actor_user,
|
|
"target_tenant": tenant,
|
|
}))?;
|
|
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 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(())
|
|
}
|
|
|
|
fn human_report(value: &Value) -> String {
|
|
let mut lines = Vec::new();
|
|
let title = value
|
|
.get("command")
|
|
.and_then(Value::as_str)
|
|
.map(|command| format!("Disasmer {command}"))
|
|
.or_else(|| {
|
|
if value.get("human_flow").is_some() {
|
|
Some("Disasmer login".to_owned())
|
|
} else if value.get("metadata").is_some()
|
|
&& value.get("source_provider_manifest").is_some()
|
|
{
|
|
Some("Disasmer bundle inspect".to_owned())
|
|
} else if value.get("entry").is_some() && value.get("session").is_some() {
|
|
Some("Disasmer run".to_owned())
|
|
} else if value.get("capabilities").is_some() && value.get("node").is_some() {
|
|
Some("Disasmer node attach".to_owned())
|
|
} else if value.get("public_key_fingerprint").is_some() {
|
|
Some("Disasmer agent enroll".to_owned())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or_else(|| "Disasmer report".to_owned());
|
|
lines.push(title);
|
|
|
|
push_string_field(&mut lines, value, "status", "status");
|
|
push_string_field(&mut lines, value, "coordinator", "coordinator");
|
|
push_string_field(&mut lines, value, "active_coordinator", "coordinator");
|
|
push_string_field(&mut lines, value, "tenant", "tenant");
|
|
push_string_field(&mut lines, value, "project", "project");
|
|
push_string_field(&mut lines, value, "process", "process");
|
|
push_string_field(&mut lines, value, "active_process", "active process");
|
|
push_string_field(&mut lines, value, "node", "node");
|
|
push_string_field(&mut lines, value, "artifact", "artifact");
|
|
push_string_field(&mut lines, value, "entry", "entry");
|
|
push_string_field(&mut lines, value, "quota_tier", "quota tier");
|
|
push_string_field(&mut lines, value, "config_file", "config");
|
|
push_string_field(&mut lines, value, "adapter", "adapter");
|
|
|
|
if let Some(project) = value.get("project").and_then(Value::as_str) {
|
|
if !lines
|
|
.iter()
|
|
.any(|line| line == &format!("project: {project}"))
|
|
{
|
|
lines.push(format!("project: {project}"));
|
|
}
|
|
}
|
|
if let Some(project_config) = value.get("project_config").or_else(|| value.get("project")) {
|
|
if project_config.is_object() {
|
|
push_nested_string_field(&mut lines, project_config, "tenant", "tenant");
|
|
push_nested_string_field(&mut lines, project_config, "project", "project");
|
|
push_nested_string_field(&mut lines, project_config, "coordinator", "coordinator");
|
|
}
|
|
}
|
|
if let Some(link) = value.get("current_directory_link") {
|
|
if link
|
|
.get("links_current_directory")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
{
|
|
lines.push("current directory linked: true".to_owned());
|
|
}
|
|
push_nested_string_field(&mut lines, link, "config_file", "current directory config");
|
|
}
|
|
if let Some(metadata) = value.get("metadata") {
|
|
push_nested_string_field(&mut lines, metadata, "identity", "bundle");
|
|
if let Some(environments) = metadata.get("environments").and_then(Value::as_array) {
|
|
let names = environments
|
|
.iter()
|
|
.filter_map(|env| env.get("name").and_then(Value::as_str))
|
|
.collect::<Vec<_>>();
|
|
if !names.is_empty() {
|
|
lines.push(format!("environments: {}", names.join(", ")));
|
|
}
|
|
}
|
|
}
|
|
if let Some(bundle) = value.get("bundle") {
|
|
if let Some(metadata) = bundle.get("metadata") {
|
|
push_nested_string_field(&mut lines, metadata, "identity", "bundle");
|
|
}
|
|
}
|
|
if let Some(environments) = value
|
|
.get("discovered_environments")
|
|
.and_then(Value::as_array)
|
|
{
|
|
let names = environments
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.collect::<Vec<_>>();
|
|
if !names.is_empty() {
|
|
lines.push(format!("environments: {}", names.join(", ")));
|
|
}
|
|
}
|
|
if let Some(attached_nodes) = value.get("attached_nodes") {
|
|
if let Some(count) = attached_nodes.get("count").and_then(Value::as_u64) {
|
|
let online = attached_nodes
|
|
.get("online")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0);
|
|
lines.push(format!("attached nodes: {count} ({online} online)"));
|
|
}
|
|
}
|
|
if let Some(current_usage) = value.pointer("/quota_posture/current_usage") {
|
|
lines.push(format!("quota usage: {}", compact_json(current_usage)));
|
|
}
|
|
if let Some(current_task_count) = value.get("current_task_count").and_then(Value::as_u64) {
|
|
lines.push(format!("tasks: {current_task_count}"));
|
|
}
|
|
if let Some(tasks) = value.get("current_tasks").and_then(Value::as_array) {
|
|
push_task_placement_reasons(&mut lines, tasks);
|
|
push_task_locality_failures(&mut lines, tasks);
|
|
}
|
|
if let Some(tasks) = value.get("tasks").and_then(Value::as_array) {
|
|
lines.push(format!("tasks: {}", tasks.len()));
|
|
push_task_placement_reasons(&mut lines, tasks);
|
|
push_task_locality_failures(&mut lines, tasks);
|
|
}
|
|
if let Some(log_entries) = value.get("log_entries").and_then(Value::as_array) {
|
|
lines.push(format!("log entries: {}", log_entries.len()));
|
|
}
|
|
if let Some(artifacts) = value.get("artifacts").and_then(Value::as_array) {
|
|
lines.push(format!("artifacts: {}", artifacts.len()));
|
|
}
|
|
if let Some(statuses) = value
|
|
.get("source_provider_statuses")
|
|
.and_then(Value::as_array)
|
|
{
|
|
push_source_provider_statuses(&mut lines, statuses);
|
|
}
|
|
if let Some(bundle_statuses) = value
|
|
.pointer("/bundle/source_provider_statuses")
|
|
.and_then(Value::as_array)
|
|
{
|
|
push_source_provider_statuses(&mut lines, bundle_statuses);
|
|
}
|
|
if let Some(diagnostics) = value
|
|
.get("pre_schedule_diagnostics")
|
|
.or_else(|| value.get("diagnostics"))
|
|
.and_then(Value::as_array)
|
|
{
|
|
push_cli_diagnostics(&mut lines, diagnostics);
|
|
}
|
|
if let Some(bundle_diagnostics) = value
|
|
.pointer("/bundle/pre_schedule_diagnostics")
|
|
.and_then(Value::as_array)
|
|
{
|
|
push_cli_diagnostics(&mut lines, bundle_diagnostics);
|
|
}
|
|
if let Some(current_usage) = value.get("current_usage") {
|
|
lines.push(format!("quota usage: {}", compact_json(current_usage)));
|
|
}
|
|
if let Some(next_blocked_action) = value.get("next_blocked_action") {
|
|
if !next_blocked_action.is_null() {
|
|
lines.push(format!(
|
|
"next blocked action: {}",
|
|
compact_json(next_blocked_action)
|
|
));
|
|
}
|
|
}
|
|
push_machine_error_line(&mut lines, value.get("machine_error"));
|
|
push_machine_error_line(&mut lines, value.pointer("/run_start/machine_error"));
|
|
push_machine_error_line(&mut lines, value.pointer("/download_session/machine_error"));
|
|
push_machine_error_line(&mut lines, value.pointer("/export_plan/machine_error"));
|
|
push_machine_error_line(&mut lines, value.pointer("/local_export/machine_error"));
|
|
push_machine_error_line(
|
|
&mut lines,
|
|
value.pointer("/local_export/download_session/machine_error"),
|
|
);
|
|
push_machine_error_line(
|
|
&mut lines,
|
|
value.pointer("/local_export/stream/machine_error"),
|
|
);
|
|
if let Some(flow) = value.get("human_flow") {
|
|
if let Some(device) = flow.get("Device") {
|
|
lines.push("flow: device".to_owned());
|
|
push_nested_string_field(&mut lines, device, "verification_url", "verify");
|
|
push_nested_string_field(&mut lines, device, "user_code", "code");
|
|
} else if let Some(browser) = flow.get("Browser") {
|
|
lines.push("flow: browser".to_owned());
|
|
push_nested_string_field(&mut lines, browser, "authorization_url", "open");
|
|
push_nested_string_field(&mut lines, browser, "callback_path", "callback");
|
|
}
|
|
}
|
|
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}"));
|
|
}
|
|
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())
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fn cli_error_summary(message: &str) -> Value {
|
|
let category = classify_cli_error_message(message);
|
|
cli_error_summary_for_category(category, message)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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("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"
|
|
}
|
|
|
|
fn message_mentions_locality_failure(message: &str) -> bool {
|
|
message.contains("direct connectivity unavailable")
|
|
|| message.contains("direct transfer")
|
|
|| message.contains("source snapshot unavailable")
|
|
|| (message.contains("required artifact")
|
|
&& message.contains("unavailable")
|
|
&& message.contains("direct connectivity"))
|
|
|| message.contains("locality assumption")
|
|
}
|
|
|
|
fn cli_error_exit_code(category: &str) -> i64 {
|
|
match category {
|
|
"authentication" => 20,
|
|
"authorization" => 21,
|
|
"quota" => 22,
|
|
"policy" => 23,
|
|
"capability" => 24,
|
|
"connectivity" => 25,
|
|
"environment" => 26,
|
|
"program" => 27,
|
|
"active_process" => 28,
|
|
_ => 1,
|
|
}
|
|
}
|
|
|
|
fn cli_error_retryable_after_user_action(category: &str) -> bool {
|
|
matches!(
|
|
category,
|
|
"authentication"
|
|
| "authorization"
|
|
| "quota"
|
|
| "policy"
|
|
| "capability"
|
|
| "connectivity"
|
|
| "environment"
|
|
| "program"
|
|
| "active_process"
|
|
)
|
|
}
|
|
|
|
fn cli_error_next_actions(category: &str) -> Vec<&'static str> {
|
|
match category {
|
|
"authentication" => vec!["disasmer login --browser", "disasmer auth status"],
|
|
"authorization" => vec![
|
|
"disasmer auth status",
|
|
"check tenant/project selection",
|
|
"ask an admin to grant access",
|
|
],
|
|
"quota" => vec![
|
|
"disasmer quota status",
|
|
"reduce concurrent work or wait for usage to fall",
|
|
],
|
|
"policy" => vec![
|
|
"disasmer doctor",
|
|
"check coordinator policy for this action",
|
|
],
|
|
"capability" => vec![
|
|
"disasmer node list",
|
|
"attach a node with the required capabilities",
|
|
"check tenant/project on the attached node",
|
|
],
|
|
"connectivity" => vec![
|
|
"disasmer doctor",
|
|
"check the coordinator endpoint and network reachability",
|
|
],
|
|
"environment" => vec![
|
|
"disasmer inspect",
|
|
"check envs/<name>/Containerfile or envs/<name>/Dockerfile",
|
|
],
|
|
"program" => vec!["disasmer logs", "fix the program or task command and rerun"],
|
|
"active_process" => vec![
|
|
"disasmer process status",
|
|
"disasmer logs",
|
|
"disasmer process restart --yes",
|
|
"disasmer process cancel --yes",
|
|
"wait for the active process to finish",
|
|
],
|
|
_ => vec![
|
|
"disasmer doctor",
|
|
"rerun with --json for machine-readable details",
|
|
],
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
if let Err(error) = 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);
|
|
}
|
|
}
|
|
|
|
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 json_output = args.json;
|
|
if args.non_interactive
|
|
&& args.browser
|
|
&& !args.plan
|
|
&& args.complete_browser_code.is_none()
|
|
{
|
|
let report = non_interactive_browser_login_report(&args);
|
|
emit_report(&report, json_output)?;
|
|
} else if args.complete_browser_code.is_some() {
|
|
let report = execute_browser_login_completion(args)?;
|
|
emit_report(&report, json_output)?;
|
|
} else if args.browser && !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::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 (report, json_output) = match command {
|
|
KeyCommands::Add(args) => {
|
|
let json_output = args.scope.json;
|
|
(key_add_report(args)?, json_output)
|
|
}
|
|
KeyCommands::List(args) => {
|
|
let json_output = args.scope.json;
|
|
(key_list_report(args)?, json_output)
|
|
}
|
|
KeyCommands::Revoke(args) => {
|
|
let json_output = args.scope.json;
|
|
(key_revoke_report(args)?, 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 (report, json_output) = match command {
|
|
NodeCommands::Enroll(args) => {
|
|
let json_output = args.scope.json;
|
|
(node_enroll_report(args)?, json_output)
|
|
}
|
|
NodeCommands::List(args) => {
|
|
let json_output = args.scope.json;
|
|
(node_list_report(args)?, json_output)
|
|
}
|
|
NodeCommands::Status(args) => {
|
|
let json_output = args.scope.json;
|
|
(node_status_report(args)?, json_output)
|
|
}
|
|
NodeCommands::Revoke(args) => {
|
|
let json_output = args.scope.json;
|
|
(node_revoke_report(args)?, json_output)
|
|
}
|
|
NodeCommands::Attach(_) => unreachable!("node attach is handled above"),
|
|
};
|
|
emit_report(&report, json_output)?;
|
|
}
|
|
Commands::Process { command } => {
|
|
let (report, json_output) = match command {
|
|
ProcessCommands::Status(args) => {
|
|
let json_output = args.scope.json;
|
|
(process_status_report(args)?, json_output)
|
|
}
|
|
ProcessCommands::Restart(args) => {
|
|
let json_output = args.scope.json;
|
|
(process_restart_report(args)?, json_output)
|
|
}
|
|
ProcessCommands::Cancel(args) => {
|
|
let json_output = args.scope.json;
|
|
(process_cancel_report(args)?, json_output)
|
|
}
|
|
};
|
|
emit_report(&report, json_output)?;
|
|
}
|
|
Commands::Task { command } => {
|
|
let (report, json_output) = match command {
|
|
TaskCommands::List(args) => {
|
|
let json_output = args.scope.json;
|
|
(task_list_report(args)?, json_output)
|
|
}
|
|
TaskCommands::Restart(args) => {
|
|
let json_output = args.scope.json;
|
|
(task_restart_report(args)?, json_output)
|
|
}
|
|
};
|
|
emit_report(&report, json_output)?;
|
|
}
|
|
Commands::Logs(args) => {
|
|
let json_output = args.scope.json;
|
|
emit_report(&logs_report(args)?, json_output)?;
|
|
}
|
|
Commands::Artifact { command } => {
|
|
let (report, json_output) = match command {
|
|
ArtifactCommands::List(args) => {
|
|
let json_output = args.scope.json;
|
|
(artifact_list_report(args)?, json_output)
|
|
}
|
|
ArtifactCommands::Download(args) => {
|
|
let json_output = args.scope.json;
|
|
(artifact_download_report(args)?, json_output)
|
|
}
|
|
ArtifactCommands::Export(args) => {
|
|
let json_output = args.scope.json;
|
|
(artifact_export_report(args)?, 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;
|
|
emit_report(&debug_attach_report(args)?, 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)?, 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(())
|
|
}
|
|
|
|
fn project_config_file(project: &Path) -> PathBuf {
|
|
project.join(".disasmer").join("project.json")
|
|
}
|
|
|
|
fn session_config_file(project: &Path) -> PathBuf {
|
|
project.join(".disasmer").join("session.json")
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
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()))
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
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()))?;
|
|
}
|
|
std::fs::write(&file, serde_json::to_vec_pretty(session)?)
|
|
.with_context(|| format!("failed to write {}", file.display()))?;
|
|
Ok(file)
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
fn auth_state_value(cwd: &Path) -> Result<Value> {
|
|
match session_from_env() {
|
|
CliSession::Anonymous => {
|
|
if let Some(session) = read_cli_session(cwd)? {
|
|
return Ok(json!({
|
|
"kind": session.kind,
|
|
"authenticated": true,
|
|
"source": "session_file",
|
|
"coordinator": session.coordinator,
|
|
"tenant": session.tenant,
|
|
"project": session.project,
|
|
"principal": session.user,
|
|
"cli_session_credential_kind": session.cli_session_credential_kind,
|
|
"provider_tokens_exposed_to_cli": session.provider_tokens_exposed_to_cli,
|
|
"provider_tokens_exposed_to_nodes": session.provider_tokens_sent_to_nodes,
|
|
"expires_at": session.expires_at,
|
|
"token_expiry_posture": session.token_expiry_posture,
|
|
}));
|
|
}
|
|
Ok(json!({
|
|
"kind": "anonymous",
|
|
"authenticated": false,
|
|
"source": "environment",
|
|
"token_expiry_posture": "no_session",
|
|
}))
|
|
}
|
|
CliSession::HumanSession => {
|
|
let expires_at = std::env::var("DISASMER_TOKEN_EXPIRES_AT").ok();
|
|
Ok(json!({
|
|
"kind": "human",
|
|
"authenticated": true,
|
|
"source": "DISASMER_TOKEN",
|
|
"provider_tokens_exposed_to_nodes": false,
|
|
"expires_at": expires_at,
|
|
"token_expiry_posture": if expires_at.is_some() { "expires_at" } else { "unknown_env_token" },
|
|
}))
|
|
}
|
|
CliSession::AgentPublicKey {
|
|
agent,
|
|
public_key_fingerprint,
|
|
browser_interaction_required,
|
|
} => Ok(json!({
|
|
"kind": "agent_public_key",
|
|
"authenticated": true,
|
|
"agent": agent,
|
|
"source": "DISASMER_AGENT_PUBLIC_KEY",
|
|
"public_key_fingerprint": public_key_fingerprint,
|
|
"browser_interaction_required": browser_interaction_required,
|
|
"token_expiry_posture": "not_applicable_public_key",
|
|
})),
|
|
}
|
|
}
|
|
|
|
fn command_available(command: &str) -> bool {
|
|
Command::new(command)
|
|
.arg("--version")
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.is_ok()
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
fn dap_binary_path() -> Result<PathBuf> {
|
|
if let Some(path) = std::env::var_os("DISASMER_DAP_BIN") {
|
|
return Ok(PathBuf::from(path));
|
|
}
|
|
if let Some(path) = sibling_binary("disasmer-debug-dap") {
|
|
return Ok(path);
|
|
}
|
|
let release = PathBuf::from("target/release").join(format!(
|
|
"disasmer-debug-dap{}",
|
|
std::env::consts::EXE_SUFFIX
|
|
));
|
|
if release.is_file() {
|
|
return Ok(release);
|
|
}
|
|
let debug = PathBuf::from("target/debug").join(format!(
|
|
"disasmer-debug-dap{}",
|
|
std::env::consts::EXE_SUFFIX
|
|
));
|
|
if debug.is_file() {
|
|
return Ok(debug);
|
|
}
|
|
anyhow::bail!("could not locate disasmer-debug-dap; set DISASMER_DAP_BIN")
|
|
}
|
|
|
|
fn command_nonce(prefix: &str) -> String {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_nanos())
|
|
.unwrap_or_default();
|
|
format!("{prefix}-{now}-{}", std::process::id())
|
|
}
|
|
|
|
fn list_task_events_if_available(
|
|
coordinator: Option<&str>,
|
|
scope: &CliScopeArgs,
|
|
process: Option<String>,
|
|
) -> Result<Option<Value>> {
|
|
let Some(coordinator) = coordinator else {
|
|
return Ok(None);
|
|
};
|
|
let mut session = JsonLineSession::connect(coordinator)?;
|
|
let response = session.request(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(),
|
|
})))
|
|
}
|
|
|
|
fn list_attached_nodes_if_available(
|
|
coordinator: Option<&str>,
|
|
scope: &CliScopeArgs,
|
|
) -> 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(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(),
|
|
}))
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
fn active_process_from_task_events(task_events: Option<&Value>) -> Option<String> {
|
|
let events = task_events?
|
|
.pointer("/response/events")
|
|
.and_then(Value::as_array)?;
|
|
let processes = events
|
|
.iter()
|
|
.filter_map(|event| event.get("process").and_then(Value::as_str))
|
|
.map(str::to_owned)
|
|
.collect::<BTreeSet<_>>();
|
|
match processes.len() {
|
|
0 => None,
|
|
1 => processes.into_iter().next(),
|
|
_ => Some(format!(
|
|
"multiple_observed:{}",
|
|
processes.into_iter().collect::<Vec<_>>().join(",")
|
|
)),
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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",
|
|
]
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
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.map_or(true, |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_else(|| 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)
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
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",
|
|
})
|
|
}
|
|
|
|
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_else(|| 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_else(|| json!(false)),
|
|
"active_task": response
|
|
.get("active_task")
|
|
.cloned()
|
|
.unwrap_or_else(|| json!(false)),
|
|
"completed_event_observed": response
|
|
.get("completed_event_observed")
|
|
.cloned()
|
|
.unwrap_or_else(|| json!(false)),
|
|
"requires_whole_process_restart": response
|
|
.get("requires_whole_process_restart")
|
|
.cloned()
|
|
.unwrap_or_else(|| 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,
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
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,
|
|
}])
|
|
}
|
|
|
|
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(),
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
fn quota_limits_value() -> Value {
|
|
let limits = ResourceLimits::community_tier_defaults();
|
|
json!({
|
|
"api_calls": limits.limit(&LimitKind::ApiCall),
|
|
"spawns": limits.limit(&LimitKind::Spawn),
|
|
"log_bytes": limits.limit(&LimitKind::LogBytes),
|
|
"metadata_bytes": limits.limit(&LimitKind::MetadataBytes),
|
|
"debug_read_bytes": limits.limit(&LimitKind::DebugReadBytes),
|
|
"ui_events": limits.limit(&LimitKind::UiEvent),
|
|
"rendezvous_attempts": limits.limit(&LimitKind::RendezvousAttempt),
|
|
"artifact_download_bytes": limits.limit(&LimitKind::ArtifactDownloadBytes),
|
|
"hosted_fuel": limits.limit(&LimitKind::HostedFuel),
|
|
"hosted_memory_bytes": limits.limit(&LimitKind::HostedMemoryBytes),
|
|
"hosted_wall_clock_ms": limits.limit(&LimitKind::HostedWallClockMs),
|
|
"hosted_state_bytes": limits.limit(&LimitKind::HostedStateBytes),
|
|
"hosted_tuning_private": true,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fn login_plan(args: LoginArgs) -> LoginPlan {
|
|
login_plan_with_nonce(args, login_nonce())
|
|
}
|
|
|
|
fn default_operator_endpoint() -> String {
|
|
DEFAULT_OPERATOR_ENDPOINT.to_owned()
|
|
}
|
|
|
|
fn login_plan_with_nonce(args: LoginArgs, nonce: String) -> LoginPlan {
|
|
login_plan_with_nonce_and_callback(args, nonce, browser_callback_url())
|
|
}
|
|
|
|
fn login_plan_with_nonce_and_callback(
|
|
args: LoginArgs,
|
|
nonce: String,
|
|
callback_path: String,
|
|
) -> LoginPlan {
|
|
let challenge = Digest::from_parts([
|
|
b"cli-login-challenge:v1".as_slice(),
|
|
args.coordinator.as_bytes(),
|
|
nonce.as_bytes(),
|
|
]);
|
|
let human_flow = if args.browser {
|
|
let state = challenge.as_str().to_owned();
|
|
LoginFlowPlan::Browser(BrowserLoginFlow {
|
|
authorization_url: browser_authorization_url(&args, &state, &callback_path),
|
|
callback_path,
|
|
state,
|
|
})
|
|
} else {
|
|
LoginFlowPlan::Device(CliLoginFlow {
|
|
verification_url: format!("{}/auth/device", args.coordinator),
|
|
user_code: device_user_code(&challenge),
|
|
device_code: challenge.as_str().to_owned(),
|
|
expires_in_seconds: 900,
|
|
yields_long_lived_secret_directly: false,
|
|
})
|
|
};
|
|
|
|
LoginPlan {
|
|
coordinator: args.coordinator,
|
|
human_flow,
|
|
}
|
|
}
|
|
|
|
fn execute_browser_login_completion(args: LoginArgs) -> Result<LoginCompletionReport> {
|
|
let plan = login_plan_with_nonce(args.clone(), login_nonce());
|
|
execute_browser_login_completion_for_plan(args, plan)
|
|
}
|
|
|
|
fn execute_browser_login_completion_for_plan(
|
|
args: LoginArgs,
|
|
plan: LoginPlan,
|
|
) -> Result<LoginCompletionReport> {
|
|
if !args.browser {
|
|
anyhow::bail!("browser login completion requires --browser");
|
|
}
|
|
let authorization_code = args
|
|
.complete_browser_code
|
|
.clone()
|
|
.context("--complete-browser-code requires an authorization code")?;
|
|
let issuer_url = args
|
|
.oidc_issuer_url
|
|
.clone()
|
|
.unwrap_or_else(default_oidc_issuer_url);
|
|
let coordinator = args.coordinator.clone();
|
|
let tenant = args.tenant.clone();
|
|
let project = args.project.clone();
|
|
let user = args.user.clone();
|
|
let client_id = args.oidc_client_id.clone();
|
|
let LoginFlowPlan::Browser(flow) = &plan.human_flow else {
|
|
anyhow::bail!("browser login completion requires a browser flow");
|
|
};
|
|
let state = args.state.clone().unwrap_or_else(|| flow.state.clone());
|
|
|
|
let mut session = JsonLineSession::connect(&coordinator)?;
|
|
let coordinator_response = session.request(json!({
|
|
"type": "oidc_browser_login",
|
|
"tenant": tenant,
|
|
"project": project,
|
|
"user": user,
|
|
"issuer_url": issuer_url,
|
|
"client_id": client_id,
|
|
"redirect_path": flow.callback_path.clone(),
|
|
"state": state,
|
|
"authorization_code": authorization_code,
|
|
}))?;
|
|
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(
|
|
&args,
|
|
&coordinator,
|
|
&coordinator_response,
|
|
provider_tokens_exposed_to_cli,
|
|
provider_tokens_sent_to_nodes,
|
|
);
|
|
write_cli_session(&cwd, &stored_session)?;
|
|
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: session.requests(),
|
|
},
|
|
coordinator_response,
|
|
})
|
|
}
|
|
|
|
fn stored_cli_session_from_login_response(
|
|
args: &LoginArgs,
|
|
coordinator: &str,
|
|
coordinator_response: &Value,
|
|
provider_tokens_exposed_to_cli: bool,
|
|
provider_tokens_sent_to_nodes: bool,
|
|
) -> 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);
|
|
StoredCliSession {
|
|
kind: "human".to_owned(),
|
|
coordinator: coordinator.to_owned(),
|
|
tenant: session
|
|
.get("tenant")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(&args.tenant)
|
|
.to_owned(),
|
|
project: session
|
|
.get("project")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(&args.project)
|
|
.to_owned(),
|
|
user: session
|
|
.get("user")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(&args.user)
|
|
.to_owned(),
|
|
cli_session_credential_kind: session
|
|
.get("cli_session_credential_kind")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("CliDeviceSession")
|
|
.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(),
|
|
}
|
|
}
|
|
|
|
fn execute_interactive_browser_login(mut args: LoginArgs) -> Result<LoginCompletionReport> {
|
|
args.browser = true;
|
|
let callback_path = browser_callback_url();
|
|
let listener = TcpListener::bind(BROWSER_CALLBACK_ADDR).with_context(|| {
|
|
format!("failed to listen for browser login callback on {BROWSER_CALLBACK_ADDR}")
|
|
})?;
|
|
let plan = login_plan_with_nonce_and_callback(args.clone(), login_nonce(), callback_path);
|
|
let LoginFlowPlan::Browser(flow) = &plan.human_flow else {
|
|
anyhow::bail!("browser login requires a browser flow");
|
|
};
|
|
|
|
eprintln!("Opening Disasmer browser login: {}", flow.authorization_url);
|
|
eprintln!("Waiting for login callback on {}", flow.callback_path);
|
|
open_browser(&flow.authorization_url)?;
|
|
|
|
let callback = receive_browser_callback(&listener)?;
|
|
if callback.state != flow.state {
|
|
anyhow::bail!("browser login callback state did not match the requested login state");
|
|
}
|
|
args.complete_browser_code = Some(callback.authorization_code);
|
|
args.state = Some(callback.state);
|
|
execute_browser_login_completion_for_plan(args, plan)
|
|
}
|
|
|
|
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.");
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct BrowserCallback {
|
|
authorization_code: String,
|
|
state: String,
|
|
}
|
|
|
|
fn browser_callback_url() -> String {
|
|
format!("http://{BROWSER_CALLBACK_ADDR}{BROWSER_CALLBACK_PATH}")
|
|
}
|
|
|
|
fn default_oidc_issuer_url() -> String {
|
|
DEFAULT_OIDC_ISSUER_URL.to_owned()
|
|
}
|
|
|
|
fn browser_authorization_url(args: &LoginArgs, state: &str, callback_path: &str) -> String {
|
|
if let Some(issuer_url) = &args.oidc_issuer_url {
|
|
return oidc_authorization_url(issuer_url, &args.oidc_client_id, state, callback_path);
|
|
}
|
|
let start = if args.coordinator == DEFAULT_OPERATOR_ENDPOINT {
|
|
DEFAULT_BROWSER_LOGIN_START.to_owned()
|
|
} else {
|
|
format!(
|
|
"{}/auth/browser/start",
|
|
args.coordinator.trim_end_matches('/')
|
|
)
|
|
};
|
|
format!(
|
|
"{start}?client_id={}&state={}&redirect_uri={}",
|
|
percent_encode(&args.oidc_client_id),
|
|
percent_encode(state),
|
|
percent_encode(callback_path)
|
|
)
|
|
}
|
|
|
|
fn oidc_authorization_url(
|
|
issuer_url: &str,
|
|
client_id: &str,
|
|
state: &str,
|
|
callback_path: &str,
|
|
) -> String {
|
|
format!(
|
|
"{}/application/o/authorize/?client_id={}&response_type=code&scope=openid%20profile%20email&state={}&redirect_uri={}",
|
|
issuer_url.trim_end_matches('/'),
|
|
percent_encode(client_id),
|
|
percent_encode(state),
|
|
percent_encode(callback_path)
|
|
)
|
|
}
|
|
|
|
fn open_browser(url: &str) -> Result<()> {
|
|
let mut command = if let Some(command) = std::env::var_os("DISASMER_BROWSER_OPEN_COMMAND") {
|
|
Command::new(command)
|
|
} else {
|
|
platform_browser_command()
|
|
};
|
|
command.arg(url);
|
|
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 receive_browser_callback(listener: &TcpListener) -> Result<BrowserCallback> {
|
|
listener
|
|
.set_nonblocking(true)
|
|
.context("failed to configure browser callback listener")?;
|
|
let timeout = browser_callback_timeout();
|
|
let started = Instant::now();
|
|
let mut stream = loop {
|
|
match listener.accept() {
|
|
Ok((stream, _)) => break stream,
|
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
|
if started.elapsed() >= timeout {
|
|
anyhow::bail!(
|
|
"timed out waiting for browser login callback on {BROWSER_CALLBACK_ADDR}"
|
|
);
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
Err(error) => return Err(error).context("failed to accept browser login callback"),
|
|
}
|
|
};
|
|
let callback = read_browser_callback_request(&stream);
|
|
match &callback {
|
|
Ok(_) => write_browser_callback_response(
|
|
&mut stream,
|
|
"200 OK",
|
|
"Disasmer login callback received. You can return to the terminal.",
|
|
)?,
|
|
Err(error) => write_browser_callback_response(
|
|
&mut stream,
|
|
"400 Bad Request",
|
|
&format!("Disasmer login callback was rejected: {error}"),
|
|
)?,
|
|
}
|
|
callback
|
|
}
|
|
|
|
fn browser_callback_timeout() -> Duration {
|
|
let seconds = std::env::var("DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS")
|
|
.ok()
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
.filter(|seconds| *seconds > 0)
|
|
.unwrap_or(DEFAULT_BROWSER_LOGIN_CALLBACK_TIMEOUT_SECONDS);
|
|
Duration::from_secs(seconds)
|
|
}
|
|
|
|
fn unix_timestamp_seconds() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
}
|
|
|
|
fn read_browser_callback_request(stream: &TcpStream) -> Result<BrowserCallback> {
|
|
let mut reader = BufReader::new(
|
|
stream
|
|
.try_clone()
|
|
.context("failed to read browser callback request")?,
|
|
);
|
|
let mut request_line = String::new();
|
|
reader
|
|
.read_line(&mut request_line)
|
|
.context("failed to read browser callback request line")?;
|
|
parse_browser_callback_request_line(&request_line)
|
|
}
|
|
|
|
fn parse_browser_callback_request_line(line: &str) -> Result<BrowserCallback> {
|
|
let mut parts = line.split_whitespace();
|
|
let method = parts.next().context("browser callback omitted method")?;
|
|
let target = parts.next().context("browser callback omitted path")?;
|
|
if method != "GET" {
|
|
anyhow::bail!("browser callback must use GET");
|
|
}
|
|
let (path, query) = target
|
|
.split_once('?')
|
|
.context("browser callback omitted query string")?;
|
|
if path != BROWSER_CALLBACK_PATH {
|
|
anyhow::bail!("browser callback used unexpected path {path}");
|
|
}
|
|
let mut authorization_code = None;
|
|
let mut state = None;
|
|
for pair in query.split('&').filter(|item| !item.is_empty()) {
|
|
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
|
|
match key {
|
|
"code" => authorization_code = Some(percent_decode(value)?),
|
|
"state" => state = Some(percent_decode(value)?),
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(BrowserCallback {
|
|
authorization_code: authorization_code.context("browser callback omitted code")?,
|
|
state: state.context("browser callback omitted state")?,
|
|
})
|
|
}
|
|
|
|
fn write_browser_callback_response(stream: &mut TcpStream, status: &str, body: &str) -> Result<()> {
|
|
let body = html_escape(body);
|
|
let html = format!(
|
|
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Disasmer login</title></head><body><h1>Disasmer login</h1><p>{body}</p></body></html>"
|
|
);
|
|
write!(
|
|
stream,
|
|
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
|
|
html.len()
|
|
)
|
|
.context("failed to write browser callback response")
|
|
}
|
|
|
|
fn percent_encode(input: &str) -> String {
|
|
let mut encoded = String::new();
|
|
for byte in input.as_bytes() {
|
|
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
|
encoded.push(*byte as char);
|
|
} else {
|
|
encoded.push_str(&format!("%{byte:02X}"));
|
|
}
|
|
}
|
|
encoded
|
|
}
|
|
|
|
fn percent_decode(input: &str) -> Result<String> {
|
|
let mut decoded = Vec::new();
|
|
let bytes = input.as_bytes();
|
|
let mut index = 0;
|
|
while index < bytes.len() {
|
|
match bytes[index] {
|
|
b'+' => {
|
|
decoded.push(b' ');
|
|
index += 1;
|
|
}
|
|
b'%' => {
|
|
let high = bytes
|
|
.get(index + 1)
|
|
.copied()
|
|
.and_then(hex_digit)
|
|
.context("invalid percent-encoded callback value")?;
|
|
let low = bytes
|
|
.get(index + 2)
|
|
.copied()
|
|
.and_then(hex_digit)
|
|
.context("invalid percent-encoded callback value")?;
|
|
decoded.push((high << 4) | low);
|
|
index += 3;
|
|
}
|
|
byte => {
|
|
decoded.push(byte);
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
String::from_utf8(decoded).context("callback value was not UTF-8")
|
|
}
|
|
|
|
fn hex_digit(byte: u8) -> Option<u8> {
|
|
match byte {
|
|
b'0'..=b'9' => Some(byte - b'0'),
|
|
b'a'..=b'f' => Some(byte - b'a' + 10),
|
|
b'A'..=b'F' => Some(byte - b'A' + 10),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn html_escape(input: &str) -> String {
|
|
input
|
|
.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
fn login_nonce() -> String {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_nanos())
|
|
.unwrap_or_default();
|
|
format!("{now}:{}", std::process::id())
|
|
}
|
|
|
|
fn device_user_code(challenge: &Digest) -> String {
|
|
let hex = challenge.as_str().trim_start_matches("sha256:");
|
|
let first = hex.get(0..4).unwrap_or(hex).to_ascii_uppercase();
|
|
let second = hex.get(4..8).unwrap_or(hex).to_ascii_uppercase();
|
|
format!("DISASMER-{first}-{second}")
|
|
}
|
|
|
|
fn agent_enrollment_plan(args: AgentEnrollArgs) -> AgentEnrollmentPlan {
|
|
AgentEnrollmentPlan {
|
|
public_key_fingerprint: Digest::sha256(args.public_key),
|
|
browser_interaction_required_each_run: false,
|
|
}
|
|
}
|
|
|
|
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 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 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: identity_inputs.inspectable_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)
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct SourceProviderSelection {
|
|
active_provider: SourceProviderKind,
|
|
statuses: Vec<SourceProviderStatus>,
|
|
}
|
|
|
|
fn source_provider_selection(
|
|
project: &Path,
|
|
requested_provider: Option<&str>,
|
|
disabled_providers: &[String],
|
|
) -> SourceProviderSelection {
|
|
let has_git_checkout = project.join(".git").exists();
|
|
let disabled = disabled_providers
|
|
.iter()
|
|
.map(|provider| provider.trim().to_ascii_lowercase())
|
|
.collect::<BTreeSet<_>>();
|
|
let requested = requested_provider.map(source_provider_kind_from_id);
|
|
let active_provider = requested.clone().unwrap_or_else(|| {
|
|
if has_git_checkout && !disabled.contains("git") {
|
|
SourceProviderKind::Git
|
|
} else {
|
|
SourceProviderKind::Filesystem
|
|
}
|
|
});
|
|
let active_provider_id = active_provider.provider_id().to_owned();
|
|
let mut statuses = vec![
|
|
builtin_source_provider_status(
|
|
SourceProviderKind::Filesystem,
|
|
true,
|
|
disabled.contains("filesystem"),
|
|
active_provider_id == "filesystem",
|
|
),
|
|
builtin_source_provider_status(
|
|
SourceProviderKind::Git,
|
|
has_git_checkout,
|
|
disabled.contains("git"),
|
|
active_provider_id == "git",
|
|
),
|
|
SourceProviderStatus {
|
|
provider: "custom".to_owned(),
|
|
status: "disabled".to_owned(),
|
|
active: false,
|
|
reason: "no custom source-provider module was configured for this project".to_owned(),
|
|
coordinator_checkout_required: false,
|
|
coordinator_receives_source_bytes_by_default: false,
|
|
},
|
|
];
|
|
|
|
if let Some(SourceProviderKind::Custom(provider)) = requested {
|
|
statuses.push(SourceProviderStatus {
|
|
provider,
|
|
status: "unsupported".to_owned(),
|
|
active: true,
|
|
reason: "custom source-provider modules must be supplied by public plugin/API code before use".to_owned(),
|
|
coordinator_checkout_required: false,
|
|
coordinator_receives_source_bytes_by_default: false,
|
|
});
|
|
}
|
|
|
|
SourceProviderSelection {
|
|
active_provider,
|
|
statuses,
|
|
}
|
|
}
|
|
|
|
fn source_provider_kind_from_id(provider: &str) -> SourceProviderKind {
|
|
match provider.trim().to_ascii_lowercase().as_str() {
|
|
"filesystem" => SourceProviderKind::Filesystem,
|
|
"git" => SourceProviderKind::Git,
|
|
other => SourceProviderKind::Custom(other.to_owned()),
|
|
}
|
|
}
|
|
|
|
fn builtin_source_provider_status(
|
|
kind: SourceProviderKind,
|
|
available: bool,
|
|
disabled: bool,
|
|
active: bool,
|
|
) -> SourceProviderStatus {
|
|
let provider = kind.provider_id().to_owned();
|
|
let (status, reason) = if disabled {
|
|
(
|
|
"disabled",
|
|
format!("source provider `{provider}` was disabled by CLI override"),
|
|
)
|
|
} else if available {
|
|
(
|
|
"enabled",
|
|
format!("source provider `{provider}` is available in this checkout"),
|
|
)
|
|
} else {
|
|
(
|
|
"missing",
|
|
format!("source provider `{provider}` is not available for this checkout"),
|
|
)
|
|
};
|
|
SourceProviderStatus {
|
|
provider,
|
|
status: status.to_owned(),
|
|
active,
|
|
reason,
|
|
coordinator_checkout_required: false,
|
|
coordinator_receives_source_bytes_by_default: false,
|
|
}
|
|
}
|
|
|
|
fn source_provider_manifest(kind: &SourceProviderKind) -> SourceProviderManifest {
|
|
SourceProviderManifest::local_first(
|
|
kind.clone(),
|
|
"default source provider manifest; snapshot creation can be scheduled as a node task",
|
|
)
|
|
}
|
|
|
|
fn environment_diagnostics_for_inputs(
|
|
project: &Path,
|
|
selected_inputs: &[SelectedInput],
|
|
environments: &[disasmer_core::EnvironmentResource],
|
|
) -> Result<Vec<EnvironmentDiagnosticReport>> {
|
|
let mut diagnostics = Vec::new();
|
|
for input in selected_inputs {
|
|
if !input.path.ends_with(".rs") {
|
|
continue;
|
|
}
|
|
let source = std::fs::read_to_string(project.join(&input.path))
|
|
.with_context(|| format!("failed to read {}", project.join(&input.path).display()))?;
|
|
diagnostics.extend(
|
|
diagnose_environment_references(&source, environments)
|
|
.into_iter()
|
|
.map(|diagnostic| EnvironmentDiagnosticReport {
|
|
path: input.path.clone(),
|
|
reference: diagnostic.reference,
|
|
message: diagnostic.message,
|
|
}),
|
|
);
|
|
}
|
|
Ok(diagnostics)
|
|
}
|
|
|
|
fn pre_schedule_diagnostics(
|
|
source_provider_statuses: &[SourceProviderStatus],
|
|
environment_diagnostics: &[EnvironmentDiagnosticReport],
|
|
environments: &[disasmer_core::EnvironmentResource],
|
|
) -> Vec<CliDiagnostic> {
|
|
let mut diagnostics = Vec::new();
|
|
diagnostics.extend(
|
|
environment_diagnostics
|
|
.iter()
|
|
.map(|diagnostic| CliDiagnostic {
|
|
severity: "error".to_owned(),
|
|
category: "environment".to_owned(),
|
|
code: "missing_environment".to_owned(),
|
|
message: format!("{} at {}", diagnostic.message, diagnostic.path),
|
|
next_actions: vec![
|
|
"create the missing envs/<name>/Containerfile or envs/<name>/Dockerfile"
|
|
.to_owned(),
|
|
"rerun disasmer inspect".to_owned(),
|
|
],
|
|
}),
|
|
);
|
|
|
|
diagnostics.extend(
|
|
source_provider_statuses
|
|
.iter()
|
|
.filter(|status| {
|
|
status.active
|
|
&& matches!(
|
|
status.status.as_str(),
|
|
"missing" | "disabled" | "unsupported"
|
|
)
|
|
})
|
|
.map(|status| CliDiagnostic {
|
|
severity: "error".to_owned(),
|
|
category: "source_provider".to_owned(),
|
|
code: format!("source_provider_{}", status.status),
|
|
message: format!(
|
|
"active source provider `{}` is {}: {}",
|
|
status.provider, status.status, status.reason
|
|
),
|
|
next_actions: vec![
|
|
"choose an available source provider with --source-provider".to_owned(),
|
|
"rerun disasmer inspect --json".to_owned(),
|
|
],
|
|
}),
|
|
);
|
|
|
|
for environment in environments {
|
|
if environment.requirements.capabilities.is_empty() {
|
|
continue;
|
|
}
|
|
let mut capabilities = environment
|
|
.requirements
|
|
.capabilities
|
|
.iter()
|
|
.map(|capability| format!("{capability:?}"))
|
|
.collect::<Vec<_>>();
|
|
capabilities.sort();
|
|
diagnostics.push(CliDiagnostic {
|
|
severity: "info".to_owned(),
|
|
category: "capability".to_owned(),
|
|
code: "environment_capability_requirements".to_owned(),
|
|
message: format!(
|
|
"environment `{}` requires node capabilities: {}",
|
|
environment.name,
|
|
capabilities.join(", ")
|
|
),
|
|
next_actions: vec![
|
|
"attach a node that reports these capabilities before scheduling work".to_owned(),
|
|
],
|
|
});
|
|
}
|
|
|
|
diagnostics
|
|
}
|
|
|
|
fn wasm_source_proxy_digest(selected_inputs: &[SelectedInput]) -> Digest {
|
|
let mut parts = vec![b"wasm-source-proxy:v1".to_vec()];
|
|
for input in selected_inputs {
|
|
parts.push(input.path.as_bytes().to_vec());
|
|
parts.push(input.digest.as_str().as_bytes().to_vec());
|
|
}
|
|
Digest::from_parts(parts)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
fn run_plan(args: RunArgs, cwd: PathBuf, session: CliSession) -> Result<RunPlan> {
|
|
let (coordinator, operator_endpoint) = if let Some(url) = args.coordinator {
|
|
(CoordinatorSelection::LocalOverride(url), None)
|
|
} else if args.local {
|
|
(CoordinatorSelection::LocalOnly, None)
|
|
} else if session.is_authenticated() {
|
|
(
|
|
CoordinatorSelection::Hosted,
|
|
Some(default_operator_endpoint()),
|
|
)
|
|
} else {
|
|
(CoordinatorSelection::LocalOnly, None)
|
|
};
|
|
|
|
Ok(RunPlan {
|
|
project: args.project.unwrap_or(cwd),
|
|
entry: args.entry.unwrap_or_else(|| "build".to_owned()),
|
|
coordinator,
|
|
operator_endpoint,
|
|
session,
|
|
})
|
|
}
|
|
|
|
fn non_interactive_run_requires_auth_report(args: RunArgs, cwd: PathBuf) -> Value {
|
|
let project = args.project.unwrap_or(cwd);
|
|
let entry = args.entry.unwrap_or_else(|| "build".to_owned());
|
|
let message = "non-interactive run requires an authenticated human or agent session unless --local or --coordinator is explicit";
|
|
let next_actions = vec![
|
|
"disasmer login --browser",
|
|
"set DISASMER_AGENT_PUBLIC_KEY for automation",
|
|
"pass --local to run against local services",
|
|
"pass --coordinator for an explicit self-hosted coordinator",
|
|
];
|
|
json!({
|
|
"command": "run",
|
|
"status": "authentication_required",
|
|
"project_root": project,
|
|
"entry": entry,
|
|
"non_interactive": true,
|
|
"browser_opened": false,
|
|
"safe_failure": true,
|
|
"message": message,
|
|
"next_actions": next_actions,
|
|
"private_website_required": false,
|
|
"machine_error": non_interactive_auth_machine_error(message, next_actions),
|
|
})
|
|
}
|
|
|
|
fn run_report(args: RunArgs, cwd: PathBuf, session: CliSession) -> Result<Value> {
|
|
if args.non_interactive
|
|
&& !args.local
|
|
&& args.coordinator.is_none()
|
|
&& !session.is_authenticated()
|
|
{
|
|
return Ok(non_interactive_run_requires_auth_report(args, cwd));
|
|
}
|
|
let plan = run_plan(args, cwd, session)?;
|
|
if should_execute_local_node(&plan) {
|
|
return Ok(serde_json::to_value(execute_local_node_run(plan)?)?);
|
|
}
|
|
coordinator_run_report(plan)
|
|
}
|
|
|
|
fn coordinator_run_report(plan: RunPlan) -> Result<Value> {
|
|
let config = read_project_config(&plan.project)?;
|
|
let tenant = config
|
|
.as_ref()
|
|
.map(|config| config.tenant.clone())
|
|
.unwrap_or_else(|| "tenant".to_owned());
|
|
let project = config
|
|
.as_ref()
|
|
.map(|config| config.project.clone())
|
|
.unwrap_or_else(|| "project".to_owned());
|
|
let user = config
|
|
.as_ref()
|
|
.map(|config| config.user.clone())
|
|
.unwrap_or_else(|| "user".to_owned());
|
|
let coordinator = run_coordinator_endpoint(&plan)?;
|
|
let process = "vp-current".to_owned();
|
|
let mut session = JsonLineSession::connect(&coordinator)?;
|
|
let mut request = json!({
|
|
"type": "start_process",
|
|
"tenant": tenant.clone(),
|
|
"project": project.clone(),
|
|
"process": process.clone(),
|
|
"restart": false,
|
|
});
|
|
add_workflow_actor_fields(&mut request, &plan.session, &user);
|
|
let response = session.request_allow_error(request)?;
|
|
let run_start = run_start_summary(&response);
|
|
let status = run_start
|
|
.get("status")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("coordinator_response");
|
|
Ok(json!({
|
|
"command": "run",
|
|
"status": status,
|
|
"project_root": plan.project,
|
|
"entry": plan.entry,
|
|
"tenant": tenant,
|
|
"project": project,
|
|
"user": user,
|
|
"workflow_actor": response.get("actor").cloned().unwrap_or(Value::Null),
|
|
"coordinator": coordinator,
|
|
"process": process,
|
|
"run_start": run_start,
|
|
"coordinator_response": response,
|
|
"coordinator_session_requests": session.requests(),
|
|
"private_website_required": false,
|
|
}))
|
|
}
|
|
|
|
fn run_coordinator_endpoint(plan: &RunPlan) -> Result<String> {
|
|
match &plan.coordinator {
|
|
CoordinatorSelection::Hosted => Ok(plan
|
|
.operator_endpoint
|
|
.clone()
|
|
.unwrap_or_else(default_operator_endpoint)),
|
|
CoordinatorSelection::LocalOverride(coordinator) => Ok(coordinator.clone()),
|
|
CoordinatorSelection::LocalOnly => {
|
|
anyhow::bail!("local-only run should execute through local services")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_start_summary(response: &Value) -> Value {
|
|
if response.get("type").and_then(Value::as_str) == Some("process_started") {
|
|
return json!({
|
|
"status": "started",
|
|
"accepted": true,
|
|
"process": response.get("process").cloned().unwrap_or(Value::Null),
|
|
"coordinator_epoch": response.get("epoch").cloned().unwrap_or(Value::Null),
|
|
"actor": response.get("actor").cloned().unwrap_or(Value::Null),
|
|
"restart": false,
|
|
"single_active_process_boundary": true,
|
|
"next_actions": [
|
|
"disasmer process status",
|
|
"disasmer logs",
|
|
"disasmer process cancel"
|
|
],
|
|
});
|
|
}
|
|
|
|
let message = response
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("coordinator rejected run");
|
|
let active_conflict = message.contains("already has active virtual process");
|
|
let machine_error = if active_conflict {
|
|
cli_error_summary_for_category("active_process", message)
|
|
} else {
|
|
cli_error_summary(message)
|
|
};
|
|
let error_category = machine_error
|
|
.get("category")
|
|
.cloned()
|
|
.unwrap_or_else(|| json!("unknown"));
|
|
let stable_exit_code = machine_error
|
|
.get("stable_exit_code")
|
|
.cloned()
|
|
.unwrap_or_else(|| json!(1));
|
|
json!({
|
|
"status": if active_conflict { "blocked_active_process" } else { "coordinator_rejected" },
|
|
"accepted": false,
|
|
"category": if active_conflict { "active_process_already_running" } else { "coordinator" },
|
|
"error_category": error_category,
|
|
"stable_exit_code": stable_exit_code,
|
|
"machine_error": machine_error,
|
|
"message": message,
|
|
"restart": false,
|
|
"single_active_process_boundary": true,
|
|
"safe_failure": true,
|
|
"next_actions": if active_conflict {
|
|
json!([
|
|
"disasmer process status",
|
|
"disasmer logs",
|
|
"disasmer process restart --yes",
|
|
"disasmer process cancel --yes",
|
|
"wait for the active process to finish"
|
|
])
|
|
} else {
|
|
json!(["disasmer doctor", "check coordinator status"])
|
|
},
|
|
})
|
|
}
|
|
|
|
fn should_execute_local_node(plan: &RunPlan) -> bool {
|
|
match &plan.coordinator {
|
|
CoordinatorSelection::LocalOnly => true,
|
|
CoordinatorSelection::LocalOverride(coordinator) => !coordinator.contains("://"),
|
|
CoordinatorSelection::Hosted => false,
|
|
}
|
|
}
|
|
|
|
fn execute_local_node_run(plan: RunPlan) -> Result<RunExecutionReport> {
|
|
let local_coordinator = match &plan.coordinator {
|
|
CoordinatorSelection::LocalOverride(coordinator) => LocalCoordinator::external(coordinator),
|
|
CoordinatorSelection::LocalOnly => LocalCoordinator::start_ephemeral()?,
|
|
CoordinatorSelection::Hosted => anyhow::bail!("local node execution requires local mode"),
|
|
};
|
|
let coordinator_address = local_coordinator.address.clone();
|
|
let spawned = spawn_node_process(&plan, &coordinator_address)?;
|
|
if !spawned.output.status.success() {
|
|
anyhow::bail!(
|
|
"node process failed with status {}\n{}",
|
|
spawned.output.status,
|
|
String::from_utf8_lossy(&spawned.output.stderr)
|
|
);
|
|
}
|
|
let stdout = String::from_utf8(spawned.output.stdout).context("node stdout was not UTF-8")?;
|
|
let line = stdout
|
|
.lines()
|
|
.rev()
|
|
.find(|line| !line.trim().is_empty())
|
|
.context("node did not print a JSON report")?;
|
|
let node_report: serde_json::Value =
|
|
serde_json::from_str(line).context("node report was not JSON")?;
|
|
let node_session_requests = node_report
|
|
.get("session_requests")
|
|
.and_then(serde_json::Value::as_u64)
|
|
.unwrap_or_default();
|
|
|
|
Ok(RunExecutionReport {
|
|
plan,
|
|
boundary: RunBoundaryEvidence {
|
|
cli_process_started_node_process: true,
|
|
cli_process_started_coordinator_process: local_coordinator.process_id.is_some(),
|
|
coordinator_address,
|
|
coordinator_process_id: local_coordinator.process_id,
|
|
spawned_node_process_id: spawned.process_id,
|
|
node_session_requests,
|
|
},
|
|
node_report,
|
|
})
|
|
}
|
|
|
|
struct SpawnedNodeOutput {
|
|
process_id: u32,
|
|
output: std::process::Output,
|
|
}
|
|
|
|
fn spawn_node_process(plan: &RunPlan, coordinator: &str) -> Result<SpawnedNodeOutput> {
|
|
let mut command = node_command()?;
|
|
command.args([
|
|
"--coordinator",
|
|
coordinator,
|
|
"--tenant",
|
|
"tenant",
|
|
"--project-id",
|
|
"project",
|
|
"--node",
|
|
"node-cli-local",
|
|
"--process",
|
|
"vp-cli-local",
|
|
"--task",
|
|
"compile-linux",
|
|
"--project",
|
|
]);
|
|
command.arg(&plan.project);
|
|
command.args(["--artifact", "/vfs/artifacts/cli-run-output.txt"]);
|
|
command.stdout(Stdio::piped());
|
|
command.stderr(Stdio::piped());
|
|
let child = command.spawn().context("failed to spawn node process")?;
|
|
let process_id = child.id();
|
|
let output = child
|
|
.wait_with_output()
|
|
.context("failed to wait for node process")?;
|
|
Ok(SpawnedNodeOutput { process_id, output })
|
|
}
|
|
|
|
fn node_command() -> Result<Command> {
|
|
if let Some(path) = std::env::var_os("DISASMER_NODE_BIN") {
|
|
return Ok(Command::new(path));
|
|
}
|
|
|
|
let mut sibling = std::env::current_exe().context("cannot locate current executable")?;
|
|
sibling.set_file_name(format!("disasmer-node{}", std::env::consts::EXE_SUFFIX));
|
|
if sibling.is_file() {
|
|
return Ok(Command::new(sibling));
|
|
}
|
|
|
|
let mut command = Command::new("cargo");
|
|
command.args([
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"disasmer-node",
|
|
"--bin",
|
|
"disasmer-node",
|
|
"--",
|
|
]);
|
|
Ok(command)
|
|
}
|
|
|
|
struct LocalCoordinator {
|
|
address: String,
|
|
process_id: Option<u32>,
|
|
child: Option<std::process::Child>,
|
|
}
|
|
|
|
impl LocalCoordinator {
|
|
fn external(address: &str) -> Self {
|
|
Self {
|
|
address: address.to_owned(),
|
|
process_id: None,
|
|
child: None,
|
|
}
|
|
}
|
|
|
|
fn start_ephemeral() -> Result<Self> {
|
|
let mut command = coordinator_command()?;
|
|
command.args(["--listen", "127.0.0.1:0"]);
|
|
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),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn read_coordinator_ready_address(child: &mut std::process::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")
|
|
}
|
|
|
|
impl Drop for LocalCoordinator {
|
|
fn drop(&mut self) {
|
|
if let Some(mut child) = self.child.take() {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn coordinator_command() -> Result<Command> {
|
|
if let Some(path) = std::env::var_os("DISASMER_COORDINATOR_BIN") {
|
|
return Ok(Command::new(path));
|
|
}
|
|
|
|
let mut sibling = std::env::current_exe().context("cannot locate current executable")?;
|
|
sibling.set_file_name(format!(
|
|
"disasmer-coordinator{}",
|
|
std::env::consts::EXE_SUFFIX
|
|
));
|
|
if sibling.is_file() {
|
|
return Ok(Command::new(sibling));
|
|
}
|
|
|
|
let mut command = Command::new("cargo");
|
|
command.args([
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"disasmer-coordinator",
|
|
"--bin",
|
|
"disasmer-coordinator",
|
|
"--",
|
|
]);
|
|
Ok(command)
|
|
}
|
|
|
|
fn session_from_env() -> CliSession {
|
|
if let Some(public_key) = std::env::var_os("DISASMER_AGENT_PUBLIC_KEY") {
|
|
let public_key = public_key.to_string_lossy();
|
|
return CliSession::AgentPublicKey {
|
|
agent: std::env::var("DISASMER_AGENT_ID").unwrap_or_else(|_| "agent".to_owned()),
|
|
public_key_fingerprint: Digest::sha256(public_key.as_bytes()),
|
|
browser_interaction_required: false,
|
|
};
|
|
}
|
|
if std::env::var_os("DISASMER_TOKEN").is_some() {
|
|
return CliSession::HumanSession;
|
|
}
|
|
CliSession::Anonymous
|
|
}
|
|
|
|
fn session_from_sources(project: &Path) -> Result<CliSession> {
|
|
let session = session_from_env();
|
|
if session.is_authenticated() {
|
|
return Ok(session);
|
|
}
|
|
if read_cli_session(project)?.is_some() {
|
|
return Ok(CliSession::HumanSession);
|
|
}
|
|
Ok(CliSession::Anonymous)
|
|
}
|
|
|
|
fn add_workflow_actor_fields(request: &mut Value, session: &CliSession, fallback_user: &str) {
|
|
let Value::Object(map) = request else {
|
|
return;
|
|
};
|
|
match session {
|
|
CliSession::AgentPublicKey {
|
|
agent,
|
|
public_key_fingerprint,
|
|
..
|
|
} => {
|
|
map.insert("actor_agent".to_owned(), json!(agent));
|
|
map.insert(
|
|
"agent_public_key_fingerprint".to_owned(),
|
|
json!(public_key_fingerprint),
|
|
);
|
|
}
|
|
CliSession::HumanSession | CliSession::Anonymous => {
|
|
map.insert("actor_user".to_owned(), json!(fallback_user));
|
|
}
|
|
}
|
|
}
|
|
|
|
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(|| format!("{node}-public-key"));
|
|
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
|
|
}
|
|
|
|
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 public_key = args
|
|
.public_key
|
|
.clone()
|
|
.unwrap_or_else(|| format!("{node}-public-key"));
|
|
let plan = attach_plan(args);
|
|
|
|
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,
|
|
"now_epoch_seconds": 0,
|
|
}))?
|
|
} else {
|
|
session.request(json!({
|
|
"type": "attach_node",
|
|
"tenant": &tenant,
|
|
"project": &project,
|
|
"node": &node,
|
|
"public_key": &public_key,
|
|
}))?
|
|
};
|
|
let heartbeat_response = session.request(json!({
|
|
"type": "node_heartbeat",
|
|
"node": &plan.node,
|
|
}))?;
|
|
let capability_response = session.request(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,
|
|
})
|
|
}
|
|
|
|
struct JsonLineSession {
|
|
writer: TcpStream,
|
|
reader: BufReader<TcpStream>,
|
|
requests: u64,
|
|
}
|
|
|
|
impl JsonLineSession {
|
|
fn connect(addr: &str) -> Result<Self> {
|
|
let transport_addr = json_line_transport_addr(addr);
|
|
let writer = TcpStream::connect(&transport_addr)
|
|
.with_context(|| format!("failed to connect to {addr} via {transport_addr}"))?;
|
|
Self::from_stream(writer)
|
|
}
|
|
|
|
fn from_stream(writer: TcpStream) -> Result<Self> {
|
|
let reader = BufReader::new(writer.try_clone()?);
|
|
Ok(Self {
|
|
writer,
|
|
reader,
|
|
requests: 0,
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
fn request_allow_error(&mut self, value: Value) -> Result<Value> {
|
|
serde_json::to_writer(&mut self.writer, &value)?;
|
|
self.writer.write_all(b"\n")?;
|
|
self.writer.flush()?;
|
|
|
|
let mut line = String::new();
|
|
if self.reader.read_line(&mut line)? == 0 {
|
|
anyhow::bail!("coordinator closed session without a response");
|
|
}
|
|
self.requests += 1;
|
|
let response: Value = serde_json::from_str(&line)?;
|
|
Ok(response)
|
|
}
|
|
|
|
fn requests(&self) -> u64 {
|
|
self.requests
|
|
}
|
|
}
|
|
|
|
fn json_line_transport_addr(endpoint: &str) -> String {
|
|
let endpoint = endpoint.trim();
|
|
for (scheme, default_port) in [("https://", 443), ("http://", 80)] {
|
|
if let Some(rest) = endpoint.strip_prefix(scheme) {
|
|
let authority = rest.split('/').next().unwrap_or(rest);
|
|
if authority.contains(':') {
|
|
return authority.to_owned();
|
|
}
|
|
return format!("{authority}:{default_port}");
|
|
}
|
|
}
|
|
endpoint.to_owned()
|
|
}
|
|
|
|
fn default_node_id() -> String {
|
|
std::env::var("DISASMER_NODE_ID")
|
|
.or_else(|_| std::env::var("HOSTNAME"))
|
|
.or_else(|_| std::env::var("COMPUTERNAME"))
|
|
.unwrap_or_else(|_| "node-local".to_owned())
|
|
}
|
|
|
|
fn parse_capability(cap: &str) -> Option<Capability> {
|
|
match cap {
|
|
"command" => Some(Capability::Command),
|
|
"containers" => Some(Capability::Containers),
|
|
"rootless-podman" => Some(Capability::RootlessPodman),
|
|
"source-filesystem" => Some(Capability::SourceFilesystem),
|
|
"source-git" => Some(Capability::SourceGit),
|
|
"host-filesystem" => Some(Capability::HostFilesystem),
|
|
"network" => Some(Capability::Network),
|
|
"secrets" => Some(Capability::Secrets),
|
|
"inbound-ports" => Some(Capability::InboundPorts),
|
|
"arbitrary-syscalls" => Some(Capability::ArbitrarySyscalls),
|
|
"vfs-artifacts" => Some(Capability::VfsArtifacts),
|
|
"wasmtime" => Some(Capability::Wasmtime),
|
|
"windows-command-dev" => Some(Capability::WindowsCommandDev),
|
|
"quic-direct" => Some(Capability::QuicDirect),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::fs;
|
|
|
|
use clap::CommandFactory;
|
|
|
|
use super::*;
|
|
|
|
fn parse(args: &[&str]) -> Cli {
|
|
Cli::parse_from(args)
|
|
}
|
|
|
|
#[test]
|
|
fn cli_error_classifier_distinguishes_mvp_failure_categories() {
|
|
for (message, category, exit_code) in [
|
|
(
|
|
"login required before running this command",
|
|
"authentication",
|
|
20,
|
|
),
|
|
("unauthorized tenant action", "authorization", 21),
|
|
(
|
|
"quota unavailable: resource limit exceeded for api_calls",
|
|
"quota",
|
|
22,
|
|
),
|
|
("policy denied native command execution", "policy", 23),
|
|
(
|
|
"scheduler placement failed: no capable node for placement: project mismatch",
|
|
"capability",
|
|
24,
|
|
),
|
|
(
|
|
"scheduler placement failed: no capable node for placement: source snapshot unavailable and direct connectivity unavailable",
|
|
"connectivity",
|
|
25,
|
|
),
|
|
(
|
|
"failed to connect to coordinator: connection refused",
|
|
"connectivity",
|
|
25,
|
|
),
|
|
(
|
|
"missing environment envs/linux/Containerfile",
|
|
"environment",
|
|
26,
|
|
),
|
|
("task exited with status 101 after panic", "program", 27),
|
|
(
|
|
"project already has active virtual process vp-current",
|
|
"active_process",
|
|
28,
|
|
),
|
|
] {
|
|
let summary = cli_error_summary(message);
|
|
assert_eq!(summary["category"], category, "{message}");
|
|
assert_eq!(summary["stable_exit_code"], exit_code, "{message}");
|
|
assert_eq!(summary["safe_failure"], true);
|
|
assert_eq!(summary["process_exit_code_applied"], false);
|
|
assert!(summary["next_actions"].as_array().unwrap().len() >= 2);
|
|
if category == "quota" {
|
|
assert_eq!(summary["resource_category"], "api_calls");
|
|
assert_eq!(summary["community_tier_language"], true);
|
|
assert_eq!(summary["community_tier_label"], "community tier");
|
|
assert_eq!(summary["private_abuse_heuristics_exposed"], false);
|
|
let rendered = human_report(&json!({
|
|
"command": "run",
|
|
"machine_error": summary,
|
|
}));
|
|
assert!(rendered.contains("quota tier: community tier"));
|
|
let forbidden_tier = ["free", "tier"].join(" ");
|
|
assert!(!rendered.to_ascii_lowercase().contains(&forbidden_tier));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn command_report_exit_code_marks_command_failures_only() {
|
|
let run_start = run_start_summary(&json!({
|
|
"type": "error",
|
|
"message": "quota unavailable: resource limit exceeded for api_calls",
|
|
}));
|
|
let mut report = json!({
|
|
"command": "run",
|
|
"status": run_start["status"].clone(),
|
|
"run_start": run_start,
|
|
});
|
|
|
|
assert_eq!(apply_command_report_exit_code(&mut report), Some(22));
|
|
assert_eq!(
|
|
report["run_start"]["machine_error"]["process_exit_code_applied"],
|
|
true
|
|
);
|
|
|
|
let mut task_list = json!({
|
|
"command": "task list",
|
|
"tasks": [{
|
|
"task": "compile",
|
|
"state": "failed",
|
|
"machine_error": cli_error_summary_for_category("program", "task exited with status 1"),
|
|
}],
|
|
});
|
|
assert_eq!(apply_command_report_exit_code(&mut task_list), None);
|
|
assert_eq!(
|
|
task_list["tasks"][0]["machine_error"]["process_exit_code_applied"],
|
|
false
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_version_is_available() {
|
|
let command = Cli::command();
|
|
assert_eq!(command.get_name(), "disasmer");
|
|
assert!(command.get_version().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn run_defaults_to_current_project_and_build_entry() {
|
|
let Cli {
|
|
command: Commands::Run(args),
|
|
} = parse(&["disasmer", "run"])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = run_plan(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap();
|
|
|
|
assert_eq!(plan.project, PathBuf::from("/repo"));
|
|
assert_eq!(plan.entry, "build");
|
|
assert_eq!(plan.coordinator, CoordinatorSelection::LocalOnly);
|
|
assert_eq!(plan.operator_endpoint, None);
|
|
assert_eq!(plan.session, CliSession::Anonymous);
|
|
}
|
|
|
|
#[test]
|
|
fn non_interactive_run_without_session_requires_explicit_auth_or_local() {
|
|
let Cli {
|
|
command: Commands::Run(args),
|
|
} = parse(&["disasmer", "run", "--non-interactive", "--json"])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let report = run_report(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap();
|
|
|
|
assert_eq!(report["command"], "run");
|
|
assert_eq!(report["status"], "authentication_required");
|
|
assert_eq!(report["non_interactive"], true);
|
|
assert_eq!(report["browser_opened"], false);
|
|
assert_eq!(report["private_website_required"], false);
|
|
assert_eq!(report["machine_error"]["category"], "authentication");
|
|
assert_eq!(report["machine_error"]["stable_exit_code"], 20);
|
|
assert_eq!(report["machine_error"]["browser_opened"], false);
|
|
let next_actions = report["machine_error"]["next_actions"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.collect::<Vec<_>>();
|
|
assert!(next_actions.contains(&"disasmer login --browser"));
|
|
assert!(next_actions.contains(&"set DISASMER_AGENT_PUBLIC_KEY for automation"));
|
|
assert!(next_actions.contains(&"pass --local to run against local services"));
|
|
}
|
|
|
|
#[test]
|
|
fn run_project_and_named_entry_are_respected() {
|
|
let Cli {
|
|
command: Commands::Run(args),
|
|
} = parse(&["disasmer", "run", "test", "--project", "/other"])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = run_plan(args, PathBuf::from("/repo"), CliSession::HumanSession).unwrap();
|
|
|
|
assert_eq!(plan.project, PathBuf::from("/other"));
|
|
assert_eq!(plan.entry, "test");
|
|
assert_eq!(plan.coordinator, CoordinatorSelection::Hosted);
|
|
assert_eq!(
|
|
plan.operator_endpoint.as_deref(),
|
|
Some(DEFAULT_OPERATOR_ENDPOINT)
|
|
);
|
|
assert_eq!(plan.session, CliSession::HumanSession);
|
|
}
|
|
|
|
#[test]
|
|
fn node_attach_detects_and_accepts_capability_overrides() {
|
|
let Cli {
|
|
command:
|
|
Commands::Node {
|
|
command: NodeCommands::Attach(args),
|
|
},
|
|
} = parse(&["disasmer", "node", "attach", "--cap", "quic-direct"])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = attach_plan(args);
|
|
|
|
assert!(plan
|
|
.capabilities
|
|
.capabilities
|
|
.contains(&Capability::QuicDirect));
|
|
assert!(!plan.capabilities.arch.is_empty());
|
|
assert!(plan.detection.auto_detected);
|
|
assert_eq!(plan.detection.os, plan.capabilities.os);
|
|
assert_eq!(plan.detection.arch, plan.capabilities.arch);
|
|
assert_eq!(plan.detection.command_backend, "native-command");
|
|
assert!(plan.detection.command_backend_available);
|
|
assert!(plan.detection.manual_capability_overrides_allowed);
|
|
assert_eq!(
|
|
plan.detection.manual_capability_overrides,
|
|
vec!["quic-direct".to_owned()]
|
|
);
|
|
assert!(plan
|
|
.detection
|
|
.recognized_capability_overrides
|
|
.contains(&Capability::QuicDirect));
|
|
assert!(plan.detection.unrecognized_capability_overrides.is_empty());
|
|
assert!(!plan.detection.os_arch_capabilities_require_manual_flags);
|
|
assert!(plan
|
|
.detection
|
|
.source_provider_backends
|
|
.iter()
|
|
.any(|provider| provider.provider == "filesystem" && provider.detected));
|
|
}
|
|
|
|
#[test]
|
|
fn node_attach_discloses_dangerous_capability_grants() {
|
|
let Cli {
|
|
command:
|
|
Commands::Node {
|
|
command: NodeCommands::Attach(args),
|
|
},
|
|
} = parse(&[
|
|
"disasmer",
|
|
"node",
|
|
"attach",
|
|
"--cap",
|
|
"network",
|
|
"--cap",
|
|
"host-filesystem",
|
|
"--cap",
|
|
"secrets",
|
|
])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = attach_plan(args);
|
|
let grants = plan
|
|
.grant_disclosures
|
|
.iter()
|
|
.map(|disclosure| disclosure.grant.as_str())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(grants.contains(&"native_command_execution"));
|
|
assert!(grants.contains(&"source_access"));
|
|
assert!(grants.contains(&"network_access"));
|
|
assert!(grants.contains(&"host_filesystem_access"));
|
|
assert!(grants.contains(&"secret_access"));
|
|
assert!(plan
|
|
.grant_disclosures
|
|
.iter()
|
|
.all(|disclosure| disclosure.coordinator_policy_limited));
|
|
|
|
let rendered = human_report(&json!({
|
|
"command": "node attach",
|
|
"node": plan.node,
|
|
"grant_disclosures": plan.grant_disclosures,
|
|
}));
|
|
assert!(rendered.contains("grant native_command_execution"));
|
|
assert!(rendered.contains("grant network_access"));
|
|
assert!(rendered.contains("policy-limited"));
|
|
}
|
|
|
|
#[test]
|
|
fn agents_can_select_hosted_with_public_key_identity() {
|
|
let args = RunArgs {
|
|
entry: None,
|
|
project: None,
|
|
coordinator: None,
|
|
local: false,
|
|
non_interactive: true,
|
|
json: false,
|
|
};
|
|
let plan = run_plan(
|
|
args,
|
|
PathBuf::from("/repo"),
|
|
CliSession::AgentPublicKey {
|
|
agent: "agent-ci".to_owned(),
|
|
public_key_fingerprint: Digest::sha256("agent-key"),
|
|
browser_interaction_required: false,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(plan.coordinator, CoordinatorSelection::Hosted);
|
|
assert_eq!(
|
|
plan.operator_endpoint.as_deref(),
|
|
Some(DEFAULT_OPERATOR_ENDPOINT)
|
|
);
|
|
assert_eq!(
|
|
plan.session,
|
|
CliSession::AgentPublicKey {
|
|
agent: "agent-ci".to_owned(),
|
|
public_key_fingerprint: Digest::sha256("agent-key"),
|
|
browser_interaction_required: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn run_with_agent_public_key_sends_attributable_workflow_actor() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_project_config(
|
|
temp.path(),
|
|
&ProjectConfig {
|
|
tenant: "tenant-live".to_owned(),
|
|
project: "project-live".to_owned(),
|
|
user: "user-live".to_owned(),
|
|
coordinator: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let fingerprint = Digest::sha256("agent-key");
|
|
let expected_fingerprint = fingerprint.to_string();
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""type":"start_process""#));
|
|
assert!(line.contains(r#""tenant":"tenant-live""#));
|
|
assert!(line.contains(r#""project":"project-live""#));
|
|
assert!(line.contains(r#""actor_agent":"agent-ci""#));
|
|
assert!(line.contains(&format!(
|
|
r#""agent_public_key_fingerprint":"{expected_fingerprint}""#
|
|
)));
|
|
assert!(!line.contains(r#""actor_user""#));
|
|
stream
|
|
.write_all(
|
|
format!(
|
|
r#"{{"type":"process_started","process":"vp-current","epoch":7,"actor":{{"kind":"agent","user":"user-live","agent":"agent-ci","credential_kind":"public_key","public_key_fingerprint":"{expected_fingerprint}","authenticated_without_browser":true,"scopes":["project:read","project:run"]}}}}"#
|
|
)
|
|
.as_bytes(),
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
});
|
|
|
|
let report = run_report(
|
|
RunArgs {
|
|
entry: Some("build".to_owned()),
|
|
project: Some(temp.path().to_path_buf()),
|
|
coordinator: Some(format!("http://{addr}")),
|
|
local: false,
|
|
non_interactive: true,
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
CliSession::AgentPublicKey {
|
|
agent: "agent-ci".to_owned(),
|
|
public_key_fingerprint: fingerprint,
|
|
browser_interaction_required: false,
|
|
},
|
|
)
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(report["status"], "started");
|
|
assert_eq!(report["workflow_actor"]["kind"], "agent");
|
|
assert_eq!(report["workflow_actor"]["agent"], "agent-ci");
|
|
assert_eq!(
|
|
report["workflow_actor"]["authenticated_without_browser"],
|
|
true
|
|
);
|
|
assert_eq!(report["run_start"]["actor"]["kind"], "agent");
|
|
}
|
|
|
|
#[test]
|
|
fn run_local_flag_overrides_logged_in_hosted_selection() {
|
|
let Cli {
|
|
command: Commands::Run(args),
|
|
} = parse(&["disasmer", "run", "--local"])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = run_plan(args, PathBuf::from("/repo"), CliSession::HumanSession).unwrap();
|
|
|
|
assert_eq!(plan.coordinator, CoordinatorSelection::LocalOnly);
|
|
assert_eq!(plan.operator_endpoint, None);
|
|
}
|
|
|
|
#[test]
|
|
fn run_contacts_configured_coordinator_and_reports_active_process_conflicts() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_project_config(
|
|
temp.path(),
|
|
&ProjectConfig {
|
|
tenant: "tenant-live".to_owned(),
|
|
project: "project-live".to_owned(),
|
|
user: "user-live".to_owned(),
|
|
coordinator: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
for response in [
|
|
r#"{"type":"process_started","process":"vp-current","epoch":7}"#,
|
|
r#"{"type":"error","message":"coordinator request failed: unauthorized coordinator action: project already has active virtual process vp-current; inspect it, restart it, cancel it, or wait before starting another run"}"#,
|
|
] {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""type":"start_process""#));
|
|
assert!(line.contains(r#""tenant":"tenant-live""#));
|
|
assert!(line.contains(r#""project":"project-live""#));
|
|
assert!(line.contains(r#""process":"vp-current""#));
|
|
assert!(line.contains(r#""restart":false"#));
|
|
stream.write_all(response.as_bytes()).unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
}
|
|
});
|
|
|
|
let started = run_report(
|
|
RunArgs {
|
|
entry: Some("build".to_owned()),
|
|
project: Some(temp.path().to_path_buf()),
|
|
coordinator: Some(format!("http://{addr}")),
|
|
local: false,
|
|
non_interactive: false,
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
CliSession::Anonymous,
|
|
)
|
|
.unwrap();
|
|
let blocked = run_report(
|
|
RunArgs {
|
|
entry: Some("test".to_owned()),
|
|
project: Some(temp.path().to_path_buf()),
|
|
coordinator: Some(format!("http://{addr}")),
|
|
local: false,
|
|
non_interactive: false,
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
CliSession::Anonymous,
|
|
)
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(started["status"], "started");
|
|
assert_eq!(started["entry"], "build");
|
|
assert_eq!(started["tenant"], "tenant-live");
|
|
assert_eq!(started["project"], "project-live");
|
|
assert_eq!(started["process"], "vp-current");
|
|
assert_eq!(started["run_start"]["restart"], false);
|
|
assert_eq!(started["run_start"]["single_active_process_boundary"], true);
|
|
|
|
assert_eq!(blocked["status"], "blocked_active_process");
|
|
assert_eq!(blocked["entry"], "test");
|
|
assert_eq!(
|
|
blocked["run_start"]["category"],
|
|
"active_process_already_running"
|
|
);
|
|
assert_eq!(blocked["run_start"]["error_category"], "active_process");
|
|
assert_eq!(blocked["run_start"]["stable_exit_code"], 28);
|
|
assert_eq!(
|
|
blocked["run_start"]["machine_error"]["category"],
|
|
"active_process"
|
|
);
|
|
assert_eq!(
|
|
blocked["run_start"]["machine_error"]["stable_exit_code"],
|
|
28
|
|
);
|
|
assert_eq!(blocked["run_start"]["safe_failure"], true);
|
|
assert!(blocked["run_start"]["next_actions"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|action| action == "disasmer process restart --yes"));
|
|
}
|
|
|
|
#[test]
|
|
fn run_rejection_reports_machine_readable_error_category() {
|
|
let rejected = run_start_summary(&json!({
|
|
"type": "error",
|
|
"message": "quota unavailable: resource limit exceeded for api_calls"
|
|
}));
|
|
|
|
assert_eq!(rejected["status"], "coordinator_rejected");
|
|
assert_eq!(rejected["error_category"], "quota");
|
|
assert_eq!(rejected["stable_exit_code"], 22);
|
|
assert_eq!(rejected["machine_error"]["category"], "quota");
|
|
assert_eq!(rejected["machine_error"]["resource_category"], "api_calls");
|
|
assert_eq!(rejected["machine_error"]["community_tier_language"], true);
|
|
assert_eq!(
|
|
rejected["machine_error"]["private_abuse_heuristics_exposed"],
|
|
false
|
|
);
|
|
assert!(rejected["machine_error"]["next_actions"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|action| action == "disasmer quota status"));
|
|
}
|
|
|
|
#[test]
|
|
fn local_only_run_executes_ephemeral_local_services() {
|
|
let Cli {
|
|
command: Commands::Run(args),
|
|
} = parse(&["disasmer", "run", "--local"])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = run_plan(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap();
|
|
|
|
assert!(should_execute_local_node(&plan));
|
|
}
|
|
|
|
#[test]
|
|
fn login_uses_device_flow_without_long_lived_secret_copying() {
|
|
let Cli {
|
|
command: Commands::Login(args),
|
|
} = parse(&["disasmer", "login"])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = login_plan(args);
|
|
|
|
assert_eq!(plan.coordinator, DEFAULT_OPERATOR_ENDPOINT);
|
|
match plan.human_flow {
|
|
LoginFlowPlan::Device(flow) => {
|
|
assert!(!flow.yields_long_lived_secret_directly);
|
|
assert!(flow.verification_url.contains("/auth/device"));
|
|
assert_ne!(flow.user_code, format!("DISASMER-{}", "DEMO"));
|
|
assert!(flow.user_code.starts_with("DISASMER-"));
|
|
assert!(flow.device_code.starts_with("sha256:"));
|
|
}
|
|
LoginFlowPlan::Browser(_) => panic!("expected device flow"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn browser_login_flow_is_available_for_humans() {
|
|
let Cli {
|
|
command: Commands::Login(args),
|
|
} = parse(&["disasmer", "login", "--browser"])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = login_plan(args);
|
|
|
|
let LoginFlowPlan::Browser(flow) = plan.human_flow else {
|
|
panic!("expected browser flow");
|
|
};
|
|
assert!(flow.authorization_url.contains("/auth/browser/start"));
|
|
assert!(flow.state.starts_with("sha256:"));
|
|
}
|
|
|
|
#[test]
|
|
fn browser_login_non_interactive_fails_before_opening_browser() {
|
|
let Cli {
|
|
command: Commands::Login(args),
|
|
} = parse(&["disasmer", "login", "--browser", "--non-interactive"])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let report = non_interactive_browser_login_report(&args);
|
|
|
|
assert_eq!(report["command"], "login");
|
|
assert_eq!(report["status"], "authentication_required");
|
|
assert_eq!(report["non_interactive"], true);
|
|
assert_eq!(report["browser_requested"], true);
|
|
assert_eq!(report["browser_opened"], false);
|
|
assert_eq!(report["machine_error"]["category"], "authentication");
|
|
assert_eq!(report["machine_error"]["stable_exit_code"], 20);
|
|
assert_eq!(report["machine_error"]["browser_opened"], false);
|
|
let next_actions = report["machine_error"]["next_actions"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.collect::<Vec<_>>();
|
|
assert!(next_actions.contains(&"rerun without --non-interactive to open the browser"));
|
|
assert!(next_actions.contains(&"use DISASMER_AGENT_PUBLIC_KEY for automation"));
|
|
}
|
|
|
|
#[test]
|
|
fn browser_login_completion_detects_raw_provider_token_fields() {
|
|
assert!(contains_provider_token_field(&json!({
|
|
"session": {
|
|
"access_token": "secret"
|
|
}
|
|
})));
|
|
assert!(!contains_provider_token_field(&json!({
|
|
"session": {
|
|
"cli_session_credential_kind": "CliDeviceSession",
|
|
"oidc_token_exchange": {
|
|
"received_access_token": true,
|
|
"received_id_token": true
|
|
}
|
|
}
|
|
})));
|
|
}
|
|
|
|
#[test]
|
|
fn stored_browser_login_session_omits_provider_token_values() {
|
|
let Cli {
|
|
command: Commands::Login(args),
|
|
} = parse(&[
|
|
"disasmer",
|
|
"login",
|
|
"--browser",
|
|
"--coordinator",
|
|
"https://coord.example.test",
|
|
"--tenant",
|
|
"tenant-cli",
|
|
"--project-id",
|
|
"project-cli",
|
|
"--user",
|
|
"user-cli",
|
|
])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let stored = stored_cli_session_from_login_response(
|
|
&args,
|
|
"https://coord.example.test",
|
|
&json!({
|
|
"session": {
|
|
"tenant": "tenant-live",
|
|
"project": "project-live",
|
|
"user": "user-live",
|
|
"cli_session_credential_kind": "CliDeviceSession",
|
|
"expires_at": "2026-07-04T00:00:00Z",
|
|
"access_token": "provider-secret",
|
|
"id_token": "provider-id-token",
|
|
"provider_tokens_sent_to_nodes": false
|
|
}
|
|
}),
|
|
true,
|
|
false,
|
|
);
|
|
let serialized = serde_json::to_string(&stored).unwrap();
|
|
|
|
assert_eq!(stored.kind, "human");
|
|
assert_eq!(stored.coordinator, "https://coord.example.test");
|
|
assert_eq!(stored.tenant, "tenant-live");
|
|
assert_eq!(stored.project, "project-live");
|
|
assert_eq!(stored.user, "user-live");
|
|
assert_eq!(stored.token_expiry_posture, "expires_at");
|
|
assert!(stored.provider_tokens_exposed_to_cli);
|
|
assert!(!stored.provider_tokens_sent_to_nodes);
|
|
assert!(!serialized.contains("provider-secret"));
|
|
assert!(!serialized.contains("provider-id-token"));
|
|
assert!(!serialized.contains("access_token"));
|
|
assert!(!serialized.contains("id_token"));
|
|
}
|
|
|
|
#[test]
|
|
fn agent_enroll_uses_public_key_without_browser_each_run() {
|
|
let Cli {
|
|
command:
|
|
Commands::Agent {
|
|
command: AgentCommands::Enroll(args),
|
|
},
|
|
} = parse(&["disasmer", "agent", "enroll", "--public-key", "agent-key"])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = agent_enrollment_plan(args);
|
|
|
|
assert!(!plan.browser_interaction_required_each_run);
|
|
assert!(plan.public_key_fingerprint.as_str().starts_with("sha256:"));
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_inspect_discovers_environments_selected_inputs_and_source_providers() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(temp.path().join("envs/linux")).unwrap();
|
|
fs::create_dir_all(temp.path().join("envs/docker")).unwrap();
|
|
fs::create_dir_all(temp.path().join("src")).unwrap();
|
|
fs::write(
|
|
temp.path().join("envs/linux/Containerfile"),
|
|
"FROM alpine\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
temp.path().join("envs/docker/Dockerfile"),
|
|
"FROM debian:stable-slim\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap();
|
|
fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
|
|
|
|
let Cli {
|
|
command:
|
|
Commands::Bundle {
|
|
command: BundleCommands::Inspect(args),
|
|
},
|
|
} = parse(&[
|
|
"disasmer",
|
|
"bundle",
|
|
"inspect",
|
|
"--project",
|
|
temp.path().to_str().unwrap(),
|
|
])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let inspection = bundle_inspection(args, PathBuf::from("/unused")).unwrap();
|
|
|
|
assert_eq!(inspection.project, temp.path());
|
|
assert!(inspection
|
|
.default_source_providers
|
|
.contains(&SourceProviderKind::Git));
|
|
assert!(inspection.source_provider_statuses.iter().any(|status| {
|
|
status.provider == "filesystem" && status.status == "enabled" && status.active
|
|
}));
|
|
assert!(inspection
|
|
.source_provider_statuses
|
|
.iter()
|
|
.any(|status| status.provider == "git" && status.status == "missing"));
|
|
assert!(inspection
|
|
.source_provider_manifest
|
|
.description
|
|
.contains("node task"));
|
|
assert!(
|
|
!inspection
|
|
.source_provider_manifest
|
|
.coordinator_requires_checkout_access
|
|
);
|
|
assert!(
|
|
!inspection
|
|
.source_provider_manifest
|
|
.transfer_policy
|
|
.default_full_repo_tarball
|
|
);
|
|
assert!(inspection
|
|
.metadata
|
|
.environments
|
|
.iter()
|
|
.any(|environment| environment.name == "linux"));
|
|
assert!(inspection
|
|
.metadata
|
|
.environments
|
|
.iter()
|
|
.any(|environment| environment.name == "docker"));
|
|
assert_eq!(
|
|
inspection.metadata.source_metadata.source_provider_manifest,
|
|
inspection.source_provider_manifest.digest
|
|
);
|
|
assert!(
|
|
inspection
|
|
.metadata
|
|
.source_metadata
|
|
.transfer_policy
|
|
.local_source_bytes_remain_node_local
|
|
);
|
|
assert!(
|
|
!inspection
|
|
.metadata
|
|
.source_metadata
|
|
.transfer_policy
|
|
.coordinator_receives_source_bytes_by_default
|
|
);
|
|
assert!(
|
|
!inspection
|
|
.metadata
|
|
.source_metadata
|
|
.transfer_policy
|
|
.default_full_repo_tarball
|
|
);
|
|
assert!(inspection
|
|
.metadata
|
|
.task_metadata
|
|
.entrypoints
|
|
.contains(&"build".to_owned()));
|
|
assert_eq!(
|
|
inspection.metadata.task_metadata.default_entrypoint,
|
|
"build"
|
|
);
|
|
assert_eq!(
|
|
inspection.metadata.task_metadata.task_abi,
|
|
task_abi_digest(&ProjectModel::discover_without_config(temp.path()).unwrap())
|
|
);
|
|
assert!(inspection
|
|
.metadata
|
|
.wasm_code
|
|
.as_str()
|
|
.starts_with("sha256:"));
|
|
assert!(inspection.metadata.debug_metadata.available);
|
|
assert!(inspection.metadata.debug_metadata.source_level_breakpoints);
|
|
assert!(inspection.metadata.debug_metadata.variables_pane_supported);
|
|
assert!(
|
|
inspection
|
|
.metadata
|
|
.large_input_policy
|
|
.selected_inputs_are_content_digests
|
|
);
|
|
assert!(
|
|
!inspection
|
|
.metadata
|
|
.large_input_policy
|
|
.selected_input_bytes_included
|
|
);
|
|
assert!(
|
|
!inspection
|
|
.metadata
|
|
.large_input_policy
|
|
.full_repository_bytes_included
|
|
);
|
|
assert!(
|
|
!inspection
|
|
.metadata
|
|
.large_input_policy
|
|
.silent_task_argument_serialization
|
|
);
|
|
assert!(inspection
|
|
.metadata
|
|
.large_input_policy
|
|
.supported_handle_types
|
|
.contains(&"SourceSnapshot".to_owned()));
|
|
assert!(
|
|
inspection
|
|
.metadata
|
|
.restart_compatibility
|
|
.source_edits_can_restart_from_clean_task_boundary
|
|
);
|
|
assert!(
|
|
inspection
|
|
.metadata
|
|
.restart_compatibility
|
|
.requires_clean_checkpoint_boundary
|
|
);
|
|
assert_eq!(
|
|
inspection.metadata.restart_compatibility.compares_task_abi,
|
|
inspection.metadata.task_metadata.task_abi
|
|
);
|
|
assert!(
|
|
inspection
|
|
.metadata
|
|
.restart_compatibility
|
|
.incompatible_changes_require_whole_process_restart
|
|
);
|
|
assert!(!inspection.metadata.embeds_full_container_images);
|
|
assert!(inspection
|
|
.metadata
|
|
.selected_inputs
|
|
.iter()
|
|
.any(|input| input.path == "Cargo.toml"));
|
|
assert!(inspection
|
|
.metadata
|
|
.selected_inputs
|
|
.iter()
|
|
.any(|input| input.path == "src/main.rs"));
|
|
assert!(inspection.environment_diagnostics.is_empty());
|
|
assert!(inspection
|
|
.pre_schedule_diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.category == "capability"
|
|
&& diagnostic.message.contains("environment `linux`")));
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_inspect_reports_missing_environment_references_before_schedule() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(temp.path().join("src")).unwrap();
|
|
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap();
|
|
fs::write(
|
|
temp.path().join("src/main.rs"),
|
|
"fn main() { let _target = env!(\"linux\"); }\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let inspection = bundle_inspection(
|
|
BundleInspectArgs {
|
|
project: Some(temp.path().to_path_buf()),
|
|
source_provider: None,
|
|
disabled_source_providers: Vec::new(),
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(inspection.environment_diagnostics.len(), 1);
|
|
assert_eq!(inspection.environment_diagnostics[0].path, "src/main.rs");
|
|
assert_eq!(
|
|
inspection.environment_diagnostics[0].reference.name,
|
|
"linux"
|
|
);
|
|
assert!(inspection.environment_diagnostics[0]
|
|
.message
|
|
.contains("missing Disasmer environment `linux`"));
|
|
assert!(inspection
|
|
.pre_schedule_diagnostics
|
|
.iter()
|
|
.any(|diagnostic| {
|
|
diagnostic.severity == "error"
|
|
&& diagnostic.category == "environment"
|
|
&& diagnostic.code == "missing_environment"
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_inspect_reports_source_provider_overrides_before_schedule() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap();
|
|
|
|
let missing_git = bundle_inspection(
|
|
BundleInspectArgs {
|
|
project: Some(temp.path().to_path_buf()),
|
|
source_provider: Some("git".to_owned()),
|
|
disabled_source_providers: Vec::new(),
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
)
|
|
.unwrap();
|
|
assert!(missing_git.source_provider_statuses.iter().any(|status| {
|
|
status.provider == "git" && status.status == "missing" && status.active
|
|
}));
|
|
assert!(missing_git
|
|
.pre_schedule_diagnostics
|
|
.iter()
|
|
.any(|diagnostic| {
|
|
diagnostic.category == "source_provider"
|
|
&& diagnostic.code == "source_provider_missing"
|
|
}));
|
|
|
|
let unsupported = bundle_inspection(
|
|
BundleInspectArgs {
|
|
project: Some(temp.path().to_path_buf()),
|
|
source_provider: Some("custom-lfs".to_owned()),
|
|
disabled_source_providers: Vec::new(),
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
)
|
|
.unwrap();
|
|
assert!(unsupported.source_provider_statuses.iter().any(|status| {
|
|
status.provider == "custom-lfs" && status.status == "unsupported" && status.active
|
|
}));
|
|
assert!(unsupported
|
|
.pre_schedule_diagnostics
|
|
.iter()
|
|
.any(|diagnostic| {
|
|
diagnostic.category == "source_provider"
|
|
&& diagnostic.code == "source_provider_unsupported"
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_identity_changes_when_selected_input_file_changes() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap();
|
|
|
|
let first = bundle_inspection(
|
|
BundleInspectArgs {
|
|
project: Some(temp.path().to_path_buf()),
|
|
source_provider: None,
|
|
disabled_source_providers: Vec::new(),
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
temp.path().join("Cargo.toml"),
|
|
"[package]\nname='changed'\n",
|
|
)
|
|
.unwrap();
|
|
let second = bundle_inspection(
|
|
BundleInspectArgs {
|
|
project: Some(temp.path().to_path_buf()),
|
|
source_provider: None,
|
|
disabled_source_providers: Vec::new(),
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_ne!(first.metadata.identity, second.metadata.identity);
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_rebuild_after_source_edit_keeps_restart_compatibility_contract() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(temp.path().join("src")).unwrap();
|
|
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap();
|
|
fs::write(
|
|
temp.path().join("src/main.rs"),
|
|
"fn main() { println!(\"first\"); }\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let inspect = |project: &Path| {
|
|
bundle_inspection(
|
|
BundleInspectArgs {
|
|
project: Some(project.to_path_buf()),
|
|
source_provider: None,
|
|
disabled_source_providers: Vec::new(),
|
|
json: true,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
)
|
|
.unwrap()
|
|
};
|
|
|
|
let first = inspect(temp.path());
|
|
fs::write(
|
|
temp.path().join("src/main.rs"),
|
|
"fn main() { println!(\"second\"); }\n",
|
|
)
|
|
.unwrap();
|
|
let rebuilt = inspect(temp.path());
|
|
|
|
assert_ne!(first.metadata.identity, rebuilt.metadata.identity);
|
|
assert_ne!(
|
|
first
|
|
.metadata
|
|
.source_metadata
|
|
.selected_inputs
|
|
.iter()
|
|
.find(|input| input.path == "src/main.rs")
|
|
.unwrap()
|
|
.digest,
|
|
rebuilt
|
|
.metadata
|
|
.source_metadata
|
|
.selected_inputs
|
|
.iter()
|
|
.find(|input| input.path == "src/main.rs")
|
|
.unwrap()
|
|
.digest
|
|
);
|
|
assert_eq!(
|
|
first.metadata.task_metadata.task_abi,
|
|
rebuilt.metadata.task_metadata.task_abi
|
|
);
|
|
assert_eq!(
|
|
first.metadata.restart_compatibility.compares_task_abi,
|
|
rebuilt.metadata.restart_compatibility.compares_task_abi
|
|
);
|
|
assert!(
|
|
rebuilt
|
|
.metadata
|
|
.restart_compatibility
|
|
.source_edits_can_restart_from_clean_task_boundary
|
|
);
|
|
assert!(
|
|
rebuilt
|
|
.metadata
|
|
.restart_compatibility
|
|
.requires_clean_checkpoint_boundary
|
|
);
|
|
assert!(
|
|
rebuilt
|
|
.metadata
|
|
.restart_compatibility
|
|
.discards_unflushed_task_local_changes
|
|
);
|
|
assert!(
|
|
rebuilt
|
|
.metadata
|
|
.restart_compatibility
|
|
.incompatible_changes_require_whole_process_restart
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn source_provider_manifest_digest_does_not_include_local_project_path() {
|
|
let first = tempfile::tempdir().unwrap();
|
|
let second = tempfile::tempdir().unwrap();
|
|
fs::write(first.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap();
|
|
fs::write(second.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap();
|
|
|
|
let first = bundle_inspection(
|
|
BundleInspectArgs {
|
|
project: Some(first.path().to_path_buf()),
|
|
source_provider: None,
|
|
disabled_source_providers: Vec::new(),
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
)
|
|
.unwrap();
|
|
let second = bundle_inspection(
|
|
BundleInspectArgs {
|
|
project: Some(second.path().to_path_buf()),
|
|
source_provider: None,
|
|
disabled_source_providers: Vec::new(),
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
first.source_provider_manifest.digest,
|
|
second.source_provider_manifest.digest
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn node_attach_can_exchange_enrollment_grant() {
|
|
let Cli {
|
|
command:
|
|
Commands::Node {
|
|
command: NodeCommands::Attach(args),
|
|
},
|
|
} = parse(&[
|
|
"disasmer",
|
|
"node",
|
|
"attach",
|
|
"--enrollment-grant",
|
|
"grant",
|
|
"--public-key",
|
|
"node-key",
|
|
])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = attach_plan(args);
|
|
|
|
assert!(
|
|
plan.enrollment
|
|
.unwrap()
|
|
.exchanges_short_lived_grant_for_long_lived_node_identity
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn node_attach_enrollment_uses_default_public_key_when_not_explicit() {
|
|
let Cli {
|
|
command:
|
|
Commands::Node {
|
|
command: NodeCommands::Attach(args),
|
|
},
|
|
} = parse(&[
|
|
"disasmer",
|
|
"node",
|
|
"attach",
|
|
"--node",
|
|
"node-default-key",
|
|
"--enrollment-grant",
|
|
"grant",
|
|
])
|
|
else {
|
|
panic!("wrong command");
|
|
};
|
|
let plan = attach_plan(args);
|
|
|
|
let enrollment = plan.enrollment.unwrap();
|
|
assert_eq!(enrollment.grant, "grant");
|
|
assert_eq!(
|
|
enrollment.public_key_fingerprint,
|
|
Digest::sha256("node-default-key-public-key")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hosted_operator_url_maps_to_json_line_transport_address() {
|
|
assert_eq!(
|
|
json_line_transport_addr(DEFAULT_OPERATOR_ENDPOINT),
|
|
"disasmer.michelpaulissen.com:9443"
|
|
);
|
|
assert_eq!(
|
|
json_line_transport_addr("https://disasmer.michelpaulissen.com:9443/auth/device"),
|
|
"disasmer.michelpaulissen.com:9443"
|
|
);
|
|
assert_eq!(
|
|
json_line_transport_addr("http://operator.example.test"),
|
|
"operator.example.test:80"
|
|
);
|
|
assert_eq!(json_line_transport_addr("127.0.0.1:7999"), "127.0.0.1:7999");
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_reports_unchecked_coordinator_reachability_without_config() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let report = doctor_report(
|
|
DoctorArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(report["command"], "doctor");
|
|
assert!(report["coordinator"].is_null());
|
|
assert_eq!(report["coordinator_reachability"]["checked"], false);
|
|
assert_eq!(
|
|
report["coordinator_reachability"]["status"],
|
|
"not_configured"
|
|
);
|
|
assert!(matches!(
|
|
report["node_readiness_summary"]["status"].as_str(),
|
|
Some("ready_to_attach")
|
|
| Some("local_dependencies_missing")
|
|
| Some("limited_capabilities")
|
|
));
|
|
assert_eq!(
|
|
report["node_readiness_summary"]["explicit_attach_required"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
report["node_readiness_summary"]["command_execution_capability"],
|
|
true
|
|
);
|
|
assert!(report["node_readiness_summary"]["missing_local_dependencies"].is_array());
|
|
assert!(
|
|
report["node_readiness_summary"]["next_actions"]
|
|
.as_array()
|
|
.unwrap()
|
|
.len()
|
|
>= 2
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_pings_configured_coordinator() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains("\"type\":\"ping\""));
|
|
stream
|
|
.write_all(b"{\"type\":\"pong\",\"epoch\":42}\n")
|
|
.unwrap();
|
|
});
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let report = doctor_report(
|
|
DoctorArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: Some(addr.clone()),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(report["coordinator"], addr);
|
|
assert_eq!(report["coordinator_reachability"]["checked"], true);
|
|
assert_eq!(report["coordinator_reachability"]["status"], "reachable");
|
|
assert_eq!(
|
|
report["coordinator_reachability"]["response"]["type"],
|
|
"pong"
|
|
);
|
|
assert_eq!(report["coordinator_reachability"]["response"]["epoch"], 42);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_status_reads_stored_cli_session_without_provider_tokens() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""type":"auth_status""#));
|
|
assert!(line.contains(r#""tenant":"tenant-session""#));
|
|
assert!(line.contains(r#""project":"project-session""#));
|
|
assert!(line.contains(r#""actor_user":"user-session""#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"auth_status","tenant":"tenant-session","project":"project-session","actor":"user-session","authenticated":true,"account_status":"active","suspended":false,"disabled":false,"sanitized_reason":null,"next_actions":[],"private_moderation_details_exposed":false,"signup_failure_details_exposed":false}"#,
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
});
|
|
write_cli_session(
|
|
temp.path(),
|
|
&StoredCliSession {
|
|
kind: "human".to_owned(),
|
|
coordinator: addr.clone(),
|
|
tenant: "tenant-session".to_owned(),
|
|
project: "project-session".to_owned(),
|
|
user: "user-session".to_owned(),
|
|
cli_session_credential_kind: "CliDeviceSession".to_owned(),
|
|
token_expiry_posture: "expires_at".to_owned(),
|
|
expires_at: Some("2026-07-04T00:00:00Z".to_owned()),
|
|
provider_tokens_exposed_to_cli: false,
|
|
provider_tokens_sent_to_nodes: false,
|
|
created_at_unix_seconds: 1,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let report = auth_status_report(
|
|
AuthStatusArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(report["active_coordinator"], addr);
|
|
assert_eq!(report["principal"], "user-session");
|
|
assert_eq!(report["tenant"], "tenant-session");
|
|
assert_eq!(report["project"], "project-session");
|
|
assert_eq!(report["session"]["kind"], "human");
|
|
assert_eq!(report["session"]["source"], "session_file");
|
|
assert_eq!(report["session"]["authenticated"], true);
|
|
assert_eq!(
|
|
report["session"]["cli_session_credential_kind"],
|
|
"CliDeviceSession"
|
|
);
|
|
assert_eq!(report["session"]["token_expiry_posture"], "expires_at");
|
|
assert_eq!(report["session"]["provider_tokens_exposed_to_cli"], false);
|
|
assert_eq!(report["session"]["provider_tokens_exposed_to_nodes"], false);
|
|
assert_eq!(report["coordinator_account_status"]["checked"], true);
|
|
assert_eq!(
|
|
report["coordinator_account_status"]["account_status"],
|
|
"active"
|
|
);
|
|
assert_eq!(
|
|
report["coordinator_account_status"]["private_moderation_details_exposed"],
|
|
false
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_status_queries_coordinator_account_state_without_private_moderation_details() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""type":"auth_status""#));
|
|
assert!(line.contains(r#""tenant":"tenant-live""#));
|
|
assert!(line.contains(r#""project":"project-live""#));
|
|
assert!(line.contains(r#""actor_user":"user-live""#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"auth_status","tenant":"tenant-live","project":"project-live","actor":"user-live","authenticated":true,"account_status":"suspended","suspended":true,"disabled":false,"sanitized_reason":"account or tenant is suspended by hosted policy","next_actions":["contact the hosted operator"],"private_moderation_details_exposed":false,"signup_failure_details_exposed":false,"abuse_score":99,"moderation_notes":"private moderation note"}"#,
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
});
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let report = auth_status_report(
|
|
AuthStatusArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant-live".to_owned(),
|
|
project: "project-live".to_owned(),
|
|
user: "user-live".to_owned(),
|
|
json: false,
|
|
},
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(report["command"], "auth status");
|
|
assert_eq!(report["coordinator_account_status"]["checked"], true);
|
|
assert_eq!(report["coordinator_account_status"]["reachable"], true);
|
|
assert_eq!(
|
|
report["coordinator_account_status"]["source"],
|
|
"public_coordinator_api"
|
|
);
|
|
assert_eq!(
|
|
report["coordinator_account_status"]["account_status"],
|
|
"suspended"
|
|
);
|
|
assert_eq!(report["coordinator_account_status"]["suspended"], true);
|
|
assert_eq!(report["coordinator_account_status"]["disabled"], false);
|
|
assert_eq!(
|
|
report["coordinator_account_status"]["sanitized_reason"],
|
|
"account or tenant is suspended by hosted policy"
|
|
);
|
|
assert_eq!(
|
|
report["coordinator_account_status"]["private_moderation_details_exposed"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
report["coordinator_account_status"]["signup_failure_details_exposed"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
report["coordinator_account_status"]["coordinator_response_type"],
|
|
"auth_status"
|
|
);
|
|
assert_eq!(
|
|
report["coordinator_account_status"]["coordinator_session_requests"],
|
|
1
|
|
);
|
|
let serialized = serde_json::to_string(&report).unwrap();
|
|
assert!(!serialized.contains("abuse_score"));
|
|
assert!(!serialized.contains("moderation_notes"));
|
|
assert!(!serialized.contains("private moderation note"));
|
|
|
|
let rendered = human_report(&report);
|
|
assert!(rendered.contains("account status: suspended"));
|
|
assert!(rendered.contains("account suspended: true"));
|
|
assert!(rendered.contains("private moderation details exposed: false"));
|
|
assert!(!rendered.contains("private moderation note"));
|
|
}
|
|
|
|
#[test]
|
|
fn cli_first_mvp_command_surface_parses() {
|
|
for args in [
|
|
&["disasmer", "doctor"][..],
|
|
&["disasmer", "auth", "status"],
|
|
&["disasmer", "logout", "--yes"],
|
|
&["disasmer", "auth", "logout", "--yes"],
|
|
&["disasmer", "login", "--browser", "--non-interactive"],
|
|
&[
|
|
"disasmer",
|
|
"key",
|
|
"add",
|
|
"--agent",
|
|
"agent",
|
|
"--public-key",
|
|
"key",
|
|
],
|
|
&["disasmer", "key", "list"],
|
|
&["disasmer", "key", "revoke", "--agent", "agent", "--yes"],
|
|
&["disasmer", "project", "init", "--yes"],
|
|
&["disasmer", "project", "status"],
|
|
&["disasmer", "project", "list"],
|
|
&["disasmer", "project", "select", "project"],
|
|
&["disasmer", "inspect"],
|
|
&["disasmer", "build"],
|
|
&["disasmer", "run", "--non-interactive"],
|
|
&["disasmer", "node", "enroll"],
|
|
&["disasmer", "node", "list"],
|
|
&["disasmer", "node", "status"],
|
|
&["disasmer", "node", "revoke", "--node", "node", "--yes"],
|
|
&["disasmer", "process", "status"],
|
|
&["disasmer", "process", "restart", "--yes"],
|
|
&["disasmer", "process", "cancel", "--yes"],
|
|
&["disasmer", "task", "list"],
|
|
&["disasmer", "task", "restart", "compile-linux", "--yes"],
|
|
&["disasmer", "logs"],
|
|
&["disasmer", "artifact", "list"],
|
|
&["disasmer", "artifact", "download", "artifact"],
|
|
&[
|
|
"disasmer", "artifact", "export", "artifact", "--to", "/tmp/out",
|
|
],
|
|
&["disasmer", "dap", "--plan"],
|
|
&["disasmer", "debug", "attach"],
|
|
&["disasmer", "quota", "status"],
|
|
&["disasmer", "admin", "status"],
|
|
&["disasmer", "admin", "bootstrap", "--yes"],
|
|
&[
|
|
"disasmer",
|
|
"admin",
|
|
"revoke-node",
|
|
"--node",
|
|
"node",
|
|
"--yes",
|
|
],
|
|
&["disasmer", "admin", "stop-process", "--yes"],
|
|
&["disasmer", "admin", "suspend-tenant", "--yes"],
|
|
] {
|
|
let _ = parse(args);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cli_has_no_direct_hosted_account_creation_command() {
|
|
for args in [
|
|
&["disasmer", "signup"][..],
|
|
&["disasmer", "account", "create"],
|
|
&["disasmer", "login", "--create-account"],
|
|
] {
|
|
let error = Cli::try_parse_from(args).unwrap_err().to_string();
|
|
assert!(
|
|
error.contains("unrecognized subcommand") || error.contains("unexpected argument"),
|
|
"expected no direct account creation command for {args:?}, got {error}"
|
|
);
|
|
}
|
|
|
|
let mut command = Cli::command();
|
|
let help = command.render_help().to_string();
|
|
assert!(help.contains("Hosted account creation happens in the browser login flow."));
|
|
assert!(!help.contains("disasmer signup"));
|
|
assert!(!help.contains("account create"));
|
|
}
|
|
|
|
#[test]
|
|
fn admin_bootstrap_reports_self_hosted_cli_only_path() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let report = admin_bootstrap_report(
|
|
AdminBootstrapArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "team".to_owned(),
|
|
project: "self-hosted".to_owned(),
|
|
user: "admin".to_owned(),
|
|
json: false,
|
|
},
|
|
name: "Self Hosted".to_owned(),
|
|
yes: true,
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(report["command"], "admin bootstrap");
|
|
assert_eq!(report["mode"], "self_hosted_local");
|
|
assert_eq!(report["private_website_required"], false);
|
|
assert_eq!(report["self_hosted_cli_only"], true);
|
|
assert_eq!(report["project_config_written"], true);
|
|
assert_eq!(report["project_init"]["command"], "project init");
|
|
assert_eq!(report["project_init"]["private_website_required"], false);
|
|
assert_eq!(
|
|
report["project_init"]["project_config"]["project"],
|
|
"self-hosted"
|
|
);
|
|
assert_eq!(
|
|
report["admin_surfaces"]["node"],
|
|
"disasmer node enroll/list/status/revoke"
|
|
);
|
|
let steps = report["bootstrap_sequence"].as_array().unwrap();
|
|
for expected in [
|
|
"start_self_hosted_coordinator",
|
|
"create_or_link_project",
|
|
"create_node_enrollment_grant",
|
|
"attach_worker_node",
|
|
"run_process",
|
|
"inspect_status_logs_artifacts",
|
|
"revoke_access",
|
|
] {
|
|
assert!(
|
|
steps.iter().any(|step| step["step"] == expected),
|
|
"missing bootstrap step {expected}"
|
|
);
|
|
}
|
|
assert!(steps.iter().all(|step| {
|
|
step.get("private_website_required")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
== false
|
|
}));
|
|
assert!(steps.iter().any(|step| step
|
|
.get("command")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.contains("disasmer node enroll")));
|
|
assert!(steps.iter().any(|step| step
|
|
.get("command")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.contains("disasmer admin revoke-node")));
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_help_exposes_primary_workflow_without_auth() {
|
|
let mut command = Cli::command();
|
|
let help = command.render_help().to_string();
|
|
|
|
for expected in [
|
|
"Primary workflow:",
|
|
"disasmer login --browser",
|
|
"disasmer project init",
|
|
"disasmer node enroll",
|
|
"disasmer node attach --worker",
|
|
"disasmer run [entry] --project <path>",
|
|
"Disasmer: Launch Virtual Process",
|
|
"disasmer dap",
|
|
"disasmer process status",
|
|
"task list",
|
|
"logs",
|
|
"artifact list",
|
|
"--json",
|
|
"Hosted account creation happens in the browser login flow.",
|
|
] {
|
|
assert!(help.contains(expected), "help output missing {expected}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_logout_alias_removes_only_cli_session_state() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let session_file = session_config_file(temp.path());
|
|
fs::create_dir_all(session_file.parent().unwrap()).unwrap();
|
|
fs::write(&session_file, br#"{"kind":"human","token":"local"}"#).unwrap();
|
|
|
|
let unconfirmed = logout_report(
|
|
AuthLogoutArgs {
|
|
yes: false,
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
},
|
|
temp.path().to_path_buf(),
|
|
"logout",
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(unconfirmed["status"], "confirmation_required");
|
|
assert_eq!(unconfirmed["coordinator_request_sent"], false);
|
|
assert_eq!(unconfirmed["machine_error"]["category"], "policy");
|
|
assert!(session_file.exists());
|
|
|
|
let report = logout_report(
|
|
AuthLogoutArgs {
|
|
yes: true,
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
},
|
|
temp.path().to_path_buf(),
|
|
"logout",
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(report["command"], "logout");
|
|
assert_eq!(report["requires_confirmation"], false);
|
|
assert_eq!(report["removed_cli_session_file"], true);
|
|
assert_eq!(report["node_credentials_untouched"], true);
|
|
assert!(!session_file.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn mutating_commands_require_yes_before_side_effects() {
|
|
let scope = CliScopeArgs {
|
|
coordinator: Some("127.0.0.1:9".to_owned()),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
};
|
|
let reports = [
|
|
key_revoke_report(KeyRevokeArgs {
|
|
scope: scope.clone(),
|
|
agent: "agent-ci".to_owned(),
|
|
yes: false,
|
|
})
|
|
.unwrap(),
|
|
node_revoke_report(NodeRevokeArgs {
|
|
scope: scope.clone(),
|
|
node: "node-a".to_owned(),
|
|
yes: false,
|
|
})
|
|
.unwrap(),
|
|
process_restart_report(ProcessRestartArgs {
|
|
scope: scope.clone(),
|
|
process: "vp".to_owned(),
|
|
yes: false,
|
|
})
|
|
.unwrap(),
|
|
process_cancel_report(ProcessCancelArgs {
|
|
scope: scope.clone(),
|
|
process: "vp".to_owned(),
|
|
node: None,
|
|
task: None,
|
|
yes: false,
|
|
})
|
|
.unwrap(),
|
|
task_restart_report(TaskRestartArgs {
|
|
scope: scope.clone(),
|
|
task: "compile-linux".to_owned(),
|
|
process: "vp".to_owned(),
|
|
yes: false,
|
|
})
|
|
.unwrap(),
|
|
admin_suspend_tenant_report(AdminSuspendTenantArgs {
|
|
scope,
|
|
target_tenant: Some("tenant".to_owned()),
|
|
yes: false,
|
|
})
|
|
.unwrap(),
|
|
];
|
|
|
|
for report in reports {
|
|
assert_eq!(report["status"], "confirmation_required");
|
|
assert_eq!(report["requires_confirmation"], true);
|
|
assert_eq!(report["coordinator_request_sent"], false);
|
|
assert_eq!(report["safe_failure"], true);
|
|
assert_eq!(report["machine_error"]["category"], "policy");
|
|
assert_eq!(report["machine_error"]["confirmation_required"], true);
|
|
assert!(report["next_actions"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|action| action.as_str().unwrap_or_default().contains("--yes")));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cli_first_json_mode_parses_for_primary_commands() {
|
|
for args in [
|
|
&["disasmer", "doctor", "--json"][..],
|
|
&["disasmer", "login", "--json"],
|
|
&[
|
|
"disasmer",
|
|
"login",
|
|
"--browser",
|
|
"--non-interactive",
|
|
"--json",
|
|
],
|
|
&["disasmer", "logout", "--yes", "--json"],
|
|
&["disasmer", "auth", "status", "--json"],
|
|
&[
|
|
"disasmer",
|
|
"agent",
|
|
"enroll",
|
|
"--public-key",
|
|
"key",
|
|
"--json",
|
|
],
|
|
&[
|
|
"disasmer",
|
|
"key",
|
|
"add",
|
|
"--agent",
|
|
"agent",
|
|
"--public-key",
|
|
"key",
|
|
"--json",
|
|
],
|
|
&["disasmer", "project", "init", "--yes", "--json"],
|
|
&["disasmer", "inspect", "--json"],
|
|
&["disasmer", "build", "--json"],
|
|
&["disasmer", "bundle", "inspect", "--json"],
|
|
&["disasmer", "run", "--json"],
|
|
&["disasmer", "run", "--non-interactive", "--json"],
|
|
&["disasmer", "node", "attach", "--json"],
|
|
&["disasmer", "node", "enroll", "--json"],
|
|
&["disasmer", "process", "status", "--json"],
|
|
&["disasmer", "task", "list", "--json"],
|
|
&["disasmer", "logs", "--json"],
|
|
&["disasmer", "artifact", "list", "--json"],
|
|
&["disasmer", "artifact", "download", "artifact", "--json"],
|
|
&[
|
|
"disasmer", "artifact", "export", "artifact", "--to", "/tmp/out", "--json",
|
|
],
|
|
&["disasmer", "dap", "--plan", "--json"],
|
|
&["disasmer", "debug", "attach", "--json"],
|
|
&["disasmer", "quota", "status", "--json"],
|
|
&["disasmer", "admin", "status", "--json"],
|
|
] {
|
|
let _ = parse(args);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn key_lifecycle_reports_project_scoped_agent_credentials() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let add_response = concat!(
|
|
r#"{"type":"agent_public_key","actor":"user","record":{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":false,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}}"#
|
|
);
|
|
let list_response = concat!(
|
|
r#"{"type":"agent_public_keys","actor":"user","records":[{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":false,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}]}"#
|
|
);
|
|
let revoke_response = concat!(
|
|
r#"{"type":"agent_public_key","actor":"user","record":{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":true,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}}"#
|
|
);
|
|
for (expected, response) in [
|
|
("register_agent_public_key", add_response),
|
|
("list_agent_public_keys", list_response),
|
|
("revoke_agent_public_key", revoke_response),
|
|
] {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(&format!(r#""type":"{expected}""#)));
|
|
assert!(line.contains(r#""tenant":"tenant""#));
|
|
assert!(line.contains(r#""project":"project""#));
|
|
assert!(line.contains(r#""user":"user""#));
|
|
if expected != "list_agent_public_keys" {
|
|
assert!(line.contains(r#""agent":"agent-ci""#));
|
|
}
|
|
if expected == "register_agent_public_key" {
|
|
assert!(line.contains(r#""public_key":"agent-key-v1""#));
|
|
}
|
|
stream.write_all(response.as_bytes()).unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
}
|
|
});
|
|
let scope = CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
};
|
|
|
|
let added = key_add_report(KeyAddArgs {
|
|
scope: scope.clone(),
|
|
agent: "agent-ci".to_owned(),
|
|
public_key: "agent-key-v1".to_owned(),
|
|
})
|
|
.unwrap();
|
|
let listed = key_list_report(KeyListArgs {
|
|
scope: scope.clone(),
|
|
})
|
|
.unwrap();
|
|
let revoked = key_revoke_report(KeyRevokeArgs {
|
|
scope,
|
|
agent: "agent-ci".to_owned(),
|
|
yes: true,
|
|
})
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(added["command"], "key add");
|
|
assert_eq!(added["agent"], "agent-ci");
|
|
assert_eq!(added["credential_scope"]["actions"][0], "project:read");
|
|
assert_eq!(
|
|
added["credential_scope"]["human_account_creation_privilege"],
|
|
false
|
|
);
|
|
assert_eq!(added["browser_interaction_required_each_run"], false);
|
|
assert_eq!(added["attribution"]["registered_by_user"], "user");
|
|
assert_eq!(listed["records"].as_array().unwrap().len(), 1);
|
|
assert_eq!(listed["credential_scope"]["listed_for_user"], "user");
|
|
assert_eq!(revoked["revoked"], true);
|
|
assert_eq!(revoked["attribution"]["revoked_by_user"], "user");
|
|
}
|
|
|
|
#[test]
|
|
fn node_revoke_reports_scoped_credential_revocation() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""type":"revoke_node_credential""#));
|
|
assert!(line.contains(r#""tenant":"tenant""#));
|
|
assert!(line.contains(r#""project":"project""#));
|
|
assert!(line.contains(r#""actor_user":"user""#));
|
|
assert!(line.contains(r#""node":"node-a""#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"node_credential_revoked","node":"node-a","tenant":"tenant","project":"project","actor":"user","descriptor_removed":true,"queued_assignments_removed":2}"#,
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
});
|
|
|
|
let revoked = node_revoke_report(NodeRevokeArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
node: "node-a".to_owned(),
|
|
yes: true,
|
|
})
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(revoked["command"], "node revoke");
|
|
assert_eq!(revoked["node"], "node-a");
|
|
assert_eq!(revoked["credential_revoked"], true);
|
|
assert_eq!(revoked["descriptor_removed"], true);
|
|
assert_eq!(revoked["queued_assignments_removed"], 2);
|
|
assert_eq!(revoked["node_credentials_separate_from_user_session"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn admin_status_and_suspend_use_public_coordinator_api() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
for (expected, response) in [
|
|
(
|
|
"admin_status",
|
|
r#"{"type":"admin_status","tenant":"tenant","actor":"admin","suspended":false,"safe_default":"read_only"}"#,
|
|
),
|
|
(
|
|
"suspend_tenant",
|
|
r#"{"type":"tenant_suspended","tenant":"tenant","actor":"admin","policy":{"tenant":"tenant","name":"tenant:suspended","digest":"sha256:suspension"}}"#,
|
|
),
|
|
] {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(&format!(r#""type":"{expected}""#)));
|
|
assert!(
|
|
line.contains(r#""tenant":"admin-tenant""#)
|
|
|| line.contains(r#""tenant":"tenant""#)
|
|
);
|
|
assert!(line.contains(r#""actor_user":"admin""#));
|
|
if expected == "suspend_tenant" {
|
|
assert!(line.contains(r#""target_tenant":"tenant""#));
|
|
}
|
|
stream.write_all(response.as_bytes()).unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
}
|
|
});
|
|
let scope = CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "admin-tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "admin".to_owned(),
|
|
json: false,
|
|
};
|
|
|
|
let status = admin_status_report(AdminStatusArgs {
|
|
scope: scope.clone(),
|
|
})
|
|
.unwrap();
|
|
let suspended = admin_suspend_tenant_report(AdminSuspendTenantArgs {
|
|
scope,
|
|
target_tenant: Some("tenant".to_owned()),
|
|
yes: true,
|
|
})
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(status["command"], "admin status");
|
|
assert_eq!(status["safe_default"], "read_only");
|
|
assert_eq!(status["private_website_required"], false);
|
|
assert_eq!(status["suspended"], false);
|
|
assert_eq!(suspended["command"], "admin suspend-tenant");
|
|
assert_eq!(suspended["tenant"], "tenant");
|
|
assert_eq!(suspended["actor_tenant"], "admin-tenant");
|
|
assert_eq!(suspended["suspended"], true);
|
|
assert_eq!(suspended["private_website_required"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn debug_attach_reports_public_authorization() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""type":"debug_attach""#));
|
|
assert!(line.contains(r#""tenant":"tenant""#));
|
|
assert!(line.contains(r#""project":"project""#));
|
|
assert!(line.contains(r#""actor_user":"user""#));
|
|
assert!(line.contains(r#""process":"vp""#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"debug_attach","process":"vp","actor":"user","authorization":{"allowed":true,"reason":"debug attach authorized for project"},"audit_event":{"tenant":"tenant","project":"project","process":"vp","task":null,"actor":"user","operation":"debug_attach","allowed":true,"reason":"debug attach authorized for project","charged_debug_read_bytes":1024,"used_debug_read_bytes":1024},"charged_debug_read_bytes":1024,"used_debug_read_bytes":1024}"#,
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
});
|
|
|
|
let report = debug_attach_report_with_dap(
|
|
DebugAttachArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
process: "vp".to_owned(),
|
|
},
|
|
"/tmp/disasmer-debug-dap-test".to_owned(),
|
|
)
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(report["command"], "debug attach");
|
|
assert_eq!(report["process"], "vp");
|
|
assert_eq!(report["authorized"], true);
|
|
assert_eq!(
|
|
report["authorization"]["reason"],
|
|
"debug attach authorized for project"
|
|
);
|
|
assert_eq!(report["audit_event"]["operation"], "debug_attach");
|
|
assert_eq!(report["charged_debug_read_bytes"], 1024);
|
|
assert_eq!(report["used_debug_read_bytes"], 1024);
|
|
assert_eq!(report["debug_reads_quota_limited"], true);
|
|
assert_eq!(report["private_website_required"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn human_report_is_text_not_json() {
|
|
let report = json!({
|
|
"command": "doctor",
|
|
"status": "ok",
|
|
"coordinator": "127.0.0.1:9443",
|
|
"next_actions": ["disasmer login --browser", "disasmer project init"],
|
|
});
|
|
let human = human_report(&report);
|
|
|
|
assert!(!human.trim_start().starts_with('{'));
|
|
assert!(human.contains("Disasmer doctor"));
|
|
assert!(human.contains("status: ok"));
|
|
assert!(human.contains("coordinator: 127.0.0.1:9443"));
|
|
assert!(human.contains("disasmer login --browser"));
|
|
}
|
|
|
|
#[test]
|
|
fn project_init_select_and_status_use_local_project_config() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
|
|
let init = project_init_report(
|
|
ProjectInitArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "ignored".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
new_project: "project-a".to_owned(),
|
|
name: "Project A".to_owned(),
|
|
yes: true,
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(init["command"], "project init");
|
|
assert_eq!(init["source"], "local_project_config");
|
|
assert_eq!(init["project_config_written"], true);
|
|
assert_eq!(
|
|
init["current_directory_link"]["config_format"],
|
|
"disasmer_project_config_v1"
|
|
);
|
|
assert_eq!(
|
|
init["current_directory_link"]["links_current_directory"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
init["current_directory_link"]["writes_current_directory_only"],
|
|
true
|
|
);
|
|
assert_eq!(init["safe_defaults"]["project"], "project-a");
|
|
assert_eq!(init["safe_defaults"]["tenant"], "tenant");
|
|
assert_eq!(init["safe_defaults"]["browser_interaction_required"], false);
|
|
assert_eq!(init["coordinator_create_before_local_write"], false);
|
|
let rendered = human_report(&init);
|
|
assert!(rendered.contains("current directory linked: true"));
|
|
assert!(rendered.contains("current directory config:"));
|
|
|
|
let config = read_project_config(temp.path()).unwrap().unwrap();
|
|
assert_eq!(config.project, "project-a");
|
|
assert_eq!(config.tenant, "tenant");
|
|
|
|
let selected = project_select_report(
|
|
ProjectSelectArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "ignored".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
selected_project: "project-b".to_owned(),
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(selected["command"], "project select");
|
|
assert_eq!(
|
|
read_project_config(temp.path()).unwrap().unwrap().project,
|
|
"project-b"
|
|
);
|
|
|
|
let status = project_status_report(
|
|
ProjectStatusArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(status["command"], "project status");
|
|
assert_eq!(status["project_identity"]["project"], "project-b");
|
|
assert_eq!(status["project_identity"]["tenant"], "tenant");
|
|
assert_eq!(status["active_process"], "unknown_without_coordinator");
|
|
assert_eq!(status["attached_nodes"]["checked"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn project_init_uses_public_create_before_writing_local_config() {
|
|
let temp_success = tempfile::tempdir().unwrap();
|
|
let temp_rejected = tempfile::tempdir().unwrap();
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
for index in 0..2 {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""type":"create_project""#));
|
|
assert!(line.contains(r#""tenant":"tenant-live""#));
|
|
assert!(line.contains(r#""actor_user":"user-live""#));
|
|
match index {
|
|
0 => {
|
|
assert!(line.contains(r#""project":"project-created""#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"project_created","project":{"id":"project-created","tenant":"tenant-live","name":"Created Project"},"actor":"user-live"}"#,
|
|
)
|
|
.unwrap();
|
|
}
|
|
1 => {
|
|
assert!(line.contains(r#""project":"foreign-project""#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"error","message":"project id is outside the signed-in tenant scope"}"#,
|
|
)
|
|
.unwrap();
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
stream.write_all(b"\n").unwrap();
|
|
}
|
|
});
|
|
|
|
let scope = CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant-live".to_owned(),
|
|
project: "ignored".to_owned(),
|
|
user: "user-live".to_owned(),
|
|
json: false,
|
|
};
|
|
let created = project_init_report(
|
|
ProjectInitArgs {
|
|
scope: scope.clone(),
|
|
new_project: "project-created".to_owned(),
|
|
name: "Created Project".to_owned(),
|
|
yes: true,
|
|
},
|
|
temp_success.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(created["command"], "project init");
|
|
assert_eq!(created["source"], "public_coordinator_api");
|
|
assert_eq!(created["coordinator_create_before_local_write"], true);
|
|
assert_eq!(
|
|
created["project_config_write_after_coordinator_acceptance"],
|
|
true
|
|
);
|
|
assert_eq!(created["coordinator_session_requests"], 1);
|
|
assert_eq!(
|
|
created["created_or_linked_project"]["id"],
|
|
"project-created"
|
|
);
|
|
assert_eq!(
|
|
created["current_directory_link"]["links_current_directory"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
created["safe_defaults"]["browser_interaction_required"],
|
|
false
|
|
);
|
|
assert_eq!(created["private_website_required"], false);
|
|
assert_eq!(
|
|
read_project_config(temp_success.path())
|
|
.unwrap()
|
|
.unwrap()
|
|
.project,
|
|
"project-created"
|
|
);
|
|
|
|
let rejected = project_init_report(
|
|
ProjectInitArgs {
|
|
scope,
|
|
new_project: "foreign-project".to_owned(),
|
|
name: "Foreign Project".to_owned(),
|
|
yes: true,
|
|
},
|
|
temp_rejected.path().to_path_buf(),
|
|
)
|
|
.unwrap_err();
|
|
server.join().unwrap();
|
|
|
|
assert!(rejected.to_string().contains("tenant scope"));
|
|
assert!(read_project_config(temp_rejected.path()).unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn project_list_and_select_use_public_api_without_website() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_project_config(
|
|
temp.path(),
|
|
&ProjectConfig {
|
|
tenant: "tenant-live".to_owned(),
|
|
project: "project-original".to_owned(),
|
|
user: "user-live".to_owned(),
|
|
coordinator: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
for index in 0..3 {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""tenant":"tenant-live""#));
|
|
assert!(line.contains(r#""actor_user":"user-live""#));
|
|
match index {
|
|
0 => {
|
|
assert!(line.contains(r#""type":"list_projects""#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"projects","projects":[{"id":"project-a","tenant":"tenant-live","name":"Project A"}],"actor":"user-live"}"#,
|
|
)
|
|
.unwrap();
|
|
}
|
|
1 => {
|
|
assert!(line.contains(r#""type":"select_project""#));
|
|
assert!(line.contains(r#""project":"project-a""#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"project_selected","project":{"id":"project-a","tenant":"tenant-live","name":"Project A"},"actor":"user-live"}"#,
|
|
)
|
|
.unwrap();
|
|
}
|
|
2 => {
|
|
assert!(line.contains(r#""type":"select_project""#));
|
|
assert!(line.contains(r#""project":"project-b""#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"error","message":"project is outside the signed-in tenant scope"}"#,
|
|
)
|
|
.unwrap();
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
stream.write_all(b"\n").unwrap();
|
|
}
|
|
});
|
|
|
|
let scope = CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant-live".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user-live".to_owned(),
|
|
json: false,
|
|
};
|
|
let list = project_list_report(
|
|
ProjectListArgs {
|
|
scope: scope.clone(),
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(list["command"], "project list");
|
|
assert_eq!(list["source"], "public_coordinator_api");
|
|
assert_eq!(list["project_count"], 1);
|
|
assert_eq!(list["projects"][0]["id"], "project-a");
|
|
assert_eq!(list["private_website_required"], false);
|
|
assert_eq!(list["coordinator_session_requests"], 1);
|
|
|
|
let selected = project_select_report(
|
|
ProjectSelectArgs {
|
|
scope: scope.clone(),
|
|
selected_project: "project-a".to_owned(),
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(selected["command"], "project select");
|
|
assert_eq!(selected["source"], "public_coordinator_api");
|
|
assert_eq!(selected["selected_project"]["id"], "project-a");
|
|
assert_eq!(selected["project_config_written"], true);
|
|
assert_eq!(selected["private_website_required"], false);
|
|
assert_eq!(
|
|
read_project_config(temp.path()).unwrap().unwrap().project,
|
|
"project-a"
|
|
);
|
|
|
|
let rejected = project_select_report(
|
|
ProjectSelectArgs {
|
|
scope,
|
|
selected_project: "project-b".to_owned(),
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap_err();
|
|
server.join().unwrap();
|
|
|
|
assert!(rejected.to_string().contains("tenant scope"));
|
|
assert_eq!(
|
|
read_project_config(temp.path()).unwrap().unwrap().project,
|
|
"project-a"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn project_status_queries_public_coordinator_state() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
for _ in 0..2 {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
if line.contains("\"type\":\"list_node_descriptors\"") {
|
|
assert!(line.contains("\"project\":\"project-live\""));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"node_descriptors","descriptors":[{"id":"node-a","online":true}],"actor":"user"}"#,
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
} else if line.contains("\"type\":\"list_task_events\"") {
|
|
assert!(line.contains("\"project\":\"project-live\""));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"task_events","events":[{"process":"vp-live","task":"task-a"}]}"#,
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
} else {
|
|
panic!("unexpected coordinator request: {line}");
|
|
}
|
|
}
|
|
});
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_project_config(
|
|
temp.path(),
|
|
&ProjectConfig {
|
|
tenant: "tenant-live".to_owned(),
|
|
project: "project-live".to_owned(),
|
|
user: "user".to_owned(),
|
|
coordinator: Some(addr.clone()),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let status = project_status_report(
|
|
ProjectStatusArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(status["coordinator"], addr);
|
|
assert_eq!(status["project_identity"]["project"], "project-live");
|
|
assert_eq!(status["attached_nodes"]["checked"], true);
|
|
assert_eq!(status["attached_nodes"]["count"], 1);
|
|
assert_eq!(status["attached_nodes"]["online"], 1);
|
|
assert_eq!(status["active_process"], "vp-live");
|
|
assert_eq!(
|
|
status["quota_posture"]["current_usage"]["observed_task_events"],
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn quota_status_uses_project_config_and_generic_public_limits() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_project_config(
|
|
temp.path(),
|
|
&ProjectConfig {
|
|
tenant: "tenant-quota".to_owned(),
|
|
project: "project-quota".to_owned(),
|
|
user: "user".to_owned(),
|
|
coordinator: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let status = quota_status_report(
|
|
QuotaStatusArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(status["command"], "quota status");
|
|
assert_eq!(status["tenant"], "tenant-quota");
|
|
assert_eq!(status["project"], "project-quota");
|
|
assert_eq!(status["current_usage"]["attached_nodes"], 0);
|
|
assert_eq!(status["limits"]["artifact_download_bytes"], 268_435_456);
|
|
assert_eq!(status["community_tier_language"], true);
|
|
assert_eq!(status["quota_tier"], "community tier");
|
|
assert_eq!(status["community_tier_label"], "community tier");
|
|
let rendered = human_report(&status);
|
|
assert!(rendered.contains("quota tier: community tier"));
|
|
let forbidden_tier = ["free", "tier"].join(" ");
|
|
assert!(!rendered.to_ascii_lowercase().contains(&forbidden_tier));
|
|
assert_eq!(
|
|
status["next_blocked_action"]["action"],
|
|
"node_work_requires_online_attached_node"
|
|
);
|
|
assert_eq!(
|
|
status["next_blocked_action"]["machine_error"]["category"],
|
|
"capability"
|
|
);
|
|
assert_eq!(
|
|
status["next_blocked_action"]["machine_error"]["stable_exit_code"],
|
|
24
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn quota_status_queries_public_coordinator_usage() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
for _ in 0..2 {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
if line.contains("\"type\":\"list_node_descriptors\"") {
|
|
assert!(line.contains("\"tenant\":\"tenant-live\""));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"node_descriptors","descriptors":[{"id":"node-a","online":true},{"id":"node-b","online":false}],"actor":"user"}"#,
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
} else if line.contains("\"type\":\"list_task_events\"") {
|
|
assert!(line.contains("\"project\":\"project-live\""));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"task_events","events":[{"process":"vp-live","task":"task-a"},{"process":"vp-live","task":"task-b"}]}"#,
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
} else {
|
|
panic!("unexpected coordinator request: {line}");
|
|
}
|
|
}
|
|
});
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
|
write_project_config(
|
|
temp.path(),
|
|
&ProjectConfig {
|
|
tenant: "tenant-live".to_owned(),
|
|
project: "project-live".to_owned(),
|
|
user: "user".to_owned(),
|
|
coordinator: Some(addr.clone()),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let status = quota_status_report(
|
|
QuotaStatusArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
},
|
|
temp.path().to_path_buf(),
|
|
)
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(status["coordinator"], addr);
|
|
assert_eq!(status["current_usage"]["attached_nodes"], 2);
|
|
assert_eq!(status["current_usage"]["online_nodes"], 1);
|
|
assert_eq!(status["current_usage"]["observed_task_events"], 2);
|
|
assert!(status["next_blocked_action"].is_null());
|
|
assert_eq!(status["task_events"]["response"]["type"], "task_events");
|
|
}
|
|
|
|
#[test]
|
|
fn process_task_log_and_artifact_reports_summarize_task_events() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let response = concat!(
|
|
r#"{"type":"task_events","events":["#,
|
|
r#"{"tenant":"tenant","project":"project","process":"vp","node":"node-a","task":"task-a","placement":{"node":"node-a","score":120,"reasons":["warm environment cache","source snapshot already local"]},"terminal_state":"completed","status_code":0,"stdout_bytes":12,"stderr_bytes":0,"stdout_tail":"ok","stderr_tail":"","stdout_truncated":false,"stderr_truncated":false,"artifact_path":"/vfs/artifacts/app.txt","artifact_digest":"sha256:artifact","artifact_size_bytes":12},"#,
|
|
r#"{"tenant":"tenant","project":"project","process":"vp","node":"node-b","task":"task-b","terminal_state":"failed","status_code":1,"stdout_bytes":0,"stderr_bytes":7,"stdout_tail":"","stderr_tail":"boom","stdout_truncated":false,"stderr_truncated":false,"artifact_path":null,"artifact_digest":null,"artifact_size_bytes":null},"#,
|
|
r#"{"tenant":"tenant","project":"project","process":"vp","node":"node-c","task":"task-c","terminal_state":"failed","status_code":1,"stdout_bytes":0,"stderr_bytes":71,"stdout_tail":"","stderr_tail":"source snapshot unavailable and direct connectivity unavailable","stdout_truncated":false,"stderr_truncated":false,"artifact_path":null,"artifact_digest":null,"artifact_size_bytes":null}"#,
|
|
r#"]}"#
|
|
);
|
|
for _ in 0..4 {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains("\"type\":\"list_task_events\""));
|
|
assert!(line.contains("\"process\":\"vp\""));
|
|
stream.write_all(response.as_bytes()).unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
}
|
|
});
|
|
let scope = CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
};
|
|
|
|
let process = process_status_report(ProcessStatusArgs {
|
|
scope: scope.clone(),
|
|
process: "vp".to_owned(),
|
|
})
|
|
.unwrap();
|
|
let tasks = task_list_report(TaskListArgs {
|
|
scope: scope.clone(),
|
|
process: Some("vp".to_owned()),
|
|
})
|
|
.unwrap();
|
|
let logs = logs_report(LogsArgs {
|
|
scope: scope.clone(),
|
|
process: Some("vp".to_owned()),
|
|
task: Some("task-a".to_owned()),
|
|
})
|
|
.unwrap();
|
|
let artifacts = artifact_list_report(ArtifactListArgs {
|
|
scope,
|
|
process: Some("vp".to_owned()),
|
|
})
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(process["state"], "has_failed_tasks");
|
|
assert_eq!(process["current_task_count"], 3);
|
|
assert_eq!(
|
|
process["current_tasks"][0]["node_placement"]["node"],
|
|
"node-a"
|
|
);
|
|
assert_eq!(
|
|
process["current_tasks"][0]["node_placement"]["reasons"][0],
|
|
"warm environment cache"
|
|
);
|
|
assert_eq!(tasks["tasks"][0]["node_placement"]["score"], 120);
|
|
let rendered_tasks = human_report(&tasks);
|
|
assert!(rendered_tasks.contains("placement task-a: node-a"));
|
|
assert!(rendered_tasks.contains("source snapshot already local"));
|
|
assert_eq!(tasks["tasks"][1]["failure_reason"], "boom");
|
|
assert_eq!(tasks["tasks"][1]["machine_error"]["category"], "program");
|
|
assert_eq!(tasks["tasks"][1]["machine_error"]["stable_exit_code"], 27);
|
|
assert_eq!(
|
|
tasks["tasks"][2]["locality_failure"]["affected_data"],
|
|
"source_snapshot"
|
|
);
|
|
assert_eq!(
|
|
tasks["tasks"][2]["locality_failure"]["coordinator_bulk_relay_used"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
tasks["tasks"][2]["machine_error"]["category"],
|
|
"connectivity"
|
|
);
|
|
assert_eq!(tasks["tasks"][2]["machine_error"]["stable_exit_code"], 25);
|
|
assert_eq!(tasks["tasks"][2]["machine_error"]["locality_failure"], true);
|
|
assert!(tasks["tasks"][2]["machine_error"]["next_actions"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|action| action == "rerun source preparation on an attached node"));
|
|
assert!(rendered_tasks.contains("locality task-c: source_snapshot"));
|
|
assert!(rendered_tasks.contains("do not rely on coordinator bulk source relay"));
|
|
assert_eq!(logs["log_entries"].as_array().unwrap().len(), 1);
|
|
assert_eq!(logs["log_entries"][0]["task"], "task-a");
|
|
assert_eq!(logs["log_entries"][0]["stdout_tail"], "ok");
|
|
assert_eq!(artifacts["artifacts"].as_array().unwrap().len(), 1);
|
|
assert_eq!(artifacts["artifacts"][0]["artifact"], "app.txt");
|
|
assert_eq!(artifacts["artifacts"][0]["size_bytes"], 12);
|
|
assert_eq!(artifacts["artifacts"][0]["known_locations"][0], "node-a");
|
|
assert_eq!(artifacts["default_durable_store_assumed"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn log_and_task_reports_redact_secret_like_values() {
|
|
let events = json!({
|
|
"response": {
|
|
"type": "task_events",
|
|
"events": [{
|
|
"tenant": "tenant",
|
|
"project": "project",
|
|
"process": "vp",
|
|
"node": "node-a",
|
|
"task": "task-secret",
|
|
"terminal_state": "failed",
|
|
"status_code": 1,
|
|
"stdout_bytes": 128,
|
|
"stderr_bytes": 64,
|
|
"stdout_tail": "upload token=abc123 Authorization: Bearer bearer-secret",
|
|
"stderr_tail": "failed password=hunter2 access_token=provider-secret",
|
|
"stdout_truncated": true,
|
|
"stderr_truncated": false
|
|
}]
|
|
}
|
|
});
|
|
|
|
let entries = log_entries(Some(&events), Some("task-secret"));
|
|
let entry = &entries.as_array().unwrap()[0];
|
|
assert_eq!(
|
|
entry["stdout_tail"],
|
|
"upload token=[redacted] Authorization: Bearer [redacted]"
|
|
);
|
|
assert_eq!(
|
|
entry["stderr_tail"],
|
|
"failed password=[redacted] access_token=[redacted]"
|
|
);
|
|
assert_eq!(entry["stdout_bytes"], 128);
|
|
assert_eq!(entry["stdout_truncated"], true);
|
|
assert_eq!(entry["secret_like_values_redacted"], true);
|
|
assert_eq!(entry["redacted_fields"][0], "stdout_tail");
|
|
assert_eq!(entry["redacted_fields"][1], "stderr_tail");
|
|
|
|
let tasks = task_summaries(Some(&events));
|
|
let task = &tasks.as_array().unwrap()[0];
|
|
assert_eq!(
|
|
task["failure_reason"],
|
|
"failed password=[redacted] access_token=[redacted]"
|
|
);
|
|
assert_eq!(
|
|
task["machine_error"]["message"],
|
|
"failed password=[redacted] access_token=[redacted]"
|
|
);
|
|
assert!(!serde_json::to_string(&entries)
|
|
.unwrap()
|
|
.contains("provider-secret"));
|
|
assert!(!serde_json::to_string(&tasks).unwrap().contains("hunter2"));
|
|
}
|
|
|
|
#[test]
|
|
fn artifact_download_and_export_reports_expose_safe_session_boundaries() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let download_response = concat!(
|
|
r#"{"type":"artifact_download_link","link":{"artifact":"app.txt","source":{"RetainedNode":"node-a"},"url_path":"/artifacts/tenant/project/vp/app.txt","scoped_token_digest":"sha256:token","expires_at_epoch_seconds":60,"tenant":"tenant","project":"project","process":"vp","actor":{"User":"user"},"max_bytes":2048,"policy_context_digest":"sha256:policy"}}"#
|
|
);
|
|
let export_response = concat!(
|
|
r#"{"type":"artifact_export_plan","source_node":"node-a","receiver_node":"node-b","artifact_size_bytes":9,"plan":{"transport":"NativeQuic","scope":{"tenant":"tenant","project":"project","process":"vp","object":{"Artifact":"app.txt"},"authorization_subject":"artifact-export:node-a-to-node-b"},"source":{"node":"node-a","advertised_addr":"quic://node-a","public_key_fingerprint":"sha256:source"},"destination":{"node":"node-b","advertised_addr":"quic://node-b","public_key_fingerprint":"sha256:destination"},"authorization_digest":"sha256:auth","coordinator_assisted_rendezvous":true,"coordinator_bulk_relay_allowed":false}}"#
|
|
);
|
|
let export_download_response = concat!(
|
|
r#"{"type":"artifact_download_link","link":{"artifact":"app.txt","source":{"RetainedNode":"node-a"},"url_path":"/artifacts/tenant/project/vp/app.txt","scoped_token_digest":"sha256:export-token","expires_at_epoch_seconds":60,"tenant":"tenant","project":"project","process":"vp","actor":{"User":"user"},"max_bytes":67108864,"policy_context_digest":"sha256:policy"}}"#
|
|
);
|
|
let export_stream_response = concat!(
|
|
r#"{"type":"artifact_download_stream","link":{"artifact":"app.txt","source":{"RetainedNode":"node-a"},"url_path":"/artifacts/tenant/project/vp/app.txt","scoped_token_digest":"sha256:export-token","expires_at_epoch_seconds":60,"tenant":"tenant","project":"project","process":"vp","actor":{"User":"user"},"max_bytes":67108864,"policy_context_digest":"sha256:policy"},"streamed_bytes":9,"charged_download_bytes":9,"content_bytes_available":true,"content_base64":"YXBwLWJ5dGVz","content_source":"coordinator_complete_stdout_tail"}"#
|
|
);
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""type":"create_artifact_download_link""#));
|
|
assert!(line.contains(r#""tenant":"tenant""#));
|
|
assert!(line.contains(r#""project":"project""#));
|
|
assert!(line.contains(r#""artifact":"app.txt""#));
|
|
assert!(line.contains(r#""max_bytes":2048"#));
|
|
stream.write_all(download_response.as_bytes()).unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
for (expected, phase) in [
|
|
("export_artifact_to_node", "export-plan"),
|
|
("create_artifact_download_link", "export-download"),
|
|
("open_artifact_download_stream", "export-stream"),
|
|
] {
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(&format!(r#""type":"{expected}""#)));
|
|
assert!(line.contains(r#""tenant":"tenant""#));
|
|
assert!(line.contains(r#""project":"project""#));
|
|
assert!(line.contains(r#""artifact":"app.txt""#));
|
|
if expected == "create_artifact_download_link" {
|
|
assert_eq!(phase, "export-download");
|
|
assert!(line.contains(&format!(
|
|
r#""max_bytes":{}"#,
|
|
DEFAULT_ARTIFACT_EXPORT_MAX_BYTES
|
|
)));
|
|
stream
|
|
.write_all(export_download_response.as_bytes())
|
|
.unwrap();
|
|
} else if expected == "export_artifact_to_node" {
|
|
assert!(line.contains(r#""receiver_node":"node-b""#));
|
|
stream.write_all(export_response.as_bytes()).unwrap();
|
|
} else {
|
|
assert!(line.contains(r#""chunk_bytes":9"#));
|
|
assert!(line.contains(r#""token_digest":"sha256:export-token""#));
|
|
stream.write_all(export_stream_response.as_bytes()).unwrap();
|
|
}
|
|
stream.write_all(b"\n").unwrap();
|
|
}
|
|
});
|
|
let scope = CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
};
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let export_path = temp.path().join("exports/app.txt");
|
|
let download = artifact_download_report(ArtifactDownloadArgs {
|
|
scope: scope.clone(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 2048,
|
|
})
|
|
.unwrap();
|
|
let export = artifact_export_report(ArtifactExportArgs {
|
|
scope,
|
|
artifact: "app.txt".to_owned(),
|
|
to: export_path.clone(),
|
|
receiver_node: "node-b".to_owned(),
|
|
})
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(
|
|
download["download_session"]["status"],
|
|
"download_link_issued"
|
|
);
|
|
assert_eq!(download["download_session"]["tenant"], "tenant");
|
|
assert_eq!(download["download_session"]["project"], "project");
|
|
assert_eq!(download["download_session"]["process"], "vp");
|
|
assert_eq!(
|
|
download["download_session"]["source"]["RetainedNode"],
|
|
"node-a"
|
|
);
|
|
assert_eq!(download["download_session"]["max_bytes"], 2048);
|
|
assert_eq!(
|
|
download["download_session"]["token_material_returned"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
download["download_session"]["scoped_token_digest_present"],
|
|
true
|
|
);
|
|
assert_eq!(download["download_session"]["authorization_required"], true);
|
|
assert_eq!(download["download_session"]["short_lived"], true);
|
|
assert_eq!(download["download_session"]["guessable_public_url"], false);
|
|
assert_eq!(download["download_session"]["cross_tenant_usable"], false);
|
|
assert_eq!(
|
|
download["download_session"]["unauthorized_project_usable"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
download["download_session"]["default_durable_store_assumed"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
download["grant_disclosures"][0]["grant"],
|
|
"artifact_download"
|
|
);
|
|
assert_eq!(
|
|
download["grant_disclosures"][0]["coordinator_policy_limited"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
download["grant_disclosures"][0]["scoped_token_digest_present"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
download["grant_disclosures"][0]["token_material_returned"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
download["grant_disclosures"][0]["guessable_public_url"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
download["grant_disclosures"][0]["cross_tenant_reuse_allowed"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
download["grant_disclosures"][0]["unauthorized_project_reuse_allowed"],
|
|
false
|
|
);
|
|
let rendered_download = human_report(&download);
|
|
assert!(rendered_download.contains("grant artifact_download"));
|
|
assert!(rendered_download.contains("policy-limited"));
|
|
|
|
assert_eq!(export["export_plan"]["status"], "transfer_plan_created");
|
|
assert_eq!(export["export_plan"]["source_node"], "node-a");
|
|
assert_eq!(export["export_plan"]["receiver_node"], "node-b");
|
|
assert_eq!(export["export_plan"]["artifact"], "app.txt");
|
|
assert_eq!(
|
|
export["export_plan"]["local_path"].as_str().unwrap(),
|
|
export_path.to_string_lossy().as_ref()
|
|
);
|
|
assert_eq!(export["export_plan"]["artifact_size_bytes"], 9);
|
|
assert_eq!(export["export_plan"]["local_bytes_written_by_cli"], true);
|
|
assert_eq!(
|
|
export["export_plan"]["local_export_status"],
|
|
"local_bytes_written"
|
|
);
|
|
assert_eq!(export["export_plan"]["bytes_written"], 9);
|
|
assert_eq!(
|
|
export["local_export"]["stream"]["content_material_returned_in_report"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
export["local_export"]["download_session"]["scoped_token_digest_present"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
export["local_export"]["download_session"]["guessable_public_url"],
|
|
false
|
|
);
|
|
assert_eq!(export["grant_disclosures"][0]["grant"], "artifact_download");
|
|
assert_eq!(
|
|
export["grant_disclosures"][0]["cross_tenant_reuse_allowed"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(&export_path).unwrap().as_slice(),
|
|
b"app-bytes"
|
|
);
|
|
assert_eq!(
|
|
export["export_plan"]["default_durable_store_assumed"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
export["export_plan"]["coordinator_bulk_relay_allowed"],
|
|
false
|
|
);
|
|
assert_eq!(export["export_plan"]["authorization_digest_present"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn artifact_failure_reports_apply_stable_exit_codes() {
|
|
let mut download = json!({
|
|
"command": "artifact download",
|
|
"download_session": artifact_download_session_summary(&json!({
|
|
"type": "error",
|
|
"message": "artifact download unauthorized for project",
|
|
})),
|
|
});
|
|
assert_eq!(apply_command_report_exit_code(&mut download), Some(21));
|
|
assert_eq!(
|
|
download["download_session"]["machine_error"]["category"],
|
|
"authorization"
|
|
);
|
|
assert_eq!(
|
|
download["download_session"]["machine_error"]["process_exit_code_applied"],
|
|
true
|
|
);
|
|
|
|
let mut export = json!({
|
|
"command": "artifact export",
|
|
"export_plan": artifact_export_plan_summary(
|
|
&json!({
|
|
"type": "error",
|
|
"message": "direct connectivity unavailable for artifact export",
|
|
}),
|
|
Path::new("dist/app.txt"),
|
|
),
|
|
});
|
|
assert_eq!(apply_command_report_exit_code(&mut export), Some(25));
|
|
assert_eq!(
|
|
export["export_plan"]["machine_error"]["category"],
|
|
"connectivity"
|
|
);
|
|
assert_eq!(
|
|
export["export_plan"]["machine_error"]["process_exit_code_applied"],
|
|
true
|
|
);
|
|
|
|
let mut stream = json!({
|
|
"command": "artifact export",
|
|
"local_export": {
|
|
"stream": artifact_stream_summary(&json!({
|
|
"type": "error",
|
|
"message": "quota unavailable: resource limit exceeded for artifact_download_bytes",
|
|
})),
|
|
},
|
|
});
|
|
assert_eq!(apply_command_report_exit_code(&mut stream), Some(22));
|
|
assert_eq!(
|
|
stream["local_export"]["stream"]["machine_error"]["category"],
|
|
"quota"
|
|
);
|
|
assert_eq!(
|
|
stream["local_export"]["stream"]["machine_error"]["process_exit_code_applied"],
|
|
true
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_restart_and_cancel_reports_expose_control_boundaries() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let restart_response = r#"{"type":"process_started","process":"vp","epoch":42}"#;
|
|
let cancel_response = r#"{"type":"process_cancellation_requested","process":"vp","cancelled_tasks":[{"process":"vp","task":"compile-linux","node":"node-a"},{"process":"vp","task":"link-linux","node":"node-b"}],"affected_nodes":["node-a","node-b"]}"#;
|
|
for expected in ["start_process", "cancel_process"] {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(&format!(r#""type":"{expected}""#)));
|
|
assert!(line.contains(r#""tenant":"tenant""#));
|
|
assert!(line.contains(r#""project":"project""#));
|
|
assert!(line.contains(r#""process":"vp""#));
|
|
if expected == "start_process" {
|
|
stream.write_all(restart_response.as_bytes()).unwrap();
|
|
} else {
|
|
assert!(line.contains(r#""actor_user":"user""#));
|
|
stream.write_all(cancel_response.as_bytes()).unwrap();
|
|
}
|
|
stream.write_all(b"\n").unwrap();
|
|
}
|
|
});
|
|
let scope = CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
};
|
|
|
|
let restart = process_restart_report(ProcessRestartArgs {
|
|
scope: scope.clone(),
|
|
process: "vp".to_owned(),
|
|
yes: true,
|
|
})
|
|
.unwrap();
|
|
let cancel = process_cancel_report(ProcessCancelArgs {
|
|
scope,
|
|
process: "vp".to_owned(),
|
|
node: None,
|
|
task: None,
|
|
yes: true,
|
|
})
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(restart["restart_request"]["status"], "process_started");
|
|
assert_eq!(
|
|
restart["restart_request"]["operation"],
|
|
"restart_virtual_process"
|
|
);
|
|
assert_eq!(restart["restart_request"]["accepted"], true);
|
|
assert_eq!(restart["restart_request"]["process"], "vp");
|
|
assert_eq!(restart["restart_request"]["coordinator_epoch"], 42);
|
|
assert_eq!(restart["restart_request"]["requires_confirmation"], false);
|
|
assert_eq!(restart["restart_request"]["website_required"], false);
|
|
|
|
assert_eq!(
|
|
cancel["cancel_request"]["status"],
|
|
"process_cancellation_requested"
|
|
);
|
|
assert_eq!(
|
|
cancel["cancel_request"]["operation"],
|
|
"cancel_virtual_process"
|
|
);
|
|
assert_eq!(cancel["cancel_request"]["accepted"], true);
|
|
assert_eq!(cancel["cancel_request"]["process"], "vp");
|
|
assert_eq!(cancel["cancel_request"]["cancelled_task_count"], 2);
|
|
assert_eq!(
|
|
cancel["cancel_request"]["cancelled_tasks"][0]["task"],
|
|
"compile-linux"
|
|
);
|
|
assert_eq!(cancel["cancel_request"]["affected_nodes"][1], "node-b");
|
|
assert_eq!(cancel["cancel_request"]["requires_confirmation"], false);
|
|
assert_eq!(cancel["cancel_request"]["website_required"], false);
|
|
assert_eq!(
|
|
cancel["cancel_request"]["whole_process_cancel_available"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
cancel["cancel_request"]["node_must_poll_task_control"],
|
|
true
|
|
);
|
|
assert_eq!(cancel["cancel_request"]["new_task_launches_blocked"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn task_restart_reports_clean_boundary_requirements() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""type":"restart_task""#));
|
|
assert!(line.contains(r#""tenant":"tenant""#));
|
|
assert!(line.contains(r#""project":"project""#));
|
|
assert!(line.contains(r#""actor_user":"user""#));
|
|
assert!(line.contains(r#""process":"vp""#));
|
|
assert!(line.contains(r#""task":"compile-linux""#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"task_restart","process":"vp","task":"compile-linux","actor":"user","accepted":false,"clean_boundary_available":false,"active_task":true,"completed_event_observed":false,"requires_whole_process_restart":true,"message":"selected task is still active; clean task restart requires a captured checkpoint boundary","audit_event":{"tenant":"tenant","project":"project","process":"vp","task":"compile-linux","actor":"user","operation":"restart_task","allowed":true,"reason":"selected task is still active; clean task restart requires a captured checkpoint boundary","charged_debug_read_bytes":1024,"used_debug_read_bytes":1024},"charged_debug_read_bytes":1024,"used_debug_read_bytes":1024}"#,
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
});
|
|
|
|
let report = task_restart_report(TaskRestartArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
task: "compile-linux".to_owned(),
|
|
process: "vp".to_owned(),
|
|
yes: true,
|
|
})
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(report["command"], "task restart");
|
|
assert_eq!(
|
|
report["restart_request"]["operation"],
|
|
"restart_selected_task"
|
|
);
|
|
assert_eq!(report["restart_request"]["accepted"], false);
|
|
assert_eq!(report["restart_request"]["clean_boundary_available"], false);
|
|
assert_eq!(
|
|
report["restart_request"]["requires_whole_process_restart"],
|
|
true
|
|
);
|
|
assert_eq!(report["restart_request"]["active_task"], true);
|
|
assert_eq!(
|
|
report["restart_request"]["audit_event"]["operation"],
|
|
"restart_task"
|
|
);
|
|
assert_eq!(report["restart_request"]["charged_debug_read_bytes"], 1024);
|
|
assert_eq!(report["restart_request"]["used_debug_read_bytes"], 1024);
|
|
assert_eq!(report["restart_request"]["debug_reads_quota_limited"], true);
|
|
assert_eq!(report["restart_request"]["website_required"], false);
|
|
assert_eq!(report["coordinator_session_requests"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn build_command_reuses_bundle_inspection_without_full_repo_upload() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(temp.path().join("src")).unwrap();
|
|
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap();
|
|
fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
|
|
|
|
let report = build_report(
|
|
BuildArgs {
|
|
project: Some(temp.path().to_path_buf()),
|
|
source_provider: None,
|
|
disabled_source_providers: Vec::new(),
|
|
output: None,
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(report["command"], "build");
|
|
assert_eq!(report["content_addressed"], true);
|
|
assert_eq!(report["contains_full_repository_upload"], false);
|
|
assert!(report["bundle"]["metadata"]["identity"]
|
|
.as_str()
|
|
.unwrap()
|
|
.starts_with("sha256:"));
|
|
assert!(report["bundle"]["metadata"]["wasm_code"]
|
|
.as_str()
|
|
.unwrap()
|
|
.starts_with("sha256:"));
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["task_metadata"]["default_entrypoint"],
|
|
"build"
|
|
);
|
|
assert!(report["bundle"]["metadata"]["task_metadata"]["entrypoints"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|entrypoint| entrypoint == "release"));
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["source_metadata"]["transfer_policy"]
|
|
["coordinator_receives_source_bytes_by_default"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["source_metadata"]["transfer_policy"]
|
|
["default_full_repo_tarball"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["debug_metadata"]["dap_virtual_process"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["large_input_policy"]
|
|
["selected_inputs_are_content_digests"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["large_input_policy"]["selected_input_bytes_included"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["large_input_policy"]["full_repository_bytes_included"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["large_input_policy"]
|
|
["silent_task_argument_serialization"],
|
|
false
|
|
);
|
|
assert!(
|
|
report["bundle"]["metadata"]["large_input_policy"]["supported_handle_types"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|handle| handle == "Artifact")
|
|
);
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["restart_compatibility"]
|
|
["source_edits_can_restart_from_clean_task_boundary"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["restart_compatibility"]
|
|
["requires_clean_checkpoint_boundary"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["restart_compatibility"]["compares_task_abi"],
|
|
report["bundle"]["metadata"]["task_metadata"]["task_abi"]
|
|
);
|
|
assert_eq!(
|
|
report["bundle"]["metadata"]["restart_compatibility"]
|
|
["incompatible_changes_require_whole_process_restart"],
|
|
true
|
|
);
|
|
assert_eq!(report["status"], "built");
|
|
assert_eq!(report["scheduled_work"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn build_blocks_before_schedule_on_missing_environment_reference() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(temp.path().join("src")).unwrap();
|
|
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap();
|
|
fs::write(
|
|
temp.path().join("src/main.rs"),
|
|
"fn main() { let _target = env!(\"linux\"); }\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let report = build_report(
|
|
BuildArgs {
|
|
project: Some(temp.path().to_path_buf()),
|
|
source_provider: None,
|
|
disabled_source_providers: Vec::new(),
|
|
output: None,
|
|
json: false,
|
|
},
|
|
PathBuf::from("/unused"),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(report["status"], "blocked_before_schedule");
|
|
assert_eq!(report["scheduled_work"], false);
|
|
assert_eq!(report["machine_error"]["category"], "environment");
|
|
assert_eq!(report["diagnostics"][0]["code"], "missing_environment");
|
|
}
|
|
|
|
#[test]
|
|
fn node_enroll_reports_short_lived_public_api_grant() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(line.contains(r#""type":"create_node_enrollment_grant""#));
|
|
assert!(line.contains(r#""tenant":"tenant""#));
|
|
assert!(line.contains(r#""project":"project""#));
|
|
assert!(line.contains(r#""actor_user":"user""#));
|
|
assert!(line.contains(r#""grant":"grant-live""#));
|
|
assert!(line.contains(r#""ttl_seconds":300"#));
|
|
stream
|
|
.write_all(
|
|
br#"{"type":"node_enrollment_grant_created","tenant":"tenant","project":"project","grant":"grant-live","scope":"node:attach","expires_at_epoch_seconds":300}"#,
|
|
)
|
|
.unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
});
|
|
|
|
let report = node_enroll_report(NodeEnrollArgs {
|
|
scope: CliScopeArgs {
|
|
coordinator: Some(addr),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
},
|
|
grant: "grant-live".to_owned(),
|
|
ttl_seconds: 300,
|
|
})
|
|
.unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(report["command"], "node enroll");
|
|
assert_eq!(report["status"], "created");
|
|
assert_eq!(report["private_website_required"], false);
|
|
assert_eq!(report["tenant"], "tenant");
|
|
assert_eq!(report["project"], "project");
|
|
assert_eq!(report["user"], "user");
|
|
assert_eq!(report["enrollment_grant"]["grant"], "grant-live");
|
|
assert_eq!(report["enrollment_grant"]["scope"], "node:attach");
|
|
assert_eq!(report["enrollment_grant"]["ttl_seconds"], 300);
|
|
assert_eq!(report["enrollment_grant"]["expires_at_epoch_seconds"], 300);
|
|
assert_eq!(report["enrollment_grant"]["short_lived"], true);
|
|
assert_eq!(
|
|
report["enrollment_grant"]["exchange_for_persistent_node_identity"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
report["enrollment_grant"]["node_credentials_separate_from_user_session"],
|
|
true
|
|
);
|
|
assert_eq!(report["coordinator_session_requests"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn node_enroll_and_process_commands_have_safe_plan_without_coordinator() {
|
|
let scope = CliScopeArgs {
|
|
coordinator: None,
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
json: false,
|
|
};
|
|
let enroll = node_enroll_report(NodeEnrollArgs {
|
|
scope: scope.clone(),
|
|
grant: "grant".to_owned(),
|
|
ttl_seconds: 60,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(enroll["status"], "planned_without_coordinator");
|
|
assert_eq!(enroll["private_website_required"], false);
|
|
assert_eq!(enroll["enrollment_grant"]["grant"], "grant");
|
|
assert_eq!(enroll["enrollment_grant"]["scope"], "node:attach");
|
|
assert_eq!(enroll["enrollment_grant"]["ttl_seconds"], 60);
|
|
assert_eq!(enroll["enrollment_grant"]["short_lived"], true);
|
|
assert_eq!(
|
|
enroll["enrollment_grant"]["exchange_for_persistent_node_identity"],
|
|
true
|
|
);
|
|
|
|
let cancel = process_cancel_report(ProcessCancelArgs {
|
|
scope,
|
|
process: "vp".to_owned(),
|
|
node: None,
|
|
task: None,
|
|
yes: false,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(cancel["status"], "confirmation_required");
|
|
assert_eq!(cancel["requires_confirmation"], true);
|
|
assert_eq!(cancel["coordinator_request_sent"], false);
|
|
}
|
|
}
|