diff --git a/src/access_handlers/docker.rs b/src/access_handlers/docker.rs index 071e3c9..97d39d5 100644 --- a/src/access_handlers/docker.rs +++ b/src/access_handlers/docker.rs @@ -10,8 +10,8 @@ use minijinja; use tokio; use tracing::{debug, error, info, trace, warn}; -use crate::clients::{docker, render_strict}; use crate::configparser::{get_config, get_profile_config}; +use crate::{clients::docker, utils::render_strict}; /// container registry / daemon access checks #[tokio::main(flavor = "current_thread")] // make this a sync function diff --git a/src/asset_files/challenge_templates/http.yaml.j2 b/src/asset_files/challenge_templates/http.yaml.j2 index 9a1bd17..feaa97b 100644 --- a/src/asset_files/challenge_templates/http.yaml.j2 +++ b/src/asset_files/challenge_templates/http.yaml.j2 @@ -24,6 +24,7 @@ metadata: namespace: "rcds-{{ slug }}" annotations: app.kubernetes.io/managed-by: rcds + cert-manager.io/cluster-issuer: letsencrypt spec: ingressClassName: beavercds rules: @@ -39,3 +40,10 @@ spec: port: number: {{ p.internal }} {% endfor -%} + + tls: + - hosts: + {%- for p in http_ports %} + - "{{ p.expose.http }}.{{ domain }}" + {% endfor -%} + secretName: "rcds-tls-{{ slug }}-{{ pod.name }}" diff --git a/src/asset_files/challenge_templates/tcp.yaml.j2 b/src/asset_files/challenge_templates/tcp.yaml.j2 index f01dfd2..0d79272 100644 --- a/src/asset_files/challenge_templates/tcp.yaml.j2 +++ b/src/asset_files/challenge_templates/tcp.yaml.j2 @@ -9,6 +9,14 @@ metadata: # still use separate domain for these, since exposed LoadBalancer services # will all have different ips from each other external-dns.alpha.kubernetes.io/hostname: "{{ slug }}.{{ domain }}" + + # aws-specific annotations for lb options + service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing" + service.beta.kubernetes.io/aws-load-balancer-backend-protocol: tcp + service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true" + service.beta.kubernetes.io/aws-load-balancer-type: nlb + service.beta.kubernetes.io/aws-load-balancer-manage-backend-security-group-rules: "true" + spec: type: LoadBalancer selector: diff --git a/src/asset_files/setup_manifests/external-dns.helm.yaml.j2 b/src/asset_files/setup_manifests/external-dns.helm.yaml.j2 index 7d484f9..c559c11 100644 --- a/src/asset_files/setup_manifests/external-dns.helm.yaml.j2 +++ b/src/asset_files/setup_manifests/external-dns.helm.yaml.j2 @@ -2,8 +2,6 @@ rbac: create: true -{{ provider_credentials }} - # Watch these resources for new DNS records sources: - service @@ -20,10 +18,10 @@ txtOwnerId: "k8s-external-dns" txtPrefix: "k8s-owner." extraArgs: - # ignore any services with internal ips - #exclude-target-net: "10.0.0.0/8" # special character replacement - txt-wildcard-replacement: star + - --txt-wildcard-replacement=star + # use CNAME instead of ALIAS for alb targets + - --aws-prefer-cname ## Limit external-dns resources resources: @@ -34,3 +32,6 @@ resources: cpu: 10m logLevel: debug + +# assign last to override any previous values if required +{{ provider_credentials }} diff --git a/src/asset_files/setup_manifests/ingress-nginx.helm.yaml b/src/asset_files/setup_manifests/ingress-nginx.helm.yaml index 025ec0b..517e286 100644 --- a/src/asset_files/setup_manifests/ingress-nginx.helm.yaml +++ b/src/asset_files/setup_manifests/ingress-nginx.helm.yaml @@ -3,6 +3,15 @@ controller: ingressClassResource: name: beavercds + # set variety of annotations needed for the cloud providers + + annotations: + service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing" + service.beta.kubernetes.io/aws-load-balancer-backend-protocol: tcp + service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true" + service.beta.kubernetes.io/aws-load-balancer-type: nlb + service.beta.kubernetes.io/aws-load-balancer-manage-backend-security-group-rules: "true" + # nginx values for tcp ports will be set separately in other values file # this will make it easier for `deploy` to update those values without # subsequent calls to `cluster-setup` overwriting changes. diff --git a/src/asset_files/setup_manifests/letsencrypt.issuers.yaml b/src/asset_files/setup_manifests/letsencrypt.issuers.yaml index 0cc1fd3..eb8c38a 100644 --- a/src/asset_files/setup_manifests/letsencrypt.issuers.yaml +++ b/src/asset_files/setup_manifests/letsencrypt.issuers.yaml @@ -4,15 +4,15 @@ metadata: name: letsencrypt spec: acme: - server: https://acme-v02.api.letsencrypt.org/directory" + server: https://acme-v02.api.letsencrypt.org/directory # TODO: use user email? - email: beavercds-prod@example.com + email: beavercds-prod@{{ chal_domain }} privateKeySecretRef: name: letsencrypt-secret solvers: - http01: ingress: - class: nginx + ingressClassName: beavercds --- apiVersion: cert-manager.io/v1 @@ -23,10 +23,10 @@ spec: acme: server: https://acme-staging-v02.api.letsencrypt.org/directory # TODO: use user email? - email: beavercds-staging@example.com + email: beavercds-staging@{{ chal_domain }} privateKeySecretRef: name: letsencrypt-staging-secret solvers: - http01: ingress: - class: nginx + ingressClassName: beavercds diff --git a/src/clients.rs b/src/clients.rs index 545da34..629e24b 100644 --- a/src/clients.rs +++ b/src/clients.rs @@ -345,19 +345,3 @@ pub async fn wait_for_status(client: &kube::Client, object: &DynamicObject) -> R Ok(()) } - -// -// Minijinja strict rendering with error -// - -/// Similar to minijinja.render!(), but return Error if any undefined values. -pub fn render_strict(template: &str, context: minijinja::Value) -> Result { - let mut strict_env = minijinja::Environment::new(); - // error on any undefined template variables - strict_env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict); - - let r = strict_env - .render_str(template, context) - .context(format!("could not render template {:?}", template))?; - Ok(r) -} diff --git a/src/cluster_setup/mod.rs b/src/cluster_setup/mod.rs index 8eb01a9..c55ddb1 100644 --- a/src/cluster_setup/mod.rs +++ b/src/cluster_setup/mod.rs @@ -22,6 +22,7 @@ use tracing::{debug, error, info, trace, warn}; use crate::clients::{apply_manifest_yaml, kube_client}; use crate::configparser::{config, get_config, get_profile_config}; +use crate::utils::render_strict; // Deploy cluster resources needed for challenges to work. // @@ -40,7 +41,8 @@ pub async fn install_ingress(profile: &config::ProfileConfig) -> Result<()> { install_helm_chart( profile, "ingress-nginx", - Some("https://kubernetes.github.io/ingress-nginx"), + "https://kubernetes.github.io/ingress-nginx", + None, "ingress-nginx", INGRESS_NAMESPACE, VALUES, @@ -57,7 +59,8 @@ pub async fn install_certmanager(profile: &config::ProfileConfig) -> Result<()> install_helm_chart( profile, "cert-manager", - Some("https://charts.jetstack.io"), + "https://charts.jetstack.io", + None, "cert-manager", INGRESS_NAMESPACE, VALUES, @@ -67,9 +70,17 @@ pub async fn install_certmanager(profile: &config::ProfileConfig) -> Result<()> let client = kube_client(profile).await?; // letsencrypt and letsencrypt-staging - const ISSUERS_YAML: &str = + const ISSUERS_TEMPLATE: &str = include_str!("../asset_files/setup_manifests/letsencrypt.issuers.yaml"); - apply_manifest_yaml(&client, ISSUERS_YAML).await?; + + let issuers_yaml = render_strict( + ISSUERS_TEMPLATE, + minijinja::context! { + chal_domain => profile.challenges_domain + }, + )?; + + apply_manifest_yaml(&client, &issuers_yaml).await?; Ok(()) } @@ -81,16 +92,20 @@ pub async fn install_extdns(profile: &config::ProfileConfig) -> Result<()> { include_str!("../asset_files/setup_manifests/external-dns.helm.yaml.j2"); // add profile dns: field directly to chart values - let values = minijinja::render!( + let values = render_strict( VALUES_TEMPLATE, - provider_credentials => serde_yml::to_string(&profile.dns)?, - chal_domain => profile.challenges_domain - ); + minijinja::context! { + provider_credentials => serde_yml::to_string(&profile.dns)?, + chal_domain => profile.challenges_domain + }, + )?; + trace!("deploying templated external-dns values:\n{}", values); install_helm_chart( profile, - "oci://registry-1.docker.io/bitnamicharts/external-dns", + "external-dns", + "https://kubernetes-sigs.github.io/external-dns", None, "external-dns", INGRESS_NAMESPACE, @@ -106,11 +121,17 @@ pub async fn install_extdns(profile: &config::ProfileConfig) -> Result<()> { fn install_helm_chart( profile: &config::ProfileConfig, chart: &str, - repo: Option<&str>, + repo: &str, + version: Option<&str>, release_name: &str, namespace: &str, values: &str, ) -> Result<()> { + // make sure `helm` is available to run + duct::cmd!("helm", "version") + .read() + .context("helm binary is not available")?; + // write values to tempfile let mut temp_values = tempfile::Builder::new() .prefix(release_name) @@ -118,8 +139,8 @@ fn install_helm_chart( .tempfile()?; temp_values.write_all(values.as_bytes())?; - let repo_arg = match repo { - Some(r) => format!("--repo {r}"), + let version_arg = match version { + Some(v) => format!("--version {v}"), None => "".to_string(), }; @@ -134,7 +155,7 @@ fn install_helm_chart( r#" upgrade --install {release_name} - {chart} {repo_arg} + {chart} --repo {repo} {version_arg} --namespace {namespace} --create-namespace --values {} --wait --timeout 1m diff --git a/src/commands/build.rs b/src/commands/build.rs index 676bd80..602ee5e 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use itertools::Itertools; use std::process::exit; use tracing::{debug, error, info, trace, warn}; @@ -6,15 +7,12 @@ use crate::builder::build_challenges; use crate::configparser::{get_config, get_profile_config}; #[tokio::main(flavor = "current_thread")] // make this a sync function -pub async fn run(profile_name: &str, push: &bool, extract: &bool) { +pub async fn run(profile_name: &str, push: &bool, extract: &bool) -> Result<()> { info!("building images..."); - let results = match build_challenges(profile_name, *push, *extract).await { - Ok(results) => results, - Err(e) => { - error!("{e:?}"); - exit(1) - } - }; + let results = build_challenges(profile_name, *push, *extract).await?; + info!("images built successfully!"); + + Ok(()) } diff --git a/src/commands/check_access.rs b/src/commands/check_access.rs index bae35d1..841a117 100644 --- a/src/commands/check_access.rs +++ b/src/commands/check_access.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Error, Result}; +use anyhow::{bail, Context, Error, Result}; use itertools::Itertools; use std::process::exit; use tracing::{debug, error, info, trace, warn}; @@ -6,7 +6,13 @@ use tracing::{debug, error, info, trace, warn}; use crate::access_handlers as access; use crate::configparser::{get_config, get_profile_config}; -pub fn run(profile: &str, kubernetes: &bool, frontend: &bool, registry: &bool, bucket: &bool) { +pub fn run( + profile: &str, + kubernetes: &bool, + frontend: &bool, + registry: &bool, + bucket: &bool, +) -> Result<()> { // if user did not give a specific check, check all of them let check_all = !kubernetes && !frontend && !registry && !bucket; @@ -36,6 +42,7 @@ pub fn run(profile: &str, kubernetes: &bool, frontend: &bool, registry: &bool, b debug!("access results: {results:?}"); // die if there were any errors + // TODO: figure out how to return this error directly let mut should_exit = false; for (profile, result) in results.iter() { match result { @@ -48,8 +55,10 @@ pub fn run(profile: &str, kubernetes: &bool, frontend: &bool, registry: &bool, b } } if should_exit { - exit(1); + bail!("config validation failed"); } + + Ok(()) } /// checks a single profile (`profile`) for the given accesses diff --git a/src/commands/cluster_setup.rs b/src/commands/cluster_setup.rs index b6cb16b..c1e0289 100644 --- a/src/commands/cluster_setup.rs +++ b/src/commands/cluster_setup.rs @@ -7,22 +7,15 @@ use crate::cluster_setup as setup; use crate::configparser::{get_config, get_profile_config}; #[tokio::main(flavor = "current_thread")] // make this a sync function -pub async fn run(profile_name: &str) { +pub async fn run(profile_name: &str) -> Result<()> { info!("setting up cluster..."); let config = get_profile_config(profile_name).unwrap(); - if let Err(e) = setup::install_ingress(config).await { - error!("{e:?}"); - exit(1); - } - if let Err(e) = setup::install_certmanager(config).await { - error!("{e:?}"); - exit(1); - } - if let Err(e) = setup::install_extdns(config).await { - error!("{e:?}"); - exit(1); - } + setup::install_ingress(config).await?; + setup::install_certmanager(config).await?; + setup::install_extdns(config).await?; - info!("charts deployed!") + info!("charts deployed!"); + + Ok(()) } diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index 930b052..4cb1f31 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use itertools::Itertools; use std::process::exit; use tracing::{debug, error, info, trace, warn}; @@ -7,14 +8,11 @@ use crate::configparser::{get_config, get_profile_config}; use crate::deploy; #[tokio::main(flavor = "current_thread")] // make this a sync function -pub async fn run(profile_name: &str, no_build: &bool, _dry_run: &bool) { +pub async fn run(profile_name: &str, no_build: &bool, _dry_run: &bool) -> Result<()> { let profile = get_profile_config(profile_name).unwrap(); // has the cluster been setup? - if let Err(e) = deploy::check_setup(profile).await { - error!("{e:?}"); - exit(1); - } + deploy::check_setup(profile).await?; // build before deploying if *no_build { @@ -24,13 +22,7 @@ pub async fn run(profile_name: &str, no_build: &bool, _dry_run: &bool) { } info!("building challenges..."); - let build_results = match build_challenges(profile_name, true, true).await { - Ok(result) => result, - Err(e) => { - error!("{e:?}"); - exit(1); - } - }; + let build_results = build_challenges(profile_name, true, true).await?; trace!( "got built results: {:#?}", @@ -47,20 +39,13 @@ pub async fn run(profile_name: &str, no_build: &bool, _dry_run: &bool) { // C) update frontend with new state of challenges // A) - if let Err(e) = deploy::kubernetes::deploy_challenges(profile_name, &build_results).await { - error!("{e:?}"); - exit(1); - } + deploy::kubernetes::deploy_challenges(profile_name, &build_results).await?; // B) - if let Err(e) = deploy::s3::upload_assets(profile_name, &build_results).await { - error!("{e:?}"); - exit(1); - } + deploy::s3::upload_assets(profile_name, &build_results).await?; // C) - if let Err(e) = deploy::frontend::update_frontend(profile_name, &build_results).await { - error!("{e:?}"); - exit(1); - } + deploy::frontend::update_frontend(profile_name, &build_results).await?; + + Ok(()) } diff --git a/src/commands/validate.rs b/src/commands/validate.rs index 0399f88..ccad57d 100644 --- a/src/commands/validate.rs +++ b/src/commands/validate.rs @@ -1,28 +1,26 @@ +use anyhow::{bail, Result}; use std::path::Path; use std::process::exit; use tracing::{debug, error, info, trace, warn}; use crate::configparser::{get_challenges, get_config, get_profile_deploy}; -pub fn run() { +pub fn run() -> Result<()> { info!("validating config..."); - let config = match get_config() { - Ok(c) => c, - Err(err) => { - error!("{err:#}"); - exit(1); - } - }; + + let config = get_config()?; info!(" config ok!"); info!("validating challenges..."); + // print these errors here instead of returning, since its a vec of them + // TODO: figure out how to return this error directly let chals = match get_challenges() { Ok(c) => c, Err(errors) => { for e in errors.iter() { error!("{e:#}"); } - exit(1); + bail!("failed to validate challenges"); } }; info!(" challenges ok!"); @@ -31,29 +29,28 @@ pub fn run() { info!("validating deploy config..."); for (profile_name, _pconfig) in config.profiles.iter() { // fetch from config - let deploy_challenges = match get_profile_deploy(profile_name) { - Ok(d) => &d.challenges, - Err(err) => { - error!("{err:#}"); - exit(1); - } - }; + let deploy_challenges = get_profile_deploy(profile_name)?; // check for missing let missing: Vec<_> = deploy_challenges + .challenges .keys() .filter( // try to find any challenge paths in deploy config that do not exist |path| !chals.iter().any(|c| c.directory == Path::new(path)), ) .collect(); + + // TODO: figure out how to return this error directly if !missing.is_empty() { error!( "Deploy settings for profile '{profile_name}' has challenges that do not exist:" ); missing.iter().for_each(|path| error!(" - {path}")); - exit(1) + bail!("failed to validate deploy config"); } } - info!(" deploy ok!") + info!(" deploy ok!"); + + Ok(()) } diff --git a/src/configparser/challenge.rs b/src/configparser/challenge.rs index 960330a..5c1dfea 100644 --- a/src/configparser/challenge.rs +++ b/src/configparser/challenge.rs @@ -12,10 +12,10 @@ use std::str::FromStr; use tracing::{debug, error, info, trace, warn}; use void::Void; -use crate::clients::render_strict; use crate::configparser::config::Resource; use crate::configparser::field_coersion::string_or_struct; use crate::configparser::get_config; +use crate::utils::render_strict; pub fn parse_all() -> Result, Vec> { // find all challenge.yaml files diff --git a/src/deploy/kubernetes/mod.rs b/src/deploy/kubernetes/mod.rs index d1f2a64..bc63c11 100644 --- a/src/deploy/kubernetes/mod.rs +++ b/src/deploy/kubernetes/mod.rs @@ -12,7 +12,7 @@ use crate::clients::{apply_manifest_yaml, kube_client, wait_for_status}; use crate::configparser::challenge::ExposeType; use crate::configparser::config::ProfileConfig; use crate::configparser::{get_config, get_profile_config, ChallengeConfig}; -use crate::utils::TryJoinAll; +use crate::utils::{render_strict, TryJoinAll}; pub mod templates; @@ -73,10 +73,10 @@ async fn deploy_single_challenge( let kube = kube_client(profile).await?; - let ns_manifest = minijinja::render!( + let ns_manifest = render_strict( templates::CHALLENGE_NAMESPACE, - chal, slug => chal.slugify() - ); + minijinja::context! { chal, slug => chal.slugify() }, + )?; trace!("NAMESPACE:\n{}", ns_manifest); debug!("applying namespace for chal {:?}", chal.directory); @@ -94,10 +94,13 @@ async fn deploy_single_challenge( for pod in &chal.pods { let pod_image = chal.container_tag_for_pod(profile_name, &pod.name)?; - let depl_manifest = minijinja::render!( + let depl_manifest = render_strict( templates::CHALLENGE_DEPLOYMENT, - chal, pod, pod_image, profile_name, slug => chal.slugify(), - ); + minijinja::context! { + chal, pod, pod_image, profile_name, + slug => chal.slugify(), + }, + )?; trace!("DEPLOYMENT:\n{}", depl_manifest); debug!( @@ -132,10 +135,13 @@ async fn deploy_single_challenge( .partition(|p| matches!(p.expose, ExposeType::Tcp(_))); if !tcp_ports.is_empty() { - let tcp_manifest = minijinja::render!( + let tcp_manifest = render_strict( templates::CHALLENGE_SERVICE_TCP, - chal, pod, tcp_ports, slug => chal.slugify(), domain => profile.challenges_domain - ); + minijinja::context! { + chal, pod, tcp_ports, + slug => chal.slugify(), domain => profile.challenges_domain + }, + )?; trace!("TCP SERVICE:\n{}", tcp_manifest); debug!( @@ -168,10 +174,13 @@ async fn deploy_single_challenge( } if !http_ports.is_empty() { - let http_manifest = minijinja::render!( + let http_manifest = render_strict( templates::CHALLENGE_SERVICE_HTTP, - chal, pod, http_ports, slug => chal.slugify(), domain => profile.challenges_domain - ); + minijinja::context! { + chal, pod, http_ports, + slug => chal.slugify(), domain => profile.challenges_domain + }, + )?; trace!("HTTP INGRESS:\n{}", http_manifest); debug!( diff --git a/src/main.rs b/src/main.rs index 6f9ae5f..f5c71c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use beavercds_ng::commands; use clap::Parser; -use tracing::{trace, Level}; +use tracing::{error, trace, Level}; use tracing_subscriber::{ fmt::{format::FmtSpan, time}, EnvFilter, @@ -38,7 +38,19 @@ fn main() { .init(); trace!("args: {:?}", cli); + // dispatch commands + match dispatch(cli) { + Ok(_) => (), + Err(e) => { + error!("{e:?}"); + std::process::exit(1) + } + }; +} + +/// dispatch commands +fn dispatch(cli: cli::Cli) -> anyhow::Result<()> { match &cli.command { cli::Commands::Validate => commands::validate::run(), @@ -49,7 +61,7 @@ fn main() { registry, bucket, } => { - commands::validate::run(); + commands::validate::run()?; commands::check_access::run(profile, kubernetes, frontend, registry, bucket) } @@ -60,7 +72,7 @@ fn main() { no_push, extract_assets, } => { - commands::validate::run(); + commands::validate::run()?; commands::build::run(profile, &!no_push, extract_assets) } @@ -69,12 +81,13 @@ fn main() { no_build, dry_run, } => { - commands::validate::run(); + commands::validate::run()?; commands::deploy::run(profile, no_build, dry_run) } cli::Commands::ClusterSetup { profile } => { - commands::cluster_setup::run(profile); + commands::validate::run()?; + commands::cluster_setup::run(profile) } } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 2b6d037..830084f 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ +use anyhow::{Context, Result}; use futures::{future::try_join_all, TryFuture}; /// Helper trait for `Iterator` to add futures::try_await_all() as chain method. @@ -26,3 +27,19 @@ where try_join_all(self).await } } + +// +// Minijinja strict rendering with error +// + +/// Similar to minijinja.render!(), but return Error if any undefined values. +pub fn render_strict(template: &str, context: minijinja::Value) -> Result { + let mut strict_env = minijinja::Environment::new(); + // error on any undefined template variables + strict_env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict); + + let r = strict_env + .render_str(template, context) + .context(format!("could not render template {:?}", template))?; + Ok(r) +}