diff --git a/Cargo.lock b/Cargo.lock index 68955b4..a5514f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1012,7 +1012,7 @@ dependencies = [ [[package]] name = "runseal" -version = "0.2.1" +version = "0.6.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/README.md b/README.md index 37eeab1..61a5b5d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ runseal @profile runseal @resources runseal @resolve resource:// resource://ssh/config runseal @transpile --input-lang=seal --output-lang=bash ./operator.seal -runseal @tool json get '{"releaseVersion":"v0.2.1"}' '.releaseVersion' +runseal @tool json get '{"releaseVersion":"v0.6.0"}' '.releaseVersion' runseal @wrappers runseal @which :ssh-run ``` diff --git a/app/Cargo.toml b/app/Cargo.toml index 726d13c..19c286c 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runseal" -version = "0.2.1" +version = "0.6.0" edition = "2024" [dependencies] diff --git a/app/src/core/tool/cloudflare.rs b/app/src/core/tool/cloudflare.rs index 2fb7d5a..0fb029e 100644 --- a/app/src/core/tool/cloudflare.rs +++ b/app/src/core/tool/cloudflare.rs @@ -7,6 +7,8 @@ use std::{ use anyhow::{Context, Result, bail}; use serde_json::Value as JsonValue; +mod dns_record; + #[derive(Debug, Clone)] struct Config { account_id: String, @@ -85,7 +87,8 @@ fn zone(args: &[String]) -> Result> { match args { [command, rest @ ..] if command == "get" => zone_get(rest), [ruleset, rest @ ..] if ruleset == "ruleset" => zone_ruleset(rest), - _ => bail!("usage: runseal @tool cloudflare zone get|ruleset ..."), + [dns, command, rest @ ..] if dns == "dns-record" => dns_record::eval(command, rest), + _ => bail!("usage: runseal @tool cloudflare zone get|ruleset|dns-record ..."), } } diff --git a/app/src/core/tool/cloudflare/dns_record.rs b/app/src/core/tool/cloudflare/dns_record.rs new file mode 100644 index 0000000..363fa67 --- /dev/null +++ b/app/src/core/tool/cloudflare/dns_record.rs @@ -0,0 +1,73 @@ +use anyhow::{Context, Result, bail}; +use serde_json::Value as JsonValue; + +use super::{load_config, optional_option, request, required_option}; + +pub(super) fn eval(command: &str, args: &[String]) -> Result> { + match command { + "list" => list(args), + "create" => create(args), + "update" => update(args), + _ => bail!("usage: runseal @tool cloudflare zone dns-record list|create|update ..."), + } +} + +fn list(args: &[String]) -> Result> { + let zone_id = required_option(args, "--zone-id")?; + let query = optional_option(args, "--name") + .map(|name| vec![("name".to_string(), name)]) + .unwrap_or_default(); + let config = load_config()?; + let payload = request( + &config, + "GET", + &format!("/zones/{zone_id}/dns_records"), + query, + None, + )?; + let value: JsonValue = serde_json::from_str(&payload.unwrap_or_default())?; + Ok(Some(serde_json::to_string( + value.get("result").unwrap_or(&JsonValue::Array(Vec::new())), + )?)) +} + +fn create(args: &[String]) -> Result> { + let zone_id = required_option(args, "--zone-id")?; + let body = record_json(args)?; + let config = load_config()?; + let payload = request( + &config, + "POST", + &format!("/zones/{zone_id}/dns_records"), + Vec::new(), + Some(body), + )?; + result(payload) +} + +fn update(args: &[String]) -> Result> { + let zone_id = required_option(args, "--zone-id")?; + let record_id = required_option(args, "--record-id")?; + let body = record_json(args)?; + let config = load_config()?; + let payload = request( + &config, + "PATCH", + &format!("/zones/{zone_id}/dns_records/{record_id}"), + Vec::new(), + Some(body), + )?; + result(payload) +} + +fn record_json(args: &[String]) -> Result { + let record_json = required_option(args, "--json")?; + serde_json::from_str(&record_json).context("invalid DNS record JSON") +} + +fn result(payload: Option) -> Result> { + let value: JsonValue = serde_json::from_str(&payload.unwrap_or_default())?; + Ok(Some(serde_json::to_string( + value.get("result").unwrap_or(&JsonValue::Null), + )?)) +} diff --git a/app/src/core/tool/mod.rs b/app/src/core/tool/mod.rs index 0c65d62..12025c9 100644 --- a/app/src/core/tool/mod.rs +++ b/app/src/core/tool/mod.rs @@ -61,6 +61,9 @@ Tools: fs contains-any ... print true when file contains any text fs backup-numbered move path to .bak or .bak.N and print it github pr checks probe print true when PR checks are reported + cloudflare zone dns-record list list DNS records in a zone + cloudflare zone dns-record create create a DNS record from JSON + cloudflare zone dns-record update update a DNS record from JSON cloudflare ... run an atomic Cloudflare resource op @tool is the runseal atomic tool runtime. Tool inputs use argv/env, output is diff --git a/app/tests/operator/cloudflare.rs b/app/tests/operator/cloudflare.rs index 8f9bfc9..4ca8a2b 100644 --- a/app/tests/operator/cloudflare.rs +++ b/app/tests/operator/cloudflare.rs @@ -116,6 +116,48 @@ CLOUDFLARE_MANAGE_REDIRECT_PREFIX= .expect("credentials should be written"); } +fn tool_credentials() -> (TempDir, PathBuf) { + let temp = TempDir::new().expect("temp dir should be created"); + let secrets = temp.path().join("secrets"); + std::fs::create_dir_all(&secrets).expect("secrets dir should be created"); + std::fs::write( + secrets.join("cloudflare.env"), + "\ +CLOUDFLARE_ACCOUNT_ID=account-123 +CLOUDFLARE_API_TOKEN=token-456 +", + ) + .expect("credentials should be written"); + (temp, secrets) +} + +fn mock_cloudflare(assert_request: F, body: &'static str) -> (String, thread::JoinHandle<()>) +where + F: FnOnce(&str) + Send + 'static, +{ + let server = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); + let address = server + .local_addr() + .expect("mock server address should exist"); + let handle = thread::spawn(move || { + let (mut stream, _) = server.accept().expect("mock request should arrive"); + let mut request = [0_u8; 4096]; + let read = stream + .read(&mut request) + .expect("request should be readable"); + let request = String::from_utf8_lossy(&request[..read]); + assert_request(&request); + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ) + .expect("response should be written"); + }); + (format!("http://{address}"), handle) +} + fn stdout(output: &std::process::Output) -> String { String::from_utf8(output.stdout.clone()).expect("stdout should be UTF-8") } @@ -155,40 +197,14 @@ fn manage_plan_uses_tool() { #[test] fn zone_get_uses_tool() { - let temp = TempDir::new().expect("temp dir should be created"); - let secrets = temp.path().join("secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should be created"); - std::fs::write( - secrets.join("cloudflare.env"), - "\ -CLOUDFLARE_ACCOUNT_ID=account-123 -CLOUDFLARE_API_TOKEN=token-456 -", - ) - .expect("credentials should be written"); - let server = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); - let address = server - .local_addr() - .expect("mock server address should exist"); - let handle = thread::spawn(move || { - let (mut stream, _) = server.accept().expect("mock request should arrive"); - let mut request = [0_u8; 2048]; - let read = stream - .read(&mut request) - .expect("request should be readable"); - let request = String::from_utf8_lossy(&request[..read]); - assert!(request.starts_with("GET /zones?name=perish.uk ")); - assert!(request.contains("authorization: Bearer token-456")); - let body = - r#"{"success":true,"result":[{"id":"zone-123","name":"perish.uk","status":"active"}]}"#; - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", - body.len(), - body - ) - .expect("response should be written"); - }); + let (_temp, secrets) = tool_credentials(); + let (api_base, handle) = mock_cloudflare( + move |request| { + assert!(request.starts_with("GET /zones?name=perish.uk ")); + assert!(request.contains("authorization: Bearer token-456")); + }, + r#"{"success":true,"result":[{"id":"zone-123","name":"perish.uk","status":"active"}]}"#, + ); let output = run_cloudflare_tool( &["@tool", "cloudflare", "zone", "get", "--name", "perish.uk"], @@ -197,7 +213,7 @@ CLOUDFLARE_API_TOKEN=token-456 "RUNSEAL_REPO_SECRETS_DIR", secrets.to_string_lossy().into_owned(), ), - ("RUNSEAL_CLOUDFLARE_API_BASE", format!("http://{address}")), + ("RUNSEAL_CLOUDFLARE_API_BASE", api_base), ], ); @@ -209,6 +225,169 @@ CLOUDFLARE_API_TOKEN=token-456 ); } +#[test] +fn dns_record_list() { + let (_temp, secrets) = tool_credentials(); + let (api_base, handle) = mock_cloudflare( + move |request| { + assert!(request.starts_with("GET /zones/zone-123/dns_records?name=sidecar.perish.uk ")); + assert!(request.contains("authorization: Bearer token-456")); + }, + r#"{"success":true,"result":[{"id":"record-123","name":"sidecar.perish.uk"}]}"#, + ); + + let output = run_cloudflare_tool( + &[ + "@tool", + "cloudflare", + "zone", + "dns-record", + "list", + "--zone-id", + "zone-123", + "--name", + "sidecar.perish.uk", + ], + &[ + ( + "RUNSEAL_REPO_SECRETS_DIR", + secrets.to_string_lossy().into_owned(), + ), + ("RUNSEAL_CLOUDFLARE_API_BASE", api_base), + ], + ); + + handle.join().expect("mock server should finish"); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + stdout(&output), + r#"[{"id":"record-123","name":"sidecar.perish.uk"}]"#.to_string() + "\n" + ); +} + +#[test] +fn dns_record_create() { + let (_temp, secrets) = tool_credentials(); + let record = r#"{"type":"CNAME","name":"sidecar.perish.uk","content":"releases.sidecar.perish.uk","ttl":1,"proxied":true}"#; + let (api_base, handle) = mock_cloudflare( + move |request| { + assert!(request.starts_with("POST /zones/zone-123/dns_records ")); + assert!(request.contains("authorization: Bearer token-456")); + assert_json_body(request, record); + }, + r#"{"success":true,"result":{"id":"record-123","type":"CNAME"}}"#, + ); + + let output = run_cloudflare_tool( + &[ + "@tool", + "cloudflare", + "zone", + "dns-record", + "create", + "--zone-id", + "zone-123", + "--json", + record, + ], + &[ + ( + "RUNSEAL_REPO_SECRETS_DIR", + secrets.to_string_lossy().into_owned(), + ), + ("RUNSEAL_CLOUDFLARE_API_BASE", api_base), + ], + ); + + handle.join().expect("mock server should finish"); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + stdout(&output), + r#"{"id":"record-123","type":"CNAME"}"#.to_string() + "\n" + ); +} + +#[test] +fn dns_record_update() { + let (_temp, secrets) = tool_credentials(); + let record = r#"{"type":"CNAME","name":"sidecar.perish.uk","content":"releases.sidecar.perish.uk","ttl":1,"proxied":true}"#; + let (api_base, handle) = mock_cloudflare( + move |request| { + assert!(request.starts_with("PATCH /zones/zone-123/dns_records/record-123 ")); + assert!(request.contains("authorization: Bearer token-456")); + assert_json_body(request, record); + }, + r#"{"success":true,"result":{"id":"record-123","modified":true}}"#, + ); + + let output = run_cloudflare_tool( + &[ + "@tool", + "cloudflare", + "zone", + "dns-record", + "update", + "--zone-id", + "zone-123", + "--record-id", + "record-123", + "--json", + record, + ], + &[ + ( + "RUNSEAL_REPO_SECRETS_DIR", + secrets.to_string_lossy().into_owned(), + ), + ("RUNSEAL_CLOUDFLARE_API_BASE", api_base), + ], + ); + + handle.join().expect("mock server should finish"); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + stdout(&output), + r#"{"id":"record-123","modified":true}"#.to_string() + "\n" + ); +} + +#[test] +fn dns_record_bad_json() { + let (_temp, secrets) = tool_credentials(); + + let output = run_cloudflare_tool( + &[ + "@tool", + "cloudflare", + "zone", + "dns-record", + "create", + "--zone-id", + "zone-123", + "--json", + "{", + ], + &[( + "RUNSEAL_REPO_SECRETS_DIR", + secrets.to_string_lossy().into_owned(), + )], + ); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("invalid DNS record JSON")); +} + +fn assert_json_body(request: &str, expected: &str) { + let body = request + .split_once("\r\n\r\n") + .map(|(_, body)| body) + .expect("request should include a body"); + let actual: serde_json::Value = serde_json::from_str(body).expect("body should be JSON"); + let expected: serde_json::Value = + serde_json::from_str(expected).expect("expected body should be JSON"); + assert_eq!(actual, expected); +} + #[test] fn api_passthrough_uses_tool() { let fx = fixture();