P6: S3 blob store, Helm chart, air-gapped installer verify loop

- S3BlobStore (object_store, path-style) behind the same BlobStore trait,
  tested against a REAL MinIO container (round trip, overwrite, NotFound
  on get and delete, nested keys); [storage] backend=local|s3 config with
  validation + server-side selection (S3 creds via env overlay)
- Helm chart: server pod with the secret broker as a SIDECAR sharing a
  private emptyDir unix socket (no network hop carries credentials),
  frontend, optional local PVC vs S3, OIDC/oauth values, unbuffered-SSE
  ingress annotations, NetworkPolicies (frontend->server only), hardened
  securityContexts; ci/check-helm.sh lints AND asserts the rendered
  topology properties
- deploy/airgapped/install.sh: offline signature+checksum verification via
  the bundled teamclaw-bundler BEFORE any docker load; --verify-only mode;
  ci/test-install.sh rehearses clean/tampered/wrong-key paths with the
  real binary
- CI: helm gate + installer rehearsal wired in

149 Rust tests; helm lint + rendered assertions green; installer
verify-path rehearsal green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 08:19:43 -05:00
co-authored by Claude Fable 5
parent ccf96053e6
commit 70ec39f696
20 changed files with 851 additions and 3 deletions
+4
View File
@@ -16,6 +16,8 @@ jobs:
run: ./ci/check-no-placeholders.sh run: ./ci/check-no-placeholders.sh
- name: Compose config validates - name: Compose config validates
run: POSTGRES_PASSWORD=ci docker compose -f deploy/compose/docker-compose.yml config -q run: POSTGRES_PASSWORD=ci docker compose -f deploy/compose/docker-compose.yml config -q
- name: Helm chart lints and renders the safety topology
run: ./ci/check-helm.sh
rust: rust:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -33,6 +35,8 @@ jobs:
run: cargo clippy --workspace --all-targets -- -D warnings run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test - name: Test
run: cargo test --workspace run: cargo test --workspace
- name: Air-gapped installer verify path
run: ./ci/test-install.sh
frontend: frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
Generated
+85
View File
@@ -1120,6 +1120,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.10.1" version = "1.10.1"
@@ -1167,6 +1173,7 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"rustls", "rustls",
"rustls-native-certs",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
@@ -1666,6 +1673,42 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "object_store"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00"
dependencies = [
"async-trait",
"base64",
"bytes",
"chrono",
"form_urlencoded",
"futures",
"http",
"http-body-util",
"humantime",
"hyper",
"itertools",
"md-5",
"parking_lot",
"percent-encoding",
"quick-xml",
"rand 0.9.4",
"reqwest",
"ring",
"serde",
"serde_json",
"serde_urlencoded",
"thiserror",
"tokio",
"tracing",
"url",
"walkdir",
"wasm-bindgen-futures",
"web-time",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -1976,6 +2019,16 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -2197,6 +2250,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -2209,6 +2263,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
"rustls", "rustls",
"rustls-native-certs",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
@@ -2371,6 +2426,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.29" version = "0.1.29"
@@ -3049,6 +3113,8 @@ name = "tc-files"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"object_store",
"testcontainers-modules",
"thiserror", "thiserror",
"tokio", "tokio",
"uuid", "uuid",
@@ -3759,6 +3825,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -3964,6 +4040,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Lints the chart and asserts the load-bearing rendered properties: the
# broker rides as a server sidecar sharing the socket volume, SSE is
# unbuffered at the ingress, and pods run hardened.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CHART="$ROOT/deploy/helm/teamclaw"
helm lint "$CHART" \
--set auth.issuerUrl=https://idp.example.com \
--set oauth.redirectBase=https://teamclaw.example.com
RENDERED=$(helm template teamclaw "$CHART" \
--set auth.issuerUrl=https://idp.example.com \
--set oauth.redirectBase=https://teamclaw.example.com)
require() {
if ! grep -qF -- "$1" <<<"$RENDERED"; then
echo "FAIL: rendered chart is missing: $1"
exit 1
fi
}
# Broker sidecar shares the unix-socket emptyDir with the server.
require 'name: broker'
require 'value: /run/teamclaw/broker.sock'
require 'mountPath: /run/teamclaw'
# SSE must not buffer at the ingress.
require 'nginx.ingress.kubernetes.io/proxy-buffering: "off"'
# Hardened pods.
require 'runAsNonRoot: true'
require 'drop: ["ALL"]'
require 'readOnlyRootFilesystem: true'
# Config wired through the ConfigMap.
require 'socket_path = "/run/teamclaw/broker.sock"'
echo "helm chart OK"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Rehearses the air-gapped delivery loop without docker: assemble a signed
# bundle with the real bundler, run install.sh --verify-only against it,
# then tamper and prove the installer refuses before touching anything.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
cargo build -q -p teamclaw-bundler
BUNDLER="$ROOT/target/debug/teamclaw-bundler"
"$BUNDLER" keygen "$WORK/release.key" "$WORK/release.pub"
# Fixture artifacts standing in for image tarballs and compose files.
echo "pretend image bytes" > "$WORK/server.tar"
mkdir -p "$WORK/compose-src"
cp "$ROOT/deploy/compose/docker-compose.yml" "$WORK/compose-src/"
cp "$ROOT/deploy/compose/teamclaw.toml" "$WORK/compose-src/"
cp "$ROOT/deploy/compose/.env.example" "$WORK/compose-src/"
"$BUNDLER" assemble "$WORK/bundle" "0.0.0-test" "$WORK/release.key" \
"$WORK/server.tar=images/server.tar" \
"$WORK/compose-src/docker-compose.yml=compose/docker-compose.yml" \
"$WORK/compose-src/teamclaw.toml=compose/teamclaw.toml" \
"$WORK/compose-src/.env.example=compose/.env.example" \
"$BUNDLER=bin/teamclaw-bundler"
chmod +x "$WORK/bundle/bin/teamclaw-bundler"
# 1. A clean bundle verifies.
"$ROOT/deploy/airgapped/install.sh" "$WORK/bundle" "$WORK/release.pub" --verify-only
# 2. A tampered image is refused.
echo "EVIL" >> "$WORK/bundle/images/server.tar"
if "$ROOT/deploy/airgapped/install.sh" "$WORK/bundle" "$WORK/release.pub" --verify-only 2>/dev/null; then
echo "FAIL: tampered bundle was accepted"
exit 1
fi
# 3. The wrong public key is refused.
"$BUNDLER" keygen "$WORK/other.key" "$WORK/other.pub"
if "$ROOT/deploy/airgapped/install.sh" "$WORK/bundle" "$WORK/other.pub" --verify-only 2>/dev/null; then
echo "FAIL: wrong key was accepted"
exit 1
fi
echo "install.sh verify path OK"
+14 -3
View File
@@ -69,9 +69,20 @@ async fn run() -> Result<(), String> {
} }
let provider = build_provider(&config)?; let provider = build_provider(&config)?;
let blob = std::sync::Arc::new(tc_files::LocalBlobStore::new(PathBuf::from( let blob: std::sync::Arc<dyn tc_files::BlobStore> = match config.storage.backend {
&config.storage.data_dir, tc_config::StorageBackend::Local => std::sync::Arc::new(tc_files::LocalBlobStore::new(
))); PathBuf::from(&config.storage.data_dir),
)),
tc_config::StorageBackend::S3 => std::sync::Arc::new(
tc_files::S3BlobStore::connect(
config.storage.s3_endpoint.as_deref().expect("validated"),
config.storage.s3_bucket.as_deref().expect("validated"),
config.storage.s3_access_key.as_deref().unwrap_or_default(),
config.storage.s3_secret_key.as_deref().unwrap_or_default(),
)
.map_err(|e| format!("s3 storage: {e}"))?,
),
};
let runtime = Runtime::with_blob_store( let runtime = Runtime::with_blob_store(
pool.clone(), pool.clone(),
provider, provider,
+31
View File
@@ -67,16 +67,39 @@ pub struct AuthConfig {
pub client_id: Option<String>, pub client_id: Option<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StorageBackend {
Local,
S3,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct StorageConfig { pub struct StorageConfig {
/// Root directory for file-drive blobs (volume-mounted in compose). /// Root directory for file-drive blobs (volume-mounted in compose).
pub data_dir: String, pub data_dir: String,
#[serde(default = "default_backend")]
pub backend: StorageBackend,
/// S3 backend settings; keys arrive via TEAMCLAW_STORAGE__* env vars.
pub s3_endpoint: Option<String>,
pub s3_bucket: Option<String>,
pub s3_access_key: Option<String>,
pub s3_secret_key: Option<String>,
}
fn default_backend() -> StorageBackend {
StorageBackend::Local
} }
impl Default for StorageConfig { impl Default for StorageConfig {
fn default() -> Self { fn default() -> Self {
StorageConfig { StorageConfig {
data_dir: "./data".into(), data_dir: "./data".into(),
backend: StorageBackend::Local,
s3_endpoint: None,
s3_bucket: None,
s3_access_key: None,
s3_secret_key: None,
} }
} }
} }
@@ -177,6 +200,14 @@ impl AppConfig {
} }
_ => {} _ => {}
} }
if self.storage.backend == StorageBackend::S3
&& (self.storage.s3_endpoint.is_none() || self.storage.s3_bucket.is_none())
{
return Err(ConfigError::Invalid(
"storage.backend = \"s3\" requires storage.s3_endpoint and storage.s3_bucket"
.into(),
));
}
if self.auth.mode == AuthMode::Oidc { if self.auth.mode == AuthMode::Oidc {
if self.auth.issuer_url.is_none() { if self.auth.issuer_url.is_none() {
return Err(ConfigError::Invalid( return Err(ConfigError::Invalid(
+12
View File
@@ -139,3 +139,15 @@ fn missing_file_is_a_clear_error() {
Ok(()) Ok(())
}); });
} }
#[test]
fn s3_backend_requires_endpoint_and_bucket() {
figment::Jail::expect_with(|jail| {
let toml =
format!("{AIR_GAPPED_TOML}\n[storage]\ndata_dir = \"./data\"\nbackend = \"s3\"\n");
jail.create_file("teamclaw.toml", &toml)?;
let err = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap_err();
assert!(matches!(err, ConfigError::Invalid(msg) if msg.contains("s3_endpoint")));
Ok(())
});
}
+2
View File
@@ -8,10 +8,12 @@ publish.workspace = true
[dependencies] [dependencies]
async-trait = "0.1" async-trait = "0.1"
object_store = { version = "0.12", features = ["aws"] }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
[dev-dependencies] [dev-dependencies]
testcontainers-modules = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
[lints] [lints]
+4
View File
@@ -2,6 +2,10 @@
//! filesystem implementation serves dev and the air-gapped target; an //! filesystem implementation serves dev and the air-gapped target; an
//! S3-compatible implementation slots in behind the same trait for cloud. //! S3-compatible implementation slots in behind the same trait for cloud.
mod s3;
pub use s3::S3BlobStore;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
+84
View File
@@ -0,0 +1,84 @@
//! S3-compatible blob store (cloud target). The same `BlobStore` contract
//! as the local filesystem implementation; MinIO serves it in tests and
//! self-hosted installs, AWS S3 in managed clouds.
use object_store::aws::AmazonS3;
use object_store::path::Path as ObjectPath;
use object_store::{ObjectStore, PutPayload};
use crate::{BlobError, BlobStore};
pub struct S3BlobStore {
store: AmazonS3,
}
impl S3BlobStore {
/// `endpoint` is the S3 API base (http allowed for in-cluster MinIO);
/// credentials come from deployment secrets.
pub fn connect(
endpoint: &str,
bucket: &str,
access_key: &str,
secret_key: &str,
) -> Result<S3BlobStore, BlobError> {
let store = object_store::aws::AmazonS3Builder::new()
.with_endpoint(endpoint)
.with_allow_http(true)
.with_bucket_name(bucket)
.with_access_key_id(access_key)
.with_secret_access_key(secret_key)
.with_region("us-east-1")
// MinIO and most self-hosted S3s require path-style addressing.
.with_virtual_hosted_style_request(false)
.build()
.map_err(|e| BlobError::Io(e.to_string()))?;
Ok(S3BlobStore { store })
}
fn key(key: &str) -> Result<ObjectPath, BlobError> {
if key.is_empty() || key.split('/').any(|part| part.is_empty() || part == "..") {
return Err(BlobError::InvalidKey(key.to_owned()));
}
ObjectPath::parse(key).map_err(|e| BlobError::InvalidKey(e.to_string()))
}
}
#[async_trait::async_trait]
impl BlobStore for S3BlobStore {
async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), BlobError> {
let path = Self::key(key)?;
self.store
.put(&path, PutPayload::from_bytes(bytes.to_vec().into()))
.await
.map_err(|e| BlobError::Io(e.to_string()))?;
Ok(())
}
async fn get(&self, key: &str) -> Result<Vec<u8>, BlobError> {
let path = Self::key(key)?;
match self.store.get(&path).await {
Ok(result) => Ok(result
.bytes()
.await
.map_err(|e| BlobError::Io(e.to_string()))?
.to_vec()),
Err(object_store::Error::NotFound { .. }) => Err(BlobError::NotFound),
Err(e) => Err(BlobError::Io(e.to_string())),
}
}
async fn delete(&self, key: &str) -> Result<(), BlobError> {
let path = Self::key(key)?;
// object_store's S3 delete is idempotent; the drive UX wants an
// honest NotFound, so probe first (head).
match self.store.head(&path).await {
Ok(_) => {}
Err(object_store::Error::NotFound { .. }) => return Err(BlobError::NotFound),
Err(e) => return Err(BlobError::Io(e.to_string())),
}
self.store
.delete(&path)
.await
.map_err(|e| BlobError::Io(e.to_string()))
}
}
+91
View File
@@ -0,0 +1,91 @@
//! The cloud-target blob store against a REAL S3-compatible server
//! (MinIO in a container) — same contract the local store satisfies.
use testcontainers_modules::testcontainers::core::{ExecCommand, IntoContainerPort, WaitFor};
use testcontainers_modules::testcontainers::runners::AsyncRunner;
use testcontainers_modules::testcontainers::{GenericImage, ImageExt};
use tc_files::{BlobError, BlobStore, S3BlobStore};
async fn minio_store() -> (
S3BlobStore,
testcontainers_modules::testcontainers::ContainerAsync<GenericImage>,
) {
let container = GenericImage::new("minio/minio", "latest")
.with_exposed_port(9000.tcp())
.with_wait_for(WaitFor::message_on_either_std("API:"))
.with_env_var("MINIO_ROOT_USER", "tc-access")
.with_env_var("MINIO_ROOT_PASSWORD", "tc-secret-key")
.with_cmd(["server", "/data"])
.start()
.await
.expect("minio starts");
// Create the bucket with the bundled mc client.
container
.exec(ExecCommand::new([
"sh",
"-c",
"mc alias set local http://127.0.0.1:9000 tc-access tc-secret-key && mc mb local/teamclaw",
]))
.await
.expect("bucket created");
let port = container.get_host_port_ipv4(9000).await.unwrap();
// The bucket is created asynchronously after boot; retry connect+probe.
let store = S3BlobStore::connect(
&format!("http://127.0.0.1:{port}"),
"teamclaw",
"tc-access",
"tc-secret-key",
)
.expect("client builds");
for _ in 0..50 {
if store.put("probe", b"x").await.is_ok() {
store.delete("probe").await.ok();
return (store, container);
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
panic!("minio bucket never became writable");
}
#[tokio::test]
async fn s3_round_trip_overwrite_and_missing_keys() {
let (store, _container) = minio_store().await;
store
.put("ws1/documents/agent1/report.md", b"# Q2 Report")
.await
.unwrap();
assert_eq!(
store.get("ws1/documents/agent1/report.md").await.unwrap(),
b"# Q2 Report"
);
// Overwrite replaces.
store
.put("ws1/documents/agent1/report.md", b"# Q3 Report")
.await
.unwrap();
assert_eq!(
store.get("ws1/documents/agent1/report.md").await.unwrap(),
b"# Q3 Report"
);
// Delete then NotFound on both get and delete.
store
.delete("ws1/documents/agent1/report.md")
.await
.unwrap();
assert!(matches!(
store.get("ws1/documents/agent1/report.md").await,
Err(BlobError::NotFound)
));
assert!(matches!(
store.delete("ws1/documents/agent1/report.md").await,
Err(BlobError::NotFound)
));
// Nested keys work without directory semantics.
store.put("a/b/c/deep.txt", b"deep").await.unwrap();
assert_eq!(store.get("a/b/c/deep.txt").await.unwrap(), b"deep");
}
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# TeamClaw air-gapped installer. Verifies the signed bundle FULLY OFFLINE
# (ed25519 signature over sha256 checksums) before anything is loaded;
# a failed verification aborts with nothing touched.
#
# Usage:
# ./install.sh <bundle-dir> <release.pub> [--verify-only]
#
# The bundle layout (produced by teamclaw-bundler assemble):
# bin/teamclaw-bundler verifier binary
# images/*.tar docker image tarballs
# compose/ docker-compose.yml + teamclaw.toml + .env.example
# checksums.txt + checksums.sig + manifest.json
set -euo pipefail
BUNDLE="${1:?usage: install.sh <bundle-dir> <release.pub> [--verify-only]}"
PUBKEY="${2:?usage: install.sh <bundle-dir> <release.pub> [--verify-only]}"
MODE="${3:-install}"
BUNDLER="$BUNDLE/bin/teamclaw-bundler"
if [ ! -x "$BUNDLER" ]; then
echo "install.sh: verifier missing at $BUNDLER" >&2
exit 1
fi
echo "Verifying bundle signature and checksums (offline)…"
"$BUNDLER" verify "$BUNDLE" "$PUBKEY"
if [ "$MODE" = "--verify-only" ]; then
echo "Verification complete; skipping install (--verify-only)."
exit 0
fi
echo "Loading container images…"
for tarball in "$BUNDLE"/images/*.tar; do
docker load --input "$tarball"
done
echo "Installing compose deployment…"
TARGET="${TEAMCLAW_HOME:-/opt/teamclaw}"
mkdir -p "$TARGET"
cp -R "$BUNDLE/compose/." "$TARGET/"
if [ ! -f "$TARGET/.env" ]; then
cp "$TARGET/.env.example" "$TARGET/.env"
echo "Edit $TARGET/.env (set POSTGRES_PASSWORD), then run:"
echo " docker compose --project-directory $TARGET up -d"
exit 0
fi
docker compose --project-directory "$TARGET" up -d
echo "TeamClaw is starting; the server self-migrates on boot."
+9
View File
@@ -0,0 +1,9 @@
apiVersion: v2
name: teamclaw
description: >-
TeamClaw — collaborative AI agents with a human-in-the-loop safety layer.
One chart deploys the server (API + gateway + runtime), the secret broker,
and the frontend; Postgres is either managed (external URL) or in-cluster.
type: application
version: 0.1.0
appVersion: "0.1.0"
@@ -0,0 +1,20 @@
{{- define "teamclaw.labels" -}}
app.kubernetes.io/name: teamclaw
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "teamclaw.serverSelector" -}}
app.kubernetes.io/name: teamclaw
app.kubernetes.io/component: server
{{- end }}
{{- define "teamclaw.brokerSelector" -}}
app.kubernetes.io/name: teamclaw
app.kubernetes.io/component: broker
{{- end }}
{{- define "teamclaw.frontendSelector" -}}
app.kubernetes.io/name: teamclaw
app.kubernetes.io/component: frontend
{{- end }}
@@ -0,0 +1,46 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: teamclaw-config
labels: {{- include "teamclaw.labels" . | nindent 4 }}
data:
teamclaw.toml: |
deploy_target = "cloud"
listen_addr = "0.0.0.0:8080"
[database]
# Overlaid by TEAMCLAW_DATABASE__URL from the Secret.
url = "postgres://overridden-by-env"
[llm]
provider = "{{ .Values.llm.provider }}"
model = "{{ .Values.llm.model }}"
{{- if .Values.llm.baseUrl }}
base_url = "{{ .Values.llm.baseUrl }}"
{{- end }}
[auth]
mode = "{{ .Values.auth.mode }}"
{{- if eq .Values.auth.mode "oidc" }}
issuer_url = "{{ required "auth.issuerUrl is required for oidc" .Values.auth.issuerUrl }}"
client_id = "{{ .Values.auth.clientId }}"
{{- end }}
[storage]
data_dir = "{{ .Values.storage.dataDir }}"
backend = "{{ .Values.storage.backend }}"
{{- if eq .Values.storage.backend "s3" }}
s3_endpoint = "{{ required "storage.s3.endpoint required" .Values.storage.s3.endpoint }}"
s3_bucket = "{{ required "storage.s3.bucket required" .Values.storage.s3.bucket }}"
{{- end }}
[broker]
socket_path = "/run/teamclaw/broker.sock"
{{- if .Values.oauth.issuerUrl }}
[oauth]
issuer_url = "{{ .Values.oauth.issuerUrl }}"
client_id = "{{ .Values.oauth.clientId }}"
redirect_base = "{{ .Values.oauth.redirectBase }}"
{{- end }}
@@ -0,0 +1,41 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: teamclaw-frontend
labels: {{- include "teamclaw.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.frontend.replicas }}
selector:
matchLabels: {{- include "teamclaw.frontendSelector" . | nindent 6 }}
template:
metadata:
labels:
{{- include "teamclaw.frontendSelector" . | nindent 8 }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
containers:
- name: frontend
image: "{{ .Values.image.registry }}/frontend:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- { containerPort: 3000, name: http }
env:
- name: API_ORIGIN
value: http://teamclaw-server:8080
resources: {{- toYaml .Values.frontend.resources | nindent 12 }}
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
---
apiVersion: v1
kind: Service
metadata:
name: teamclaw-frontend
labels: {{- include "teamclaw.labels" . | nindent 4 }}
spec:
selector: {{- include "teamclaw.frontendSelector" . | nindent 4 }}
ports:
- { name: http, port: 3000, targetPort: http }
@@ -0,0 +1,37 @@
{{- if .Values.ingress.enabled }}
# SSE streaming (POST /api/gateway) requires unbuffered proxying with long
# read timeouts; without these annotations approvals and live transcripts
# stall behind nginx buffering.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: teamclaw
labels: {{- include "teamclaw.labels" . | nindent 4 }}
annotations:
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.tlsSecretName }}
tls:
- hosts: [{{ .Values.ingress.host | quote }}]
secretName: {{ .Values.ingress.tlsSecretName }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: teamclaw-server
port: { name: http }
- path: /
pathType: Prefix
backend:
service:
name: teamclaw-frontend
port: { name: http }
{{- end }}
@@ -0,0 +1,44 @@
# The broker has no Service and shares the server pod, so its socket is
# already unreachable over the network. These policies enforce the rest of
# the §15 topology: the database secret-holders are the only egress-capable
# pods, and the frontend can reach only the server.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: teamclaw-frontend-egress
labels: {{- include "teamclaw.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels: {{- include "teamclaw.frontendSelector" . | nindent 6 }}
policyTypes: [Egress]
egress:
# DNS, then only the server.
- to: []
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
- to:
- podSelector:
matchLabels: {{- include "teamclaw.serverSelector" . | nindent 14 }}
ports:
- { protocol: TCP, port: 8080 }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: teamclaw-server-ingress
labels: {{- include "teamclaw.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels: {{- include "teamclaw.serverSelector" . | nindent 6 }}
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: {{- include "teamclaw.frontendSelector" . | nindent 14 }}
# Ingress controller namespaces vary; admit via namespace selector.
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- { protocol: TCP, port: 8080 }
+124
View File
@@ -0,0 +1,124 @@
# The server pod runs teamclaw-server with the secret broker as a sidecar
# sharing a private emptyDir for the unix socket — the §15 topology: no
# network hop carries credentials, and nothing else can reach the socket.
apiVersion: apps/v1
kind: Deployment
metadata:
name: teamclaw-server
labels: {{- include "teamclaw.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.server.replicas }}
selector:
matchLabels: {{- include "teamclaw.serverSelector" . | nindent 6 }}
template:
metadata:
labels:
{{- include "teamclaw.serverSelector" . | nindent 8 }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: server
image: "{{ .Values.image.registry }}/server:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- { containerPort: 8080, name: http }
env:
- name: TEAMCLAW_CONFIG
value: /etc/teamclaw/teamclaw.toml
- name: TEAMCLAW_DATABASE__URL
valueFrom:
secretKeyRef:
name: {{ .Values.database.urlSecretName }}
key: url
{{- if eq .Values.llm.provider "anthropic" }}
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.llm.apiKeySecretName }}
key: api-key
{{- end }}
{{- if eq .Values.storage.backend "s3" }}
- name: TEAMCLAW_STORAGE__S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.storage.s3.credentialsSecretName }}
key: access-key
- name: TEAMCLAW_STORAGE__S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.storage.s3.credentialsSecretName }}
key: secret-key
{{- end }}
volumeMounts:
- { name: config, mountPath: /etc/teamclaw, readOnly: true }
- { name: broker-socket, mountPath: /run/teamclaw }
{{- if eq .Values.storage.backend "local" }}
- { name: data, mountPath: {{ .Values.storage.dataDir }} }
{{- end }}
readinessProbe:
httpGet: { path: /healthz, port: http }
initialDelaySeconds: 3
resources: {{- toYaml .Values.server.resources | nindent 12 }}
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
readOnlyRootFilesystem: true
- name: broker
image: "{{ .Values.image.registry }}/broker:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: TEAMCLAW_BROKER_SOCKET
value: /run/teamclaw/broker.sock
- name: TEAMCLAW_BROKER_KEY_FILE
value: /etc/teamclaw-broker/broker.key
- name: TEAMCLAW_DATABASE__URL
valueFrom:
secretKeyRef:
name: {{ .Values.database.urlSecretName }}
key: url
volumeMounts:
- { name: broker-socket, mountPath: /run/teamclaw }
- { name: broker-key, mountPath: /etc/teamclaw-broker, readOnly: true }
resources: {{- toYaml .Values.broker.resources | nindent 12 }}
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
readOnlyRootFilesystem: true
volumes:
- name: config
configMap: { name: teamclaw-config }
- name: broker-socket
emptyDir: {}
- name: broker-key
secret: { secretName: {{ .Values.broker.keySecretName }} }
{{- if eq .Values.storage.backend "local" }}
- name: data
persistentVolumeClaim: { claimName: teamclaw-data }
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: teamclaw-server
labels: {{- include "teamclaw.labels" . | nindent 4 }}
spec:
selector: {{- include "teamclaw.serverSelector" . | nindent 4 }}
ports:
- { name: http, port: 8080, targetPort: http }
{{- if eq .Values.storage.backend "local" }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: teamclaw-data
labels: {{- include "teamclaw.labels" . | nindent 4 }}
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.storage.pvcSize }}
{{- end }}
+66
View File
@@ -0,0 +1,66 @@
# TeamClaw cloud deployment values.
image:
registry: ghcr.io/teamclaw
tag: latest
pullPolicy: IfNotPresent
server:
replicas: 1
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { cpu: "1", memory: 512Mi }
broker:
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 250m, memory: 128Mi }
# Master key Secret (key: broker.key); create it before install and BACK
# IT UP — secrets are unrecoverable without it.
keySecretName: teamclaw-broker-key
frontend:
replicas: 1
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 256Mi }
database:
# External/managed Postgres connection string Secret (key: url).
urlSecretName: teamclaw-database
llm:
# anthropic | openai_compat | scripted
provider: anthropic
model: claude-sonnet-4-6
baseUrl: ""
# Secret holding ANTHROPIC_API_KEY (key: api-key) for the anthropic provider.
apiKeySecretName: teamclaw-llm
storage:
# local (PVC) | s3
backend: local
dataDir: /var/lib/teamclaw
pvcSize: 10Gi
s3:
endpoint: ""
bucket: ""
credentialsSecretName: teamclaw-s3
auth:
# local | oidc
mode: oidc
issuerUrl: ""
clientId: teamclaw
oauth:
issuerUrl: ""
clientId: teamclaw
clientSecretName: teamclaw-oauth
redirectBase: ""
ingress:
enabled: true
className: nginx
host: teamclaw.example.com
tlsSecretName: ""