alien_core/debug_session.rs
1//! Wire shapes for `alien debug` sessions.
2//!
3//! These types are the contract between the CLI, the manager (push mode),
4//! and the agent (pull mode). Pure data + a small AWS-host parser; no
5//! cloud-client dependencies, so any layer in the dep graph can speak them.
6//!
7//! The credential *translator* — projecting a resolved [`crate::ClientConfig`]
8//! onto a `DebugSessionResponse::Push` payload — lives in `alien-platform-core`,
9//! one tier above the cloud-client crates.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15/// Errors raised by the debug-session producer (manager or agent). Callers
16/// wrap into their own error type when surfacing across crate boundaries.
17#[derive(Debug, thiserror::Error)]
18pub enum DebugSessionError {
19 /// A credential variant we don't know how to project onto a user shell.
20 /// The resolver path normally produces exportable variants — hitting this
21 /// means an upstream change.
22 #[error("alien debug ({platform}): {message}")]
23 UnsupportedCredential { platform: String, message: String },
24
25 /// The deployment's platform doesn't have a push-mode shell session
26 /// (Kubernetes, Local, Test).
27 #[error("alien debug: {message}")]
28 UnsupportedPlatform { message: String },
29
30 /// I/O error reading a credential off the manager's / agent's filesystem
31 /// (e.g. the Azure federated-token file).
32 #[error("alien debug ({platform}): {message}")]
33 IoError { platform: String, message: String },
34
35 /// Failed to mint a token from the resolved credential (e.g. GCP
36 /// `IAMCredentials.generateAccessToken`).
37 #[error("alien debug ({platform}): {message}")]
38 TokenMintFailed { platform: String, message: String },
39}
40
41/// Wire response. Discriminated by `kind`. Identical shape on both ends so
42/// the CLI can deserialize a session regardless of who produced it (manager
43/// or agent).
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(tag = "kind", rename_all = "camelCase")]
46pub enum DebugSessionResponse {
47 /// Cloud credentials projected as env vars (+ optional files / setup
48 /// script). The CLI execs the user's command with the merged env.
49 Push(PushDebugSession),
50 /// Pure-Kubernetes session: a self-contained kubeconfig the CLI binds to
51 /// `KUBECONFIG`.
52 Pull(PullDebugSession),
53 /// Session creation is async. Returned for pull-mode deployments where
54 /// the agent must first open an outbound tunnel back to the manager
55 /// before the kubeconfig resolves. The CLI long-polls `poll_url` until
56 /// the session resolves to `Pull` (kubeconfig ready) or errors out.
57 Pending(PendingDebugSession),
58 /// Push-mode cloud session via a manager-hosted WebSocket tunnel.
59 /// Credentials stay on the manager; the CLI dials `tunnel_url`, spawns
60 /// a local HTTP proxy, and every cloud-CLI request the child process
61 /// emits is forwarded over the tunnel for the manager to re-sign with
62 /// the impersonated identity.
63 PushTunnel(PushTunnelDebugSession),
64 /// Runtime shell/exec session via an agent-hosted process tunnel.
65 RuntimeTunnel(RuntimeTunnelDebugSession),
66 /// Remote shell/exec session via a control-plane attach WebSocket.
67 RemoteExec(RemoteExecDebugSession),
68}
69
70impl DebugSessionResponse {
71 /// RFC3339 expiry of the underlying credentials, if the producer
72 /// surfaced one. Used by the CLI's on-disk cache to skip near-expired
73 /// sessions.
74 pub fn expires_at(&self) -> Option<&str> {
75 match self {
76 Self::Push(p) => p.expires_at.as_deref(),
77 Self::Pull(p) => p.expires_at.as_deref(),
78 Self::Pending(_) => None,
79 Self::PushTunnel(p) => p.expires_at.as_deref(),
80 Self::RuntimeTunnel(p) => p.expires_at.as_deref(),
81 Self::RemoteExec(p) => p.expires_at.as_deref(),
82 }
83 }
84}
85
86/// What kind of debug session the caller is requesting.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub enum DebugSessionKind {
90 /// Existing behavior: run local commands with deployment context.
91 Context,
92 /// Open an interactive shell in the deployment runtime.
93 RuntimeShell,
94 /// Run one non-interactive command in the deployment runtime.
95 RuntimeExec,
96}
97
98impl Default for DebugSessionKind {
99 fn default() -> Self {
100 Self::Context
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct RuntimeTunnelDebugSession {
107 /// Server-assigned session id.
108 pub session_id: String,
109 /// `local` for v1.
110 pub platform: String,
111 /// Absolute `wss://…/v1/debug/sessions/<sid>/runtime-client` URL.
112 pub tunnel_url: String,
113 /// Bearer the CLI presents on the WebSocket upgrade.
114 pub client_token: String,
115 /// Runtime operation this tunnel accepts.
116 pub kind: DebugSessionKind,
117 /// Runtime frame protocol version.
118 pub protocol_version: u32,
119 /// RFC3339 expiry.
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub expires_at: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct RemoteExecDebugSession {
127 /// Control-plane session id.
128 pub session_id: String,
129 /// Deployment runtime platform label.
130 pub platform: String,
131 /// Absolute HTTP(S) or WS(S) URL the CLI should attach to.
132 pub attach_url: String,
133 /// Bearer the CLI presents on the WebSocket upgrade.
134 pub client_token: String,
135 /// Whether the remote session was created with a TTY.
136 pub tty: bool,
137 /// RFC3339 expiry, if the control plane returns one.
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub expires_at: Option<String>,
140}
141
142/// Client-to-agent runtime debug frames, relayed by the manager.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(tag = "type", rename_all = "camelCase")]
145pub enum RuntimeClientFrame {
146 /// Start an interactive shell with an optional initial terminal size.
147 StartShell {
148 /// Terminal columns.
149 cols: u16,
150 /// Terminal rows.
151 rows: u16,
152 },
153 /// Start a non-interactive command.
154 StartExec {
155 /// Executable and argv to run on the remote host.
156 command: Vec<String>,
157 /// Optional timeout in milliseconds.
158 timeout_ms: Option<u64>,
159 },
160 /// Standard input bytes, base64-encoded.
161 Stdin {
162 /// Base64-encoded bytes.
163 data_b64: String,
164 },
165 /// Resize the interactive terminal.
166 Resize {
167 /// Terminal columns.
168 cols: u16,
169 /// Terminal rows.
170 rows: u16,
171 },
172 /// Close stdin for the remote process.
173 CloseStdin,
174 /// Cancel the remote process.
175 Cancel,
176}
177
178/// Agent-to-client runtime debug frames, relayed by the manager.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(tag = "type", rename_all = "camelCase")]
181pub enum RuntimeAgentFrame {
182 /// The process has started.
183 Started {
184 /// Optional process id where available.
185 pid: Option<u32>,
186 /// Human-readable account or shell description.
187 detail: Option<String>,
188 },
189 /// Standard output bytes, base64-encoded.
190 Stdout {
191 /// Base64-encoded bytes.
192 data_b64: String,
193 },
194 /// Standard error bytes, base64-encoded.
195 Stderr {
196 /// Base64-encoded bytes.
197 data_b64: String,
198 },
199 /// The process exited.
200 Exit {
201 /// Process exit code. `None` means signal/unknown.
202 code: Option<i32>,
203 /// Whether the process was terminated by the runtime timeout.
204 timed_out: bool,
205 /// Whether output was truncated.
206 output_truncated: bool,
207 },
208 /// The remote runtime failed before producing an exit code.
209 Error {
210 /// Safe, user-facing error message.
211 message: String,
212 },
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct PushTunnelDebugSession {
218 /// Server-assigned session id (`ds_…`).
219 pub session_id: String,
220 /// `aws`, `gcp`, `azure`. Drives which `_ENDPOINT_URL` env var the CLI
221 /// sets and which signing flow the manager runs.
222 pub provider: String,
223 /// Absolute `wss://…/v1/debug/sessions/<sid>/push-tunnel` URL.
224 pub tunnel_url: String,
225 /// Bearer the CLI presents on the WebSocket upgrade and on subsequent
226 /// HTTP proxy requests.
227 pub client_token: String,
228 /// RFC3339 expiry mirroring the underlying impersonated credential's TTL.
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub expires_at: Option<String>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct PushDebugSession {
236 /// Short identifier surfaced to the user (e.g. `"aws"`, `"gcp"`, `"azure"`).
237 pub provider: String,
238 /// Environment variables the CLI sets on the spawned process.
239 pub env: BTreeMap<String, String>,
240 /// Files the CLI materializes under the per-session tempdir before exec.
241 /// When `env_var` is set, the CLI binds that env var to the resulting
242 /// absolute file path.
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 pub files: Vec<DebugCredFile>,
245 /// Optional shell snippet the CLI runs (`sh -c`) after env/files are set
246 /// up but before the user's command. Must be idempotent.
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub setup_script: Option<String>,
249 /// RFC3339 expiry. None when the credential type doesn't expose one.
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub expires_at: Option<String>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct DebugCredFile {
257 /// Filename — no path components. Written under the per-session tempdir.
258 pub file_name: String,
259 /// File contents.
260 pub content: String,
261 /// If set, the CLI binds this env var to the file's absolute path.
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub env_var: Option<String>,
264}
265
266/// Async-session handle. The CLI polls `poll_url` until the manager has
267/// received the agent's tunnel-ready signal and can return a `Pull` payload
268/// whose kubeconfig points at the per-session HTTPS proxy on the manager.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct PendingDebugSession {
272 /// Server-assigned session id. Embedded in URLs and command channel
273 /// messages so all parties (CLI, manager, agent) reference the same row.
274 pub session_id: String,
275 /// Absolute URL the CLI should GET to poll for readiness. Same auth as
276 /// `POST /v1/debug/sessions`.
277 pub poll_url: String,
278 /// Suggested initial poll interval in milliseconds. The CLI should back
279 /// off on repeated `pending` responses but never poll faster than this.
280 #[serde(default)]
281 pub poll_interval_ms: u32,
282 /// RFC3339 absolute deadline. The CLI should give up after this and
283 /// surface the most recent status. Bounded server-side; defaults to
284 /// the session TTL.
285 pub deadline: String,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct PullDebugSession {
291 /// Server-assigned session id. The CLI sends a DELETE to the manager on
292 /// exit so the agent's `serve_session` ends.
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub session_id: Option<String>,
295 /// Kubeconfig YAML the CLI writes to a temp file and binds to `KUBECONFIG`.
296 pub kubeconfig: String,
297 /// Additional env vars the CLI sets alongside `KUBECONFIG`.
298 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
299 pub env: BTreeMap<String, String>,
300 /// Extra files to materialize alongside the kubeconfig.
301 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub files: Vec<DebugCredFile>,
303 /// When set, the CLI also spawns a local AWS loopback proxy and points
304 /// `AWS_ENDPOINT_URL` at it.
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub aws_endpoint_url: Option<String>,
307 /// GCP equivalent — signed agent-side with the pod's GKE Workload Identity
308 /// token.
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub gcp_endpoint_url: Option<String>,
311 /// Azure equivalent — signed agent-side with the pod's Workload Identity
312 /// federated token exchanged for an AAD bearer.
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub azure_endpoint_url: Option<String>,
315 /// Bearer the CLI's cloud loopbacks must present on requests to the
316 /// `*_endpoint_url`s. Same `client_token` as the kubeconfig auth.
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub cloud_proxy_token: Option<String>,
319 /// RFC3339 expiry. None when the SA token doesn't expose one.
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub expires_at: Option<String>,
322}
323
324/// Derive `(service, signing_region)` from an AWS API URL host.
325///
326/// Handles the common shapes:
327///
328/// - `<service>.<region>.amazonaws.com` → (service, region)
329/// - `<service>.amazonaws.com` → (service, fallback_region)
330/// - `<bucket>.s3.<region>.amazonaws.com` → ("s3", region)
331/// - `<bucket>.s3.amazonaws.com` → ("s3", fallback_region)
332///
333/// Falls back to `fallback_region` when the host doesn't carry one. Takes
334/// `&str` so this stays HTTP-client-agnostic.
335pub fn extract_aws_service_and_region(host: &str, fallback_region: &str) -> (&'static str, String) {
336 let labels: Vec<&str> = host.split('.').collect();
337
338 let Some(amz_idx) = labels.iter().rposition(|l| *l == "amazonaws") else {
339 return ("execute-api", fallback_region.to_string());
340 };
341
342 let (service, region) = match &labels[..amz_idx] {
343 [_bucket_or_subdomain @ .., service, region]
344 if region.contains('-') && service.len() <= 8 =>
345 {
346 (*service, region.to_string())
347 }
348 [_subdomain @ .., service] => (*service, fallback_region.to_string()),
349 _ => ("execute-api", fallback_region.to_string()),
350 };
351
352 let static_service: &'static str = match service {
353 "sts" => "sts",
354 "iam" => "iam",
355 "ec2" => "ec2",
356 "lambda" => "lambda",
357 "s3" => "s3",
358 "dynamodb" => "dynamodb",
359 "sqs" => "sqs",
360 "sns" => "sns",
361 "ecr" => "ecr",
362 "eks" => "eks",
363 "ecs" => "ecs",
364 "cloudformation" => "cloudformation",
365 "cloudwatch" => "monitoring",
366 "logs" => "logs",
367 "ssm" => "ssm",
368 "secretsmanager" => "secretsmanager",
369 "kms" => "kms",
370 "events" | "eventbridge" => "events",
371 "apigateway" => "apigateway",
372 "execute-api" => "execute-api",
373 _ => "execute-api",
374 };
375
376 (static_service, region)
377}
378
379#[cfg(test)]
380mod aws_endpoint_parsing_tests {
381 use super::extract_aws_service_and_region;
382
383 #[test]
384 fn regional_service() {
385 assert_eq!(
386 extract_aws_service_and_region("ec2.us-east-1.amazonaws.com", "us-west-2"),
387 ("ec2", "us-east-1".to_string())
388 );
389 }
390
391 #[test]
392 fn global_service() {
393 assert_eq!(
394 extract_aws_service_and_region("iam.amazonaws.com", "us-east-1"),
395 ("iam", "us-east-1".to_string())
396 );
397 }
398
399 #[test]
400 fn s3_bucket_regional() {
401 assert_eq!(
402 extract_aws_service_and_region("mybucket.s3.us-east-1.amazonaws.com", "us-west-2"),
403 ("s3", "us-east-1".to_string())
404 );
405 }
406
407 #[test]
408 fn unknown_host() {
409 assert_eq!(
410 extract_aws_service_and_region("internal.example.com", "us-east-1"),
411 ("execute-api", "us-east-1".to_string())
412 );
413 }
414}