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:
archipelago 2026-04-23 02:19:52 -04:00
parent ba8bd0bb86
commit 3e9c192b48
8 changed files with 577 additions and 6 deletions

View 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

View 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
View 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

View 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"));
}
}

View File

@ -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";

View File

@ -1,3 +1,4 @@
pub mod bitcoin_ui;
pub mod boot_reconciler;
pub mod data_manager;
pub mod dev_orchestrator;

View File

@ -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());

View File

@ -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 \