Post-1.0: OTLP tracing, broker image + compose service, install rehearsal

- tc-telemetry: fmt subscriber always; with [telemetry] otlp_endpoint
  set, spans batch-export over OTLP/HTTP. Tested against a REAL OTLP
  receiver decoding the actual protobuf (official proto types): the
  emitted span and service.name arrive on the wire. No endpoint = no
  export = no network (air-gap stance). tower-http TraceLayer gives
  every API request a span
- The broker finally has its own image (images/broker.Dockerfile,
  9.5MB from scratch) — the Helm chart referenced one that never
  existed — and the compose deployment now RUNS the broker, sharing a
  socket volume with the server (the unix-socket equivalent of the K8s
  sidecar). Compose secret flows were silently dead before this
- server.Dockerfile fixes surfaced by the rehearsal: the workspace
  build needs tools/ (bundler joined the workspace) and
  images/seccomp/ (include_str! profile) in the build context
- scripts/rehearse-install.sh (plan: clean-VM rehearsal): assembles a
  REAL signed bundle from the built images (server/frontend/broker/
  postgres/socket-proxy), runs the customer path — offline verify,
  docker load, compose up — and asserts /healthz plus the served login
  page before teardown. Passing locally; wired as a release.yml step,
  which also builds/ships the broker + socket-proxy images now

