diff --git a/src/commands/local/aws.rs b/src/commands/local/aws.rs index 423c957..e2c567e 100644 --- a/src/commands/local/aws.rs +++ b/src/commands/local/aws.rs @@ -9,6 +9,8 @@ use std::time::Duration; const DEFAULT_PROVIDER_PACKAGE: &str = "xpkg.crossplane.io/crossplane-contrib/provider-family-aws:v2.4.0"; const DEFAULT_PROVIDER_NAME: &str = "crossplane-contrib-provider-family-aws"; +const DEFAULT_AWS_REGION: &str = "us-east-2"; +const DEFAULT_AWS_RUNTIME_CONFIG_NAME: &str = "aws"; const PROVIDER_CONFIG_CRD: &str = "providerconfigs.aws.m.upbound.io"; #[derive(Args, Debug)] @@ -18,6 +20,11 @@ pub struct AwsArgs { #[arg(long, short = 'p')] pub profile: Option, + /// AWS region to write into the ProviderConfig credentials file + /// (falls back to AWS_REGION/AWS_DEFAULT_REGION, then us-east-2) + #[arg(long, short = 'r')] + pub region: Option, + /// Namespace for the generated Secret and ProviderConfig #[arg(long, short = 'n', default_value = "default")] pub namespace: String, @@ -30,6 +37,10 @@ pub struct AwsArgs { #[arg(long, default_value = "default")] pub provider_config_name: String, + /// DeploymentRuntimeConfig name to use for AWS provider pods + #[arg(long, default_value = DEFAULT_AWS_RUNTIME_CONFIG_NAME)] + pub runtime_config_name: String, + /// Provider resource name for provider-family-aws #[arg(long, default_value = DEFAULT_PROVIDER_NAME)] pub provider_name: String, @@ -38,7 +49,7 @@ pub struct AwsArgs { #[arg(long, default_value = DEFAULT_PROVIDER_PACKAGE)] pub provider_package: String, - /// Refresh credentials in the secret only; skips Provider and ProviderConfig apply + /// Refresh credentials and AWS runtime region; skips Provider and ProviderConfig apply #[arg(long)] pub refresh: bool, } @@ -55,12 +66,23 @@ struct AwsExportCredentials { pub fn run(args: &AwsArgs) -> Result<(), Box> { let profile = resolve_profile(args.profile.as_deref())?; + let region = resolve_region(args.region.as_deref())?; log::info!("Exporting AWS credentials from profile '{}'...", profile); let creds = export_credentials(&profile)?; - let credentials_ini = build_credentials_ini(&creds); + let credentials_ini = build_credentials_ini(&creds, ®ion); if args.refresh { + log::info!( + "Applying AWS provider runtime '{}' for region '{}'...", + args.runtime_config_name, + region + ); + kubectl_apply_stdin(&build_runtime_config_yaml( + &args.runtime_config_name, + ®ion, + ))?; + log::info!( "Refreshing secret '{}/{}' with generated credentials...", args.namespace, @@ -72,14 +94,25 @@ pub fn run(args: &AwsArgs) -> Result<(), Box> { &credentials_ini, ))?; log::info!( - "AWS credentials secret refreshed from profile '{}' ({}/{})", + "AWS credentials secret refreshed from profile '{}' for region '{}' ({}/{})", profile, + region, args.namespace, args.secret_name ); return Ok(()); } + log::info!( + "Applying AWS provider runtime '{}' for region '{}'...", + args.runtime_config_name, + region + ); + kubectl_apply_stdin(&build_runtime_config_yaml( + &args.runtime_config_name, + ®ion, + ))?; + log::info!( "Applying provider-family-aws package '{}'...", args.provider_package @@ -87,6 +120,7 @@ pub fn run(args: &AwsArgs) -> Result<(), Box> { kubectl_apply_stdin(&build_provider_yaml( &args.provider_name, &args.provider_package, + &args.runtime_config_name, ))?; wait_for_crd(PROVIDER_CONFIG_CRD)?; @@ -114,8 +148,9 @@ pub fn run(args: &AwsArgs) -> Result<(), Box> { ))?; log::info!( - "AWS provider configured from profile '{}' (ProviderConfig: {}/{})", + "AWS provider configured from profile '{}' for region '{}' (ProviderConfig: {}/{})", profile, + region, args.namespace, args.provider_config_name ); @@ -137,6 +172,39 @@ fn resolve_profile(cli_profile: Option<&str>) -> Result> prompt_for_profile() } +fn resolve_region(cli_region: Option<&str>) -> Result> { + let env_region = std::env::var("AWS_REGION").ok(); + let env_default_region = std::env::var("AWS_DEFAULT_REGION").ok(); + + let region = select_region( + cli_region, + env_region.as_deref(), + env_default_region.as_deref(), + ); + + if !region + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-') + { + return Err(format!("Invalid AWS region '{}'.", region).into()); + } + + Ok(region.to_string()) +} + +fn select_region<'a>( + cli_region: Option<&'a str>, + env_region: Option<&'a str>, + env_default_region: Option<&'a str>, +) -> &'a str { + [cli_region, env_region, env_default_region] + .into_iter() + .flatten() + .map(str::trim) + .find(|region| !region.is_empty()) + .unwrap_or(DEFAULT_AWS_REGION) +} + fn select_profile( cli_profile: Option<&str>, env_profile: Option<&str>, @@ -265,10 +333,10 @@ fn wait_for_crd(crd: &str) -> Result<(), Box> { Err(format!("Timed out waiting for CRD {}", crd).into()) } -fn build_credentials_ini(creds: &AwsExportCredentials) -> String { +fn build_credentials_ini(creds: &AwsExportCredentials, region: &str) -> String { let mut ini = format!( - "[default]\naws_access_key_id = {}\naws_secret_access_key = {}\n", - creds.access_key_id, creds.secret_access_key + "[default]\naws_access_key_id = {}\naws_secret_access_key = {}\nregion = {}\n", + creds.access_key_id, creds.secret_access_key, region ); if let Some(session_token) = creds.session_token.as_deref() { @@ -280,9 +348,19 @@ fn build_credentials_ini(creds: &AwsExportCredentials) -> String { ini } -fn build_provider_yaml(provider_name: &str, provider_package: &str) -> String { +fn build_runtime_config_yaml(runtime_config_name: &str, region: &str) -> String { format!( - "apiVersion: pkg.crossplane.io/v1\nkind: Provider\nmetadata:\n name: {provider_name}\nspec:\n package: {provider_package}\n" + "apiVersion: pkg.crossplane.io/v1beta1\nkind: DeploymentRuntimeConfig\nmetadata:\n name: {runtime_config_name}\nspec:\n deploymentTemplate:\n spec:\n selector: {{}}\n template:\n spec:\n containers:\n - name: package-runtime\n env:\n - name: AWS_REGION\n value: {region}\n - name: AWS_DEFAULT_REGION\n value: {region}\n" + ) +} + +fn build_provider_yaml( + provider_name: &str, + provider_package: &str, + runtime_config_name: &str, +) -> String { + format!( + "apiVersion: pkg.crossplane.io/v1\nkind: Provider\nmetadata:\n name: {provider_name}\nspec:\n package: {provider_package}\n runtimeConfigRef:\n name: {runtime_config_name}\n" ) } @@ -360,12 +438,32 @@ mod tests { session_token: Some("token".to_string()), }; - let ini = build_credentials_ini(&creds); + let ini = build_credentials_ini(&creds, "us-east-2"); assert!(ini.contains("aws_access_key_id = AKIA...")); assert!(ini.contains("aws_secret_access_key = secret")); + assert!(ini.contains("region = us-east-2")); assert!(ini.contains("aws_session_token = token")); } + #[test] + fn resolve_region_prefers_cli_then_envs_then_default() { + assert_eq!( + select_region(Some("us-west-2"), Some("eu-west-1"), Some("ap-south-1")), + "us-west-2" + ); + assert_eq!( + select_region(None, Some("eu-west-1"), Some("ap-south-1")), + "eu-west-1" + ); + assert_eq!(select_region(None, None, Some("ap-south-1")), "ap-south-1"); + assert_eq!(select_region(None, None, None), DEFAULT_AWS_REGION); + } + + #[test] + fn resolve_region_rejects_unexpected_characters() { + assert!(resolve_region(Some("us-east-2;rm")).is_err()); + } + #[test] fn provider_config_yaml_uses_secret_ref() { let yaml = build_provider_config_yaml("default", "default", "aws-creds"); @@ -374,4 +472,24 @@ mod tests { assert!(yaml.contains("name: aws-creds")); assert!(yaml.contains("key: credentials")); } + + #[test] + fn runtime_config_yaml_sets_aws_region_env() { + let yaml = build_runtime_config_yaml("aws", "us-east-2"); + assert!(yaml.contains("kind: DeploymentRuntimeConfig")); + assert!(yaml.contains("name: aws")); + assert!(yaml.contains("name: AWS_REGION")); + assert!(yaml.contains("value: us-east-2")); + assert!(yaml.contains("name: AWS_DEFAULT_REGION")); + } + + #[test] + fn provider_yaml_uses_aws_runtime_config() { + let yaml = build_provider_yaml("aws-provider", "xpkg.example/provider:v1", "aws"); + assert!(yaml.contains("kind: Provider")); + assert!(yaml.contains("name: aws-provider")); + assert!(yaml.contains("package: xpkg.example/provider:v1")); + assert!(yaml.contains("runtimeConfigRef:")); + assert!(yaml.contains("name: aws")); + } } diff --git a/src/commands/local/cloudflare.rs b/src/commands/local/cloudflare.rs new file mode 100644 index 0000000..5d6e909 --- /dev/null +++ b/src/commands/local/cloudflare.rs @@ -0,0 +1,381 @@ +use super::{command_exists, kubectl_apply_stdin, run_cmd, run_cmd_output}; +use clap::Args; +use serde_json::json; +use std::error::Error; +use std::io::{self, IsTerminal}; +use std::thread; +use std::time::Duration; + +const DEFAULT_DNS_PROVIDER_PACKAGE: &str = + "xpkg.upbound.io/wildbitca/provider-cloudflare-dns:v0.2.5"; +const DEFAULT_DNS_PROVIDER_NAME: &str = "wildbitca-provider-cloudflare-dns"; +const PROVIDER_CONFIG_CRD: &str = "providerconfigs.upjet-cloudflare.m.upbound.io"; +const DNS_RECORD_CRD: &str = "records.dns.upjet-cloudflare.m.upbound.io"; + +#[derive(Args, Debug)] +pub struct CloudflareArgs { + /// Cloudflare API token. Falls back to CLOUDFLARE_API_TOKEN, then AWS Secrets Manager. + #[arg(long)] + pub api_token: Option, + + /// AWS Secrets Manager secret ID used when no token is passed or exported. + #[arg(long, default_value = "cloudflare/dns-edit")] + pub aws_secret_id: String, + + /// JSON property inside the AWS Secrets Manager SecretString. + #[arg(long, default_value = "token")] + pub aws_secret_property: String, + + /// AWS CLI profile for reading the Cloudflare token from Secrets Manager. + #[arg(long, short = 'p')] + pub profile: Option, + + /// Namespace for the generated Secret and ProviderConfig. + #[arg(long, short = 'n', default_value = "default")] + pub namespace: String, + + /// Secret name that stores generated Cloudflare credentials JSON. + #[arg(long, default_value = "cloudflare-credentials")] + pub secret_name: String, + + /// ProviderConfig name to create/update. + #[arg(long, default_value = "default")] + pub provider_config_name: String, + + /// Provider resource name for provider-cloudflare-dns. + #[arg(long, default_value = DEFAULT_DNS_PROVIDER_NAME)] + pub provider_name: String, + + /// provider-cloudflare-dns package reference. + #[arg(long, default_value = DEFAULT_DNS_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: &CloudflareArgs) -> Result<(), Box> { + let token = resolve_api_token(args)?; + let credentials_json = build_credentials_json(&token)?; + + if args.refresh { + log::info!( + "Refreshing secret '{}/{}' with generated Cloudflare credentials...", + args.namespace, + args.secret_name + ); + kubectl_apply_stdin(&build_secret_yaml( + &args.namespace, + &args.secret_name, + &credentials_json, + ))?; + log::info!( + "Cloudflare credentials secret refreshed ({}/{})", + args.namespace, + args.secret_name + ); + return Ok(()); + } + + log::info!( + "Applying Cloudflare DNS provider package '{}'...", + args.provider_package + ); + kubectl_apply_stdin(&build_provider_yaml( + &args.provider_name, + &args.provider_package, + ))?; + + wait_for_crd(PROVIDER_CONFIG_CRD)?; + wait_for_crd(DNS_RECORD_CRD)?; + + log::info!( + "Applying secret '{}/{}' with generated Cloudflare credentials...", + args.namespace, + args.secret_name + ); + kubectl_apply_stdin(&build_secret_yaml( + &args.namespace, + &args.secret_name, + &credentials_json, + ))?; + + log::info!( + "Applying ProviderConfig '{}/{}'...", + args.namespace, + args.provider_config_name + ); + kubectl_apply_stdin(&build_provider_config_yaml( + &args.namespace, + &args.provider_config_name, + &args.secret_name, + ))?; + + log::info!( + "Cloudflare DNS provider configured (ProviderConfig: {}/{})", + args.namespace, + args.provider_config_name + ); + Ok(()) +} + +fn resolve_api_token(args: &CloudflareArgs) -> Result> { + if let Some(token) = non_empty(args.api_token.as_deref()) { + return Ok(token.to_string()); + } + + if let Ok(token) = std::env::var("CLOUDFLARE_API_TOKEN") { + if let Some(token) = non_empty(Some(&token)) { + return Ok(token.to_string()); + } + } + + let profile = resolve_profile(args.profile.as_deref()); + read_aws_secret_token( + &args.aws_secret_id, + &args.aws_secret_property, + profile.as_deref(), + ) +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn resolve_profile(cli_profile: Option<&str>) -> Option { + let env_profile = std::env::var("AWS_PROFILE").ok(); + let env_default_profile = std::env::var("AWS_DEFAULT_PROFILE").ok(); + + let profile = [ + cli_profile, + env_profile.as_deref(), + env_default_profile.as_deref(), + ] + .into_iter() + .flatten() + .map(str::trim) + .find(|profile| !profile.is_empty()) + .map(str::to_string); + + profile +} + +fn read_aws_secret_token( + secret_id: &str, + property: &str, + profile: Option<&str>, +) -> Result> { + if !command_exists("aws") { + return Err( + "Cloudflare API token is not set and AWS CLI (`aws`) is not in PATH. Pass `--api-token`, set CLOUDFLARE_API_TOKEN, or install AWS CLI." + .into(), + ); + } + + log::info!( + "Reading Cloudflare API token from AWS Secrets Manager secret '{}'...", + secret_id + ); + let output = match run_aws_get_secret_value(secret_id, profile) { + Ok(output) => output, + Err(initial_err) => { + if sso_login_required(&initial_err) { + let profile = profile.ok_or_else(|| { + format!( + "failed to read AWS secret '{}': {}\nSSO login is required, but no AWS profile was selected.", + secret_id, initial_err + ) + })?; + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err(format!( + "failed to read AWS secret '{}': {}\nSSO login is required, but no interactive terminal was detected. Run `aws sso login --profile {}` first.", + secret_id, initial_err, profile + ) + .into()); + } + + log::info!( + "AWS SSO token missing/expired for profile '{}'. Running `aws sso login --profile {}`...", + profile, + profile + ); + run_cmd("aws", &["sso", "login", "--profile", profile]).map_err(|login_err| { + format!( + "failed to read AWS secret '{}': {}\nAttempted `aws sso login --profile {}`, but login failed: {}", + secret_id, initial_err, profile, login_err + ) + })?; + run_aws_get_secret_value(secret_id, Some(profile)).map_err(|retry_err| { + format!( + "failed to read AWS secret '{}': {}\nAttempted `aws sso login --profile {}` and retried, but it still failed: {}", + secret_id, initial_err, profile, retry_err + ) + })? + } else { + return Err(format!( + "failed to read AWS secret '{}': {}\nPass `--api-token`, set CLOUDFLARE_API_TOKEN, or verify AWS credentials.", + secret_id, initial_err + ) + .into()); + } + } + }; + + extract_secret_property(output.trim(), property) + .map_err(|err| format!("failed to extract Cloudflare token: {}", err).into()) +} + +fn run_aws_get_secret_value(secret_id: &str, profile: Option<&str>) -> Result { + let mut args = vec![ + "secretsmanager".to_string(), + "get-secret-value".to_string(), + "--secret-id".to_string(), + secret_id.to_string(), + "--query".to_string(), + "SecretString".to_string(), + "--output".to_string(), + "text".to_string(), + ]; + if let Some(profile) = profile { + args.push("--profile".to_string()); + args.push(profile.to_string()); + } + let refs: Vec<&str> = args.iter().map(|arg| arg.as_str()).collect(); + run_cmd_output("aws", &refs).map_err(|err| err.to_string()) +} + +fn sso_login_required(error: &str) -> bool { + let lower = error.to_ascii_lowercase(); + lower.contains("error loading sso token") + || lower.contains("token for") && lower.contains("does not exist") + || lower.contains("sso session associated with this profile has expired") +} + +fn extract_secret_property(secret_string: &str, property: &str) -> Result { + let trimmed = secret_string.trim(); + if trimmed.is_empty() { + return Err("AWS Secrets Manager returned an empty SecretString".to_string()); + } + + if property.trim().is_empty() { + return Ok(trimmed.to_string()); + } + + match serde_json::from_str::(trimmed) { + Ok(value) => value + .get(property) + .and_then(|value| value.as_str()) + .and_then(|value| non_empty(Some(value))) + .map(str::to_string) + .ok_or_else(|| { + format!( + "SecretString JSON does not contain non-empty property '{}'", + property + ) + }), + Err(_) if property == "token" => Ok(trimmed.to_string()), + Err(err) => Err(format!( + "SecretString is not JSON and property '{}' was requested: {}", + property, err + )), + } +} + +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(api_token: &str) -> Result> { + serde_json::to_string(&json!({ + "api_token": api_token, + })) + .map_err(|err| format!("failed to serialize Cloudflare 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( + namespace: &str, + provider_config_name: &str, + secret_name: &str, +) -> String { + format!( + "apiVersion: upjet-cloudflare.m.upbound.io/v1beta1\nkind: ProviderConfig\nmetadata:\n name: {provider_config_name}\n namespace: {namespace}\nspec:\n credentials:\n source: Secret\n secretRef:\n namespace: {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_cloudflare_provider_shape() { + let json = build_credentials_json("cf-token").unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(value["api_token"], "cf-token"); + } + + #[test] + fn extracts_token_property_from_json_secret() { + let token = + extract_secret_property(r#"{"token":"cf-token","other":"ignored"}"#, "token").unwrap(); + assert_eq!(token, "cf-token"); + } + + #[test] + fn default_token_property_accepts_plain_secret_string() { + let token = extract_secret_property("cf-token", "token").unwrap(); + assert_eq!(token, "cf-token"); + } + + #[test] + fn non_default_property_requires_json_secret_string() { + let err = extract_secret_property("cf-token", "api_token").unwrap_err(); + assert!(err.contains("SecretString is not JSON")); + } + + #[test] + fn provider_config_yaml_uses_namespaced_wildbit_cloudflare_api_group() { + let yaml = build_provider_config_yaml("default", "default", "cloudflare-credentials"); + assert!(yaml.contains("apiVersion: upjet-cloudflare.m.upbound.io/v1beta1")); + assert!(yaml.contains("kind: ProviderConfig")); + assert!(yaml.contains("namespace: default")); + assert!(yaml.contains("name: cloudflare-credentials")); + assert!(yaml.contains("key: credentials")); + } + + #[test] + fn provider_yaml_uses_wildbit_dns_provider_package() { + let yaml = build_provider_yaml(DEFAULT_DNS_PROVIDER_NAME, DEFAULT_DNS_PROVIDER_PACKAGE); + assert!(yaml.contains("name: wildbitca-provider-cloudflare-dns")); + assert!(yaml.contains("xpkg.upbound.io/wildbitca/provider-cloudflare-dns:v0.2.5")); + } +} diff --git a/src/commands/local/listmonk.rs b/src/commands/local/listmonk.rs index fed0696..68999bc 100644 --- a/src/commands/local/listmonk.rs +++ b/src/commands/local/listmonk.rs @@ -197,7 +197,9 @@ fn resolve_endpoint(args: &ListmonkArgs) -> Result> { 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_or( + "--endpoint required when --source-secret-name doesn't end with `-provider-creds`", + )?; Ok(format!( "http://{}.{}.svc.cluster.local:9000", release, args.source_namespace @@ -335,11 +337,8 @@ mod tests { #[test] fn provider_config_yaml_uses_cluster_scoped_api_group() { - let yaml = build_provider_config_yaml( - "default", - "crossplane-system", - "listmonk-credentials", - ); + 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")); diff --git a/src/commands/local/mod.rs b/src/commands/local/mod.rs index ce7695c..6adfca6 100644 --- a/src/commands/local/mod.rs +++ b/src/commands/local/mod.rs @@ -1,4 +1,5 @@ mod aws; +mod cloudflare; mod destroy; mod doctor; mod github; @@ -82,6 +83,8 @@ pub enum LocalCommands { Doctor, /// Configure crossplane-contrib provider-family-aws and AWS ProviderConfig Aws(aws::AwsArgs), + /// Configure Wildbit Cloudflare DNS provider and ProviderConfig + Cloudflare(cloudflare::CloudflareArgs), /// Configure crossplane-contrib provider-upjet-github and GitHub ProviderConfig Github(github::GithubArgs), /// Configure crossplane-contrib provider-upjet-zitadel and Zitadel ProviderConfig @@ -111,6 +114,7 @@ pub fn run(args: &LocalArgs) -> Result<(), Box> { LocalCommands::Resize(resize_args) => resize::run(resize_args), LocalCommands::Doctor => doctor::run(), LocalCommands::Aws(aws_args) => aws::run(aws_args), + LocalCommands::Cloudflare(cloudflare_args) => cloudflare::run(cloudflare_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), @@ -293,7 +297,15 @@ pub fn kubectl_patch_merge( "patch", resource, name, "-n", namespace, "--type", "merge", "-p", patch_json, ]; let base_logged = [ - "patch", resource, name, "-n", namespace, "--type", "merge", "-p", "", + "patch", + resource, + name, + "-n", + namespace, + "--type", + "merge", + "-p", + "", ]; let full_args = with_kube_context(&base_args); let full_logged = with_kube_context(&base_logged); diff --git a/src/commands/provider/install.rs b/src/commands/provider/install.rs index c8ad920..321b0d6 100644 --- a/src/commands/provider/install.rs +++ b/src/commands/provider/install.rs @@ -237,14 +237,7 @@ fn run_local_path( crossplane_xpkg_push(&xpkg_path, &push_xpkg_ref)?; let runtime_src = find_runtime_image(&provider_name, arch)?; - let push_runtime_ref = format!( - "{}/hops-ops/{}-{}:{}", - REGISTRY_PUSH, provider_name, arch, dev_tag - ); - let pull_runtime_ref = format!( - "{}/hops-ops/{}-{}:{}", - REGISTRY_PULL, provider_name, arch, dev_tag - ); + let push_runtime_ref = local_runtime_image_ref(&provider_name, arch, &dev_tag); log::info!( "Tagging runtime image {} as {}...", runtime_src, @@ -263,7 +256,7 @@ fn run_local_path( &resolved, &spec_package, Some((&upstream_url_prefix, &local_pull_xpkg_path)), - Some(&pull_runtime_ref), + Some(&push_runtime_ref), skip_dependency_resolution, ) } @@ -397,6 +390,15 @@ fn find_runtime_image(provider_name: &str, arch: &str) -> Result String { + // Provider runtime pods are pulled by the node runtime, not Crossplane's + // package manager, so they need the node-pullable local registry address. + format!( + "{}/hops-ops/{}-{}:{}", + REGISTRY_PUSH, provider_name, arch, tag + ) +} + fn crossplane_xpkg_push(xpkg_path: &Path, push_ref: &str) -> Result<(), Box> { let xpkg_str = xpkg_path.to_string_lossy().to_string(); run_cmd( @@ -1117,6 +1119,17 @@ mod tests { assert!(without.contains("app.kubernetes.io/managed-by: hops-provider-install")); } + + #[test] + fn local_runtime_image_ref_uses_nodeport_registry() { + let image = local_runtime_image_ref("provider-helm", "arm64", "v1.999.3"); + assert_eq!( + image, + "localhost:30500/hops-ops/provider-helm-arm64:v1.999.3" + ); + assert!(!image.contains(REGISTRY_PULL)); + } + #[test] fn build_cluster_role_binding_yaml_targets_service_account() { let yaml = build_cluster_role_binding_yaml("p-cluster-admin", "p");