Update public backend API surface

Private source commit: ba3f7ce2b6d9
This commit is contained in:
Clusterflux Release 2026-07-26 17:22:33 +02:00
parent 784463622c
commit 26fdcb9d84
34 changed files with 5473 additions and 72 deletions

View file

@ -0,0 +1,18 @@
[package]
name = "clusterflux-client"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
base64.workspace = true
clusterflux-control = { path = "../clusterflux-control" }
clusterflux-core = { path = "../clusterflux-core" }
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["sync", "time"] }
[dev-dependencies]
clusterflux-coordinator = { path = "../clusterflux-coordinator" }

View file

@ -0,0 +1,68 @@
# clusterflux-client
`clusterflux-client` is the public, typed Rust boundary for a Clusterflux web
backend. It talks to the same versioned control and login endpoints as the CLI.
Callers do not construct JSON envelopes or place session secrets into request
objects.
The crate intentionally contains no coordinator implementation, persistence
types, scheduler policy, or website-specific business rules. A web backend only
needs this crate to authenticate and work with account status, projects, Agent
keys, node enrollment and liveness, current/recent processes, task attempts,
recent logs, artifacts, quota state, process control, task recovery, and Debug
Epoch state. It does not expose workflow launch or whole-process replay.
```rust,no_run
use clusterflux_client::{ClusterfluxClient, SessionCredential};
# async fn example() -> Result<(), clusterflux_client::ClientError> {
let credential = SessionCredential::from_secret(
std::env::var("CLUSTERFLUX_SESSION_SECRET").expect("server-side session secret"),
);
let client = ClusterfluxClient::connect("https://clusterflux.lesstuff.com")?
.with_session_credential(&credential);
let account = client.account_status().await?;
let nodes = client.list_nodes().await?;
let processes = client.list_processes(None, 20).await?;
# let _ = (account, nodes, processes);
# Ok(())
# }
```
## Login boundary
`begin_browser_login` starts the hosted Authentik flow. The identity callback
returns only to the fixed configured website URL with a short-lived handoff.
The website backend calls `exchange_browser_login_handoff` once and stores the
returned `SessionCredential` only in encrypted server-side session storage.
The credential has redacted `Debug` output and deliberately does not implement
Serde serialization. Logout calls the existing session-revocation operation.
The browser must not receive or persist the Clusterflux session credential,
provider tokens, authorization code, PKCE verifier, or CLI polling secret.
## Errors, bounds, and transport
API failures are returned as `ClientError::Api(ApiError)`. Decisions should use
the stable `code`, `category`, `retryable`, and `request_id` fields, while
`message` is for people. The client rejects an error whose request ID does not
match the originating request.
Paginated list methods require an explicit bounded page size. Process pages are
limited to 100 entries; node, artifact, and recent-log pages to 200.
`list_nodes()` is a bounded first-page convenience; `list_nodes_page()` exposes
its cursor. Recent logs are ephemeral and can report truncation or sequence
loss. Artifact bytes use `ArtifactDownload::next_chunk`, which validates
offsets and decodes bounded chunks without materializing the complete artifact.
`ControlTransport` has bounded connect and I/O timeouts and performs blocking
network work outside the async executor. Dropping a request future cancels the
callers wait; any already-started blocking I/O remains bounded by its timeout.
`MockTransport` supplies deterministic responses and records exact envelopes
for application tests.
`CLIENT_API_VERSION` is the one supported control protocol version. The
checked-in `web_operations.json` contract fixture records every website
operation and its stable error shape, and the crates contract suite checks the
fixture.

View file

