From 35c456355eb0cbc75c72ca51498196786899965f Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 25 May 2026 03:50:40 -0500 Subject: [PATCH] feat(local): add `hops local listmonk` + make --source-context required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors `hops local zitadel` / `hops local github`. Bootstraps the hops-ops/provider-listmonk Crossplane provider + a cluster-scoped ProviderConfig pointing at a Listmonk instance via Basic-Auth credentials (JSON Secret). Credential resolution waterfall: 1. Explicit --endpoint / --username / --token flags 2. LISTMONK_{ENDPOINT,USERNAME,TOKEN} env vars 3. Read from the chart-bootstrapped Secret on a source cluster (with keys `username` + `token` — the shape produced by listmonk-chart v0.2.0's post-install api-user-bootstrap hook) Endpoint is derived from the source Secret name when not explicitly set: `-provider-creds` → in-cluster service `http://..svc.cluster.local:9000`. Default upjet provider package: ghcr.io/hops-ops/provider-listmonk:v0.0.3. Also drops the `pat-local` default from `--source-context` on BOTH this command and `hops local zitadel` — that hardcoded value bakes the implementer's personal cluster name into a tool meant for multiple users. Required positional flag now; users explicitly pass their own source context. Verified end-to-end on pat-local 2026-05-25: - Provider install + Healthy - ProviderConfig applied - UserRole MR reconciled (Crossplane → upjet → TF provider → Listmonk REST API → users / roles table) - User MR reconciled with cross-resource userRoleIdRef → numeric userRoleId (typed-reference resolution works end-to-end) - AppSettings MR reconciled (no-op write of current values; round- trip lossless) --- src/commands/local/listmonk.rs | 357 +++++++++++++++++++++++++++++++++ src/commands/local/mod.rs | 4 + src/commands/local/zitadel.rs | 5 +- 3 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 src/commands/local/listmonk.rs diff --git a/src/commands/local/listmonk.rs b/src/commands/local/listmonk.rs new file mode 100644 index 0000000..fed0696 --- /dev/null +++ b/src/commands/local/listmonk.rs @@ -0,0 +1,357 @@ +use super::{kubectl_apply_stdin, run_cmd_output}; +use clap::Args; +use serde_json::json; +use std::error::Error; +use std::process::Command; +use std::thread; +use std::time::Duration; + +const DEFAULT_PROVIDER_PACKAGE: &str = "ghcr.io/hops-ops/provider-listmonk:v0.0.3"; +const DEFAULT_PROVIDER_NAME: &str = "hops-ops-provider-listmonk"; +const PROVIDER_CONFIG_CRD: &str = "providerconfigs.listmonk.crossplane.io"; + +#[derive(Args, Debug)] +pub struct ListmonkArgs { + /// Listmonk API endpoint (e.g. http://marketing-listmonk.marketing.svc.cluster.local:9000). + /// Falls back to LISTMONK_ENDPOINT, then derives from the source service. + #[arg(long)] + pub endpoint: Option, + + /// Listmonk api-typed user username. Falls back to LISTMONK_USERNAME, + /// then reads the `username` key from the source Secret. + #[arg(long)] + pub username: Option, + + /// Listmonk api-typed user token (the user's password, since for + /// type=api users the password IS the Basic-Auth token). + /// Falls back to LISTMONK_TOKEN, then reads the `token` key from + /// the source Secret. + #[arg(long)] + pub token: Option, + + /// Kubernetes context containing the chart-bootstrapped + /// provider-creds Secret. Required — no platform-name default. + #[arg(long)] + pub source_context: String, + + /// Namespace of the chart-bootstrapped provider-creds Secret. + /// Default `marketing` (matches EmailMarketingStack v3 install ns). + #[arg(long, default_value = "marketing")] + pub source_namespace: String, + + /// Source Secret name. Default `marketing-listmonk-provider-creds` + /// (the chart's `-provider-creds` for a release named + /// `marketing-listmonk`). + #[arg(long, default_value = "marketing-listmonk-provider-creds")] + pub source_secret_name: String, + + /// Key in the source Secret holding the api username. Default + /// `username` (matches the chart's api-user-bootstrap Job output). + #[arg(long, default_value = "username")] + pub source_username_key: String, + + /// Key in the source Secret holding the api token. Default `token`. + #[arg(long, default_value = "token")] + pub source_token_key: String, + + /// Namespace where the credentials Secret + ProviderConfig get + /// applied. ProviderConfig is cluster-scoped so its `namespace` + /// metadata doesn't matter, but the credential Secret lives here. + #[arg(long, short = 'n', default_value = "crossplane-system")] + pub namespace: String, + + /// Secret name that stores generated Listmonk credentials JSON. + #[arg(long, default_value = "listmonk-credentials")] + pub secret_name: String, + + /// ProviderConfig name to create/update. + #[arg(long, default_value = "default")] + pub provider_config_name: String, + + /// Provider resource name for hops-ops/provider-listmonk. + #[arg(long, default_value = DEFAULT_PROVIDER_NAME)] + pub provider_name: String, + + /// provider-listmonk package reference. + #[arg(long, default_value = DEFAULT_PROVIDER_PACKAGE)] + pub provider_package: String, + + /// Refresh credentials in the Secret only; skips Provider and + /// ProviderConfig apply. + #[arg(long)] + pub refresh: bool, +} + +pub fn run(args: &ListmonkArgs) -> Result<(), Box> { + let username = resolve_username(args)?; + let token = resolve_token(args)?; + let endpoint = resolve_endpoint(args)?; + let credentials_json = build_credentials_json(&endpoint, &username, &token)?; + + if args.refresh { + log::info!( + "Refreshing secret '{}/{}' with generated Listmonk credentials...", + args.namespace, + args.secret_name + ); + kubectl_apply_stdin(&build_secret_yaml( + &args.namespace, + &args.secret_name, + &credentials_json, + ))?; + log::info!( + "Listmonk credentials secret refreshed ({}/{})", + args.namespace, + args.secret_name + ); + return Ok(()); + } + + log::info!( + "Applying provider-listmonk package '{}'...", + args.provider_package + ); + kubectl_apply_stdin(&build_provider_yaml( + &args.provider_name, + &args.provider_package, + ))?; + + wait_for_crd(PROVIDER_CONFIG_CRD)?; + + log::info!( + "Applying secret '{}/{}' with generated Listmonk credentials...", + args.namespace, + args.secret_name + ); + kubectl_apply_stdin(&build_secret_yaml( + &args.namespace, + &args.secret_name, + &credentials_json, + ))?; + + log::info!("Applying ProviderConfig '{}'...", args.provider_config_name); + kubectl_apply_stdin(&build_provider_config_yaml( + &args.provider_config_name, + &args.namespace, + &args.secret_name, + ))?; + + log::info!( + "Listmonk provider configured for '{}' (ProviderConfig: {})", + endpoint, + args.provider_config_name + ); + Ok(()) +} + +fn resolve_username(args: &ListmonkArgs) -> Result> { + if let Some(value) = non_empty(args.username.as_deref()) { + return Ok(value.to_string()); + } + if let Ok(value) = std::env::var("LISTMONK_USERNAME") { + if let Some(value) = non_empty(Some(&value)) { + return Ok(value.to_string()); + } + } + read_source_secret_value( + &args.source_context, + &args.source_namespace, + &args.source_secret_name, + &args.source_username_key, + "username", + ) +} + +fn resolve_token(args: &ListmonkArgs) -> Result> { + if let Some(value) = non_empty(args.token.as_deref()) { + return Ok(value.to_string()); + } + if let Ok(value) = std::env::var("LISTMONK_TOKEN") { + if let Some(value) = non_empty(Some(&value)) { + return Ok(value.to_string()); + } + } + read_source_secret_value( + &args.source_context, + &args.source_namespace, + &args.source_secret_name, + &args.source_token_key, + "token", + ) +} + +fn resolve_endpoint(args: &ListmonkArgs) -> Result> { + if let Some(value) = non_empty(args.endpoint.as_deref()) { + return Ok(value.to_string()); + } + if let Ok(value) = std::env::var("LISTMONK_ENDPOINT") { + if let Some(value) = non_empty(Some(&value)) { + return Ok(value.to_string()); + } + } + // Derive from the source secret's namespace — assume the chart's + // standard service naming `-listmonk..svc.cluster.local:9000`. + // The convention is that the chart-bootstrapped Secret is named + // `-provider-creds`, so the release name is the secret + // name minus the `-provider-creds` suffix. + let release = args + .source_secret_name + .strip_suffix("-provider-creds") + .ok_or("--endpoint required when --source-secret-name doesn't end with `-provider-creds`")?; + Ok(format!( + "http://{}.{}.svc.cluster.local:9000", + release, args.source_namespace + )) +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn read_source_secret_value( + context: &str, + namespace: &str, + secret_name: &str, + secret_key: &str, + label: &str, +) -> Result> { + log::info!( + "Reading Listmonk {} from {}/{} in context '{}'...", + label, + namespace, + secret_name, + context + ); + + let template = format!( + "go-template={{{{ index .data {:?} | base64decode }}}}", + secret_key + ); + let output = Command::new("kubectl") + .args([ + "--context", + context, + "get", + "secret", + secret_name, + "-n", + namespace, + "-o", + &template, + ]) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("kubectl exited with {}: {}", output.status, stderr).into()); + } + + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + return Err(format!( + "Secret {}/{} key '{}' in context '{}' is empty", + namespace, secret_name, secret_key, context + ) + .into()); + } + Ok(value) +} + +fn wait_for_crd(crd: &str) -> Result<(), Box> { + log::info!("Waiting for CRD {}...", crd); + for _ in 0..60 { + if run_cmd_output("kubectl", &["get", "crd", crd]).is_ok() { + return Ok(()); + } + thread::sleep(Duration::from_secs(5)); + } + + Err(format!("Timed out waiting for CRD {}", crd).into()) +} + +fn build_credentials_json( + endpoint: &str, + username: &str, + token: &str, +) -> Result> { + serde_json::to_string(&json!({ + "endpoint": endpoint, + "username": username, + "token": token, + })) + .map_err(|err| format!("failed to serialize Listmonk credentials JSON: {}", err).into()) +} + +fn build_provider_yaml(provider_name: &str, provider_package: &str) -> String { + format!( + "apiVersion: pkg.crossplane.io/v1\nkind: Provider\nmetadata:\n name: {provider_name}\nspec:\n package: {provider_package}\n" + ) +} + +fn build_secret_yaml(namespace: &str, secret_name: &str, credentials_json: &str) -> String { + let credentials_block = indent_block(credentials_json, 4); + format!( + "apiVersion: v1\nkind: Secret\nmetadata:\n name: {secret_name}\n namespace: {namespace}\ntype: Opaque\nstringData:\n credentials: |\n{credentials_block}" + ) +} + +fn build_provider_config_yaml( + provider_config_name: &str, + secret_namespace: &str, + secret_name: &str, +) -> String { + format!( + "apiVersion: listmonk.crossplane.io/v1beta1\nkind: ProviderConfig\nmetadata:\n name: {provider_config_name}\nspec:\n credentials:\n source: Secret\n secretRef:\n namespace: {secret_namespace}\n name: {secret_name}\n key: credentials\n" + ) +} + +fn indent_block(text: &str, spaces: usize) -> String { + let pad = " ".repeat(spaces); + text.lines() + .map(|line| format!("{pad}{line}\n")) + .collect::() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn credentials_json_matches_listmonk_provider_shape() { + let json = build_credentials_json( + "http://marketing-listmonk.marketing.svc.cluster.local:9000", + "crossplane-provider", + "secret-token-value", + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + value["endpoint"], + "http://marketing-listmonk.marketing.svc.cluster.local:9000" + ); + assert_eq!(value["username"], "crossplane-provider"); + assert_eq!(value["token"], "secret-token-value"); + } + + #[test] + fn provider_config_yaml_uses_cluster_scoped_api_group() { + let yaml = build_provider_config_yaml( + "default", + "crossplane-system", + "listmonk-credentials", + ); + assert!(yaml.contains("apiVersion: listmonk.crossplane.io/v1beta1")); + assert!(yaml.contains("kind: ProviderConfig")); + assert!(yaml.contains("name: default")); + assert!(yaml.contains("namespace: crossplane-system")); + assert!(yaml.contains("name: listmonk-credentials")); + assert!(yaml.contains("key: credentials")); + } + + #[test] + fn provider_yaml_uses_v0_0_3_default() { + let yaml = build_provider_yaml(DEFAULT_PROVIDER_NAME, DEFAULT_PROVIDER_PACKAGE); + assert!(yaml.contains("name: hops-ops-provider-listmonk")); + assert!(yaml.contains("ghcr.io/hops-ops/provider-listmonk")); + } +} diff --git a/src/commands/local/mod.rs b/src/commands/local/mod.rs index db333f9..9d7a665 100644 --- a/src/commands/local/mod.rs +++ b/src/commands/local/mod.rs @@ -2,6 +2,7 @@ mod aws; mod destroy; mod github; mod install; +mod listmonk; pub mod package_install; mod reset; mod start; @@ -65,6 +66,8 @@ pub enum LocalCommands { Github(github::GithubArgs), /// Configure crossplane-contrib provider-upjet-zitadel and Zitadel ProviderConfig Zitadel(zitadel::ZitadelArgs), + /// Configure hops-ops/provider-listmonk and Listmonk ProviderConfig + Listmonk(listmonk::ListmonkArgs), /// Stop the local cluster Stop, /// Destroy the local cluster VM @@ -81,6 +84,7 @@ pub fn run(args: &LocalArgs) -> Result<(), Box> { LocalCommands::Aws(aws_args) => aws::run(aws_args), LocalCommands::Github(github_args) => github::run(github_args), LocalCommands::Zitadel(zitadel_args) => zitadel::run(zitadel_args), + LocalCommands::Listmonk(listmonk_args) => listmonk::run(listmonk_args), LocalCommands::Stop => stop::run(), LocalCommands::Destroy => destroy::run(), LocalCommands::Uninstall => uninstall::run(), diff --git a/src/commands/local/zitadel.rs b/src/commands/local/zitadel.rs index 4dbfafa..7cc7a5c 100644 --- a/src/commands/local/zitadel.rs +++ b/src/commands/local/zitadel.rs @@ -31,8 +31,9 @@ pub struct ZitadelArgs { #[arg(long)] pub insecure: bool, - /// Kubernetes context containing the AuthStack iam-admin PAT Secret - #[arg(long, default_value = "pat-local")] + /// Kubernetes context containing the AuthStack iam-admin PAT Secret. + /// Required — no platform-name default. + #[arg(long)] pub source_context: String, /// Namespace containing the AuthStack iam-admin PAT Secret