Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "archipelago-container"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
||||
hyper = { version = "0.14", features = ["client", "http1"] }
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
indexmap = { version = "2.0", features = ["serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[lib]
|
||||
name = "archipelago_container"
|
||||
path = "src/lib.rs"
|
||||
@@ -0,0 +1,219 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BitcoinSimulationMode {
|
||||
Mock,
|
||||
Testnet,
|
||||
Mainnet,
|
||||
None,
|
||||
}
|
||||
|
||||
pub struct BitcoinSimulator {
|
||||
mode: BitcoinSimulationMode,
|
||||
rpc_url: Option<String>,
|
||||
mock_blockchain_info: Arc<RwLock<Value>>,
|
||||
}
|
||||
|
||||
impl BitcoinSimulator {
|
||||
pub fn new(mode: BitcoinSimulationMode) -> Self {
|
||||
let mock_blockchain_info = json!({
|
||||
"chain": "main",
|
||||
"blocks": 800000,
|
||||
"headers": 800000,
|
||||
"bestblockhash": "0000000000000000000123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"difficulty": 50000000000.0,
|
||||
"mediantime": 1700000000,
|
||||
"verificationprogress": 1.0,
|
||||
"initialblockdownload": false,
|
||||
"chainwork": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"size_on_disk": 500000000000i64,
|
||||
"pruned": false,
|
||||
"softforks": {},
|
||||
"warnings": ""
|
||||
});
|
||||
|
||||
let rpc_url = match mode {
|
||||
BitcoinSimulationMode::Mock => None,
|
||||
BitcoinSimulationMode::Testnet => Some("http://localhost:18332".to_string()),
|
||||
BitcoinSimulationMode::Mainnet => Some("http://localhost:8332".to_string()),
|
||||
BitcoinSimulationMode::None => None,
|
||||
};
|
||||
|
||||
Self {
|
||||
mode,
|
||||
rpc_url,
|
||||
mock_blockchain_info: Arc::new(RwLock::new(mock_blockchain_info)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_bitcoin_available(&self) -> bool {
|
||||
match self.mode {
|
||||
BitcoinSimulationMode::Mock => true,
|
||||
BitcoinSimulationMode::Testnet | BitcoinSimulationMode::Mainnet => {
|
||||
// In real mode, we'd check if the container is running
|
||||
// For now, assume it's available if we have an RPC URL
|
||||
self.rpc_url.is_some()
|
||||
}
|
||||
BitcoinSimulationMode::None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_bitcoin_rpc_url(&self) -> Option<String> {
|
||||
self.rpc_url.clone()
|
||||
}
|
||||
|
||||
pub async fn simulate_rpc_call(&self, method: &str, params: &[Value]) -> Result<Value> {
|
||||
match self.mode {
|
||||
BitcoinSimulationMode::Mock => self.mock_rpc_call(method, params).await,
|
||||
BitcoinSimulationMode::Testnet | BitcoinSimulationMode::Mainnet => {
|
||||
// Make actual RPC call to Bitcoin node
|
||||
self.real_rpc_call(method, params).await
|
||||
}
|
||||
BitcoinSimulationMode::None => Err(anyhow::anyhow!("Bitcoin simulation is disabled")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn mock_rpc_call(&self, method: &str, _params: &[Value]) -> Result<Value> {
|
||||
match method {
|
||||
"getblockchaininfo" => {
|
||||
let info = self.mock_blockchain_info.read().await;
|
||||
Ok(info.clone())
|
||||
}
|
||||
"getnetworkinfo" => Ok(json!({
|
||||
"version": 260000,
|
||||
"subversion": "/Bitcoin Core:26.0.0/",
|
||||
"protocolversion": 70016,
|
||||
"localservices": "000000000000040d",
|
||||
"localservicesnames": ["NETWORK", "WITNESS", "NETWORK_LIMITED"],
|
||||
"connections": 8,
|
||||
"connections_in": 4,
|
||||
"connections_out": 4,
|
||||
"networkactive": true,
|
||||
"networks": [],
|
||||
"relayfee": 0.00001000,
|
||||
"incrementalfee": 0.00001000,
|
||||
"localaddresses": [],
|
||||
"warnings": ""
|
||||
})),
|
||||
"getwalletinfo" => Ok(json!({
|
||||
"walletname": "wallet.dat",
|
||||
"walletversion": 169900,
|
||||
"balance": 0.0,
|
||||
"unconfirmed_balance": 0.0,
|
||||
"immature_balance": 0.0,
|
||||
"txcount": 0,
|
||||
"keypoololdest": 1700000000,
|
||||
"keypoolsize": 1000,
|
||||
"keypoolsize_hd_internal": 1000,
|
||||
"paytxfee": 0.00000000,
|
||||
"hdseedid": "0000000000000000000000000000000000000000",
|
||||
"private_keys_enabled": true,
|
||||
"avoid_reuse": false,
|
||||
"scanning": false
|
||||
})),
|
||||
"getblockcount" => Ok(json!(800000)),
|
||||
"getblockhash" => Ok(json!(
|
||||
"0000000000000000000123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
)),
|
||||
"getmempoolinfo" => Ok(json!({
|
||||
"loaded": true,
|
||||
"size": 100,
|
||||
"bytes": 100000,
|
||||
"usage": 200000,
|
||||
"total_fee": 0.00001000,
|
||||
"maxmempool": 300000000,
|
||||
"mempoolminfee": 0.00001000,
|
||||
"minrelaytxfee": 0.00001000
|
||||
})),
|
||||
"getpeerinfo" => Ok(json!([])),
|
||||
"getrawmempool" => Ok(json!([])),
|
||||
"estimatesmartfee" => Ok(json!({
|
||||
"feerate": 0.00001000,
|
||||
"blocks": 6
|
||||
})),
|
||||
_ => {
|
||||
// Default response for unknown methods
|
||||
Ok(json!(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn real_rpc_call(&self, method: &str, params: &[Value]) -> Result<Value> {
|
||||
let url = self
|
||||
.rpc_url
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No RPC URL configured"))?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let request_body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params
|
||||
});
|
||||
|
||||
// TODO: Get RPC credentials from config/secrets
|
||||
let response = client
|
||||
.post(url)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send RPC request")?;
|
||||
|
||||
let response_json: Value = response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.context("Failed to parse RPC response")?;
|
||||
|
||||
if let Some(error) = response_json.get("error") {
|
||||
return Err(anyhow::anyhow!("Bitcoin RPC error: {}", error));
|
||||
}
|
||||
|
||||
Ok(response_json.get("result").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> &BitcoinSimulationMode {
|
||||
&self.mode
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for BitcoinSimulationMode {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"mock" => BitcoinSimulationMode::Mock,
|
||||
"testnet" => BitcoinSimulationMode::Testnet,
|
||||
"mainnet" => BitcoinSimulationMode::Mainnet,
|
||||
_ => BitcoinSimulationMode::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_bitcoin_available() {
|
||||
let simulator = BitcoinSimulator::new(BitcoinSimulationMode::Mock);
|
||||
assert!(simulator.is_bitcoin_available());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_getblockchaininfo() {
|
||||
let simulator = BitcoinSimulator::new(BitcoinSimulationMode::Mock);
|
||||
let result = simulator
|
||||
.simulate_rpc_call("getblockchaininfo", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.get("blocks").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_none_bitcoin_not_available() {
|
||||
let simulator = BitcoinSimulator::new(BitcoinSimulationMode::None);
|
||||
assert!(!simulator.is_bitcoin_available());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use crate::manifest::HealthCheck;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::time::interval;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum HealthStatus {
|
||||
Healthy,
|
||||
Unhealthy,
|
||||
Unknown,
|
||||
Starting,
|
||||
}
|
||||
|
||||
pub struct HealthMonitor {
|
||||
container_name: String,
|
||||
health_check: Option<HealthCheck>,
|
||||
}
|
||||
|
||||
impl HealthMonitor {
|
||||
pub fn new(container_name: String, health_check: Option<HealthCheck>) -> Self {
|
||||
Self {
|
||||
container_name,
|
||||
health_check,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_health(&self) -> Result<HealthStatus> {
|
||||
if let Some(ref check) = self.health_check {
|
||||
match check.check_type.as_str() {
|
||||
"http" => self.check_http_health(check).await,
|
||||
"exec" => self.check_exec_health(check).await,
|
||||
_ => {
|
||||
warn!("Unknown health check type: {}", check.check_type);
|
||||
Ok(HealthStatus::Unknown)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No health check defined, assume healthy if container is running
|
||||
Ok(HealthStatus::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_http_health(&self, check: &HealthCheck) -> Result<HealthStatus> {
|
||||
let endpoint = check
|
||||
.endpoint
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("HTTP health check missing endpoint"))?;
|
||||
|
||||
let url = if let Some(path) = &check.path {
|
||||
format!("{}{}", endpoint, path)
|
||||
} else {
|
||||
endpoint.clone()
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
Ok(HealthStatus::Healthy)
|
||||
} else {
|
||||
Ok(HealthStatus::Unhealthy)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Health check failed for {}: {}", self.container_name, e);
|
||||
Ok(HealthStatus::Unhealthy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_exec_health(&self, check: &HealthCheck) -> Result<HealthStatus> {
|
||||
// Execute health check command in container
|
||||
let endpoint = check
|
||||
.endpoint
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Exec health check missing endpoint"))?;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
let output = Command::new("podman")
|
||||
.arg("exec")
|
||||
.arg(&self.container_name)
|
||||
.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(endpoint)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute health check")?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(HealthStatus::Healthy)
|
||||
} else {
|
||||
Ok(HealthStatus::Unhealthy)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn monitor_health(
|
||||
&self,
|
||||
mut shutdown: tokio::sync::broadcast::Receiver<()>,
|
||||
on_status_change: impl Fn(HealthStatus) + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let check = self.health_check.clone();
|
||||
let interval_duration = if let Some(ref check) = check {
|
||||
parse_duration(&check.interval).unwrap_or(Duration::from_secs(30))
|
||||
} else {
|
||||
Duration::from_secs(30)
|
||||
};
|
||||
|
||||
let mut interval = interval(interval_duration);
|
||||
let mut consecutive_failures = 0;
|
||||
let max_failures = check.as_ref().map(|c| c.retries).unwrap_or(3);
|
||||
|
||||
let mut last_status = HealthStatus::Unknown;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
match self.check_health().await {
|
||||
Ok(status) => {
|
||||
if status != last_status {
|
||||
info!("Health status changed for {}: {:?} -> {:?}",
|
||||
self.container_name, last_status, status);
|
||||
on_status_change(status.clone());
|
||||
last_status = status.clone();
|
||||
}
|
||||
|
||||
match status {
|
||||
HealthStatus::Healthy => {
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
HealthStatus::Unhealthy => {
|
||||
consecutive_failures += 1;
|
||||
if consecutive_failures >= max_failures {
|
||||
error!("Container {} is unhealthy after {} failures",
|
||||
self.container_name, consecutive_failures);
|
||||
// Auto-restart is handled by the orchestrator-level health monitor
|
||||
// (core/archipelago/src/health_monitor.rs) which runs every 60s,
|
||||
// checks all container states via `podman ps`, and restarts
|
||||
// exited containers with exponential backoff (10s/30s/90s).
|
||||
// This per-container monitor is for manifest-driven health
|
||||
// tracking and status change callbacks only.
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Health check error for {}: {}", self.container_name, e);
|
||||
consecutive_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown.recv() => {
|
||||
info!("Health monitoring stopped for {}", self.container_name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_duration(s: &str) -> Option<Duration> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with('s') {
|
||||
let secs: u64 = s.trim_end_matches('s').parse().ok()?;
|
||||
Some(Duration::from_secs(secs))
|
||||
} else if s.ends_with('m') {
|
||||
let mins: u64 = s.trim_end_matches('m').parse().ok()?;
|
||||
Some(Duration::from_secs(mins * 60))
|
||||
} else if s.ends_with('h') {
|
||||
let hours: u64 = s.trim_end_matches('h').parse().ok()?;
|
||||
Some(Duration::from_secs(hours * 3600))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration() {
|
||||
assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
|
||||
assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
|
||||
assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Container image signature verification (cosign).
|
||||
//!
|
||||
//! The manifest/catalog `image_signature` field is a *claim* that the image
|
||||
//! is signed with the fleet's cosign key. Verification runs at the pull
|
||||
//! choke points (`PodmanClient::pull_image`, `DockerRuntime::pull_image`);
|
||||
//! a declared signature that cannot be verified hard-fails the pull.
|
||||
//!
|
||||
//! Every manifest has carried the literal placeholder `cosign://...` since
|
||||
//! the field was introduced — that means "not signed yet" and is treated as
|
||||
//! no claim, so enforcement stays dormant until the signing ceremony
|
||||
//! publishes real signatures AND nodes carry the pinned cosign public key.
|
||||
//! Ship order matters: key + cosign binary reach the fleet first, real
|
||||
//! signature values in the catalog come after.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The literal placeholder every pre-ceremony manifest carries.
|
||||
pub const SIGNATURE_PLACEHOLDER: &str = "cosign://...";
|
||||
|
||||
/// Env override for the pinned cosign public key path (tests, staging).
|
||||
pub const COSIGN_PUBKEY_ENV: &str = "ARCHIPELAGO_COSIGN_PUBKEY";
|
||||
|
||||
const DEFAULT_PUBKEY_PATH: &str = "/etc/archipelago/cosign.pub";
|
||||
|
||||
const COSIGN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SignatureClaim {
|
||||
/// No signature declared (field absent or empty).
|
||||
None,
|
||||
/// The literal `cosign://...` placeholder — manifest predates real signing.
|
||||
Placeholder,
|
||||
/// A real declared signature reference; MUST verify or the pull fails.
|
||||
Declared(String),
|
||||
}
|
||||
|
||||
pub fn classify_signature(signature: Option<&str>) -> SignatureClaim {
|
||||
match signature.map(str::trim) {
|
||||
None | Some("") => SignatureClaim::None,
|
||||
Some(SIGNATURE_PLACEHOLDER) => SignatureClaim::Placeholder,
|
||||
Some(s) => SignatureClaim::Declared(s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn pinned_pubkey_path() -> PathBuf {
|
||||
std::env::var(COSIGN_PUBKEY_ENV)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from(DEFAULT_PUBKEY_PATH))
|
||||
}
|
||||
|
||||
/// Verify a declared image signature with the fleet's pinned cosign key.
|
||||
/// Any failure — missing key, missing cosign binary, verification error —
|
||||
/// is a hard error: an image that CLAIMS to be signed must never be pulled
|
||||
/// on a node that can't prove the claim.
|
||||
pub async fn verify_declared_signature(
|
||||
image: &str,
|
||||
sig_ref: &str,
|
||||
allow_insecure_registry: bool,
|
||||
) -> Result<()> {
|
||||
verify_with_key_path(
|
||||
image,
|
||||
sig_ref,
|
||||
&pinned_pubkey_path(),
|
||||
allow_insecure_registry,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn verify_with_key_path(
|
||||
image: &str,
|
||||
sig_ref: &str,
|
||||
key_path: &std::path::Path,
|
||||
allow_insecure_registry: bool,
|
||||
) -> Result<()> {
|
||||
if !key_path.exists() {
|
||||
bail!(
|
||||
"Image '{image}' declares signature '{sig_ref}' but the pinned cosign \
|
||||
public key is missing at {} (override with {COSIGN_PUBKEY_ENV}). \
|
||||
Refusing to pull an image whose signature claim cannot be verified.",
|
||||
key_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Self-managed key => signatures aren't in the public Rekor transparency
|
||||
// log, so tlog verification must be disabled explicitly (cosign v2
|
||||
// defaults it on and would fail every private-key signature otherwise).
|
||||
let mut cmd = tokio::process::Command::new("cosign");
|
||||
cmd.arg("verify")
|
||||
.arg("--key")
|
||||
.arg(key_path)
|
||||
.arg("--insecure-ignore-tlog=true");
|
||||
if allow_insecure_registry {
|
||||
// podman's --tls-verify=false covers both plain HTTP and bad TLS;
|
||||
// cosign splits those into two flags — pass both to match.
|
||||
cmd.arg("--allow-insecure-registry");
|
||||
cmd.arg("--allow-http-registry");
|
||||
}
|
||||
cmd.arg(image);
|
||||
|
||||
let output = tokio::time::timeout(COSIGN_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"cosign verify timed out after {}s for image '{image}'",
|
||||
COSIGN_TIMEOUT.as_secs()
|
||||
)
|
||||
})?
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to run cosign for image '{image}' which declares signature \
|
||||
'{sig_ref}' — is cosign installed? A declared signature cannot be \
|
||||
skipped."
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!(
|
||||
"Signature verification FAILED for image '{image}' (declared: '{sig_ref}'): \
|
||||
{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!("cosign signature verified for image {image}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shared pull-site gate: decide whether the pull may proceed.
|
||||
/// Returns Ok(()) for unsigned/placeholder claims (with a log line) and only
|
||||
/// after successful cosign verification for declared ones.
|
||||
pub async fn enforce_signature_claim(
|
||||
image: &str,
|
||||
signature: Option<&str>,
|
||||
allow_insecure_registry: bool,
|
||||
) -> Result<()> {
|
||||
match classify_signature(signature) {
|
||||
SignatureClaim::None => {
|
||||
tracing::debug!("image {image}: no signature declared, pulling unverified");
|
||||
Ok(())
|
||||
}
|
||||
SignatureClaim::Placeholder => {
|
||||
tracing::debug!(
|
||||
"image {image}: signature is the pre-ceremony placeholder, pulling unverified"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
SignatureClaim::Declared(sig_ref) => {
|
||||
verify_declared_signature(image, &sig_ref, allow_insecure_registry).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_signatures_are_no_claim() {
|
||||
assert_eq!(classify_signature(None), SignatureClaim::None);
|
||||
assert_eq!(classify_signature(Some("")), SignatureClaim::None);
|
||||
assert_eq!(classify_signature(Some(" ")), SignatureClaim::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_placeholder_is_not_a_claim() {
|
||||
assert_eq!(
|
||||
classify_signature(Some("cosign://...")),
|
||||
SignatureClaim::Placeholder
|
||||
);
|
||||
assert_eq!(
|
||||
classify_signature(Some(" cosign://... ")),
|
||||
SignatureClaim::Placeholder
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_values_are_declared_claims() {
|
||||
assert_eq!(
|
||||
classify_signature(Some("cosign://sha256-abc.sig")),
|
||||
SignatureClaim::Declared("cosign://sha256-abc.sig".to_string())
|
||||
);
|
||||
// Unknown schemes still count as a claim — better to fail closed on
|
||||
// a value we don't understand than to pull unverified.
|
||||
assert_eq!(
|
||||
classify_signature(Some("sigstore://whatever")),
|
||||
SignatureClaim::Declared("sigstore://whatever".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn declared_signature_without_pinned_key_hard_fails() {
|
||||
let err = verify_with_key_path(
|
||||
"registry.example/app:1.0",
|
||||
"cosign://sha256-abc.sig",
|
||||
std::path::Path::new("/nonexistent/cosign.pub"),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("pinned cosign public key is missing"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsigned_and_placeholder_claims_pass_the_gate() {
|
||||
enforce_signature_claim("registry.example/app:1.0", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
enforce_signature_claim("registry.example/app:1.0", Some("cosign://..."), false)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub mod bitcoin_simulator;
|
||||
pub mod health_monitor;
|
||||
pub mod image_verify;
|
||||
pub mod manifest;
|
||||
pub mod podman_client;
|
||||
pub mod port_manager;
|
||||
pub mod runtime;
|
||||
|
||||
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
||||
pub use health_monitor::HealthMonitor;
|
||||
pub use manifest::{
|
||||
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedCert,
|
||||
GeneratedFile, GeneratedSecret, HealthCheck, HookStep, HostCopy, HostFacts, LifecycleHooks,
|
||||
ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretGenKind, SecretsProvider,
|
||||
SecurityPolicy, Volume,
|
||||
};
|
||||
pub use podman_client::{
|
||||
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
|
||||
};
|
||||
pub use port_manager::{PortError, PortManager};
|
||||
pub use runtime::{AutoRuntime, ContainerRuntime, DockerRuntime, PodmanRuntime};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,996 @@
|
||||
//! Podman container management via the REST API unix socket.
|
||||
//!
|
||||
//! Connects to the rootless Podman API at /run/user/{UID}/podman/podman.sock.
|
||||
//! All operations are non-blocking async via tokio + hyper.
|
||||
//! Falls back to CLI only for image pulls (long-running streaming operations).
|
||||
|
||||
use crate::manifest::AppManifest;
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Body, Request, Uri};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
const API_VERSION: &str = "v4.0.0";
|
||||
const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
const LONG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PodmanError {
|
||||
#[error("Podman API error: {0}")]
|
||||
ApiError(String),
|
||||
#[error("Container not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Podman socket not available: {0}")]
|
||||
SocketUnavailable(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContainerStatus {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub state: ContainerState,
|
||||
pub health: Option<String>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub started_at: Option<String>,
|
||||
pub image: String,
|
||||
pub created: String,
|
||||
pub ports: Vec<String>,
|
||||
pub lan_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ContainerState {
|
||||
Created,
|
||||
Running,
|
||||
Stopping,
|
||||
Stopped,
|
||||
Exited,
|
||||
Paused,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<&str> for ContainerState {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"created" | "initialized" => ContainerState::Created,
|
||||
"running" => ContainerState::Running,
|
||||
"stopping" | "removing" => ContainerState::Stopping,
|
||||
"stopped" => ContainerState::Stopped,
|
||||
"exited" => ContainerState::Exited,
|
||||
"paused" => ContainerState::Paused,
|
||||
other => ContainerState::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse health status from podman's Status string (e.g., "Up 5 minutes (healthy)")
|
||||
fn parse_health_from_status(status: &str) -> Option<String> {
|
||||
if let Some(start) = status.rfind('(') {
|
||||
if let Some(end) = status.rfind(')') {
|
||||
if start < end {
|
||||
return Some(status[start + 1..end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub struct PodmanClient {
|
||||
socket_path: PathBuf,
|
||||
}
|
||||
|
||||
impl PodmanClient {
|
||||
pub fn new(user: String) -> Self {
|
||||
// Determine socket path based on user
|
||||
let uid = Self::get_uid(&user);
|
||||
let socket_path = PathBuf::from(format!("/run/user/{}/podman/podman.sock", uid));
|
||||
Self { socket_path }
|
||||
}
|
||||
|
||||
fn get_uid(user: &str) -> u32 {
|
||||
// Try to get UID from /etc/passwd
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/passwd") {
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
if parts.len() >= 3 && parts[0] == user {
|
||||
if let Ok(uid) = parts[2].parse() {
|
||||
return uid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default to 1000 (standard first user)
|
||||
1000
|
||||
}
|
||||
|
||||
/// Map container name to its UI launch URL
|
||||
pub fn lan_address_for(name: &str) -> Option<String> {
|
||||
if let Some(url) = manifest_lan_address_for(name) {
|
||||
return Some(url);
|
||||
}
|
||||
|
||||
let url = match name {
|
||||
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
|
||||
"lnd" | "archy-lnd-ui" => "http://localhost:18083",
|
||||
"archy-mempool-web" | "mempool" => "http://localhost:4080",
|
||||
"ollama" => "http://localhost:11434",
|
||||
"cryptpad" => "http://localhost:3003",
|
||||
"penpot" => "http://localhost:9001",
|
||||
"immich_server" | "immich" => "http://localhost:2283",
|
||||
// Gitea publishes SSH (2222) and web (3001). Without a manifest on
|
||||
// disk, extract_lan_address() returns whichever podman lists first —
|
||||
// which can be the SSH port, breaking the launch. Pin the web UI.
|
||||
"gitea" => "http://localhost:3001",
|
||||
"nginx-proxy-manager" => "http://localhost:8081",
|
||||
"fedimint-gateway" => "http://localhost:8176",
|
||||
"endurain" => "http://localhost:8080",
|
||||
// HTTPS: netbird's dashboard needs a secure context for OIDC PKCE
|
||||
// (window.crypto.subtle), so the proxy serves TLS on 8087 (issue #15).
|
||||
"netbird" => "https://localhost:8087",
|
||||
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
|
||||
_ => return None,
|
||||
};
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
// ─── API Client ──────────────────────────────────────────────
|
||||
|
||||
/// Send a request to the Podman API via unix socket.
|
||||
async fn api_request(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<serde_json::Value>,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<serde_json::Value> {
|
||||
let socket_path = self.socket_path.clone();
|
||||
|
||||
// Connect to the unix socket (30s timeout — podman can be slow under load on boot)
|
||||
let stream = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
UnixStream::connect(&socket_path),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Podman socket connection timed out (30s)"))?
|
||||
.context(format!(
|
||||
"Cannot connect to Podman socket at {}",
|
||||
socket_path.display()
|
||||
))?;
|
||||
|
||||
// Build the hyper client with the unix stream
|
||||
let (mut sender, conn) = hyper::client::conn::Builder::new()
|
||||
.handshake::<_, Body>(stream)
|
||||
.await
|
||||
.context("Podman API handshake failed")?;
|
||||
|
||||
// Spawn the connection handler
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = conn.await {
|
||||
tracing::debug!("Podman API connection ended: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Build the request
|
||||
let uri: Uri = format!("/{}/{}", API_VERSION, path.trim_start_matches('/'))
|
||||
.parse()
|
||||
.context("Invalid API path")?;
|
||||
|
||||
let req = match method {
|
||||
"POST" => {
|
||||
let body_str = match body {
|
||||
Some(b) => serde_json::to_string(&b)
|
||||
.context("Failed to serialize request body to JSON")?,
|
||||
None => String::new(),
|
||||
};
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("Host", "localhost")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(body_str))
|
||||
.context("Failed to build POST request")?
|
||||
}
|
||||
"DELETE" => Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(uri)
|
||||
.header("Host", "localhost")
|
||||
.body(Body::empty())
|
||||
.context("Failed to build DELETE request")?,
|
||||
_ => Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri)
|
||||
.header("Host", "localhost")
|
||||
.body(Body::empty())
|
||||
.context("Failed to build GET request")?,
|
||||
};
|
||||
|
||||
// Send with timeout
|
||||
let resp = tokio::time::timeout(timeout, sender.send_request(req))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("Podman API request timed out after {}s", timeout.as_secs())
|
||||
})?
|
||||
.context("Podman API request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body_bytes = hyper::body::to_bytes(resp.into_body())
|
||||
.await
|
||||
.context("Failed to read Podman API response")?;
|
||||
|
||||
if status == hyper::StatusCode::NOT_FOUND {
|
||||
return Err(anyhow::anyhow!("Not found"));
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = String::from_utf8_lossy(&body_bytes);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Podman API {} {}: {}",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or(""),
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
// Some endpoints return empty body on success (start/stop/restart)
|
||||
if body_bytes.is_empty() {
|
||||
return Ok(serde_json::json!({"ok": true}));
|
||||
}
|
||||
|
||||
serde_json::from_slice(&body_bytes).context("Failed to parse Podman API JSON response")
|
||||
}
|
||||
|
||||
/// Simple POST with no body (start/stop/restart)
|
||||
async fn api_post_action(&self, path: &str) -> Result<()> {
|
||||
self.api_request("POST", path, None, DEFAULT_TIMEOUT)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Container Operations ────────────────────────────────────
|
||||
|
||||
pub async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
||||
// A declared (non-placeholder) signature must verify before we fetch
|
||||
// anything; placeholder/absent claims pull unverified until the
|
||||
// signing ceremony ships real signatures (see image_verify).
|
||||
crate::image_verify::enforce_signature_claim(
|
||||
image,
|
||||
signature,
|
||||
image_uses_insecure_registry(image),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Image pull uses CLI — it's a streaming operation that the API handles differently
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
cmd.arg(image);
|
||||
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(600), // 10 min for large images
|
||||
cmd.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Image pull timed out after 10 minutes"))?
|
||||
.context("Failed to execute podman pull")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_container(&self, manifest: &AppManifest, name: &str) -> Result<String> {
|
||||
// Build the container spec for the API
|
||||
let mut port_mappings = Vec::new();
|
||||
for port in &manifest.app.ports {
|
||||
if !crate::manifest::host_can_bind_publish_ip(&port.bind) {
|
||||
tracing::warn!(
|
||||
app = %manifest.app.id,
|
||||
bind = %port.bind,
|
||||
host_port = port.host,
|
||||
"dropping publish: bind address not assignable on this host"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Honour the manifest's protocol (default tcp). netbird's STUN port
|
||||
// is 3478/udp; forcing tcp here would publish the wrong protocol and
|
||||
// silently break relay discovery.
|
||||
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
||||
"udp" => "udp",
|
||||
"sctp" => "sctp",
|
||||
_ => "tcp",
|
||||
};
|
||||
let mut mapping = serde_json::json!({
|
||||
"container_port": port.container,
|
||||
"host_port": port.host,
|
||||
"protocol": protocol,
|
||||
});
|
||||
if !port.bind.is_empty() {
|
||||
mapping["host_ip"] = serde_json::json!(port.bind);
|
||||
}
|
||||
port_mappings.push(mapping);
|
||||
}
|
||||
|
||||
let mut mounts = Vec::new();
|
||||
for volume in &manifest.app.volumes {
|
||||
if volume.volume_type == "tmpfs" {
|
||||
let options: Vec<String> = volume
|
||||
.tmpfs_options
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
mounts.push(serde_json::json!({
|
||||
"destination": volume.target,
|
||||
"type": "tmpfs",
|
||||
"options": options,
|
||||
}));
|
||||
} else {
|
||||
mounts.push(serde_json::json!({
|
||||
"destination": volume.target,
|
||||
"source": volume.source,
|
||||
"type": "bind",
|
||||
"options": volume.options,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let mut env_map = serde_json::Map::new();
|
||||
for env in &manifest.app.environment {
|
||||
if let Some((k, v)) = env.split_once('=') {
|
||||
env_map.insert(k.to_string(), serde_json::Value::String(v.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let cap_add: Vec<String> = manifest.app.security.capabilities.clone();
|
||||
let cap_drop = vec!["ALL".to_string()];
|
||||
|
||||
let image_ref = manifest.app.container.image_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"container config for {} has neither a valid image nor build source",
|
||||
manifest.app.id
|
||||
)
|
||||
})?;
|
||||
|
||||
// Build resource_limits conditionally: if the manifest has no memory or
|
||||
// cpu limit, OMIT the field entirely rather than sending 0. The podman
|
||||
// libpod HTTP API treats `memory.limit: 0` as "set MemoryMax=0" which
|
||||
// systemd then rejects at container-start time. Absent = unlimited.
|
||||
let mut resource_limits = serde_json::Map::new();
|
||||
if let Some(mem_bytes) = manifest
|
||||
.app
|
||||
.resources
|
||||
.memory_limit
|
||||
.as_ref()
|
||||
.and_then(|m| parse_memory_limit(m))
|
||||
{
|
||||
resource_limits.insert(
|
||||
"memory".to_string(),
|
||||
serde_json::json!({ "limit": mem_bytes }),
|
||||
);
|
||||
}
|
||||
if let Some(cpu) = manifest.app.resources.cpu_limit {
|
||||
resource_limits.insert(
|
||||
"cpu".to_string(),
|
||||
serde_json::json!({
|
||||
"quota": (cpu as i64) * 100_000,
|
||||
"period": 100_000u64,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let (net_mode, custom_network) = podman_network_settings(
|
||||
manifest.app.container.network.as_deref(),
|
||||
manifest.app.security.network_policy.as_str(),
|
||||
);
|
||||
|
||||
// Secret env travels by reference (podman injects the value at
|
||||
// start), so it never shows up in `podman inspect` output. The
|
||||
// combined content hash rides as a label for rotation-drift checks.
|
||||
let mut secret_env_map = serde_json::Map::new();
|
||||
for r in &manifest.app.container.secret_env_refs {
|
||||
secret_env_map.insert(
|
||||
r.env_key.clone(),
|
||||
serde_json::Value::String(r.secret_name.clone()),
|
||||
);
|
||||
}
|
||||
let mut labels_map = serde_json::Map::new();
|
||||
if let Some(hash) = &manifest.app.container.secret_env_hash {
|
||||
labels_map.insert(
|
||||
crate::manifest::SECRET_ENV_HASH_LABEL.to_string(),
|
||||
serde_json::Value::String(hash.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"name": name,
|
||||
"image": image_ref,
|
||||
"portmappings": port_mappings,
|
||||
"mounts": mounts,
|
||||
"env": env_map,
|
||||
"secret_env": secret_env_map,
|
||||
"labels": labels_map,
|
||||
"entrypoint": manifest.app.container.entrypoint.clone(),
|
||||
"command": manifest.app.container.custom_args.clone(),
|
||||
"hostadd": [
|
||||
"host.containers.internal:host-gateway",
|
||||
"host.archipelago:10.89.0.1",
|
||||
],
|
||||
"devices": manifest.app.devices.iter().map(|d| {
|
||||
serde_json::json!({"path": d})
|
||||
}).collect::<Vec<_>>(),
|
||||
"resource_limits": resource_limits,
|
||||
"cap_add": cap_add,
|
||||
"cap_drop": cap_drop,
|
||||
"read_only_filesystem": manifest.app.security.readonly_root,
|
||||
"no_new_privileges": manifest.app.security.no_new_privileges,
|
||||
"restart_policy": "unless-stopped",
|
||||
"restart_tries": 5,
|
||||
"netns": {
|
||||
"nsmode": net_mode
|
||||
},
|
||||
});
|
||||
if let Some(network) = custom_network {
|
||||
// The container always answers to its own name; manifest
|
||||
// network_aliases add extra short hostnames peers may bake in
|
||||
// (e.g. indeedhub's api/minio/relay). Dedup so a manifest that
|
||||
// redundantly lists its own name doesn't double it.
|
||||
let mut aliases = vec![name.to_string()];
|
||||
for a in &manifest.app.container.network_aliases {
|
||||
if !aliases.iter().any(|x| x == a) {
|
||||
aliases.push(a.clone());
|
||||
}
|
||||
}
|
||||
body.as_object_mut()
|
||||
.expect("container create body is a JSON object")
|
||||
.insert(
|
||||
"networks".to_string(),
|
||||
serde_json::json!({ network: { "aliases": aliases } }),
|
||||
);
|
||||
}
|
||||
|
||||
let result = self
|
||||
.api_request("POST", "libpod/containers/create", Some(body), LONG_TIMEOUT)
|
||||
.await?;
|
||||
|
||||
let id = result["Id"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.context("Podman API returned no container ID — creation may have failed")?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn start_container(&self, name: &str) -> Result<()> {
|
||||
self.api_post_action(&format!("libpod/containers/{}/start", name))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn stop_container(&self, name: &str) -> Result<()> {
|
||||
self.stop_container_with_grace(name, 10).await
|
||||
}
|
||||
|
||||
/// Stop via libpod honouring a per-app grace (seconds). The HTTP deadline is
|
||||
/// kept above the grace so the post-grace SIGKILL lands before we give up —
|
||||
/// otherwise slow-to-SIGTERM apps (fedimint, bitcoin-core, electrumx…) time
|
||||
/// out at exactly the grace boundary and the stop is reported as failed.
|
||||
pub async fn stop_container_with_grace(&self, name: &str, grace_secs: u64) -> Result<()> {
|
||||
let deadline = std::time::Duration::from_secs(
|
||||
grace_secs + crate::runtime::STOP_GRACE_DEADLINE_BUFFER_SECS,
|
||||
);
|
||||
self.api_request(
|
||||
"POST",
|
||||
&format!("libpod/containers/{}/stop?t={}", name, grace_secs),
|
||||
None,
|
||||
deadline,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn restart_container(&self, name: &str) -> Result<()> {
|
||||
self.api_request(
|
||||
"POST",
|
||||
&format!("libpod/containers/{}/restart?t=10", name),
|
||||
None,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn remove_container(&self, name: &str) -> Result<()> {
|
||||
self.api_request(
|
||||
"DELETE",
|
||||
&format!("libpod/containers/{}?force=true", name),
|
||||
None,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> {
|
||||
let data = self
|
||||
.api_request(
|
||||
"GET",
|
||||
&format!("libpod/containers/{}/json", name),
|
||||
None,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let state_str = data["State"]["Status"].as_str().unwrap_or("unknown");
|
||||
let health = data["State"]["Health"]["Status"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string());
|
||||
let started_at = data["State"]["StartedAt"].as_str().map(|s| s.to_string());
|
||||
let container_name = data["Name"].as_str().unwrap_or(name).to_string();
|
||||
|
||||
// Parse port bindings
|
||||
let ports = parse_port_bindings(&data["HostConfig"]["PortBindings"]);
|
||||
let lan_address = Self::lan_address_for(&container_name);
|
||||
|
||||
let exit_code = data["State"]["ExitCode"].as_i64().map(|c| c as i32);
|
||||
|
||||
Ok(ContainerStatus {
|
||||
id: data["Id"].as_str().unwrap_or("").to_string(),
|
||||
name: container_name,
|
||||
state: ContainerState::from(state_str),
|
||||
health,
|
||||
exit_code,
|
||||
started_at,
|
||||
image: data["ImageName"]
|
||||
.as_str()
|
||||
.or_else(|| data["Config"]["Image"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
created: data["Created"].as_str().unwrap_or("").to_string(),
|
||||
ports,
|
||||
lan_address,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>> {
|
||||
// Logs endpoint returns raw text, not JSON — use CLI for this
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("logs")
|
||||
.arg("--tail")
|
||||
.arg(lines.to_string())
|
||||
.arg(name);
|
||||
|
||||
let output = tokio::time::timeout(DEFAULT_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Container logs timed out"))?
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to get logs: {}", stderr));
|
||||
}
|
||||
|
||||
// Podman logs go to both stdout and stderr
|
||||
let mut all_output = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.is_empty() {
|
||||
all_output.push_str(&stderr);
|
||||
}
|
||||
Ok(all_output.lines().map(|s| s.to_string()).collect())
|
||||
}
|
||||
|
||||
pub async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
||||
let data = self
|
||||
.api_request(
|
||||
"GET",
|
||||
"libpod/containers/json?all=true",
|
||||
None,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let containers = data
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("Expected array from containers/json"))?;
|
||||
|
||||
let mut result = Vec::with_capacity(containers.len());
|
||||
|
||||
for c in containers {
|
||||
let name = if let Some(names) = c["Names"].as_array() {
|
||||
names
|
||||
.first()
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
} else {
|
||||
c["Names"].as_str().unwrap_or("").to_string()
|
||||
};
|
||||
|
||||
let ports = if let Some(ports_array) = c["Ports"].as_array() {
|
||||
ports_array
|
||||
.iter()
|
||||
.filter_map(|port| {
|
||||
let host_port = port["host_port"].as_u64()?;
|
||||
let container_port = port["container_port"].as_u64()?;
|
||||
let protocol = port["protocol"].as_str().unwrap_or("tcp");
|
||||
Some(format!(
|
||||
"0.0.0.0:{}->{}/{}",
|
||||
host_port, container_port, protocol
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let status_str = c["Status"].as_str().unwrap_or("");
|
||||
let health = parse_health_from_status(status_str)
|
||||
.or_else(|| c["Health"].as_str().map(|s| s.to_string()));
|
||||
let started_at = c["StartedAt"]
|
||||
.as_str()
|
||||
.or_else(|| c["Started"].as_str())
|
||||
.map(|s| s.to_string());
|
||||
let lan_address = Self::lan_address_for(&name);
|
||||
|
||||
let exit_code = c["ExitCode"]
|
||||
.as_i64()
|
||||
.or_else(|| c["State"]["ExitCode"].as_i64())
|
||||
.map(|c| c as i32);
|
||||
|
||||
result.push(ContainerStatus {
|
||||
id: c["Id"].as_str().unwrap_or("").to_string(),
|
||||
name,
|
||||
state: ContainerState::from(c["State"].as_str().unwrap_or("unknown")),
|
||||
health,
|
||||
exit_code,
|
||||
started_at,
|
||||
image: c["Image"].as_str().unwrap_or("").to_string(),
|
||||
created: c["Created"].as_str().unwrap_or("").to_string(),
|
||||
ports,
|
||||
lan_address,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Check if the Podman socket is available and responding.
|
||||
pub async fn health_check(&self) -> bool {
|
||||
self.api_request(
|
||||
"GET",
|
||||
"libpod/info",
|
||||
None,
|
||||
std::time::Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Registries we ship with as `--tls-verify=false` because they're internal
|
||||
/// HTTP mirrors. Add a host:port here only if it's a controlled mirror that
|
||||
/// the fleet trusts and operators won't ever paste a malicious URL into.
|
||||
const INSECURE_REGISTRY_HOSTS: &[&str] = &["146.59.87.168:3000"];
|
||||
|
||||
pub fn image_uses_insecure_registry(image: &str) -> bool {
|
||||
image
|
||||
.split('/')
|
||||
.next()
|
||||
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
|
||||
}
|
||||
|
||||
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("pasta") => ("pasta", None),
|
||||
Some("private") => ("private", None),
|
||||
Some(custom) => ("bridge", Some(custom.to_string())),
|
||||
None if network_policy == "host" => ("host", None),
|
||||
None => ("bridge", None),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
|
||||
let mut ports = Vec::new();
|
||||
if let Some(obj) = bindings.as_object() {
|
||||
for (container_port, host_bindings) in obj {
|
||||
if let Some(arr) = host_bindings.as_array() {
|
||||
for binding in arr {
|
||||
let host_ip = binding["HostIp"].as_str().unwrap_or("0.0.0.0");
|
||||
let host_port = binding["HostPort"].as_str().unwrap_or("");
|
||||
if !host_port.is_empty() {
|
||||
ports.push(format!("{}:{}->{}", host_ip, host_port, container_port));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ports
|
||||
}
|
||||
|
||||
fn manifest_lan_address_for(container_name: &str) -> Option<String> {
|
||||
for apps_dir in manifest_apps_dirs() {
|
||||
let Ok(entries) = std::fs::read_dir(apps_dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path().join("manifest.yml");
|
||||
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(manifest) = AppManifest::parse(&contents) else {
|
||||
continue;
|
||||
};
|
||||
if manifest_runtime_names(&manifest)
|
||||
.iter()
|
||||
.any(|name| name == container_name)
|
||||
{
|
||||
if let Some(url) = manifest_primary_interface_url(&manifest) {
|
||||
return Some(url);
|
||||
}
|
||||
if manifest_has_http_health(&manifest) {
|
||||
if let Some(port) = manifest
|
||||
.app
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.protocol.eq_ignore_ascii_case("tcp"))
|
||||
.map(|port| port.host)
|
||||
{
|
||||
return Some(format!("http://localhost:{port}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn manifest_primary_interface_url(manifest: &AppManifest) -> Option<String> {
|
||||
let main = manifest.app.interfaces.get("main")?;
|
||||
if main.interface_type != "ui" {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"{}://localhost:{}{}",
|
||||
main.protocol, main.port, main.path
|
||||
))
|
||||
}
|
||||
|
||||
fn manifest_has_http_health(manifest: &AppManifest) -> bool {
|
||||
manifest
|
||||
.app
|
||||
.health_check
|
||||
.as_ref()
|
||||
.is_some_and(|health| health.check_type.eq_ignore_ascii_case("http"))
|
||||
}
|
||||
|
||||
fn manifest_runtime_names(manifest: &AppManifest) -> Vec<String> {
|
||||
let mut names = vec![manifest_container_name(manifest)];
|
||||
match manifest.app.id.as_str() {
|
||||
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => names.push(manifest.app.id.clone()),
|
||||
"fedimint" => names.push("fedimintd".to_string()),
|
||||
"immich" => names.push("immich_server".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn manifest_container_name(manifest: &AppManifest) -> String {
|
||||
if let Some(v) = manifest.app.extensions.get("container_name") {
|
||||
if let Some(s) = v.as_str() {
|
||||
if !s.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
match manifest.app.id.as_str() {
|
||||
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => format!("archy-{}", manifest.app.id),
|
||||
id => id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_apps_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||
}
|
||||
dirs.extend([
|
||||
Path::new("apps").to_path_buf(),
|
||||
Path::new("/opt/archipelago/apps").to_path_buf(),
|
||||
Path::new("/opt/archipelago/web-ui/archipelago-runtime/apps").to_path_buf(),
|
||||
]);
|
||||
dirs
|
||||
}
|
||||
|
||||
fn parse_memory_limit(limit: &str) -> Option<i64> {
|
||||
// Supports the Kubernetes-style suffixes used throughout apps/*/manifest.yml
|
||||
// (IEC binary: Ki/Mi/Gi/Ti) as well as the shorter docker-style k/m/g/t.
|
||||
// Longest suffix matched first so "Mi" isn't mis-matched as "m".
|
||||
//
|
||||
// Historical bug: we used to lowercase+trim_end_matches('m'), which turned
|
||||
// "128Mi" into "128i" → parse::<f64> failed → None → .unwrap_or(0) wrote
|
||||
// memory.limit:0 into the OCI spec, which systemd then rejected at start
|
||||
// time with "MemoryMax is out of range" on rootless podman. See
|
||||
// docs/rust-orchestrator-migration.md Step 9 notes.
|
||||
let trimmed = limit.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
const UNITS: &[(&str, i64)] = &[
|
||||
("Ki", 1024),
|
||||
("Mi", 1024 * 1024),
|
||||
("Gi", 1024 * 1024 * 1024),
|
||||
("Ti", 1024i64 * 1024 * 1024 * 1024),
|
||||
("kB", 1000),
|
||||
("MB", 1_000_000),
|
||||
("GB", 1_000_000_000),
|
||||
("TB", 1_000_000_000_000),
|
||||
("k", 1024),
|
||||
("K", 1024),
|
||||
("m", 1024 * 1024),
|
||||
("M", 1024 * 1024),
|
||||
("g", 1024 * 1024 * 1024),
|
||||
("G", 1024 * 1024 * 1024),
|
||||
("t", 1024i64 * 1024 * 1024 * 1024),
|
||||
("T", 1024i64 * 1024 * 1024 * 1024),
|
||||
("b", 1),
|
||||
("B", 1),
|
||||
];
|
||||
for (suffix, multiplier) in UNITS {
|
||||
if let Some(num) = trimmed.strip_suffix(suffix) {
|
||||
let num = num.trim();
|
||||
return num
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|v| (v * (*multiplier as f64)) as i64)
|
||||
.filter(|n| *n > 0);
|
||||
}
|
||||
}
|
||||
// No recognised suffix — treat as raw bytes.
|
||||
trimmed.parse::<i64>().ok().filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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"
|
||||
));
|
||||
// The legacy Hetzner mirror at 23.182.128.160 was decommissioned and
|
||||
// is no longer trusted — it must NOT bypass TLS even if a stale
|
||||
// registry config still references it.
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"23.182.128.160:3000/lfg2025/filebrowser:v2.27.0"
|
||||
));
|
||||
// HTTPS registries never match the insecure list.
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"ghcr.io/lfg2025/bitcoin-knots:latest"
|
||||
));
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"docker.io/library/nginx:latest"
|
||||
));
|
||||
// Spoofing immune: an attacker host that prefixes the trusted IP
|
||||
// string into its own URL still has the attacker host in the
|
||||
// registry-host slot, so it does NOT match.
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"evil.example:80/146.59.87.168:3000/lfg2025/x: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]
|
||||
fn lan_address_uses_manifest_http_port_for_regular_apps() {
|
||||
assert_eq!(
|
||||
PodmanClient::lan_address_for("filebrowser").as_deref(),
|
||||
Some("http://localhost:8083")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_address_prefers_manifest_main_interface() {
|
||||
assert_eq!(
|
||||
PodmanClient::lan_address_for("fedimint").as_deref(),
|
||||
Some("http://localhost:8175/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_address_does_not_expose_tcp_only_service_ports() {
|
||||
assert_eq!(
|
||||
PodmanClient::lan_address_for("bitcoin-knots").as_deref(),
|
||||
Some("http://localhost:8334")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_limit_iec_binary_suffixes() {
|
||||
// Kubernetes-style — this is what apps/*/manifest.yml uses.
|
||||
assert_eq!(parse_memory_limit("128Mi"), Some(128 * 1024 * 1024));
|
||||
assert_eq!(parse_memory_limit("64Mi"), Some(64 * 1024 * 1024));
|
||||
assert_eq!(parse_memory_limit("4Gi"), Some(4i64 * 1024 * 1024 * 1024));
|
||||
assert_eq!(parse_memory_limit("512Ki"), Some(512 * 1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_limit_shorthand_suffixes() {
|
||||
// Docker-style shorthand — treated as IEC binary for backwards compat.
|
||||
assert_eq!(parse_memory_limit("128m"), Some(128 * 1024 * 1024));
|
||||
assert_eq!(parse_memory_limit("128M"), Some(128 * 1024 * 1024));
|
||||
assert_eq!(parse_memory_limit("2g"), Some(2i64 * 1024 * 1024 * 1024));
|
||||
assert_eq!(parse_memory_limit("2G"), Some(2i64 * 1024 * 1024 * 1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_limit_si_decimal_suffixes() {
|
||||
assert_eq!(parse_memory_limit("1MB"), Some(1_000_000));
|
||||
assert_eq!(parse_memory_limit("1GB"), Some(1_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_limit_raw_bytes() {
|
||||
assert_eq!(parse_memory_limit("134217728"), Some(134_217_728));
|
||||
assert_eq!(parse_memory_limit(" 134217728 "), Some(134_217_728));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_limit_invalid_returns_none() {
|
||||
// Regression guard: the old implementation returned Some(0) for "128Mi"
|
||||
// because lowercase+trim_end_matches('m') left "128i" which parse::<f64>
|
||||
// rejected. The new implementation must never return Some(0) or Some of
|
||||
// a negative number from any input.
|
||||
assert_eq!(parse_memory_limit(""), None);
|
||||
assert_eq!(parse_memory_limit(" "), None);
|
||||
assert_eq!(parse_memory_limit("abc"), None);
|
||||
assert_eq!(parse_memory_limit("0"), None);
|
||||
assert_eq!(parse_memory_limit("0Mi"), None);
|
||||
assert_eq!(parse_memory_limit("-1Mi"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_limit_tolerates_whitespace_and_fractional() {
|
||||
assert_eq!(
|
||||
parse_memory_limit(" 1.5Gi "),
|
||||
Some((1.5 * (1024.0 * 1024.0 * 1024.0)) as i64)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PortError {
|
||||
#[error("Port {0} is already allocated to app {1}")]
|
||||
PortConflict(u16, String),
|
||||
#[error("App {0} has no allocated ports")]
|
||||
NoPortsAllocated(String),
|
||||
#[error("Lock poisoned: {0}")]
|
||||
LockPoisoned(String),
|
||||
}
|
||||
|
||||
pub struct PortManager {
|
||||
allocations: Arc<RwLock<HashMap<String, Vec<u16>>>>,
|
||||
port_to_app: Arc<RwLock<HashMap<u16, String>>>,
|
||||
port_offset: u16,
|
||||
}
|
||||
|
||||
impl PortManager {
|
||||
pub fn new(port_offset: u16) -> Self {
|
||||
Self {
|
||||
allocations: Arc::new(RwLock::new(HashMap::new())),
|
||||
port_to_app: Arc::new(RwLock::new(HashMap::new())),
|
||||
port_offset,
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate ports for an app, applying the port offset
|
||||
pub fn allocate_ports(&self, app_id: &str, base_ports: &[u16]) -> Result<Vec<u16>, PortError> {
|
||||
let mut allocations = self
|
||||
.allocations
|
||||
.write()
|
||||
.map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
let mut port_to_app = self
|
||||
.port_to_app
|
||||
.write()
|
||||
.map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
let mut allocated_ports = Vec::new();
|
||||
|
||||
// Check for conflicts and allocate ports
|
||||
for &base_port in base_ports {
|
||||
let dev_port = base_port + self.port_offset;
|
||||
|
||||
// Check if port is already allocated
|
||||
if let Some(existing_app) = port_to_app.get(&dev_port) {
|
||||
if existing_app != app_id {
|
||||
return Err(PortError::PortConflict(dev_port, existing_app.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
allocated_ports.push(dev_port);
|
||||
port_to_app.insert(dev_port, app_id.to_string());
|
||||
}
|
||||
|
||||
// Store allocation for this app
|
||||
allocations.insert(app_id.to_string(), allocated_ports.clone());
|
||||
|
||||
Ok(allocated_ports)
|
||||
}
|
||||
|
||||
/// Get allocated ports for an app
|
||||
pub fn get_port_mapping(&self, app_id: &str) -> Result<Option<Vec<u16>>, PortError> {
|
||||
let allocations = self
|
||||
.allocations
|
||||
.read()
|
||||
.map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
Ok(allocations.get(app_id).cloned())
|
||||
}
|
||||
|
||||
/// Get the dev port for a specific base port of an app
|
||||
pub fn get_dev_port(&self, app_id: &str, base_port: u16) -> Result<Option<u16>, PortError> {
|
||||
Ok(self.get_port_mapping(app_id)?.and_then(|ports| {
|
||||
ports
|
||||
.iter()
|
||||
.find(|&&p| p == base_port + self.port_offset)
|
||||
.copied()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Release all ports allocated to an app
|
||||
pub fn release_ports(&self, app_id: &str) -> Result<(), PortError> {
|
||||
let mut allocations = self
|
||||
.allocations
|
||||
.write()
|
||||
.map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
let mut port_to_app = self
|
||||
.port_to_app
|
||||
.write()
|
||||
.map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
|
||||
if let Some(ports) = allocations.remove(app_id) {
|
||||
for port in ports {
|
||||
port_to_app.remove(&port);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PortError::NoPortsAllocated(app_id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a port is available
|
||||
pub fn is_port_available(&self, base_port: u16) -> Result<bool, PortError> {
|
||||
let dev_port = base_port + self.port_offset;
|
||||
let port_to_app = self
|
||||
.port_to_app
|
||||
.read()
|
||||
.map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
Ok(!port_to_app.contains_key(&dev_port))
|
||||
}
|
||||
|
||||
/// Get all allocated ports
|
||||
pub fn get_all_allocations(&self) -> Result<HashMap<String, Vec<u16>>, PortError> {
|
||||
let allocations = self
|
||||
.allocations
|
||||
.read()
|
||||
.map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||
Ok(allocations.clone())
|
||||
}
|
||||
|
||||
/// Get port offset
|
||||
pub fn port_offset(&self) -> u16 {
|
||||
self.port_offset
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_port_allocation() {
|
||||
let manager = PortManager::new(10000);
|
||||
|
||||
let ports = manager.allocate_ports("app1", &[8332, 8333]).unwrap();
|
||||
assert_eq!(ports, vec![18332, 18333]);
|
||||
|
||||
let mapping = manager.get_port_mapping("app1").unwrap().unwrap();
|
||||
assert_eq!(mapping, vec![18332, 18333]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_conflict() {
|
||||
let manager = PortManager::new(10000);
|
||||
|
||||
manager.allocate_ports("app1", &[8332]).unwrap();
|
||||
|
||||
// Try to allocate the same port to another app
|
||||
let result = manager.allocate_ports("app2", &[8332]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_release() {
|
||||
let manager = PortManager::new(10000);
|
||||
|
||||
manager.allocate_ports("app1", &[8332]).unwrap();
|
||||
manager.release_ports("app1").unwrap();
|
||||
|
||||
// Port should now be available
|
||||
assert!(manager.is_port_available(8332).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_dev_port() {
|
||||
let manager = PortManager::new(10000);
|
||||
|
||||
manager.allocate_ports("app1", &[8332, 8333]).unwrap();
|
||||
|
||||
assert_eq!(manager.get_dev_port("app1", 8332).unwrap(), Some(18332));
|
||||
assert_eq!(manager.get_dev_port("app1", 8333).unwrap(), Some(18333));
|
||||
assert_eq!(manager.get_dev_port("app1", 9999).unwrap(), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user