Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions operator/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
123 changes: 118 additions & 5 deletions operator/src/apply.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::manifest::OABServiceManifest;
use crate::discord;
use anyhow::{Context, Result};
use aws_sdk_ecs::types::{
AssignPublicIp, AwsVpcConfiguration, CapacityProviderStrategyItem, ContainerDefinition,
Expand All @@ -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)?;

Expand All @@ -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<String> {
// 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<Vec<OABServiceManifest>> {
let mut manifests = Vec::new();
if path.is_dir() {
Expand All @@ -54,8 +98,10 @@ fn parse_manifest(path: &Path) -> Result<OABServiceManifest> {
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<Option<String>> {
let service_name = m.ecs_service_name();
let bucket = "oab-control-plane";

Expand All @@ -71,6 +117,46 @@ 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.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();

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);
}
}
}

// 1. Render config.toml and upload to S3 (immutable path)
let config_toml = render_config_toml(&m.spec.config);
let config_key = format!(
Expand All @@ -88,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()
Expand Down Expand Up @@ -125,7 +226,7 @@ async fn apply_one(
);
}

let secrets: Vec<Secret> = m
let mut secrets: Vec<Secret> = m
.spec
.secrets
.iter()
Expand All @@ -138,6 +239,18 @@ async fn apply_one(
})
.collect();

// 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(
Secret::builder()
.name("DISCORD_TOKEN")
.value_from(&ssm_path)
.build()
.unwrap(),
);
}

let container = ContainerDefinition::builder()
.name("openab")
.image(&m.spec.task_definition.image)
Expand Down Expand Up @@ -229,7 +342,7 @@ async fn apply_one(
);
}

Ok(())
Ok(invite_url)
}

fn render_config_toml(config: &crate::manifest::AgentConfig) -> String {
Expand Down
126 changes: 126 additions & 0 deletions operator/src/discord.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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 {
#[allow(dead_code)]
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<String>,
}

/// 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<ProvisionedBot> {
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<Option<String>> {
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<ApplicationResponse> = resp.json().await?;
Ok(apps.into_iter().find(|a| a.name == bot_name).map(|a| a.id))
}
6 changes: 5 additions & 1 deletion operator/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod manifest;
mod apply;
mod get;
mod delete;
mod discord;

use clap::{Parser, Subcommand};

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading