use crate::api::DiscourseClient;
use crate::commands::common::{ensure_api_credentials, select_discourse};
use crate::config::Config;
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use std::process::Command;
use std::thread::sleep;
use std::time::{Duration, Instant};
struct Names {
bucket: String,
policy: String,
user: String,
}
fn derive_names(forum: &str, bucket_override: Option<&str>) -> Names {
let bucket = bucket_override
.map(str::to_string)
.unwrap_or_else(|| format!("{forum}-discourse-backups"));
Names {
policy: format!("s3-single-bucket-{bucket}"),
user: format!("{forum}-discourse-backup-user"),
bucket,
}
}
fn single_bucket_policy(bucket: &str) -> Value {
json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": format!("arn:aws:s3:::{bucket}")
},
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": [format!("arn:aws:s3:::{bucket}/*")]
}
]
})
}
fn create_bucket_args(bucket: &str, region: &str) -> Vec<String> {
let mut args = vec![
"s3api".into(),
"create-bucket".into(),
"--bucket".into(),
bucket.into(),
"--region".into(),
region.into(),
];
if region != "us-east-1" {
args.push("--create-bucket-configuration".into());
args.push(format!("LocationConstraint={region}"));
}
args
}
const PUBLIC_ACCESS_BLOCK: &str =
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true";
fn aws_json(args: &[String]) -> Result<Value> {
let output = Command::new("aws")
.args(args)
.args(["--output", "json"])
.output()
.context("running `aws` - is the AWS CLI installed and on PATH?")?;
if !output.status.success() {
bail!(
"aws {} failed:\n{}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return Ok(Value::Null);
}
serde_json::from_str(&stdout)
.with_context(|| format!("parsing `aws {}` output", args.join(" ")))
}
fn aws_run(args: &[String]) -> Result<()> {
let output = Command::new("aws")
.args(args)
.output()
.context("running `aws` - is the AWS CLI installed and on PATH?")?;
if !output.status.success() {
bail!(
"aws {} failed:\n{}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
pub fn setup_s3(
config: &Config,
discourse_name: &str,
region: &str,
bucket: Option<&str>,
no_test: bool,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let names = derive_names(&discourse.name, bucket);
let policy_doc = single_bucket_policy(&names.bucket);
let policy_json = serde_json::to_string(&policy_doc)?;
let policy_pretty = serde_json::to_string_pretty(&policy_doc)?;
if dry_run {
print_plan(&discourse.name, &names, region, &policy_pretty, no_test);
return Ok(());
}
let identity = aws_json(&["sts".into(), "get-caller-identity".into()])
.context("AWS pre-flight failed (need credentials with IAM + S3 admin rights)")?;
let account = identity
.get("Account")
.and_then(|v| v.as_str())
.unwrap_or("(unknown)");
let client = DiscourseClient::new(discourse)?;
client
.fetch_version_info()
.context("forum pre-flight failed: could not reach the Discourse admin API")?;
println!(
"Provisioning S3 backups for {} in AWS account {} (region {})",
discourse.name, account, region
);
aws_run(&create_bucket_args(&names.bucket, region))?;
aws_run(&[
"s3api".into(),
"put-public-access-block".into(),
"--bucket".into(),
names.bucket.clone(),
"--public-access-block-configuration".into(),
PUBLIC_ACCESS_BLOCK.into(),
])?;
println!(" created bucket {} (public access blocked)", names.bucket);
let policy = aws_json(&[
"iam".into(),
"create-policy".into(),
"--policy-name".into(),
names.policy.clone(),
"--policy-document".into(),
policy_json,
])?;
let policy_arn = policy
.get("Policy")
.and_then(|p| p.get("Arn"))
.and_then(|v| v.as_str())
.context("create-policy did not return a Policy ARN")?
.to_string();
println!(" created policy {}", names.policy);
aws_run(&[
"iam".into(),
"create-user".into(),
"--user-name".into(),
names.user.clone(),
])?;
aws_run(&[
"iam".into(),
"attach-user-policy".into(),
"--user-name".into(),
names.user.clone(),
"--policy-arn".into(),
policy_arn,
])?;
let key = aws_json(&[
"iam".into(),
"create-access-key".into(),
"--user-name".into(),
names.user.clone(),
])?;
let access_key_id = key
.get("AccessKey")
.and_then(|k| k.get("AccessKeyId"))
.and_then(|v| v.as_str())
.context("create-access-key did not return an AccessKeyId")?
.to_string();
let secret_access_key = key
.get("AccessKey")
.and_then(|k| k.get("SecretAccessKey"))
.and_then(|v| v.as_str())
.context("create-access-key did not return a SecretAccessKey")?
.to_string();
println!(
" created user {} with access key {}",
names.user, access_key_id
);
client.update_site_setting("s3_backup_bucket", &names.bucket)?;
client.update_site_setting("s3_region", region)?;
client.update_site_setting("s3_access_key_id", &access_key_id)?;
client.update_site_setting("s3_secret_access_key", &secret_access_key)?;
client.update_site_setting("backup_location", "s3")?;
println!(" set Discourse S3 backup settings (secret written to the setting, not stored)");
if no_test {
println!(
"Done. Skipped the test backup (--no-test); run `dsc backup create {}` to verify.",
discourse.name
);
return Ok(());
}
println!("Triggering a test backup and waiting for it to land in the bucket...");
client.create_backup()?;
if wait_for_backup_object(&names.bucket)? {
println!(
"✓ Test backup landed in s3://{}/ - setup verified.",
names.bucket
);
} else {
println!(
"Backup triggered, but nothing visible in s3://{}/ yet. Discourse backups run \
asynchronously - re-check with `aws s3 ls s3://{}/ --recursive` shortly.",
names.bucket, names.bucket
);
}
Ok(())
}
fn wait_for_backup_object(bucket: &str) -> Result<bool> {
let deadline = Instant::now() + Duration::from_secs(180);
while Instant::now() < deadline {
let output = Command::new("aws")
.args(["s3", "ls", &format!("s3://{bucket}/"), "--recursive"])
.output()
.context("running `aws s3 ls`")?;
if output.status.success() && String::from_utf8_lossy(&output.stdout).contains(".tar.gz") {
return Ok(true);
}
sleep(Duration::from_secs(10));
}
Ok(false)
}
fn print_plan(forum: &str, names: &Names, region: &str, policy_pretty: &str, no_test: bool) {
println!("[dry-run] S3 backup setup for {forum} (region {region})\n");
println!("AWS resources to create:");
println!(
" bucket {} (private; Block Public Access on; SSE-S3)",
names.bucket
);
println!(
" policy {} (single-bucket, least privilege)",
names.policy
);
println!(" user {} (+ one access key)\n", names.user);
println!("IAM policy document:");
for line in policy_pretty.lines() {
println!(" {line}");
}
println!();
println!("aws commands:");
println!(
" aws {}",
create_bucket_args(&names.bucket, region).join(" ")
);
println!(
" aws s3api put-public-access-block --bucket {} --public-access-block-configuration {}",
names.bucket, PUBLIC_ACCESS_BLOCK
);
println!(
" aws iam create-policy --policy-name {} --policy-document <json above>",
names.policy
);
println!(" aws iam create-user --user-name {}", names.user);
println!(
" aws iam attach-user-policy --user-name {} --policy-arn <policy ARN>",
names.user
);
println!(" aws iam create-access-key --user-name {}\n", names.user);
println!("Discourse settings to set (in this order):");
println!(" s3_backup_bucket = {}", names.bucket);
println!(" s3_region = {region}");
println!(" s3_access_key_id = <minted at run time>");
println!(" s3_secret_access_key = <minted at run time; never printed>");
println!(" backup_location = s3 (enabled LAST, once the above are set)\n");
if no_test {
println!("Test backup: skipped (--no-test).");
} else {
println!(
"Then: dsc backup create {forum}, and confirm the dump appears via \
aws s3 ls s3://{}/ (skip with --no-test).",
names.bucket
);
}
println!("\nNothing was created or changed (--dry-run).");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_follow_the_runbook_scheme() {
let n = derive_names("myforum", None);
assert_eq!(n.bucket, "myforum-discourse-backups");
assert_eq!(n.policy, "s3-single-bucket-myforum-discourse-backups");
assert_eq!(n.user, "myforum-discourse-backup-user");
}
#[test]
fn bucket_override_keeps_user_forum_derived() {
let n = derive_names("myforum", Some("custom-bucket"));
assert_eq!(n.bucket, "custom-bucket");
assert_eq!(n.policy, "s3-single-bucket-custom-bucket");
assert_eq!(n.user, "myforum-discourse-backup-user");
}
#[test]
fn policy_is_confined_to_the_one_bucket() {
let p = single_bucket_policy("b");
let stmts = p["Statement"].as_array().unwrap();
assert_eq!(stmts[0]["Action"], "s3:ListBucket");
assert_eq!(stmts[0]["Resource"], "arn:aws:s3:::b");
assert_eq!(stmts[1]["Resource"][0], "arn:aws:s3:::b/*");
}
#[test]
fn create_bucket_omits_location_constraint_for_us_east_1() {
let args = create_bucket_args("b", "us-east-1");
assert!(!args.iter().any(|a| a.contains("LocationConstraint")));
}
#[test]
fn create_bucket_sets_location_constraint_elsewhere() {
let args = create_bucket_args("b", "eu-west-2");
assert!(args.contains(&"LocationConstraint=eu-west-2".to_string()));
}
}