From 7fd6fa6aaa522e7a782a70bdab5bfd922b7d6890 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 09:15:37 -0800 Subject: [PATCH] feat(aws): environment record, agent token, access point wait and resource removal RuntimeApi (GetParameter, GetRole, GetSecretValue, DescribeAccessPoints, task definition retirement, DeleteSecret with a 7-day window, DeleteAccessPoint) with the decisions in tested functions over it. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/environments.rs | 2 +- app/src-tauri/src/aws/runtime.rs | 498 +++++++++++++++++++++++++ app/src-tauri/src/aws/runtime/tests.rs | 341 +++++++++++++++++ 3 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 app/src-tauri/src/aws/runtime.rs create mode 100644 app/src-tauri/src/aws/runtime/tests.rs diff --git a/app/src-tauri/src/aws/environments.rs b/app/src-tauri/src/aws/environments.rs index 48d1304..2320a75 100644 --- a/app/src-tauri/src/aws/environments.rs +++ b/app/src-tauri/src/aws/environments.rs @@ -754,7 +754,7 @@ fn error_parts( } /// Yes / No (missing) / Unknown (denied) from a lookup's result. -fn knowable( +pub(crate) fn knowable( action: &str, res: Result>, ) -> Result diff --git a/app/src-tauri/src/aws/runtime.rs b/app/src-tauri/src/aws/runtime.rs new file mode 100644 index 0000000..58c8cad --- /dev/null +++ b/app/src-tauri/src/aws/runtime.rs @@ -0,0 +1,498 @@ +//! What the AWS host asks AWS around a task, besides running it +//! (`tasks.rs`) and preparing its resources (`workspace.rs`): the +//! environment's record, whether the caller is onboarded, the agent token of +//! a running task (P2), waiting for a new access point, and removing a +//! project's resources. +//! +//! Like the other `aws/` modules, the decisions are made by the functions +//! here over a small trait ([`RuntimeApi`]) so a fake exercises them; the SDK +//! calls are in [`SdkRuntimeApi`]. + +use std::future::Future; +use std::time::Duration; + +use aws_config::SdkConfig; +use aws_sdk_secretsmanager::error::ProvideErrorMetadata; + +use super::environments::{knowable, parse_environment_record, EnvironmentInfo, Knowable}; +use super::error::{classify_sdk_error, AwsError}; +use super::tasks::{find_project_task, TaskApi, STOP_REASON}; +use super::tofu_modules::{ + access_point_root, environment_parameter_name, project_secret_name, task_family, + user_file_system_token, user_role_names, +}; +use super::workspace::WorkspaceApi; + +/// The secret key holding the agent token (Task 4's payload). +pub const AGENT_TOKEN_KEY: &str = "TRIPLEC_AGENT_TOKEN"; +/// Secrets Manager's recovery window when a project is removed. +pub const SECRET_RECOVERY_DAYS: i64 = 7; +/// A project secret is at most 64 KiB; anything bigger is not ours. +const MAX_SECRET_READ: usize = 70_000; + +fn other(message: impl Into) -> AwsError { + AwsError::Other { + message: message.into(), + } +} + +pub trait RuntimeApi: Send + Sync { + /// `ssm:GetParameter` of an environment's record; `None` when absent. + fn environment_record( + &self, + env: &str, + ) -> impl Future, AwsError>> + Send; + /// `iam:GetRole`. + fn role_exists(&self, name: &str) -> impl Future> + Send; + /// `secretsmanager:GetSecretValue`'s `SecretString`. + fn secret_string(&self, name: &str) -> impl Future> + Send; + /// The access point's `LifeCycleState`; `None` when it is gone. + fn access_point_state( + &self, + id: &str, + ) -> impl Future, AwsError>> + Send; + fn deregister_task_definition( + &self, + arn: &str, + ) -> impl Future> + Send; + fn delete_task_definitions( + &self, + arns: &[String], + ) -> impl Future> + Send; + /// Schedule deletion with a [`SECRET_RECOVERY_DAYS`] window. A secret + /// that does not exist is success. + fn delete_secret(&self, name: &str) -> impl Future> + Send; + /// A missing access point is success. + fn delete_access_point(&self, id: &str) -> impl Future> + Send; +} + +// ------------------------------------------------------------ environment + +/// The environment's record, read with one `GetParameter` and validated. +pub async fn read_environment( + rt: &R, + env: &str, + account: &str, + region: &str, +) -> Result { + let value = rt.environment_record(env).await?.ok_or_else(|| { + other(format!( + "The environment \"{env}\" was not found in {region}. It may have been removed; \ + check Settings → Hosts." + )) + })?; + parse_environment_record(env, &value, account, region).map_err(|e| { + other(format!( + "The record of environment \"{env}\" in {region} is not usable: {e}" + )) + }) +} + +/// Whether the caller's task role exists in the environment: the onboarding +/// signal a workspace user can read. A denied lookup is not an answer, so +/// it is let through (RunTask will say what is wrong). +pub async fn check_onboarded( + rt: &R, + env: &str, + owner_key: &str, +) -> Result<(), AwsError> { + let (task_role, _) = user_role_names(env, owner_key); + match rt.role_exists(&task_role).await? { + Knowable::No => Err(other(format!( + "You have not joined the environment \"{env}\" yet. Join it in Settings → Hosts \ + (or ask an administrator to onboard you), then start the project again." + ))), + Knowable::Yes | Knowable::Unknown => Ok(()), + } +} + +/// The agent token in a project secret's JSON. The error never echoes the +/// secret. +pub fn agent_token_from_secret(json: &str) -> Result { + if json.len() > MAX_SECRET_READ { + return Err("The project secret is larger than a project secret can be".into()); + } + let v: serde_json::Value = serde_json::from_str(json) + .map_err(|_| "The project secret is not a JSON object".to_string())?; + v.get(AGENT_TOKEN_KEY) + .and_then(|t| t.as_str()) + .filter(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_graphic())) + .map(str::to_string) + .ok_or_else(|| "The project secret holds no agent token".to_string()) +} + +/// The token a running task of this project started with (P2: the caller +/// may read their own secret; `prepare_workspace` wrote it before RunTask). +pub async fn read_agent_token( + rt: &R, + env: &str, + owner_key: &str, + project_id: &str, +) -> Result { + let name = project_secret_name(env, owner_key, project_id); + let json = rt.secret_string(&name).await?; + agent_token_from_secret(&json).map_err(other) +} + +/// How long a fresh access point may take to become `available`. +#[derive(Debug, Clone, Copy)] +pub struct PollTiming { + pub interval: Duration, + pub timeout: Duration, +} + +impl Default for PollTiming { + fn default() -> Self { + PollTiming { + interval: Duration::from_secs(2), + timeout: Duration::from_secs(90), + } + } +} + +/// Wait until the access point is `available` (a task mounting one that is +/// still `creating` fails to start). +pub async fn wait_access_point( + rt: &R, + id: &str, + timing: PollTiming, +) -> Result<(), AwsError> { + let deadline = tokio::time::Instant::now() + timing.timeout; + loop { + match rt.access_point_state(id).await?.as_deref() { + Some("available") => return Ok(()), + Some("creating") | Some("updating") => {} + Some(s) => { + return Err(other(format!( + "The project's EFS access point {id} is {s}; start the project again" + ))) + } + None => { + return Err(other(format!( + "The project's EFS access point {id} disappeared; start the project again" + ))) + } + } + if tokio::time::Instant::now() + timing.interval > deadline { + return Err(other(format!( + "The project's EFS access point {id} did not become available in time; \ + start the project again in a minute" + ))); + } + tokio::time::sleep(timing.interval).await; + } +} + +// ------------------------------------------------------------------ ARNs + +/// The cluster *name* of `arn:...:task//` (ECS accepts a name +/// wherever it takes a cluster). +pub fn cluster_of_task(task_arn: &str) -> Option<&str> { + let (_, rest) = task_arn.split_once(":task/")?; + let (cluster, id) = rest.split_once('/')?; + (task_arn.starts_with("arn:") && !cluster.is_empty() && !id.is_empty() && !id.contains('/')) + .then_some(cluster) +} + +/// The cluster ARN of a task ARN (same partition, region and account). +pub fn cluster_arn_of_task(task_arn: &str) -> Option { + let cluster = cluster_of_task(task_arn)?; + let (prefix, _) = task_arn.split_once(":task/")?; + Some(format!("{prefix}:cluster/{cluster}")) +} + +// ---------------------------------------------------------------- removal + +/// What removing a project's AWS resources needs to know. +pub struct RemoveRequest<'a> { + pub env: &'a str, + pub region: &'a str, + pub owner_user_id: &'a str, + pub owner_key: &'a str, + pub project_id: &'a str, + /// The task ARN the store has, if any (stopped too, in case the listing + /// missed it). + pub stored_task_arn: Option<&'a str>, + pub stored_access_point_id: Option<&'a str>, + /// Delete the EFS access point. Its data stays on the file system until + /// an administrator removes it; the path is not reused. + pub discard_data: bool, +} + +/// Stop the project's task, retire its task definition revisions, schedule +/// its secret for deletion (7 days) and, with `discard_data`, delete its +/// access point. A task that can't be stopped stops the removal (nothing +/// billed may be left running); the rest is attempted in full and every +/// failure reported. +pub async fn remove_workspace_resources( + tasks: &T, + work: &W, + rt: &R, + req: &RemoveRequest<'_>, +) -> Result<(), AwsError> { + let cluster = format!("triple-c-{}", req.env); + let family = task_family(req.env, req.owner_key, req.project_id); + + // 1. The task. + let live = + find_project_task(tasks, &cluster, &family, req.owner_user_id, req.project_id).await?; + let mut to_stop: Vec = live.into_iter().map(|t| t.arn).collect(); + if let Some(arn) = req.stored_task_arn { + if cluster_of_task(arn) == Some(cluster.as_str()) && !to_stop.iter().any(|a| a == arn) { + to_stop.push(arn.to_string()); + } + } + for arn in &to_stop { + tasks.stop_task(&cluster, arn, STOP_REASON).await?; + } + + let mut failures: Vec = Vec::new(); + + // 2. Task definition revisions the caller registered for this project. + let mut retired = Vec::new(); + match work.task_definition_revisions(&family).await { + Ok(revisions) => { + for arn in revisions { + match work.describe_task_definition(&arn).await { + Ok(Some(d)) + if d.owner.as_deref() == Some(req.owner_user_id) + && d.project.as_deref() == Some(req.project_id) => + { + match rt.deregister_task_definition(&d.arn).await { + Ok(()) => retired.push(d.arn), + Err(e) => failures.push(format!("task definition {}: {e}", d.arn)), + } + } + Ok(_) => {} + Err(e) => failures.push(format!("task definition {arn}: {e}")), + } + } + } + Err(e) => failures.push(format!("task definitions: {e}")), + } + // DeleteTaskDefinitions takes at most 10 at a time. + for chunk in retired.chunks(10) { + if let Err(e) = rt.delete_task_definitions(chunk).await { + failures.push(format!("deleting retired task definitions: {e}")); + } + } + + // 3. The secret. + let secret = project_secret_name(req.env, req.owner_key, req.project_id); + if let Err(e) = rt.delete_secret(&secret).await { + failures.push(format!("secret {secret}: {e}")); + } + + // 4. The access point, only when asked. + if req.discard_data { + let mut ids: Vec = req.stored_access_point_id.map(String::from).into_iter().collect(); + let token = user_file_system_token(req.env, req.owner_key); + match work.find_file_system(&token).await { + Ok(Some(fs)) if fs.owner.as_deref() == Some(req.owner_user_id) => { + let root = access_point_root(req.owner_key, req.project_id); + match work.access_points(&fs.id).await { + Ok(points) => ids.extend( + points + .into_iter() + .filter(|a| { + a.file_system_id == fs.id + && a.root_path.as_deref() == Some(root.as_str()) + && a.owner.as_deref() == Some(req.owner_user_id) + && a.project.as_deref() == Some(req.project_id) + }) + .map(|a| a.id), + ), + Err(e) => failures.push(format!("access points: {e}")), + } + } + Ok(_) => {} + Err(e) => failures.push(format!("file system: {e}")), + } + ids.sort(); + ids.dedup(); + for id in ids { + if let Err(e) = rt.delete_access_point(&id).await { + failures.push(format!("access point {id}: {e}")); + } + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(other(format!( + "Some of the project's AWS resources in {} could not be removed: {}", + req.region, + failures.join("; ") + ))) + } +} + +// ---------------------------------------------------------------- real API + +pub struct SdkRuntimeApi { + ssm: aws_sdk_ssm::Client, + iam: aws_sdk_iam::Client, + secrets: aws_sdk_secretsmanager::Client, + efs: aws_sdk_efs::Client, + ecs: aws_sdk_ecs::Client, +} + +impl SdkRuntimeApi { + pub fn new(cfg: &SdkConfig) -> Self { + SdkRuntimeApi { + ssm: aws_sdk_ssm::Client::new(cfg), + iam: aws_sdk_iam::Client::new(cfg), + secrets: aws_sdk_secretsmanager::Client::new(cfg), + efs: aws_sdk_efs::Client::new(cfg), + ecs: aws_sdk_ecs::Client::new(cfg), + } + } +} + +impl RuntimeApi for SdkRuntimeApi { + async fn environment_record(&self, env: &str) -> Result, AwsError> { + const ACTION: &str = "ssm:GetParameter"; + match self + .ssm + .get_parameter() + .name(environment_parameter_name(env)) + .with_decryption(false) + .send() + .await + { + Ok(out) => Ok(out.parameter().and_then(|p| p.value()).map(str::to_string)), + Err(e) + if e.as_service_error() + .is_some_and(|s| s.is_parameter_not_found()) => + { + Ok(None) + } + Err(e) => Err(classify_sdk_error(ACTION, &e)), + } + } + + async fn role_exists(&self, name: &str) -> Result { + let res = self.iam.get_role().role_name(name).send().await; + knowable("iam:GetRole", res) + } + + async fn secret_string(&self, name: &str) -> Result { + const ACTION: &str = "secretsmanager:GetSecretValue"; + let out = self + .secrets + .get_secret_value() + .secret_id(name) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + out.secret_string() + .map(str::to_string) + .ok_or_else(|| other(format!("{ACTION} returned no SecretString"))) + } + + async fn access_point_state(&self, id: &str) -> Result, AwsError> { + const ACTION: &str = "elasticfilesystem:DescribeAccessPoints"; + match self.efs.describe_access_points().access_point_id(id).send().await { + Ok(out) => Ok(out + .access_points() + .iter() + .find(|a| a.access_point_id() == Some(id)) + .and_then(|a| a.life_cycle_state()) + .map(|s| s.as_str().to_string())), + Err(e) + if e.as_service_error() + .is_some_and(|s| s.is_access_point_not_found()) => + { + Ok(None) + } + Err(e) => Err(classify_sdk_error(ACTION, &e)), + } + } + + async fn deregister_task_definition(&self, arn: &str) -> Result<(), AwsError> { + const ACTION: &str = "ecs:DeregisterTaskDefinition"; + self.ecs + .deregister_task_definition() + .task_definition(arn) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + Ok(()) + } + + async fn delete_task_definitions(&self, arns: &[String]) -> Result<(), AwsError> { + const ACTION: &str = "ecs:DeleteTaskDefinitions"; + let out = self + .ecs + .delete_task_definitions() + .set_task_definitions(Some(arns.to_vec())) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + let failed: Vec = out + .failures() + .iter() + .map(|f| { + format!( + "{} ({})", + f.arn().unwrap_or("?"), + f.reason().unwrap_or("no reason given") + ) + }) + .collect(); + if failed.is_empty() { + Ok(()) + } else { + Err(other(format!("{ACTION} failed for {}", failed.join(", ")))) + } + } + + async fn delete_secret(&self, name: &str) -> Result<(), AwsError> { + const ACTION: &str = "secretsmanager:DeleteSecret"; + match self + .secrets + .delete_secret() + .secret_id(name) + .force_delete_without_recovery(false) + .recovery_window_in_days(SECRET_RECOVERY_DAYS) + .send() + .await + { + Ok(_) => Ok(()), + Err(e) + if e.as_service_error() + .is_some_and(|s| s.is_resource_not_found_exception()) => + { + Ok(()) + } + // Already scheduled: the goal. + Err(e) + if e.as_service_error().is_some_and(|s| { + s.is_invalid_request_exception() + && s.message().is_some_and(|m| m.contains("scheduled for deletion")) + }) => + { + Ok(()) + } + Err(e) => Err(classify_sdk_error(ACTION, &e)), + } + } + + async fn delete_access_point(&self, id: &str) -> Result<(), AwsError> { + const ACTION: &str = "elasticfilesystem:DeleteAccessPoint"; + match self.efs.delete_access_point().access_point_id(id).send().await { + Ok(_) => Ok(()), + Err(e) + if e.as_service_error() + .is_some_and(|s| s.is_access_point_not_found()) => + { + Ok(()) + } + Err(e) => Err(classify_sdk_error(ACTION, &e)), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/aws/runtime/tests.rs b/app/src-tauri/src/aws/runtime/tests.rs new file mode 100644 index 0000000..7c3334c --- /dev/null +++ b/app/src-tauri/src/aws/runtime/tests.rs @@ -0,0 +1,341 @@ +use std::sync::Mutex; + +use aws_sdk_ecs::operation::register_task_definition::RegisterTaskDefinitionInput; +use aws_sdk_ecs::operation::run_task::RunTaskInput; + +use super::*; +use crate::aws::tasks::TaskInfo; +use crate::aws::tofu_modules::owner_key; +use crate::aws::workspace::{AccessPointRef, FileSystemRef, TaskDefinitionRef}; + +const REGION: &str = "eu-west-1"; +const ACCOUNT: &str = "111122223333"; +const USER: &str = "AROAEXAMPLEID:alice@example.com"; +const PROJECT: &str = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + +fn ok() -> String { + owner_key(REGION, USER) +} + +fn family() -> String { + task_family("dev", &ok(), PROJECT) +} + +fn task_arn(id: &str) -> String { + format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task/triple-c-dev/{id}") +} + +#[derive(Default)] +struct Fake { + calls: Mutex>, + record: Option, + role: Option, + secret: Option, + ap_states: Mutex>>, + live_tasks: Vec, + revisions: Vec, + access_points: Vec, + fail_secret_delete: bool, +} + +impl Fake { + fn log(&self, s: String) { + self.calls.lock().unwrap().push(s); + } + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } +} + +impl RuntimeApi for Fake { + async fn environment_record(&self, env: &str) -> Result, AwsError> { + self.log(format!("record {env}")); + Ok(self.record.clone()) + } + async fn role_exists(&self, name: &str) -> Result { + self.log(format!("role {name}")); + Ok(self.role.clone().unwrap_or(Knowable::Yes)) + } + async fn secret_string(&self, name: &str) -> Result { + self.log(format!("secret {name}")); + self.secret.clone().ok_or_else(|| other("no secret")) + } + async fn access_point_state(&self, id: &str) -> Result, AwsError> { + self.log(format!("ap {id}")); + let mut s = self.ap_states.lock().unwrap(); + Ok(if s.len() > 1 { s.remove(0) } else { s.first().cloned().flatten() }) + } + async fn deregister_task_definition(&self, arn: &str) -> Result<(), AwsError> { + self.log(format!("deregister {arn}")); + Ok(()) + } + async fn delete_task_definitions(&self, arns: &[String]) -> Result<(), AwsError> { + self.log(format!("delete-td {}", arns.join(","))); + Ok(()) + } + async fn delete_secret(&self, name: &str) -> Result<(), AwsError> { + self.log(format!("delete-secret {name}")); + if self.fail_secret_delete { + return Err(other("denied")); + } + Ok(()) + } + async fn delete_access_point(&self, id: &str) -> Result<(), AwsError> { + self.log(format!("delete-ap {id}")); + Ok(()) + } +} + +impl TaskApi for Fake { + async fn run_task(&self, _req: RunTaskInput) -> Result { + unreachable!("removal never runs a task") + } + async fn describe(&self, _c: &str, arns: &[String]) -> Result, AwsError> { + Ok(self.live_tasks.iter().filter(|t| arns.contains(&t.arn)).cloned().collect()) + } + async fn list_project_tasks(&self, _c: &str, _f: &str) -> Result, AwsError> { + Ok(self.live_tasks.iter().map(|t| t.arn.clone()).collect()) + } + async fn stop_task(&self, cluster: &str, arn: &str, reason: &str) -> Result<(), AwsError> { + self.log(format!("stop {cluster} {arn} {reason}")); + Ok(()) + } + async fn log_tail(&self, _g: &str, _s: &str, _l: i32) -> Result, AwsError> { + Ok(Vec::new()) + } +} + +impl WorkspaceApi for Fake { + async fn find_file_system(&self, _token: &str) -> Result, AwsError> { + Ok(Some(FileSystemRef { id: "fs-1".into(), owner: Some(USER.into()) })) + } + async fn access_points(&self, _fs: &str) -> Result, AwsError> { + Ok(self.access_points.clone()) + } + async fn create_access_point( + &self, + _fs: &str, + _root: &str, + _tags: &[(String, String)], + ) -> Result { + unreachable!() + } + async fn put_project_secret( + &self, + _n: &str, + _k: &str, + _j: &str, + _t: &[(String, String)], + ) -> Result { + unreachable!() + } + async fn task_definition_revisions(&self, _f: &str) -> Result, AwsError> { + Ok(self.revisions.iter().map(|r| r.arn.clone()).collect()) + } + async fn describe_task_definition( + &self, + arn: &str, + ) -> Result, AwsError> { + Ok(self.revisions.iter().find(|r| r.arn == arn).cloned()) + } + async fn register_task_definition( + &self, + _req: RegisterTaskDefinitionInput, + ) -> Result { + unreachable!() + } +} + +fn record() -> String { + serde_json::json!({ + "schema": 1, "name": "dev", "template_version": 1, + "cluster_arn": format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev"), + "security_group_id": "sg-0123456789abcdef0", + "subnet_ids": ["subnet-0a", "subnet-0b"], + "assign_public_ip": true, + "kms_key_arn": format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234-abcd"), + "log_group": "/triple-c/dev", "network_mode": "public", "tenancy": "strict" + }) + .to_string() +} + +#[tokio::test] +async fn the_environment_is_read_with_one_lookup_and_validated() { + let fake = Fake { record: Some(record()), ..Default::default() }; + let env = read_environment(&fake, "dev", ACCOUNT, REGION).await.unwrap(); + assert_eq!(env.log_group, "/triple-c/dev"); + assert_eq!(fake.calls(), vec!["record dev"]); + + let missing = Fake::default(); + let e = read_environment(&missing, "dev", ACCOUNT, REGION).await.unwrap_err(); + assert!(e.to_string().contains("was not found"), "{e}"); + + // A record from another account is refused, by field name only. + let foreign = Fake { record: Some(record()), ..Default::default() }; + let e = read_environment(&foreign, "dev", "999999999999", REGION).await.unwrap_err(); + assert!(e.to_string().contains("cluster_arn"), "{e}"); +} + +#[tokio::test] +async fn a_missing_task_role_means_not_joined_and_a_denied_lookup_lets_the_start_try() { + let no = Fake { role: Some(Knowable::No), ..Default::default() }; + let e = check_onboarded(&no, "dev", &ok()).await.unwrap_err(); + assert!(e.to_string().contains("not joined"), "{e}"); + assert_eq!(no.calls(), vec![format!("role triple-c-dev-{}-task", ok())]); + let unknown = Fake { role: Some(Knowable::Unknown), ..Default::default() }; + assert!(check_onboarded(&unknown, "dev", &ok()).await.is_ok()); +} + +#[test] +fn the_agent_token_is_read_from_the_secret_without_echoing_it() { + let json = r#"{"TRIPLEC_AGENT_TOKEN":"abc-DEF_123","OTHER":"SENTINEL-VALUE-7f3a"}"#; + assert_eq!(agent_token_from_secret(json).unwrap(), "abc-DEF_123"); + for bad in [ + r#"{"OTHER":"SENTINEL-VALUE-7f3a"}"#, + r#"{"TRIPLEC_AGENT_TOKEN":""}"#, + r#"{"TRIPLEC_AGENT_TOKEN":"has space"}"#, + "SENTINEL-VALUE-7f3a", + ] { + let e = agent_token_from_secret(bad).unwrap_err(); + assert!(!e.contains("SENTINEL") && !e.contains("space"), "{e}"); + } +} + +#[tokio::test] +async fn the_token_comes_from_the_projects_own_secret() { + let fake = Fake { + secret: Some(r#"{"TRIPLEC_AGENT_TOKEN":"tok"}"#.into()), + ..Default::default() + }; + assert_eq!(read_agent_token(&fake, "dev", &ok(), PROJECT).await.unwrap(), "tok"); + assert_eq!(fake.calls(), vec![format!("secret triple-c/dev/{}/{PROJECT}", ok())]); +} + +#[tokio::test(start_paused = true)] +async fn a_new_access_point_is_waited_for_until_available() { + let fake = Fake { + ap_states: Mutex::new(vec![Some("creating".into()), Some("creating".into()), Some("available".into())]), + ..Default::default() + }; + wait_access_point(&fake, "fsap-1", PollTiming::default()).await.unwrap(); + assert_eq!(fake.calls().len(), 3); + + let stuck = Fake { ap_states: Mutex::new(vec![Some("creating".into())]), ..Default::default() }; + let e = wait_access_point(&stuck, "fsap-1", PollTiming::default()).await.unwrap_err(); + assert!(e.to_string().contains("did not become available"), "{e}"); + + let gone = Fake { ap_states: Mutex::new(vec![Some("deleting".into())]), ..Default::default() }; + assert!(wait_access_point(&gone, "fsap-1", PollTiming::default()).await.is_err()); +} + +#[test] +fn task_arns_name_their_cluster() { + let arn = task_arn("abc123"); + assert_eq!(cluster_of_task(&arn), Some("triple-c-dev")); + assert_eq!( + cluster_arn_of_task(&arn).as_deref(), + Some(format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev").as_str()) + ); + assert_eq!(cluster_of_task("arn:aws:ecs:r:a:task/abc"), None, "old format, no cluster"); + assert_eq!(cluster_of_task("task/triple-c-dev/abc"), None); + assert_eq!(cluster_of_task("arn:aws:ecs:r:a:task/c/a/b"), None); +} + +fn live(id: &str) -> TaskInfo { + TaskInfo { + arn: task_arn(id), + last_status: "RUNNING".into(), + desired_status: "RUNNING".into(), + health: None, + stop_code: None, + stopped_reason: None, + task_definition_arn: format!( + "arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:2", + family() + ), + runtime_id: None, + exec_agent_running: false, + owner: Some(USER.into()), + project: Some(PROJECT.into()), + } +} + +fn rev(n: u32, owner: &str) -> TaskDefinitionRef { + TaskDefinitionRef { + arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:{n}", family()), + family: family(), + owner: Some(owner.into()), + project: Some(PROJECT.into()), + fingerprint: None, + } +} + +#[tokio::test] +async fn removal_stops_the_task_retires_own_revisions_and_schedules_the_secret() { + let fake = Fake { + live_tasks: vec![live("t1")], + revisions: vec![rev(2, USER), rev(3, "AROAOTHER:mallory"), rev(1, USER)], + access_points: vec![AccessPointRef { + id: "fsap-found".into(), + file_system_id: "fs-1".into(), + root_path: Some(access_point_root(&ok(), PROJECT)), + owner: Some(USER.into()), + project: Some(PROJECT.into()), + state: Some("available".into()), + }], + ..Default::default() + }; + let key = ok(); + let t0 = task_arn("t0"); + let mut r = RemoveRequest { + env: "dev", + region: REGION, + owner_user_id: USER, + owner_key: &key, + project_id: PROJECT, + stored_task_arn: Some(&t0), + stored_access_point_id: Some("fsap-stored"), + discard_data: false, + }; + remove_workspace_resources(&fake, &fake, &fake, &r).await.unwrap(); + let calls = fake.calls(); + let secret = project_secret_name("dev", &ok(), PROJECT); + assert_eq!( + calls, + vec![ + format!("stop triple-c-dev {} {STOP_REASON}", task_arn("t1")), + format!("stop triple-c-dev {} {STOP_REASON}", task_arn("t0")), + format!("deregister {}", rev(2, USER).arn), + format!("deregister {}", rev(1, USER).arn), + format!("delete-td {},{}", rev(2, USER).arn, rev(1, USER).arn), + format!("delete-secret {secret}"), + ], + "another user's revision is left alone and the access point is kept" + ); + + let keep = Fake { access_points: fake.access_points.clone(), ..Default::default() }; + r.discard_data = true; + remove_workspace_resources(&keep, &keep, &keep, &r).await.unwrap(); + let calls = keep.calls(); + assert!(calls.contains(&"delete-ap fsap-found".to_string()), "{calls:?}"); + assert!(calls.contains(&"delete-ap fsap-stored".to_string()), "{calls:?}"); +} + +#[tokio::test] +async fn a_failed_step_is_reported_but_the_rest_still_runs() { + let fake = Fake { fail_secret_delete: true, ..Default::default() }; + let key = ok(); + let r = RemoveRequest { + env: "dev", + region: REGION, + owner_user_id: USER, + owner_key: &key, + project_id: PROJECT, + stored_task_arn: None, + stored_access_point_id: Some("fsap-1"), + discard_data: true, + }; + let e = remove_workspace_resources(&fake, &fake, &fake, &r).await.unwrap_err(); + assert!(e.to_string().contains("secret"), "{e}"); + assert!(fake.calls().contains(&"delete-ap fsap-1".to_string())); +}