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:
co-authored by
Claude Fable 5
parent
ccf96053e6
commit
70ec39f696
@@ -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
@@ -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"
|
||||||
|
|||||||
Executable
+38
@@ -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"
|
||||||
Executable
+48
@@ -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"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
Executable
+51
@@ -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."
|
||||||
@@ -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 }
|
||||||
@@ -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 }}
|
||||||
@@ -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: ""
|
||||||
Reference in New Issue
Block a user