@ -0,0 +1,909 @@
mod protocol;
mod transport;
mod types;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use clusterflux_control::{CONTROL_API_PATH, LOGIN_API_PATH};
use clusterflux_core::{coordinator_wire_request, ApiError, COORDINATOR_PROTOCOL_VERSION};
use protocol::{AuthenticatedRequest, LoginRequest, WireResponse};
use serde_json::json;
use thiserror::Error;
use transport::{ClientTransport, TransportRequest};
pub use clusterflux_core::{
AgentId, ApiErrorCategory, ApiErrorCode, ArtifactId, Authorization, Capability, CredentialKind,
Digest, DownloadLink, EnvironmentBackend, LimitKind, NodeCapabilities, NodeId, Os, ProcessId,
ProjectId, ResourceLimits, TaskDefinitionId, TaskFailurePolicy, TaskInstanceId, TenantId,
UserId, VfsPath,
};
pub use transport::{
ClientTransportError, ControlTransport, MockTransport, TransportFuture, TransportResponse,
};
pub use types::*;
pub const CLIENT_API_VERSION: u64 = COORDINATOR_PROTOCOL_VERSION;
#[derive(Debug, Error)]
pub enum ClientError {
#[error("Clusterflux API error: {0}")]
Api(ApiError),
#[error(transparent)]
Transport(#[from] ClientTransportError),
#[error("Clusterflux client protocol error: {0}")]
Protocol(String),
}
#[derive(Clone)]
pub struct ClusterfluxClient {
transport: Arc<dyn ClientTransport>,
session_secret: Arc<Mutex<Option<String>>>,
next_request: Arc<AtomicU64>,
}
impl ClusterfluxClient {
pub fn connect(endpoint: impl Into<String>) -> Result<Self, ClientError> {
Ok(Self::with_transport(ControlTransport::new(endpoint)?))
}
pub fn with_transport(transport: impl ClientTransport) -> Self {
Self {
transport: Arc::new(transport),
session_secret: Arc::new(Mutex::new(None)),
next_request: Arc::new(AtomicU64::new(1)),
}
}
pub fn with_session_credential(mut self, credential: &SessionCredential) -> Self {
self.session_secret = Arc::new(Mutex::new(Some(credential.0.clone())));
self
}
pub fn is_session_configured(&self) -> bool {
self.session_secret
.lock()
.map(|secret| secret.is_some())
.unwrap_or(false)
}
pub async fn begin_browser_login(&self) -> Result<BrowserLoginStart, ClientError> {
match self
.send_login(LoginRequest::BeginWebBrowserLogin {})
.await?
{
WireResponse::WebBrowserLoginStarted {
transaction_id,
authorization_url,
expires_at_epoch_seconds,
} => Ok(BrowserLoginStart {
transaction_id,
authorization_url,
expires_at_epoch_seconds,
}),
_ => Err(unexpected_response("web_browser_login_started")),
}
}
pub async fn exchange_browser_login_handoff(
&self,
transaction_id: impl Into<String>,
handoff_code: impl Into<String>,
) -> Result<BrowserSession, ClientError> {
match self
.send_login(LoginRequest::ExchangeWebLoginHandoff {
transaction_id: transaction_id.into(),
handoff_code: handoff_code.into(),
})
.await?
{
WireResponse::WebBrowserSession { session } => Ok(BrowserSession {
tenant: session.tenant,
project: session.project,
user: session.user,
credential: SessionCredential(session.session_secret),
expires_at_epoch_seconds: session.expires_at_epoch_seconds,
}),
_ => Err(unexpected_response("web_browser_session")),
}
}
pub async fn account_status(&self) -> Result<AccountStatus, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::AuthStatus)
.await?
{
WireResponse::AuthStatus {
tenant,
project,
actor,
authenticated,
account_status,
suspended,
disabled,
deleted,
manual_review,
sanitized_reason,
next_actions,
} => Ok(AccountStatus {
tenant,
project,
actor,
authenticated,
account_status,
suspended,
disabled,
deleted,
manual_review,
sanitized_reason,
next_actions,
}),
_ => Err(unexpected_response("auth_status")),
}
}
pub async fn logout(&self) -> Result<(), ClientError> {
match self
.send_authenticated(AuthenticatedRequest::RevokeCliSession)
.await?
{
WireResponse::CliSessionRevoked {} => {
*self.session_secret.lock().map_err(|_| {
ClientError::Protocol("session credential lock was poisoned".to_owned())
})? = None;
Ok(())
}
_ => Err(unexpected_response("cli_session_revoked")),
}
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::ListProjects)
.await?
{
WireResponse::Projects { projects } => Ok(projects),
_ => Err(unexpected_response("projects")),
}
}
pub async fn create_project(
&self,
project: ProjectId,
name: impl Into<String>,
) -> Result<Project, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::CreateProject {
project,
name: name.into(),
})
.await?
{
WireResponse::ProjectCreated { project } => Ok(project),
_ => Err(unexpected_response("project_created")),
}
}
pub async fn select_project(&self, project: ProjectId) -> Result<Project, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::SelectProject { project })
.await?
{
WireResponse::ProjectSelected { project } => Ok(project),
_ => Err(unexpected_response("project_selected")),
}
}
pub async fn register_agent_public_key(
&self,
agent: AgentId,
public_key: impl Into<String>,
) -> Result<AgentPublicKey, ClientError> {
self.agent_key_mutation(AuthenticatedRequest::RegisterAgentPublicKey {
agent,
public_key: public_key.into(),
})
.await
}
pub async fn list_agent_public_keys(&self) -> Result<Vec<AgentPublicKey>, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::ListAgentPublicKeys)
.await?
{
WireResponse::AgentPublicKeys { records } => Ok(records),
_ => Err(unexpected_response("agent_public_keys")),
}
}
pub async fn rotate_agent_public_key(
&self,
agent: AgentId,
public_key: impl Into<String>,
) -> Result<AgentPublicKey, ClientError> {
self.agent_key_mutation(AuthenticatedRequest::RotateAgentPublicKey {
agent,
public_key: public_key.into(),
})
.await
}
pub async fn revoke_agent_public_key(
&self,
agent: AgentId,
) -> Result<AgentPublicKey, ClientError> {
self.agent_key_mutation(AuthenticatedRequest::RevokeAgentPublicKey { agent })
.await
}
async fn agent_key_mutation(
&self,
request: AuthenticatedRequest,
) -> Result<AgentPublicKey, ClientError> {
match self.send_authenticated(request).await? {
WireResponse::AgentPublicKey { record } => Ok(record),
_ => Err(unexpected_response("agent_public_key")),
}
}
pub async fn create_node_enrollment_grant(
&self,
ttl_seconds: u64,
) -> Result<NodeEnrollmentGrant, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::CreateNodeEnrollmentGrant { ttl_seconds })
.await?
{
WireResponse::NodeEnrollmentGrantCreated {
tenant,
project,
grant,
scope,
expires_at_epoch_seconds,
} => Ok(NodeEnrollmentGrant {
tenant,
project,
grant,
scope,
expires_at_epoch_seconds,
}),
_ => Err(unexpected_response("node_enrollment_grant_created")),
}
}
pub async fn list_nodes(&self) -> Result<Vec<NodeSummary>, ClientError> {
Ok(self.list_nodes_page(None, 200).await?.nodes)
}
pub async fn list_nodes_page(
&self,
cursor: Option<String>,
limit: u32,
) -> Result<NodePage, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::ListNodeSummaries { cursor, limit })
.await?
{
WireResponse::NodeSummaries { nodes, next_cursor } => {
Ok(NodePage { nodes, next_cursor })
}
_ => Err(unexpected_response("node_summaries")),
}
}
pub async fn revoke_node(&self, node: NodeId) -> Result<NodeRevocation, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::RevokeNodeCredential { node })
.await?
{
WireResponse::NodeCredentialRevoked {
node,
tenant,
project,
actor,
descriptor_removed,
queued_assignments_removed,
} => Ok(NodeRevocation {
node,
tenant,
project,
actor,
descriptor_removed,
queued_assignments_removed,
}),
_ => Err(unexpected_response("node_credential_revoked")),
}
}
pub async fn list_processes(
&self,
cursor: Option<String>,
limit: u32,
) -> Result<ProcessPage, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::ListProcessSummaries { cursor, limit })
.await?
{
WireResponse::ProcessSummaries {
processes,
next_cursor,
} => Ok(ProcessPage {
processes,
next_cursor,
}),
_ => Err(unexpected_response("process_summaries")),
}
}
pub async fn cancel_process(
&self,
process: ProcessId,
) -> Result<ProcessCancellation, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::CancelProcess { process })
.await?
{
WireResponse::ProcessCancellationRequested {
process,
cancelled_tasks,
affected_nodes,
} => Ok(ProcessCancellation {
process,
affected_tasks: cancelled_tasks,
affected_nodes,
aborted: false,
}),
_ => Err(unexpected_response("process_cancellation_requested")),
}
}
pub async fn abort_process(
&self,
process: ProcessId,
) -> Result<ProcessCancellation, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::AbortProcess {
process,
launch_attempt: None,
})
.await?
{
WireResponse::ProcessAborted {
process,
aborted_tasks,
affected_nodes,
} => Ok(ProcessCancellation {
process,
affected_tasks: aborted_tasks,
affected_nodes,
aborted: true,
}),
_ => Err(unexpected_response("process_aborted")),
}
}
pub async fn quota_status(&self) -> Result<QuotaStatus, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::QuotaStatus)
.await?
{
WireResponse::QuotaStatus {
tenant,
project,
actor,
policy_label,
limits,
window_seconds,
usage,
window_started_epoch_seconds,
} => Ok(QuotaStatus {
tenant,
project,
actor,
policy_label,
limits,
window_seconds,
usage,
window_started_epoch_seconds,
}),
_ => Err(unexpected_response("quota_status")),
}
}
pub async fn list_task_events(
&self,
process: Option<ProcessId>,
) -> Result<Vec<TaskCompletionEvent>, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::ListTaskEvents { process })
.await?
{
WireResponse::TaskEvents { events } => Ok(events),
_ => Err(unexpected_response("task_events")),
}
}
pub async fn list_task_snapshots(
&self,
process: ProcessId,
) -> Result<Vec<TaskAttemptSnapshot>, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::ListTaskSnapshots { process })
.await?
{
WireResponse::TaskSnapshots { snapshots } => Ok(snapshots),
_ => Err(unexpected_response("task_snapshots")),
}
}
pub async fn list_recent_logs(
&self,
process: ProcessId,
task: Option<TaskInstanceId>,
after_sequence: Option<u64>,
limit: u32,
) -> Result<RecentLogPage, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::ListRecentLogs {
process,
task,
after_sequence,
limit,
})
.await?
{
WireResponse::RecentLogs {
entries,
next_sequence,
history_truncated,
} => Ok(RecentLogPage {
entries,
next_sequence,
history_truncated,
}),
_ => Err(unexpected_response("recent_logs")),
}
}
pub async fn restart_task(
&self,
process: ProcessId,
task: TaskInstanceId,
replacement_bundle: Option<TaskReplacementBundle>,
) -> Result<TaskRestart, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::RestartTask {
process,
task,
replacement_bundle,
})
.await?
{
WireResponse::TaskRestart {
process,
task,
restarted_task_instance,
restarted_attempt_id,
actor,
accepted,
clean_boundary_available,
active_task,
completed_event_observed,
requires_whole_process_restart,
message,
audit_event,
} => Ok(TaskRestart {
process,
task,
restarted_task_instance,
restarted_attempt_id,
actor,
accepted,
clean_boundary_available,
active_task,
completed_event_observed,
requires_whole_process_restart,
message,
audit_event,
}),
_ => Err(unexpected_response("task_restart")),
}
}
pub async fn resolve_task_failure(
&self,
process: ProcessId,
task: TaskInstanceId,
resolution: TaskFailureResolution,
) -> Result<TaskFailureResolutionResult, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::ResolveTaskFailure {
process,
task,
resolution,
})
.await?
{
WireResponse::TaskFailureResolved {
process,
task,
attempt_id,
resolution,
} => Ok(TaskFailureResolutionResult {
process,
task,
attempt_id,
resolution,
}),
_ => Err(unexpected_response("task_failure_resolved")),
}
}
pub async fn debug_attach(&self, process: ProcessId) -> Result<DebugAttach, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::DebugAttach { process })
.await?
{
WireResponse::DebugAttach {
process,
actor,
authorization,
audit_event,
} => Ok(DebugAttach {
process,
actor,
authorization,
audit_event,
}),
_ => Err(unexpected_response("debug_attach")),
}
}
pub async fn create_debug_epoch(
&self,
process: ProcessId,
stopped_task: TaskInstanceId,
reason: impl Into<String>,
) -> Result<DebugEpochControl, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::CreateDebugEpoch {
process,
stopped_task,
reason: reason.into(),
})
.await?
{
WireResponse::DebugEpoch {
process,
actor,
epoch,
command,
affected_tasks,
all_stop_requested,
audit_event,
} => Ok(DebugEpochControl {
process,
actor,
epoch,
command,
affected_tasks,
all_stop_requested,
audit_event,
}),
_ => Err(unexpected_response("debug_epoch")),
}
}
pub async fn resume_debug_epoch(
&self,
process: ProcessId,
epoch: u64,
) -> Result<DebugEpochControl, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::ResumeDebugEpoch { process, epoch })
.await?
{
WireResponse::DebugEpoch {
process,
actor,
epoch,
command,
affected_tasks,
all_stop_requested,
audit_event,
} => Ok(DebugEpochControl {
process,
actor,
epoch,
command,
affected_tasks,
all_stop_requested,
audit_event,
}),
_ => Err(unexpected_response("debug_epoch")),
}
}
pub async fn inspect_debug_epoch(
&self,
process: ProcessId,
epoch: u64,
) -> Result<DebugEpochStatus, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::InspectDebugEpoch { process, epoch })
.await?
{
WireResponse::DebugEpochStatus {
process,
actor,
epoch,
command,
expected_tasks,
acknowledgements,
fully_frozen,
partially_frozen,
fully_resumed,
failed,
failure_messages,
audit_event,
} => Ok(DebugEpochStatus {
process,
actor,
epoch,
command,
expected_tasks,
acknowledgements,
fully_frozen,
partially_frozen,
fully_resumed,
failed,
failure_messages,
audit_event,
}),
_ => Err(unexpected_response("debug_epoch_status")),
}
}
pub async fn list_artifacts(
&self,
process: Option<ProcessId>,
cursor: Option<String>,
limit: u32,
) -> Result<ArtifactPage, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::ListArtifacts {
process,
cursor,
limit,
})
.await?
{
WireResponse::Artifacts {
artifacts,
next_cursor,
} => Ok(ArtifactPage {
artifacts,
next_cursor,
}),
_ => Err(unexpected_response("artifacts")),
}
}
pub async fn get_artifact(&self, artifact: ArtifactId) -> Result<ArtifactSummary, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::GetArtifact { artifact })
.await?
{
WireResponse::Artifact { artifact } => Ok(artifact),
_ => Err(unexpected_response("artifact")),
}
}
pub async fn begin_artifact_download(
&self,
artifact: ArtifactId,
max_bytes: u64,
ttl_seconds: u64,
chunk_bytes: u64,
) -> Result<ArtifactDownload, ClientError> {
match self
.send_authenticated(AuthenticatedRequest::CreateArtifactDownloadLink {
artifact: artifact.clone(),
max_bytes,
ttl_seconds,
})
.await?
{
WireResponse::ArtifactDownloadLink { link } => Ok(ArtifactDownload {
client: self.clone(),
artifact,
max_bytes,
chunk_bytes,
token_digest: link.scoped_token_digest.clone(),
link,
expected_offset: 0,
finished: false,
}),
_ => Err(unexpected_response("artifact_download_link")),
}
}
async fn send_authenticated(
&self,
request: AuthenticatedRequest,
) -> Result<WireResponse, ClientError> {
let session_secret = self
.session_secret
.lock()
.map_err(|_| ClientError::Protocol("session credential lock was poisoned".to_owned()))?
.clone()
.ok_or_else(|| {
ClientError::Api(ApiError::from_message(
"client",
"no authenticated Clusterflux session is configured",
))
})?;
self.send(
CONTROL_API_PATH,
json!({
"type": "authenticated",
"session_secret": session_secret,
"request": request,
}),
)
.await
}
async fn send_login(&self, request: LoginRequest) -> Result<WireResponse, ClientError> {
let payload = serde_json::to_value(request)
.map_err(|error| ClientError::Protocol(error.to_string()))?;
self.send(LOGIN_API_PATH, payload).await
}
async fn send(
&self,
api_path: &str,
payload: serde_json::Value,
) -> Result<WireResponse, ClientError> {
let request_number = self.next_request.fetch_add(1, Ordering::Relaxed);
let request_id = format!("client-{request_number}");
let envelope = coordinator_wire_request(&request_id, payload);
let body = serde_json::to_vec(&envelope)
.map_err(|error| ClientError::Protocol(error.to_string()))?;
let response = self
.transport
.send(TransportRequest {
api_path: api_path.to_owned(),
body,
})
.await?;
let response: WireResponse = serde_json::from_slice(&response.body).map_err(|error| {
ClientError::Protocol(format!("decode typed API response: {error}"))
})?;
match response {
WireResponse::Error {
code,
category,
message,
retryable,
request_id: response_request_id,
} => {
if response_request_id != request_id {
return Err(ClientError::Protocol(format!(
"error response request_id {response_request_id} does not match {request_id}"
)));
}
Err(ClientError::Api(ApiError::new(
code,
category,
message,
retryable,
response_request_id,
)))
}
response => Ok(response),
}
}
}
pub struct ArtifactDownload {
client: ClusterfluxClient,
artifact: ArtifactId,
max_bytes: u64,
chunk_bytes: u64,
token_digest: Digest,
link: clusterflux_core::DownloadLink,
expected_offset: u64,
finished: bool,
}
impl ArtifactDownload {
pub fn link(&self) -> &clusterflux_core::DownloadLink {
&self.link
}
pub async fn next_chunk(&mut self) -> Result<ArtifactDownloadPoll, ClientError> {
if self.finished {
return Ok(ArtifactDownloadPoll::Chunk {
offset: self.expected_offset,
bytes: Vec::new(),
eof: true,
});
}
match self
.client
.send_authenticated(AuthenticatedRequest::OpenArtifactDownloadStream {
artifact: self.artifact.clone(),
max_bytes: self.max_bytes,
token_digest: self.token_digest.clone(),
chunk_bytes: self.chunk_bytes,
})
.await?
{
WireResponse::ArtifactDownloadStream {
content_bytes_available,
content_offset,
content_eof,
content_base64,
..
} => {
if !content_bytes_available {
return Ok(ArtifactDownloadPoll::Pending);
}
let offset = content_offset.ok_or_else(|| {
ClientError::Protocol(
"artifact response contained bytes without an offset".to_owned(),
)
})?;
if offset != self.expected_offset {
return Err(ClientError::Protocol(format!(
"artifact chunk offset {offset} does not match expected {}",
self.expected_offset
)));
}
let bytes = BASE64_STANDARD
.decode(content_base64.unwrap_or_default())
.map_err(|error| {
ClientError::Protocol(format!(
"artifact response contains invalid base64: {error}"
))
})?;
self.expected_offset = self.expected_offset.saturating_add(bytes.len() as u64);
self.finished = content_eof;
Ok(ArtifactDownloadPoll::Chunk {
offset,
bytes,
eof: content_eof,
})
}
_ => Err(unexpected_response("artifact_download_stream")),
}
}
pub async fn cancel(mut self) -> Result<(), ClientError> {
if self.finished {
return Ok(());
}
match self
.client
.send_authenticated(AuthenticatedRequest::RevokeArtifactDownloadLink {
artifact: self.artifact.clone(),
token_digest: self.token_digest.clone(),
})
.await?
{
WireResponse::ArtifactDownloadLinkRevoked {} => {
self.finished = true;
Ok(())
}
_ => Err(unexpected_response("artifact_download_link_revoked")),
}
}
}
fn unexpected_response(expected: &str) -> ClientError {
ClientError::Protocol(format!(
"expected typed response {expected}, received another response variant"
))
}

