PSA/ee/runner/tests/container_static_integration.rs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

765 lines
23 KiB
Rust

use axum::Router;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine as _;
use rand::{distributions::Alphanumeric, Rng};
use reqwest::Client;
use serde_json::json;
use serial_test::serial;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tar::Builder;
use tokio::net::TcpListener;
use tokio::process::Command;
use tokio::task::JoinHandle;
use tokio::time::sleep;
use tower_http::services::ServeDir;
use zstd::stream::encode_all as zstd_encode_all;
const TENANT_ID: &str = "tenant-a";
const EXTENSION_ID: &str = "demo-ext";
const DYNAMIC_COMPONENT_WASM: &[u8] = include_bytes!("fixtures/dynamic_component/component.wasm");
fn verbose_enabled() -> bool {
std::env::var("VERBOSE_TESTS").is_ok()
}
fn log(msg: impl AsRef<str>) {
if verbose_enabled() {
log_always(msg);
}
}
fn log_always(msg: impl AsRef<str>) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
eprintln!(
"[container-static {:>6}.{:03}] {}",
now.as_secs(),
now.subsec_millis(),
msg.as_ref()
);
}
fn render_bytes(label: &str, data: &[u8], limit: usize) -> String {
let text = String::from_utf8_lossy(data);
if data.is_empty() {
return format!("{label}: <empty>");
}
if verbose_enabled() || text.len() <= limit {
return format!("{label} ({} bytes):\n{}", data.len(), text);
}
let head = &text[..limit.min(text.len())];
let tail_start = text.len().saturating_sub(limit);
let tail = &text[tail_start..];
format!(
"{label} ({} bytes, showing first/last {} chars):\n{}...\n[truncated]\n...{}",
data.len(),
limit,
head,
tail
)
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn runner_container_serves_static_bundle() -> anyhow::Result<()> {
log("starting runner_container_serves_static_bundle");
if !docker_available().await {
log("docker not available; skipping container integration test");
return Ok(());
}
log("creating temporary bundle directory for static bundle test");
let bundle_dir = tempfile::tempdir()?;
log(&format!(
"bundle tempdir created at {}",
bundle_dir.path().display()
));
let (bundle_hash, index_body) = write_bundle_artifact(bundle_dir.path())?;
log(&format!(
"bundle artifact ready with hash sha256:{bundle_hash} (index length: {} bytes)",
index_body.len()
));
let (bundle_server, bundle_port) = start_bundle_server(bundle_dir.path().to_path_buf()).await?;
log(&format!("bundle server listening on port {bundle_port}"));
let image_tag = format!("alga-runner-test:{}", unique_suffix());
log(&format!("building docker image {image_tag}"));
docker_build(&image_tag).await?;
let _image_guard = ImageGuard::new(&image_tag);
let host_port = allocate_port()?;
let container_name = format!("alga-runner-container-{}", unique_suffix());
let mut container_guard = None;
let run_output = docker_run(
&image_tag,
&container_name,
host_port,
bundle_port,
&bundle_hash,
)
.await?;
container_guard = Some(ContainerGuard::new(&container_name));
log(&format!(
"container {container_name} started; host port {host_port}, raw output len {} bytes",
run_output.len()
));
log("waiting for container /healthz endpoint");
wait_for_health(host_port).await?;
log("health endpoint reachable");
log("verifying static index content");
verify_static_response(host_port, &bundle_hash, &index_body).await?;
log("static response contains expected contents");
if let Some(guard) = &container_guard {
log("stopping container via guard");
guard.stop().await;
}
bundle_server.abort();
let _ = bundle_server.await;
log("bundle server aborted and joined");
log("runner_container_serves_static_bundle completed successfully");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn runner_container_executes_dynamic_component() -> anyhow::Result<()> {
log("starting runner_container_executes_dynamic_component");
if !docker_available().await {
log("docker not available; skipping dynamic component test");
return Ok(());
}
log("creating temporary bundle directory for dynamic component");
let bundle_dir = tempfile::tempdir()?;
log(&format!(
"dynamic component tempdir: {}",
bundle_dir.path().display()
));
let wasm_hash = write_dynamic_component_artifact(bundle_dir.path())?;
log(&format!("dynamic component bundle hash sha256:{wasm_hash}"));
let (bundle_server, bundle_port) = start_bundle_server(bundle_dir.path().to_path_buf()).await?;
log(&format!(
"dynamic component bundle server listening on port {bundle_port}"
));
let image_tag = format!("alga-runner-test:{}", unique_suffix());
log(&format!(
"building docker image {image_tag} for dynamic component test"
));
docker_build(&image_tag).await?;
let _image_guard = ImageGuard::new(&image_tag);
let host_port = allocate_port()?;
let container_name = format!("alga-runner-container-{}", unique_suffix());
let mut container_guard = None;
let run_output = docker_run(
&image_tag,
&container_name,
host_port,
bundle_port,
&wasm_hash,
)
.await?;
container_guard = Some(ContainerGuard::new(&container_name));
log(&format!(
"dynamic component container {container_name} started on host port {host_port} (stdout len {} bytes)",
run_output.len()
));
log("waiting for dynamic component container /healthz");
wait_for_health(host_port).await?;
log("dynamic component health ready");
log("verifying dynamic component execution");
if let Err(err) = verify_dynamic_execution(host_port, &wasm_hash).await {
log_always(&format!(
"dynamic component execution failed: {err:?}; dumping container logs"
));
dump_container_logs(&container_name).await?;
dump_docker_state().await?;
return Err(err);
}
log("dynamic component execution verified");
if let Some(guard) = &container_guard {
log("stopping dynamic component container via guard");
guard.stop().await;
}
bundle_server.abort();
let _ = bundle_server.await;
log("dynamic component bundle server aborted and joined");
log("runner_container_executes_dynamic_component completed successfully");
Ok(())
}
fn unique_suffix() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let rand: String = rand::thread_rng()
.sample_iter(rand::distributions::Alphanumeric)
.take(6)
.map(char::from)
.collect();
format!("{now}{rand}")
}
async fn docker_available() -> bool {
match Command::new("docker")
.arg("version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
{
Ok(status) => {
log(&format!("docker version status: {status}"));
status.success()
}
Err(err) => {
log(&format!("failed to invoke docker: {err}"));
false
}
}
}
async fn docker_build(image_tag: &str) -> anyhow::Result<()> {
log(&format!("docker_build start for {image_tag}"));
let mut cmd = Command::new("docker");
cmd.arg("build")
.arg("-t")
.arg(image_tag)
.arg("-f")
.arg("Dockerfile")
.arg(".")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = cmd.output().await?;
log(&format!(
"docker_build completed for {image_tag} (status: {}, stdout: {} bytes, stderr: {} bytes)",
output.status,
output.stdout.len(),
output.stderr.len()
));
ensure_success(&output, "docker build")?;
Ok(())
}
async fn docker_run(
image_tag: &str,
container_name: &str,
host_port: u16,
bundle_port: u16,
bundle_hash: &str,
) -> anyhow::Result<String> {
let bundle_base = format!("http://host.docker.internal:{bundle_port}/");
let mut cmd = Command::new("docker");
cmd.arg("run")
.arg("-d")
.arg("--rm")
.arg("--name")
.arg(container_name)
.arg("--add-host")
.arg("host.docker.internal:host-gateway")
.arg("--add-host")
.arg("host.testcontainers.internal:host-gateway")
.arg("-p")
.arg(format!("{host_port}:8080"))
.arg("-e")
.arg("ALGA_AUTH_KEY=test-key")
.arg("-e")
.arg("EXT_STATIC_STRICT_VALIDATION=false")
.arg("-e")
.arg("REGISTRY_BASE_URL=http://localhost:9001")
.arg("-e")
.arg(format!(
"RUST_LOG={}",
std::env::var("RUNNER_CONTAINER_RUST_LOG").unwrap_or_else(|_| "info".to_string())
))
.arg("-e")
.arg("WASMTIME_BACKTRACE_DETAILS=1")
.arg("-e")
.arg(format!("BUNDLE_STORE_BASE={bundle_base}"))
.arg(image_tag)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
log(&format!(
"docker_run start: image={image_tag}, container={container_name}, host_port={host_port}, bundle_port={bundle_port}"
));
let output = cmd.output().await?;
log(&format!(
"docker_run completed (status: {}, stdout: {} bytes, stderr: {} bytes)",
output.status,
output.stdout.len(),
output.stderr.len()
));
ensure_success(&output, "docker run")?;
let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
tracing::info!(container_id=%id, bundle_hash=%bundle_hash, "started runner container");
log(&format!("docker_run spawned container id: {id}"));
Ok(id)
}
async fn wait_for_health(port: u16) -> anyhow::Result<()> {
let client = Client::builder().timeout(Duration::from_secs(3)).build()?;
let health_url = format!("http://127.0.0.1:{port}/healthz");
let deadline = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
+ Duration::from_secs(30);
log(&format!(
"wait_for_health polling {health_url} with 30s deadline"
));
let mut attempts = 0usize;
loop {
attempts += 1;
match client.get(&health_url).send().await {
Ok(resp) if resp.status().is_success() => {
log(&format!("wait_for_health succeeded on attempt {attempts}"));
return Ok(());
}
Ok(resp) => {
log(&format!(
"wait_for_health attempt {attempts} received status {}",
resp.status()
));
}
Err(err) => {
log(&format!("wait_for_health attempt {attempts} error: {err}"));
}
}
if SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
>= deadline
{
anyhow::bail!("runner container did not become healthy in time");
}
if attempts % 10 == 0 {
log(&format!(
"wait_for_health still waiting after {attempts} attempts"
));
}
sleep(Duration::from_millis(200)).await;
}
}
async fn verify_static_response(
port: u16,
bundle_hash: &str,
expected_body: &str,
) -> anyhow::Result<()> {
let client = Client::builder().timeout(Duration::from_secs(5)).build()?;
let url = format!(
"http://127.0.0.1:{}/ext-ui/{}/sha256:{}/index.html",
port, EXTENSION_ID, bundle_hash
);
log(&format!("verify_static_response requesting {url}"));
let resp = client
.get(url)
.header("x-tenant-id", TENANT_ID)
.header("x-request-id", "container-test")
.send()
.await?;
if !resp.status().is_success() {
log(&format!(
"verify_static_response non-success status: {}",
resp.status()
));
anyhow::bail!("unexpected status from runner: {}", resp.status());
}
let body = resp.text().await?;
log(&format!(
"verify_static_response body length: {}, preview: {}",
body.len(),
body.chars().take(100).collect::<String>()
));
assert!(
body.contains(expected_body),
"runner response missing expected bundle contents"
);
Ok(())
}
async fn verify_dynamic_execution(port: u16, wasm_hash: &str) -> anyhow::Result<()> {
// Give the runner ample time to compile the wasm component on the first request.
let client = Client::builder().timeout(Duration::from_secs(20)).build()?;
let url = format!("http://127.0.0.1:{}/v1/execute", port);
log(&format!(
"verify_dynamic_execution posting to {url} for hash sha256:{wasm_hash}"
));
let raw_payload = br#"{\"ping\":true}"#;
let body_b64 = BASE64_STANDARD.encode(raw_payload);
let request_json = json!({
"context": {
"request_id": "container-dynamic",
"tenant_id": TENANT_ID,
"extension_id": EXTENSION_ID,
"content_hash": format!("sha256:{}", wasm_hash),
"config": {}
},
"http": {
"method": "POST",
"path": "/dynamic/echo",
"query": { "foo": "bar" },
"headers": {
"content-type": "application/json"
},
"body_b64": body_b64
},
"limits": {
"timeout_ms": 5000
},
"providers": [
"cap:context.read",
"cap:log.emit"
]
});
log(&format!(
"verify_dynamic_execution payload prepared ({} bytes)",
request_json.to_string().len()
));
let resp = client
.post(url)
.header("x-request-id", "container-dynamic-test")
.header("x-alga-tenant", TENANT_ID)
.header("x-alga-extension", EXTENSION_ID)
.json(&request_json)
.send()
.await?;
if !resp.status().is_success() {
log(&format!(
"verify_dynamic_execution non-success status: {}",
resp.status()
));
anyhow::bail!("unexpected status from runner execute: {}", resp.status());
}
let payload: serde_json::Value = resp.json().await?;
log(&format!(
"verify_dynamic_execution response payload: {}",
payload
));
let status = payload
.get("status")
.and_then(|v| v.as_u64())
.unwrap_or_default();
if status != 200 {
anyhow::bail!("runner execute returned non-200 status: {status}, payload: {payload}");
}
let headers = payload
.get("headers")
.and_then(|h| h.as_object())
.expect("headers map present");
assert_eq!(
headers.get("content-type").and_then(|v| v.as_str()),
Some("application/json"),
"component response missing content-type header"
);
assert_eq!(
headers.get("x-generated-by").and_then(|v| v.as_str()),
Some("js-component"),
"component response missing x-generated-by header"
);
let body_b64 = payload
.get("body_b64")
.and_then(|b| b.as_str())
.expect("body_b64 present");
let body_bytes = BASE64_STANDARD
.decode(body_b64)
.expect("body base64 decoded");
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes)?;
assert_eq!(body_json.get("ok").and_then(|v| v.as_bool()), Some(true));
assert_eq!(
body_json.get("tenantId").and_then(|v| v.as_str()),
Some(TENANT_ID)
);
assert_eq!(
body_json.get("extensionId").and_then(|v| v.as_str()),
Some(EXTENSION_ID)
);
assert_eq!(
body_json.get("method").and_then(|v| v.as_str()),
Some("POST")
);
assert_eq!(
body_json.get("path").and_then(|v| v.as_str()),
Some("/dynamic/echo?foo=bar")
);
let expected_echo = raw_payload.as_slice();
let actual_echo: Vec<u8> = body_json
.get("echo")
.and_then(|v| v.as_array())
.expect("echo array present")
.iter()
.map(|v| v.as_u64().unwrap_or_default() as u8)
.collect();
assert_eq!(actual_echo, expected_echo, "echo payload mismatch");
log("verify_dynamic_execution succeeded");
Ok(())
}
async fn dump_container_logs(container_name: &str) -> anyhow::Result<()> {
log_always(&format!("fetching logs for container {container_name}"));
let output = Command::new("docker")
.arg("logs")
.arg(container_name)
.output()
.await?;
log_always(render_bytes("docker logs stdout", &output.stdout, 4000));
if !output.stderr.is_empty() {
log_always(render_bytes("docker logs stderr", &output.stderr, 1000));
}
Ok(())
}
async fn dump_docker_state() -> anyhow::Result<()> {
let ps = Command::new("docker").arg("ps").arg("-a").output().await?;
log_always(render_bytes("docker ps -a", &ps.stdout, 1000));
if !ps.stderr.is_empty() {
log_always(render_bytes("docker ps -a stderr", &ps.stderr, 500));
}
Ok(())
}
fn write_bundle_artifact(root: &Path) -> anyhow::Result<(String, String)> {
log(&format!(
"write_bundle_artifact into root {}",
root.display()
));
let (bundle, hash, index_body) = make_bundle_tarzst();
let target = root.join(format!(
"tenants/{}/extensions/{}/sha256/{}/bundle.tar.zst",
TENANT_ID, EXTENSION_ID, hash
));
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, &bundle)?;
log(&format!(
"bundle artifact written to {} ({} bytes)",
target.display(),
bundle.len()
));
Ok((hash, index_body))
}
fn write_dynamic_component_artifact(root: &Path) -> anyhow::Result<String> {
use sha2::{Digest, Sha256};
log(&format!(
"write_dynamic_component_artifact into {}",
root.display()
));
let mut raw_tar: Vec<u8> = Vec::new();
{
let mut tar = Builder::new(&mut raw_tar);
let mut hdr = tar::Header::new_gnu();
hdr.set_size(DYNAMIC_COMPONENT_WASM.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "dist/main.wasm", DYNAMIC_COMPONENT_WASM)?;
tar.finish()?;
}
let tarzst = zstd_encode_all(&raw_tar[..], 0)?;
let mut hasher = Sha256::new();
hasher.update(&tarzst);
let hex = hex::encode(hasher.finalize());
let target = root.join(format!(
"tenants/{}/extensions/{}/sha256/{}/bundle.tar.zst",
TENANT_ID, EXTENSION_ID, hex
));
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, &tarzst)?;
log(&format!(
"dynamic component bundle written to {} ({} bytes, hash sha256:{hex})",
target.display(),
tarzst.len()
));
Ok(hex)
}
fn make_bundle_tarzst() -> (Vec<u8>, String, String) {
log("make_bundle_tarzst generating bundle tarball");
let mut raw_tar: Vec<u8> = Vec::new();
let index_body =
"<!doctype html><html><head><meta charset=\"utf-8\" /></head><body>Hello Container UI</body></html>";
{
let mut tar = Builder::new(&mut raw_tar);
let mut hdr = tar::Header::new_gnu();
hdr.set_size(index_body.as_bytes().len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "ui/index.html", &index_body.as_bytes()[..])
.unwrap();
let mut hdr2 = tar::Header::new_gnu();
let js = b"console.log('hello from container');";
hdr2.set_size(js.len() as u64);
hdr2.set_mode(0o644);
hdr2.set_cksum();
tar.append_data(&mut hdr2, "ui/assets/app.js", &js[..])
.unwrap();
tar.finish().unwrap();
}
let tarzst = zstd_encode_all(&raw_tar[..], 0).unwrap();
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&tarzst);
let hex = hex::encode(hasher.finalize());
log(&format!(
"make_bundle_tarzst produced raw tar {} bytes, compressed {} bytes, hash {hex}",
raw_tar.len(),
tarzst.len()
));
(tarzst, hex, index_body.to_string())
}
async fn start_bundle_server(root: PathBuf) -> anyhow::Result<(JoinHandle<()>, u16)> {
log(&format!(
"start_bundle_server binding with root {}",
root.display()
));
let service = ServeDir::new(root);
let app = Router::new().nest_service("/", service);
// Bind to 0.0.0.0 so that the dockerized runner can reach the bundle server
// via host.docker.internal. Binding to 127.0.0.1 would confine the listener
// to loopback only, causing connection refusals from the container.
let listener = TcpListener::bind(("0.0.0.0", 0)).await?;
let port = listener.local_addr()?.port();
let handle = tokio::spawn(async move {
if let Err(err) = axum::serve(listener, app).await {
eprintln!("bundle server error: {err}");
}
});
log(&format!("start_bundle_server spawned on port {port}"));
Ok((handle, port))
}
fn ensure_success(output: &std::process::Output, context: &str) -> anyhow::Result<()> {
if output.status.success() {
log(&format!(
"{context} succeeded with status {}",
output.status
));
Ok(())
} else {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
log(&format!(
"{context} failed (status {}), stdout len {}, stderr len {}",
output.status,
stdout.len(),
stderr.len()
));
anyhow::bail!("{context} failed\nstdout:\n{}\nstderr:\n{}", stdout, stderr);
}
}
fn allocate_port() -> anyhow::Result<u16> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
log(&format!("allocate_port reserved port {port}"));
Ok(port)
}
struct ContainerGuard {
name: String,
}
impl ContainerGuard {
fn new(name: &str) -> Self {
log(&format!("ContainerGuard created for {name}"));
Self {
name: name.to_string(),
}
}
async fn stop(&self) {
log(&format!("ContainerGuard stopping {}", self.name));
let _ = Command::new("docker")
.arg("stop")
.arg(&self.name)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
}
impl Drop for ContainerGuard {
fn drop(&mut self) {
log(&format!(
"ContainerGuard dropping; removing container {}",
self.name
));
let _ = std::process::Command::new("docker")
.arg("rm")
.arg("-f")
.arg(&self.name)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
struct ImageGuard {
tag: String,
}
impl ImageGuard {
fn new(tag: &str) -> Self {
log(&format!("ImageGuard created for {tag}"));
Self {
tag: tag.to_string(),
}
}
}
impl Drop for ImageGuard {
fn drop(&mut self) {
log(&format!("ImageGuard dropping; removing image {}", self.tag));
let _ = std::process::Command::new("docker")
.arg("rmi")
.arg("-f")
.arg(&self.tag)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}