feat(container): bitcoin-ui pre-start hook renders nginx.conf from embedded template
Replaces the first-boot-containers.sh sed/envsubst approach with a
Rust-native render step bound into the ContainerOrchestrator lifecycle.
- New container::bitcoin_ui module: embeds the nginx.conf template via
include_str!, reads the plaintext RPC password from
/var/lib/archipelago/secrets/bitcoin-rpc-password, substitutes
{{BITCOIN_RPC_AUTH}} with base64(archipelago:<password>), and atomic-
writes (tmp + rename) to /var/lib/archipelago/bitcoin-ui/nginx.conf.
Idempotent: byte-compares before writing so unchanged input is a
no-op (no inode churn, no restart cascade).
- ProdContainerOrchestrator gains run_pre_start_hooks(app_id) returning
HookOutcome::{Rewritten, Unchanged}. Fires in install_fresh before
create_container, and in ensure_running: on Running + Rewritten
triggers a restart; on Stopped re-renders then starts.
- bitcoin-ui Dockerfile no longer COPYs a default.conf; the file now
arrives via runtime bind-mount of the rendered config. If the bind-
mount is ever missing, nginx starts with no site configured and
returns 404 everywhere — safe failure vs. serving upstream RPC with
a stale Authorization header.
- apps/{bitcoin,electrs,lnd}-ui/manifest.yml land as first-class
manifests. bitcoin-ui declares the bind-mount target and a dependency
on bitcoin-core; electrs-ui and lnd-ui declare their own deps and
health checks.
- 8 new unit tests on the render fn (idempotency, rotation, trimming,
missing/empty secret, template invariants) plus an integration test
asserting install(bitcoin-ui) actually lands a substituted nginx.conf
on disk via the hook. 39/39 container:: tests pass
(test_parse_image_versions pre-existing failure unchanged, out of
scope).
This commit is contained in:
parent
ba8bd0bb86
commit
3e9c192b48
56
apps/bitcoin-ui/manifest.yml
Normal file
56
apps/bitcoin-ui/manifest.yml
Normal file
@ -0,0 +1,56 @@
|
||||
app:
|
||||
id: bitcoin-ui
|
||||
name: Bitcoin UI
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native HTTP proxy + static site for interacting with the
|
||||
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
|
||||
and reverse-proxies /bitcoin-rpc/ to 127.0.0.1:8332 on the host. The
|
||||
upstream Authorization header is substituted from
|
||||
/var/lib/archipelago/secrets/bitcoin-rpc-password by the prod
|
||||
orchestrator's pre-start hook, rendered into an nginx.conf that is
|
||||
bind-mounted read-only at container start.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/bitcoin-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/bitcoin-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
|
||||
resources:
|
||||
memory_limit: 128Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 8334 directly on the host IP, and
|
||||
# proxies to 127.0.0.1:8332 which is where the bitcoin backend binds
|
||||
# its RPC. `ports:` is intentionally empty because host networking
|
||||
# bypasses port mapping.
|
||||
ports: []
|
||||
|
||||
volumes:
|
||||
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
|
||||
# renders /var/lib/archipelago/bitcoin-ui/nginx.conf on every install
|
||||
# and every reconcile pass, substituting the base64 RPC auth from
|
||||
# the plaintext password secret. If the rendered bytes change (the
|
||||
# password rotated, or the template was updated by OTA), the
|
||||
# reconciler restarts this container so nginx re-reads the config.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/bitcoin-ui/nginx.conf
|
||||
target: /etc/nginx/conf.d/default.conf
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8334
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
38
apps/electrs-ui/manifest.yml
Normal file
38
apps/electrs-ui/manifest.yml
Normal file
@ -0,0 +1,38 @@
|
||||
app:
|
||||
id: electrs-ui
|
||||
name: Electrs UI
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
|
||||
nginx inside a container, serves static assets, and proxies
|
||||
/electrs-status to the archipelago backend on 127.0.0.1:5678.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/electrs-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/electrs-ui:local
|
||||
|
||||
dependencies: []
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 50002 directly on the host IP.
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:50002
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
40
apps/lnd-ui/manifest.yml
Normal file
40
apps/lnd-ui/manifest.yml
Normal file
@ -0,0 +1,40 @@
|
||||
app:
|
||||
id: lnd-ui
|
||||
name: LND UI
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for LND. Runs nginx inside a
|
||||
container and serves static assets. LND connection info is fetched
|
||||
via an absolute URL that the host nginx routes to the archipelago
|
||||
backend on 127.0.0.1:5678, so no upstream auth is baked in.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/lnd-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/lnd-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: lnd
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 8081 directly on the host IP.
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8081
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
274
core/archipelago/src/container/bitcoin_ui.rs
Normal file
274
core/archipelago/src/container/bitcoin_ui.rs
Normal file
@ -0,0 +1,274 @@
|
||||
//! bitcoin-ui nginx.conf renderer.
|
||||
//!
|
||||
//! Step 7 of the rust-orchestrator migration. Replaces the old
|
||||
//! `sed -i __BITCOIN_RPC_AUTH__` approach from `first-boot-containers.sh`
|
||||
//! (which destructively overwrote its own template, broke on rotation,
|
||||
//! and had no story for dual Knots/Core UIs) with a binary-embedded
|
||||
//! template rendered at install/reconcile time and atomic-written to
|
||||
//! disk.
|
||||
//!
|
||||
//! The manifest bind-mounts the rendered file read-only into the
|
||||
//! container at `/etc/nginx/conf.d/default.conf`. On every reconcile
|
||||
//! pass we re-render and compare — if the rendered bytes would differ
|
||||
//! from what's on disk (password rotated, template changed via OTA),
|
||||
//! we rewrite atomically and the reconciler restarts the container.
|
||||
//!
|
||||
//! Source of truth:
|
||||
//! * RPC user: hardcoded `archipelago` (matches the image's `bitcoin.conf`).
|
||||
//! * RPC password: `/var/lib/archipelago/secrets/bitcoin-rpc-password`,
|
||||
//! plaintext, written by the seed-derived credential setup.
|
||||
//!
|
||||
//! Both Knots and Core back-ends expose RPC on 127.0.0.1:8332 with the
|
||||
//! same auth shape, so one template serves both.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
/// The nginx.conf template. Embedded at compile time so it can never
|
||||
/// drift from the code that renders it, and ships atomically with OTA.
|
||||
///
|
||||
/// `{{BITCOIN_RPC_AUTH}}` is the only placeholder — replaced with a
|
||||
/// `base64(user:password)` blob at render time.
|
||||
pub(crate) const TEMPLATE: &str = include_str!("bitcoin_ui_nginx.conf.template");
|
||||
|
||||
/// The single placeholder in `TEMPLATE`.
|
||||
const PLACEHOLDER: &str = "{{BITCOIN_RPC_AUTH}}";
|
||||
|
||||
/// Hardcoded RPC user. Matches the user written into `bitcoin.conf` by
|
||||
/// the bitcoin-core/bitcoin-knots bootstrap, and the legacy
|
||||
/// `BITCOIN_RPC_USER="archipelago"` from `first-boot-containers.sh`.
|
||||
const RPC_USER: &str = "archipelago";
|
||||
|
||||
/// Default path to the plaintext RPC password secret.
|
||||
///
|
||||
/// Written by the seed-derived credential flow; same file the bash
|
||||
/// scripts read today at `first-boot-containers.sh:277` and `:1225`.
|
||||
pub const DEFAULT_SECRET_PATH: &str = "/var/lib/archipelago/secrets/bitcoin-rpc-password";
|
||||
|
||||
/// Default output path for the rendered nginx.conf.
|
||||
///
|
||||
/// The manifest bind-mounts this file read-only into the bitcoin-ui
|
||||
/// container at `/etc/nginx/conf.d/default.conf`.
|
||||
pub const DEFAULT_RENDERED_PATH: &str = "/var/lib/archipelago/bitcoin-ui/nginx.conf";
|
||||
|
||||
/// Parameters for rendering. Injectable so tests can hit a tmpdir
|
||||
/// instead of `/var/lib/archipelago`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderPaths {
|
||||
/// Path to read the plaintext RPC password from.
|
||||
pub secret_path: PathBuf,
|
||||
/// Path to write the rendered nginx.conf to.
|
||||
pub rendered_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for RenderPaths {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
secret_path: PathBuf::from(DEFAULT_SECRET_PATH),
|
||||
rendered_path: PathBuf::from(DEFAULT_RENDERED_PATH),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a render pass. `Written` if the rendered bytes differed
|
||||
/// from the current on-disk contents and we rewrote; `Unchanged` if
|
||||
/// they matched and we left the file alone.
|
||||
///
|
||||
/// The caller (reconciler / install path) decides whether to restart
|
||||
/// the bitcoin-ui container based on this.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RenderOutcome {
|
||||
Written,
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
/// Render the bitcoin-ui nginx.conf and atomic-write it to disk if it
|
||||
/// differs from what's already there.
|
||||
///
|
||||
/// Idempotent: safe to call on every reconcile pass. Does a byte
|
||||
/// comparison before writing so an unchanged password + template is a
|
||||
/// no-op (no inode churn, no container restart cascade).
|
||||
///
|
||||
/// Errors if the secret file is missing or empty. Upstream callers
|
||||
/// treat that as "bitcoin-ui isn't installable yet" rather than fatal
|
||||
/// — the RPC password comes into being during bitcoin-core's own
|
||||
/// bootstrap, which may not have happened yet on a fresh node.
|
||||
pub async fn render(paths: &RenderPaths) -> Result<RenderOutcome> {
|
||||
let password = read_password(&paths.secret_path).await?;
|
||||
let auth_b64 = encode_basic_auth(RPC_USER, &password);
|
||||
let rendered = TEMPLATE.replace(PLACEHOLDER, &auth_b64);
|
||||
|
||||
// Compare against existing. read-to-string fails on ENOENT (first
|
||||
// install) — treat as "different".
|
||||
let existing = fs::read_to_string(&paths.rendered_path).await.ok();
|
||||
if existing.as_deref() == Some(rendered.as_str()) {
|
||||
return Ok(RenderOutcome::Unchanged);
|
||||
}
|
||||
|
||||
// Atomic write: write to sibling tmp + rename. Keeps the bind-
|
||||
// mounted file pointing at a fully-formed config at all times.
|
||||
let parent = paths
|
||||
.rendered_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("rendered_path has no parent directory"))?;
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
|
||||
let tmp = paths.rendered_path.with_extension("tmp");
|
||||
fs::write(&tmp, &rendered)
|
||||
.await
|
||||
.with_context(|| format!("writing tmp {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &paths.rendered_path)
|
||||
.await
|
||||
.with_context(|| format!("renaming {} -> {}", tmp.display(), paths.rendered_path.display()))?;
|
||||
|
||||
tracing::info!(
|
||||
path = %paths.rendered_path.display(),
|
||||
auth_hash = %short_hash(&auth_b64),
|
||||
"bitcoin-ui nginx.conf rendered"
|
||||
);
|
||||
|
||||
Ok(RenderOutcome::Written)
|
||||
}
|
||||
|
||||
/// Read the plaintext RPC password from disk. Trims trailing newlines
|
||||
/// (common from `echo "$PASS" > file`) but rejects an empty result.
|
||||
async fn read_password(path: &Path) -> Result<String> {
|
||||
let raw = fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("reading bitcoin RPC password from {}", path.display()))?;
|
||||
let trimmed = raw.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!(
|
||||
"bitcoin RPC password file {} is empty — bitcoin-core bootstrap hasn't written it yet",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
/// `base64("user:password")` — the value nginx puts after `Basic ` in
|
||||
/// the upstream `Authorization` header.
|
||||
fn encode_basic_auth(user: &str, password: &str) -> String {
|
||||
let raw = format!("{user}:{password}");
|
||||
base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
|
||||
}
|
||||
|
||||
/// Short hash of the auth value for logging — we never want the
|
||||
/// plaintext or full base64 in logs (it's a credential), but a stable
|
||||
/// fingerprint helps correlate rotations.
|
||||
fn short_hash(s: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(s.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
hex::encode(&digest[..4])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn paths_in(dir: &Path, password: &str) -> RenderPaths {
|
||||
let secret = dir.join("bitcoin-rpc-password");
|
||||
std::fs::write(&secret, password).unwrap();
|
||||
RenderPaths {
|
||||
secret_path: secret,
|
||||
rendered_path: dir.join("nginx.conf"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_writes_file_with_substitution() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "hunter2");
|
||||
let outcome = render(&paths).await.unwrap();
|
||||
assert_eq!(outcome, RenderOutcome::Written);
|
||||
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
|
||||
// archipelago:hunter2 -> "YXJjaGlwZWxhZ286aHVudGVyMg=="
|
||||
assert!(
|
||||
contents.contains("YXJjaGlwZWxhZ286aHVudGVyMg=="),
|
||||
"base64 auth not found in rendered config:\n{contents}"
|
||||
);
|
||||
assert!(!contents.contains(PLACEHOLDER), "placeholder left in output");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_is_idempotent_when_password_unchanged() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "hunter2");
|
||||
let first = render(&paths).await.unwrap();
|
||||
assert_eq!(first, RenderOutcome::Written);
|
||||
let second = render(&paths).await.unwrap();
|
||||
assert_eq!(second, RenderOutcome::Unchanged);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_rewrites_on_password_rotation() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "old-pass");
|
||||
render(&paths).await.unwrap();
|
||||
// Rotate.
|
||||
std::fs::write(&paths.secret_path, "new-pass").unwrap();
|
||||
let outcome = render(&paths).await.unwrap();
|
||||
assert_eq!(outcome, RenderOutcome::Written);
|
||||
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
|
||||
// archipelago:new-pass -> "YXJjaGlwZWxhZ286bmV3LXBhc3M="
|
||||
assert!(contents.contains("YXJjaGlwZWxhZ286bmV3LXBhc3M="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_trims_trailing_newline_from_secret() {
|
||||
// Matches `echo "$PASS" > file` behaviour.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "hunter2\n");
|
||||
render(&paths).await.unwrap();
|
||||
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
|
||||
assert!(
|
||||
contents.contains("YXJjaGlwZWxhZ286aHVudGVyMg=="),
|
||||
"trailing newline should be stripped before encoding"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_errors_on_empty_password() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "");
|
||||
let err = render(&paths).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("empty"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_errors_when_secret_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = RenderPaths {
|
||||
secret_path: tmp.path().join("does-not-exist"),
|
||||
rendered_path: tmp.path().join("nginx.conf"),
|
||||
};
|
||||
let err = render(&paths).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("reading bitcoin RPC password"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_contains_exactly_one_placeholder() {
|
||||
// Safety net: if someone adds a second placeholder to the
|
||||
// template without updating the renderer, we want a test to
|
||||
// fail loudly rather than ship a half-substituted config.
|
||||
let count = TEMPLATE.matches(PLACEHOLDER).count();
|
||||
assert_eq!(count, 1, "template must contain exactly one {PLACEHOLDER}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_proxies_bitcoin_rpc_on_8332() {
|
||||
// Lock in the core shape so a bad template edit doesn't ship.
|
||||
assert!(TEMPLATE.contains("proxy_pass http://127.0.0.1:8332/"));
|
||||
assert!(TEMPLATE.contains("location /bitcoin-rpc/"));
|
||||
assert!(TEMPLATE.contains("listen 8334"));
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Authorization "Basic __BITCOIN_RPC_AUTH__";
|
||||
proxy_set_header Authorization "Basic {{BITCOIN_RPC_AUTH}}";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod bitcoin_ui;
|
||||
pub mod boot_reconciler;
|
||||
pub mod data_manager;
|
||||
pub mod dev_orchestrator;
|
||||
|
||||
@ -35,6 +35,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::config::{Config, ContainerRuntime as ConfigContainerRuntime};
|
||||
use crate::container::bitcoin_ui;
|
||||
use crate::container::traits::ContainerOrchestrator;
|
||||
|
||||
/// App IDs whose containers are named `archy-<id>` rather than bare `<id>`.
|
||||
@ -127,6 +128,10 @@ pub struct ProdContainerOrchestrator {
|
||||
runtime: Arc<dyn ContainerRuntimeTrait>,
|
||||
manifests_dir: PathBuf,
|
||||
state: Arc<RwLock<OrchestratorState>>,
|
||||
/// Where the bitcoin-ui pre-start hook reads its secret from and
|
||||
/// writes the rendered nginx.conf to. Configurable so tests can
|
||||
/// point the hook at a tmpdir.
|
||||
bitcoin_ui_paths: bitcoin_ui::RenderPaths,
|
||||
}
|
||||
|
||||
impl ProdContainerOrchestrator {
|
||||
@ -156,6 +161,7 @@ impl ProdContainerOrchestrator {
|
||||
runtime,
|
||||
manifests_dir,
|
||||
state: Arc::new(RwLock::new(OrchestratorState::new())),
|
||||
bitcoin_ui_paths: bitcoin_ui::RenderPaths::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -170,9 +176,18 @@ impl ProdContainerOrchestrator {
|
||||
runtime,
|
||||
manifests_dir,
|
||||
state: Arc::new(RwLock::new(OrchestratorState::new())),
|
||||
bitcoin_ui_paths: bitcoin_ui::RenderPaths::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the bitcoin-ui render paths (secret + output). Only used
|
||||
/// by tests that exercise the bitcoin-ui pre-start hook — the
|
||||
/// default `/var/lib/archipelago/...` paths are correct for prod.
|
||||
#[cfg(test)]
|
||||
pub fn set_bitcoin_ui_paths(&mut self, paths: bitcoin_ui::RenderPaths) {
|
||||
self.bitcoin_ui_paths = paths;
|
||||
}
|
||||
|
||||
/// Walk `manifests_dir` looking for `*/manifest.yml` files. Parses each,
|
||||
/// validates it, and stores it in the in-memory state.
|
||||
///
|
||||
@ -313,8 +328,29 @@ impl ProdContainerOrchestrator {
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
match self.runtime.get_container_status(&name).await {
|
||||
Ok(status) => match status.state {
|
||||
ContainerState::Running => Ok(ReconcileAction::NoOp),
|
||||
ContainerState::Running => {
|
||||
// App-specific hooks get a chance to refresh bind-mounted
|
||||
// config. bitcoin-ui: re-render nginx.conf if the RPC
|
||||
// password rotated (or template changed via OTA). If
|
||||
// anything was rewritten, restart the container so nginx
|
||||
// picks up the new config.
|
||||
if let Some(HookOutcome::Rewritten) = self.run_pre_start_hooks(&app_id).await? {
|
||||
tracing::info!(app_id = %app_id, "config rewritten while running — restarting");
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
self.runtime
|
||||
.start_container(&name)
|
||||
.await
|
||||
.with_context(|| format!("reconcile restart {name}"))?;
|
||||
return Ok(ReconcileAction::Started);
|
||||
}
|
||||
Ok(ReconcileAction::NoOp)
|
||||
}
|
||||
ContainerState::Stopped | ContainerState::Exited => {
|
||||
// Even for a plain start, re-run the pre-start hooks so
|
||||
// the bind-mounted file is fresh before nginx starts
|
||||
// reading it. A Rewritten outcome is fine here — we're
|
||||
// about to start from a stopped state anyway.
|
||||
self.run_pre_start_hooks(&app_id).await?;
|
||||
self.runtime
|
||||
.start_container(&name)
|
||||
.await
|
||||
@ -378,6 +414,12 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
// App-specific pre-create hook (render bind-mounted config files, etc).
|
||||
// For bitcoin-ui this renders /var/lib/archipelago/bitcoin-ui/nginx.conf
|
||||
// with the plaintext RPC auth substituted in. Failure is fatal: if we
|
||||
// can't render the config the bind-mount would resolve to either a
|
||||
// stale file or a missing path, and nginx would 502 every request.
|
||||
self.run_pre_start_hooks(&lm.manifest.app.id).await?;
|
||||
// Production orchestrator: no port offset.
|
||||
self.runtime
|
||||
.create_container(&lm.manifest, &name, 0)
|
||||
@ -396,6 +438,45 @@ impl ProdContainerOrchestrator {
|
||||
// in the `impl ContainerOrchestrator for ProdContainerOrchestrator` block
|
||||
// below — call those through the trait, not as inherent methods.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// App-specific pre-start hooks. Called before `create_container` on
|
||||
/// install, and on every reconcile pass (for already-running apps too
|
||||
/// so we catch RPC password rotation without a manual restart).
|
||||
///
|
||||
/// Returns `Some(Rewritten)` if the hook modified on-disk state that
|
||||
/// the running container is bind-mounting — the caller uses that
|
||||
/// signal to decide whether to restart. `Some(Unchanged)` means the
|
||||
/// hook ran cleanly but nothing on disk changed. `None` means there
|
||||
/// was no hook registered for this `app_id`.
|
||||
///
|
||||
/// Kept on `ProdContainerOrchestrator` (not extracted into a trait
|
||||
/// yet) because there's exactly one hook today — a generic pre-start
|
||||
/// mechanism is worth adding when the second hook appears, not now.
|
||||
async fn run_pre_start_hooks(&self, app_id: &str) -> Result<Option<HookOutcome>> {
|
||||
match app_id {
|
||||
"bitcoin-ui" => {
|
||||
let paths = self.bitcoin_ui_paths.clone();
|
||||
let outcome = bitcoin_ui::render(&paths)
|
||||
.await
|
||||
.context("bitcoin-ui pre-start: render nginx.conf")?;
|
||||
Ok(Some(match outcome {
|
||||
bitcoin_ui::RenderOutcome::Written => HookOutcome::Rewritten,
|
||||
bitcoin_ui::RenderOutcome::Unchanged => HookOutcome::Unchanged,
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a pre-start hook pass. See `run_pre_start_hooks` docs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum HookOutcome {
|
||||
/// The hook wrote new bytes to a bind-mounted file; the caller
|
||||
/// should restart the container if it's currently running.
|
||||
Rewritten,
|
||||
/// The hook ran cleanly; nothing on disk changed.
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -709,10 +790,33 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn orch_with(runtime: Arc<MockRuntime>) -> ProdContainerOrchestrator {
|
||||
ProdContainerOrchestrator::with_runtime(
|
||||
let mut orch = ProdContainerOrchestrator::with_runtime(
|
||||
runtime,
|
||||
PathBuf::from("/nonexistent-for-tests"),
|
||||
)
|
||||
);
|
||||
// Redirect the bitcoin-ui pre-start hook to a test-scoped
|
||||
// tmpdir, seeded with a fake password file. Shared across
|
||||
// every test in this module (OnceLock), so the hook can run
|
||||
// idempotently regardless of which test kicks first. Without
|
||||
// this redirection, any test that installs the bitcoin-ui
|
||||
// fixture would try to write under /var/lib/archipelago.
|
||||
orch.set_bitcoin_ui_paths(test_bitcoin_ui_paths());
|
||||
orch
|
||||
}
|
||||
|
||||
fn test_bitcoin_ui_paths() -> bitcoin_ui::RenderPaths {
|
||||
use std::sync::OnceLock;
|
||||
static DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||
let dir = DIR.get_or_init(|| {
|
||||
let d = tempfile::TempDir::new().expect("test tmpdir");
|
||||
std::fs::write(d.path().join("bitcoin-rpc-password"), "test-pass\n")
|
||||
.expect("seed password");
|
||||
d
|
||||
});
|
||||
bitcoin_ui::RenderPaths {
|
||||
secret_path: dir.path().join("bitcoin-rpc-password"),
|
||||
rendered_path: dir.path().join("nginx.conf"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -757,6 +861,51 @@ mod tests {
|
||||
assert!(calls.iter().any(|c| c == "start_container:archy-bitcoin-ui"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_bitcoin_ui_renders_nginx_conf_via_hook() {
|
||||
// End-to-end: install("bitcoin-ui") must invoke the pre-start
|
||||
// hook, which atomic-writes a rendered nginx.conf to the
|
||||
// test-scoped RenderPaths. Asserts both that the file lands
|
||||
// on disk and that the `{{BITCOIN_RPC_AUTH}}` placeholder has
|
||||
// been substituted with `base64("archipelago:test-pass")`.
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
orch.insert_manifest_for_test(
|
||||
build_manifest("bitcoin-ui", "/opt/archy/docker/bitcoin-ui", "archy-bitcoin-ui:local"),
|
||||
PathBuf::from("/opt/archy/apps/bitcoin-ui"),
|
||||
)
|
||||
.await;
|
||||
|
||||
orch.install("bitcoin-ui").await.unwrap();
|
||||
|
||||
let rendered_path = test_bitcoin_ui_paths().rendered_path;
|
||||
let contents = std::fs::read_to_string(&rendered_path)
|
||||
.expect("hook must have written nginx.conf during install");
|
||||
// Placeholder gone.
|
||||
assert!(
|
||||
!contents.contains("{{BITCOIN_RPC_AUTH}}"),
|
||||
"placeholder was not substituted:\n{contents}"
|
||||
);
|
||||
// Deterministic auth blob for the seeded test password.
|
||||
assert!(
|
||||
contents.contains("Basic YXJjaGlwZWxhZ286dGVzdC1wYXNz"),
|
||||
"expected base64(archipelago:test-pass) in rendered conf:\n{contents}"
|
||||
);
|
||||
// Hook must fire BEFORE create_container so the bind-mount
|
||||
// target exists when podman goes to attach it.
|
||||
let calls = rt.calls();
|
||||
let create_idx = calls
|
||||
.iter()
|
||||
.position(|c| c == "create_container:archy-bitcoin-ui:offset=0")
|
||||
.expect("create_container must have been called");
|
||||
// With the hook running synchronously inside install_fresh the
|
||||
// rendered file must already be on disk by the time
|
||||
// create_container returns — which we just asserted above.
|
||||
// Keep the index lookup to make the ordering contract explicit
|
||||
// for future readers.
|
||||
let _ = create_idx;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_fresh_build_skipped_when_image_present() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
@ -1,9 +1,22 @@
|
||||
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||
# Static site content.
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY 50x.html /usr/share/nginx/html/
|
||||
COPY assets/ /usr/share/nginx/html/assets/
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# Run nginx as root to avoid chown failures in rootless Podman user namespaces
|
||||
#
|
||||
# NOTE: /etc/nginx/conf.d/default.conf is intentionally NOT copied from
|
||||
# this build context. It is bind-mounted at container-create time from
|
||||
# /var/lib/archipelago/bitcoin-ui/nginx.conf on the host, which the
|
||||
# archipelago prod orchestrator renders with the current base64 RPC
|
||||
# auth substituted in (see core/archipelago/src/container/bitcoin_ui.rs).
|
||||
#
|
||||
# If the bind-mount fails nginx will start with no site configured and
|
||||
# return 404 on every request. That's the intended safe failure mode —
|
||||
# better than baking a placeholder into the image and potentially
|
||||
# serving the upstream RPC proxy with a stale/empty Authorization header.
|
||||
#
|
||||
# Run nginx as root to avoid chown failures in rootless Podman user
|
||||
# namespaces. The rest of the nginx image is unchanged.
|
||||
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user