diff --git a/dag/gunbc/fabric_door_socket_seed_growth.dag b/dag/gunbc/fabric_door_socket_seed_growth.dag index 52b4eeaee88..e8e53c1348c 100644 --- a/dag/gunbc/fabric_door_socket_seed_growth.dag +++ b/dag/gunbc/fabric_door_socket_seed_growth.dag @@ -16,7 +16,7 @@ import std.decl_ref { DeclarationRef, WholeDeclaration } // (gunbc.fabric_writer_roster fabric_door_principal_of, fabric_door_writer_admission_over). An // unresolvable peer is dropped at accept, before any request exists -- no host-side admission. // -// HAND-ITEM DELTA: +5 citable declarations in v1_compiler.cli_run (a trait, an enum, a record and two +// HAND-ITEM DELTA: +6 citable declarations (serve_handler_args added by the peer_user repair, gunbc#12627) in v1_compiler.cli_run (a trait, an enum, a record and two // free functions) and one test module of three tests; the two impl blocks and the listener's two // methods are not citable declarations and are named here rather than omitted. data fabric_door_socket_seed_growth_justification: SeedGrowthJustification = SeedGrowthJustification { @@ -25,10 +25,11 @@ data fabric_door_socket_seed_growth_justification: SeedGrowthJustification = See DeclarationRef { module_path: "v1_compiler.cli_run", decl_name: "ServeListener", field: WholeDeclaration }, DeclarationRef { module_path: "v1_compiler.cli_run", decl_name: "ServeBoundAddress", field: WholeDeclaration }, DeclarationRef { module_path: "v1_compiler.cli_run", decl_name: "serve_bind", field: WholeDeclaration }, - DeclarationRef { module_path: "v1_compiler.cli_run", decl_name: "serve_peer_user", field: WholeDeclaration } + DeclarationRef { module_path: "v1_compiler.cli_run", decl_name: "serve_peer_user", field: WholeDeclaration }, + DeclarationRef { module_path: "v1_compiler.cli_run", decl_name: "serve_handler_args", field: WholeDeclaration } ], reason: "Binding a unix socket, reading SO_PEERCRED from an accepted connection and resolving its uid are operations no .dag fold can perform: they are host facts about a live connection, in the same class as the argv read and status set the native CLI's rendered main keeps. serve_bind generalizes the one TCP bind the seed already had to a TCP-or-socket listener (refusing to replace any non-socket file); serve_peer_user reads the peer and returns its account name as a value; ServeConnection/ServeListener/ServeBoundAddress let the existing request parse and response write run unchanged over either stream. The fabric door's admission consumes peer_user in .dag; nothing here decides.", owning_dissolution_lane: "v1-hand-queue-drain" as RoadmapNodeId, - trigger: "Delete all five when `serve` is a verb of the v2-native CLI door (v2.cli.compile_cli) and its accept loop and peer read are bound as a modeled host effect (an extdeps socket interface with an attested-peer operation) the rendered main realizes -- at which point this listener is emitted rather than hand-authored, and the TCP arm dissolves with it.", + trigger: "Delete all six when `serve` is a verb of the v2-native CLI door (v2.cli.compile_cli) and its accept loop and peer read are bound as a modeled host effect (an extdeps socket interface with an attested-peer operation) the rendered main realizes -- at which point this listener is emitted rather than hand-authored, and the TCP arm dissolves with it.", current_boundary: "Confined to handle_serve's listener and accept loop in v1_compiler.cli_run: every other serve behavior (liveness route, budget arming, handler call, response write) is unchanged byte for byte except that it reads the connection through ServeConnection. A TCP listener passes an empty peer_user, which the fabric door refuses (DoorUnattested); only the --unix-socket arm attests. Tests: cli_run serve_unix_socket_door_tests." } diff --git a/dag/gunbc/recurring_failure_mode/a_deploy_that_observes_its_own_service_cannot_repair_it.dag b/dag/gunbc/recurring_failure_mode/a_deploy_that_observes_its_own_service_cannot_repair_it.dag new file mode 100644 index 00000000000..ede5f2ae30f --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/a_deploy_that_observes_its_own_service_cannot_repair_it.dag @@ -0,0 +1,20 @@ +module gunbc.recurring_failure_mode.a_deploy_that_observes_its_own_service_cannot_repair_it + +import std.types { NonEmptyStr } +import gunbc.recurring_failure_mode { RecurringFailureMode } + +data a_deploy_that_observes_its_own_service_cannot_repair_it: RecurringFailureMode = RecurringFailureMode { + identity: "a_deploy_that_observes_its_own_service_cannot_repair_it" as NonEmptyStr, + + receipts: [ + "INVALID STATE: the modeled deploy route (fleet-converge dashboard_deploy, gunbc.live_deploy) derives its plan by observing the running service, and a running service whose /healthz answers non-2xx makes that observation REFUSE -- so the only sanctioned route to replace a broken release requires the release not to be broken. The route can install a release that breaks the service and then cannot install any release at all, rollback included.", + + "HARM, 2026-09-29: after dashboard_deploy run 36584004074 left gunbc-roadmap answering HTTP 500 (gunbc.recurring_failure_mode a_serve_contract_widened_for_one_handler_refuses_every_other), the rollback dashboard_deploy run 36593744640 at 361db9017b refused before acting: 'the deployment plan could not be derived from the host: service-process: refused -- could not observe the host: the process is active but /healthz refused'. Recovery needed an out-of-band operator step (stopping gunbc-roadmap.service by hand at 16:25) before the modeled route could proceed.", + + "WHY IT IS A CLASS AND NOT AN INCIDENT: the observation conflates two facts -- 'the host cannot be observed' (a transport or principal failure, where refusing is right) and 'the service is observed and unhealthy' (a reading, where the plan's job is to replace it). Treating the second as the first makes health a precondition of repair. DESIGN section 5 wants the unhealthy reading to be a typed observed state the plan can act on, not an absorbing refusal of the whole transaction.", + + "RUNG FOUND AT: mitigatable (the failure is loud and located, and an operator can break the deadlock by hand). CEILING: 3 -- the service-process observation can type an unhealthy-but-observed process as its own arm and the plan can derive a replace-release step from it. NEXT TRIGGER: the live_deploy service-process observation distinguishes observed-unhealthy from unobservable, and a dashboard_deploy over an observed-unhealthy process at a different expected_revision plans the replacement instead of refusing; evidence is a fixture with a 500-answering /healthz that plans, beside the existing refusal for an unreachable host.", + ], + + evidence: [], +} diff --git a/dag/gunbc/recurring_failure_mode/a_serve_contract_widened_for_one_handler_refuses_every_other.dag b/dag/gunbc/recurring_failure_mode/a_serve_contract_widened_for_one_handler_refuses_every_other.dag new file mode 100644 index 00000000000..82e8fb2b165 --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/a_serve_contract_widened_for_one_handler_refuses_every_other.dag @@ -0,0 +1,22 @@ +module gunbc.recurring_failure_mode.a_serve_contract_widened_for_one_handler_refuses_every_other + +import std.types { NonEmptyStr } +import gunbc.recurring_failure_mode { RecurringFailureMode } + +data a_serve_contract_widened_for_one_handler_refuses_every_other: RecurringFailureMode = RecurringFailureMode { + identity: "a_serve_contract_widened_for_one_handler_refuses_every_other" as NonEmptyStr, + + receipts: [ + "INVALID STATE: the seed's `gunbc serve` hands every handler a named argument that only one handler declares. The interpreter binds named arguments by exact bijection -- an undeclared name is a call-contract refusal, not a dropped value -- so every handler lacking the parameter refuses every request. gunbc#12482 added peer_user for the fabric door's unix-socket handler and passed it unconditionally, under a seed comment asserting the opposite ('a handler that does not declare it is not handed it'), which nothing executed.", + + "HARM, 2026-09-29: dashboard_deploy run 36584004074 at 178fb76c71 installed the release; gunbc-roadmap.service came back answering /healthz HTTP 500 on every request ('call contract mismatch calling ...: no parameter named peer_user'), the readiness poll ran to the 30-minute step timeout, and the roadmap dashboard on srv1 was down pending a rollback to 361db9017b (the last release it served). The approval broker was spared only because it runs its own pinned pre-change release.", + + "WHY NO GATE SAW IT: every witness of the change called the fabric handler's .dag function directly with its declared parameters; the seed tests exercised the listener and the peer read; nothing ran a real handler that lacks the new parameter through the real serve loop before deploy. The contract is the seed's argument list against EVERY served handler's signature -- a population of handlers, while each check covered one. The comment's false claim is DESIGN section 5's specification-without-execution: asserted behavior that no executed control could contradict.", + + "REPAIR: serve_handler_args passes peer_user only when the listener attests a peer (--unix-socket), so TCP handlers receive exactly the prior argument set; seed test cli_run serve_unix_socket_door_tests a_tcp_handler_is_not_handed_peer_user pins the argument names per listener, and a live `gunbc serve` of a handler without peer_user answered 200 where the defective seed answered 500.", + + "RUNG FOUND AT: silent wrongness shipped to production (the failure was loud only at runtime on the host). RUNG NOW: 1, mitigatable -- the seed test cli_run serve_unix_socket_door_tests a_tcp_handler_is_not_handed_peer_user exposes a regression, but it is a v1-compiler --lib unit test that blocks no merge while gunbc.rung_drop rust_unit_tests_off_the_merge_path stands, so it is local diligence, not a mechanism (review 72786). It reaches rung 2 when that drop is retired, or through the next trigger below. CEILING: 3 -- the served handlers are declared rows (gunbc.live_deploy spec serve_entry/serve_function), so the seed's argument set can be checked against each declared handler's signature at acceptance. NEXT TRIGGER: a required claim that, for every serve unit the deploy spec declares, binds the listener's argument set to that handler's declared parameters and refuses a mismatch before any deploy.", + ], + + evidence: [], +} diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 60d1c4270cc..7ea024fad6b 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -21404,6 +21404,7 @@ pub fn handle_serve( .map(|v| v.to_string()) .unwrap_or_else(|| "unset".to_string()), ); + let listener_attests_peer = matches!(listener, ServeListener::Unix(..)); v1_interpreter::with_active_context(&ctx, || { loop { let (mut conn, peer_user) = match listener.accept() { @@ -21464,30 +21465,14 @@ pub fn handle_serve( ) } Ok(Some((method, path, body, tailscale_identity))) => { - let args: Vec<(Option, v1_interpreter::Value)> = vec![ - (Some("method".to_string()), str_value(method)), - (Some("path".to_string()), str_value(path)), - (Some("body".to_string()), str_value(body)), - // Empty when the header was absent. The `.dag` side refuses on empty - // rather than treating it as an anonymous caller, so a deployment that - // stopped routing through the tailscale proxy fails closed. - ( - Some("tailscale_identity".to_string()), - str_value(tailscale_identity), - ), - // The kernel-attested peer of a unix-socket connection (SO_PEERCRED, - // resolved to its account name); empty on TCP, where the kernel attests - // nothing about the caller. A handler that does not declare it is not - // handed it (the same as tailscale_identity). - (Some("peer_user".to_string()), str_value(peer_user.clone())), - // Captured once above and cloned per request: the value - // is fixed for the process lifetime, so no request can - // observe a different release than any other request. - ( - Some("release_revision".to_string()), - str_value(release_revision.clone()), - ), - ]; + let args = serve_handler_args( + method, + path, + body, + tailscale_identity, + release_revision.clone(), + if listener_attests_peer { Some(peer_user.clone()) } else { None }, + ); // THE DEADLINE IS ARMED HERE, AROUND THIS CALL, AND THE SCOPE IS THE POINT. // Before this existed the serve path armed nothing, so a route evaluation // that did not return also prevented the `Err` arm below from ever running: @@ -21733,6 +21718,39 @@ impl ServeListener { } } +// THE HANDLER'S ARGUMENTS, NAMED. The tailscale identity is empty when the header was absent (the +// `.dag` side refuses on empty rather than treating it as anonymous); the release revision is fixed +// for the process lifetime. THE PEER IS AN ARGUMENT ONLY WHERE THE KERNEL ATTESTS ONE: a named +// argument the handler does not declare is a call-contract refusal, not a dropped value, so handing +// peer_user to a TCP handler (the roadmap's) refused every request with a 500 on srv1 +// (dashboard_deploy run 36584004074). Only the --unix-socket listener passes Some. +fn serve_handler_args( + method: String, + path: String, + body: String, + tailscale_identity: String, + release_revision: String, + attested_peer: Option, +) -> Vec<(Option, v1_interpreter::Value)> { + let mut args = vec![ + (Some("method".to_string()), str_value(method)), + (Some("path".to_string()), str_value(path)), + (Some("body".to_string()), str_value(body)), + ( + Some("tailscale_identity".to_string()), + str_value(tailscale_identity), + ), + ( + Some("release_revision".to_string()), + str_value(release_revision), + ), + ]; + if let Some(peer) = attested_peer { + args.push((Some("peer_user".to_string()), str_value(peer))); + } + args +} + fn serve_peer_user(s: &std::os::unix::net::UnixStream) -> std::io::Result { use std::os::unix::io::AsRawFd; let mut cred: libc::ucred = unsafe { std::mem::zeroed() }; @@ -46499,6 +46517,44 @@ mod serve_unix_socket_door_tests { assert_eq!(std::fs::read(&path).unwrap(), b"data"); } + // A TCP handler is handed exactly the five arguments every existing handler declares -- never + // peer_user, whose presence refuses a handler that does not declare it (srv1 500, run 36584004074). + #[test] + fn a_tcp_handler_is_not_handed_peer_user() { + let names = |a: &Vec<(Option, v1_interpreter::Value)>| -> Vec { + a.iter() + .map(|(n, _)| n.clone().unwrap_or_default()) + .collect() + }; + let tcp = serve_handler_args( + "GET".into(), + "/x".into(), + String::new(), + String::new(), + "r".into(), + None, + ); + assert_eq!( + names(&tcp), + vec![ + "method", + "path", + "body", + "tailscale_identity", + "release_revision" + ] + ); + let unix = serve_handler_args( + "GET".into(), + "/x".into(), + String::new(), + String::new(), + "r".into(), + Some("ghrunner".into()), + ); + assert_eq!(names(&unix).last().map(|s| s.as_str()), Some("peer_user")); + } + // A TCP listener attests nothing: its peer is empty, which the fabric door refuses to admit. #[test] fn a_tcp_listener_attests_no_peer() {