View file

@ -0,0 +1,398 @@
use std::collections::BTreeMap;
use clusterflux_core::{
AgentId, ApiErrorCategory, ApiErrorCode, ArtifactId, Authorization, Digest, DownloadLink,
LimitKind, NodeId, ProcessId, ProjectId, ResourceLimits, TaskInstanceId, TenantId, UserId,
};
use serde::{Deserialize, Serialize};
use crate::types::*;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum AuthenticatedRequest {
AuthStatus,
RevokeCliSession,
CreateProject {
project: ProjectId,
name: String,
},
SelectProject {
project: ProjectId,
},
ListProjects,
RegisterAgentPublicKey {
agent: AgentId,
public_key: String,
},
ListAgentPublicKeys,
RotateAgentPublicKey {
agent: AgentId,
public_key: String,
},
RevokeAgentPublicKey {
agent: AgentId,
},
CreateNodeEnrollmentGrant {
ttl_seconds: u64,
},
ListNodeSummaries {
cursor: Option<String>,
limit: u32,
},
RevokeNodeCredential {
node: NodeId,
},
ListProcessSummaries {
cursor: Option<String>,
limit: u32,
},
CancelProcess {
process: ProcessId,
},
AbortProcess {
process: ProcessId,
launch_attempt: Option<String>,
},
QuotaStatus,
ListTaskEvents {
process: Option<ProcessId>,
},
ListTaskSnapshots {
process: ProcessId,
},
ListRecentLogs {
process: ProcessId,
task: Option<TaskInstanceId>,
after_sequence: Option<u64>,
limit: u32,
},
RestartTask {
process: ProcessId,
task: TaskInstanceId,
replacement_bundle: Option<TaskReplacementBundle>,
},
ResolveTaskFailure {
process: ProcessId,
task: TaskInstanceId,
resolution: TaskFailureResolution,
},
DebugAttach {
process: ProcessId,
},
CreateDebugEpoch {
process: ProcessId,
stopped_task: TaskInstanceId,
reason: String,
},
ResumeDebugEpoch {
process: ProcessId,
epoch: u64,
},
InspectDebugEpoch {
process: ProcessId,
epoch: u64,
},
ListArtifacts {
process: Option<ProcessId>,
cursor: Option<String>,
limit: u32,
},
GetArtifact {
artifact: ArtifactId,
},
CreateArtifactDownloadLink {
artifact: ArtifactId,
max_bytes: u64,
ttl_seconds: u64,
},
OpenArtifactDownloadStream {
artifact: ArtifactId,
max_bytes: u64,
token_digest: Digest,
chunk_bytes: u64,
},
RevokeArtifactDownloadLink {
artifact: ArtifactId,
token_digest: Digest,
},
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum LoginRequest {
BeginWebBrowserLogin {},
ExchangeWebLoginHandoff {
transaction_id: String,
handoff_code: String,
},
}
#[derive(Clone, Deserialize)]
pub(crate) struct WireBrowserSession {
pub tenant: TenantId,
pub project: ProjectId,
pub user: UserId,
pub session_secret: String,
pub expires_at_epoch_seconds: u64,
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum WireResponse {
AuthStatus {
tenant: TenantId,
project: ProjectId,
actor: UserId,
authenticated: bool,
account_status: String,
suspended: bool,
disabled: bool,
deleted: bool,
manual_review: bool,
sanitized_reason: Option<String>,
next_actions: Vec<String>,
},
CliSessionRevoked {},
ProjectCreated {
project: Project,
},
ProjectSelected {
project: Project,
},
Projects {
projects: Vec<Project>,
},
AgentPublicKey {
record: AgentPublicKey,
},
AgentPublicKeys {
records: Vec<AgentPublicKey>,
},
NodeEnrollmentGrantCreated {
tenant: TenantId,
project: ProjectId,
grant: String,
scope: String,
expires_at_epoch_seconds: u64,
},
NodeSummaries {
nodes: Vec<NodeSummary>,
next_cursor: Option<String>,
},
NodeCredentialRevoked {
node: NodeId,
tenant: TenantId,
project: ProjectId,
actor: UserId,
descriptor_removed: bool,
queued_assignments_removed: usize,
},
ProcessSummaries {
processes: Vec<ProcessSummary>,
next_cursor: Option<String>,
},
ProcessCancellationRequested {
process: ProcessId,
cancelled_tasks: Vec<TaskCancellationTarget>,
affected_nodes: Vec<NodeId>,
},
ProcessAborted {
process: ProcessId,
aborted_tasks: Vec<TaskCancellationTarget>,
affected_nodes: Vec<NodeId>,
},
QuotaStatus {
tenant: TenantId,
project: ProjectId,
actor: UserId,
policy_label: Option<String>,
limits: ResourceLimits,
window_seconds: BTreeMap<LimitKind, u64>,
usage: BTreeMap<LimitKind, u64>,
window_started_epoch_seconds: BTreeMap<LimitKind, u64>,
},
TaskEvents {
events: Vec<TaskCompletionEvent>,
},
TaskSnapshots {
snapshots: Vec<TaskAttemptSnapshot>,
},
RecentLogs {
entries: Vec<RecentLogEntry>,
next_sequence: Option<u64>,
history_truncated: bool,
},
TaskRestart {
process: ProcessId,
task: TaskInstanceId,
restarted_task_instance: Option<TaskInstanceId>,
restarted_attempt_id: Option<String>,
actor: UserId,
accepted: bool,
clean_boundary_available: bool,
active_task: bool,
completed_event_observed: bool,
requires_whole_process_restart: bool,
message: String,
audit_event: DebugAuditEvent,
},
TaskFailureResolved {
process: ProcessId,
task: TaskInstanceId,
attempt_id: String,
resolution: TaskFailureResolution,
},
DebugAttach {
process: ProcessId,
actor: UserId,
authorization: Authorization,
audit_event: DebugAuditEvent,
},
DebugEpoch {
process: ProcessId,
actor: UserId,
epoch: u64,
command: String,
affected_tasks: Vec<TaskCancellationTarget>,
all_stop_requested: bool,
audit_event: DebugAuditEvent,
},
DebugEpochStatus {
process: ProcessId,
actor: UserId,
epoch: u64,
command: String,
expected_tasks: Vec<TaskCancellationTarget>,
acknowledgements: Vec<DebugParticipantAcknowledgement>,
fully_frozen: bool,
partially_frozen: bool,
fully_resumed: bool,
failed: bool,
failure_messages: Vec<String>,
audit_event: DebugAuditEvent,
},
Artifacts {
artifacts: Vec<ArtifactSummary>,
next_cursor: Option<String>,
},
Artifact {
artifact: ArtifactSummary,
},
ArtifactDownloadLink {
link: DownloadLink,
},
ArtifactDownloadLinkRevoked {},
ArtifactDownloadStream {
#[serde(rename = "link")]
_link: DownloadLink,
content_bytes_available: bool,
content_offset: Option<u64>,
content_eof: bool,
content_base64: Option<String>,
},
WebBrowserLoginStarted {
transaction_id: String,
authorization_url: String,
expires_at_epoch_seconds: u64,
},
WebBrowserSession {
session: WireBrowserSession,
},
Error {
code: ApiErrorCode,
category: ApiErrorCategory,
message: String,
retryable: bool,
request_id: String,
},
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use serde::Deserialize;
use serde_json::Value;
use super::*;
#[derive(Deserialize)]
struct OperationFixture {
operation: String,
boundary: String,
request: Value,
response: Value,
}
#[test]
fn website_operation_contract_fixtures_cover_every_typed_request() {
let fixtures: Vec<OperationFixture> =
serde_json::from_str(include_str!("../tests/fixtures/web_operations.json")).unwrap();
let expected = BTreeSet::from([
"abort_process",
"auth_status",
"begin_web_browser_login",
"cancel_process",
"create_artifact_download_link",
"create_debug_epoch",
"create_node_enrollment_grant",
"create_project",
"debug_attach",
"exchange_web_login_handoff",
"get_artifact",
"inspect_debug_epoch",
"list_agent_public_keys",
"list_artifacts",
"list_node_summaries",
"list_process_summaries",
"list_projects",
"list_recent_logs",
"list_task_events",
"list_task_snapshots",
"open_artifact_download_stream",
"quota_status",
"register_agent_public_key",
"resolve_task_failure",
"restart_task",
"resume_debug_epoch",
"revoke_agent_public_key",
"revoke_artifact_download_link",
"revoke_cli_session",
"revoke_node_credential",
"rotate_agent_public_key",
"select_project",
])
.into_iter()
.map(str::to_owned)
.collect();
let mut observed = BTreeSet::new();
for fixture in fixtures {
assert_eq!(fixture.request["type"], fixture.operation);
let round_trip = match fixture.boundary.as_str() {
"control" => {
let typed: AuthenticatedRequest =
serde_json::from_value(fixture.request.clone()).unwrap();
serde_json::to_value(typed).unwrap()
}
"login" => {
let typed: LoginRequest =
serde_json::from_value(fixture.request.clone()).unwrap();
serde_json::to_value(typed).unwrap()
}
boundary => panic!("unknown fixture boundary {boundary}"),
};
assert_eq!(round_trip, fixture.request);
let response: WireResponse = serde_json::from_value(fixture.response).unwrap();
assert!(
!matches!(response, WireResponse::Error { .. }),
"{} must carry its operation-specific success response fixture",
fixture.operation
);
assert!(observed.insert(fixture.operation));
}
assert_eq!(observed, expected);
}
}

View file

@ -0,0 +1,187 @@
use std::collections::{BTreeMap, VecDeque};
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use clusterflux_control::{endpoint_identity, ControlSession};
use thiserror::Error;
pub type TransportFuture =
Pin<Box<dyn Future<Output = Result<TransportResponse, ClientTransportError>> + Send + 'static>>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TransportRequest {
pub api_path: String,
pub body: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TransportResponse {
pub body: Vec<u8>,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ClientTransportError {
#[error("client transport failed: {0}")]
Failed(String),
#[error("client transport task failed: {0}")]
Task(String),
}
pub trait ClientTransport: Send + Sync + 'static {
fn send(&self, request: TransportRequest) -> TransportFuture;
}
pub struct ControlTransport {
endpoint: String,
connect_timeout: Duration,
io_timeout: Duration,
sessions: Arc<Mutex<BTreeMap<String, ControlSession>>>,
}
impl ControlTransport {
pub fn new(endpoint: impl Into<String>) -> Result<Self, ClientTransportError> {
Self::with_timeouts(endpoint, Duration::from_secs(10), Duration::from_secs(30))
}
pub fn with_timeouts(
endpoint: impl Into<String>,
connect_timeout: Duration,
io_timeout: Duration,
) -> Result<Self, ClientTransportError> {
let endpoint = endpoint.into();
endpoint_identity(&endpoint)
.map_err(|error| ClientTransportError::Failed(error.to_string()))?;
Ok(Self {
endpoint,
connect_timeout,
io_timeout,
sessions: Arc::new(Mutex::new(BTreeMap::new())),
})
}
}
impl ClientTransport for ControlTransport {
fn send(&self, request: TransportRequest) -> TransportFuture {
let endpoint = self.endpoint.clone();
let connect_timeout = self.connect_timeout;
let io_timeout = self.io_timeout;
let sessions = Arc::clone(&self.sessions);
Box::pin(async move {
tokio::task::spawn_blocking(move || {
let value = serde_json::from_slice(&request.body)
.map_err(|error| ClientTransportError::Failed(error.to_string()))?;
let mut sessions = sessions.lock().map_err(|_| {
ClientTransportError::Failed(
"client transport session lock was poisoned".to_owned(),
)
})?;
if !sessions.contains_key(&request.api_path) {
let session = ControlSession::connect_to_api_path_with_timeouts(
&endpoint,
&request.api_path,
connect_timeout,
io_timeout,
)
.map_err(|error| ClientTransportError::Failed(error.to_string()))?;
sessions.insert(request.api_path.clone(), session);
}
let response = sessions
.get_mut(&request.api_path)
.expect("session was inserted for the requested API path")
.request(&value);
match response {
Ok(response) => serde_json::to_vec(&response)
.map(|body| TransportResponse { body })
.map_err(|error| ClientTransportError::Failed(error.to_string())),
Err(error) => {
sessions.remove(&request.api_path);
Err(ClientTransportError::Failed(error.to_string()))
}
}
})
.await
.map_err(|error| ClientTransportError::Task(error.to_string()))?
})
}
}
#[derive(Clone, Default)]
pub struct MockTransport {
state: Arc<Mutex<MockTransportState>>,
}
#[derive(Default)]
struct MockTransportState {
responses: VecDeque<Result<Vec<u8>, ClientTransportError>>,
requests: Vec<TransportRequest>,
}
impl MockTransport {
pub fn from_json_responses(responses: impl IntoIterator<Item = impl Into<String>>) -> Self {
let responses = responses
.into_iter()
.map(|response| Ok(response.into().into_bytes()))
.collect();
Self {
state: Arc::new(Mutex::new(MockTransportState {
responses,
requests: Vec::new(),
})),
}
}
pub fn push_json_response(&self, response: impl Into<String>) {
self.state
.lock()
.expect("mock transport lock is not poisoned")
.responses
.push_back(Ok(response.into().into_bytes()));
}
pub fn push_error(&self, message: impl Into<String>) {
self.state
.lock()
.expect("mock transport lock is not poisoned")
.responses
.push_back(Err(ClientTransportError::Failed(message.into())));
}
pub fn request_bodies(&self) -> Vec<String> {
self.state
.lock()
.expect("mock transport lock is not poisoned")
.requests
.iter()
.map(|request| String::from_utf8_lossy(&request.body).into_owned())
.collect()
}
pub fn requests(&self) -> Vec<TransportRequest> {
self.state
.lock()
.expect("mock transport lock is not poisoned")
.requests
.clone()
}
}
impl ClientTransport for MockTransport {
fn send(&self, request: TransportRequest) -> TransportFuture {
let state = Arc::clone(&self.state);
Box::pin(async move {
let mut state = state.lock().map_err(|_| {
ClientTransportError::Failed("mock transport lock was poisoned".to_owned())
})?;
state.requests.push(request);
state
.responses
.pop_front()
.ok_or_else(|| {
ClientTransportError::Failed("mock transport has no queued response".to_owned())
})?
.map(|body| TransportResponse { body })
})
}
}

View file

@ -0,0 +1,468 @@
use std::collections::BTreeMap;
use clusterflux_core::{
AgentId, ArtifactId, Authorization, Digest, LimitKind, NodeCapabilities, NodeId, Placement,
ProcessId, ProjectId, ResourceLimits, TaskBoundaryValue, TaskDefinitionId, TaskFailurePolicy,
TaskInstanceId, TenantId, UserId, VfsPath,
};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountStatus {
pub tenant: TenantId,
pub project: ProjectId,
pub actor: UserId,
pub authenticated: bool,
pub account_status: String,
pub suspended: bool,
pub disabled: bool,
pub deleted: bool,
pub manual_review: bool,
pub sanitized_reason: Option<String>,
pub next_actions: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Project {
pub id: ProjectId,
pub tenant: TenantId,
pub name: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentPublicKey {
pub tenant: TenantId,
pub project: ProjectId,
pub user: UserId,
pub agent: AgentId,
pub public_key: String,
pub public_key_fingerprint: Digest,
pub version: u64,
pub revoked: bool,
pub scopes: Vec<String>,
pub human_account_creation_privilege: bool,
pub browser_interaction_required_each_run: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeEnrollmentGrant {
pub tenant: TenantId,
pub project: ProjectId,
pub grant: String,
pub scope: String,
pub expires_at_epoch_seconds: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeSummary {
pub id: NodeId,
pub display_name: String,
pub online: bool,
pub stale: bool,
pub last_seen_epoch_seconds: Option<u64>,
pub capabilities: NodeCapabilities,
pub direct_connectivity: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodePage {
pub nodes: Vec<NodeSummary>,
pub next_cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeRevocation {
pub node: NodeId,
pub tenant: TenantId,
pub project: ProjectId,
pub actor: UserId,
pub descriptor_removed: bool,
pub queued_assignments_removed: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProcessLifecycleState {
Active,
RecentTerminal,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProcessActivityState {
Running,
WaitingForNode,
WaitingForTask,
AwaitingAction,
DebugEpochPartial,
Cancelling,
Completed,
Failed,
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProcessFinalResult {
Completed,
Failed,
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugEpochSummary {
pub epoch: u64,
pub command: String,
pub fully_frozen: bool,
pub partially_frozen: bool,
pub fully_resumed: bool,
pub failed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcessSummary {
pub process: ProcessId,
pub lifecycle: ProcessLifecycleState,
pub activity: ProcessActivityState,
pub main_wait_state: Option<String>,
pub started_at_epoch_seconds: u64,
pub ended_at_epoch_seconds: Option<u64>,
pub final_result: Option<ProcessFinalResult>,
pub connected_nodes: Vec<NodeId>,
pub current_debug_epoch: Option<DebugEpochSummary>,
pub order_cursor: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcessPage {
pub processes: Vec<ProcessSummary>,
pub next_cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskTerminalState {
Completed,
Failed,
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskExecutor {
CoordinatorMain,
Node,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskCompletionEvent {
pub tenant: TenantId,
pub project: ProjectId,
pub process: ProcessId,
pub node: NodeId,
pub executor: TaskExecutor,
pub task_definition: TaskDefinitionId,
pub task: TaskInstanceId,
pub attempt_id: Option<String>,
pub placement: Option<Placement>,
pub terminal_state: TaskTerminalState,
pub status_code: Option<i32>,
pub stdout_bytes: u64,
pub stderr_bytes: u64,
pub stdout_tail: String,
pub stderr_tail: String,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
pub artifact_path: Option<VfsPath>,
pub artifact_digest: Option<Digest>,
pub artifact_size_bytes: Option<u64>,
pub result: Option<TaskBoundaryValue>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskAttemptState {
Queued,
Running,
FailedAwaitingAction,
Completed,
Failed,
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskAttemptSnapshot {
pub process: ProcessId,
pub task: TaskInstanceId,
pub attempt_id: String,
pub attempt_number: u32,
pub task_definition: TaskDefinitionId,
pub display_name: String,
pub state: TaskAttemptState,
pub current: bool,
pub node: Option<NodeId>,
pub environment_id: Option<String>,
pub environment_digest: Option<Digest>,
pub argument_summary: Vec<String>,
pub handle_summary: Vec<String>,
pub command_state: Option<String>,
pub vfs_checkpoint: String,
pub probe_symbol: Option<String>,
pub source_path: Option<String>,
pub source_line: Option<u32>,
pub restart_compatible: bool,
pub failure_policy: TaskFailurePolicy,
pub artifact_path: Option<VfsPath>,
pub artifact_digest: Option<Digest>,
pub artifact_size_bytes: Option<u64>,
pub status_code: Option<i32>,
pub error: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskLogStream {
Stdout,
Stderr,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecentLogEntry {
pub sequence: u64,
pub process: ProcessId,
pub task: TaskInstanceId,
pub stream: TaskLogStream,
pub text: String,
pub server_timestamp_epoch_seconds: u64,
pub truncated: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecentLogPage {
pub entries: Vec<RecentLogEntry>,
pub next_sequence: Option<u64>,
pub history_truncated: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactAvailability {
Available,
NodeOffline,
Unavailable,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactRetentionState {
NodeRetained,
ExplicitStorage,
Lost,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactSummary {
pub id: ArtifactId,
pub display_path: String,
pub display_name: String,
pub process: ProcessId,
pub producer_task: TaskInstanceId,
pub safe_node: Option<NodeId>,
pub digest: Digest,
pub size_bytes: u64,
pub availability: ArtifactAvailability,
pub downloadable_now: bool,
pub retention_state: ArtifactRetentionState,
pub explicit_storage: bool,
pub order_cursor: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactPage {
pub artifacts: Vec<ArtifactSummary>,
pub next_cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuotaStatus {
pub tenant: TenantId,
pub project: ProjectId,
pub actor: UserId,
pub policy_label: Option<String>,
pub limits: ResourceLimits,
pub window_seconds: BTreeMap<LimitKind, u64>,
pub usage: BTreeMap<LimitKind, u64>,
pub window_started_epoch_seconds: BTreeMap<LimitKind, u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskCancellationTarget {
pub process: ProcessId,
pub task: TaskInstanceId,
pub node: NodeId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcessCancellation {
pub process: ProcessId,
pub affected_tasks: Vec<TaskCancellationTarget>,
pub affected_nodes: Vec<NodeId>,
pub aborted: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugAuditEvent {
pub tenant: TenantId,
pub project: ProjectId,
pub process: ProcessId,
pub task: Option<TaskInstanceId>,
pub actor: UserId,
pub operation: String,
pub allowed: bool,
pub reason: String,
pub charged_debug_read_bytes: u64,
pub used_debug_read_bytes: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugAttach {
pub process: ProcessId,
pub actor: UserId,
pub authorization: Authorization,
pub audit_event: DebugAuditEvent,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugEpochControl {
pub process: ProcessId,
pub actor: UserId,
pub epoch: u64,
pub command: String,
pub affected_tasks: Vec<TaskCancellationTarget>,
pub all_stop_requested: bool,
pub audit_event: DebugAuditEvent,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DebugAcknowledgementState {
Frozen,
Running,
Failed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugParticipantAcknowledgement {
pub node: NodeId,
pub task_definition: TaskDefinitionId,
pub task: TaskInstanceId,
pub epoch: u64,
pub state: DebugAcknowledgementState,
pub stack_frames: Vec<String>,
pub local_values: Vec<(String, String)>,
pub task_args: Vec<(String, String)>,
pub handles: Vec<(String, String)>,
pub command_status: Option<String>,
pub recent_output: Vec<String>,
pub message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugEpochStatus {
pub process: ProcessId,
pub actor: UserId,
pub epoch: u64,
pub command: String,
pub expected_tasks: Vec<TaskCancellationTarget>,
pub acknowledgements: Vec<DebugParticipantAcknowledgement>,
pub fully_frozen: bool,
pub partially_frozen: bool,
pub fully_resumed: bool,
pub failed: bool,
pub failure_messages: Vec<String>,
pub audit_event: DebugAuditEvent,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskRestart {
pub process: ProcessId,
pub task: TaskInstanceId,
pub restarted_task_instance: Option<TaskInstanceId>,
pub restarted_attempt_id: Option<String>,
pub actor: UserId,
pub accepted: bool,
pub clean_boundary_available: bool,
pub active_task: bool,
pub completed_event_observed: bool,
pub requires_whole_process_restart: bool,
pub message: String,
pub audit_event: DebugAuditEvent,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskFailureResolutionResult {
pub process: ProcessId,
pub task: TaskInstanceId,
pub attempt_id: String,
pub resolution: TaskFailureResolution,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskFailureResolution {
AcceptFailure,
Cancel,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskReplacementBundle {
pub bundle_digest: Digest,
pub wasm_module_base64: String,
pub source_snapshot: Option<Digest>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowserLoginStart {
pub transaction_id: String,
pub authorization_url: String,
pub expires_at_epoch_seconds: u64,
}
#[derive(Clone, PartialEq, Eq)]
pub struct SessionCredential(pub(crate) String);
impl SessionCredential {
pub fn from_secret(secret: impl Into<String>) -> Self {
Self(secret.into())
}
pub fn expose_secret(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SessionCredential {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SessionCredential([REDACTED])")
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BrowserSession {
pub tenant: TenantId,
pub project: ProjectId,
pub user: UserId,
pub credential: SessionCredential,
pub expires_at_epoch_seconds: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ArtifactDownloadPoll {
Pending,
Chunk {
offset: u64,
bytes: Vec<u8>,
eof: bool,
},
}

View file

@ -0,0 +1,183 @@
use std::net::TcpListener;
use clusterflux_client::{
ApiErrorCategory, ApiErrorCode, ArtifactDownloadPoll, ArtifactId, ClientError,
ClusterfluxClient, MockTransport, ProjectId, SessionCredential, TenantId, UserId,
CLIENT_API_VERSION,
};
use clusterflux_control::CONTROL_API_PATH;
use clusterflux_coordinator::service::CoordinatorService;
use serde_json::{json, Value};
#[tokio::test]
async fn mock_transport_exercises_typed_envelope_and_session_plumbing() {
let transport = MockTransport::from_json_responses([json!({
"type": "projects",
"projects": [{
"id": "project-one",
"tenant": "tenant-one",
"name": "Project one"
}],
"actor": "user-one"
})
.to_string()]);
let client = ClusterfluxClient::with_transport(transport.clone())
.with_session_credential(&SessionCredential::from_secret("test-session-secret"));
let projects = client.list_projects().await.unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].id, ProjectId::from("project-one"));
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].api_path, CONTROL_API_PATH);
let envelope: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(envelope["protocol_version"], CLIENT_API_VERSION);
assert_eq!(envelope["request_id"], "client-1");
assert_eq!(envelope["operation"], "authenticated");
assert_eq!(envelope["payload"]["type"], "authenticated");
assert_eq!(envelope["payload"]["request"]["type"], "list_projects");
assert_eq!(envelope["payload"]["session_secret"], "test-session-secret");
}
#[tokio::test]
async fn structured_errors_retain_machine_fields_and_originating_request_id() {
let transport = MockTransport::from_json_responses([json!({
"type": "error",
"code": "account_suspended",
"category": "authorization",
"message": "account access is suspended",
"retryable": false,
"request_id": "client-1"
})
.to_string()]);
let client = ClusterfluxClient::with_transport(transport)
.with_session_credential(&SessionCredential::from_secret("test-session-secret"));
let ClientError::Api(error) = client.account_status().await.unwrap_err() else {
panic!("expected typed API error");
};
assert_eq!(error.code, ApiErrorCode::AccountSuspended);
assert_eq!(error.category, ApiErrorCategory::Authorization);
assert_eq!(error.request_id, "client-1");
assert!(!error.retryable);
}
#[tokio::test]
async fn a_mismatched_error_request_id_is_rejected_as_a_protocol_error() {
let transport = MockTransport::from_json_responses([json!({
"type": "error",
"code": "validation_error",
"category": "validation",
"message": "bad request",
"retryable": false,
"request_id": "another-request"
})
.to_string()]);
let client = ClusterfluxClient::with_transport(transport)
.with_session_credential(&SessionCredential::from_secret("test-session-secret"));
let ClientError::Protocol(message) = client.list_projects().await.unwrap_err() else {
panic!("expected protocol error");
};
assert!(message.contains("does not match client-1"));
}
#[test]
fn session_credentials_are_redacted_from_debug_output() {
let credential = SessionCredential::from_secret("must-not-appear");
assert_eq!(format!("{credential:?}"), "SessionCredential([REDACTED])");
}
#[tokio::test]
async fn artifact_download_is_a_typed_bounded_chunk_stream() {
let link = json!({
"artifact": "artifact-one",
"artifact_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"artifact_size_bytes": 5,
"source": { "RetainedNode": "node-one" },
"url_path": "/artifacts/tenant-one/project-one/process-one/artifact-one",
"scoped_token_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"expires_at_epoch_seconds": 1000,
"tenant": "tenant-one",
"project": "project-one",
"process": "process-one",
"actor": { "User": "user-one" },
"max_bytes": 1024,
"policy_context_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
});
let transport = MockTransport::from_json_responses([
json!({ "type": "artifact_download_link", "link": link.clone() }).to_string(),
json!({
"type": "artifact_download_stream",
"link": link.clone(),
"streamed_bytes": 0,
"charged_download_bytes": 0,
"content_bytes_available": false,
"content_eof": false
})
.to_string(),
json!({
"type": "artifact_download_stream",
"link": link,
"streamed_bytes": 5,
"charged_download_bytes": 5,
"content_bytes_available": true,
"content_offset": 0,
"content_eof": true,
"content_base64": "aGVsbG8="
})
.to_string(),
]);
let client = ClusterfluxClient::with_transport(transport)
.with_session_credential(&SessionCredential::from_secret("test-session-secret"));
let mut download = client
.begin_artifact_download(ArtifactId::from("artifact-one"), 1024, 60, 64)
.await
.unwrap();
assert_eq!(
download.next_chunk().await.unwrap(),
ArtifactDownloadPoll::Pending
);
assert_eq!(
download.next_chunk().await.unwrap(),
ArtifactDownloadPoll::Chunk {
offset: 0,
bytes: b"hello".to_vec(),
eof: true,
}
);
}
#[tokio::test]
async fn typed_client_runs_against_the_real_strict_control_endpoint() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let mut service = CoordinatorService::new(7);
service
.issue_cli_session(
TenantId::from("tenant-one"),
ProjectId::from("project-one"),
UserId::from("user-one"),
"real-endpoint-session",
None,
)
.unwrap();
let (stream, _) = listener.accept().unwrap();
service.handle_stream(stream).unwrap();
});
let client = ClusterfluxClient::connect(format!("clusterflux+tcp://{address}"))
.unwrap()
.with_session_credential(&SessionCredential::from_secret("real-endpoint-session"));
let projects = client.list_projects().await.unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].id, ProjectId::from("project-one"));
let status = client.account_status().await.unwrap();
assert!(status.authenticated);
assert_eq!(status.actor, UserId::from("user-one"));
drop(client);
server.join().unwrap();
}

View file

@ -0,0 +1,194 @@
[
{
"operation": "begin_web_browser_login",
"boundary": "login",
"request": { "type": "begin_web_browser_login" },
"response": { "type": "web_browser_login_started", "transaction_id": "login-transaction", "authorization_url": "https://auth.clusterflux.lesstuff.com/authorize?state=opaque", "expires_at_epoch_seconds": 1000 }
},
{
"operation": "exchange_web_login_handoff",
"boundary": "login",
"request": { "type": "exchange_web_login_handoff", "transaction_id": "login-transaction", "handoff_code": "one-time-handoff" },
"response": { "type": "web_browser_session", "session": { "tenant": "tenant-one", "project": "project-one", "user": "user-one", "session_secret": "server-side-session", "expires_at_epoch_seconds": 2000 } }
},
{
"operation": "auth_status",
"boundary": "control",
"request": { "type": "auth_status" },
"response": { "type": "auth_status", "tenant": "tenant-one", "project": "project-one", "actor": "user-one", "authenticated": true, "account_status": "active", "suspended": false, "disabled": false, "deleted": false, "manual_review": false, "sanitized_reason": null, "next_actions": [] }
},
{
"operation": "revoke_cli_session",
"boundary": "control",
"request": { "type": "revoke_cli_session" },
"response": { "type": "cli_session_revoked" }
},
{
"operation": "create_project",
"boundary": "control",
"request": { "type": "create_project", "project": "project-new", "name": "New project" },
"response": { "type": "project_created", "project": { "id": "project-new", "tenant": "tenant-one", "name": "New project" } }
},
{
"operation": "select_project",
"boundary": "control",
"request": { "type": "select_project", "project": "project-selected" },
"response": { "type": "project_selected", "project": { "id": "project-selected", "tenant": "tenant-one", "name": "Selected project" } }
},
{
"operation": "list_projects",
"boundary": "control",
"request": { "type": "list_projects" },
"response": { "type": "projects", "projects": [{ "id": "project-one", "tenant": "tenant-one", "name": "Project one" }] }
},
{
"operation": "register_agent_public_key",
"boundary": "control",
"request": { "type": "register_agent_public_key", "agent": "agent-browser", "public_key": "ed25519:test-public-key" },
"response": { "type": "agent_public_key", "record": { "tenant": "tenant-one", "project": "project-one", "user": "user-one", "agent": "agent-browser", "public_key": "ed25519:test-public-key", "public_key_fingerprint": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "version": 1, "revoked": false, "scopes": ["project"], "human_account_creation_privilege": false, "browser_interaction_required_each_run": false } }
},
{
"operation": "list_agent_public_keys",
"boundary": "control",
"request": { "type": "list_agent_public_keys" },
"response": { "type": "agent_public_keys", "records": [] }
},
{
"operation": "rotate_agent_public_key",
"boundary": "control",
"request": { "type": "rotate_agent_public_key", "agent": "agent-browser", "public_key": "ed25519:rotated-public-key" },
"response": { "type": "agent_public_key", "record": { "tenant": "tenant-one", "project": "project-one", "user": "user-one", "agent": "agent-browser", "public_key": "ed25519:rotated-public-key", "public_key_fingerprint": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "version": 2, "revoked": false, "scopes": ["project"], "human_account_creation_privilege": false, "browser_interaction_required_each_run": false } }
},
{
"operation": "revoke_agent_public_key",
"boundary": "control",
"request": { "type": "revoke_agent_public_key", "agent": "agent-browser" },
"response": { "type": "agent_public_key", "record": { "tenant": "tenant-one", "project": "project-one", "user": "user-one", "agent": "agent-browser", "public_key": "ed25519:rotated-public-key", "public_key_fingerprint": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "version": 3, "revoked": true, "scopes": ["project"], "human_account_creation_privilege": false, "browser_interaction_required_each_run": false } }
},
{
"operation": "create_node_enrollment_grant",
"boundary": "control",
"request": { "type": "create_node_enrollment_grant", "ttl_seconds": 300 },
"response": { "type": "node_enrollment_grant_created", "tenant": "tenant-one", "project": "project-one", "grant": "one-time-grant", "scope": "tenant-one/project-one", "expires_at_epoch_seconds": 1000 }
},
{
"operation": "list_node_summaries",
"boundary": "control",
"request": { "type": "list_node_summaries", "cursor": null, "limit": 200 },
"response": { "type": "node_summaries", "nodes": [], "next_cursor": null }
},
{
"operation": "revoke_node_credential",
"boundary": "control",
"request": { "type": "revoke_node_credential", "node": "node-one" },
"response": { "type": "node_credential_revoked", "node": "node-one", "tenant": "tenant-one", "project": "project-one", "actor": "user-one", "descriptor_removed": true, "queued_assignments_removed": 0 }
},
{
"operation": "list_process_summaries",
"boundary": "control",
"request": { "type": "list_process_summaries", "cursor": "process:1", "limit": 20 },
"response": { "type": "process_summaries", "processes": [], "next_cursor": null }
},
{
"operation": "cancel_process",
"boundary": "control",
"request": { "type": "cancel_process", "process": "process-one" },
"response": { "type": "process_cancellation_requested", "process": "process-one", "cancelled_tasks": [], "affected_nodes": [] }
},
{
"operation": "abort_process",
"boundary": "control",
"request": { "type": "abort_process", "process": "process-one", "launch_attempt": null },
"response": { "type": "process_aborted", "process": "process-one", "aborted_tasks": [], "affected_nodes": [] }
},
{
"operation": "quota_status",
"boundary": "control",
"request": { "type": "quota_status" },
"response": { "type": "quota_status", "tenant": "tenant-one", "project": "project-one", "actor": "user-one", "policy_label": "community tier", "limits": { "limits": { "ApiCall": 10000 } }, "window_seconds": { "ApiCall": 60 }, "usage": { "ApiCall": 12 }, "window_started_epoch_seconds": { "ApiCall": 900 } }
},
{
"operation": "list_task_events",
"boundary": "control",
"request": { "type": "list_task_events", "process": "process-one" },
"response": { "type": "task_events", "events": [] }
},
{
"operation": "list_task_snapshots",
"boundary": "control",
"request": { "type": "list_task_snapshots", "process": "process-one" },
"response": { "type": "task_snapshots", "snapshots": [] }
},
{
"operation": "list_recent_logs",
"boundary": "control",
"request": { "type": "list_recent_logs", "process": "process-one", "task": "task-one", "after_sequence": 7, "limit": 100 },
"response": { "type": "recent_logs", "entries": [{ "sequence": 1, "process": "process-one", "task": "task-one", "stream": "stdout", "text": "building", "server_timestamp_epoch_seconds": 1000, "truncated": false }], "next_sequence": 1, "history_truncated": false }
},
{
"operation": "restart_task",
"boundary": "control",
"request": { "type": "restart_task", "process": "process-one", "task": "task-one", "replacement_bundle": null },
"response": { "type": "task_restart", "process": "process-one", "task": "task-one", "restarted_task_instance": "task-one-retry", "restarted_attempt_id": "attempt-2", "actor": "user-one", "accepted": true, "clean_boundary_available": true, "active_task": false, "completed_event_observed": true, "requires_whole_process_restart": false, "message": "task restart accepted", "audit_event": { "tenant": "tenant-one", "project": "project-one", "process": "process-one", "task": "task-one", "actor": "user-one", "operation": "restart_task", "allowed": true, "reason": "authorized", "charged_debug_read_bytes": 0, "used_debug_read_bytes": 0 } }
},
{
"operation": "resolve_task_failure",
"boundary": "control",
"request": { "type": "resolve_task_failure", "process": "process-one", "task": "task-one", "resolution": "accept_failure" },
"response": { "type": "task_failure_resolved", "process": "process-one", "task": "task-one", "attempt_id": "attempt-1", "resolution": "accept_failure" }
},
{
"operation": "debug_attach",
"boundary": "control",
"request": { "type": "debug_attach", "process": "process-one" },
"response": { "type": "debug_attach", "process": "process-one", "actor": "user-one", "authorization": { "allowed": true, "reason": "authorized" }, "audit_event": { "tenant": "tenant-one", "project": "project-one", "process": "process-one", "task": null, "actor": "user-one", "operation": "debug_attach", "allowed": true, "reason": "authorized", "charged_debug_read_bytes": 0, "used_debug_read_bytes": 0 } }
},
{
"operation": "create_debug_epoch",
"boundary": "control",
"request": { "type": "create_debug_epoch", "process": "process-one", "stopped_task": "task-one", "reason": "breakpoint" },
"response": { "type": "debug_epoch", "process": "process-one", "actor": "user-one", "epoch": 3, "command": "freeze", "affected_tasks": [], "all_stop_requested": true, "audit_event": { "tenant": "tenant-one", "project": "project-one", "process": "process-one", "task": "task-one", "actor": "user-one", "operation": "create_debug_epoch", "allowed": true, "reason": "authorized", "charged_debug_read_bytes": 0, "used_debug_read_bytes": 0 } }
},
{
"operation": "resume_debug_epoch",
"boundary": "control",
"request": { "type": "resume_debug_epoch", "process": "process-one", "epoch": 3 },
"response": { "type": "debug_epoch", "process": "process-one", "actor": "user-one", "epoch": 3, "command": "resume", "affected_tasks": [], "all_stop_requested": false, "audit_event": { "tenant": "tenant-one", "project": "project-one", "process": "process-one", "task": null, "actor": "user-one", "operation": "resume_debug_epoch", "allowed": true, "reason": "authorized", "charged_debug_read_bytes": 0, "used_debug_read_bytes": 0 } }
},
{
"operation": "inspect_debug_epoch",
"boundary": "control",
"request": { "type": "inspect_debug_epoch", "process": "process-one", "epoch": 3 },
"response": { "type": "debug_epoch_status", "process": "process-one", "actor": "user-one", "epoch": 3, "command": "freeze", "expected_tasks": [], "acknowledgements": [], "fully_frozen": false, "partially_frozen": true, "fully_resumed": false, "failed": false, "failure_messages": [], "audit_event": { "tenant": "tenant-one", "project": "project-one", "process": "process-one", "task": null, "actor": "user-one", "operation": "inspect_debug_epoch", "allowed": true, "reason": "authorized", "charged_debug_read_bytes": 0, "used_debug_read_bytes": 0 } }
},
{
"operation": "list_artifacts",
"boundary": "control",
"request": { "type": "list_artifacts", "process": "process-one", "cursor": "artifact:1", "limit": 50 },
"response": { "type": "artifacts", "artifacts": [], "next_cursor": null }
},
{
"operation": "get_artifact",
"boundary": "control",
"request": { "type": "get_artifact", "artifact": "artifact-one" },
"response": { "type": "artifact", "artifact": { "id": "artifact-one", "display_path": "/out/artifact-one", "display_name": "artifact-one", "process": "process-one", "producer_task": "task-one", "safe_node": "node-one", "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "size_bytes": 5, "availability": "available", "downloadable_now": true, "retention_state": "node_retained", "explicit_storage": false, "order_cursor": "artifact:1" } }
},
{
"operation": "create_artifact_download_link",
"boundary": "control",
"request": { "type": "create_artifact_download_link", "artifact": "artifact-one", "max_bytes": 1048576, "ttl_seconds": 120 },
"response": { "type": "artifact_download_link", "link": { "artifact": "artifact-one", "artifact_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "artifact_size_bytes": 5, "source": { "RetainedNode": "node-one" }, "url_path": "/artifacts/tenant-one/project-one/process-one/artifact-one", "scoped_token_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "expires_at_epoch_seconds": 1100, "tenant": "tenant-one", "project": "project-one", "process": "process-one", "actor": { "User": "user-one" }, "max_bytes": 1048576, "policy_context_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" } }
},
{
"operation": "open_artifact_download_stream",
"boundary": "control",
"request": { "type": "open_artifact_download_stream", "artifact": "artifact-one", "max_bytes": 1048576, "token_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "chunk_bytes": 65536 },
"response": { "type": "artifact_download_stream", "link": { "artifact": "artifact-one", "artifact_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "artifact_size_bytes": 5, "source": { "RetainedNode": "node-one" }, "url_path": "/artifacts/tenant-one/project-one/process-one/artifact-one", "scoped_token_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "expires_at_epoch_seconds": 1100, "tenant": "tenant-one", "project": "project-one", "process": "process-one", "actor": { "User": "user-one" }, "max_bytes": 1048576, "policy_context_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" }, "streamed_bytes": 5, "charged_download_bytes": 5, "content_bytes_available": true, "content_offset": 0, "content_eof": true, "content_base64": "aGVsbG8=" }
},
{
"operation": "revoke_artifact_download_link",
"boundary": "control",
"request": { "type": "revoke_artifact_download_link", "artifact": "artifact-one", "token_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
"response": { "type": "artifact_download_link_revoked" }
}
]