Skip to main content

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. Renamed on the wire to
116    /// `runtimeKind` so it doesn't collide with the `DebugSessionResponse`
117    /// enum's `kind` discriminator tag (which would produce a duplicate
118    /// `kind` field and break deserialization).
119    #[serde(rename = "runtimeKind")]
120    pub kind: DebugSessionKind,
121    /// Runtime frame protocol version.
122    pub protocol_version: u32,
123    /// RFC3339 expiry.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub expires_at: Option<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct RemoteExecDebugSession {
131    /// Control-plane session id.
132    pub session_id: String,
133    /// Deployment runtime platform label.
134    pub platform: String,
135    /// Absolute HTTP(S) or WS(S) URL the CLI should attach to.
136    pub attach_url: String,
137    /// Bearer the CLI presents on the WebSocket upgrade.
138    pub client_token: String,
139    /// Whether the remote session was created with a TTY.
140    pub tty: bool,
141    /// RFC3339 expiry, if the control plane returns one.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub expires_at: Option<String>,
144}
145
146/// Client-to-agent runtime debug frames, relayed by the manager.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(tag = "type", rename_all = "camelCase")]
149pub enum RuntimeClientFrame {
150    /// Start an interactive shell with an optional initial terminal size.
151    StartShell {
152        /// Terminal columns.
153        cols: u16,
154        /// Terminal rows.
155        rows: u16,
156    },
157    /// Start a non-interactive command.
158    StartExec {
159        /// Executable and argv to run on the remote host.
160        command: Vec<String>,
161        /// Optional timeout in milliseconds.
162        timeout_ms: Option<u64>,
163    },
164    /// Standard input bytes, base64-encoded.
165    Stdin {
166        /// Base64-encoded bytes.
167        data_b64: String,
168    },
169    /// Resize the interactive terminal.
170    Resize {
171        /// Terminal columns.
172        cols: u16,
173        /// Terminal rows.
174        rows: u16,
175    },
176    /// Close stdin for the remote process.
177    CloseStdin,
178    /// Cancel the remote process.
179    Cancel,
180}
181
182/// Agent-to-client runtime debug frames, relayed by the manager.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184#[serde(tag = "type", rename_all = "camelCase")]
185pub enum RuntimeAgentFrame {
186    /// The process has started.
187    Started {
188        /// Optional process id where available.
189        pid: Option<u32>,
190        /// Human-readable account or shell description.
191        detail: Option<String>,
192    },
193    /// Standard output bytes, base64-encoded.
194    Stdout {
195        /// Base64-encoded bytes.
196        data_b64: String,
197    },
198    /// Standard error bytes, base64-encoded.
199    Stderr {
200        /// Base64-encoded bytes.
201        data_b64: String,
202    },
203    /// The process exited.
204    Exit {
205        /// Process exit code. `None` means signal/unknown.
206        code: Option<i32>,
207        /// Whether the process was terminated by the runtime timeout.
208        timed_out: bool,
209        /// Whether output was truncated.
210        output_truncated: bool,
211    },
212    /// The remote runtime failed before producing an exit code.
213    Error {
214        /// Safe, user-facing error message.
215        message: String,
216    },
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct PushTunnelDebugSession {
222    /// Server-assigned session id (`ds_…`).
223    pub session_id: String,
224    /// `aws`, `gcp`, `azure`. Drives which `_ENDPOINT_URL` env var the CLI
225    /// sets and which signing flow the manager runs.
226    pub provider: String,
227    /// Absolute `wss://…/v1/debug/sessions/<sid>/push-tunnel` URL.
228    pub tunnel_url: String,
229    /// Bearer the CLI presents on the WebSocket upgrade and on subsequent
230    /// HTTP proxy requests.
231    pub client_token: String,
232    /// RFC3339 expiry mirroring the underlying impersonated credential's TTL.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub expires_at: Option<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct PushDebugSession {
240    /// Short identifier surfaced to the user (e.g. `"aws"`, `"gcp"`, `"azure"`).
241    pub provider: String,
242    /// Environment variables the CLI sets on the spawned process.
243    pub env: BTreeMap<String, String>,
244    /// Files the CLI materializes under the per-session tempdir before exec.
245    /// When `env_var` is set, the CLI binds that env var to the resulting
246    /// absolute file path.
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub files: Vec<DebugCredFile>,
249    /// Optional shell snippet the CLI runs (`sh -c`) after env/files are set
250    /// up but before the user's command. Must be idempotent.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub setup_script: Option<String>,
253    /// RFC3339 expiry. None when the credential type doesn't expose one.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub expires_at: Option<String>,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct DebugCredFile {
261    /// Filename — no path components. Written under the per-session tempdir.
262    pub file_name: String,
263    /// File contents.
264    pub content: String,
265    /// If set, the CLI binds this env var to the file's absolute path.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub env_var: Option<String>,
268}
269
270/// Async-session handle. The CLI polls `poll_url` until the manager has
271/// received the agent's tunnel-ready signal and can return a `Pull` payload
272/// whose kubeconfig points at the per-session HTTPS proxy on the manager.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct PendingDebugSession {
276    /// Server-assigned session id. Embedded in URLs and command channel
277    /// messages so all parties (CLI, manager, agent) reference the same row.
278    pub session_id: String,
279    /// Absolute URL the CLI should GET to poll for readiness. Same auth as
280    /// `POST /v1/debug/sessions`.
281    pub poll_url: String,
282    /// Suggested initial poll interval in milliseconds. The CLI should back
283    /// off on repeated `pending` responses but never poll faster than this.
284    #[serde(default)]
285    pub poll_interval_ms: u32,
286    /// RFC3339 absolute deadline. The CLI should give up after this and
287    /// surface the most recent status. Bounded server-side; defaults to
288    /// the session TTL.
289    pub deadline: String,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase")]
294pub struct PullDebugSession {
295    /// Server-assigned session id. The CLI sends a DELETE to the manager on
296    /// exit so the agent's `serve_session` ends.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub session_id: Option<String>,
299    /// Kubeconfig YAML the CLI writes to a temp file and binds to `KUBECONFIG`.
300    pub kubeconfig: String,
301    /// Additional env vars the CLI sets alongside `KUBECONFIG`.
302    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
303    pub env: BTreeMap<String, String>,
304    /// Extra files to materialize alongside the kubeconfig.
305    #[serde(default, skip_serializing_if = "Vec::is_empty")]
306    pub files: Vec<DebugCredFile>,
307    /// When set, the CLI also spawns a local AWS loopback proxy and points
308    /// `AWS_ENDPOINT_URL` at it.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub aws_endpoint_url: Option<String>,
311    /// GCP equivalent — signed agent-side with the pod's GKE Workload Identity
312    /// token.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub gcp_endpoint_url: Option<String>,
315    /// Azure equivalent — signed agent-side with the pod's Workload Identity
316    /// federated token exchanged for an AAD bearer.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub azure_endpoint_url: Option<String>,
319    /// Bearer the CLI's cloud loopbacks must present on requests to the
320    /// `*_endpoint_url`s. Same `client_token` as the kubeconfig auth.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub cloud_proxy_token: Option<String>,
323    /// RFC3339 expiry. None when the SA token doesn't expose one.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub expires_at: Option<String>,
326}
327
328/// Derive `(service, signing_region)` from an AWS API URL host.
329///
330/// Handles the common shapes:
331///
332/// - `<service>.<region>.amazonaws.com`     → (service, region)
333/// - `<service>.amazonaws.com`              → (service, fallback_region)
334/// - `<bucket>.s3.<region>.amazonaws.com`   → ("s3", region)
335/// - `<bucket>.s3.amazonaws.com`            → ("s3", fallback_region)
336///
337/// Falls back to `fallback_region` when the host doesn't carry one. Takes
338/// `&str` so this stays HTTP-client-agnostic.
339pub fn extract_aws_service_and_region(host: &str, fallback_region: &str) -> (&'static str, String) {
340    let labels: Vec<&str> = host.split('.').collect();
341
342    let Some(amz_idx) = labels.iter().rposition(|l| *l == "amazonaws") else {
343        return ("execute-api", fallback_region.to_string());
344    };
345
346    let (service, region) = match &labels[..amz_idx] {
347        [_bucket_or_subdomain @ .., service, region]
348            if region.contains('-') && service.len() <= 8 =>
349        {
350            (*service, region.to_string())
351        }
352        [_subdomain @ .., service] => (*service, fallback_region.to_string()),
353        _ => ("execute-api", fallback_region.to_string()),
354    };
355
356    let static_service: &'static str = match service {
357        "sts" => "sts",
358        "iam" => "iam",
359        "ec2" => "ec2",
360        "lambda" => "lambda",
361        "s3" => "s3",
362        "dynamodb" => "dynamodb",
363        "sqs" => "sqs",
364        "sns" => "sns",
365        "ecr" => "ecr",
366        "eks" => "eks",
367        "ecs" => "ecs",
368        "cloudformation" => "cloudformation",
369        "cloudwatch" => "monitoring",
370        "logs" => "logs",
371        "ssm" => "ssm",
372        "secretsmanager" => "secretsmanager",
373        "kms" => "kms",
374        "events" | "eventbridge" => "events",
375        "apigateway" => "apigateway",
376        "execute-api" => "execute-api",
377        _ => "execute-api",
378    };
379
380    (static_service, region)
381}
382
383#[cfg(test)]
384mod aws_endpoint_parsing_tests {
385    use super::extract_aws_service_and_region;
386
387    #[test]
388    fn regional_service() {
389        assert_eq!(
390            extract_aws_service_and_region("ec2.us-east-1.amazonaws.com", "us-west-2"),
391            ("ec2", "us-east-1".to_string())
392        );
393    }
394
395    #[test]
396    fn global_service() {
397        assert_eq!(
398            extract_aws_service_and_region("iam.amazonaws.com", "us-east-1"),
399            ("iam", "us-east-1".to_string())
400        );
401    }
402
403    #[test]
404    fn s3_bucket_regional() {
405        assert_eq!(
406            extract_aws_service_and_region("mybucket.s3.us-east-1.amazonaws.com", "us-west-2"),
407            ("s3", "us-east-1".to_string())
408        );
409    }
410
411    #[test]
412    fn unknown_host() {
413        assert_eq!(
414            extract_aws_service_and_region("internal.example.com", "us-east-1"),
415            ("execute-api", "us-east-1".to_string())
416        );
417    }
418}