refactor(werkbank): extract soft-PLC provisioning + ICS probe into werkbank-exec (WB-04a) (#208)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m46s
CI / Deploy Dashboard (push) Successful in 2m53s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m46s
CI / Deploy Dashboard (push) Successful in 2m53s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
This commit was merged in pull request #208.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
//! Ephemeral soft-PLC container lifecycle (#183, sub-task 1 + 5).
|
||||
//!
|
||||
//! Provisions a throwaway OpenPLC container per scan, isolated on the agent's own
|
||||
//! Docker network with hard resource caps and **no host port exposure**, then
|
||||
//! guarantees teardown. The container is reachable in-cluster only, by its name
|
||||
//! (the shared user-defined network's embedded DNS resolves it); it is never
|
||||
//! published to the host.
|
||||
//!
|
||||
//! The `docker` argv is produced by pure functions so provisioning is unit-tested
|
||||
//! without a Docker daemon — only the thin [`run_docker`] wrapper touches the OS.
|
||||
//! It requires the agent's runtime to have Docker access (a socket mount), which
|
||||
//! is why the whole path is gated behind [`PlcRuntimeConfig::enabled`].
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use compliance_core::PlcRuntimeConfig;
|
||||
|
||||
use crate::error::ExecError;
|
||||
|
||||
/// The Modbus/TCP port an OpenPLC instance opens once a program is running.
|
||||
const MODBUS_PORT: u16 = 502;
|
||||
/// The OpenPLC web-UI / WebVisu port.
|
||||
const WEBVISU_PORT: u16 = 8080;
|
||||
|
||||
/// Label key marking a container as an ephemeral PLC runtime we own.
|
||||
const OWNER_LABEL_KEY: &str = "certifai.ephemeral";
|
||||
/// Label value for our ephemeral PLC runtimes.
|
||||
const OWNER_LABEL_VALUE: &str = "plc-runtime";
|
||||
|
||||
/// A running ephemeral soft-PLC instance. Reachable in-cluster by `name`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProvisionedRuntime {
|
||||
/// The container name — also its in-network DNS alias.
|
||||
pub name: String,
|
||||
/// `name:502` — the Modbus/TCP endpoint the ICS probe targets.
|
||||
pub modbus_endpoint: String,
|
||||
/// `http://name:8080` — the WebVisu / OpenPLC web UI.
|
||||
pub webvisu_url: String,
|
||||
}
|
||||
|
||||
/// A source of ephemeral soft-PLC instances. Abstracted so the provision-and-test
|
||||
/// orchestration is unit-testable with a fake that never touches Docker.
|
||||
pub trait SoftPlc {
|
||||
/// Start a fresh instance for a target and return its handle.
|
||||
fn provision(
|
||||
&self,
|
||||
target_id: &str,
|
||||
) -> impl std::future::Future<Output = Result<ProvisionedRuntime, ExecError>> + Send;
|
||||
|
||||
/// Tear an instance down. Best-effort and idempotent — never fails the scan.
|
||||
fn teardown(&self, handle: &ProvisionedRuntime)
|
||||
-> impl std::future::Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
/// Provisions OpenPLC instances by shelling out to the Docker CLI.
|
||||
pub struct DockerSoftPlc {
|
||||
cfg: PlcRuntimeConfig,
|
||||
}
|
||||
|
||||
impl DockerSoftPlc {
|
||||
/// Build a provisioner from the PLC-runtime config.
|
||||
pub fn new(cfg: PlcRuntimeConfig) -> Self {
|
||||
Self { cfg }
|
||||
}
|
||||
}
|
||||
|
||||
impl SoftPlc for DockerSoftPlc {
|
||||
async fn provision(&self, target_id: &str) -> Result<ProvisionedRuntime, ExecError> {
|
||||
// Best-effort sweep of any container leaked by a crashed earlier run
|
||||
// before we add another. Only removes instances past their max lifetime,
|
||||
// so it can never disturb a concurrent run.
|
||||
reap_stale(&self.cfg, now_epoch()).await;
|
||||
|
||||
let name = instance_name(target_id, now_epoch(), &random_suffix());
|
||||
let args = run_args(&self.cfg, &name, target_id);
|
||||
let out = run_docker(&args).await?;
|
||||
if !out.status.success() {
|
||||
return Err(ExecError::Other(format!(
|
||||
"docker run for soft-PLC {name} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(ProvisionedRuntime {
|
||||
modbus_endpoint: format!("{name}:{MODBUS_PORT}"),
|
||||
webvisu_url: format!("http://{name}:{WEBVISU_PORT}"),
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
async fn teardown(&self, handle: &ProvisionedRuntime) {
|
||||
match run_docker(&rm_args(&handle.name)).await {
|
||||
Ok(out) if out.status.success() => {
|
||||
tracing::info!(instance = %handle.name, "soft-PLC instance torn down");
|
||||
}
|
||||
Ok(out) => tracing::warn!(
|
||||
instance = %handle.name,
|
||||
"soft-PLC teardown non-zero exit: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(instance = %handle.name, error = %e, "soft-PLC teardown failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch (0 if the clock is before 1970, which never
|
||||
/// happens in practice).
|
||||
fn now_epoch() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A short random, docker-name-safe suffix.
|
||||
fn random_suffix() -> String {
|
||||
uuid::Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
/// A unique, docker-safe container name that encodes the creation epoch (for the
|
||||
/// stale reaper) and the target it belongs to. Shape:
|
||||
/// `certifai-plc-<epoch>-<target12>-<rand6>`.
|
||||
fn instance_name(target_id: &str, epoch: u64, rand: &str) -> String {
|
||||
let short: String = target_id
|
||||
.chars()
|
||||
.filter(char::is_ascii_alphanumeric)
|
||||
.take(12)
|
||||
.collect();
|
||||
let rand: String = rand
|
||||
.chars()
|
||||
.filter(char::is_ascii_alphanumeric)
|
||||
.take(6)
|
||||
.collect();
|
||||
format!("certifai-plc-{epoch}-{short}-{rand}")
|
||||
}
|
||||
|
||||
/// The creation epoch encoded in an instance name, if it is one of ours.
|
||||
fn parse_epoch(name: &str) -> Option<u64> {
|
||||
name.strip_prefix("certifai-plc-")?
|
||||
.split('-')
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// The `docker run` argv for an ephemeral soft-PLC: detached, joined to the
|
||||
/// agent's network, resource-capped, hardened, labelled for reaping, and — by
|
||||
/// omitting any `-p` — never published to the host.
|
||||
fn run_args(cfg: &PlcRuntimeConfig, name: &str, target_id: &str) -> Vec<String> {
|
||||
vec![
|
||||
"run".into(),
|
||||
"-d".into(),
|
||||
"--name".into(),
|
||||
name.into(),
|
||||
"--network".into(),
|
||||
cfg.network.clone(),
|
||||
"--memory".into(),
|
||||
cfg.memory.clone(),
|
||||
"--cpus".into(),
|
||||
cfg.cpus.clone(),
|
||||
"--pids-limit".into(),
|
||||
"512".into(),
|
||||
"--security-opt".into(),
|
||||
"no-new-privileges".into(),
|
||||
"--stop-timeout".into(),
|
||||
"5".into(),
|
||||
"--label".into(),
|
||||
format!("{OWNER_LABEL_KEY}={OWNER_LABEL_VALUE}"),
|
||||
"--label".into(),
|
||||
format!("certifai.target={target_id}"),
|
||||
cfg.image.clone(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `docker rm -f` argv that stops and removes an instance.
|
||||
fn rm_args(name: &str) -> Vec<String> {
|
||||
vec!["rm".into(), "-f".into(), name.into()]
|
||||
}
|
||||
|
||||
/// The `docker ps` argv listing the names of every ephemeral PLC container we own.
|
||||
fn reap_list_args() -> Vec<String> {
|
||||
vec![
|
||||
"ps".into(),
|
||||
"-a".into(),
|
||||
"--filter".into(),
|
||||
format!("label={OWNER_LABEL_KEY}={OWNER_LABEL_VALUE}"),
|
||||
"--format".into(),
|
||||
"{{.Names}}".into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Remove any ephemeral PLC container older than twice the configured max
|
||||
/// lifetime — i.e. one a crashed run leaked. The generous threshold guarantees a
|
||||
/// container from a *live* run (still within its own deadline) is never swept.
|
||||
/// Best-effort: any Docker error (e.g. no daemon) is ignored.
|
||||
async fn reap_stale(cfg: &PlcRuntimeConfig, now: u64) {
|
||||
let cutoff = cfg.max_lifetime_secs.saturating_mul(2);
|
||||
let Ok(out) = run_docker(&reap_list_args()).await else {
|
||||
return;
|
||||
};
|
||||
if !out.status.success() {
|
||||
return;
|
||||
}
|
||||
let names = String::from_utf8_lossy(&out.stdout);
|
||||
for name in names.lines().map(str::trim).filter(|n| !n.is_empty()) {
|
||||
let Some(epoch) = parse_epoch(name) else {
|
||||
continue;
|
||||
};
|
||||
if now.saturating_sub(epoch) > cutoff {
|
||||
tracing::warn!(instance = %name, "reaping stale soft-PLC instance");
|
||||
let _ = run_docker(&rm_args(name)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a `docker` subcommand, capturing its output.
|
||||
async fn run_docker(args: &[String]) -> Result<std::process::Output, ExecError> {
|
||||
tokio::process::Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(ExecError::Io)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PlcRuntimeConfig {
|
||||
PlcRuntimeConfig {
|
||||
enabled: true,
|
||||
image: "registry.example.com/openplc:latest".into(),
|
||||
network: "certifai".into(),
|
||||
memory: "512m".into(),
|
||||
cpus: "0.5".into(),
|
||||
max_lifetime_secs: 180,
|
||||
..PlcRuntimeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_name_is_unique_docker_safe_and_reaper_parseable() {
|
||||
let a = instance_name("64f0aabbccddeeff00112233", 1_700_000_000, "abcdef123456");
|
||||
assert_eq!(a, "certifai-plc-1700000000-64f0aabbccdd-abcdef");
|
||||
assert_eq!(parse_epoch(&a), Some(1_700_000_000));
|
||||
// Docker names: only [A-Za-z0-9_.-].
|
||||
assert!(a
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')));
|
||||
// A different random suffix yields a different name for the same target.
|
||||
let b = instance_name("64f0aabbccddeeff00112233", 1_700_000_000, "zzzzzz999999");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_epoch_rejects_foreign_names() {
|
||||
assert_eq!(parse_epoch("some-other-container"), None);
|
||||
assert_eq!(parse_epoch("certifai-plc-notanumber-x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_args_cap_resources_harden_label_and_never_publish_a_port() {
|
||||
let args = run_args(&cfg(), "certifai-plc-1-t-r", "target-123");
|
||||
// No host port publishing.
|
||||
assert!(!args.iter().any(|a| a == "-p" || a == "--publish"));
|
||||
// Detached.
|
||||
assert!(args.contains(&"-d".to_string()));
|
||||
// Joined to the agent's own network.
|
||||
let net = args.iter().position(|a| a == "--network").expect("network");
|
||||
assert_eq!(args[net + 1], "certifai");
|
||||
// Resource caps.
|
||||
let mem = args.iter().position(|a| a == "--memory").expect("memory");
|
||||
assert_eq!(args[mem + 1], "512m");
|
||||
let cpu = args.iter().position(|a| a == "--cpus").expect("cpus");
|
||||
assert_eq!(args[cpu + 1], "0.5");
|
||||
assert!(args.iter().any(|a| a == "--pids-limit"));
|
||||
// Hardening.
|
||||
let so = args
|
||||
.iter()
|
||||
.position(|a| a == "--security-opt")
|
||||
.expect("secopt");
|
||||
assert_eq!(args[so + 1], "no-new-privileges");
|
||||
// Ownership + target labels for reaping / attribution.
|
||||
assert!(args.contains(&"certifai.ephemeral=plc-runtime".to_string()));
|
||||
assert!(args.contains(&"certifai.target=target-123".to_string()));
|
||||
// Image is last.
|
||||
assert_eq!(
|
||||
args.last().map(String::as_str),
|
||||
Some("registry.example.com/openplc:latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rm_args_force_remove() {
|
||||
assert_eq!(rm_args("x"), vec!["rm", "-f", "x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reap_list_filters_by_owner_label() {
|
||||
let args = reap_list_args();
|
||||
assert!(args.contains(&"label=certifai.ephemeral=plc-runtime".to_string()));
|
||||
assert!(args.contains(&"{{.Names}}".to_string()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user