Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions contrib/chart/templates/apirs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,35 @@ spec:
name: {{ include "centaur.secretEnvName" . }}
key: {{ printf "%sIRON_CONTROL_INITIAL_API_KEY" .Values.secretManager.envPrefix }}
{{- end }}
{{- if .Values.toolServer.enabled }}
{{- if not .Values.toolServer.repo }}
{{- fail "toolServer.repo (owner/name) must be set when toolServer.enabled=true" }}
{{- end }}
# Base tools are git-cloned into each sandbox at boot by a
# tools-bootstrap init container (the repo-cache architecture, without
# the shared node cache), so adding a tool is a push to the repo, not
# an api-rs image rebuild. api-rs still discovers its own /app/tools to
# grant proxy creds, so pin toolServer.ref to the tool set that image
# carries to avoid drift between granted creds and installed tools.
- name: KUBERNETES_TOOLS_REPO
value: {{ .Values.toolServer.repo | quote }}
{{- if .Values.toolServer.ref }}
- name: KUBERNETES_TOOLS_REF
value: {{ .Values.toolServer.ref | quote }}
{{- end }}
- name: KUBERNETES_TOOLS_SUBDIR
value: {{ .Values.toolServer.subdir | quote }}
- name: KUBERNETES_TOOLS_RUNNER_IMAGE
value: {{ printf "%s:%s" (default .Values.sandbox.image.repository .Values.toolServer.runnerImage.repository) (default .Values.sandbox.image.tag .Values.toolServer.runnerImage.tag) | quote }}
- name: KUBERNETES_TOOLS_RUNNER_IMAGE_PULL_POLICY
value: {{ default .Values.sandbox.image.pullPolicy .Values.toolServer.runnerImage.pullPolicy | quote }}
{{- if .Values.toolServer.githubToken.existingSecretName }}
- name: KUBERNETES_TOOLS_GITHUB_TOKEN_SECRET
value: {{ .Values.toolServer.githubToken.existingSecretName | quote }}
- name: KUBERNETES_TOOLS_GITHUB_TOKEN_SECRET_KEY
value: {{ .Values.toolServer.githubToken.secretKey | quote }}
{{- end }}
{{- end }}
{{- range $name, $value := .Values.apiRs.extraEnv }}
- name: {{ $name }}
value: {{ $value | quote }}
Expand Down
32 changes: 26 additions & 6 deletions contrib/chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,35 @@ ironProxy:
secretSource: onepassword
secretTtl: 10m

# Tool-server sidecar — runs alongside the sandbox container in each sandbox
# Pod and exposes the same /tools/* surface the API used to. Same image as
# the API; the sidecar runs uvicorn against api.tool_server_app.
# When toolServer.enabled is false (default), the sandbox pod has no
# sidecar — currently the API still serves /tools so this is a no-op
# until the API drops its router (Phase 5).
# Base tools delivery for api-rs sandboxes. When enabled, a tools-bootstrap init
# container git-clones `repo` at `ref` into each sandbox's /app/tools (the
# repo-cache architecture — clone a repo into a pre-provisioned dir — without the
# shared node cache), so adding a tool is a push to the repo, not an image
# rebuild. `port` is legacy (the deprecated tool-server sidecar) and unused here.
toolServer:
enabled: true
port: 8001
# owner/name of the GitHub repo carrying the tools tree. REQUIRED when enabled;
# override for forks. Private repos additionally need githubToken below.
repo: paradigmxyz/centaur
# Branch, tag, or commit to check out; empty = the repo's default branch. Pin
# this to the tool set the api-rs image was built with so the creds api-rs
# grants match the tools the sandbox installs.
ref: ""
# Subdirectory in the repo holding the tools tree (mounted at /app/tools).
subdir: tools
# Git-capable image the clone init container runs in; empty fields fall back to
# sandbox.image (which carries git).
runnerImage:
repository: ""
tag: ""
pullPolicy: ""
# GitHub token for private-repo clones (fed to git via GIT_ASKPASS). Leave the
# secret name empty for public repos; point it at the repo-cache token secret
# to reuse the same credential.
githubToken:
existingSecretName: ""
secretKey: token

