diff --git a/CHANGELOG.md b/CHANGELOG.md index 01f68983..88bc1916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -233,6 +233,19 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): always present. `SealOutbox::unsealed_published_count` is now `unsealed_published_counts` and returns both. `odal seal status` prints both. +- **A `NODE_PROFILE` that is not a profile refuses to start.** `Production`, + `prod` and `production ` with a trailing space used to boot as + `development`, the profile that allows stub trust ports and honours + `ALLOW_UNSIGNED_PLUGINS=true`, so a mistyped production deployment ran the + permissive profile without a word. The accepted values are exactly + `development`, `sandbox` and `production`; the error quotes what it found. + Unset and empty are unchanged and still mean `development`. + `NodeProfile::from_env` now returns a `Result`, and `NodeProfile::parse` is + the same check on a raw value. + + **Migration:** a deployment that sets `NODE_PROFILE` to anything else fixes the + spelling, or unsets the variable to keep `development`. + ### Added - **Bulk import and CSV templates reach `electronics` and `detergent`.** Both diff --git a/crates/dpp-node/src/main.rs b/crates/dpp-node/src/main.rs index ed71bc15..e605288f 100644 --- a/crates/dpp-node/src/main.rs +++ b/crates/dpp-node/src/main.rs @@ -64,7 +64,7 @@ async fn main() -> anyhow::Result<()> { // Read once and handed to both gates that depend on it — the plugin signing // policy below and the trust report — so the two cannot disagree about it. - let profile = NodeProfile::from_env(); + let profile = NodeProfile::from_env().context("Invalid NODE_PROFILE")?; // ── Wasm plugin host ────────────────────────────────────────────────────── let plugin_host = diff --git a/crates/dpp-types/src/trust.rs b/crates/dpp-types/src/trust.rs index 937a182c..eaf0c3a9 100644 --- a/crates/dpp-types/src/trust.rs +++ b/crates/dpp-types/src/trust.rs @@ -88,15 +88,55 @@ pub enum NodeProfile { Production, } +/// A `NODE_PROFILE` that was set to something other than a profile. +/// +/// Refused rather than read as `development`: a value that was set is a +/// statement of intent, and reading `Production` or `prod` as the permissive +/// profile would boot a node the operator meant to be strict without a word. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error( + "NODE_PROFILE={value:?} is not a profile. Use exactly `development`, `sandbox` or \ + `production`, or leave it unset for `development`" +)] +pub struct UnknownProfile { + /// What the variable held, lossily decoded if it was not valid UTF-8. + pub value: String, +} + impl NodeProfile { - /// Read `NODE_PROFILE` from the environment. Anything other than - /// `production` or `sandbox` (including unset) is `Development`. - #[must_use] - pub fn from_env() -> Self { - match std::env::var("NODE_PROFILE").ok().as_deref() { - Some("production") => Self::Production, - Some("sandbox") => Self::Sandbox, - _ => Self::Development, + /// Read `NODE_PROFILE` from the environment. + /// + /// Unset or empty is `Development`, the documented default (an empty value + /// is a blanked template variable, read as unset). `development`, `sandbox` + /// and `production` are the three profiles, matched exactly. + /// + /// # Errors + /// + /// [`UnknownProfile`] for any other value, so a misspelling cannot boot as + /// the permissive profile. + pub fn from_env() -> Result { + match std::env::var("NODE_PROFILE") { + Ok(raw) => Self::parse(Some(&raw)), + Err(std::env::VarError::NotPresent) => Self::parse(None), + Err(std::env::VarError::NotUnicode(raw)) => Err(UnknownProfile { + value: raw.to_string_lossy().into_owned(), + }), + } + } + + /// The profile a raw `NODE_PROFILE` value names. See [`Self::from_env`]. + /// + /// # Errors + /// + /// [`UnknownProfile`] for a value that is none of the three profiles. + pub fn parse(raw: Option<&str>) -> Result { + match raw { + None | Some("" | "development") => Ok(Self::Development), + Some("sandbox") => Ok(Self::Sandbox), + Some("production") => Ok(Self::Production), + Some(other) => Err(UnknownProfile { + value: other.to_owned(), + }), } } @@ -247,6 +287,52 @@ mod tests { ] } + #[test] + fn each_profile_name_parses_to_its_profile() { + assert_eq!( + NodeProfile::parse(Some("development")), + Ok(NodeProfile::Development) + ); + assert_eq!( + NodeProfile::parse(Some("sandbox")), + Ok(NodeProfile::Sandbox) + ); + assert_eq!( + NodeProfile::parse(Some("production")), + Ok(NodeProfile::Production) + ); + } + + /// Unset is the documented default, and an empty value is a blanked + /// template variable: both stay `Development`. + #[test] + fn unset_and_empty_are_development() { + assert_eq!(NodeProfile::parse(None), Ok(NodeProfile::Development)); + assert_eq!(NodeProfile::parse(Some("")), Ok(NodeProfile::Development)); + } + + /// 🚨 Each of these used to boot as `Development` — the profile that allows + /// ghost trust ports and unsigned plugins — with nothing said. + #[test] + fn a_misspelled_profile_is_refused_and_named() { + for bad in [ + "Production", + "PRODUCTION", + "prod", + "production ", + " production", + "Sandbox", + "staging", + "live", + ] { + let err = NodeProfile::parse(Some(bad)).expect_err(bad); + assert_eq!(err.value, bad); + // The value is quoted, so a stray space is visible in the message. + assert!(err.to_string().contains(&format!("{bad:?}")), "{err}"); + assert!(err.to_string().contains("`production`"), "{err}"); + } + } + #[test] fn gauge_encoding_is_stable() { assert_eq!(TrustMode::Ghost.gauge_value(), 0.0); diff --git a/crates/dpp-vault/src/main.rs b/crates/dpp-vault/src/main.rs index 6d571981..1959d5fc 100644 --- a/crates/dpp-vault/src/main.rs +++ b/crates/dpp-vault/src/main.rs @@ -89,7 +89,7 @@ async fn main() -> anyhow::Result<()> { // adapter when configured) — so a production profile must refuse to boot // here too, or this binary would silently run with placeholder trust. let trust = NodeTrustReport::new( - NodeProfile::from_env(), + NodeProfile::from_env().context("Invalid NODE_PROFILE")?, vec![ TrustPort { port: "registry_sync", diff --git a/docs/guides/DEVELOPER-GUIDE.md b/docs/guides/DEVELOPER-GUIDE.md index a2d79c2b..48160444 100644 --- a/docs/guides/DEVELOPER-GUIDE.md +++ b/docs/guides/DEVELOPER-GUIDE.md @@ -92,7 +92,7 @@ binary, with its default and its reasoning. The ones you need to run the node: | `DID_WEB_BASE_URL` | `http://localhost:8001` | Origin of the `did:web` identifier | | `RESOLVER_BASE_URL` | `http://localhost:8003` | Origin printed into QR codes. Required; no default | | `ADMIN_USERNAME`, `ADMIN_PASSWORD` | | Local admin credential for `odal bootstrap`. Leave unset once a key exists if you do not want the path | -| `NODE_PROFILE` | `development` (default) | `sandbox` refuses to start while a required trust adapter is a stub; `production` also refuses sandbox adapters | +| `NODE_PROFILE` | `development` (default) | Exactly `development`, `sandbox` or `production`; any other value refuses to start. `sandbox` refuses to start while a required trust adapter is a stub; `production` also refuses sandbox adapters | | `LOG_LEVEL` | `info` | A `tracing` filter, so `dpp_vault=debug,info` also works | | `LOG_FORMAT` | unset | `pretty` for readable terminal output; JSON otherwise | diff --git a/docs/ops/PRODUCTION-RUNBOOK.md b/docs/ops/PRODUCTION-RUNBOOK.md index d35cc663..2f85dfc7 100644 --- a/docs/ops/PRODUCTION-RUNBOOK.md +++ b/docs/ops/PRODUCTION-RUNBOOK.md @@ -119,7 +119,7 @@ Caddy obtains and renews the certificates. | `KEY_STORE_PATH` | Set by Compose | Leave it on the `node-data` volume; anywhere else, a container recreate generates a new key | | `ADMIN_USERNAME`, `ADMIN_PASSWORD` | For onboarding | Remove them once API keys exist, unless you want the recovery path available | | `ODAL_VERSION` | Yes | A release number without the tag's `v` (tag `v0.14.0` is image `0.14.0`). Never `latest`. Record the image digest you deploy, and pin the PostgreSQL image digest too | -| `NODE_PROFILE` | No | Leave unset (`development`) until every required trust port can be live | +| `NODE_PROFILE` | No | Leave unset (`development`) until every required trust port can be live. A value that is not exactly `development`, `sandbox` or `production` stops the node at boot | | `METRICS_ADDR` | No | Keep on loopback. See [METRICS-PLAN.md](METRICS-PLAN.md) | | `PLUGINS_DIR` | Set by Compose (`/plugins`) | Put the signed product group `.wasm` files and their `.sig` files on the `node-plugins` volume, and set `PLUGIN_SIGNING_KEY` | | `RULESET_BUNDLE_PATH`, `RULESET_PUBLISHER_PUBKEY` | No | Both or neither. A bad bundle keeps the last good one and increments `ruleset_load_failures_total`. A new bundle is taken within `RULESET_POLL_INTERVAL_SECS` (default 300), or at once with `odal ruleset reload` |