chore: baseline codex hardening before lifecycle refactor
Snapshots the in-flight hardening work so subsequent reconcile/Quadlet
phases land on a clean before/after diff.
Changes:
- core/container/src/podman_client.rs: image_uses_insecure_registry()
whitelist for the OVH (146.59.87.168:3000) and legacy Hetzner
(23.182.128.160:3000) HTTP mirrors; podman_network_settings() lifts
custom networks into the Networks map so containers can join them.
- core/archipelago/src/container/prod_orchestrator.rs:
ensure_container_network() creates per-manifest networks on demand;
apply_data_uid() now goes through host_sudo for mkdir -p + chown so
bind-mount roots get created and chowned without password prompts.
- core/archipelago/src/api/rpc/package/{install,update,stacks}.rs:
podman pull adds --tls-verify=false only for whitelisted registries.
- core/archipelago/src/bootstrap.rs: removes stale dev-mode systemd
override on startup (live nodes carried it from old installers).
- core/archipelago/src/config.rs: ignore ARCHIPELAGO_DEV_MODE in prod
binaries — it had been silently rerouting volumes to /tmp.
- apps/bitcoin-{core,knots}/manifest.yml: locate bitcoind at runtime
so image-layout differences don't break entrypoint.
- scripts/app-catalog-image-smoke-test.py: production catalog/image
smoke test that probes a target node before users click Install.
- .gitignore: cover .codex, .pnpm-store, __pycache__, *.bak.
Removes filebrowser.rs.bak and two stale catalog.json.bak files
(verified identical to live counterparts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -77,3 +77,9 @@ web/
|
|||||||
|
|
||||||
# Resilience harness reports (generated, contains session cookies)
|
# Resilience harness reports (generated, contains session cookies)
|
||||||
scripts/resilience/reports/
|
scripts/resilience/reports/
|
||||||
|
|
||||||
|
# Codex / pnpm / python caches / editor backups
|
||||||
|
.codex
|
||||||
|
.pnpm-store/
|
||||||
|
**/__pycache__/
|
||||||
|
*.bak
|
||||||
|
|||||||
@@ -14,14 +14,22 @@ app:
|
|||||||
custom_args:
|
custom_args:
|
||||||
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||||
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||||
# the IBD sweet spot — 4GB on full nodes, 1GB on pruned. Container
|
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||||
# mempool + connections.
|
# mempool + connections.
|
||||||
- >-
|
- >-
|
||||||
|
BITCOIND="$(command -v bitcoind || true)";
|
||||||
|
if [ -z "$BITCOIND" ]; then
|
||||||
|
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||||
|
fi;
|
||||||
|
if [ -z "$BITCOIND" ]; then
|
||||||
|
echo "bitcoind not found in image" >&2;
|
||||||
|
exit 127;
|
||||||
|
fi;
|
||||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||||
exec bitcoind -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
exec "$BITCOIND" -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||||
else
|
else
|
||||||
exec bitcoind -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
exec "$BITCOIND" -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||||
fi
|
fi
|
||||||
derived_env:
|
derived_env:
|
||||||
- key: DISK_GB
|
- key: DISK_GB
|
||||||
|
|||||||
@@ -14,14 +14,22 @@ app:
|
|||||||
custom_args:
|
custom_args:
|
||||||
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||||
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||||
# the IBD sweet spot — 4GB on full nodes, 1GB on pruned. Container
|
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||||
# mempool + connections.
|
# mempool + connections.
|
||||||
- >-
|
- >-
|
||||||
|
BITCOIND="$(command -v bitcoind || true)";
|
||||||
|
if [ -z "$BITCOIND" ]; then
|
||||||
|
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||||
|
fi;
|
||||||
|
if [ -z "$BITCOIND" ]; then
|
||||||
|
echo "bitcoind not found in image" >&2;
|
||||||
|
exit 127;
|
||||||
|
fi;
|
||||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||||
exec bitcoind -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
exec "$BITCOIND" -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||||
else
|
else
|
||||||
exec bitcoind -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
exec "$BITCOIND" -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||||
fi
|
fi
|
||||||
derived_env:
|
derived_env:
|
||||||
- key: DISK_GB
|
- key: DISK_GB
|
||||||
|
|||||||
@@ -237,11 +237,12 @@ impl RpcHandler {
|
|||||||
check_install_deps(package_id, &deps)?;
|
check_install_deps(package_id, &deps)?;
|
||||||
log_optional_dep_info(package_id, &deps);
|
log_optional_dep_info(package_id, &deps);
|
||||||
check_bitcoin_implementation_conflict(package_id).await?;
|
check_bitcoin_implementation_conflict(package_id).await?;
|
||||||
let repaired_bitcoin_conf = if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
let repaired_bitcoin_conf =
|
||||||
ensure_bitcoin_rpc_bindings().await?
|
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||||
} else {
|
ensure_bitcoin_rpc_bindings().await?
|
||||||
false
|
} else {
|
||||||
};
|
false
|
||||||
|
};
|
||||||
|
|
||||||
// Check if container already exists
|
// Check if container already exists
|
||||||
let check_output = tokio::process::Command::new("podman")
|
let check_output = tokio::process::Command::new("podman")
|
||||||
@@ -1692,10 +1693,12 @@ autopilot.active=false\n",
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No local Dockerfile — try pulling from registry
|
// No local Dockerfile — try pulling from registry
|
||||||
let pull = tokio::process::Command::new("podman")
|
let mut pull_cmd = tokio::process::Command::new("podman");
|
||||||
.args(["pull", ®istry_image])
|
pull_cmd
|
||||||
.output()
|
.arg("pull")
|
||||||
.await;
|
.arg("--tls-verify=false")
|
||||||
|
.arg(®istry_image);
|
||||||
|
let pull = pull_cmd.output().await;
|
||||||
if pull.is_ok_and(|o| o.status.success()) {
|
if pull.is_ok_and(|o| o.status.success()) {
|
||||||
info!("Pulled {} UI from registry", name);
|
info!("Pulled {} UI from registry", name);
|
||||||
registry_image.clone()
|
registry_image.clone()
|
||||||
|
|||||||
@@ -240,8 +240,13 @@ async fn pull_image_with_retry(image: &str) -> Result<()> {
|
|||||||
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
|
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
|
||||||
|
|
||||||
for attempt in 1..=MAX_ATTEMPTS {
|
for attempt in 1..=MAX_ATTEMPTS {
|
||||||
let output = tokio::process::Command::new("podman")
|
let mut cmd = tokio::process::Command::new("podman");
|
||||||
.args(["pull", image])
|
cmd.arg("pull");
|
||||||
|
if archipelago_container::image_uses_insecure_registry(image) {
|
||||||
|
cmd.arg("--tls-verify=false");
|
||||||
|
}
|
||||||
|
let output = cmd
|
||||||
|
.arg(image)
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.context("Failed to execute podman pull")?;
|
.context("Failed to execute podman pull")?;
|
||||||
|
|||||||
@@ -322,8 +322,13 @@ impl RpcHandler {
|
|||||||
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
|
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
|
||||||
self.set_install_progress(package_id, 0, 0).await;
|
self.set_install_progress(package_id, 0, 0).await;
|
||||||
|
|
||||||
let mut child = tokio::process::Command::new("podman")
|
let mut cmd = tokio::process::Command::new("podman");
|
||||||
.args(["pull", image])
|
cmd.arg("pull");
|
||||||
|
if archipelago_container::image_uses_insecure_registry(image) {
|
||||||
|
cmd.arg("--tls-verify=false");
|
||||||
|
}
|
||||||
|
let mut child = cmd
|
||||||
|
.arg(image)
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe
|
|||||||
/// Entry point called from main startup. Never returns an error to the caller —
|
/// Entry point called from main startup. Never returns an error to the caller —
|
||||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||||
pub async fn ensure_doctor_installed() {
|
pub async fn ensure_doctor_installed() {
|
||||||
|
match run_service_override_repair().await {
|
||||||
|
Ok(true) => info!("Removed stale Archipelago dev-mode service override"),
|
||||||
|
Ok(false) => debug!("No stale Archipelago dev-mode service override found"),
|
||||||
|
Err(e) => warn!("Service override repair failed (non-fatal): {:#}", e),
|
||||||
|
}
|
||||||
match run_runtime_assets().await {
|
match run_runtime_assets().await {
|
||||||
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
||||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||||
@@ -63,6 +68,39 @@ pub async fn ensure_doctor_installed() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_service_override_repair() -> Result<bool> {
|
||||||
|
let override_path = Path::new("/etc/systemd/system/archipelago.service.d/override.conf");
|
||||||
|
let Ok(content) = fs::read_to_string(override_path).await else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if !content.contains("ARCHIPELAGO_DEV_MODE=true") {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let only_dev_mode_override = content
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||||
|
.all(|line| line == "[Service]" || line == "Environment=ARCHIPELAGO_DEV_MODE=true");
|
||||||
|
if !only_dev_mode_override {
|
||||||
|
warn!(
|
||||||
|
path = %override_path.display(),
|
||||||
|
"Archipelago service override contains ARCHIPELAGO_DEV_MODE=true plus other settings; leaving it untouched"
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_s = override_path.to_string_lossy().to_string();
|
||||||
|
let status = host_sudo(&["rm", "-f", &path_s])
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("remove {}", override_path.display()))?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("remove {} exited with {}", override_path.display(), status);
|
||||||
|
}
|
||||||
|
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_runtime_assets() -> Result<bool> {
|
async fn run_runtime_assets() -> Result<bool> {
|
||||||
// The v1.7.50 OTA bridge puts scripts/apps/docker assets inside the
|
// The v1.7.50 OTA bridge puts scripts/apps/docker assets inside the
|
||||||
// frontend tarball because older binaries only know how to apply the
|
// frontend tarball because older binaries only know how to apply the
|
||||||
|
|||||||
@@ -132,9 +132,12 @@ impl Config {
|
|||||||
config.log_level = level;
|
config.log_level = level;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev mode configuration
|
// Production binaries must not be switched into dev orchestration by
|
||||||
if let Ok(dev_mode) = std::env::var("ARCHIPELAGO_DEV_MODE") {
|
// host environment. Several live nodes carried a stale systemd
|
||||||
config.dev_mode = dev_mode.parse().unwrap_or(false);
|
// ARCHIPELAGO_DEV_MODE override, which rewrote production volume
|
||||||
|
// mounts into /tmp and prevented real installs from starting.
|
||||||
|
if std::env::var("ARCHIPELAGO_DEV_MODE").is_ok() {
|
||||||
|
tracing::warn!("Ignoring ARCHIPELAGO_DEV_MODE in production config");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(runtime) = std::env::var("ARCHIPELAGO_CONTAINER_RUNTIME") {
|
if let Ok(runtime) = std::env::var("ARCHIPELAGO_CONTAINER_RUNTIME") {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ use crate::config::{Config, ContainerRuntime as ConfigContainerRuntime};
|
|||||||
use crate::container::bitcoin_ui;
|
use crate::container::bitcoin_ui;
|
||||||
use crate::container::filebrowser;
|
use crate::container::filebrowser;
|
||||||
use crate::container::traits::ContainerOrchestrator;
|
use crate::container::traits::ContainerOrchestrator;
|
||||||
|
use crate::update::host_sudo;
|
||||||
|
|
||||||
/// App IDs whose containers are named `archy-<id>` rather than bare `<id>`.
|
/// App IDs whose containers are named `archy-<id>` rather than bare `<id>`.
|
||||||
///
|
///
|
||||||
@@ -457,6 +458,7 @@ impl ProdContainerOrchestrator {
|
|||||||
// stale file or a missing path, and nginx would 502 every request.
|
// stale file or a missing path, and nginx would 502 every request.
|
||||||
self.run_pre_start_hooks(&lm.manifest.app.id).await?;
|
self.run_pre_start_hooks(&lm.manifest.app.id).await?;
|
||||||
self.apply_data_uid(&resolved_manifest).await?;
|
self.apply_data_uid(&resolved_manifest).await?;
|
||||||
|
self.ensure_container_network(&resolved_manifest).await?;
|
||||||
// Production orchestrator: no port offset.
|
// Production orchestrator: no port offset.
|
||||||
self.runtime
|
self.runtime
|
||||||
.create_container(&resolved_manifest, &name, 0)
|
.create_container(&resolved_manifest, &name, 0)
|
||||||
@@ -469,6 +471,43 @@ impl ProdContainerOrchestrator {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> {
|
||||||
|
let Some(network) = manifest.app.container.network.as_deref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if network.is_empty() || matches!(network, "host" | "bridge" | "none" | "slirp4netns") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let exists = tokio::process::Command::new("podman")
|
||||||
|
.args(["network", "exists", network])
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("checking podman network {network}"))?;
|
||||||
|
if exists.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let create = tokio::process::Command::new("podman")
|
||||||
|
.args(["network", "create", network])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating podman network {network}"))?;
|
||||||
|
if create.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let stderr = String::from_utf8_lossy(&create.stderr);
|
||||||
|
if stderr.contains("already exists") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"podman network create {} failed: {}",
|
||||||
|
network,
|
||||||
|
stderr.trim()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Prod-specific inherent methods. The shared lifecycle surface
|
// Prod-specific inherent methods. The shared lifecycle surface
|
||||||
// (install/start/stop/restart/remove/upgrade/status/list/logs/health) lives
|
// (install/start/stop/restart/remove/upgrade/status/list/logs/health) lives
|
||||||
@@ -615,11 +654,18 @@ impl ProdContainerOrchestrator {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = tokio::process::Command::new("chown")
|
let mkdir_status = host_sudo(&["mkdir", "-p", &volume.source])
|
||||||
.arg("-R")
|
.await
|
||||||
.arg(uid_gid)
|
.with_context(|| format!("mkdir {}", volume.source))?;
|
||||||
.arg(&volume.source)
|
if !mkdir_status.success() {
|
||||||
.status()
|
return Err(anyhow::anyhow!(
|
||||||
|
"mkdir -p {} failed with status {:?}",
|
||||||
|
volume.source,
|
||||||
|
mkdir_status.code()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = host_sudo(&["chown", "-R", uid_gid, &volume.source])
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("running chown on {}", volume.source))?;
|
.with_context(|| format!("running chown on {}", volume.source))?;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub use manifest::{
|
|||||||
ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider, SecurityPolicy,
|
ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider, SecurityPolicy,
|
||||||
Volume,
|
Volume,
|
||||||
};
|
};
|
||||||
pub use podman_client::{ContainerState, ContainerStatus, PodmanClient};
|
pub use podman_client::{
|
||||||
|
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
|
||||||
|
};
|
||||||
pub use port_manager::{PortError, PortManager};
|
pub use port_manager::{PortError, PortManager};
|
||||||
pub use runtime::{AutoRuntime, ContainerRuntime, DockerRuntime, PodmanRuntime};
|
pub use runtime::{AutoRuntime, ContainerRuntime, DockerRuntime, PodmanRuntime};
|
||||||
|
|||||||
@@ -257,7 +257,11 @@ impl PodmanClient {
|
|||||||
pub async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> {
|
pub async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> {
|
||||||
// Image pull uses CLI — it's a streaming operation that the API handles differently
|
// Image pull uses CLI — it's a streaming operation that the API handles differently
|
||||||
let mut cmd = tokio::process::Command::new("podman");
|
let mut cmd = tokio::process::Command::new("podman");
|
||||||
cmd.arg("pull").arg(image);
|
cmd.arg("pull");
|
||||||
|
if image_uses_insecure_registry(image) {
|
||||||
|
cmd.arg("--tls-verify=false");
|
||||||
|
}
|
||||||
|
cmd.arg(image);
|
||||||
|
|
||||||
let output = tokio::time::timeout(
|
let output = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(600), // 10 min for large images
|
std::time::Duration::from_secs(600), // 10 min for large images
|
||||||
@@ -357,20 +361,12 @@ impl PodmanClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let net_mode = if let Some(n) = manifest.app.container.network.as_ref() {
|
let (net_mode, custom_network) = podman_network_settings(
|
||||||
if n.is_empty() {
|
manifest.app.container.network.as_deref(),
|
||||||
"bridge"
|
manifest.app.security.network_policy.as_str(),
|
||||||
} else {
|
);
|
||||||
n.as_str()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match manifest.app.security.network_policy.as_str() {
|
|
||||||
"host" => "host",
|
|
||||||
_ => "bridge",
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
let mut body = serde_json::json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
"image": image_ref,
|
"image": image_ref,
|
||||||
"portmappings": port_mappings,
|
"portmappings": port_mappings,
|
||||||
@@ -393,6 +389,11 @@ impl PodmanClient {
|
|||||||
"nsmode": net_mode
|
"nsmode": net_mode
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if let Some(network) = custom_network {
|
||||||
|
body.as_object_mut()
|
||||||
|
.expect("container create body is a JSON object")
|
||||||
|
.insert("networks".to_string(), serde_json::json!({ network: {} }));
|
||||||
|
}
|
||||||
|
|
||||||
let result = self
|
let result = self
|
||||||
.api_request("POST", "libpod/containers/create", Some(body), LONG_TIMEOUT)
|
.api_request("POST", "libpod/containers/create", Some(body), LONG_TIMEOUT)
|
||||||
@@ -601,6 +602,30 @@ impl PodmanClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn image_uses_insecure_registry(image: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
image.split('/').next(),
|
||||||
|
Some("146.59.87.168:3000") | Some("23.182.128.160:3000")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn podman_network_settings(
|
||||||
|
network: Option<&str>,
|
||||||
|
network_policy: &str,
|
||||||
|
) -> (&'static str, Option<String>) {
|
||||||
|
match network {
|
||||||
|
Some("") => ("bridge", None),
|
||||||
|
Some("host") => ("host", None),
|
||||||
|
Some("bridge") => ("bridge", None),
|
||||||
|
Some("none") => ("none", None),
|
||||||
|
Some("slirp4netns") => ("slirp4netns", None),
|
||||||
|
Some("private") => ("private", None),
|
||||||
|
Some(custom) => ("bridge", Some(custom.to_string())),
|
||||||
|
None if network_policy == "host" => ("host", None),
|
||||||
|
None => ("bridge", None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
|
fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
|
||||||
@@ -673,6 +698,40 @@ fn parse_memory_limit(limit: &str) -> Option<i64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insecure_registry_detection_matches_http_mirrors_only() {
|
||||||
|
assert!(image_uses_insecure_registry(
|
||||||
|
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
|
||||||
|
));
|
||||||
|
assert!(image_uses_insecure_registry(
|
||||||
|
"23.182.128.160:3000/lfg2025/filebrowser:v2.27.0"
|
||||||
|
));
|
||||||
|
assert!(!image_uses_insecure_registry(
|
||||||
|
"git.tx1138.com/lfg2025/bitcoin-knots:latest"
|
||||||
|
));
|
||||||
|
assert!(!image_uses_insecure_registry(
|
||||||
|
"docker.io/library/nginx:latest"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn podman_network_settings_uses_networks_map_for_custom_networks() {
|
||||||
|
assert_eq!(
|
||||||
|
podman_network_settings(Some("archy-net"), "isolated"),
|
||||||
|
("bridge", Some("archy-net".to_string()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
podman_network_settings(Some("host"), "isolated"),
|
||||||
|
("host", None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
podman_network_settings(Some(""), "isolated"),
|
||||||
|
("bridge", None)
|
||||||
|
);
|
||||||
|
assert_eq!(podman_network_settings(None, "host"), ("host", None));
|
||||||
|
assert_eq!(podman_network_settings(None, "isolated"), ("bridge", None));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_memory_limit_iec_binary_suffixes() {
|
fn parse_memory_limit_iec_binary_suffixes() {
|
||||||
// Kubernetes-style — this is what apps/*/manifest.yml uses.
|
// Kubernetes-style — this is what apps/*/manifest.yml uses.
|
||||||
|
|||||||
214
scripts/app-catalog-image-smoke-test.py
Executable file
214
scripts/app-catalog-image-smoke-test.py
Executable file
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Production app catalog image smoke test.
|
||||||
|
|
||||||
|
Parses local app manifests, then probes images on a target production node via
|
||||||
|
SSH. This catches catalog/image mismatches before a user clicks Install.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- manifest YAML loads and required app/container fields exist
|
||||||
|
- production node health endpoint responds
|
||||||
|
- each non-local image can be pulled on the node
|
||||||
|
- shell-entrypoint apps reference commands that exist inside the image
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
scripts/app-catalog-image-smoke-test.py \
|
||||||
|
--target archipelago@192.168.1.198 \
|
||||||
|
--ssh-key /home/archipelago/.ssh/id_ed25519
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
INSECURE_REGISTRIES = ("146.59.87.168:3000", "23.182.128.160:3000")
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
cmd,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Remote:
|
||||||
|
def __init__(self, target: str, ssh_key: str | None, extra: list[str]) -> None:
|
||||||
|
self.base = [
|
||||||
|
"ssh",
|
||||||
|
"-F",
|
||||||
|
"/dev/null",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=8",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"PreferredAuthentications=publickey",
|
||||||
|
"-o",
|
||||||
|
"PasswordAuthentication=no",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
]
|
||||||
|
if ssh_key:
|
||||||
|
self.base.extend(["-i", ssh_key])
|
||||||
|
self.base.extend(extra)
|
||||||
|
self.target = target
|
||||||
|
|
||||||
|
def sh(self, script: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||||
|
return run(self.base + [self.target, script], timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifests(apps_dir: Path) -> list[dict]:
|
||||||
|
manifests = []
|
||||||
|
for path in sorted(apps_dir.glob("*/manifest.yml")):
|
||||||
|
with path.open("r", encoding="utf-8") as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
app = None
|
||||||
|
container = None
|
||||||
|
elif isinstance(data.get("app"), dict):
|
||||||
|
app = data["app"]
|
||||||
|
container = app.get("container")
|
||||||
|
else:
|
||||||
|
app = data
|
||||||
|
container = data.get("container") if isinstance(data.get("container"), dict) else data
|
||||||
|
manifests.append({"path": path, "app": app, "container": container})
|
||||||
|
return manifests
|
||||||
|
|
||||||
|
|
||||||
|
def insecure(image: str) -> bool:
|
||||||
|
return image.startswith(INSECURE_REGISTRIES)
|
||||||
|
|
||||||
|
|
||||||
|
def shell_probe_for(app_id: str, command: str) -> str | None:
|
||||||
|
if app_id in {"bitcoin-core", "bitcoin-knots"}:
|
||||||
|
return "command -v bitcoind || find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1"
|
||||||
|
|
||||||
|
match = re.search(r"\bexec\s+([\"']?)([A-Za-z0-9_./-]+)\1", command)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
binary = match.group(2)
|
||||||
|
if binary.startswith("$"):
|
||||||
|
return None
|
||||||
|
if "/" in binary:
|
||||||
|
return f"test -x {shlex.quote(binary)} && echo {shlex.quote(binary)}"
|
||||||
|
return f"command -v {shlex.quote(binary)}"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--target", required=True)
|
||||||
|
parser.add_argument("--ssh-key", default=os.environ.get("ARCHIPELAGO_SSH_KEY"))
|
||||||
|
parser.add_argument("--apps-dir", default="apps")
|
||||||
|
parser.add_argument("--pull", action="store_true", help="pull missing images before probing")
|
||||||
|
parser.add_argument("--ssh-option", action="append", default=[])
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
apps_dir = Path(args.apps_dir)
|
||||||
|
remote = Remote(args.target, args.ssh_key, sum((["-o", x] for x in args.ssh_option), []))
|
||||||
|
|
||||||
|
failures: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
passes = 0
|
||||||
|
|
||||||
|
health = remote.sh("curl -fsS --max-time 5 http://127.0.0.1:5678/health", timeout=15)
|
||||||
|
if health.returncode != 0:
|
||||||
|
failures.append(f"target health failed: {health.stderr.strip() or health.stdout.strip()}")
|
||||||
|
print(json.dumps({"passes": passes, "warnings": 0, "failures": len(failures)}, sort_keys=True))
|
||||||
|
for failure in failures:
|
||||||
|
print(f"FAIL {failure}")
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
passes += 1
|
||||||
|
print(f"PASS target health {health.stdout.strip()}")
|
||||||
|
|
||||||
|
manifests = load_manifests(apps_dir)
|
||||||
|
print(f"INFO loaded {len(manifests)} manifests from {apps_dir}")
|
||||||
|
|
||||||
|
for item in manifests:
|
||||||
|
path = item["path"]
|
||||||
|
app = item["app"]
|
||||||
|
container = item["container"]
|
||||||
|
if not isinstance(app, dict) or not isinstance(container, dict):
|
||||||
|
failures.append(f"{path}: missing app.container")
|
||||||
|
continue
|
||||||
|
|
||||||
|
app_id = str(app.get("id") or "")
|
||||||
|
image = str(container.get("image") or app.get("image") or "")
|
||||||
|
if not app_id:
|
||||||
|
failures.append(f"{path}: missing app id")
|
||||||
|
continue
|
||||||
|
if not image and container.get("build"):
|
||||||
|
warnings.append(f"{app_id}: skipped locally built image")
|
||||||
|
continue
|
||||||
|
if not image:
|
||||||
|
failures.append(f"{path}: missing container image")
|
||||||
|
continue
|
||||||
|
passes += 1
|
||||||
|
|
||||||
|
if image.startswith("localhost/") or image.startswith("archipelago/"):
|
||||||
|
warnings.append(f"{app_id}: skipped local/unpublished image {image}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
pull_args = ["pull"]
|
||||||
|
if insecure(image):
|
||||||
|
pull_args.append("--tls-verify=false")
|
||||||
|
pull_args.append(image)
|
||||||
|
|
||||||
|
if args.pull:
|
||||||
|
pull_cmd = "timeout 300s podman " + " ".join(shlex.quote(x) for x in pull_args)
|
||||||
|
pulled = remote.sh(pull_cmd, timeout=330)
|
||||||
|
if pulled.returncode != 0:
|
||||||
|
failures.append(f"{app_id}: pull failed for {image}: {(pulled.stderr or pulled.stdout).strip()[-500:]}")
|
||||||
|
continue
|
||||||
|
print(f"PASS {app_id}: pulled {image}")
|
||||||
|
passes += 1
|
||||||
|
else:
|
||||||
|
exists = remote.sh(f"podman image exists {shlex.quote(image)}", timeout=30)
|
||||||
|
if exists.returncode != 0:
|
||||||
|
warnings.append(f"{app_id}: image not present on target, rerun with --pull: {image}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
custom_args = container.get("custom_args") or []
|
||||||
|
entrypoint = container.get("entrypoint") or []
|
||||||
|
if entrypoint == ["sh", "-lc"] and custom_args:
|
||||||
|
command = str(custom_args[0])
|
||||||
|
probe = shell_probe_for(app_id, command)
|
||||||
|
if probe:
|
||||||
|
remote_script = (
|
||||||
|
"timeout 45s podman run --rm "
|
||||||
|
f"--entrypoint sh {shlex.quote(image)} -c {shlex.quote(probe)}"
|
||||||
|
)
|
||||||
|
checked = remote.sh(remote_script, timeout=60)
|
||||||
|
found = checked.stdout.strip().splitlines()[-1:] or [""]
|
||||||
|
if checked.returncode == 0 and found[0]:
|
||||||
|
print(f"PASS {app_id}: command probe found {found[0]}")
|
||||||
|
passes += 1
|
||||||
|
else:
|
||||||
|
failures.append(
|
||||||
|
f"{app_id}: command probe failed in {image}: {(checked.stderr or checked.stdout).strip()[-500:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps({"passes": passes, "warnings": len(warnings), "failures": len(failures)}, sort_keys=True))
|
||||||
|
for warning in warnings:
|
||||||
|
print(f"WARN {warning}")
|
||||||
|
for failure in failures:
|
||||||
|
print(f"FAIL {failure}")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user