# iron-control — Rails control plane for authenticated API access + encrypted
# secret storage. Off by default; enable per-environment. Uses a dedicated
Expand Down
142 changes: 139 additions & 3 deletions services/api-rs/crates/centaur-api-server/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ use centaur_iron_proxy::{
ProxyFragment, SourceKind, SourcePolicy, harness_auth_fragment, infra_fragment,
};
use centaur_sandbox_agent_k8s::{
AgentSandboxBackend, AgentSandboxConfig, IronControlSettings, IronProxyConfig,
AgentSandboxBackend, AgentSandboxConfig, GitHubTokenRef, IronControlSettings, IronProxyConfig,
ToolsConfig,
};
use centaur_sandbox_core::{Mount, MountKind, OverlayImage, SandboxSpec};
use centaur_sandbox_local::LocalSandboxBackend;
Expand Down Expand Up @@ -223,6 +224,8 @@ struct SandboxArgs {
workflow_host_image: Option<String>,
#[arg(long = "workflow-host-command", env = "WORKFLOW_HOST_COMMAND")]
workflow_host_command: Option<String>,
#[command(flatten)]
tools_source: ToolsArgs,
}

impl SandboxArgs {
Expand Down Expand Up @@ -385,7 +388,7 @@ impl SandboxArgs {
.read_only(),
);
}
if let Some(overlay) = workflow_overlay_image_from_env() {
if let Some(overlay) = overlay_image_from_env() {
spec = spec.overlay_image(overlay);
}
}
Expand Down Expand Up @@ -668,6 +671,11 @@ impl TryFrom<&SandboxArgs> for AgentSandboxConfig {
proxy.fragments = fragments;
}
config.iron_control = args.iron_control.settings();
config.tools = args.tools_source.to_config();
// The same org overlay (and the same CENTAUR_OVERLAY_* env the chart
// already sets) serves every sandbox; workflow hosts set it spec-level
// via `workflow_host_spec`, which then takes precedence in the backend.
config.overlay = overlay_image_from_env();
// iron-control is the only proxy mode: a per-sandbox proxy syncs its
// secrets from the control plane, so configuring iron-proxy without
// iron-control would produce a non-functional proxy. Fail fast.
Expand All @@ -681,6 +689,82 @@ impl TryFrom<&SandboxArgs> for AgentSandboxConfig {
}
}

#[derive(Debug, ClapArgs)]
struct ToolsArgs {
// Tools are git-cloned into each sandbox at boot by a `tools-bootstrap` init
// container (repo-cache-style) rather than baked into an image, so adding a
// tool needs no image rebuild. Explicit `id`s avoid clap arg-id collisions
// with sibling flattened structs.
#[arg(
id = "tools_source_repo",
long = "kubernetes-tools-repo",
env = "KUBERNETES_TOOLS_REPO"
)]
repo: Option<String>,
#[arg(
id = "tools_source_ref",
long = "kubernetes-tools-ref",
env = "KUBERNETES_TOOLS_REF"
)]
git_ref: Option<String>,
#[arg(
id = "tools_source_subdir",
long = "kubernetes-tools-subdir",
env = "KUBERNETES_TOOLS_SUBDIR",
default_value = "tools"
)]
source_subdir: String,
// Git-capable image the clone init container runs (the sandbox image carries git).
#[arg(
id = "tools_runner_image",
long = "kubernetes-tools-runner-image",
env = "KUBERNETES_TOOLS_RUNNER_IMAGE"
)]
image: Option<String>,
#[arg(
id = "tools_runner_image_pull_policy",
long = "kubernetes-tools-runner-image-pull-policy",
env = "KUBERNETES_TOOLS_RUNNER_IMAGE_PULL_POLICY"
)]
image_pull_policy: Option<String>,
// Secret + key holding a GitHub token for private-repo clones (optional).
#[arg(
id = "tools_github_token_secret",
long = "kubernetes-tools-github-token-secret",
env = "KUBERNETES_TOOLS_GITHUB_TOKEN_SECRET"
)]
github_token_secret: Option<String>,
#[arg(
id = "tools_github_token_secret_key",
long = "kubernetes-tools-github-token-secret-key",
env = "KUBERNETES_TOOLS_GITHUB_TOKEN_SECRET_KEY",
default_value = "token"
)]
github_token_secret_key: String,
}

impl ToolsArgs {
/// `None` when no repo or runner image is configured (tools disabled).
fn to_config(&self) -> Option<ToolsConfig> {
let repo = clean_optional_value(self.repo.as_deref())?;
let image = clean_optional_value(self.image.as_deref())?;
let mut config = ToolsConfig::new(repo, image);
config.image_pull_policy = self.image_pull_policy.clone();
config.git_ref = clean_optional_value(self.git_ref.as_deref());
if let Some(subdir) = clean_optional_value(Some(self.source_subdir.as_str())) {
config.source_subdir = subdir;
}
if let Some(secret_name) = clean_optional_value(self.github_token_secret.as_deref()) {
config.github_token = Some(GitHubTokenRef {
secret_name,
secret_key: clean_optional_value(Some(self.github_token_secret_key.as_str()))
.unwrap_or_else(|| "token".to_owned()),
});
}
Some(config)
}
}

