diff --git a/src/config.rs b/src/config.rs index 8a107cc..71402f4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ //! Environment-derived configuration for both modes. +use crate::env::Env; use crate::error::{Error, Result}; use crate::gh_oidc::{Principal, PrincipalKind}; use base64::Engine as _; @@ -26,7 +27,7 @@ impl CfCreds { /// Configuration shared between modes. pub struct Common { - pub env_label: String, + pub env: Env, pub port: u16, pub owner: Principal, pub vm_name: String, @@ -34,9 +35,9 @@ pub struct Common { impl Common { pub fn from_env() -> Result { - let env_label = std::env::var("DD_ENV").map_err(|_| { + let env = Env::parse(&std::env::var("DD_ENV").map_err(|_| { Error::Internal("DD_ENV required (dev / staging / production / pr-*)".into()) - })?; + })?)?; let port = std::env::var("DD_PORT") .ok() .and_then(|s| s.parse().ok()) @@ -79,7 +80,7 @@ impl Common { .unwrap_or_else(|| "unknown".into()) }); Ok(Self { - env_label, + env, port, owner, vm_name, @@ -119,7 +120,7 @@ impl ItaMode { } impl Ita { - pub fn from_env(env_label: &str) -> Result { + pub fn from_env(env: &Env) -> Result { let get = |k: &str| { std::env::var(k) .ok() @@ -134,7 +135,7 @@ impl Ita { { "" | "intel" => ItaMode::Intel, "local" => { - if env_label == "production" || env_label == "staging" { + if env.requires_intel_ita() { return Err(Error::Internal( "DD_ITA_MODE=local is not allowed for production or staging".into(), )); @@ -201,7 +202,7 @@ impl Cp { "DD_SCRAPER_SHARD_INDEX ({scraper_shard_index}) must be less than DD_SCRAPER_SHARD_TOTAL ({scraper_shard_total})" ))); } - let ita = Ita::from_env(&common.env_label)?; + let ita = Ita::from_env(&common.env)?; Ok(Self { common, cf, @@ -295,7 +296,7 @@ impl Agent { .unwrap_or_else(|_| "/var/lib/easyenclave/data/dd-agent/devices.json".into()) .into(); let oracles = parse_oracles()?; - let ita = Ita::from_env(&common.env_label)?; + let ita = Ita::from_env(&common.env)?; Ok(Self { common, cp_url, diff --git a/src/cp.rs b/src/cp.rs index 3ec6771..c90ac56 100644 --- a/src/cp.rs +++ b/src/cp.rs @@ -130,7 +130,7 @@ pub async fn run() -> Result<()> { // safe to run unconditionally — it doesn't touch the poisonable // hostname at all. let store: Store = Arc::new(Mutex::new(HashMap::new())); - let predecessor_prefix = cf::cp_prefix(&cfg.common.env_label); + let predecessor_prefix = cf::cp_prefix(cfg.common.env.label()); let has_predecessor = match cf::list(&http, &cfg.cf).await { Ok(tunnels) => tunnels.iter().any(|t| { t["name"] @@ -156,7 +156,7 @@ pub async fn run() -> Result<()> { // for `cfg.hostname` → our tunnel id; traffic moves to us the // moment CF's edge propagates that change. eprintln!("cp: self-provisioning tunnel for {}", cfg.hostname); - let self_name = cf::cp_tunnel_name(&cfg.common.env_label); + let self_name = cf::cp_tunnel_name(cfg.common.env.label()); let cp_extras: Vec<(String, u16)> = vec![("shell".into(), 7682)]; let tunnel = match cf::create(&http, &cfg.cf, &self_name, &cfg.hostname, &cp_extras).await { Ok(t) => t, @@ -184,7 +184,7 @@ pub async fn run() -> Result<()> { match cf::delete_cp_access_apps( &http, &cfg.cf, - &cfg.common.env_label, + cfg.common.env.label(), &cfg.hostname, &cp_labels, ) @@ -217,7 +217,7 @@ pub async fn run() -> Result<()> { collector::Agent { agent_id: "control-plane".into(), hostname: cfg.hostname.clone(), - vm_name: format!("dd-{}-cp", cfg.common.env_label), + vm_name: format!("dd-{}-cp", cfg.common.env.label()), attestation_type: "tdx".into(), status: "healthy".into(), last_seen: chrono::Utc::now(), @@ -309,7 +309,7 @@ pub async fn run() -> Result<()> { tokio::spawn(collector::run( store.clone(), cfg.cf.clone(), - cfg.common.env_label.clone(), + cfg.common.env.label().to_string(), cfg.hostname.clone(), ee.clone(), verifier.clone(), @@ -467,7 +467,7 @@ async fn health( "ok": true, "service": "cp", "hostname": s.cfg.hostname, - "env": s.cfg.common.env_label, + "env": s.cfg.common.env.label(), // Commit this binary was built from, baked at compile time // (`DD_BUILD_SHA` env in the release build step). "dev" for local // builds. Lets a deploy confirm which build is actually live — @@ -541,7 +541,7 @@ async fn register( } let http = cf::http_client(); - let name = cf::agent_tunnel_name(&s.cfg.common.env_label); + let name = cf::agent_tunnel_name(s.cfg.common.env.label()); let agent_hostname = format!("{name}.{}", s.cfg.cf.domain); let extras: Vec<(String, u16)> = req .extra_ingress @@ -561,7 +561,7 @@ async fn register( if let Err(e) = cf::delete_agent_access_apps( &http, &s.cfg.cf, - &s.cfg.common.env_label, + s.cfg.common.env.label(), &agent_hostname, &labels, ) @@ -694,7 +694,7 @@ async fn ingress_replace( if let Err(e) = cf::delete_agent_access_apps( &http, &s.cfg.cf, - &s.cfg.common.env_label, + s.cfg.common.env.label(), &hostname, &labels, ) @@ -980,7 +980,7 @@ fn fleet_json(s: &St, by_id: &[(String, collector::Agent)]) -> serde_json::Value let oracle_total: usize = by_id.iter().map(|(_, a)| a.oracles.len()).sum(); serde_json::json!({ "generated_at": chrono::Utc::now().to_rfc3339(), - "env": s.cfg.common.env_label, + "env": s.cfg.common.env.label(), "hostname": s.cfg.hostname, "summary": { "agents": by_id.len(), @@ -1111,7 +1111,7 @@ async fn fleet(State(s): State, headers: HeaderMap, uri: Uri) -> Response { Err(resp) => return resp, }; let by_id = scoped_snapshot(&s, &session).await; - let body = render_fleet_body(&s.cfg.hostname, &s.cfg.common.env_label, &by_id); + let body = render_fleet_body(&s.cfg.hostname, s.cfg.common.env.label(), &by_id); Html(shell( "DD Fleet", &html::nav(&[("Fleet", "/", true)]), @@ -1141,7 +1141,7 @@ async fn fleet_fragment(State(s): State, headers: HeaderMap, uri: Uri) -> Re } else { Html(render_fleet_body( &s.cfg.hostname, - &s.cfg.common.env_label, + s.cfg.common.env.label(), &by_id, )) .into_response() @@ -1304,7 +1304,7 @@ async fn cf_snapshot_handler( let snap = crate::cf_snapshot::snapshot( &http, &s.cfg.cf, - &s.cfg.common.env_label, + s.cfg.common.env.label(), &s.cfg.hostname, &s.store, ) diff --git a/src/env.rs b/src/env.rs new file mode 100644 index 0000000..d6cd271 --- /dev/null +++ b/src/env.rs @@ -0,0 +1,156 @@ +//! First-class environment identity for the control plane. +//! +//! Every DD installation — production, staging, dev, a per-PR preview, or +//! a named long-lived install like `bot` / `dogfood` — is one [`Env`]. +//! It is the single axis that distinguishes installations: all Cloudflare +//! resource names derive from [`Env::label`] (see [`crate::cf`]), and +//! env-intrinsic behaviour (Intel ITA enforcement, ephemerality) lives +//! here as methods rather than scattered `== "production"` string checks. + +use crate::error::{Error, Result}; + +/// Well-known classes of environment. Anything that validates as a label +/// but isn't one of these is [`EnvKind::Named`] — so a legitimately-named +/// install (`bot`, `dogfood`, a future env) is never rejected at boot, +/// only genuinely malformed `DD_ENV` values are. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EnvKind { + Production, + Staging, + Dev, + /// A per-PR ephemeral preview, `pr-`. + Preview, + /// A valid but non-well-known label (e.g. `bot`, `dogfood`). + Named, +} + +/// A parsed, validated environment label. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Env { + label: String, + kind: EnvKind, +} + +impl Env { + /// Parse and validate the `DD_ENV` label. + /// + /// The label is embedded directly into Cloudflare resource names + /// (`dd-{label}-cp-…`) and hostnames, so it must be a lowercase + /// DNS-style token: `^[a-z0-9][a-z0-9-]*$`, at most 40 chars. This + /// rejects empty/whitespace/uppercase values and junk like + /// `invalid name {uuid}` that would silently break collector + /// discovery, while still accepting any well-formed install name. + pub fn parse(s: &str) -> Result { + let label = s.trim(); + let valid = !label.is_empty() + && label.len() <= 40 + && label + .bytes() + .next() + .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + && label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if !valid { + return Err(Error::Internal(format!( + "DD_ENV {s:?} invalid: expected a lowercase DNS label \ + (e.g. production, staging, dev, pr-42, bot)" + ))); + } + let kind = match label { + "production" => EnvKind::Production, + "staging" => EnvKind::Staging, + "dev" => EnvKind::Dev, + // Per-PR previews are `pr-`. Any other label is a named + // long-lived install. + _ if label.starts_with("pr-") && label.len() > 3 => EnvKind::Preview, + _ => EnvKind::Named, + }; + Ok(Self { + label: label.to_string(), + kind, + }) + } + + /// The canonical env label string (what every CF name derives from). + pub fn label(&self) -> &str { + &self.label + } + + /// The classified kind. + pub fn kind(&self) -> &EnvKind { + &self.kind + } + + /// Production and staging must use real Intel TDX attestation; other + /// envs may run a signed local token on hosts without Intel PCCS + /// collateral. Mirrors the prior `== "production" || == "staging"` + /// check that gated `DD_ITA_MODE=local`. + pub fn requires_intel_ita(&self) -> bool { + matches!(self.kind, EnvKind::Production | EnvKind::Staging) + } + + /// Per-PR previews are torn down with the PR; every other install is + /// persistent. Drives lifecycle policy (libvirt autostart, CF GC TTL). + pub fn is_ephemeral(&self) -> bool { + matches!(self.kind, EnvKind::Preview) + } +} + +impl std::fmt::Display for Env { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.label) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn well_known_kinds() { + assert_eq!( + *Env::parse("production").unwrap().kind(), + EnvKind::Production + ); + assert_eq!(*Env::parse("staging").unwrap().kind(), EnvKind::Staging); + assert_eq!(*Env::parse("dev").unwrap().kind(), EnvKind::Dev); + assert_eq!(*Env::parse("pr-42").unwrap().kind(), EnvKind::Preview); + // Named long-lived installs are accepted, not rejected. + assert_eq!(*Env::parse("bot").unwrap().kind(), EnvKind::Named); + assert_eq!(*Env::parse("dogfood").unwrap().kind(), EnvKind::Named); + } + + #[test] + fn label_roundtrips_and_trims() { + assert_eq!(Env::parse(" production ").unwrap().label(), "production"); + assert_eq!(Env::parse("pr-7").unwrap().label(), "pr-7"); + } + + #[test] + fn rejects_malformed() { + assert!(Env::parse("").is_err()); + assert!(Env::parse(" ").is_err()); + assert!(Env::parse("Production").is_err()); // uppercase + assert!(Env::parse("invalid name").is_err()); // whitespace + assert!(Env::parse("-leading-hyphen").is_err()); + assert!(Env::parse("pr_42").is_err()); // underscore not allowed + assert!(Env::parse(&"x".repeat(41)).is_err()); // too long + } + + #[test] + fn requires_intel_ita_matches_prior_behaviour() { + assert!(Env::parse("production").unwrap().requires_intel_ita()); + assert!(Env::parse("staging").unwrap().requires_intel_ita()); + assert!(!Env::parse("dev").unwrap().requires_intel_ita()); + assert!(!Env::parse("pr-42").unwrap().requires_intel_ita()); + assert!(!Env::parse("bot").unwrap().requires_intel_ita()); + } + + #[test] + fn ephemerality() { + assert!(Env::parse("pr-42").unwrap().is_ephemeral()); + assert!(!Env::parse("production").unwrap().is_ephemeral()); + assert!(!Env::parse("bot").unwrap().is_ephemeral()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4e28092..8049194 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod config; pub mod cp; pub mod devices; pub mod ee; +pub mod env; pub mod error; pub mod gh_oidc; pub mod html;