diff --git a/.env.example b/.env.example index 21ee0700..9b32a832 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,18 @@ # own environment — the vars live here only so everything is in one place. # ============================================================================= +# ============================================================================= +# IMAGES — read by docker compose (docker/docker-compose.yml), not by any binary. +# ============================================================================= +# Which release of the published node and resolver images `odal up` runs. +# Write the release number WITHOUT the git tag's leading `v`: the release +# tagged v0.14.0 is image 0.14.0, and `v0.14.0` names no image at all. A bare +# minor (0.14) follows that minor's newest patch. Unset means `latest`, which +# moves on every `odal update`; pin a release in production. Inside a clone of +# the engine the images are built from source instead, and this only names the +# local build. +# ODAL_VERSION= + # ============================================================================= # NODE — the MVP binary `dpp-node` (vault + identity + integrator on one port, # routed /vault/* /identity/* /integrator/*). This is what `odal up` runs. diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f91e5f..31ce1393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -291,6 +291,27 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): ### Fixed +- **`odal` runs the published images outside a clone of this repository.** The + guided setup, the console's Start and `odal update` decided whether to build + from the profile kind, and every self-hosted node infers `dev` because it is + reached over localhost, so every install was sent down `docker compose up + --build`. Outside a clone the compose file's build context does not exist, and + the build failed before any service started. They now decide the way `odal up` + already did, by whether the install carries the engine source. `odal update` + rebuilds and restarts inside a clone and pulls the images for `ODAL_VERSION` + anywhere else; the console's Update images does the same. + + Every path that starts the stack (`odal up`, `odal update`'s rebuild, guided + setup, the console's Start and Update) now runs the same checks through one + function, `preflight_start`. Before, only `odal up` had all three: the + production `.env` preflight, the refusal of an install missing a bind-mounted + file, and the refusal to recreate a deployment started from another install + root, which would have reused that install's signing-key volume. + + `odal init` also writes `.env.example`, never `.env`. It documents + `ODAL_VERSION`: image tags are the release number without the git tag's + leading `v` (tag `v0.14.0` is image `0.14.0`). + - **The node and resolver images carry their licences.** Each runtime image now ships the engine's `LICENSE` (BSL 1.1, Additional Use Grant included) and a `THIRD-PARTY-NOTICES` file at `/usr/share/licenses/odal-node/`. The notices are diff --git a/cli/README.md b/cli/README.md index e823ae01..48d27ff2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -31,11 +31,13 @@ rendered loudly). - **Active profile selection** (highest precedence first): `--profile ` → `ODAL_PROFILE` → the saved current profile → `default`. -- **`kind` drives behaviour:** `odal up`/`down`/`update` always operate the full - self-host stack (`docker-compose.yml` — node + resolver + infra). The kind - selects how: `dev` (localhost) **builds** the node image from your source tree - and tolerates dev-default secrets; `prod` (remote) **pulls** the published - image and runs a `.env` secret preflight. The kind also sets the banner colour. +- **`kind` drives the preflight, not the build:** `odal up`/`down`/`update` + always operate the full self-host stack (`docker-compose.yml` — node + + resolver + infra). `dev` (localhost) tolerates dev-default secrets; `prod` + (remote) runs a `.env` secret preflight first. Whether the node image is + **built** or **pulled** is decided by the install, not the kind: inside a + clone of the engine it is built from source, anywhere else the published + image runs. The kind also sets the banner colour. (The infra-only `docker-compose.dev.yml` is for engine development — `just infra` + `cargo run` — and is not driven by `odal`.) @@ -122,9 +124,10 @@ For scripting and CI, all steps are also available as subcommands: odal profile create prod --node-url https://node.acme.example \ --resolver-url https://dpp.acme.example odal profile use prod -odal init # scaffold docker/docker-compose.yml for the active profile -# create .env in the deployment root (DATABASE_POSTGRES_PASS, DATABASE_APP_PASS, -# KEY_STORE_PASSPHRASE, DID_WEB_BASE_URL, ADMIN_USERNAME, ADMIN_PASSWORD) +odal init # scaffold the compose file, its bootstrap hook and .env.example +# copy .env.example to .env in the deployment root and set DATABASE_POSTGRES_PASS, +# DATABASE_APP_PASS, KEY_STORE_PASSPHRASE, DID_WEB_BASE_URL, RESOLVER_BASE_URL, +# ADMIN_USERNAME and ADMIN_PASSWORD odal up # build/pull + start the full stack (node + resolver + infra) odal status # verify services healthy odal bootstrap # mint the first API key (idempotent — refuses if already done) @@ -152,10 +155,10 @@ odal passport publish # sign + publish (mints GS1 Digital Link / | Command | Purpose | Auth | |---|---|---| -| `odal init` | Scaffold `docker/docker-compose.yml`; save connection config | none | -| `odal up` | Start the full stack (node + resolver + infra); dev builds from source, prod pulls + runs a `.env` secret preflight | none | +| `odal init` | Scaffold `docker/docker-compose.yml`, `ops/bootstrap/` and `.env.example`; save connection config | none | +| `odal up` | Start the full stack (node + resolver + infra); builds from source inside a clone of the engine, otherwise runs the published images; prod also runs a `.env` secret preflight | none | | `odal down` | Stop the full stack | none | -| `odal update` | Pull latest container images | none | +| `odal update` | Pull the published images for the pinned `ODAL_VERSION`; inside a clone, rebuild from source and restart instead | none | | `odal status` | Health of vault, identity, resolver, containers, and the node's trust posture | none (trust posture needs an API key) | | `odal whoami` | What the configured API key is: identity, scope, key id | API key | @@ -306,9 +309,11 @@ this command is never pointed at one.) ### `odal init` Saves connection config for the active profile to `~/.config/odal/config.toml` -and scaffolds `docker/docker-compose.yml` in the current directory if it does not -already exist. Never overwrites an existing compose file. Intended for scripting — -interactive operators should run `odal` instead. +and scaffolds, in the current directory, whichever of `docker/docker-compose.yml`, +`ops/bootstrap/` and `.env.example` do not already exist. Never overwrites an +existing file, and never writes `.env`: copy `.env.example` to `.env` and set its +values yourself. Intended for scripting — interactive operators should run `odal` +instead. ### `odal bootstrap` diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs index ec7aab72..4c27e8ca 100644 --- a/cli/src/commands/init.rs +++ b/cli/src/commands/init.rs @@ -63,6 +63,9 @@ pub async fn run_init( } } } + if !cwd.join(".env").exists() { + println!("No .env yet: copy .env.example to .env and set its values before `odal up`"); + } Ok(()) } diff --git a/cli/src/commands/up.rs b/cli/src/commands/up.rs index cd207765..08490eb8 100644 --- a/cli/src/commands/up.rs +++ b/cli/src/commands/up.rs @@ -1,57 +1,16 @@ //! `odal up` — start the Odal Node Docker services. -use anyhow::{Result, bail}; +use anyhow::Result; use crate::{ - config::{Config, EnvKind}, - core::infra::{ - action_up, compose_file, deployment_owned_elsewhere, missing_scaffold_files, - preflight_prod_env, source_tree_present, - }, + config::Config, + core::infra::{action_up, compose_file, preflight_start, source_tree_present}, }; pub async fn run_up() -> Result<()> { let cfg = Config::load()?; let compose = compose_file()?; - // Production must not boot on missing or dev-default secrets. - if matches!(cfg.kind, EnvKind::Prod) { - preflight_prod_env(&compose)?; - } - - // The compose file bind-mounts the database role-provisioning hook. Docker - // fabricates an empty directory for a missing mount source rather than - // failing, so without this check the stack starts, reports success, and the - // node then retries authentication forever against a role whose password - // was never set. Refuse up front instead, and say what to do about it. - let root = compose.parent().and_then(std::path::Path::parent); - if let Some(root) = root { - let missing = missing_scaffold_files(root); - if !missing.is_empty() { - bail!( - "this install is missing files the stack needs:\n{}\n\ - Run `odal init` to scaffold them, then try again.", - missing - .iter() - .map(|f| format!(" • {f}")) - .collect::>() - .join("\n") - ); - } - } - - // Compose fixes the project name, so a second install root does not get a - // second deployment — it takes over the first one's containers and volumes, - // including the one holding the signing key. Refuse rather than adopt. - if let Some(owner) = deployment_owned_elsewhere(&compose) { - bail!( - "a deployment of this project is already running, started from:\n {owner}\n\ - Starting it from here would recreate those containers with this \ - directory's .env, against that deployment's volumes — including the \ - one holding the signing key.\n\n\ - Run `odal up` from that directory instead, or set \ - COMPOSE_PROJECT_NAME to give this install its own deployment." - ); - } + preflight_start(&compose, cfg.kind)?; // Build from source only when this install actually carries it; otherwise // use the published image. See `source_tree_present`. diff --git a/cli/src/commands/update.rs b/cli/src/commands/update.rs index a00a6d9d..01a07194 100644 --- a/cli/src/commands/update.rs +++ b/cli/src/commands/update.rs @@ -1,38 +1,36 @@ //! `odal update` — bring the node up to the latest version. //! -//! What "latest" means depends on how the node is deployed, mirroring `odal up`: -//! • localhost / self-host (Dev) — node + resolver are built from source; there -//! is no published image to pull (pre-publish, GHCR even returns "denied"). -//! Update = rebuild from the current source and recreate the containers. -//! • remote / managed (Prod) — the node runs the published GHCR images. -//! Update = pull the latest images, then `odal up` to recreate. +//! Decided the way `odal up` decides whether to build — by whether this install +//! carries the engine source (`source_tree_present`), never by profile kind: +//! • source present — rebuild from it and recreate the containers. A pull +//! there would fetch images the next `odal up` replaces with a build anyway. +//! • no source — pull the published images for the pinned `ODAL_VERSION` +//! (`latest` when unset), then `odal up` to recreate. use anyhow::Result; use crate::{ - config::{Config, EnvKind}, - core::infra::{action_up, action_update, compose_file}, + config::Config, + core::infra::{action_up, action_update, compose_file, preflight_start, source_tree_present}, }; pub async fn run_update() -> Result<()> { let cfg = Config::load()?; let compose = compose_file()?; - match cfg.kind { - EnvKind::Dev => { - // Build-from-source install: `pull` would only ever hit "denied" for - // the unpublished node/resolver images. Rebuild + recreate instead. - println!( - "Rebuilding Odal Node from source ({})...", - compose.display() - ); - action_up(&compose, true).await?; - println!("Rebuilt and restarted. Run `odal status` to check health."); - } - EnvKind::Prod => { - println!("Pulling latest Odal Node images ({})...", compose.display()); - action_update(&compose).await?; - println!("Images updated. Run `odal up` to restart with the new images."); - } + if source_tree_present(&compose) { + // The rebuild recreates the containers, so it starts the node: hold it + // to every check `odal up` makes. + preflight_start(&compose, cfg.kind)?; + println!( + "Rebuilding Odal Node from source ({})...", + compose.display() + ); + action_up(&compose, true).await?; + println!("Rebuilt and restarted. Run `odal status` to check health."); + } else { + println!("Pulling Odal Node images ({})...", compose.display()); + action_update(&compose).await?; + println!("Images updated. Run `odal up` to restart with the new images."); } Ok(()) } diff --git a/cli/src/console/menu/infrastructure.rs b/cli/src/console/menu/infrastructure.rs index d928fa8b..9634b59c 100644 --- a/cli/src/console/menu/infrastructure.rs +++ b/cli/src/console/menu/infrastructure.rs @@ -5,9 +5,10 @@ use console::style; use inquire::{InquireError, Select}; use crate::{ - config::{Config, EnvKind}, + config::Config, core::infra::{ - action_down, action_status, action_up, action_update, compose_file, preflight_prod_env, + action_down, action_status, action_up, action_update, compose_file, preflight_start, + source_tree_present, }, stateless::render::render_status, }; @@ -33,7 +34,7 @@ pub(super) async fn infrastructure() -> Result<()> { v.push(MenuItem::new("Stop", "docker compose down")); v.push(MenuItem::new( "Update images", - "pull latest container images", + "pull the published images, or rebuild from source", )); } v.push(MenuItem::new("← Back", "")); @@ -67,16 +68,15 @@ pub(super) async fn infrastructure() -> Result<()> { "Start" => match Config::load() { Ok(cfg) => match compose_file() { Ok(compose) => { - let safe = !matches!(cfg.kind, EnvKind::Prod) - || match preflight_prod_env(&compose) { - Ok(()) => true, - Err(e) => { - print_err(e); - false - } - }; + let safe = match preflight_start(&compose, cfg.kind) { + Ok(()) => true, + Err(e) => { + print_err(e); + false + } + }; if safe { - let build = matches!(cfg.kind, EnvKind::Dev); + let build = source_tree_present(&compose); println!( "\n {} ({})\n", if build { @@ -120,24 +120,52 @@ pub(super) async fn infrastructure() -> Result<()> { } Err(e) => print_err(e), }, - "Update images" => match compose_file() { - Ok(compose) => { - println!( - "\n Pulling latest images ({})\n", - style(compose.display()).dim() - ); - match action_update(&compose).await { - Ok(_) => { + // The same decision `odal update` makes, so the hint it prints + // names a command that does what this just did. + "Update images" => match Config::load() { + Ok(cfg) => match compose_file() { + Ok(compose) if source_tree_present(&compose) => { + let safe = match preflight_start(&compose, cfg.kind) { + Ok(()) => true, + Err(e) => { + print_err(e); + false + } + }; + if safe { println!( - "\n {} Images updated. Run Start to restart with new images.", - style("✓").green() + "\n Rebuilding Odal Node from source ({})\n", + style(compose.display()).dim() ); - hint("odal update"); - println!(); + match action_up(&compose, true).await { + Ok(_) => { + println!( + "\n {} Rebuilt and restarted. Run Status to verify.", + style("✓").green() + ); + hint("odal update"); + println!(); + } + Err(e) => print_err(e), + } } - Err(e) => print_err(e), } - } + Ok(compose) => { + println!("\n Pulling images ({})\n", style(compose.display()).dim()); + match action_update(&compose).await { + Ok(_) => { + println!( + "\n {} Images updated. Run Start to restart with new images.", + style("✓").green() + ); + hint("odal update"); + println!(); + } + Err(e) => print_err(e), + } + } + Err(e) => print_err(e), + }, Err(e) => print_err(e), }, "← Back" => break, diff --git a/cli/src/console/setup.rs b/cli/src/console/setup.rs index f91faa2e..80e024fb 100644 --- a/cli/src/console/setup.rs +++ b/cli/src/console/setup.rs @@ -12,7 +12,7 @@ use crate::{ core::{ infra::{ COMPOSE_FILE, action_up, deployment_env_var, find_install_root, infra_container_status, - preflight_prod_env, scaffold_install, + preflight_start, scaffold_install, source_tree_present, }, onboarding::{action_bootstrap, action_node_state}, }, @@ -108,9 +108,12 @@ async fn step_infrastructure(cfg: &Config) -> Result<()> { style("✓").green() ); } else { + // Named in full: the root can be a parent of the current directory, + // and a `.env` written beside the operator instead is never read. println!( - " Create a {} file in your deployment root with these variables:\n", - style(".env").cyan() + " Copy {} to {} and set these variables:\n", + style(root.join(".env.example").display()).cyan(), + style(root.join(".env").display()).cyan() ); for var in &[ "DATABASE_POSTGRES_PASS", @@ -132,8 +135,9 @@ async fn step_infrastructure(cfg: &Config) -> Result<()> { println!(); } - // Local self-host builds the node image from source the first time. - let build = matches!(cfg.kind, EnvKind::Dev); + // Build from source only when this install carries it; a scaffolded one + // runs the published images. The same test `odal up` makes. + let build = source_tree_present(&compose_path); let start_now = Confirm::new(if build { "Build and start services now?" } else { @@ -156,9 +160,7 @@ async fn step_infrastructure(cfg: &Config) -> Result<()> { return Ok(()); } - if matches!(cfg.kind, EnvKind::Prod) - && let Err(e) = preflight_prod_env(&compose_path) - { + if let Err(e) = preflight_start(&compose_path, cfg.kind) { println!("\n {} {}\n", style("⚠").yellow(), e); return Ok(()); } diff --git a/cli/src/core/infra.rs b/cli/src/core/infra.rs index ac69b84d..9f1224f6 100644 --- a/cli/src/core/infra.rs +++ b/cli/src/core/infra.rs @@ -10,7 +10,10 @@ use std::{ use anyhow::{Context, Result}; use super::types::{ContainerHealth, ServiceHealth, ServiceStatus, StatusReport}; -use crate::{config::Config, http::OdalClient}; +use crate::{ + config::{Config, EnvKind}, + http::OdalClient, +}; /// Walk up from CWD to find the installation root — the directory that contains /// a `docker/` folder with either compose file (dev or prod). @@ -56,6 +59,12 @@ pub const COMPOSE_TEMPLATE: &str = include_str!("../../../docker/docker-compose. pub const PG_INIT_TEMPLATE: &str = include_str!("../../../ops/bootstrap/pg-init.sh"); pub const BOOTSTRAP_SQL_TEMPLATE: &str = include_str!("../../../ops/bootstrap/bootstrap.sql"); +/// The engine's own `.env.example`, embedded so an install without the source +/// tree gets the file every setup instruction starts from. The compose file +/// refuses to start without `.env` values it has no default for, and an +/// operator outside a clone otherwise has nowhere to copy them from. +pub const ENV_EXAMPLE_TEMPLATE: &str = include_str!("../../../.env.example"); + /// One file an install root needs, and what to write into it. struct ScaffoldFile { rel: &'static str, @@ -64,6 +73,10 @@ struct ScaffoldFile { /// non-executable one. `pg-init.sh` uses `set -e` and `exit 1`, which mean /// different things to a sourced script, so it is written executable. executable: bool, + /// Whether the stack reads this file when it starts, so that `odal up` + /// must refuse without it. `.env.example` is only copied from; an install + /// scaffolded before it existed still starts. + required: bool, } const SCAFFOLD: &[ScaffoldFile] = &[ @@ -71,16 +84,27 @@ const SCAFFOLD: &[ScaffoldFile] = &[ rel: "docker/docker-compose.yml", contents: COMPOSE_TEMPLATE, executable: false, + required: true, }, ScaffoldFile { rel: "ops/bootstrap/pg-init.sh", contents: PG_INIT_TEMPLATE, executable: true, + required: true, }, ScaffoldFile { rel: "ops/bootstrap/bootstrap.sql", contents: BOOTSTRAP_SQL_TEMPLATE, executable: false, + required: true, + }, + // Never `.env` itself: that holds the operator's secrets, and a scaffolder + // that wrote one would start a node on the template's values. + ScaffoldFile { + rel: ".env.example", + contents: ENV_EXAMPLE_TEMPLATE, + executable: false, + required: false, }, ]; @@ -119,7 +143,7 @@ pub fn scaffold_install(root: &Path) -> Result> { pub fn missing_scaffold_files(root: &Path) -> Vec<&'static str> { SCAFFOLD .iter() - .filter(|f| !root.join(f.rel).is_file()) + .filter(|f| f.required && !root.join(f.rel).is_file()) .map(|f| f.rel) .collect() } @@ -504,6 +528,57 @@ pub fn deployment_owned_elsewhere(compose_file: &Path) -> Option { }; (!same).then_some(theirs) } + +/// Every check a start must pass before compose runs, whichever command starts +/// the stack. +/// +/// `odal up`, `odal update`'s rebuild, guided setup and the console's Start and +/// Update all end in [`action_up`]. Each used to carry its own subset of these +/// checks and only `up` had all three, so an update or a console start could +/// boot a stack `up` refuses: one missing a bind-mount source, or one belonging +/// to another install, recreated against that install's signing-key volume. One +/// function, so a check added here reaches every start. +pub fn preflight_start(compose_file: &Path, kind: EnvKind) -> Result<()> { + // Production must not boot on missing or dev-default secrets. + if matches!(kind, EnvKind::Prod) { + preflight_prod_env(compose_file)?; + } + + // The compose file bind-mounts the database role-provisioning hook. Docker + // fabricates an empty directory for a missing mount source rather than + // failing, so without this check the stack starts, reports success, and the + // node then retries authentication forever against a role whose password + // was never set. Refuse up front instead, and say what to do about it. + if let Some(root) = compose_file.parent().and_then(Path::parent) { + let missing = missing_scaffold_files(root); + if !missing.is_empty() { + anyhow::bail!( + "this install is missing files the stack needs:\n{}\n\ + Run `odal init` to scaffold them, then try again.", + missing + .iter() + .map(|f| format!(" • {f}")) + .collect::>() + .join("\n") + ); + } + } + + // Compose fixes the project name, so a second install root does not get a + // second deployment — it takes over the first one's containers and volumes, + // including the one holding the signing key. Refuse rather than adopt. + if let Some(owner) = deployment_owned_elsewhere(compose_file) { + anyhow::bail!( + "a deployment of this project is already running, started from:\n {owner}\n\ + Starting it from here would recreate those containers with this \ + directory's .env, against that deployment's volumes — including the \ + one holding the signing key.\n\n\ + Run `odal` from that directory instead, or set \ + COMPOSE_PROJECT_NAME to give this install its own deployment." + ); + } + Ok(()) +} /// Report Docker container health for the full-stack compose project. pub(crate) fn infra_container_status() -> Result> { let compose = compose_file()?; @@ -580,8 +655,8 @@ fn compose_command(compose_file: &Path) -> std::process::Command { /// Start services via `docker compose up -d`. /// /// When `build` is set, images are built from source first (`--build`) — used -/// for local self-host from the source tree, where no published node image -/// exists yet. Remote/prod deployments pull the published image instead. +/// when the install carries the engine source (see [`source_tree_present`]). +/// Otherwise compose runs the published image, pulling it if it is missing. pub async fn action_up(compose_file: &Path, build: bool) -> Result<()> { let mut cmd = compose_command(compose_file); cmd.args(["up", "-d"]); @@ -752,6 +827,45 @@ mod tests { ); } + /// An install outside a clone has no other copy of the variables the + /// compose file refuses to start without. The example is written; the + /// operator's `.env` is not, since a scaffolded one would start a node on + /// the template's values. + #[test] + fn scaffolding_writes_the_example_env_and_never_the_env_itself() { + let root = tempfile::TempDir::new().unwrap(); + scaffold_install(root.path()).unwrap(); + + let example = std::fs::read_to_string(root.path().join(".env.example")).unwrap(); + assert_eq!(example, ENV_EXAMPLE_TEMPLATE); + for var in [ + "DATABASE_POSTGRES_PASS", + "DATABASE_APP_PASS", + "RESOLVER_BASE_URL", + ] { + assert!( + COMPOSE_TEMPLATE.contains(&format!("${{{var}:?")), + "the compose file no longer requires {var}" + ); + assert!( + example.contains(&format!("\n{var}=")), + "the scaffolded example does not set {var}, which compose requires" + ); + } + assert!(!root.path().join(".env").exists()); + } + + /// The example is copied from, never read by the stack, so an install + /// scaffolded before it was part of the scaffold still starts. + #[test] + fn an_install_without_the_example_env_still_starts() { + let root = tempfile::TempDir::new().unwrap(); + scaffold_install(root.path()).unwrap(); + std::fs::remove_file(root.path().join(".env.example")).unwrap(); + + assert!(missing_scaffold_files(root.path()).is_empty()); + } + /// What `odal up` checks before invoking compose. A mount source that is a /// *directory* is exactly what Docker leaves behind, so it must not count /// as present. @@ -770,6 +884,22 @@ mod tests { ); } + /// The check every start makes, not only `odal up`: an update's rebuild and + /// the console's Start used to skip it and boot a stack whose role hook was + /// missing. Refused before Docker is consulted, so no daemon is needed here. + #[test] + fn a_start_is_refused_while_a_mounted_file_is_missing() { + let root = tempfile::TempDir::new().unwrap(); + scaffold_install(root.path()).unwrap(); + std::fs::remove_file(root.path().join("ops/bootstrap/pg-init.sh")).unwrap(); + + let compose = root.path().join("docker").join(COMPOSE_FILE); + let refused = preflight_start(&compose, EnvKind::Dev) + .expect_err("a start with a missing mount source is refused") + .to_string(); + assert!(refused.contains("ops/bootstrap/pg-init.sh"), "{refused}"); + } + /// Whether to build is decided by the source tree being present, not by the /// profile kind — every self-hosted node is on localhost and so infers /// `dev`, which used to force a source build on installs with no source. diff --git a/docs/guides/OPERATOR-SETUP.md b/docs/guides/OPERATOR-SETUP.md index eb37dfec..2384a5f5 100644 --- a/docs/guides/OPERATOR-SETUP.md +++ b/docs/guides/OPERATOR-SETUP.md @@ -38,9 +38,14 @@ kind: localhost → `dev`, otherwise → `prod`). You can hold several profiles ### Step 2 — Infrastructure *(localhost only)* -The Console offers to scaffold `docker/docker-compose.yml` in the current -directory and start your services. Before confirming **Start services**, create a -`.env` file in the same directory: +The Console looks for an existing install, a directory holding +`docker/docker-compose.yml`, in the current directory and then its parents. It +uses the first one it finds, or starts a new install in the current directory +when there is none. It scaffolds whatever that install is missing, including +`.env.example`, and offers to start your services. Before confirming **Start +services**, copy `.env.example` to `.env` in that install's root, beside its +`docker/` directory (the Console prints both paths), and set at least these +values: ```sh # .env — never commit this file. `odal` does not create or modify it. @@ -194,9 +199,9 @@ depends on the ones above it: # 1. Point the CLI at the node, and scaffold the install files. odal profile create prod --node-url https://node.example.com \ --resolver-url https://dpp.example.com -odal --profile prod init # writes docker/ and ops/bootstrap/ +odal --profile prod init # writes docker/, ops/bootstrap/ and .env.example -# 2. Create .env next to docker/ — see "First-time setup" above. +# 2. Copy .env.example to .env next to docker/ and set it — see "First-time setup" above. # `odal` never writes this file. `odal up` refuses without it. # 3. Start the stack. First boot takes ~90s (Wasm plugins load serially). @@ -369,7 +374,7 @@ reversible; retirement is terminal and gated. ## Updating the node ```sh -odal update # pull latest container images +odal update # pull the images for ODAL_VERSION odal down # stop running services odal up # restart with new images odal status # verify healthy @@ -377,6 +382,10 @@ odal status # verify healthy Or from the Console: **Infrastructure › Update images**, then **Stop**, then **Start**. +Inside a clone of the engine, `odal update` (and **Update images**) rebuilds +from source and restarts the services itself, so skip the stop and start and go +straight to `odal status`. + --- ## Backup