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, limit: u32, }, RevokeNodeCredential { node: NodeId, }, ListProcessSummaries { cursor: Option, limit: u32, }, CancelProcess { process: ProcessId, }, AbortProcess { process: ProcessId, launch_attempt: Option, }, QuotaStatus, ListTaskEvents { process: Option, }, ListTaskSnapshots { process: ProcessId, }, ListRecentLogs { process: ProcessId, task: Option, after_sequence: Option, limit: u32, }, RestartTask { process: ProcessId, task: TaskInstanceId, replacement_bundle: Option, }, 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, cursor: Option, 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, next_actions: Vec, }, CliSessionRevoked {}, ProjectCreated { project: Project, }, ProjectSelected { project: Project, }, Projects { projects: Vec, }, AgentPublicKey { record: AgentPublicKey, }, AgentPublicKeys { records: Vec, }, NodeEnrollmentGrantCreated { tenant: TenantId, project: ProjectId, grant: String, scope: String, expires_at_epoch_seconds: u64, }, NodeSummaries { nodes: Vec, next_cursor: Option, }, NodeCredentialRevoked { node: NodeId, tenant: TenantId, project: ProjectId, actor: UserId, descriptor_removed: bool, queued_assignments_removed: usize, }, ProcessSummaries { processes: Vec, next_cursor: Option, }, ProcessCancellationRequested { process: ProcessId, cancelled_tasks: Vec, affected_nodes: Vec, }, ProcessAborted { process: ProcessId, aborted_tasks: Vec, affected_nodes: Vec, }, QuotaStatus { tenant: TenantId, project: ProjectId, actor: UserId, policy_label: Option, limits: ResourceLimits, window_seconds: BTreeMap, usage: BTreeMap, window_started_epoch_seconds: BTreeMap, }, TaskEvents { events: Vec, }, TaskSnapshots { snapshots: Vec, }, RecentLogs { entries: Vec, next_sequence: Option, history_truncated: bool, }, TaskRestart { process: ProcessId, task: TaskInstanceId, restarted_task_instance: Option, restarted_attempt_id: Option, 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, all_stop_requested: bool, audit_event: DebugAuditEvent, }, DebugEpochStatus { process: ProcessId, actor: UserId, epoch: u64, command: String, expected_tasks: Vec, acknowledgements: Vec, fully_frozen: bool, partially_frozen: bool, fully_resumed: bool, failed: bool, failure_messages: Vec, audit_event: DebugAuditEvent, }, Artifacts { artifacts: Vec, next_cursor: Option, }, Artifact { artifact: ArtifactSummary, }, ArtifactDownloadLink { link: DownloadLink, }, ArtifactDownloadLinkRevoked {}, ArtifactDownloadStream { #[serde(rename = "link")] _link: DownloadLink, content_bytes_available: bool, content_offset: Option, content_eof: bool, content_base64: Option, }, 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 = 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); } }