#[derive(Debug, ClapArgs)]
struct IronProxyArgs {
#[arg(
Expand Down Expand Up @@ -986,7 +1070,10 @@ fn default_workflow_host_path() -> String {
.to_string()
}

fn workflow_overlay_image_from_env() -> Option<OverlayImage> {
/// The org overlay image, from the `CENTAUR_OVERLAY_*` env the chart sets.
/// Shared by workflow-host specs and the agent-sandbox backend default — every
/// sandbox mounts the same overlay tree at the same path.
fn overlay_image_from_env() -> Option<OverlayImage> {
let image = env::var("CENTAUR_OVERLAY_IMAGE").ok()?;
let source_path =
env::var("CENTAUR_OVERLAY_IMAGE_SOURCE_PATH").unwrap_or_else(|_| "/overlay".to_owned());
Expand Down Expand Up @@ -1063,6 +1150,16 @@ fn parse_label_selector_arg(value: &str) -> Result<BTreeMap<String, String>, Str
mod tests {
use super::*;

// Process env is global: tests that set CENTAUR_OVERLAY_* via EnvGuard must
// not interleave, or one test observes another's values.
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());

fn env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}

struct EnvGuard {
name: &'static str,
old_value: Option<String>,
Expand Down Expand Up @@ -1170,8 +1267,47 @@ mod tests {
assert!(config.iron_proxy.is_none());
}

#[test]
fn tools_and_overlay_config_read_from_flags() {
// The overlay rides the same CENTAUR_OVERLAY_* env the workflow host
// reads — one overlay convention for every sandbox.
let _env_lock = env_lock();
let _overlay_image = EnvGuard::set("CENTAUR_OVERLAY_IMAGE", "centaur-overlay:test");
let args = Args::try_parse_from([
"centaur-api-server",
"--database-url",
"postgres://postgres:postgres@localhost/centaur",
"--session-sandbox-backend",
"agent-k8s",
"--kubernetes-sandbox-iron-proxy-mode",
"disabled",
"--kubernetes-tools-repo",
"paradigmxyz/centaur",
"--kubernetes-tools-ref",
"main",
"--kubernetes-tools-runner-image",
"centaur-agent:test",
"--kubernetes-tools-github-token-secret",
"centaur-repo-cache-github-token",
])
.unwrap();
let config = AgentSandboxConfig::try_from(&args.sandbox).unwrap();
let tools = config.tools.expect("tools should be Some");
assert_eq!(tools.repo, "paradigmxyz/centaur");
assert_eq!(tools.git_ref.as_deref(), Some("main"));
assert_eq!(tools.source_subdir, "tools");
assert_eq!(tools.image, "centaur-agent:test");
let token = tools.github_token.expect("token should be Some");
assert_eq!(token.secret_name, "centaur-repo-cache-github-token");
assert_eq!(token.secret_key, "token");
let overlay = config.overlay.expect("overlay should be Some");
assert_eq!(overlay.image, "centaur-overlay:test");
assert_eq!(overlay.mount_path, "/opt/centaur/overlay");
}

#[test]
fn agent_k8s_workflow_host_mounts_overlay_workflows() {
let _env_lock = env_lock();
let _overlay_image = EnvGuard::set("CENTAUR_OVERLAY_IMAGE", "ghcr.io/example/overlay:test");
let _overlay_pull_policy = EnvGuard::set("CENTAUR_OVERLAY_IMAGE_PULL_POLICY", "Always");
let _overlay_source_path = EnvGuard::set("CENTAUR_OVERLAY_IMAGE_SOURCE_PATH", "/overlay");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use crate::{
const IRON_PROXY_LABEL: &str = "centaur.ai/iron-proxy";
const IRON_CONTROL_PROXY_ID_ANNOTATION: &str = "centaur.ai/iron-control-proxy-id";
const FIREWALL_CA_MOUNT_PATH: &str = "/firewall-certs";
const FIREWALL_CA_CERT_PATH: &str = "/firewall-certs/ca-cert.pem";
pub(crate) const FIREWALL_CA_CERT_PATH: &str = "/firewall-certs/ca-cert.pem";
const PROXY_MANAGEMENT_PORT: u16 = 9092;
const PROXY_HEALTH_PORT: u16 = 9090;
// Managed-mode proxies carry no rendered config; these local listen/TLS
Expand Down
Loading
Loading