161 Rust tests + 29 journeys; clean-room rehearsal green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 11:35:32 -05:00
co-authored by Claude Fable 5
parent 05e9612688
commit 8046853feb
17 changed files with 569 additions and 7 deletions
+12 -1
View File
@@ -24,6 +24,7 @@ jobs:
run: | run: |
docker build -t "teamclaw/server:$VERSION" -f images/server.Dockerfile . docker build -t "teamclaw/server:$VERSION" -f images/server.Dockerfile .
docker build -t "teamclaw/frontend:$VERSION" -f images/frontend.Dockerfile . docker build -t "teamclaw/frontend:$VERSION" -f images/frontend.Dockerfile .
docker build -t "teamclaw/broker:$VERSION" -f images/broker.Dockerfile .
docker build -t "teamclaw/agent-base:$VERSION" images/agent-base docker build -t "teamclaw/agent-base:$VERSION" images/agent-base
docker build -t "teamclaw/agent-browser:$VERSION" images/agent-browser docker build -t "teamclaw/agent-browser:$VERSION" images/agent-browser
docker pull postgres:16-alpine docker pull postgres:16-alpine
@@ -33,7 +34,7 @@ jobs:
mkdir -p dist/sboms mkdir -p dist/sboms
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \ curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin | sh -s -- -b /usr/local/bin
for image in server frontend agent-base agent-browser; do for image in server frontend broker agent-base agent-browser; do
syft "teamclaw/$image:$VERSION" -o spdx-json \ syft "teamclaw/$image:$VERSION" -o spdx-json \
> "dist/sboms/$image.spdx.json" > "dist/sboms/$image.spdx.json"
done done
@@ -43,6 +44,9 @@ jobs:
mkdir -p dist/images mkdir -p dist/images
docker save "teamclaw/server:$VERSION" -o dist/images/server.tar docker save "teamclaw/server:$VERSION" -o dist/images/server.tar
docker save "teamclaw/frontend:$VERSION" -o dist/images/frontend.tar docker save "teamclaw/frontend:$VERSION" -o dist/images/frontend.tar
docker save "teamclaw/broker:$VERSION" -o dist/images/broker.tar
docker pull tecnativa/docker-socket-proxy:0.3
docker save tecnativa/docker-socket-proxy:0.3 -o dist/images/socket-proxy.tar
docker save "teamclaw/agent-base:$VERSION" -o dist/images/agent-base.tar docker save "teamclaw/agent-base:$VERSION" -o dist/images/agent-base.tar
docker save "teamclaw/agent-browser:$VERSION" -o dist/images/agent-browser.tar docker save "teamclaw/agent-browser:$VERSION" -o dist/images/agent-browser.tar
docker save postgres:16-alpine -o dist/images/postgres.tar docker save postgres:16-alpine -o dist/images/postgres.tar
@@ -98,6 +102,13 @@ jobs:
- name: Tarball - name: Tarball
run: tar -C dist -czf "teamclaw-bundle-$VERSION.tgz" bundle run: tar -C dist -czf "teamclaw-bundle-$VERSION.tgz" bundle
- name: Clean-room install rehearsal
run: |
docker tag "teamclaw/server:$VERSION" teamclaw/server:latest
docker tag "teamclaw/frontend:$VERSION" teamclaw/frontend:latest
docker tag "teamclaw/broker:$VERSION" teamclaw/broker:latest
./scripts/rehearse-install.sh
- name: Attach to release - name: Attach to release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
Generated
+214 -6
View File
@@ -1710,6 +1710,15 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@@ -1765,6 +1774,15 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "num" name = "num"
version = "0.4.3" version = "0.4.3"
@@ -1883,7 +1901,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml",
"rand 0.9.4", "rand 0.9.4",
"reqwest", "reqwest 0.12.28",
"ring", "ring",
"serde", "serde",
"serde_json", "serde_json",
@@ -1921,6 +1939,76 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "opentelemetry"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror",
"tracing",
]
[[package]]
name = "opentelemetry-http"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331"
dependencies = [
"async-trait",
"bytes",
"http",
"opentelemetry",
"reqwest 0.13.4",
]
[[package]]
name = "opentelemetry-otlp"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35"
dependencies = [
"http",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost",
"reqwest 0.13.4",
"thiserror",
]
[[package]]
name = "opentelemetry-proto"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"prost",
]
[[package]]
name = "opentelemetry_sdk"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"portable-atomic",
"rand 0.9.4",
"thiserror",
]
[[package]] [[package]]
name = "ordered-float" name = "ordered-float"
version = "2.10.1" version = "2.10.1"
@@ -2539,6 +2627,37 @@ dependencies = [
"webpki-roots 1.0.7", "webpki-roots 1.0.7",
] ]
[[package]]
name = "reqwest"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -2957,6 +3076,15 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -3347,7 +3475,7 @@ dependencies = [
"hmac", "hmac",
"jsonwebtoken", "jsonwebtoken",
"rand_core 0.6.4", "rand_core 0.6.4",
"reqwest", "reqwest 0.12.28",
"rsa", "rsa",
"serde", "serde",
"serde_json", "serde_json",
@@ -3367,6 +3495,7 @@ dependencies = [
"thiserror", "thiserror",
"time", "time",
"tokio", "tokio",
"tower-http",
"urlencoding", "urlencoding",
"uuid", "uuid",
] ]
@@ -3380,7 +3509,7 @@ dependencies = [
"base64", "base64",
"jsonwebtoken", "jsonwebtoken",
"rand_core 0.6.4", "rand_core 0.6.4",
"reqwest", "reqwest 0.12.28",
"rsa", "rsa",
"serde", "serde",
"serde_json", "serde_json",
@@ -3464,7 +3593,7 @@ dependencies = [
"async-trait", "async-trait",
"eventsource-stream", "eventsource-stream",
"futures", "futures",
"reqwest", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
@@ -3525,7 +3654,7 @@ dependencies = [
"futures", "futures",
"k8s-openapi", "k8s-openapi",
"kube", "kube",
"reqwest", "reqwest 0.12.28",
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
@@ -3558,7 +3687,7 @@ dependencies = [
"chacha20poly1305", "chacha20poly1305",
"hex", "hex",
"hmac", "hmac",
"reqwest", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -3573,6 +3702,23 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "tc-telemetry"
version = "0.1.0"
dependencies = [
"axum",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost",
"thiserror",
"tokio",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
]
[[package]] [[package]]
name = "tc-testkit" name = "tc-testkit"
version = "0.1.0" version = "0.1.0"
@@ -3635,6 +3781,7 @@ dependencies = [
"tc-runtime", "tc-runtime",
"tc-sandbox", "tc-sandbox",
"tc-scheduler", "tc-scheduler",
"tc-telemetry",
"time", "time",
"tokio", "tokio",
] ]
@@ -3712,6 +3859,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.47"
@@ -4004,6 +4160,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-opentelemetry"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
dependencies = [
"js-sys",
"opentelemetry",
"smallvec",
"tracing",
"tracing-core",
"tracing-log",
"tracing-subscriber",
"web-time",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -4197,6 +4399,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
+1
View File
@@ -13,6 +13,7 @@ members = [
"crates/tc-files", "crates/tc-files",
"crates/tc-scheduler", "crates/tc-scheduler",
"crates/tc-billing", "crates/tc-billing",
"crates/tc-telemetry",
"crates/tc-testkit", "crates/tc-testkit",
"crates/tc-auth", "crates/tc-auth",
"crates/tc-api", "crates/tc-api",
+1
View File
@@ -17,6 +17,7 @@ tc-db = { path = "../../tc-db" }
tc-files = { path = "../../tc-files" } tc-files = { path = "../../tc-files" }
tc-llm = { path = "../../tc-llm" } tc-llm = { path = "../../tc-llm" }
tc-runtime = { path = "../../tc-runtime" } tc-runtime = { path = "../../tc-runtime" }
tc-telemetry = { path = "../../tc-telemetry" }
tc-sandbox = { path = "../../tc-sandbox" } tc-sandbox = { path = "../../tc-sandbox" }
tc-scheduler = { path = "../../tc-scheduler" } tc-scheduler = { path = "../../tc-scheduler" }
tc-domain = { path = "../../tc-domain" } tc-domain = { path = "../../tc-domain" }
+3
View File
@@ -55,6 +55,9 @@ async fn run() -> Result<(), String> {
let config_path = let config_path =
PathBuf::from(std::env::var("TEAMCLAW_CONFIG").unwrap_or_else(|_| "teamclaw.toml".into())); PathBuf::from(std::env::var("TEAMCLAW_CONFIG").unwrap_or_else(|_| "teamclaw.toml".into()));
let config = AppConfig::load_from(&config_path).map_err(|e| e.to_string())?; let config = AppConfig::load_from(&config_path).map_err(|e| e.to_string())?;
let _telemetry =
tc_telemetry::init("teamclaw-server", config.telemetry.otlp_endpoint.as_deref())
.map_err(|e| format!("telemetry: {e}"))?;
let pool = tc_db::connect(&config.database.url, config.database.max_connections) let pool = tc_db::connect(&config.database.url, config.database.max_connections)
.await .await
+1
View File
@@ -24,6 +24,7 @@ tc-safety = { path = "../tc-safety" }
tc-scheduler = { path = "../tc-scheduler" } tc-scheduler = { path = "../tc-scheduler" }
tc-secrets = { path = "../tc-secrets" } tc-secrets = { path = "../tc-secrets" }
thiserror = { workspace = true } thiserror = { workspace = true }
tower-http = { version = "0.6", features = ["trace"] }
time = { workspace = true } time = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
urlencoding = "2" urlencoding = "2"
+1
View File
@@ -123,5 +123,6 @@ pub fn router(state: AppState) -> Router {
.route("/api/team/usage", get(routes::billing::usage)) .route("/api/team/usage", get(routes::billing::usage))
.route("/api/credits/redeem", post(routes::billing::redeem)) .route("/api/credits/redeem", post(routes::billing::redeem))
.route("/api/team/permissions", get(routes::team::permissions)) .route("/api/team/permissions", get(routes::team::permissions))
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state) .with_state(state)
} }
+9
View File
@@ -159,6 +159,13 @@ impl Default for SandboxConfig {
} }
} }
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TelemetryConfig {
/// OTLP/HTTP collector base (e.g. http://otel-collector:4318).
/// Unset = no export; logs only. Nothing ever phones home uninvited.
pub otlp_endpoint: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
pub struct OAuthConfig { pub struct OAuthConfig {
/// Default identity provider for directory-app OAuth connects. /// Default identity provider for directory-app OAuth connects.
@@ -186,6 +193,8 @@ pub struct AppConfig {
pub oauth: OAuthConfig, pub oauth: OAuthConfig,
#[serde(default)] #[serde(default)]
pub sandbox: SandboxConfig, pub sandbox: SandboxConfig,
#[serde(default)]
pub telemetry: TelemetryConfig,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "tc-telemetry"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
publish.workspace = true
license.workspace = true
[dependencies]
opentelemetry = "0.32"
opentelemetry-otlp = { version = "0.32", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] }
opentelemetry_sdk = "0.32"
thiserror = { workspace = true }
tracing = "0.1"
tracing-opentelemetry = "0.33"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
axum = "0.8"
opentelemetry-proto = { version = "0.32", default-features = false, features = ["trace", "gen-tonic-messages"] }
prost = "0.14"
tokio = { workspace = true }
[lints]
workspace = true
+92
View File
@@ -0,0 +1,92 @@
//! Tracing setup (§16 NFR: observability). Always installs a fmt
//! subscriber honoring `RUST_LOG`; with an OTLP endpoint configured,
//! spans also export over OTLP/HTTP — to a collector shipped in the
//! air-gapped bundle or any cloud backend. No endpoint, no export, no
//! network: telemetry never phones home uninvited.
use opentelemetry::trace::TracerProvider as _;
use opentelemetry::KeyValue;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::trace::SdkTracerProvider;
use opentelemetry_sdk::Resource;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};
/// Keeps the exporter alive; dropping it flushes pending spans.
pub struct TelemetryGuard {
provider: Option<SdkTracerProvider>,
}
impl TelemetryGuard {
/// Forces all batched spans out — call before process exit (tests
/// call it to assert delivery).
pub fn flush(&self) {
if let Some(provider) = &self.provider {
let _ = provider.force_flush();
}
}
}
impl Drop for TelemetryGuard {
fn drop(&mut self) {
if let Some(provider) = self.provider.take() {
let _ = provider.force_flush();
let _ = provider.shutdown();
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum TelemetryError {
#[error("otlp exporter: {0}")]
Exporter(String),
#[error("subscriber already installed")]
AlreadyInstalled,
}
/// Installs the global subscriber. `otlp_endpoint` is the collector's
/// OTLP/HTTP base (e.g. `http://otel-collector:4318`).
pub fn init(
service_name: &str,
otlp_endpoint: Option<&str>,
) -> Result<TelemetryGuard, TelemetryError> {
let fmt = tracing_subscriber::fmt::layer().with_target(false);
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn"));
match otlp_endpoint {
Some(endpoint) => {
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_endpoint(format!("{}/v1/traces", endpoint.trim_end_matches('/')))
.build()
.map_err(|e| TelemetryError::Exporter(e.to_string()))?;
let provider = SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(
Resource::builder()
.with_attributes([KeyValue::new("service.name", service_name.to_owned())])
.build(),
)
.build();
let tracer = provider.tracer(service_name.to_owned());
let otel = tracing_opentelemetry::layer().with_tracer(tracer);
tracing_subscriber::registry()
.with(fmt.with_filter(filter))
.with(otel)
.try_init()
.map_err(|_| TelemetryError::AlreadyInstalled)?;
Ok(TelemetryGuard {
provider: Some(provider),
})
}
None => {
tracing_subscriber::registry()
.with(fmt.with_filter(filter))
.try_init()
.map_err(|_| TelemetryError::AlreadyInstalled)?;
Ok(TelemetryGuard { provider: None })
}
}
}
+81
View File
@@ -0,0 +1,81 @@
//! Spans reach a REAL OTLP/HTTP receiver: a live server accepting
//! protobuf at /v1/traces, decoded with the official proto types — the
//! exact wire contract any collector speaks.
use std::sync::Arc;
use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
use prost::Message;
use tokio::sync::Mutex;
type Received = Arc<Mutex<Vec<ExportTraceServiceRequest>>>;
#[tokio::test(flavor = "multi_thread")]
async fn spans_export_to_a_real_otlp_receiver() {
let received: Received = Arc::new(Mutex::new(Vec::new()));
let state = received.clone();
let app = axum::Router::new()
.route(
"/v1/traces",
axum::routing::post(
|axum::extract::State(state): axum::extract::State<Received>,
body: axum::body::Bytes| async move {
let request =
ExportTraceServiceRequest::decode(body.as_ref()).expect("otlp protobuf");
state.lock().await.push(request);
// An empty ExportTraceServiceResponse.
(
[("content-type", "application/x-protobuf")],
Vec::<u8>::new(),
)
},
),
)
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let guard = tc_telemetry::init("teamclaw-test", Some(&endpoint)).expect("init");
{
let span = tracing::info_span!("approval_decision", approval_id = "ap_123");
let _entered = span.enter();
tracing::info!("decided");
}
guard.flush();
// Find our span among everything exported.
let requests = received.lock().await;
assert!(!requests.is_empty(), "no OTLP export arrived");
let mut span_names = Vec::new();
let mut service_names = Vec::new();
for request in requests.iter() {
for resource in &request.resource_spans {
if let Some(res) = &resource.resource {
for attr in &res.attributes {
if attr.key == "service.name" {
if let Some(value) = &attr.value {
service_names.push(format!("{value:?}"));
}
}
}
}
for scope in &resource.scope_spans {
for span in &scope.spans {
span_names.push(span.name.clone());
}
}
}
}
assert!(
span_names.iter().any(|name| name == "approval_decision"),
"spans: {span_names:?}"
);
assert!(
service_names.iter().any(|s| s.contains("teamclaw-test")),
"services: {service_names:?}"
);
}
+25
View File
@@ -24,6 +24,10 @@ networks:
volumes: volumes:
pgdata: {} pgdata: {}
# Unix socket + master key shared ONLY between server and broker —
# the unix-socket equivalent of the K8s sidecar topology.
broker_run: {}
broker_key: {}
services: services:
postgres: postgres:
@@ -41,6 +45,26 @@ services:
timeout: 3s timeout: 3s
retries: 12 retries: 12
# The secret broker: separate process, separate image; credentials
# never leave it. Reachable only via the shared unix socket volume.
broker:
image: teamclaw/broker:${TEAMCLAW_VERSION:-latest}
build:
context: ../..
dockerfile: images/broker.Dockerfile
restart: unless-stopped
environment:
TEAMCLAW_BROKER_SOCKET: /run/teamclaw/broker.sock
TEAMCLAW_BROKER_KEY_FILE: /etc/teamclaw-broker/broker.key
TEAMCLAW_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/teamclaw
volumes:
- broker_run:/run/teamclaw
- broker_key:/etc/teamclaw-broker
networks: [core]
depends_on:
postgres:
condition: service_healthy
# Allow-listed Docker API (§15 blast-radius cap): the server can # Allow-listed Docker API (§15 blast-radius cap): the server can
# create/exec/stop/remove sandbox containers and NOTHING else — no # create/exec/stop/remove sandbox containers and NOTHING else — no
# image builds, no networks, no secrets, no volumes. Proven by # image builds, no networks, no secrets, no volumes. Proven by
@@ -70,6 +94,7 @@ services:
DOCKER_HOST: tcp://socket-proxy:2375 DOCKER_HOST: tcp://socket-proxy:2375
volumes: volumes:
- ./teamclaw.toml:/etc/teamclaw/teamclaw.toml:ro - ./teamclaw.toml:/etc/teamclaw/teamclaw.toml:ro
- broker_run:/run/teamclaw
networks: [edge, core, engine_net] networks: [edge, core, engine_net]
ports: ports:
- "8080:8080" - "8080:8080"
+3
View File
@@ -16,3 +16,6 @@ model = "qwen2.5-72b-instruct"
[auth] [auth]
mode = "local" mode = "local"
[broker]
socket_path = "/run/teamclaw/broker.sock"
+26
View File
@@ -0,0 +1,26 @@
# The secret broker: its own minimal image, its own process boundary.
# Shares the builder pattern with the server image; ships ONLY the broker
# binary.
FROM rust:1-bookworm AS builder
RUN case "$(uname -m)" in \
aarch64) echo aarch64-unknown-linux-musl > /rust-target ;; \
*) echo x86_64-unknown-linux-musl > /rust-target ;; \
esac \
&& rustup target add "$(cat /rust-target)" \
&& apt-get update && apt-get install -y --no-install-recommends musl-tools \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY Cargo.toml rust-toolchain.toml ./
COPY crates ./crates
COPY tools ./tools
COPY images/seccomp ./images/seccomp
COPY migrations ./migrations
COPY .sqlx ./.sqlx
ENV SQLX_OFFLINE=true
RUN cargo build --release --target "$(cat /rust-target)" -p teamclaw-broker \
&& cp "target/$(cat /rust-target)/release/teamclaw-broker" /teamclaw-broker
FROM scratch
COPY --from=builder /teamclaw-broker /teamclaw-broker
USER 10001:10001
ENTRYPOINT ["/teamclaw-broker"]
+2
View File
@@ -13,6 +13,8 @@ RUN case "$TARGETARCH" in \
WORKDIR /src WORKDIR /src
COPY Cargo.toml rust-toolchain.toml ./ COPY Cargo.toml rust-toolchain.toml ./
COPY crates ./crates COPY crates ./crates
COPY tools ./tools
COPY images/seccomp ./images/seccomp
COPY migrations ./migrations COPY migrations ./migrations
COPY .sqlx ./.sqlx COPY .sqlx ./.sqlx
ENV SQLX_OFFLINE=true ENV SQLX_OFFLINE=true
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Clean-room install rehearsal (plan: per-release clean-VM install.sh
# rehearsal): assemble a REAL signed bundle from the built images, run
# the customer's install path end to end — offline verify, docker load,
# compose up — and assert the platform answers before tearing down.
#
# Requires: teamclaw/server:latest + teamclaw/frontend:latest built
# (POSTGRES_PASSWORD=x docker compose -f deploy/compose/docker-compose.yml build).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d)"
export TEAMCLAW_HOME="$WORK/opt"
cleanup() {
docker compose --project-directory "$TEAMCLAW_HOME" down -v >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT
echo "==> Building bundler + signing key"
cargo build -q -p teamclaw-bundler
BUNDLER="$ROOT/target/debug/teamclaw-bundler"
"$BUNDLER" keygen "$WORK/release.key" "$WORK/release.pub"
echo "==> Saving runtime images"
mkdir -p "$WORK/images"
docker pull -q postgres:16-alpine >/dev/null
docker pull -q tecnativa/docker-socket-proxy:0.3 >/dev/null
docker save teamclaw/server:latest -o "$WORK/images/server.tar"
docker save teamclaw/frontend:latest -o "$WORK/images/frontend.tar"
docker save teamclaw/broker:latest -o "$WORK/images/broker.tar"
docker save postgres:16-alpine -o "$WORK/images/postgres.tar"
docker save tecnativa/docker-socket-proxy:0.3 -o "$WORK/images/socket-proxy.tar"
echo "==> Assembling the signed bundle"
ARTIFACTS=""
for tar in "$WORK"/images/*.tar; do
ARTIFACTS="$ARTIFACTS $tar=images/$(basename "$tar")"
done
# shellcheck disable=SC2086
"$BUNDLER" assemble "$WORK/bundle" "rehearsal" "$WORK/release.key" \
"$ROOT/deploy/compose/docker-compose.yml=compose/docker-compose.yml" \
"$ROOT/deploy/compose/teamclaw.toml=compose/teamclaw.toml" \
"$ROOT/deploy/compose/.env.example=compose/.env.example" \
"$ROOT/deploy/airgapped/install.sh=install.sh" \
"$BUNDLER=bin/teamclaw-bundler" \
$ARTIFACTS
chmod +x "$WORK/bundle/bin/teamclaw-bundler" "$WORK/bundle/install.sh"
echo "==> Customer install: verify -> load -> stage"
"$WORK/bundle/install.sh" "$WORK/bundle" "$WORK/release.pub"
echo "==> First boot"
echo "POSTGRES_PASSWORD=rehearse-$$" > "$TEAMCLAW_HOME/.env"
docker compose --project-directory "$TEAMCLAW_HOME" up -d --no-build
echo "==> Waiting for the platform"
for _ in $(seq 1 60); do
if curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1; then
echo "==> Server is healthy"
if curl -fsS http://127.0.0.1:3000/login | grep -q teamclaw; then
echo "==> Frontend serves the login page"
echo "REHEARSAL OK"
exit 0
fi
fi
sleep 2
done
echo "REHEARSAL FAILED: platform never became healthy"
docker compose --project-directory "$TEAMCLAW_HOME" logs --tail 30
exit 1