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
6 changes: 3 additions & 3 deletions crucible-controller/src/api/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
&[],
Expand All @@ -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"]));
Expand Down
8 changes: 4 additions & 4 deletions crucible-controller/src/api/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()))
Expand All @@ -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,
Expand All @@ -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"
Expand Down
25 changes: 17 additions & 8 deletions crucible-controller/src/bin/crucible-controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
83 changes: 77 additions & 6 deletions crucible-controller/src/identity/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ pub(crate) struct BearerGuard {
pub(crate) expected: Option<SharedToken>,
pub(crate) proxy: Option<SharedToken>,
pub(crate) static_identity: Option<HeaderValue>,
/// `CONTROLLER_DEV_IDENTITY`: the login every request lands as while the guard is open.
pub(crate) dev_identity: Option<HeaderValue>,
pub(crate) kube: Option<crate::identity::kube_user::KubeUserAuth>,
pub(crate) oidc: Option<Arc<crate::identity::oidc::OidcProvider>>,
/// The pool the `users` table is read through, to tell a cluster token that names a known SSO
Expand All @@ -276,6 +278,7 @@ impl Default for BearerGuard {
expected: None,
proxy: None,
static_identity: None,
dev_identity: None,
kube: None,
oidc: None,
users: None,
Expand Down Expand Up @@ -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<String>) -> anyhow::Result<Self> {
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
Expand Down Expand Up @@ -356,6 +379,7 @@ impl BearerGuard {
expected,
proxy,
static_identity,
dev_identity: None,
kube,
oidc: None,
users: None,
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
);
}
}
}
59 changes: 26 additions & 33 deletions crucible-controller/src/playbooks/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 \
Expand All @@ -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<SpawnDefect> {
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,
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading