From f84280c714a67e41b462f9f6118627e1cdc36687 Mon Sep 17 00:00:00 2001 From: Kiro Date: Tue, 19 May 2026 03:44:15 +0000 Subject: [PATCH 1/4] feat(operator): add Discord auto-registration (--auto-register) When --auto-register is passed to oabctl apply: 1. Loads Discord developer token from env or SSM 2. Creates Discord application + bot via Discord API (idempotent by name) 3. Stores bot token in SSM at /oab/{ns}/{name}/discord-token 4. Adds DISCORD_TOKEN secret to ECS task definition 5. Returns OAuth invite URLs for each provisioned bot Usage: DISCORD_DEVELOPER_TOKEN=xxx oabctl apply -f agents/ --auto-register --- operator/Cargo.toml | 2 + operator/src/apply.rs | 95 ++++++++++++++++++++++++++++-- operator/src/discord.rs | 125 ++++++++++++++++++++++++++++++++++++++++ operator/src/main.rs | 6 +- 4 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 operator/src/discord.rs diff --git a/operator/Cargo.toml b/operator/Cargo.toml index 820540a2..14ff1daf 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -14,7 +14,9 @@ aws-sdk-ecs = "1.53" aws-sdk-s3 = "1.65" aws-sdk-ssm = "1.52" clap = { version = "4.5", features = ["derive"] } +reqwest = { version = "0.12", features = ["json"] } serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" serde_yaml = "0.9" tokio = { version = "1.40", features = ["full"] } toml = "0.8" diff --git a/operator/src/apply.rs b/operator/src/apply.rs index 868a1b4f..532027ba 100644 --- a/operator/src/apply.rs +++ b/operator/src/apply.rs @@ -1,4 +1,5 @@ use crate::manifest::OABServiceManifest; +use crate::discord; use anyhow::{Context, Result}; use aws_sdk_ecs::types::{ AssignPublicIp, AwsVpcConfiguration, CapacityProviderStrategyItem, ContainerDefinition, @@ -7,7 +8,7 @@ use aws_sdk_ecs::types::{ use aws_sdk_s3::primitives::ByteStream; use std::path::Path; -pub async fn run(aws_config: &aws_config::SdkConfig, file_path: &str) -> Result<()> { +pub async fn run(aws_config: &aws_config::SdkConfig, file_path: &str, auto_register: bool) -> Result<()> { let path = Path::new(file_path); let manifests = load_manifests(path)?; @@ -17,17 +18,60 @@ pub async fn run(aws_config: &aws_config::SdkConfig, file_path: &str) -> Result< let ecs = aws_sdk_ecs::Client::new(aws_config); let s3 = aws_sdk_s3::Client::new(aws_config); + let ssm = aws_sdk_ssm::Client::new(aws_config); + + // Load Discord developer token if auto-register is enabled + let discord_token = if auto_register { + Some(get_discord_developer_token(&ssm).await?) + } else { + None + }; + + let mut invite_urls: Vec<(String, String)> = Vec::new(); for m in &manifests { m.validate()?; println!(" Applying {}...", m.metadata.name); - apply_one(&ecs, &s3, m).await?; + + let invite = apply_one(&ecs, &s3, &ssm, m, discord_token.as_deref()).await?; + if let Some(url) = invite { + invite_urls.push((m.metadata.name.clone(), url)); + } } println!("\n{} service(s) applied.", manifests.len()); + + if !invite_urls.is_empty() { + println!("\nšŸ“Ž Discord Bot Invite URLs:"); + for (name, url) in &invite_urls { + println!(" {} → {}", name, url); + } + println!("\nPaste these URLs into your browser to add bots to your Discord server."); + } + Ok(()) } +async fn get_discord_developer_token(ssm: &aws_sdk_ssm::Client) -> Result { + // Try env var first, then SSM + if let Ok(token) = std::env::var("DISCORD_DEVELOPER_TOKEN") { + return Ok(token); + } + + let resp = ssm + .get_parameter() + .name("/oab/discord-developer-token") + .with_decryption(true) + .send() + .await + .context("failed to get Discord developer token from SSM (set DISCORD_DEVELOPER_TOKEN env var or store in SSM at /oab/discord-developer-token)")?; + + resp.parameter() + .and_then(|p| p.value()) + .map(|v| v.to_string()) + .context("Discord developer token parameter has no value") +} + fn load_manifests(path: &Path) -> Result> { let mut manifests = Vec::new(); if path.is_dir() { @@ -54,8 +98,10 @@ fn parse_manifest(path: &Path) -> Result { async fn apply_one( ecs: &aws_sdk_ecs::Client, s3: &aws_sdk_s3::Client, + ssm: &aws_sdk_ssm::Client, m: &OABServiceManifest, -) -> Result<()> { + discord_token: Option<&str>, +) -> Result> { let service_name = m.ecs_service_name(); let bucket = "oab-control-plane"; @@ -71,6 +117,33 @@ async fn apply_one( }; let generation = current_gen + 1; + // Auto-register Discord bot if --auto-register and no DISCORD_TOKEN secret defined + let mut invite_url = None; + let has_discord_secret = m.spec.secrets.iter().any(|s| s.name == "DISCORD_TOKEN"); + + if let Some(token) = discord_token { + if !has_discord_secret { + let bot_name = format!("oab-{}", m.metadata.name); + println!(" Registering Discord bot '{}'...", bot_name); + + let bot = discord::provision_bot(token, &bot_name).await?; + + // Store token in SSM + let ssm_path = format!("/oab/{}/{}/discord-token", m.metadata.namespace, m.metadata.name); + ssm.put_parameter() + .name(&ssm_path) + .value(&bot.bot_token) + .r#type(aws_sdk_ssm::types::ParameterType::SecureString) + .overwrite(true) + .send() + .await + .context("failed to store Discord bot token in SSM")?; + + println!(" āœ“ Bot registered, token stored at {}", ssm_path); + invite_url = Some(bot.invite_url); + } + } + // 1. Render config.toml and upload to S3 (immutable path) let config_toml = render_config_toml(&m.spec.config); let config_key = format!( @@ -125,7 +198,7 @@ async fn apply_one( ); } - let secrets: Vec = m + let mut secrets: Vec = m .spec .secrets .iter() @@ -138,6 +211,18 @@ async fn apply_one( }) .collect(); + // If auto-registered, add the Discord token secret + if discord_token.is_some() && !has_discord_secret { + let ssm_path = format!("/oab/{}/{}/discord-token", m.metadata.namespace, m.metadata.name); + secrets.push( + Secret::builder() + .name("DISCORD_TOKEN") + .value_from(&ssm_path) + .build() + .unwrap(), + ); + } + let container = ContainerDefinition::builder() .name("openab") .image(&m.spec.task_definition.image) @@ -229,7 +314,7 @@ async fn apply_one( ); } - Ok(()) + Ok(invite_url) } fn render_config_toml(config: &crate::manifest::AgentConfig) -> String { diff --git a/operator/src/discord.rs b/operator/src/discord.rs new file mode 100644 index 00000000..fd9c49dc --- /dev/null +++ b/operator/src/discord.rs @@ -0,0 +1,125 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; + +const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; +const BOT_PERMISSIONS: u64 = 274878221312; // Send Messages, Read Messages, etc. + +#[derive(Debug)] +pub struct ProvisionedBot { + pub application_id: String, + pub bot_token: String, + pub invite_url: String, +} + +#[derive(Deserialize)] +struct ApplicationResponse { + id: String, + #[allow(dead_code)] + name: String, +} + +#[derive(Deserialize)] +struct BotResponse { + #[allow(dead_code)] + id: String, + token: Option, +} + +/// Provision a Discord bot application and return the bot token + invite URL. +/// Requires a Discord user bearer token with `applications.commands` scope. +/// +/// Idempotency: if a bot with the same name already exists, we reset its token +/// rather than creating a duplicate. +pub async fn provision_bot( + discord_token: &str, + bot_name: &str, +) -> Result { + let client = reqwest::Client::new(); + + // 1. Check if application already exists (by listing user's apps) + let existing = find_existing_application(&client, discord_token, bot_name).await?; + + let app_id = if let Some(app_id) = existing { + app_id + } else { + // 2. Create new application + let resp = client + .post(format!("{}/applications", DISCORD_API_BASE)) + .header("Authorization", format!("Bearer {}", discord_token)) + .json(&serde_json::json!({ "name": bot_name })) + .send() + .await + .context("failed to create Discord application")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Discord API error creating application: {} {}", status, body); + } + + let app: ApplicationResponse = resp.json().await?; + app.id + }; + + // 3. Create or reset bot user for the application + let resp = client + .post(format!("{}/applications/{}/bot/reset", DISCORD_API_BASE, app_id)) + .header("Authorization", format!("Bearer {}", discord_token)) + .send() + .await + .context("failed to reset Discord bot token")?; + + let bot_token = if resp.status().is_success() { + let bot: BotResponse = resp.json().await?; + bot.token.context("Discord API did not return a bot token")? + } else { + // If reset fails, try creating the bot first + let resp = client + .post(format!("{}/applications/{}/bot", DISCORD_API_BASE, app_id)) + .header("Authorization", format!("Bearer {}", discord_token)) + .send() + .await + .context("failed to create Discord bot")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Discord API error creating bot: {} {}", status, body); + } + + let bot: BotResponse = resp.json().await?; + bot.token.context("Discord API did not return a bot token")? + }; + + let invite_url = format!( + "https://discord.com/oauth2/authorize?client_id={}&scope=bot&permissions={}", + app_id, BOT_PERMISSIONS + ); + + Ok(ProvisionedBot { + application_id: app_id, + bot_token, + invite_url, + }) +} + +async fn find_existing_application( + client: &reqwest::Client, + discord_token: &str, + bot_name: &str, +) -> Result> { + let resp = client + .get(format!("{}/applications", DISCORD_API_BASE)) + .header("Authorization", format!("Bearer {}", discord_token)) + .send() + .await + .context("failed to list Discord applications")?; + + if !resp.status().is_success() { + // If we can't list, assume it doesn't exist + return Ok(None); + } + + let apps: Vec = resp.json().await?; + Ok(apps.into_iter().find(|a| a.name == bot_name).map(|a| a.id)) +} diff --git a/operator/src/main.rs b/operator/src/main.rs index 87847309..f443625b 100644 --- a/operator/src/main.rs +++ b/operator/src/main.rs @@ -2,6 +2,7 @@ mod manifest; mod apply; mod get; mod delete; +mod discord; use clap::{Parser, Subcommand}; @@ -19,6 +20,9 @@ enum Commands { /// Path to manifest file or directory #[arg(short, long)] file: String, + /// Auto-register Discord bots (requires DISCORD_DEVELOPER_TOKEN env var or SSM) + #[arg(long, default_value_t = false)] + auto_register: bool, }, /// List OAB services and their status Get { @@ -51,7 +55,7 @@ async fn main() -> anyhow::Result<()> { let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; match cli.command { - Commands::Apply { file } => apply::run(&config, &file).await, + Commands::Apply { file, auto_register } => apply::run(&config, &file, auto_register).await, Commands::Get { resource, name, cluster } => get::run(&config, &resource, name.as_deref(), &cluster).await, Commands::Delete { resource, name, cluster, namespace } => { delete::run(&config, &resource, &name, &cluster, &namespace).await From 77be0458ce3186d17db89df3869741ae567b79bc Mon Sep 17 00:00:00 2001 From: Kiro Date: Tue, 19 May 2026 11:50:20 +0000 Subject: [PATCH 2/4] fix(operator): allow dead_code on application_id field --- operator/src/discord.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/operator/src/discord.rs b/operator/src/discord.rs index fd9c49dc..f2db7fd7 100644 --- a/operator/src/discord.rs +++ b/operator/src/discord.rs @@ -6,6 +6,7 @@ const BOT_PERMISSIONS: u64 = 274878221312; // Send Messages, Read Messages, etc. #[derive(Debug)] pub struct ProvisionedBot { + #[allow(dead_code)] pub application_id: String, pub bot_token: String, pub invite_url: String, From 2dcc2e59a5734cb5f73ea7347610b57a2917bf72 Mon Sep 17 00:00:00 2001 From: Kiro Date: Tue, 19 May 2026 12:01:43 +0000 Subject: [PATCH 3/4] fix(operator): address auto-register review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #1: Idempotent — check SSM first, skip bot creation if token exists - #2: Persist auto-registered secret in S3 manifest (survives future applies without --auto-register) - NIT: Include namespace in bot name to avoid cross-namespace collision --- operator/src/apply.rs | 58 ++++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/operator/src/apply.rs b/operator/src/apply.rs index 532027ba..847f2199 100644 --- a/operator/src/apply.rs +++ b/operator/src/apply.rs @@ -123,24 +123,37 @@ async fn apply_one( if let Some(token) = discord_token { if !has_discord_secret { - let bot_name = format!("oab-{}", m.metadata.name); - println!(" Registering Discord bot '{}'...", bot_name); - - let bot = discord::provision_bot(token, &bot_name).await?; - - // Store token in SSM + let bot_name = format!("oab-{}-{}", m.metadata.namespace, m.metadata.name); let ssm_path = format!("/oab/{}/{}/discord-token", m.metadata.namespace, m.metadata.name); - ssm.put_parameter() + + // Check if token already exists in SSM (idempotent: skip if already provisioned) + let already_provisioned = ssm + .get_parameter() .name(&ssm_path) - .value(&bot.bot_token) - .r#type(aws_sdk_ssm::types::ParameterType::SecureString) - .overwrite(true) .send() .await - .context("failed to store Discord bot token in SSM")?; - - println!(" āœ“ Bot registered, token stored at {}", ssm_path); - invite_url = Some(bot.invite_url); + .is_ok(); + + if already_provisioned { + println!(" āœ“ Bot already provisioned (token exists at {})", ssm_path); + } else { + println!(" Registering Discord bot '{}'...", bot_name); + + let bot = discord::provision_bot(token, &bot_name).await?; + + // Store token in SSM + ssm.put_parameter() + .name(&ssm_path) + .value(&bot.bot_token) + .r#type(aws_sdk_ssm::types::ParameterType::SecureString) + .overwrite(true) + .send() + .await + .context("failed to store Discord bot token in SSM")?; + + println!(" āœ“ Bot registered, token stored at {}", ssm_path); + invite_url = Some(bot.invite_url); + } } } @@ -161,6 +174,21 @@ async fn apply_one( // 2. Upload manifest to S3 (record of desired state, with updated generation) let mut manifest_to_store = serde_yaml::to_value(m)?; manifest_to_store["metadata"]["generation"] = serde_yaml::Value::Number(generation.into()); + + // Persist auto-registered secret in desired state so future applies without --auto-register keep it + if discord_token.is_some() && !has_discord_secret { + let ssm_path = format!("/oab/{}/{}/discord-token", m.metadata.namespace, m.metadata.name); + let secret_entry = serde_yaml::to_value(&crate::manifest::SecretRef { + name: "DISCORD_TOKEN".to_string(), + value_from: ssm_path, + })?; + if let Some(secrets) = manifest_to_store["spec"]["secrets"].as_sequence_mut() { + secrets.push(secret_entry); + } else { + manifest_to_store["spec"]["secrets"] = serde_yaml::Value::Sequence(vec![secret_entry]); + } + } + let manifest_yaml = serde_yaml::to_string(&manifest_to_store)?; let manifest_key = format!("manifests/{}/{}.yaml", m.metadata.namespace, m.metadata.name); s3.put_object() @@ -211,7 +239,7 @@ async fn apply_one( }) .collect(); - // If auto-registered, add the Discord token secret + // If auto-registered, add the Discord token secret to task def if discord_token.is_some() && !has_discord_secret { let ssm_path = format!("/oab/{}/{}/discord-token", m.metadata.namespace, m.metadata.name); secrets.push( From 7286f455140cf897cbaa20c9790c9a9195acbf2e Mon Sep 17 00:00:00 2001 From: shaun-agent Date: Tue, 19 May 2026 19:48:46 +0000 Subject: [PATCH 4/4] fix(operator): harden Discord auto-register SSM checks --- operator/Cargo.lock | 327 +++++++++++++++++++++++++++++++++++++++++- operator/Cargo.toml | 2 +- operator/src/apply.rs | 96 ++++++++++--- 3 files changed, 400 insertions(+), 25 deletions(-) diff --git a/operator/Cargo.lock b/operator/Cargo.lock index 435dd4af..744571d5 100644 --- a/operator/Cargo.lock +++ b/operator/Cargo.lock @@ -670,6 +670,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.1" @@ -820,7 +826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -831,7 +837,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -954,7 +960,7 @@ dependencies = [ "generic-array", "group", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -988,7 +994,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1081,8 +1087,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1092,9 +1100,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1104,7 +1114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1343,6 +1353,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots", ] [[package]] @@ -1557,6 +1568,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "md-5" version = "0.11.0" @@ -1618,7 +1635,9 @@ dependencies = [ "aws-sdk-s3", "aws-sdk-ssm", "clap", + "reqwest", "serde", + "serde_json", "serde_yaml", "tokio", "toml", @@ -1725,6 +1744,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1734,6 +1762,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.40", + "socket2 0.6.3", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls 0.23.40", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1749,6 +1832,26 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1758,6 +1861,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1773,6 +1885,44 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.40", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "rfc6979" version = "0.3.1" @@ -1798,6 +1948,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1827,6 +1983,7 @@ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.13", "subtle", @@ -1851,6 +2008,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -1986,6 +2144,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1995,6 +2166,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2075,7 +2258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" dependencies = [ "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2155,6 +2338,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -2166,6 +2358,26 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "time" version = "0.3.47" @@ -2206,6 +2418,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -2314,10 +2541,33 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", "tower-layer", "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2480,6 +2730,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.121" @@ -2512,6 +2772,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -2650,6 +2939,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2709,3 +3018,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/operator/Cargo.toml b/operator/Cargo.toml index 14ff1daf..711c1fdf 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -14,7 +14,7 @@ aws-sdk-ecs = "1.53" aws-sdk-s3 = "1.65" aws-sdk-ssm = "1.52" clap = { version = "4.5", features = ["derive"] } -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" diff --git a/operator/src/apply.rs b/operator/src/apply.rs index 847f2199..3dc560c6 100644 --- a/operator/src/apply.rs +++ b/operator/src/apply.rs @@ -1,5 +1,5 @@ -use crate::manifest::OABServiceManifest; use crate::discord; +use crate::manifest::OABServiceManifest; use anyhow::{Context, Result}; use aws_sdk_ecs::types::{ AssignPublicIp, AwsVpcConfiguration, CapacityProviderStrategyItem, ContainerDefinition, @@ -8,7 +8,11 @@ use aws_sdk_ecs::types::{ use aws_sdk_s3::primitives::ByteStream; use std::path::Path; -pub async fn run(aws_config: &aws_config::SdkConfig, file_path: &str, auto_register: bool) -> Result<()> { +pub async fn run( + aws_config: &aws_config::SdkConfig, + file_path: &str, + auto_register: bool, +) -> Result<()> { let path = Path::new(file_path); let manifests = load_manifests(path)?; @@ -91,8 +95,7 @@ fn load_manifests(path: &Path) -> Result> { fn parse_manifest(path: &Path) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read {}", path.display()))?; - serde_yaml::from_str(&content) - .with_context(|| format!("failed to parse {}", path.display())) + serde_yaml::from_str(&content).with_context(|| format!("failed to parse {}", path.display())) } async fn apply_one( @@ -106,8 +109,17 @@ async fn apply_one( let bucket = "oab-control-plane"; // Read current generation from S3 manifest (if exists), increment - let manifest_key = format!("manifests/{}/{}.yaml", m.metadata.namespace, m.metadata.name); - let current_gen = match s3.get_object().bucket(bucket).key(&manifest_key).send().await { + let manifest_key = format!( + "manifests/{}/{}.yaml", + m.metadata.namespace, m.metadata.name + ); + let current_gen = match s3 + .get_object() + .bucket(bucket) + .key(&manifest_key) + .send() + .await + { Ok(resp) => { let bytes = resp.body.collect().await?.into_bytes(); let existing: OABServiceManifest = serde_yaml::from_slice(&bytes)?; @@ -124,18 +136,29 @@ async fn apply_one( if let Some(token) = discord_token { if !has_discord_secret { let bot_name = format!("oab-{}-{}", m.metadata.namespace, m.metadata.name); - let ssm_path = format!("/oab/{}/{}/discord-token", m.metadata.namespace, m.metadata.name); + let ssm_path = format!( + "/oab/{}/{}/discord-token", + m.metadata.namespace, m.metadata.name + ); // Check if token already exists in SSM (idempotent: skip if already provisioned) - let already_provisioned = ssm - .get_parameter() - .name(&ssm_path) - .send() - .await - .is_ok(); + let already_provisioned = match ssm.get_parameter().name(&ssm_path).send().await { + Ok(_) => true, + Err(err) if err.as_service_error().is_some_and(is_parameter_not_found) => false, + Err(err) => { + anyhow::bail!( + "failed to check existing Discord token at {}: {}", + ssm_path, + err + ); + } + }; if already_provisioned { - println!(" āœ“ Bot already provisioned (token exists at {})", ssm_path); + println!( + " āœ“ Bot already provisioned (token exists at {})", + ssm_path + ); } else { println!(" Registering Discord bot '{}'...", bot_name); @@ -177,7 +200,10 @@ async fn apply_one( // Persist auto-registered secret in desired state so future applies without --auto-register keep it if discord_token.is_some() && !has_discord_secret { - let ssm_path = format!("/oab/{}/{}/discord-token", m.metadata.namespace, m.metadata.name); + let ssm_path = format!( + "/oab/{}/{}/discord-token", + m.metadata.namespace, m.metadata.name + ); let secret_entry = serde_yaml::to_value(&crate::manifest::SecretRef { name: "DISCORD_TOKEN".to_string(), value_from: ssm_path, @@ -190,7 +216,10 @@ async fn apply_one( } let manifest_yaml = serde_yaml::to_string(&manifest_to_store)?; - let manifest_key = format!("manifests/{}/{}.yaml", m.metadata.namespace, m.metadata.name); + let manifest_key = format!( + "manifests/{}/{}.yaml", + m.metadata.namespace, m.metadata.name + ); s3.put_object() .bucket(bucket) .key(&manifest_key) @@ -241,7 +270,10 @@ async fn apply_one( // If auto-registered, add the Discord token secret to task def if discord_token.is_some() && !has_discord_secret { - let ssm_path = format!("/oab/{}/{}/discord-token", m.metadata.namespace, m.metadata.name); + let ssm_path = format!( + "/oab/{}/{}/discord-token", + m.metadata.namespace, m.metadata.name + ); secrets.push( Secret::builder() .name("DISCORD_TOKEN") @@ -256,7 +288,11 @@ async fn apply_one( .image(&m.spec.task_definition.image) .essential(true) .set_environment(Some(env_vars)) - .set_secrets(if secrets.is_empty() { None } else { Some(secrets) }) + .set_secrets(if secrets.is_empty() { + None + } else { + Some(secrets) + }) .build(); let task_def = ecs @@ -388,3 +424,27 @@ fn render_config_toml(config: &crate::manifest::AgentConfig) -> String { out } + +fn is_parameter_not_found(err: &aws_sdk_ssm::operation::get_parameter::GetParameterError) -> bool { + err.is_parameter_not_found() +} + +#[cfg(test)] +mod tests { + use super::*; + use aws_sdk_ssm::{ + operation::get_parameter::GetParameterError, types::error::ParameterNotFound, + }; + + #[test] + fn classifies_only_parameter_not_found_as_missing() { + let missing = GetParameterError::ParameterNotFound(ParameterNotFound::builder().build()); + assert!(is_parameter_not_found(&missing)); + + let other = GetParameterError::unhandled(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "ssm denied", + )); + assert!(!is_parameter_not_found(&other)); + } +}