diff --git a/crucible-controller/src/api/dto.rs b/crucible-controller/src/api/dto.rs index 5edeea21..12265c94 100644 --- a/crucible-controller/src/api/dto.rs +++ b/crucible-controller/src/api/dto.rs @@ -1130,7 +1130,7 @@ mod tests { Some("quay.io/x/sandbox:dev".to_string()), )), &crate::playbooks::dispatch::DispatchCapability::new( - crate::config::PlaybookExecutor::Local, + crate::config::PlaybookExecutor::Pod, false, ), &[], @@ -1152,11 +1152,11 @@ mod tests { assert_eq!(v["dispatch"]["backend"], "openshell"); assert_eq!(v["dispatch"]["sandbox_image"], "quay.io/x/sandbox:dev"); assert_eq!(v["dispatch"]["dispatchable"], false); - assert_eq!(v["dispatch"]["local_mode"], true); + assert_eq!(v["dispatch"]["local_mode"], false); assert!( v["dispatch"]["refusal"] .as_str() - .is_some_and(|r| r.contains("openshell")), + .is_some_and(|r| r.contains("CONTROLLER_DEPLOY_PROFILE")), "{v:#}" ); assert_eq!(v["actions"], serde_json::json!(["read", "manage-members"])); diff --git a/crucible-controller/src/api/tests.rs b/crucible-controller/src/api/tests.rs index 16410696..4fcbb452 100644 --- a/crucible-controller/src/api/tests.rs +++ b/crucible-controller/src/api/tests.rs @@ -6778,7 +6778,7 @@ async fn launching_with_bad_values_is_422_naming_each_field(pool: PgPool) -> Res const OPENSHELL_MANIFEST: &str = "[repo]\npath = \".\"\n\n[workflow]\ntype = \"playbook\"\nfile = \"workflow.star\"\n\n\ [agent]\nbackend = \"openshell\"\nsandbox_image = \"quay.io/x/sandbox:dev\"\n"; -/// A deployment that runs playbooks locally cannot give an OpenShell pack its sandbox, and says so +/// A pod deployment with no deploy profile cannot give an OpenShell pack its sandbox, and says so /// at launch — where a person is watching — instead of at the bottom of a failed reconcile. #[sqlx::test(migrator = "crucible_controller::MIGRATOR")] async fn launching_a_backend_this_deployment_cannot_dispatch_is_refused( @@ -6788,7 +6788,7 @@ async fn launching_a_backend_this_deployment_cannot_dispatch_is_refused( let app = router(ApiState { roles: crate::identity::auth::Roles::new(vec!["wren".to_string()], vec![], vec![]), dispatch: crate::playbooks::dispatch::DispatchCapability::new( - crate::config::PlaybookExecutor::Local, + crate::config::PlaybookExecutor::Pod, false, ), ..ApiState::test(db.clone(), Arc::new(Recorder::default())) @@ -6813,7 +6813,7 @@ async fn launching_a_backend_this_deployment_cannot_dispatch_is_refused( "quay.io/x/sandbox:dev" ); assert_eq!(listed[0]["dispatch"]["dispatchable"], false); - assert_eq!(listed[0]["dispatch"]["local_mode"], true); + assert_eq!(listed[0]["dispatch"]["local_mode"], false); let (status, body) = post_launch( &app, @@ -6828,7 +6828,7 @@ async fn launching_a_backend_this_deployment_cannot_dispatch_is_refused( .await; assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); let message = body["fields"][0]["message"].as_str().unwrap_or_default(); - assert!(message.contains("openshell"), "{body}"); + assert!(message.contains("CONTROLLER_DEPLOY_PROFILE"), "{body}"); assert_eq!( body["error"], "this deployment cannot dispatch the pack's agent backend", "the headline names the refusal; the parameters were fine" diff --git a/crucible-controller/src/bin/crucible-controller.rs b/crucible-controller/src/bin/crucible-controller.rs index 4af2a5c1..0c0c06c1 100644 --- a/crucible-controller/src/bin/crucible-controller.rs +++ b/crucible-controller/src/bin/crucible-controller.rs @@ -818,10 +818,17 @@ async fn run_autopilot_daemon(mut cfg: crucible_controller::ControllerCfg) -> Re Some(metrics), Some(db.events().clone()), ); - if let Err(e) = config_store.ensure_configmap().await { - tracing::warn!(error = %format!("{e:#}"), "autopilot: overrides create-if-missing failed"); + let overrides_watched = kube::Config::infer().await.is_ok(); + if overrides_watched { + if let Err(e) = config_store.ensure_configmap().await { + tracing::warn!(error = %format!("{e:#}"), "autopilot: overrides create-if-missing failed"); + } + config_store.reload_once().await; + } else { + tracing::info!( + "no Kubernetes config to infer; runtime overrides are off and the parsed config stands" + ); } - config_store.reload_once().await; cfg.overrides = Some(config_store.clone()); // The WorkPod dispatcher (grounded-rank turns + loop runs + future kinds): the real kube @@ -933,11 +940,13 @@ async fn run_autopilot_daemon(mut cfg: crucible_controller::ControllerCfg) -> Re // The overrides watch: re-list the ConfigMap on a fixed cadence, swap on a valid change, keep // last-good otherwise. Shares the daemon's shutdown signal so it stops cleanly. - tokio::spawn(crucible_controller::daemon::overrides_store::watch_loop( - config_store.clone(), - std::time::Duration::from_secs(30), - shutdown.clone(), - )); + if overrides_watched { + tokio::spawn(crucible_controller::daemon::overrides_store::watch_loop( + config_store.clone(), + std::time::Duration::from_secs(30), + shutdown.clone(), + )); + } // One work queue shared between the worker and the override sink (human park/unpark/bump lands // in the same FIFO as the discovery/approval/pod-watch sources); the store carries the intent diff --git a/crucible-controller/src/identity/auth.rs b/crucible-controller/src/identity/auth.rs index de2193db..0a38178d 100644 --- a/crucible-controller/src/identity/auth.rs +++ b/crucible-controller/src/identity/auth.rs @@ -257,6 +257,8 @@ pub(crate) struct BearerGuard { pub(crate) expected: Option, pub(crate) proxy: Option, pub(crate) static_identity: Option, + /// `CONTROLLER_DEV_IDENTITY`: the login every request lands as while the guard is open. + pub(crate) dev_identity: Option, pub(crate) kube: Option, pub(crate) oidc: Option>, /// The pool the `users` table is read through, to tell a cluster token that names a known SSO @@ -276,6 +278,7 @@ impl Default for BearerGuard { expected: None, proxy: None, static_identity: None, + dev_identity: None, kube: None, oidc: None, users: None, @@ -311,7 +314,27 @@ impl BearerGuard { "CONTROLLER_AUTH_MODE=native without CONTROLLER_OIDC_ISSUER: native mode has no identity provider to run a login against" ); } - Ok(guard) + guard.with_dev_identity(std::env::var("CONTROLLER_DEV_IDENTITY").ok()) + } + + /// Land every request on an open guard as `login`. Errors on a closed guard. + pub(crate) fn with_dev_identity(mut self, login: Option) -> anyhow::Result { + let Some(login) = login + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + else { + return Ok(self); + }; + if !self.is_open() { + anyhow::bail!( + "CONTROLLER_DEV_IDENTITY is set on a guarded deployment (CONTROLLER_API_TOKEN or CONTROLLER_OIDC_ISSUER is configured), so every caller would land as {login}" + ); + } + self.dev_identity = + Some(HeaderValue::from_str(&login).map_err(|_| { + anyhow::anyhow!("CONTROLLER_DEV_IDENTITY is not a valid header value") + })?); + Ok(self) } /// Refuse the combinations that would silently hand a machine caller the edge's power to name @@ -356,6 +379,7 @@ impl BearerGuard { expected, proxy, static_identity, + dev_identity: None, kube, oidc: None, users: None, @@ -405,7 +429,8 @@ impl BearerGuard { } /// Middleware: 401 any request that doesn't carry a credential the guard recognizes. With no -/// expected token and no issuer, every request passes and the surface is loopback-bound. +/// expected token and no issuer, every request passes, as `CONTROLLER_DEV_IDENTITY` when one is +/// set, and the surface is loopback-bound. /// /// Which credential it is decides who the caller may claim to be. /// @@ -439,10 +464,16 @@ pub(crate) async fn require_auth( let source = guard.source(); if guard.is_open() { - let resolved = if native { - Resolved::anonymous(source) - } else { - resolved_from_headers(req.headers(), source) + let resolved = match &guard.dev_identity { + Some(value) => { + let Ok(name) = value.to_str().map(str::to_string) else { + return unauthorized(); + }; + stamp_identity(req.headers_mut(), Some(value.clone())); + Resolved::named(source, name) + } + None if native => Resolved::anonymous(source), + None => resolved_from_headers(req.headers(), source), }; admit(&mut req, AuthPath::Open, resolved); return next.run(req).await; @@ -1618,6 +1649,42 @@ mod tests { assert_eq!(status, StatusCode::OK); assert_eq!(body, "anonymous|"); } + + #[tokio::test] + async fn an_open_guard_with_a_dev_identity_names_every_caller() { + let guard = BearerGuard::default() + .with_dev_identity(Some(" wren ".into())) + .expect("an open guard takes a dev identity"); + let app = app(guard); + let (status, body) = call(app.clone(), req(None, &[])).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "wren|"); + let (status, body) = call(app, req(Some("Bearer anything"), &CLAIMS)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "wren|"); + } + + #[test] + fn a_dev_identity_is_refused_on_a_guarded_deployment() { + let refusal = match static_only().with_dev_identity(Some("wren".into())) { + Ok(_) => panic!("expected a refusal"), + Err(e) => e.to_string(), + }; + assert!(refusal.contains("guarded deployment"), "{refusal}"); + } + + #[test] + fn a_blank_dev_identity_is_none_and_a_bad_one_fails_boot() { + let guard = BearerGuard::default() + .with_dev_identity(Some(" ".into())) + .expect("blank is unset"); + assert!(guard.dev_identity.is_none()); + assert!( + BearerGuard::default() + .with_dev_identity(Some("wr\nen".into())) + .is_err() + ); + } } /// Native mode: identity is a cookie this controller minted or a JWT it validated, and nothing @@ -2197,6 +2264,10 @@ mod tests { !guard.is_open(), "a deployment with logins is never the open shape" ); + assert!( + guard.with_dev_identity(Some("wren".into())).is_err(), + "an issuer alone refuses a dev identity" + ); } } } diff --git a/crucible-controller/src/playbooks/dispatch.rs b/crucible-controller/src/playbooks/dispatch.rs index 3fa748bc..90d71e98 100644 --- a/crucible-controller/src/playbooks/dispatch.rs +++ b/crucible-controller/src/playbooks/dispatch.rs @@ -115,8 +115,8 @@ const DEFAULT_BACKEND: &str = "local"; /// time, so it is refused here instead — at launch, where someone is watching. const KNOWN_BACKENDS: [&str; 3] = ["local", "openshell", "command"]; -/// The backend that needs an OpenShell gateway, which only a cluster deployment has. -const CLUSTER_ONLY_BACKEND: &str = "openshell"; +/// The backend that runs each turn in an OpenShell sandbox, launched from `sandbox_image`. +const SANDBOX_BACKEND: &str = "openshell"; #[derive(Debug, Deserialize)] struct ManifestAgent { @@ -205,11 +205,6 @@ impl DispatchCapability { )); } match self.executor { - PlaybookExecutor::Local if backend == CLUSTER_ONLY_BACKEND => Some(format!( - "the pack declares [agent] backend {backend:?}, which needs an OpenShell sandbox \ - on a cluster; this deployment runs playbooks as a local subprocess \ - (CONTROLLER_PLAYBOOK_EXECUTOR=local)" - )), PlaybookExecutor::Local => None, PlaybookExecutor::Pod if !self.cluster => Some( "this deployment cannot dispatch a work pod (no CONTROLLER_DEPLOY_PROFILE) and \ @@ -223,17 +218,16 @@ impl DispatchCapability { /// Why an agent turn declared like this cannot be spawned where this deployment would dispatch /// it, or `None`. Asked where a pack is written; [`Self::refusal`] is the launch verdict. pub fn spawn_defect(&self, agent: &PackAgent) -> Option { - if self.executor != PlaybookExecutor::Pod { - return None; - } match agent.backend.as_str() { - CLUSTER_ONLY_BACKEND => agent + SANDBOX_BACKEND => agent .sandbox_image .is_none() .then_some(SpawnDefect::NoSandboxImage), - DEFAULT_BACKEND => Some(SpawnDefect::InProcessBackend { - backend: agent.backend.clone(), - }), + DEFAULT_BACKEND if self.executor == PlaybookExecutor::Pod => { + Some(SpawnDefect::InProcessBackend { + backend: agent.backend.clone(), + }) + } _ => None, } } @@ -476,19 +470,12 @@ mod tests { assert!(pack_agent(dir.path()).is_err()); } - /// The laptop case the whole surface exists for: local mode runs a `local` pack and refuses an - /// `openshell` one, naming both sides of the mismatch. #[test] - fn local_mode_takes_local_and_command_but_not_openshell() { + fn local_mode_takes_every_backend() { let cap = capability(PlaybookExecutor::Local, false); - assert_eq!(cap.refusal(&agent("local")), None); - assert_eq!(cap.refusal(&agent("command")), None); - let refusal = cap.refusal(&agent("openshell")).expect("refused"); - assert!(refusal.contains("openshell"), "{refusal}"); - assert!( - refusal.contains("CONTROLLER_PLAYBOOK_EXECUTOR"), - "{refusal}" - ); + for backend in KNOWN_BACKENDS { + assert_eq!(cap.refusal(&agent(backend)), None, "{backend}"); + } } /// Pod mode with no deploy profile dispatches nothing at all: the render it would need has no @@ -658,20 +645,26 @@ mod tests { } /// A command backend brings its own process, and local mode spawns on the machine the - /// controller runs on, where a `local` agent is the point. + /// controller runs on, where a `local` agent is the point. `openshell` needs an image in both + /// modes. #[test] fn a_command_backend_and_local_mode_earn_no_spawn_defect() { assert_eq!( capability(PlaybookExecutor::Pod, true).spawn_defect(&agent("command")), None ); - for backend in KNOWN_BACKENDS { - assert_eq!( - capability(PlaybookExecutor::Local, false).spawn_defect(&agent(backend)), - None, - "{backend}" - ); - } + let local = capability(PlaybookExecutor::Local, false); + assert_eq!(local.spawn_defect(&agent("local")), None); + assert_eq!(local.spawn_defect(&agent("command")), None); + assert_eq!( + local.spawn_defect(&agent("openshell")), + Some(SpawnDefect::NoSandboxImage) + ); + let complete = PackAgent::new( + "openshell".to_string(), + Some("localhost/sandbox:dev".to_string()), + ); + assert_eq!(local.spawn_defect(&complete), None); } /// The spawn check is the authoring path's alone. Every pack the launch path already accepts diff --git a/crucible-controller/src/runs/local_run.rs b/crucible-controller/src/runs/local_run.rs index 54852bde..f40764cc 100644 --- a/crucible-controller/src/runs/local_run.rs +++ b/crucible-controller/src/runs/local_run.rs @@ -43,8 +43,32 @@ fn session_log(dir: &Path) -> PathBuf { dir.join("pack").join("state").join("session.jsonl") } +/// The engine's forge storage root inside a local run's directory. +fn forge_root(dir: &Path) -> PathBuf { + dir.join("forge") +} + +/// Mark every file in an unpacked pack 0755, matching the pod's pack mount. +fn grant_pack_exec(dir: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? { + let path = entry?.path(); + let meta = std::fs::symlink_metadata(&path)?; + if meta.is_dir() { + grant_pack_exec(&path)?; + } else if meta.is_file() { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .with_context(|| format!("marking {} executable", path.display()))?; + } + } + Ok(()) +} + +/// Host variables every local run inherits. +const HOST_ENV: [&str; 4] = ["PATH", "HOME", "USER", "OPENSHELL_PODMAN_SOCKET"]; + /// The environment one local run is spawned with. Local mode has no registry and no grant, so the -/// only credentials a subprocess can reach are the ones an operator named in `allowlist`; +/// only credentials handed over as environment are the ones an operator named in `allowlist`; /// everything else the controller's own environment holds stays with the controller. /// /// `item` comes from the launch, never from the inherited set: the engine reads @@ -69,7 +93,7 @@ fn run_env( if allowlisted && !disclosed.is_some_and(|e| e.covers_agent_credential(&name)) { return Err(UndisclosedGrant { name }); } - if name == "PATH" || name.starts_with("CRUCIBLE_") || allowlisted { + if HOST_ENV.contains(&name.as_str()) || name.starts_with("CRUCIBLE_") || allowlisted { env.push((name, value)); } } @@ -159,7 +183,8 @@ pub async fn start( let pack = dir.join("pack"); let unpack_to = pack.clone(); tokio::task::spawn_blocking(move || { - crate::playbooks::packs::unpack_pack_tgz(&tar_gz, &unpack_to) + crate::playbooks::packs::unpack_pack_tgz(&tar_gz, &unpack_to)?; + grant_pack_exec(&unpack_to) }) .await .context("joining the local pack unpack")? @@ -177,13 +202,17 @@ pub async fn start( RunRenderOpts::Loop { .. } => CEILING_SLACK, }; let exposure = crate::launches::store::exposure_for_issue(db.pool(), issue_key).await?; - let env = run_env( + let mut env = run_env( std::env::vars(), &cfg.local_secret_allowlist, opts.tracker_item(issue_key), exposure.as_ref(), ) .with_context(|| format!("preparing the local environment for {issue_key}"))?; + env.push(( + "FORGE_STORAGE_ROOT".to_string(), + forge_root(&dir).to_string_lossy().into_owned(), + )); let child = tokio::process::Command::new(&bin) .args(&argv) .current_dir(&pack) @@ -374,13 +403,34 @@ where #[cfg(test)] mod tests { - /// Local mode has no registry, so the subprocess starts from nothing: `PATH`, the run's own - /// `CRUCIBLE_*` set, and whatever an operator named. Everything else the controller holds — - /// its database URL, its Vault login, its tokens — stays with the controller. #[test] - fn a_local_run_inherits_only_path_the_crucible_set_and_the_allowlist() { + fn every_unpacked_pack_file_is_executable_like_the_pod_mount() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(dir.path().join("inbox")).expect("mkdir"); + std::fs::write(dir.path().join("role.sh"), "#!/bin/sh\n").expect("write"); + std::fs::write(dir.path().join("inbox/a.md"), "a").expect("write"); + std::os::unix::fs::symlink("role.sh", dir.path().join("alias.sh")).expect("symlink"); + grant_pack_exec(dir.path()).expect("grant"); + for f in ["role.sh", "inbox/a.md"] { + let mode = std::fs::metadata(dir.path().join(f)) + .expect("stat") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o755, "{f}"); + } + } + + /// Local mode has no registry, so the subprocess starts from nothing: the host variables, + /// the run's own `CRUCIBLE_*` set, and whatever an operator named. Everything else the + /// controller holds — its database URL, its Vault login, its tokens — stays with the controller. + #[test] + fn a_local_run_inherits_only_host_facts_the_crucible_set_and_the_allowlist() { let inherited = [ ("PATH", "/usr/bin"), + ("HOME", "/home/wren"), + ("USER", "wren"), + ("OPENSHELL_PODMAN_SOCKET", "/run/podman.sock"), ("CRUCIBLE_BIN", "/opt/crucible"), ("DATABASE_URL", "postgres://secret"), ("VAULT_SECRET_ID", "hunter2"), @@ -397,7 +447,17 @@ mod tests { ) .expect("a disclosed allowlisted value is handed over"); let names: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect(); - assert_eq!(names, ["PATH", "CRUCIBLE_BIN", "GH_TOKEN"]); + assert_eq!( + names, + [ + "PATH", + "HOME", + "USER", + "OPENSHELL_PODMAN_SOCKET", + "CRUCIBLE_BIN", + "GH_TOKEN" + ] + ); } /// The allowlist names what the operator is willing to hand over; the pack still has to say diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 9823614e..5cf0bd66 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,6 +16,7 @@ # The control plane - [Deploying the controller](./controller-deploy.md) +- [Running the controller locally](./controller-local.md) - [Authentication](./controller-auth.md) - [Vault and the secrets registry](./controller-vault.md) diff --git a/docs/controller-deploy.md b/docs/controller-deploy.md index 927f4650..9e6c3593 100644 --- a/docs/controller-deploy.md +++ b/docs/controller-deploy.md @@ -8,6 +8,7 @@ of the runtime image, so the engine binaries it launches are in the same image. This page covers what the controller needs from you and how it is wired. Identity is on [Authentication](./controller-auth.md); the secrets registry is on [Vault](./controller-vault.md). +To try it on a laptop first, see [Running the controller locally](./controller-local.md). ## What it needs diff --git a/docs/controller-local.md b/docs/controller-local.md new file mode 100644 index 00000000..fcac2104 --- /dev/null +++ b/docs/controller-local.md @@ -0,0 +1,75 @@ +# Running the controller locally + +The controller runs on a laptop with no identity provider, no Vault and no cluster. Playbook +launches run as `crucible plan run` subprocesses on the same machine, and a pack whose agent +turns ask for an OpenShell sandbox gets one on the host's podman. + +```sh +just controller-local +``` + +Then open . The recipe needs podman (a running `podman machine` on +macOS), bun, and a Rust toolchain. It: + +- starts Postgres in a `crucible-local-pg` container on `127.0.0.1:55434`, with its data in the + `crucible-local-pg` volume, so launches and drafts survive a restart; +- builds the UI and the `crucible`, `crucible-controller` and `crux` binaries; +- runs `crucible-controller autopilot` with the settings below. + +`just controller-local 9000 wren` picks another port and login. Drive it from a shell with crux: + +```sh +export CONTROLLER_URL=http://127.0.0.1:8787 +crux whoami +crux draft-create notes --description "release notes" +crux draft-push notes examples/playbook --base 1 +crux draft-launch notes --max-cost 1 --max-time 5m +``` + +## What the recipe sets + +| Variable | Value | Why | +| --- | --- | --- | +| `CONTROLLER_DEV_IDENTITY` | `$USER` | Every request lands as this login. | +| `CONTROLLER_ADMINS` | `$USER` | Makes that login an admin, so the UI can launch and edit. | +| `CONTROLLER_PLAYBOOK_EXECUTOR` | `local` | Launches run as a subprocess here instead of a work pod. | +| `CONTROLLER_SCOPE_EXECUTOR` | `disabled` | No autopilot scoping, which would otherwise shell `crucible scope` on its own. | +| `CONTROLLER_SESSION_SECURE` | `false` | The UI is plain http. | +| `KUBECONFIG` | `/dev/null` | The controller never reaches a cluster your kubeconfig happens to point at. | +| `OPENSHELL_PODMAN_SOCKET` | the podman machine's API socket | Where an OpenShell sandbox is booted. Set it yourself to override. | +| `CRUCIBLE_BIN` | the built `crucible` | The engine a launch runs. | + +`CONTROLLER_API_TOKEN`, `CONTROLLER_PROXY_TOKEN`, `CONTROLLER_OIDC_ISSUER` and `VAULT_ADDR` are +unset for the process, whatever your shell exports. + +## The dev identity + +With no `CONTROLLER_API_TOKEN` and no `CONTROLLER_OIDC_ISSUER` the guard is open: it admits +every request and binds loopback whatever `CONTROLLER_API_ADDR` asks for. An open request names +nobody, so it is a viewer. `CONTROLLER_DEV_IDENTITY` names it instead, and drops any +`X-Auth-Request-*` header the client wrote first, so a browser and a curl land the same. The +controller refuses to boot with it set on a guarded deployment, where it would name every +caller. + +## Agent turns + +A local run starts from an empty environment. It keeps `PATH`, `HOME`, `USER`, +`OPENSHELL_PODMAN_SOCKET` and the `CRUCIBLE_*` set, and nothing else the controller holds. + +- `backend = "local"` (the default) runs the agent CLI on this machine, under your own login. + The harness finds it through `HOME` and `USER`; this spends real money. +- `backend = "command"` runs the pack's own command, with no model. +- `backend = "openshell"` runs each turn in a sandbox from `sandbox_image`, booted by the engine + on podman. It needs `openshell` and `openshell-gateway` on `PATH`, and the image must be + pullable by podman or already built into it. `just revise-loop-e2e` builds one with no model. + +Any other variable a run needs goes in `CONTROLLER_LOCAL_SECRET_ALLOWLIST`, and the pack has to +disclose it as an agent credential before a run is handed it. + +## What is off + +- The secrets registry answers 503 on its write routes without Vault. A pack that binds no + secrets launches normally; one that binds some is refused. +- Runtime overrides (the admin page's live caps) need the overrides ConfigMap, so the parsed + configuration stands. +- Discovery, triage and the work-pod paths have no cluster and no watched repos, and stay idle. diff --git a/justfile b/justfile index 91ae3567..7b01cf53 100644 --- a/justfile +++ b/justfile @@ -76,6 +76,45 @@ dev-pg: fi echo "DATABASE_URL=postgres://postgres:ci@localhost:55432/crucible" +# See docs/controller-local.md. +# Run the controller on this machine: Postgres in podman, no auth, no Vault, no cluster. +controller-local port="8787" user=env_var("USER"): + #!/usr/bin/env bash + set -euo pipefail + if ! podman container exists crucible-local-pg; then + podman run -d --name crucible-local-pg -e POSTGRES_PASSWORD=local -e POSTGRES_DB=crucible \ + -v crucible-local-pg:/var/lib/postgresql/data -p 127.0.0.1:55434:5432 postgres:16 >/dev/null + echo "created crucible-local-pg" + else + podman start crucible-local-pg >/dev/null + fi + until podman exec crucible-local-pg pg_isready -U postgres -q; do sleep 1; done + (cd crucible-controller/ui && bun install --frozen-lockfile && bun run build) + SQLX_OFFLINE=true cargo build -p crucible -p crucible-controller -p crux --bins + bin="${CARGO_TARGET_DIR:-$PWD/target}/debug" + state="${XDG_STATE_HOME:-$HOME/.local/state}/crucible-controller" + mkdir -p "$state" + if [ -z "${OPENSHELL_PODMAN_SOCKET:-}" ] && podman machine inspect >/dev/null 2>&1; then + export OPENSHELL_PODMAN_SOCKET=$(podman machine inspect --format '{{"{{"}}.ConnectionInfo.PodmanSocket.Path{{"}}"}}') + fi + echo "UI http://127.0.0.1:{{port}} as {{user}}" + echo "CLI CONTROLLER_URL=http://127.0.0.1:{{port}} $bin/crux whoami" + exec env -u CONTROLLER_API_TOKEN -u CONTROLLER_PROXY_TOKEN -u CONTROLLER_OIDC_ISSUER -u VAULT_ADDR \ + KUBECONFIG=/dev/null \ + DATABASE_URL=postgres://postgres:local@127.0.0.1:55434/crucible \ + CONTROLLER_API_ADDR=127.0.0.1:{{port}} \ + CONTROLLER_PUBLIC_URL=http://127.0.0.1:{{port}} \ + CONTROLLER_DEV_IDENTITY={{user}} \ + CONTROLLER_ADMINS={{user}} \ + CONTROLLER_AUTH_MODE=proxy \ + CONTROLLER_SESSION_SECURE=false \ + CONTROLLER_PLAYBOOK_EXECUTOR=local \ + CONTROLLER_SCOPE_EXECUTOR=disabled \ + CONTROLLER_SCRATCH_DIR="$state" \ + CRUCIBLE_BIN="$bin/crucible" \ + RUST_LOG="${RUST_LOG:-info}" \ + "$bin/crucible-controller" autopilot + # Regenerate the controller UI's OpenAPI spec + typed client (build artifacts, not committed): # `cargo run -p crucible-controller --bin openapi-spec` -> openapi.json -> openapi-typescript. # The UI's dev/check/test/build scripts run this themselves via pre-hooks.