Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 19 additions & 14 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ rendered loudly).

- **Active profile selection** (highest precedence first): `--profile <name>` →
`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`.)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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`

Expand Down
3 changes: 3 additions & 0 deletions cli/src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
49 changes: 4 additions & 45 deletions cli/src/commands/up.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>()
.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`.
Expand Down
46 changes: 22 additions & 24 deletions cli/src/commands/update.rs
Original file line number Diff line number Diff line change
@@ -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?;
Comment thread
LKSNDRTMLKV marked this conversation as resolved.
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(())
}
80 changes: 54 additions & 26 deletions cli/src/console/menu/infrastructure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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", ""));
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading