Update public backend API surface
Private source commit: ba3f7ce2b6d9
This commit is contained in:
parent
784463622c
commit
26fdcb9d84
34 changed files with 5473 additions and 72 deletions
909
crates/clusterflux-client/src/lib.rs
Normal file
909
crates/clusterflux-client/src/lib.rs
Normal 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"
|
||||
))
|
||||
}
|
||||
398
crates/clusterflux-client/src/protocol.rs
Normal file
398
crates/clusterflux-client/src/protocol.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
187
crates/clusterflux-client/src/transport.rs
Normal file
187
crates/clusterflux-client/src/transport.rs
Normal 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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
468
crates/clusterflux-client/src/types.rs
Normal file
468
crates/clusterflux-client/src/types.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue