diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index bc2eed1eb5c..0428cd21bea 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,7 @@ on: mode: description: plan shows membership hunks; apply executes a prior plan artifact; org_actions_observe validates the org credential and refuses on runner-group drift; org_runner_roster_observe reads the organization's self-hosted runner registrations to exhaustion under the same token and refuses unless the roster is complete; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; microvm_host_converge installs the cited Firecracker release on the selected host and refuses the kvm grant by name; guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' vLLM units over the password session, workers before heads; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -494,6 +494,73 @@ jobs: retention-days: 30 if: always() && github.event.inputs.mode == 'r2_object_write_mint' timeout-minutes: 10 + - name: Materialize approval MAC keys on srv1 (SM pinned -> sha256 prefix -> /etc/gunbc-roadmap 0640) + id: approval_keyring_converge + run: | + # 🟡 dissolve-on: gunbc_ci_approval_keyring_converge_invoke - orch-emitted foreign-executor (GitHub Actions run:) credential runner: WIF access token by env, two PINNED secret versions (capability and submission MAC keys) fetched over curl, 0600 files under RUNNER_TEMP with a trap armed BEFORE the credentials touch disk. The pipeline steps are modeled (gunbc_ci_approval_keyring_converge_prelude) but the runner transport itself remains hand-shell; DISSOLVES WHEN bash-emit (#5828 / ROADMAP 6-shell-slice0 / shell-to-intent Phase 2) realizes the credential runner through orchestration emit or typed host_effect_apply without a medium-as-string concat scaffold + set -euo pipefail + umask 077 + HDR_FILE="$RUNNER_TEMP/approval-keyring-auth-header" + RESP_FILE="$RUNNER_TEMP/approval-keyring-sm.json" + CAP_FILE="$RUNNER_TEMP/approval-capability-mac-key" + SUB_FILE="$RUNNER_TEMP/approval-submission-mac-key" + trap 'rm -f "$HDR_FILE" "$RESP_FILE" "$CAP_FILE" "$SUB_FILE"' EXIT + printf 'Authorization: Bearer %s\n' "$WIF_ACCESS_TOKEN" > "$HDR_FILE" + DEST_FILE="$CAP_FILE"; curl -sSf -o "$RESP_FILE" -H @"$HDR_FILE" "https://secretmanager.googleapis.com/v1/projects/gunbai-secrets/secrets/approval-capability-mac-key/versions/1:access"; python3 -c 'import sys,json,base64; sys.stdout.buffer.write(base64.b64decode(json.load(open(sys.argv[1]))["payload"]["data"]))' "$RESP_FILE" > "$DEST_FILE" + chmod 600 "$CAP_FILE" + DEST_FILE="$SUB_FILE"; curl -sSf -o "$RESP_FILE" -H @"$HDR_FILE" "https://secretmanager.googleapis.com/v1/projects/gunbai-secrets/secrets/approval-submission-mac-key/versions/1:access"; python3 -c 'import sys,json,base64; sys.stdout.buffer.write(base64.b64decode(json.load(open(sys.argv[1]))["payload"]["data"]))' "$RESP_FILE" > "$DEST_FILE" + chmod 600 "$SUB_FILE" + export GUNBC_APPROVAL_CAPABILITY_MAC_KEY_FILE="$CAP_FILE" + export GUNBC_APPROVAL_SUBMISSION_MAC_KEY_FILE="$SUB_FILE" + ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/auth/approval_keyring_converge.dag --function approval_keyring_converge_wet + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + FLEET_CONVERGE_EXPECTED_HOST: ${{ github.event.inputs.host }} + if: github.event.inputs.mode == 'approval_keyring_converge' + timeout-minutes: 5 + - name: Mt. Collins unit 1 approval-gated diskless boot (file request, poll, grant, attach, CD handoff) + id: mtcollins1_boot + run: | + # 🟡 dissolve-on: gunbc_ci_mtcollins1_boot_invoke - orch-emitted foreign-executor (GitHub Actions run:) credential runner: WIF access token by env, PINNED BMC and submission-MAC secret versions fetched over curl, 0600 files under RUNNER_TEMP with a trap armed BEFORE the credentials touch disk. SOL hold is gunbc.machine_intake.sol_hold ActivateHeld (its own scaffold). The pipeline steps are modeled (gunbc_ci_mtcollins1_boot_credential_prelude) but the runner transport itself remains hand-shell; DISSOLVES WHEN bash-emit (#5828 / ROADMAP 6-shell-slice0 / shell-to-intent Phase 2) realizes the credential runner through orchestration emit or typed host_effect_apply without a medium-as-string concat scaffold + set -euo pipefail + umask 077 + HDR_FILE="$RUNNER_TEMP/mtcollins1-boot-auth-header" + RESP_FILE="$RUNNER_TEMP/mtcollins1-boot-sm.json" + DEST_FILE="$RUNNER_TEMP/mtcollins1-bmc-credential" + SUB_FILE="$RUNNER_TEMP/approval-submission-mac-key" + SOL_PID_FILE="$RUNNER_TEMP/mtcollins1-sol.pid" + SOL_OUT="$GITHUB_WORKSPACE/artifacts/mtcollins1-boot-sol.capture" + trap 'if [ -f "$SOL_PID_FILE" ]; then kill "$(cat "$SOL_PID_FILE")" 2>/dev/null || true; fi; rm -f "$HDR_FILE" "$RESP_FILE" "$DEST_FILE" "$SUB_FILE" "$SOL_PID_FILE"' EXIT + printf 'Authorization: Bearer %s\n' "$WIF_ACCESS_TOKEN" > "$HDR_FILE" + curl -sSf -o "$RESP_FILE" -H @"$HDR_FILE" "https://secretmanager.googleapis.com/v1/projects/gunbai-secrets/secrets/bmc-mtcollins1-gunbc/versions/2:access"; python3 -c 'import sys,json,base64; sys.stdout.buffer.write(base64.b64decode(json.load(open(sys.argv[1]))["payload"]["data"]))' "$RESP_FILE" > "$DEST_FILE" + chmod 600 "$DEST_FILE" + export GUNBC_HOST_RESET_BMC_CREDENTIAL_FILE="$DEST_FILE" + HOLD_BMC="$DEST_FILE" + DEST_FILE="$SUB_FILE"; curl -sSf -o "$RESP_FILE" -H @"$HDR_FILE" "https://secretmanager.googleapis.com/v1/projects/gunbai-secrets/secrets/approval-submission-mac-key/versions/1:access"; python3 -c 'import sys,json,base64; sys.stdout.buffer.write(base64.b64decode(json.load(open(sys.argv[1]))["payload"]["data"]))' "$RESP_FILE" > "$DEST_FILE"; DEST_FILE="$HOLD_BMC" + chmod 600 "$SUB_FILE" + export GUNBC_APPROVAL_SUBMISSION_MAC_KEY_FILE="$SUB_FILE" + mkdir -p "$GITHUB_WORKSPACE/artifacts" + : > "$SOL_OUT" + export GUNBC_MTCOLLINS1_SOL_CAPTURE="$SOL_OUT" + export GUNBC_MTCOLLINS1_SOL_PID_FILE="$SOL_PID_FILE" + ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/machine_intake/mtcollins1_boot_run.dag --function mtcollins1_boot_wet + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + FLEET_CONVERGE_EXPECTED_HOST: ${{ github.event.inputs.host }} + if: github.event.inputs.mode == 'mtcollins1_boot' + timeout-minutes: 38 + - name: Upload Mt. Collins unit 1 boot SOL capture and receipts + id: mtcollins1_boot_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: mtcollins1-boot-receipts + path: artifacts/mtcollins1-boot-* + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'mtcollins1_boot' + timeout-minutes: 10 - name: "Runner micro-VM guest image: observe base artifacts and their digests" id: guest_image_observe run: | diff --git a/dag/extdeps/bmc/ipmi.dag b/dag/extdeps/bmc/ipmi.dag index efa2c344447..b6c67f55537 100644 --- a/dag/extdeps/bmc/ipmi.dag +++ b/dag/extdeps/bmc/ipmi.dag @@ -102,6 +102,26 @@ service diagnostic.ipmi.Tool { } } + operation ChassisBootDevWithOptions { + input { bmc_host: NonEmptyStr, username: NonEmptyStr, password_file: NonEmptyStr, device: NonEmptyStr, options: NonEmptyStr } + output { stdout: String from "stdout", success: Bool from "exit_success" } + transport shell { argv: ["ipmitool", "-H", "{bmc_host}", "-I", "lanplus", "-U", "{username}", "-f", "{password_file}", "chassis", "bootdev", "{device}", "options={options}"] } + exit { + 0 => Unit + nonzero => String "ipmitool chassis bootdev with options failed" + } + } + + operation SolDeactivate { + input { bmc_host: NonEmptyStr, username: NonEmptyStr, password_file: NonEmptyStr } + output { stdout: String from "stdout", success: Bool from "exit_success" } + transport shell { argv: ["ipmitool", "-H", "{bmc_host}", "-I", "lanplus", "-U", "{username}", "-f", "{password_file}", "sol", "deactivate"] } + exit { + 0 => Unit + nonzero => String "ipmitool sol deactivate failed" + } + } + operation ChassisBootParamGet { input { bmc_host: NonEmptyStr, username: NonEmptyStr, password_file: NonEmptyStr, parameter: NonEmptyStr } output { stdout: String from "stdout", success: Bool from "exit_success" } diff --git a/dag/extdeps/bmc/ipmi_boot_selection.dag b/dag/extdeps/bmc/ipmi_boot_selection.dag new file mode 100644 index 00000000000..caeefe7efcf --- /dev/null +++ b/dag/extdeps/bmc/ipmi_boot_selection.dag @@ -0,0 +1,143 @@ +module extdeps.bmc.ipmi_boot_selection + +import std.types { NonEmptyStr, String } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } +import extdeps.toolchain.types { Architecture, Aarch64 } + +// THE CLI SURFACE IS ipmitool's BOOT-FLAG ENCODER, not the IPMI wire itself. The IPMI 2.0 +// chassis boot parameter has separate valid, persistence and boot-type bits; ipmitool exposes +// them through the final `options=...` argv word. The exact implementation authority is the +// upstream source whose option table names `valid`, `persistent` and `efiboot` and whose +// set-bootdev path sets the valid bit before writing parameter 5. +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "github.com/ipmitool/ipmitool/blob/master/lib/ipmi_chassis.c" + } +} + +type IpmiBootDevice + = IpmiBootPxe + | IpmiBootCdrom + +type IpmiFirmwareBootMode + = IpmiBootLegacy + | IpmiBootEfi + +type IpmiBootPersistence + = IpmiBootOnce + | IpmiBootPersistent + +// A VALID BOOT SELECTION, NOT THREE INDEPENDENT SCALARS. This record is sole_constructor so a +// caller cannot place Aarch64 beside Legacy or manufacture an opaque options word. The only +// constructor currently exported is aarch64_uefi_once_boot_selection: its signature has no mode +// or persistence parameter, hence `(Aarch64, Legacy, Cdrom)` and persistent intake overrides +// have no source spelling at all. A future x86 legacy requirement adds a different constructor +// here; it does not widen this one with a Boolean or raw-string escape hatch. +// +// The wire and read-back spellings travel with the semantic axes because they are one CLI +// serialization. A caller selects `IpmiBootCdrom`; it never authors `cdrom`, +// an `options=` word, or one of the four strings used to adjudicate parameter 5. +// The chassis-bootdev device word is `ipmi_boot_device_label`; the options token is +// `ipmi_boot_selection_options_token`; parameter-5 readback words are +// `ipmi_boot_expected_*_rendering`. None of those strings is stored beside the axes. +type IpmiBootSelection sole_constructor { + architecture: Architecture + device: IpmiBootDevice + firmware_mode: IpmiFirmwareBootMode + persistence: IpmiBootPersistence +} + +fn aarch64_uefi_once_boot_selection(device: IpmiBootDevice) -> IpmiBootSelection { + match device { + IpmiBootPxe => IpmiBootSelection { + architecture: Aarch64, + device: IpmiBootPxe, + firmware_mode: IpmiBootEfi, + persistence: IpmiBootOnce, + } + IpmiBootCdrom => IpmiBootSelection { + architecture: Aarch64, + device: IpmiBootCdrom, + firmware_mode: IpmiBootEfi, + persistence: IpmiBootOnce, + } + } +} + +fn ipmi_boot_device_label(device: IpmiBootDevice) -> NonEmptyStr { + match device { + IpmiBootPxe => "pxe" + IpmiBootCdrom => "cdrom" + } +} + +fn ipmi_boot_mode_label(mode: IpmiFirmwareBootMode) -> NonEmptyStr { + match mode { + IpmiBootLegacy => "legacy" + IpmiBootEfi => "efi" + } +} + +fn ipmi_boot_persistence_label(persistence: IpmiBootPersistence) -> NonEmptyStr { + match persistence { + IpmiBootOnce => "once" + IpmiBootPersistent => "persistent" + } +} + +fn ipmi_boot_selection_label(selection: IpmiBootSelection) -> NonEmptyStr { + join([ + ipmi_boot_device_label(device: selection.device) as String, + "/", + ipmi_boot_mode_label(mode: selection.firmware_mode) as String, + "/", + ipmi_boot_persistence_label(persistence: selection.persistence) as String, + ], "") as NonEmptyStr +} + +fn ipmi_boot_selection_options_token(selection: IpmiBootSelection) -> NonEmptyStr { + match selection.firmware_mode { + IpmiBootEfi => + match selection.persistence { + IpmiBootOnce => "efiboot" + IpmiBootPersistent => "efiboot,persistent" + } + IpmiBootLegacy => + match selection.persistence { + IpmiBootOnce => "valid" + IpmiBootPersistent => "persistent" + } + } +} + +data ipmi_boot_flag_valid_rendering: NonEmptyStr = "Boot Flag Valid" + +fn ipmi_boot_expected_flag_rendering(selection: IpmiBootSelection) -> NonEmptyStr { + match selection.device { + IpmiBootPxe => ipmi_boot_flag_valid_rendering + IpmiBootCdrom => ipmi_boot_flag_valid_rendering + } +} + +fn ipmi_boot_expected_device_rendering(selection: IpmiBootSelection) -> NonEmptyStr { + match selection.device { + IpmiBootPxe => "Force PXE" + IpmiBootCdrom => "Force Boot from CD/DVD" + } +} + +fn ipmi_boot_expected_mode_rendering(selection: IpmiBootSelection) -> NonEmptyStr { + match selection.firmware_mode { + IpmiBootEfi => "BIOS EFI boot" + IpmiBootLegacy => "BIOS PC Compatible (legacy) boot" + } +} + +fn ipmi_boot_expected_persistence_rendering(selection: IpmiBootSelection) -> NonEmptyStr { + match selection.persistence { + IpmiBootOnce => "Options apply to only next boot" + IpmiBootPersistent => "Options apply to all future boots" + } +} diff --git a/dag/extdeps/bmc/megarac.dag b/dag/extdeps/bmc/megarac.dag index 9d6150ab485..5328c19bc1f 100644 --- a/dag/extdeps/bmc/megarac.dag +++ b/dag/extdeps/bmc/megarac.dag @@ -405,14 +405,14 @@ data megarac_ipmi_sel_layout: MegaRacEventLogLayout = MegaRacIpmiSelPayloadPrese // per probe and closing none. Every operation sequence that opens a session owes a close on every // exit, including refusals — an operation that failed still allocated the session that carried it. // -// THE CREDENTIAL IS FORM-ENCODED IN THE SESSION CALL AND NOWHERE ELSE. It reaches argv there, which -// is a real weakness and is stated rather than hidden: curl offers no netrc equivalent for a -// form-encoded POST body, and the alternative spellings (--data @file) put the credential in a file -// whose lifetime this operation does not control. Every operation AFTER the session carries no -// credential at all — the cookie is the bearer — so the exposure is one call, not the whole flow. -// The next-rung repair is a transport that reads a form body from a file the caller materializes -// and removes, which is the same missing mechanism the removed ipmi UserSetPassword operation -// needs. +// THE CREDENTIAL IS READ FROM A CALLER FILE ON OpenSession AND NOWHERE ELSE. curl --data-urlencode +// password@FILE keeps the secret out of argv; every later call carries only the cookie. THE FILE IS +// THE PASSWORD BYTES EXACTLY: curl(1) documents name@file as URL-encoding the file's content +// INCLUDING any newline, so a file ending in \n posts password=...%0A and the controller refuses the +// login as a wrong password with no hint (review 68456). The caller's prelude writes the Secret +// Manager payload bytes verbatim (base64-decoded, no trailer), so the contract lives on the stored +// payload: it is minted without a trailing newline, and boot 22 (run 35447067113) is the executed +// positive control that the stored bytes log in. // // StartMedia's json_body MUST CARRY image_redirection AND ITS ABSENCE IS OPAQUE. The contract note // on the operation row above records that omitting it makes the firmware refuse with codes @@ -423,10 +423,10 @@ data megarac_ipmi_sel_layout: MegaRacEventLogLayout = MegaRacIpmiSelPayloadPrese service megarac.Media { operation OpenSession { - input { bmc_host: NonEmptyStr, username: NonEmptyStr, password: NonEmptyStr, cookie_jar: NonEmptyStr } + input { bmc_host: NonEmptyStr, username: NonEmptyStr, password_file: NonEmptyStr, cookie_jar: NonEmptyStr } output { body: String from "stdout", success: Bool from "exit_success" } transport shell { - argv: ["curl", "--fail-with-body", "-sS", "-k", "-c", "{cookie_jar}", "-X", "POST", "-d", "username={username}", "-d", "password={password}", "https://{bmc_host}/api/session"] + argv: ["curl", "--fail-with-body", "-sS", "-k", "-c", "{cookie_jar}", "-X", "POST", "--data-urlencode", "username={username}", "--data-urlencode", "password@{password_file}", "https://{bmc_host}/api/session"] } exit { 0 => Unit @@ -434,6 +434,19 @@ service megarac.Media { } } + operation ProbeSessionOnMediaRoute { + input { bmc_host: NonEmptyStr, cookie_jar: NonEmptyStr, csrf_token: NonEmptyStr } + output { body: String from "stdout", success: Bool from "exit_success" } + readonly + transport shell { + argv: ["curl", "--fail-with-body", "-sS", "-k", "-b", "{cookie_jar}", "-H", "X-CSRFTOKEN: {csrf_token}", "-w", "\\n%\{http_code}", "https://{bmc_host}/api/settings/media/remote/configurations"] + } + exit { + 0 => Unit + nonzero => String "megarac session probe on the media route failed" + } + } + operation GetMediaGeneral { input { bmc_host: NonEmptyStr, cookie_jar: NonEmptyStr, csrf_token: NonEmptyStr } output { body: String from "stdout", success: Bool from "exit_success" } diff --git a/dag/extdeps/clock/clock.dag b/dag/extdeps/clock/clock.dag index 6e85d512063..81a65ab0e95 100644 --- a/dag/extdeps/clock/clock.dag +++ b/dag/extdeps/clock/clock.dag @@ -17,11 +17,13 @@ data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { } // feature:offset-nat-carrier — dissolve-on: when the shell transport supports -// Measure (Second) interpolation, convert TimestampOffset to -// accept offset: Second and unwrap to Nat only in the transport binder. -// Meanwhile the operation accepts offset: Nat — still a bare scalar, but at -// least the Nat refinement (Int where non_negative) encodes the sign constraint -// that Int lacked, and the parameter name names the unit as a second-best signal. +// Measure (Second) interpolation, convert Clock.TimestampOffset +// AND Clock.TimestampAdd to accept offset: Second and unwrap to Nat only in the +// transport binder. Meanwhile both operations accept offset: Nat — still a bare +// scalar, but at least the Nat refinement (Int where non_negative) encodes the +// sign constraint that Int lacked, and the parameter name names the unit as a +// second-best signal. TimestampAdd is the same carrier family as TimestampOffset +// (seconds as Nat at the operation boundary), not a second unit surface. service Clock { operation Now { @@ -68,6 +70,17 @@ service Clock { } } + operation TimestampAdd { + input { timestamp: Timestamp, offset: Nat } + output { result: Timestamp from "stdout" } + readonly + transport shell { argv: ["date", "-u", "-d", "{timestamp} +{offset} seconds", "+%Y-%m-%dT%H:%M:%SZ"] } + exit { + 0 => Unit + nonzero => String "date add failed" + } + } + operation TimestampToUnixSecs { input { timestamp: Timestamp } output { unix_secs: String from "stdout" } diff --git a/dag/extdeps/github/actions_environment.dag b/dag/extdeps/github/actions_environment.dag index 399713dca70..0fb3a3ab41c 100644 --- a/dag/extdeps/github/actions_environment.dag +++ b/dag/extdeps/github/actions_environment.dag @@ -60,6 +60,11 @@ data github_run_id_variable_name: String = "GITHUB_RUN_ID" data github_run_attempt_variable_name: String = "GITHUB_RUN_ATTEMPT" data github_runner_name_variable_name: String = "RUNNER_NAME" +// RUNNER_TEMP is likewise a runner-supplied directory, not a run coordinate: the cited variables +// reference defines it as the per-job temporary directory. Consumers that write attempt-owned +// files during a workflow read this name here rather than re-spelling the variable. +data github_runner_temp_variable_name: String = "RUNNER_TEMP" + // THE RUN'S REPOSITORY AND WORKFLOW REFERENCE, carried for the same reason the coordinates above // are. GITHUB_REPOSITORY is `owner/repo`; GITHUB_WORKFLOW_REF is // `owner/repo/.github/workflows/@`, the one runner-supplied variable that names the diff --git a/dag/extdeps/tools/coreutils_stat.dag b/dag/extdeps/tools/coreutils_stat.dag index 099ed125c61..b1e1526efcf 100644 --- a/dag/extdeps/tools/coreutils_stat.dag +++ b/dag/extdeps/tools/coreutils_stat.dag @@ -3,7 +3,8 @@ module extdeps.tools.coreutils_stat import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } import extdeps.access.posix { FileOwnership } -import std.types { Bool, Int, List, NonEmptyStr, String } +import std.types { Bool, Int, List, NonEmptyStr, Unit } +import std.string_type { String } import std.algebra { trim } // OBSERVING WHO OWNS A PATH, as a modeled operation. extdeps.access.posix owns the CONCEPT -- diff --git a/dag/gunbc/actions_run_binding.dag b/dag/gunbc/actions_run_binding.dag index b3ca4274d13..23f2d4513af 100644 --- a/dag/gunbc/actions_run_binding.dag +++ b/dag/gunbc/actions_run_binding.dag @@ -54,6 +54,13 @@ fn actions_variable_read(name: String) -> ActionsVariableRead { } } +fn actions_variable_trimmed(name: NonEmptyStr) -> String { + match actions_variable_read(name: name as String) { + ActionsVariablePresent { value: v } => v + ActionsVariableAbsent { variable: _, detail: _ } => "" + } +} + fn observe_actions_run_binding() -> ActionsRunBindingObservation { match actions_variable_read(name: github_repository_variable_name) { ActionsVariableAbsent { variable: v, detail: d } => ActionsRunUnbound { variable: v, detail: d } @@ -89,6 +96,8 @@ fn observe_actions_run_binding() -> ActionsRunBindingObservation { // transaction, so a receipt produced by any of the four runs is comparable to any other. data rlm_expected_revision_env_name: String = "RLM_EXPECTED_REVISION" +data fleet_converge_expected_host_env_name: String = "FLEET_CONVERGE_EXPECTED_HOST" + type ExpectedRevisionRead = ExpectedRevisionPresent { revision: String } | ExpectedRevisionAbsent { variable: String, detail: String } diff --git a/dag/gunbc/auth/approval_decision_store.dag b/dag/gunbc/auth/approval_decision_store.dag index 3a3906ec01d..0fd37cd1ff6 100644 --- a/dag/gunbc/auth/approval_decision_store.dag +++ b/dag/gunbc/auth/approval_decision_store.dag @@ -47,6 +47,7 @@ import gunbc.auth.approval_capability { ProposeDeny, approval_capability_signing_input, proposed_decision_name, + utc_instant_is_canonical, } import gunbc.auth.approval_broker { BrokerDecision, IdentityAttribution, HeaderAttributedToProxy, HeaderUnattributed, @@ -93,8 +94,9 @@ import gunbc.auth.approval_broker { // THE MAC KEY IS A SECRET MANAGER SECRET, MINTED 2026-09-16 UNDER OPERATOR AUTHORIZATION // ("yes you can mint it"): 32 random bytes rendered as 64 lowercase hex, version 1 ENABLED, with // roles/secretmanager.secretAccessor granted to fleet-cloud-convergence@gunbai-secrets only. The -// value never touched this repository or a session transcript; its sha256 prefix 113a3274bd95 is -// how a materialized copy is checked without printing it. Version is PINNED because a keyring that +// value never touched this repository or a session transcript; a materialized copy is checked +// against gunbc.auth.approval_keyring_pins approval_mac_key_payload_sha256_prefix without printing +// the key. Version is PINNED because a keyring that // followed `latest` would let a rotation invalidate every link in flight (approval_capability: // "key_id -- which key to check, so rotation does not invalidate what is in flight"). data approval_mac_key_secret_ref: SecretRef = secret_ref_pin_version( @@ -364,17 +366,24 @@ fn parse_stored_request(v: JsonValue) -> StoredRecordParse { Absent => StoredRecordUnreadable { detail: "stored request lacks expires_at" } Present { value: expires } => match json_field_string(v: v, key: "key_id") { Absent => StoredRecordUnreadable { detail: "stored request lacks key_id" } - Present { value: kid } => StoredRequestParsed { request: StoredApprovalRequest { - escalation_id: e as NonEmptyStr, - request_revision: rev as NonEmptyStr, - attempt: att as AttemptIdentity, - requester: req as NonEmptyStr, - purpose: purpose as NonEmptyStr, - destructive: destructive, - issued_at: issued, - expires_at: expires, - key_id: kid as MacKeyId, - } } + Present { value: kid } => + if !utc_instant_is_canonical(t: issued as Timestamp) { + StoredRecordUnreadable { detail: "stored request issued_at is not a canonical UTC instant" } + } else if !utc_instant_is_canonical(t: expires as Timestamp) { + StoredRecordUnreadable { detail: "stored request expires_at is not a canonical UTC instant" } + } else { + StoredRequestParsed { request: StoredApprovalRequest { + escalation_id: e as NonEmptyStr, + request_revision: rev as NonEmptyStr, + attempt: att as AttemptIdentity, + requester: req as NonEmptyStr, + purpose: purpose as NonEmptyStr, + destructive: destructive, + issued_at: issued, + expires_at: expires, + key_id: kid as MacKeyId, + } } + } } } } @@ -396,26 +405,38 @@ fn parse_stored_decision(v: JsonValue, kind: String) -> StoredRecordParse { Present { value: by } => match json_field_string(v: v, key: "decided_at") { Absent => StoredRecordUnreadable { detail: "stored decision lacks decided_at" } Present { value: at } => - if kind == "approved" { + if !utc_instant_is_canonical(t: at as Timestamp) { + StoredRecordUnreadable { detail: "stored decision decided_at is not a canonical UTC instant" } + } else if kind == "approved" { match json_field_string(v: v, key: "obligation_authority") { Absent => StoredRecordUnreadable { detail: "stored approval lacks obligation_authority" } Present { value: oa } => match json_field_string(v: v, key: "obligation_intent_revision") { Absent => StoredRecordUnreadable { detail: "stored approval lacks obligation_intent_revision" } Present { value: oi } => match json_field_string(v: v, key: "obligation_execute_by") { Absent => StoredRecordUnreadable { detail: "stored approval lacks obligation_execute_by" } - Present { value: ob } => StoredDecisionParsed { decision: BrokerApproved { - escalation_id: e as NonEmptyStr, revision: rev as NonEmptyStr, decided_by: by as NonEmptyStr, decided_at: at, - obligation: ExecutionObligation { authority: oa as NonEmptyStr, intent_revision: oi as NonEmptyStr, execute_by: ob }, - } } + Present { value: ob } => + if !utc_instant_is_canonical(t: ob as Timestamp) { + StoredRecordUnreadable { detail: "stored approval obligation_execute_by is not a canonical UTC instant" } + } else { + StoredDecisionParsed { decision: BrokerApproved { + escalation_id: e as NonEmptyStr, revision: rev as NonEmptyStr, decided_by: by as NonEmptyStr, decided_at: at, + obligation: ExecutionObligation { authority: oa as NonEmptyStr, intent_revision: oi as NonEmptyStr, execute_by: ob }, + } } + } } } } } else { match json_field_string(v: v, key: "reason") { Absent => StoredRecordUnreadable { detail: "stored denial lacks reason" } - Present { value: why } => StoredDecisionParsed { decision: BrokerDenied { - escalation_id: e as NonEmptyStr, revision: rev as NonEmptyStr, decided_by: by as NonEmptyStr, decided_at: at, reason: why as NonEmptyStr, - } } + Present { value: why } => + if (why as String).contains("\n") || (why as String).contains("\r") { + StoredRecordUnreadable { detail: "stored denial reason is not a single kv line" } + } else { + StoredDecisionParsed { decision: BrokerDenied { + escalation_id: e as NonEmptyStr, revision: rev as NonEmptyStr, decided_by: by as NonEmptyStr, decided_at: at, reason: why as NonEmptyStr, + } } + } } } } @@ -699,6 +720,34 @@ fn request_revision_of(request: AuthorizationRequest) -> NonEm serialize_content_hash(hash: request.intent_hash) } +fn grant_from_approved_decision( + request: AuthorizationRequest, + revision: NonEmptyStr, + decided_by: NonEmptyStr, + decided_at: Timestamp, + execute_by: Timestamp, +) -> GrantDerivation { + let want = request_revision_of(request: request) + if revision != want { + GrantForAnotherIntent { requested_revision: want, decided_revision: revision } + } else { + GrantDerived { authorization: AuthorizationGranted { + grant: OperatorGrant { + escalation_id: request.escalation_id, + scopes: request.scopes, + subject: request.subject, + purpose: request.purpose, + attempt: request.attempt, + intent_hash: request.intent_hash, + granted_by: decided_by, + granted_at: decided_at, + expires_at: execute_by, + }, + claim: Unclaimed, + } } + } +} + fn grant_from_store(root: NonEmptyStr, request: AuthorizationRequest) -> GrantDerivation { let revision = request_revision_of(request: request) match observe_escalation(root: root, escalation_id: request.escalation_id) { @@ -707,23 +756,16 @@ fn grant_from_store(root: NonEmptyStr, request: AuthorizationRequest GrantStillPending { escalation_id: request.escalation_id } EscalationDecided { decision: d } => match d { BrokerApproved { escalation_id: e, revision: r, decided_by: by, decided_at: at, obligation: o } => - if e != request.escalation_id || r != revision { + if e != request.escalation_id { GrantForAnotherIntent { requested_revision: revision, decided_revision: r } } else { - GrantDerived { authorization: AuthorizationGranted { - grant: OperatorGrant { - escalation_id: request.escalation_id, - scopes: request.scopes, - subject: request.subject, - purpose: request.purpose, - attempt: request.attempt, - intent_hash: request.intent_hash, - granted_by: by, - granted_at: at, - expires_at: o.execute_by, - }, - claim: Unclaimed, - } } + grant_from_approved_decision( + request: request, + revision: r, + decided_by: by, + decided_at: at, + execute_by: o.execute_by, + ) } BrokerDenied { escalation_id: e, revision: r, decided_by: by, decided_at: at, reason: why } => if e != request.escalation_id || r != revision { diff --git a/dag/gunbc/auth/approval_keyring_converge.dag b/dag/gunbc/auth/approval_keyring_converge.dag new file mode 100644 index 00000000000..3ccb2ef3c65 --- /dev/null +++ b/dag/gunbc/auth/approval_keyring_converge.dag @@ -0,0 +1,301 @@ +module gunbc.auth.approval_keyring_converge + +import std.types { Bool, NonEmptyStr, String, List, FilePath } +import std.algebra { trim } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import std.resources { Network } +import gunbc.auth.approval_decision_store { approval_mac_key_path, approval_decision_store_root } +import gunbc.durable_cas_file_store { authorization_claim_store_root } +import gunbc.auth.approval_request_submission { + approval_submission_mac_key_path, +} +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.tools.sha256sum { sha256sum_file_digest_via_shell, Sha256FileDigest, Sha256FileDigestUnavailable } +import extdeps.sudo.elevation { sudo_elevate } +import gunbc.fleet_intent_network { operator_host_srv1 } +import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, fleet_converge_expected_host_env_name, actions_variable_trimmed } +import gunbc.auth.approval_keyring_pins { + approval_submission_mac_key_path_env, + approval_capability_mac_key_path_env, + approval_mac_key_payload_sha256_prefix, + approval_submission_mac_key_payload_sha256_prefix, +} +import gunbc.runner_host_grants { executor_bootstrap_principal } +import gunbc.fleet_posix_accounts { fleet_posix_root_user, fleet_posix_operator_user, fleet_posix_ci_runner_user, fleet_usermod_binary_path } +import gunbc.auth.approval_ntfy_deployment { approval_ntfy_principal_name } +import gunbc.live_deploy.spec { gunbc_roadmap_unit_name } +import extdeps.systemd.systemctl { systemctl_restart_argv } +import gunbc.fleet_reach_endpoint { fleet_probe_endpoint_for } +import gunbc.fleet_known_hosts_anchor { SshTarget, FleetSshExecutionContext } +import gunbc.fleet_ssh_locus { prepare_fleet_ssh_agent_context, FleetSshContextReady, FleetSshContextRefused } +import gunbc.typed_argv_exec { typed_argv_exec_over_fleet_ssh, TypedArgvExecConverged, TypedArgvExecRefused } +import gunbc.file_access { FileTreeScope } +import gunbc.typed_remote_file_write { + typed_remote_file_write_seal, converge_typed_remote_file, + RemoteFileModeOwnerReadWriteGroupRead, RemoteFileViaNonInteractiveSudo, + TypedRemoteFileConverged, TypedRemoteFileConvergenceRefused, +} + +data approval_keyring_config_dir: NonEmptyStr = "/etc/gunbc-roadmap" + +type KeyringSha256Compare + = Sha256PrefixMatches { prefix: NonEmptyStr } + | Sha256PrefixMismatch { expected: NonEmptyStr, observed: NonEmptyStr } + | Sha256Unavailable { detail: NonEmptyStr } + +fn sha256_prefix_compare(observed_hex: String, expected_prefix: NonEmptyStr) -> KeyringSha256Compare { + let n = expected_prefix.length() + if observed_hex.length() < n { + Sha256Unavailable { detail: "observed digest shorter than the modeled prefix" } + } else { + let prefix = substring(s: observed_hex, start: 0, end: n) + let expected = expected_prefix as String + if prefix == expected { + Sha256PrefixMatches { prefix: expected_prefix } + } else { + Sha256PrefixMismatch { + expected: expected_prefix, + observed: prefix as NonEmptyStr, + } + } + } +} + +fn approval_mac_key_digest_standing(observed_hex: String, expected_prefix: NonEmptyStr, role: String) -> ProcessExit { + match sha256_prefix_compare(observed_hex: observed_hex, expected_prefix: expected_prefix) { + Sha256PrefixMatches { prefix: _ } => ExitSuccess + Sha256PrefixMismatch { expected: e, observed: o } => + exit_failure(reason: concat("approval keyring: ", concat(role, concat(" MAC key sha256 prefix mismatch (expected ", concat(e as String, concat(", observed ", o as String)), ")")))) + Sha256Unavailable { detail: d } => + exit_failure(reason: concat("approval keyring: ", concat(role, concat(" MAC key digest unusable: ", d as String)))) + } +} + +fn approval_keyring_staged_digest_ok(path: NonEmptyStr, expected_prefix: NonEmptyStr, role: String) -> ProcessExit { + match sha256sum_file_digest_via_shell(path: path) { + Sha256FileDigestUnavailable { path: _, reason: r } => + exit_failure(reason: concat("approval keyring: could not digest the staged ", concat(role, concat(" MAC key: ", r)))) + Sha256FileDigest { digest: d } => + approval_mac_key_digest_standing(observed_hex: d.hex as String, expected_prefix: expected_prefix, role: role) + } +} + +fn keyring_host_must_be_srv1(host: String) -> Bool { + host == operator_host_srv1 as String +} + +fn approval_keyring_converge_refuses_off_srv1(host: String) -> ProcessExit { + if keyring_host_must_be_srv1(host: host) { + ExitSuccess + } else { + exit_failure(reason: concat("approval keyring converge refuses host '", concat(host, concat("': the files live on the machine that serves /approve, which is ", operator_host_srv1 as String)))) + } +} + +data approval_keyring_converge_attempt_raw: String = "approval-keyring-converge-ci" + +data approval_keyring_etc_scope: FileTreeScope = FileTreeScope { root_path: "/etc" as NonEmptyStr } + +fn keyring_elevated_argv(command: List) -> List { + let inv = sudo_elevate(command: command) + concat([inv.bin_path], inv.args) +} + +fn keyring_mkdir_argv() -> List { + [ + "install", + "-d", + "-m", + "0750", + "-o", + fleet_posix_root_user.name as String, + "-g", + fleet_posix_operator_user.name as String, + approval_keyring_config_dir as String, + ] +} + +fn store_root_mkdir_argv() -> List { + [ + "install", + "-d", + "-m", + "0700", + "-o", + fleet_posix_operator_user.name as String, + "-g", + fleet_posix_operator_user.name as String, + approval_decision_store_root as String, + ] +} + +fn claim_store_root_mkdir_argv() -> List { + [ + "install", + "-d", + "-m", + "0700", + "-o", + fleet_posix_ci_runner_user.name as String, + "-g", + fleet_posix_ci_runner_user.name as String, + authorization_claim_store_root as String, + ] +} + +fn keyring_chown_argv(path: NonEmptyStr) -> List { + ["chown", concat(fleet_posix_root_user.name as String, concat(":", fleet_posix_operator_user.name as String)), path as String] +} + +fn ntfy_group_admit_argv() -> List { + [ + fleet_usermod_binary_path as String, + "-aG", + approval_ntfy_principal_name as String, + fleet_posix_operator_user.name as String, + ] +} + +fn roadmap_unit_restart_argv() -> List { + systemctl_restart_argv(unit: gunbc_roadmap_unit_name) +} + +fn keyring_ssh_target() -> SshTarget { + SshTarget { + endpoint: fleet_probe_endpoint_for(host: operator_host_srv1 as String) as NonEmptyStr, + principal: executor_bootstrap_principal.login, + } +} + +fn keyring_elevated_exec( + target: SshTarget, + context: FleetSshExecutionContext, + command: List, + step: String, +) -> ProcessExit + uses net: Network +{ + match typed_argv_exec_over_fleet_ssh(target: target, context: context, argv: keyring_elevated_argv(command: command)) { + TypedArgvExecRefused { reason: why } => + exit_failure(reason: concat("approval keyring: ", concat(step, concat(" refused: ", why)))) + TypedArgvExecConverged { result: r } => + if r.success { + ExitSuccess + } else { + exit_failure(reason: concat("approval keyring: ", concat(step, concat(" exit=", concat(to_string(r.exit_code), concat(" ", trim(s: r.stderr))))))) + } + } +} + +fn keyring_ensure_dirs( + target: SshTarget, + context: FleetSshExecutionContext, +) -> ProcessExit + uses net: Network +{ + match keyring_elevated_exec(target: target, context: context, command: keyring_mkdir_argv(), step: "mkdir") { + ExitSuccess => + match keyring_elevated_exec(target: target, context: context, command: store_root_mkdir_argv(), step: "store-root-mkdir") { + ExitSuccess => + keyring_elevated_exec(target: target, context: context, command: claim_store_root_mkdir_argv(), step: "claim-store-root-mkdir") + other => other + } + other => other + } +} + +fn keyring_install_file( + target: SshTarget, + context: FleetSshExecutionContext, + dest: NonEmptyStr, + content: String, + step: String, +) -> ProcessExit + uses net: Network +{ + match converge_typed_remote_file( + target: target, + context: context, + admission: typed_remote_file_write_seal(path: dest as FilePath, content: content, allowed_scope: approval_keyring_etc_scope), + mode: RemoteFileModeOwnerReadWriteGroupRead, + privilege: RemoteFileViaNonInteractiveSudo, + ) { + TypedRemoteFileConvergenceRefused { step: st, reason: why } => + exit_failure(reason: concat("approval keyring: ", concat(step, concat(" ", concat(st, concat(": ", why)))))) + TypedRemoteFileConverged { converged: _ } => + keyring_elevated_exec(target: target, context: context, command: keyring_chown_argv(path: dest), step: concat(step, "-chown")) + } +} + +fn approval_keyring_converge_on_host() -> ProcessExit + uses net: Network +{ + let cap_path = actions_variable_trimmed(name: approval_capability_mac_key_path_env) + let sub_path = actions_variable_trimmed(name: approval_submission_mac_key_path_env) + if cap_path == "" { + exit_failure(reason: concat("approval keyring: ", concat(approval_capability_mac_key_path_env as String, " is unset; the capability key stays in the runner temp until the administrator write"))) + } else if sub_path == "" { + exit_failure(reason: concat("approval keyring: ", concat(approval_submission_mac_key_path_env as String, " is unset; the submission key stays in the runner temp until the administrator write"))) + } else { + let cap = Filesystem.Read(path: cap_path) + let sub = Filesystem.Read(path: sub_path) + if !cap.success { + exit_failure(reason: concat("approval keyring: staged capability MAC key unreadable: ", cap.error)) + } else if !sub.success { + exit_failure(reason: concat("approval keyring: staged submission MAC key unreadable: ", sub.error)) + } else { + match approval_keyring_staged_digest_ok(path: cap_path as NonEmptyStr, expected_prefix: approval_mac_key_payload_sha256_prefix, role: "capability") { + ExitSuccess => + match approval_keyring_staged_digest_ok(path: sub_path as NonEmptyStr, expected_prefix: approval_submission_mac_key_payload_sha256_prefix, role: "submission") { + ExitSuccess => + match prepare_fleet_ssh_agent_context(attempt_raw: approval_keyring_converge_attempt_raw) { + FleetSshContextRefused { cause: c } => exit_failure(reason: concat("approval keyring: ", c)) + FleetSshContextReady { context: context, receipt: _ } => { + let target = keyring_ssh_target() + match keyring_elevated_exec(target: target, context: context, command: ["true"], step: "administrator-privilege") { + ExitSuccess => + match keyring_ensure_dirs(target: target, context: context) { + ExitSuccess => + match keyring_install_file(target: target, context: context, dest: approval_mac_key_path, content: cap.content, step: "capability-key") { + ExitSuccess => + match keyring_install_file(target: target, context: context, dest: approval_submission_mac_key_path, content: sub.content, step: "submission-key") { + ExitSuccess => + match keyring_elevated_exec(target: target, context: context, command: ntfy_group_admit_argv(), step: "ntfy-group-admit") { + ExitSuccess => + match keyring_elevated_exec(target: target, context: context, command: roadmap_unit_restart_argv(), step: "roadmap-unit-restart") { + ExitSuccess => ExitSuccess + other => other + } + other => other + } + other => other + } + other => other + } + other => other + } + other => other + } + } + } + other => other + } + other => other + } + } + } +} + +fn approval_keyring_converge_wet() -> ProcessExit + uses net: Network +{ + match actions_variable_read(name: fleet_converge_expected_host_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => + exit_failure(reason: concat("approval keyring converge: ", concat(v, concat(" ", concat(d, "; one host per run, named by the dispatch"))))) + ActionsVariablePresent { value: host } => + match approval_keyring_converge_refuses_off_srv1(host: host) { + ExitSuccess => approval_keyring_converge_on_host() + other => other + } + } +} diff --git a/dag/gunbc/auth/approval_keyring_pins.dag b/dag/gunbc/auth/approval_keyring_pins.dag new file mode 100644 index 00000000000..a85bf1da2e4 --- /dev/null +++ b/dag/gunbc/auth/approval_keyring_pins.dag @@ -0,0 +1,22 @@ +module gunbc.auth.approval_keyring_pins + +import std.types { String, NonEmptyStr } +import gunbc.auth.approval_decision_store { approval_mac_key_secret_ref } +import gunbc.auth.approval_request_submission { approval_submission_mac_key_secret_ref } +import extdeps.cloud.gcp.secret_ref { secret_ref_access_url } + +data approval_submission_mac_key_path_env: NonEmptyStr = "GUNBC_APPROVAL_SUBMISSION_MAC_KEY_FILE" + +data approval_capability_mac_key_path_env: NonEmptyStr = "GUNBC_APPROVAL_CAPABILITY_MAC_KEY_FILE" + +data approval_mac_key_payload_sha256_prefix: NonEmptyStr = "113a3274bd95" + +data approval_submission_mac_key_payload_sha256_prefix: NonEmptyStr = "01f19b2e2c7a" + +fn approval_capability_mac_access_url() -> String { + secret_ref_access_url(ref: approval_mac_key_secret_ref) +} + +fn approval_submission_mac_access_url() -> String { + secret_ref_access_url(ref: approval_submission_mac_key_secret_ref) +} diff --git a/dag/gunbc/auth/approval_request_client.dag b/dag/gunbc/auth/approval_request_client.dag new file mode 100644 index 00000000000..cafb82c65a7 --- /dev/null +++ b/dag/gunbc/auth/approval_request_client.dag @@ -0,0 +1,118 @@ +module gunbc.auth.approval_request_client + +import std.types { Bool, NonEmptyStr, String, Timestamp, Secret } +import std.scoped_authorization { AuthorizationRequest } +import extdeps.crypto.mac { + MacKey, MacSigned, MacKeyMaterialNotHex, HmacSha256, MacKeyId, +} +import gunbc.auth.approval_status_wire { + FiledApprovalStatus, StatusNotFiled, StatusPending, StatusApproved, StatusDenied, StatusUnreadable, +} +import gunbc.auth.approval_decision_store { + StoredApprovalRequest, request_revision_of, approval_mac_key_id, +} +import gunbc.auth.approval_request_submission { + approval_submission_mac_key_id, sign_submission, +} +import gunbc.fleet_posix_accounts { fleet_operator_email } +import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } + +data approval_request_submit_path: NonEmptyStr = "/approvals" + +fn approval_loopback_origin() -> NonEmptyStr { + let instance = srv1_live_dashboard_instance() + concat( + concat("http://", instance.listen_host as String), + concat(":", to_string(instance.listen_port)), + ) as NonEmptyStr +} + +fn approval_submit_url() -> NonEmptyStr { + concat(approval_loopback_origin() as String, approval_request_submit_path as String) as NonEmptyStr +} + +fn approval_status_url(escalation_id: NonEmptyStr) -> NonEmptyStr { + concat( + concat(approval_loopback_origin() as String, concat(approval_request_submit_path as String, "/")), + escalation_id as String, + ) as NonEmptyStr +} + +fn stored_request_from_authorization( + request: AuthorizationRequest, + issued_at: Timestamp, + expires_at: Timestamp, +) -> StoredApprovalRequest { + StoredApprovalRequest { + escalation_id: request.escalation_id, + request_revision: request_revision_of(request: request), + attempt: request.attempt, + requester: fleet_operator_email, + purpose: request.purpose, + destructive: request.destructive, + issued_at: issued_at, + expires_at: expires_at, + key_id: approval_mac_key_id, + } +} + +type SubmissionMac + = SubmissionMacReady { tag_hex: NonEmptyStr } + | SubmissionMacKeyNotHex { key_id: MacKeyId } + +fn sign_stored_request(key_material: String, request: StoredApprovalRequest) -> SubmissionMac { + let key = MacKey { + id: approval_submission_mac_key_id, + suite: HmacSha256, + material: key_material as Secret, + } + match sign_submission(key: key, request: request) { + MacKeyMaterialNotHex { key_id: k } => SubmissionMacKeyNotHex { key_id: k } + MacSigned { tag: t } => SubmissionMacReady { tag_hex: t.hex } + } +} + +type ApprovalPollStanding + = PollStillPending + | PollApproved { + revision: NonEmptyStr + decided_by: NonEmptyStr + decided_at: Timestamp + execute_by: Timestamp + } + | PollDenied { + revision: NonEmptyStr + decided_by: NonEmptyStr + decided_at: Timestamp + reason: NonEmptyStr + } + | PollExpired + | PollNotFiled + | PollUnreadable { detail: NonEmptyStr } + +fn approval_poll_standing( + status: FiledApprovalStatus, + observed_at: Timestamp, + expires_at: Timestamp, +) -> ApprovalPollStanding { + if observed_at > expires_at { + match status { + StatusApproved { revision: r, decided_by: by, decided_at: at, execute_by: until } => + PollApproved { revision: r, decided_by: by, decided_at: at, execute_by: until } + StatusDenied { revision: r, decided_by: by, decided_at: at, reason: why } => + PollDenied { revision: r, decided_by: by, decided_at: at, reason: why } + StatusUnreadable { detail: d } => PollUnreadable { detail: d } + _ => PollExpired + } + } else { + match status { + StatusNotFiled => PollNotFiled + StatusPending { expires_at: _ } => PollStillPending + StatusApproved { revision: r, decided_by: by, decided_at: at, execute_by: until } => + PollApproved { revision: r, decided_by: by, decided_at: at, execute_by: until } + StatusDenied { revision: r, decided_by: by, decided_at: at, reason: why } => + PollDenied { revision: r, decided_by: by, decided_at: at, reason: why } + StatusUnreadable { detail: d } => PollUnreadable { detail: d } + } + } +} diff --git a/dag/gunbc/auth/approval_request_submission.dag b/dag/gunbc/auth/approval_request_submission.dag index 2d5569adc24..cfb7cd58492 100644 --- a/dag/gunbc/auth/approval_request_submission.dag +++ b/dag/gunbc/auth/approval_request_submission.dag @@ -15,6 +15,10 @@ import gunbc.auth.approval_notification { approval_notification } import gunbc.auth.approval_ntfy_deployment import gunbc.auth.approval_ntfy_deployment { srv1_tailnet_base_url_from } import std.algebra { trim } +import gunbc.auth.approval_status_wire { + FiledApprovalStatus, StatusNotFiled, StatusPending, StatusApproved, StatusDenied, StatusUnreadable, + filed_approval_status_text, submission_filed_text, +} import gunbc.auth.approval_decision_store { ApprovalKeyringRead, ApprovalKeyLoaded, ApprovalKeyringRefused, ApprovalKeyringRefusal, StoredApprovalRequest, stored_request_json, parse_stored_record, StoredRequestParsed, StoredDecisionParsed, StoredRecordUnreadable, @@ -53,7 +57,8 @@ import gunbc.auth.approval_decision_store { // MINTED 2026-09-17 THROUGH gunbc.auth.approval_mac_key_provision under the operator's token: the // container was created for attempt approval-mac-keys-2026-09-16, version 1 added from a -// digested snapshot and read back (payload sha256 prefix 01f19b2e2c7a). Pinned to that version +// digested snapshot and read back (payload sha256 prefix owned by +// gunbc.auth.approval_keyring_pins approval_submission_mac_key_payload_sha256_prefix). Pinned to that version // for the same reason the capability key is: a keyring following `latest` would let a rotation // invalidate filings in flight, and the provisioning run refuses to add a version beside a pin. data approval_submission_mac_key_secret_ref: SecretRef = secret_ref_pin_version( @@ -247,6 +252,23 @@ type SubmissionOutcome | SubmissionRefused { refusal: SubmissionRefusal } | SubmissionFiledCapabilityKeyUnavailable { escalation_id: NonEmptyStr, cause: ApprovalKeyringRefusal } +fn submission_outcome_admits_poll(o: SubmissionOutcome) -> Bool { + match o { + SubmissionFiledAndNotified { escalation_id: _, message_id: _ } => true + SubmissionFiledNotNotified { escalation_id: _, publish: _ } => true + SubmissionFiledPublisherTokenUnavailable { escalation_id: _, detail: _ } => true + SubmissionFiledLinksNotIssued { escalation_id: _, key_id: _ } => false + SubmissionAlreadyFiled { escalation_id: _ } => true + SubmissionFiledCapabilityKeyUnavailable { escalation_id: _, cause: _ } => false + SubmissionStoreRefused { detail: _ } => false + SubmissionRefused { refusal: _ } => false + } +} + +fn submission_outcome_http_body(o: SubmissionOutcome) -> String { + submission_filed_text(filed: submission_outcome_admits_poll(o: o), reason: concat("approval submission: ", submission_outcome_reason(o: o))) +} + fn submission_outcome_reason(o: SubmissionOutcome) -> String { match o { SubmissionFiledAndNotified { escalation_id: e, message_id: m } => join(["filed ", e as String, " and notified (ntfy message ", m as String, ")"], "") @@ -376,28 +398,37 @@ fn submit_parsed_submission( // WHAT A POLLING RUN MAY LEARN OVER HTTP: the slot's standing and, once decided, who decided and // when. Not the purpose, not the requester -- the run filed those and does not need them back -- -// and never a link. The grant itself is derived by the run from the decision it already knows it -// is waiting for, through grant_from_store's join, once part D moves that read behind this route. +// and never a link. The grant join is grant_from_approved_decision over these fields: the runner +// never opens the 0600 store. // THE STATUS A POLLING RUN READS, with its wire status beside it: an answer about the slot -- // not filed, pending, decided -- is 200, and a store that could not be read is 503, because the // HTTP status is the channel the filer reads first and a failed read on the success channel is -// a warning where a refusal was owed. +// a warning where a refusal was owed. The BODY is the kv grammar of approval_status_wire. type EscalationStatus { status: HttpStatus body: String } -fn escalation_status_of(slot: EscalationSlot) -> EscalationStatus { +fn filed_status_of_slot(slot: EscalationSlot) -> FiledApprovalStatus { match slot { - EscalationNotFiled => EscalationStatus { status: 200, body: "standing=not-filed\n" } - EscalationUnreadable { detail: d } => EscalationStatus { status: 503, body: join(["standing=unreadable\ndetail=", d as String, "\n"], "") } - EscalationPending { request: r } => EscalationStatus { status: 200, body: join(["standing=pending\nexpires_at=", r.expires_at, "\n"], "") } - EscalationDecided { decision: d } => EscalationStatus { status: 200, body: match d { + EscalationNotFiled => StatusNotFiled + EscalationUnreadable { detail: d } => StatusUnreadable { detail: d } + EscalationPending { request: r } => StatusPending { expires_at: r.expires_at } + EscalationDecided { decision: d } => match d { BrokerApproved { escalation_id: _, revision: rev, decided_by: by, decided_at: at, obligation: o } => - join(["standing=approved\nrevision=", rev as String, "\ndecided_by=", by as String, "\ndecided_at=", at, "\nexecute_by=", o.execute_by, "\n"], "") + StatusApproved { revision: rev, decided_by: by, decided_at: at, execute_by: o.execute_by } BrokerDenied { escalation_id: _, revision: rev, decided_by: by, decided_at: at, reason: why } => - join(["standing=denied\nrevision=", rev as String, "\ndecided_by=", by as String, "\ndecided_at=", at, "\nreason=", why as String, "\n"], "") - } } + StatusDenied { revision: rev, decided_by: by, decided_at: at, reason: why } + } + } +} + +fn escalation_status_of(slot: EscalationSlot) -> EscalationStatus { + match slot { + EscalationUnreadable { detail: _ } => EscalationStatus { status: 503, body: filed_approval_status_text(status: filed_status_of_slot(slot: slot)) } + EscalationNotFiled => EscalationStatus { status: 200, body: filed_approval_status_text(status: filed_status_of_slot(slot: slot)) } + EscalationPending { request: _ } => EscalationStatus { status: 200, body: filed_approval_status_text(status: filed_status_of_slot(slot: slot)) } + EscalationDecided { decision: _ } => EscalationStatus { status: 200, body: filed_approval_status_text(status: filed_status_of_slot(slot: slot)) } } } @@ -421,11 +452,8 @@ data approval_submission_authority_policy_frontier: DissolutionCondition = unbou description: "a requester -> authority policy roster (which enrolled filers may put which brokered authority in front of the operator) consumed by admit_parsed_submission in place of approval_submission_authority_permitted, with a witness whose unrostered requester answers SubmissionAuthorityRefused -- SUFFICIENT FOR the authority half of admission to refuse on the live route; triggered by the first second brokered authority or the first filer class other than the fleet-converge runner", ) -// THE RUN THAT FILES. This module is the server side; the client -- a fleet-converge mode that -// materializes the submission key in-run, builds the request from a source-declared -// AuthorizationRequest, signs, POSTs, polls, and gates the boot on the grant -- is part D -// (eager-owl-205's child lane, 2026-09-16). Until it lands, submit_parsed_submission has the serve -// route and its witness as consumers and no filer; its first live receipt is D's first run. +// THE FILING ROUTES ARE NOW ON THE SERVE TABLE (POST /approvals, GET /approvals/\{escalation_id\}). +// Remaining dissolution is the same path used by every approval-gated fleet action, not one host's boot. data approval_submission_client_frontier: DissolutionCondition = unbound_dissolution( - description: "a fleet-converge mode that materializes approval-submission-mac-key in-run over WIF, signs stored_request_json of a source-declared request with sign_submission, POSTs the envelope to /approvals, polls /approvals/\{escalation_id\} bounded by the request's expires_at, and gates the actuation on grant_from_store's OperatorGrant -- SUFFICIENT FOR every approval-gated fleet action, not one host's boot; the srv3 source-declared grant dissolves into the same run.", + description: "every approval-gated fleet action files through POST /approvals and GET /approvals/ plus the escalation id, then joins the grant with grant_from_approved_decision over those standing fields; SUFFICIENT FOR that HTTP client path (runners never open the 0600 store), not one host's boot and not grant_from_store; the srv3 source-declared grant dissolves into the same run.", ) diff --git a/dag/gunbc/auth/approval_status_wire.dag b/dag/gunbc/auth/approval_status_wire.dag new file mode 100644 index 00000000000..7afafc3cca7 --- /dev/null +++ b/dag/gunbc/auth/approval_status_wire.dag @@ -0,0 +1,232 @@ +module gunbc.auth.approval_status_wire + +import std.types { Bool, NonEmptyStr, String, Timestamp } +import gunbc.auth.approval_capability { utc_instant_is_canonical } + +// ONE kv grammar for POST /approvals filed-token and GET /approvals standing, read in both +// directions. Keys and standing words are data; emit and parse consume those rows. A second +// hand-joined standing= string is a fork of this wire. + +data standing_kv_key: String = "standing" +data expires_at_kv_key: String = "expires_at" +data revision_kv_key: String = "revision" +data decided_by_kv_key: String = "decided_by" +data decided_at_kv_key: String = "decided_at" +data execute_by_kv_key: String = "execute_by" +data reason_kv_key: String = "reason" +data detail_kv_key: String = "detail" +data filed_kv_key: String = "filed" + +data standing_not_filed: String = "not-filed" +data standing_pending: String = "pending" +data standing_approved: String = "approved" +data standing_denied: String = "denied" +data standing_unreadable: String = "unreadable" + +data filed_yes: String = "yes" +data filed_no: String = "no" + +type KvLineRead + = KvLineAbsent + | KvLinePresent { value: String } + | KvLineDuplicated { key: String } + +fn kv_line(key: String, value: String) -> String { + concat(key, concat("=", concat(join(value.split(delimiter: "\n"), " "), "\n"))) +} + +fn kv_line_read(body: String, key: String) -> KvLineRead { + let prefix = concat(key, "=") + fold(body.split(delimiter: "\n"), init: KvLineAbsent, f: (acc, line) => + if !starts_with(s: line, prefix: prefix) { + acc + } else { + match acc { + KvLineAbsent => KvLinePresent { value: substring(s: line, start: prefix.length(), end: line.length()) } + KvLinePresent { value: _ } => KvLineDuplicated { key: key } + KvLineDuplicated { key: k } => KvLineDuplicated { key: k } + } + } + ) +} + +type FiledApprovalStatus + = StatusNotFiled + | StatusPending { expires_at: Timestamp } + | StatusApproved { + revision: NonEmptyStr + decided_by: NonEmptyStr + decided_at: Timestamp + execute_by: Timestamp + } + | StatusDenied { + revision: NonEmptyStr + decided_by: NonEmptyStr + decided_at: Timestamp + reason: NonEmptyStr + } + | StatusUnreadable { detail: NonEmptyStr } + +fn filed_approval_status_text(status: FiledApprovalStatus) -> String { + match status { + StatusNotFiled => kv_line(key: standing_kv_key, value: standing_not_filed) + StatusPending { expires_at: e } => + concat(kv_line(key: standing_kv_key, value: standing_pending), kv_line(key: expires_at_kv_key, value: e)) + StatusApproved { revision: r, decided_by: by, decided_at: at, execute_by: until } => + concat( + kv_line(key: standing_kv_key, value: standing_approved), + concat( + kv_line(key: revision_kv_key, value: r as String), + concat( + kv_line(key: decided_by_kv_key, value: by as String), + concat(kv_line(key: decided_at_kv_key, value: at), kv_line(key: execute_by_kv_key, value: until)), + ), + ), + ) + StatusDenied { revision: r, decided_by: by, decided_at: at, reason: why } => + concat( + kv_line(key: standing_kv_key, value: standing_denied), + concat( + kv_line(key: revision_kv_key, value: r as String), + concat( + kv_line(key: decided_by_kv_key, value: by as String), + concat(kv_line(key: decided_at_kv_key, value: at), kv_line(key: reason_kv_key, value: why as String)), + ), + ), + ) + StatusUnreadable { detail: d } => + concat(kv_line(key: standing_kv_key, value: standing_unreadable), kv_line(key: detail_kv_key, value: d as String)) + } +} + +fn parse_filed_approval_status(body: String) -> FiledApprovalStatus { + match kv_line_read(body: body, key: standing_kv_key) { + KvLineDuplicated { key: _ } => + StatusUnreadable { detail: "status text repeats standing" as NonEmptyStr } + KvLineAbsent => + StatusUnreadable { detail: "status text has no standing= line" as NonEmptyStr } + KvLinePresent { value: standing } => + parse_filed_approval_standing(body: body, standing: standing) + } +} + +fn parse_filed_approval_standing(body: String, standing: String) -> FiledApprovalStatus { + if standing == standing_not_filed { + StatusNotFiled + } else if standing == standing_pending { + match kv_line_read(body: body, key: expires_at_kv_key) { + KvLineDuplicated { key: _ } => + StatusUnreadable { detail: "pending standing repeats expires_at" as NonEmptyStr } + KvLineAbsent => + StatusUnreadable { detail: "pending standing is missing a canonical expires_at" as NonEmptyStr } + KvLinePresent { value: expires } => + if utc_instant_is_canonical(t: expires as Timestamp) { + StatusPending { expires_at: expires as Timestamp } + } else { + StatusUnreadable { detail: "pending standing is missing a canonical expires_at" as NonEmptyStr } + } + } + } else if standing == standing_approved { + kv_approved_fields(body: body) + } else if standing == standing_denied { + kv_denied_fields(body: body) + } else if standing == standing_unreadable { + match kv_line_read(body: body, key: detail_kv_key) { + KvLineDuplicated { key: _ } => + StatusUnreadable { detail: "unreadable standing repeats detail" as NonEmptyStr } + KvLineAbsent => + StatusUnreadable { detail: "unreadable standing has empty detail" as NonEmptyStr } + KvLinePresent { value: detail } => + if detail == "" { + StatusUnreadable { detail: "unreadable standing has empty detail" as NonEmptyStr } + } else { + StatusUnreadable { detail: detail as NonEmptyStr } + } + } + } else { + StatusUnreadable { detail: concat("unknown standing: ", standing) as NonEmptyStr } + } +} + +fn kv_approved_fields(body: String) -> FiledApprovalStatus { + match kv_line_read(body: body, key: revision_kv_key) { + KvLineDuplicated { key: _ } => StatusUnreadable { detail: "approved standing repeats revision" as NonEmptyStr } + KvLineAbsent => StatusUnreadable { detail: "approved standing is missing revision, decided_by, decided_at, or execute_by" as NonEmptyStr } + KvLinePresent { value: revision } => + match kv_line_read(body: body, key: decided_by_kv_key) { + KvLineDuplicated { key: _ } => StatusUnreadable { detail: "approved standing repeats decided_by" as NonEmptyStr } + KvLineAbsent => StatusUnreadable { detail: "approved standing is missing revision, decided_by, decided_at, or execute_by" as NonEmptyStr } + KvLinePresent { value: decided_by } => + match kv_line_read(body: body, key: decided_at_kv_key) { + KvLineDuplicated { key: _ } => StatusUnreadable { detail: "approved standing repeats decided_at" as NonEmptyStr } + KvLineAbsent => StatusUnreadable { detail: "approved standing is missing revision, decided_by, decided_at, or execute_by" as NonEmptyStr } + KvLinePresent { value: decided_at } => + match kv_line_read(body: body, key: execute_by_kv_key) { + KvLineDuplicated { key: _ } => StatusUnreadable { detail: "approved standing repeats execute_by" as NonEmptyStr } + KvLineAbsent => StatusUnreadable { detail: "approved standing is missing revision, decided_by, decided_at, or execute_by" as NonEmptyStr } + KvLinePresent { value: execute_by } => + if revision == "" || decided_by == "" || !utc_instant_is_canonical(t: decided_at as Timestamp) || !utc_instant_is_canonical(t: execute_by as Timestamp) { + StatusUnreadable { detail: "approved standing is missing revision, decided_by, decided_at, or execute_by" as NonEmptyStr } + } else { + StatusApproved { + revision: revision as NonEmptyStr, + decided_by: decided_by as NonEmptyStr, + decided_at: decided_at as Timestamp, + execute_by: execute_by as Timestamp, + } + } + } + } + } + } +} + +fn kv_denied_fields(body: String) -> FiledApprovalStatus { + match kv_line_read(body: body, key: revision_kv_key) { + KvLineDuplicated { key: _ } => StatusUnreadable { detail: "denied standing repeats revision" as NonEmptyStr } + KvLineAbsent => StatusUnreadable { detail: "denied standing is missing revision, decided_by, decided_at, or reason" as NonEmptyStr } + KvLinePresent { value: revision } => + match kv_line_read(body: body, key: decided_by_kv_key) { + KvLineDuplicated { key: _ } => StatusUnreadable { detail: "denied standing repeats decided_by" as NonEmptyStr } + KvLineAbsent => StatusUnreadable { detail: "denied standing is missing revision, decided_by, decided_at, or reason" as NonEmptyStr } + KvLinePresent { value: decided_by } => + match kv_line_read(body: body, key: decided_at_kv_key) { + KvLineDuplicated { key: _ } => StatusUnreadable { detail: "denied standing repeats decided_at" as NonEmptyStr } + KvLineAbsent => StatusUnreadable { detail: "denied standing is missing revision, decided_by, decided_at, or reason" as NonEmptyStr } + KvLinePresent { value: decided_at } => + match kv_line_read(body: body, key: reason_kv_key) { + KvLineDuplicated { key: _ } => StatusUnreadable { detail: "denied standing repeats reason" as NonEmptyStr } + KvLineAbsent => StatusUnreadable { detail: "denied standing is missing revision, decided_by, decided_at, or reason" as NonEmptyStr } + KvLinePresent { value: reason } => + if revision == "" || decided_by == "" || reason == "" || !utc_instant_is_canonical(t: decided_at as Timestamp) { + StatusUnreadable { detail: "denied standing is missing revision, decided_by, decided_at, or reason" as NonEmptyStr } + } else { + StatusDenied { + revision: revision as NonEmptyStr, + decided_by: decided_by as NonEmptyStr, + decided_at: decided_at as Timestamp, + reason: reason as NonEmptyStr, + } + } + } + } + } + } +} + +fn submission_filed_text(filed: Bool, reason: String) -> String { + concat( + kv_line(key: filed_kv_key, value: if filed { filed_yes } else { filed_no }), + kv_line(key: reason_kv_key, value: reason), + ) +} + +// Poll admission is the filed= token only. HTTP success is not a conjunct: a 409 already-filed +// or a 502 after a durable write with minted links still polls. A 502/503 with no minted +// capability is filed=no. A wrapper that AND-ed the transport Bool was a tautology. +fn submission_body_admits_poll(body: String) -> Bool { + match kv_line_read(body: body, key: filed_kv_key) { + KvLinePresent { value: v } => v == filed_yes + _ => false + } +} diff --git a/dag/gunbc/authorization_claim_slot.dag b/dag/gunbc/authorization_claim_slot.dag new file mode 100644 index 00000000000..fddae4666c5 --- /dev/null +++ b/dag/gunbc/authorization_claim_slot.dag @@ -0,0 +1,113 @@ +module gunbc.authorization_claim_slot + +import std.types { NonEmptyStr, String, Timestamp } +import std.process { ProcessExit, ExitSuccess, ExitFailure, exit_failure } +import std.scoped_authorization { + AttemptIdentity, + claim_authorization, claim_transition_claimed, claim_transition_completed, claim_transition_aborted, + authorization_claim_outcome, AuthorizationClaimOutcome, ClaimUndecided, +} +import std.durable_compare_and_set { + ExpectSlotAbsent, ExpectSlotGeneration, CasGeneration, + CasCommitted, CasPreconditionFailed, CasStoreRefused, +} +import gunbc.durable_cas_file_store { + OwnerOnlyCreateOnly, admit_cas_attempt, file_compare_and_set, authorization_claim_store_root, + CasAttemptAdmitted, CasAttemptDigestMismatch, CasAttemptDigestIncomparable, CasAttemptKeyNotSlotAddressable, +} + +// ONE CLAIM / SETTLE PAIR FOR EVERY AUTHORIZATION SLOT, beside the store root it writes to. The +// srv3 boot-once-cd step and the Mt. Collins boot step each held a verbatim copy of these folds +// (review 68456): same admit_cas_attempt -> file_compare_and_set shape, same arms, same refusal +// sentences, on ONE shared slot domain -- two sources that could drift on claim/terminal +// semantics for one store. The escalation id, the attempt and the step's label are the only +// facts that differed, so they are the parameters; the label prefixes every exit so an operator +// still reads which step left a claim unterminated. + +fn claim_authorization_slot( + escalation_id: NonEmptyStr, + attempt: AttemptIdentity, + at: Timestamp, +) -> AuthorizationClaimOutcome + uses fs: std.resources.Filesystem +{ + let proposed = claim_transition_claimed(attempt: attempt, at: at) + match admit_cas_attempt( + attempt: claim_authorization( + escalation_id: escalation_id, + expected: ExpectSlotAbsent, + proposed: proposed, + ), + ) { + CasAttemptAdmitted { verified: v } => + authorization_claim_outcome( + proposed: proposed, + outcome: file_compare_and_set(root: authorization_claim_store_root, publication: OwnerOnlyCreateOnly, verified: v), + ) + CasAttemptDigestMismatch { claimed: _, actual: _ } => + ClaimUndecided { reason: "claim payload and its digest disagree" as NonEmptyStr } + CasAttemptDigestIncomparable { claimed: _, actual: _ } => + ClaimUndecided { reason: "claim digest families are incomparable" as NonEmptyStr } + CasAttemptKeyNotSlotAddressable { key: _ } => + ClaimUndecided { reason: "authorization claim key is not slot-addressable" as NonEmptyStr } + } +} + +// THE TERMINAL TRANSITION RIDES THE SAME COMPARE-AND-SET, expecting the generation the step holds. +// If it does not land, the step's own result still stands -- the mutation already happened, and +// lying about it to keep the ledger tidy would be the worse failure -- but the exit carries the +// fact that the claim was left unterminated, because an operator reading a bare success would not +// know a manual slot inspection is owed. +fn settle_authorization_claim( + label: String, + outcome: ProcessExit, + escalation_id: NonEmptyStr, + attempt: AttemptIdentity, + held_at: CasGeneration, + at: Timestamp, +) -> ProcessExit + uses fs: std.resources.Filesystem +{ + let terminal = match outcome { + ExitSuccess => claim_transition_completed(attempt: attempt, at: at) + ExitFailure { code: _, reason: r } => + claim_transition_aborted(attempt: attempt, at: at, cause: r as NonEmptyStr) + } + match admit_cas_attempt( + attempt: claim_authorization( + escalation_id: escalation_id, + expected: ExpectSlotGeneration { generation: held_at }, + proposed: terminal, + ), + ) { + CasAttemptAdmitted { verified: v } => + match file_compare_and_set(root: authorization_claim_store_root, publication: OwnerOnlyCreateOnly, verified: v) { + CasCommitted { committed: _ } => outcome + CasPreconditionFailed { expected: _, observed: _ } => + unterminated_claim_exit(label: label, outcome: outcome, detail: "claim slot moved under us") + CasStoreRefused { cause: _ } => + unterminated_claim_exit(label: label, outcome: outcome, detail: "claim store refused the terminal write") + } + CasAttemptDigestMismatch { claimed: _, actual: _ } => + unterminated_claim_exit(label: label, outcome: outcome, detail: "terminal payload and its digest disagree") + CasAttemptDigestIncomparable { claimed: _, actual: _ } => + unterminated_claim_exit(label: label, outcome: outcome, detail: "terminal digest families are incomparable") + CasAttemptKeyNotSlotAddressable { key: _ } => + unterminated_claim_exit(label: label, outcome: outcome, detail: "authorization claim key is not slot-addressable") + } +} + +fn unterminated_claim_exit(label: String, outcome: ProcessExit, detail: String) -> ProcessExit { + exit_failure( + reason: concat( + label, + concat( + ": the step finished but its authorization claim was left unterminated (", + concat(detail, concat("); step outcome was: ", match outcome { + ExitSuccess => "success" + ExitFailure { code: _, reason: r } => concat("failure: ", r) + })), + ), + ), + ) +} diff --git a/dag/gunbc/ci/ci_spec.dag b/dag/gunbc/ci/ci_spec.dag index 748b01918a7..9c8301e9077 100644 --- a/dag/gunbc/ci/ci_spec.dag +++ b/dag/gunbc/ci/ci_spec.dag @@ -23,13 +23,25 @@ import gunbc.spark.credential_workflow { } import gunbc.fleet_bootstrap_principal_session { fleet_bootstrap_credential_path_env } import gunbc.host_reset_return { host_reset_return_receipt_path } +import gunbc.host_reset_return_run { host_reset_bmc_credential_path_env } +import gunbc.secret_provision { mtcollins1_bmc_gunbc_secret_ref } +import gunbc.auth.approval_keyring_pins { + approval_capability_mac_access_url, + approval_submission_mac_access_url, + approval_submission_mac_key_path_env, + approval_capability_mac_key_path_env, +} +import gunbc.machine_intake_mtcollins1_boot_run { + mtcollins1_boot_sol_capture_path_env, + mtcollins1_boot_sol_pid_path_env, +} import gunbc.spark.bootstrap_provision { spark_bootstrap_cloud_principal_member } import std.dissolution { DissolutionCondition, dissolution_description, unbound_dissolution } import std.realization_schedule { RunnableBatchClamp } import std.decl_ref { decl_ref } -import std.measure { second, millisecond, Minute, minute, minute_count } +import std.measure { second, millisecond, Minute, minute, minute_count, second_count } import std.nat { Nat } import gunbc.fleet_ssh_access { fleet_automation_ssh_key_fingerprint, fleet_automation_privkey_sm_access_url, fleet_automation_privkey_version_segment } @@ -89,7 +101,7 @@ import extdeps.git { git_remote_ref_parts, git_fetch_no_tags_shell, } -import std.types { GitRef, Int, String, List } +import std.types { GitRef, Int, String, List, NonEmptyStr } import v2.std.algebra { FreeSemigroup, non_empty_to_list } import extdeps.github.actions { RunnerSpec } import v2.std.orchestration { @@ -844,6 +856,16 @@ data gunbc_ci_host_reset_return_target: GunbcRunStepTarget = GunbcRunStepTarget function: "host_reset_return_wet", } +data gunbc_ci_approval_keyring_converge_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/auth/approval_keyring_converge.dag", + function: "approval_keyring_converge_wet", +} + +data gunbc_ci_mtcollins1_boot_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/machine_intake/mtcollins1_boot_run.dag", + function: "mtcollins1_boot_wet", +} + // THE DISPATCH ADMISSION TARGET. It takes no arguments: both values it reads -- the mode and the // observer label -- arrive by environment on the step, which is the same route the reset entry uses // for the label. It runs in the BUILD job, after the release binaries exist, because the reset job @@ -1036,7 +1058,9 @@ fn gunbc_run_step_targets() -> List { gunbc_ci_heal_verify_target, gunbc_ci_heal_candidate_target, gunbc_ci_heal_publication_admit_target, - gunbc_ci_heal_publication_publish_target + gunbc_ci_heal_publication_publish_target, + gunbc_ci_approval_keyring_converge_target, + gunbc_ci_mtcollins1_boot_target ] } @@ -2049,6 +2073,105 @@ fn gunbc_ci_spark_serving_apply_invoke() -> String { ) } +fn gunbc_ci_sm_decode_payload_to_file() -> String { + "python3 -c 'import sys,json,base64; sys.stdout.buffer.write(base64.b64decode(json.load(open(sys.argv[1]))[\"payload\"][\"data\"]))' \"$RESP_FILE\" > \"$DEST_FILE\"" +} + +fn gunbc_ci_approval_keyring_converge_prelude() -> List { + [ + Do { run: ci_retry_body_run(command: "set -euo pipefail") }, + Do { run: ci_retry_body_run(command: "umask 077") }, + Do { run: ci_retry_body_run(command: "HDR_FILE=\"$RUNNER_TEMP/approval-keyring-auth-header\"") }, + Do { run: ci_retry_body_run(command: "RESP_FILE=\"$RUNNER_TEMP/approval-keyring-sm.json\"") }, + Do { run: ci_retry_body_run(command: "CAP_FILE=\"$RUNNER_TEMP/approval-capability-mac-key\"") }, + Do { run: ci_retry_body_run(command: "SUB_FILE=\"$RUNNER_TEMP/approval-submission-mac-key\"") }, + Do { run: ci_retry_body_run(command: "trap 'rm -f \"$HDR_FILE\" \"$RESP_FILE\" \"$CAP_FILE\" \"$SUB_FILE\"' EXIT") }, + Do { run: ci_retry_body_run(command: gunbc_ci_auth_header_file_write_command()) }, + Do { run: ci_retry_body_run(command: concat("DEST_FILE=\"$CAP_FILE\"; curl -sSf -o \"$RESP_FILE\" -H @\"$HDR_FILE\" \"", concat(approval_capability_mac_access_url(), "\"; "), gunbc_ci_sm_decode_payload_to_file())) }, + Do { run: ci_retry_body_run(command: "chmod 600 \"$CAP_FILE\"") }, + Do { run: ci_retry_body_run(command: concat("DEST_FILE=\"$SUB_FILE\"; curl -sSf -o \"$RESP_FILE\" -H @\"$HDR_FILE\" \"", concat(approval_submission_mac_access_url(), "\"; "), gunbc_ci_sm_decode_payload_to_file())) }, + Do { run: ci_retry_body_run(command: "chmod 600 \"$SUB_FILE\"") }, + Do { run: ci_retry_body_run(command: concat("export ", concat(approval_capability_mac_key_path_env as String, "=\"$CAP_FILE\""))) }, + Do { run: ci_retry_body_run(command: concat("export ", concat(approval_submission_mac_key_path_env as String, "=\"$SUB_FILE\""))) }, + ] +} + +data gunbc_ci_approval_keyring_converge_shell_emit_scaffold: Disposition = Scaffold { + dissolves_to: SingleAuthority, + bind: DeclarationRef { + module_path: "gunbc.ci_spec", + decl_name: "gunbc_ci_approval_keyring_converge_invoke", + field: WholeDeclaration + } +} + +data gunbc_ci_approval_keyring_converge_shell_emit_dissolution_trigger: DissolutionCondition = unbound_dissolution(description: "🟡 dissolve-on: gunbc_ci_approval_keyring_converge_invoke - orch-emitted foreign-executor (GitHub Actions run:) credential runner: WIF access token by env, two PINNED secret versions (capability and submission MAC keys) fetched over curl, 0600 files under RUNNER_TEMP with a trap armed BEFORE the credentials touch disk. The pipeline steps are modeled (gunbc_ci_approval_keyring_converge_prelude) but the runner transport itself remains hand-shell; DISSOLVES WHEN bash-emit (#5828 / ROADMAP 6-shell-slice0 / shell-to-intent Phase 2) realizes the credential runner through orchestration emit or typed host_effect_apply without a medium-as-string concat scaffold") + +fn gunbc_ci_mtcollins1_boot_credential_prelude() -> List { + [ + Do { run: ci_retry_body_run(command: "set -euo pipefail") }, + Do { run: ci_retry_body_run(command: "umask 077") }, + Do { run: ci_retry_body_run(command: "HDR_FILE=\"$RUNNER_TEMP/mtcollins1-boot-auth-header\"") }, + Do { run: ci_retry_body_run(command: "RESP_FILE=\"$RUNNER_TEMP/mtcollins1-boot-sm.json\"") }, + Do { run: ci_retry_body_run(command: "DEST_FILE=\"$RUNNER_TEMP/mtcollins1-bmc-credential\"") }, + Do { run: ci_retry_body_run(command: "SUB_FILE=\"$RUNNER_TEMP/approval-submission-mac-key\"") }, + Do { run: ci_retry_body_run(command: "SOL_PID_FILE=\"$RUNNER_TEMP/mtcollins1-sol.pid\"") }, + Do { run: ci_retry_body_run(command: "SOL_OUT=\"$GITHUB_WORKSPACE/artifacts/mtcollins1-boot-sol.capture\"") }, + Do { run: ci_retry_body_run(command: "trap 'if [ -f \"$SOL_PID_FILE\" ]; then kill \"$(cat \"$SOL_PID_FILE\")\" 2>/dev/null || true; fi; rm -f \"$HDR_FILE\" \"$RESP_FILE\" \"$DEST_FILE\" \"$SUB_FILE\" \"$SOL_PID_FILE\"' EXIT") }, + Do { run: ci_retry_body_run(command: gunbc_ci_auth_header_file_write_command()) }, + Do { run: ci_retry_body_run(command: concat("curl -sSf -o \"$RESP_FILE\" -H @\"$HDR_FILE\" \"", concat(secret_ref_access_url(ref: mtcollins1_bmc_gunbc_secret_ref), "\"; "), gunbc_ci_sm_decode_payload_to_file())) }, + Do { run: ci_retry_body_run(command: "chmod 600 \"$DEST_FILE\"") }, + Do { run: ci_retry_body_run(command: concat("export ", concat(host_reset_bmc_credential_path_env as String, "=\"$DEST_FILE\""))) }, + Do { run: ci_retry_body_run(command: "HOLD_BMC=\"$DEST_FILE\"") }, + Do { run: ci_retry_body_run(command: concat("DEST_FILE=\"$SUB_FILE\"; curl -sSf -o \"$RESP_FILE\" -H @\"$HDR_FILE\" \"", concat(approval_submission_mac_access_url(), concat("\"; ", concat(gunbc_ci_sm_decode_payload_to_file(), "; DEST_FILE=\"$HOLD_BMC\""))))) }, + Do { run: ci_retry_body_run(command: "chmod 600 \"$SUB_FILE\"") }, + Do { run: ci_retry_body_run(command: concat("export ", concat(approval_submission_mac_key_path_env as String, "=\"$SUB_FILE\""))) }, + Do { run: ci_retry_body_run(command: "mkdir -p \"$GITHUB_WORKSPACE/artifacts\"") }, + Do { run: ci_retry_body_run(command: ": > \"$SOL_OUT\"") }, + Do { run: ci_retry_body_run(command: concat("export ", concat(mtcollins1_boot_sol_capture_path_env as String, "=\"$SOL_OUT\""))) }, + Do { run: ci_retry_body_run(command: concat("export ", concat(mtcollins1_boot_sol_pid_path_env as String, "=\"$SOL_PID_FILE\""))) }, + ] +} + +data gunbc_ci_mtcollins1_boot_credential_shell_emit_scaffold: Disposition = Scaffold { + dissolves_to: SingleAuthority, + bind: DeclarationRef { + module_path: "gunbc.ci_spec", + decl_name: "gunbc_ci_mtcollins1_boot_invoke", + field: WholeDeclaration + } +} + +data gunbc_ci_mtcollins1_boot_credential_shell_emit_dissolution_trigger: DissolutionCondition = unbound_dissolution(description: "🟡 dissolve-on: gunbc_ci_mtcollins1_boot_invoke - orch-emitted foreign-executor (GitHub Actions run:) credential runner: WIF access token by env, PINNED BMC and submission-MAC secret versions fetched over curl, 0600 files under RUNNER_TEMP with a trap armed BEFORE the credentials touch disk. SOL hold is gunbc.machine_intake.sol_hold ActivateHeld (its own scaffold). The pipeline steps are modeled (gunbc_ci_mtcollins1_boot_credential_prelude) but the runner transport itself remains hand-shell; DISSOLVES WHEN bash-emit (#5828 / ROADMAP 6-shell-slice0 / shell-to-intent Phase 2) realizes the credential runner through orchestration emit or typed host_effect_apply without a medium-as-string concat scaffold") + +fn gunbc_ci_approval_keyring_converge_invoke() -> String { + concat( + concat("# ", concat(dissolution_description(condition: gunbc_ci_approval_keyring_converge_shell_emit_dissolution_trigger), "\n")), + gunbc_run_step_script_with_prelude( + prelude: gunbc_ci_approval_keyring_converge_prelude(), + source_roots: witness_layer_roots, + entry: gunbc_ci_approval_keyring_converge_target.entry, + function: gunbc_ci_approval_keyring_converge_target.function, + claim_run: false, + receipt_rel: none + ) + ) +} + +fn gunbc_ci_mtcollins1_boot_invoke() -> String { + concat( + concat("# ", concat(dissolution_description(condition: gunbc_ci_mtcollins1_boot_credential_shell_emit_dissolution_trigger), "\n")), + gunbc_run_step_script_with_prelude( + prelude: gunbc_ci_mtcollins1_boot_credential_prelude(), + source_roots: witness_layer_roots, + entry: gunbc_ci_mtcollins1_boot_target.entry, + function: gunbc_ci_mtcollins1_boot_target.function, + claim_run: false, + receipt_rel: none + ) + ) +} + // Fleet convergence collection (operator verdict join 5, parent verification msg_8d84ae4f). THIS // WIRE IS DEAD AND THE SENTENCE THAT STOOD HERE SAID IT WAS LIVE. It read, present tense, that this // is the LIVE invocation wire for collect_fleet_convergence_wet, running on main pushes downstream diff --git a/dag/gunbc/durable_cas_file_store.dag b/dag/gunbc/durable_cas_file_store.dag index 121f7db64a6..e0126194e37 100644 --- a/dag/gunbc/durable_cas_file_store.dag +++ b/dag/gunbc/durable_cas_file_store.dag @@ -7,9 +7,11 @@ import extdeps.filesystem.filesystem_io { Filesystem, FilesystemCreateNew, FilesystemCreated, FilesystemCreateTargetOccupied, FilesystemCreateRefused, FilesystemCreateKindUnrecognized, FilesystemExactRead, FilesystemExactPathRead, FilesystemExactPathAbsent, FilesystemExactPathUnreadable, FilesystemExactPathKindUnrecognized, - FilesystemFailureKind, filesystem_create_new, filesystem_exact_read, filesystem_failure_kind_name, + FilesystemFailureKind, filesystem_create_new, filesystem_exact_read, filesystem_failure_kind_name, } +data authorization_claim_store_root: NonEmptyStr = "/var/lib/gunbc/authorization-claims" as NonEmptyStr + // The first production realization of std.durable_compare_and_set: one keyed // slot backed by generation-suffixed files, exclusion supplied by O_EXCL. // @@ -415,8 +417,9 @@ fn observe_cas_slot_state(root: NonEmptyStr, key: NonEmptyStr) -> CasSlotObserva // So what is owed is a limitation with a restoration trigger, which is what this is. Caught by // external review of the drop row itself. // -// THE POPULATION IS ONE CALLER, AND IT IS NAMED. `gunbc.srv3.srv3_boot_once_cd` publishes authorization -// claim state under `/var/lib/gunbc/authorization-claims`, and that root is a bare path constant: no +// THE POPULATION IS TWO CALLERS, AND THEY ARE NAMED. `gunbc.srv3.srv3_boot_once_cd` and +// `gunbc.machine_intake_mtcollins1_boot_run` publish authorization claim state under +// `authorization_claim_store_root`, and that root is a bare path constant: no // modeled directory ownership or mode authority stands beside it. Mode 0600 on the generation file is // therefore the ONLY confidentiality that state has, including for free-form abort-cause text, so // flipping it to the atomic binding would trade a durable-state hazard for a real privacy regression. diff --git a/dag/gunbc/extdeps_scope_frontier.dag b/dag/gunbc/extdeps_scope_frontier.dag index c0f06703d86..5f3c502f575 100644 --- a/dag/gunbc/extdeps_scope_frontier.dag +++ b/dag/gunbc/extdeps_scope_frontier.dag @@ -287,6 +287,7 @@ data scope_carrier_paths: List = [ "dag/extdeps/cpu/ampere_altra_platform_hw_design/platform.dag", "dag/extdeps/bmc/aspeed_ast2500.dag", "dag/extdeps/bmc/ipmi_channel.dag", + "dag/extdeps/bmc/ipmi_boot_selection.dag", "dag/extdeps/ocp/mt_mitchell/platform.dag", "dag/extdeps/cpu_attachment/ilm4926.dag", "dag/extdeps/ssh/client_options.dag", diff --git a/dag/gunbc/fleet/fleet_converge_plan_cli.dag b/dag/gunbc/fleet/fleet_converge_plan_cli.dag index b7e4fbad55b..e8de87c59a5 100644 --- a/dag/gunbc/fleet/fleet_converge_plan_cli.dag +++ b/dag/gunbc/fleet/fleet_converge_plan_cli.dag @@ -193,11 +193,12 @@ import gunbc.actions_run_binding { admit_run_revision, RunRevisionAdmitted, RunRevisionRefused, + fleet_converge_expected_host_env_name, } import gunbc.fleet_converge_receipt { FleetConvergePlanReceipt, FleetConvergeApplyReceipt, fleet_converge_plan_receipt_path, fleet_converge_apply_receipt_dir, fleet_converge_apply_receipt_path, - fleet_converge_expected_host_env_name, fleet_converge_plan_run_id_env_name, + fleet_converge_plan_run_id_env_name, write_fleet_converge_plan_receipt, write_fleet_converge_apply_receipt, ReceiptWritten, ReceiptWriteRefused, read_fleet_converge_plan_receipt_at, FleetConvergePlanReceiptDecoded, FleetConvergePlanReceiptUnreadable, diff --git a/dag/gunbc/fleet/fleet_converge_receipt.dag b/dag/gunbc/fleet/fleet_converge_receipt.dag index 5d7d39b341f..513f2426194 100644 --- a/dag/gunbc/fleet/fleet_converge_receipt.dag +++ b/dag/gunbc/fleet/fleet_converge_receipt.dag @@ -56,7 +56,6 @@ data fleet_converge_plan_receipt_path: String = "/tmp/fleet-converge-plan/plan_r data fleet_converge_apply_receipt_dir: String = "/tmp/fleet-converge-apply" data fleet_converge_apply_receipt_path: String = "/tmp/fleet-converge-apply/apply_receipt.json" -data fleet_converge_expected_host_env_name: String = "FLEET_CONVERGE_EXPECTED_HOST" data fleet_converge_plan_run_id_env_name: String = "FLEET_CONVERGE_PLAN_RUN_ID" type FleetConvergePlanReceipt { diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index 327a37bd024..faa292831bc 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -66,6 +66,7 @@ import gunbc.fleet_workflow_steps { } import gunbc.runner_spec_from_offer { gunbc_ci_runner_spec } import gunbc.host_reset_return_run { host_reset_dispatch_mode_env } +import gunbc.machine_intake_mtcollins1_boot_run { mtcollins1_boot_artifact_glob, mtcollins1_boot_step_timeout_minutes } import gunbc.ci_spec { gunbc_ci_fleet_converge_plan_invoke, gunbc_ci_fleet_converge_launch_environment_plan_invoke, @@ -75,6 +76,8 @@ import gunbc.ci_spec { gunbc_ci_app_control_plane_converge_invoke, gunbc_ci_r2_mint_preflight_invoke, gunbc_ci_r2_object_write_mint_invoke, + gunbc_ci_approval_keyring_converge_invoke, + gunbc_ci_mtcollins1_boot_invoke, gunbc_ci_org_runner_roster_observe_invoke, gunbc_ci_runner_guest_image_observe_invoke, gunbc_ci_runner_guest_image_converge_invoke, @@ -98,8 +101,8 @@ import gunbc.ci_spec { gunbc_rlm_launch_deployment_receipt_artifact_name, gunbc_rlm_launch_deployment_receipt_artifact_dir, } -import gunbc.fleet_converge_receipt { fleet_converge_expected_host_env_name, fleet_converge_plan_run_id_env_name } -import gunbc.actions_run_binding { rlm_expected_revision_env_name } +import gunbc.fleet_converge_receipt { fleet_converge_plan_run_id_env_name } +import gunbc.actions_run_binding { rlm_expected_revision_env_name, fleet_converge_expected_host_env_name } import gunbc.spark.credential_workflow { spark_administrator_credential_roster_hosts } import gunbc.fleet_runner_connectivity { fleet_hosts } import gunbc.runner_slot_allocation { runner_host_label } @@ -189,6 +192,8 @@ type FleetConvergeWorkflowMode | RunnerPasswordSessionToolConverge | R2MintPreflight | R2ObjectWriteMint + | ApprovalKeyringConverge + | MtCollins1Boot | OrgRunnerRosterObserve fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String { @@ -215,6 +220,8 @@ fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String RunnerPasswordSessionToolConverge => "runner_password_session_tool_converge" R2MintPreflight => "r2_mint_preflight" R2ObjectWriteMint => "r2_object_write_mint" + ApprovalKeyringConverge => "approval_keyring_converge" + MtCollins1Boot => "mtcollins1_boot" OrgRunnerRosterObserve => "org_runner_roster_observe" } } @@ -240,7 +247,7 @@ fn fleet_converge_spark_target_description() -> String { ], "") } -data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkRuntimeImageProbe, DashboardDeploy, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2ObjectWriteMint, OrgRunnerRosterObserve] +data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkRuntimeImageProbe, DashboardDeploy, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2ObjectWriteMint, OrgRunnerRosterObserve, ApprovalKeyringConverge, MtCollins1Boot] // WHICH SCOPE A MODE SELECTS, WHERE IT SELECTS ONE AT ALL. // @@ -286,6 +293,8 @@ fn fleet_converge_mode_scope(mode: FleetConvergeWorkflowMode) -> FleetConvergeSc RunnerPasswordSessionToolConverge => none R2MintPreflight => none R2ObjectWriteMint => none + ApprovalKeyringConverge => none + MtCollins1Boot => none OrgRunnerRosterObserve => none } } @@ -1203,6 +1212,61 @@ fn fleet_converge_r2_object_write_mint_step() -> Step { } } +data fleet_converge_approval_keyring_converge_step_if: String = fleet_converge_mode_step_if(mode: ApprovalKeyringConverge) +data fleet_converge_mtcollins1_boot_step_if: String = fleet_converge_mode_step_if(mode: MtCollins1Boot) + +fn fleet_converge_approval_keyring_converge_step() -> Step { + RunStep { + name: Present { value: "Materialize approval MAC keys on srv1 (SM pinned -> sha256 prefix -> /etc/gunbc-roadmap 0640)" }, + id: Present { value: "approval_keyring_converge" }, + run: gunbc_ci_approval_keyring_converge_invoke(), + shell: none, + env: Present { value: [ + kv(key: "WIF_ACCESS_TOKEN", value: yaml_string(s: "${{ steps.wif_auth.outputs.access_token }}")), + kv(key: fleet_converge_expected_host_env_name, value: yaml_string(s: "${{ github.event.inputs.host }}")), + ] }, + working_directory: none, + if_condition: Present { value: fleet_converge_approval_keyring_converge_step_if }, + continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_aux_step_timeout_minutes } + } +} + +fn fleet_converge_mtcollins1_boot_step() -> Step { + RunStep { + name: Present { value: "Mt. Collins unit 1 approval-gated diskless boot (file request, poll, grant, attach, CD handoff)" }, + id: Present { value: "mtcollins1_boot" }, + run: gunbc_ci_mtcollins1_boot_invoke(), + shell: none, + env: Present { value: [ + kv(key: "WIF_ACCESS_TOKEN", value: yaml_string(s: "${{ steps.wif_auth.outputs.access_token }}")), + kv(key: fleet_converge_expected_host_env_name, value: yaml_string(s: "${{ github.event.inputs.host }}")), + ] }, + working_directory: none, + if_condition: Present { value: fleet_converge_mtcollins1_boot_step_if }, + continue_on_error: none, + timeout_minutes: Present { value: minute_count(mtcollins1_boot_step_timeout_minutes()) } + } +} + +fn fleet_converge_mtcollins1_boot_receipt_upload_step() -> Step { + UsesStep { + name: Present { value: "Upload Mt. Collins unit 1 boot SOL capture and receipts" }, + id: Present { value: "mtcollins1_boot_receipt_upload" }, + uses: upload_artifact_action, + with: Present { value: [ + kv(key: "name", value: yaml_string(s: "mtcollins1-boot-receipts")), + kv(key: "path", value: yaml_string(s: mtcollins1_boot_artifact_glob)), + kv(key: "if-no-files-found", value: yaml_string(s: "warn")), + kv(key: "retention-days", value: yaml_int(n: 30)), + ] }, + env: none, + if_condition: Present { value: join(["always() && ", fleet_converge_mtcollins1_boot_step_if], "") }, + continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_artifact_transfer_step_timeout_minutes } + } +} + // THE ATTEMPT IDENTITY IS A WORKFLOW FACT AND IS OWNED HERE; THE DIRECTORY SHAPE IS THE MINT'S. // A self-hosted runner's /tmp survives across attempts, so an always() uploader over a shared path // could collect a PREVIOUS attempt's orphan receipt as this attempt's evidence -- and an operator @@ -1587,6 +1651,9 @@ fn fleet_converge_job() -> Job { fleet_converge_r2_mint_preflight_receipt_upload_step(), fleet_converge_r2_object_write_mint_step(), fleet_converge_r2_object_write_mint_receipt_upload_step(), + fleet_converge_approval_keyring_converge_step(), + fleet_converge_mtcollins1_boot_step(), + fleet_converge_mtcollins1_boot_receipt_upload_step(), fleet_converge_guest_image_observe_step(), fleet_converge_guest_image_converge_step(), fleet_converge_guest_image_receipt_upload_step(), diff --git a/dag/gunbc/host/host_reset_return_run.dag b/dag/gunbc/host/host_reset_return_run.dag index 2fe0ebd0142..208ad1e594e 100644 --- a/dag/gunbc/host/host_reset_return_run.dag +++ b/dag/gunbc/host/host_reset_return_run.dag @@ -75,10 +75,12 @@ import gunbc.host_reset_return { import gunbc.host_reset_subject_roster { ResetSubjectCandidate, reset_observer_peer_roster, reset_subject_candidate_for, reset_subject_roster } import gunbc.machine_intake_oob_boot_handoff { oob_boot_handoff, - BootHandoffCompleted, + BootControlIssued, BootHandoffNotAdmitted, BootSelectionFlagInvalid, BootSelectionNotApplied, + BootSelectionFirmwareMismatch, + BootSelectionNotOneShot, BootSelectionRefused, BootSelectionUnverifiable, OobBootHandoffOutcome, @@ -307,9 +309,9 @@ fn subject_baseline(host: NonEmptyStr) -> HostReturnObservation? // keeps them apart in the first place. fn acknowledgement_of_handoff(outcome: OobBootHandoffOutcome, issued_at: EpochMs) -> ResetCommandAcknowledgement { match outcome { - BootHandoffCompleted { selected: d } => + BootControlIssued { selected: d } => ResetAccepted { - controller_response: join(["boot source override verified for '", d as String, "' and the power action was accepted"], "") as NonEmptyStr, + controller_response: join(["boot source override verified for '", d as String, "' and the power action was accepted; this is BootControlIssued, not proof the artifact booted"], "") as NonEmptyStr, issued_at: issued_at, } BootSelectionRefused => @@ -322,6 +324,14 @@ fn acknowledgement_of_handoff(outcome: OobBootHandoffOutcome, issued_at: EpochMs ResetRefusedByController { diagnostic: join(["the override for '", d as String, "' was reported alongside an INVALID boot flag, so it will not be honoured; read back: ", r], "") as NonEmptyStr, } + BootSelectionNotOneShot { requested: d, readback: r } => + ResetRefusedByController { + diagnostic: join(["the override for '", d as String, "' is not one-shot next-boot; read back: ", r], "") as NonEmptyStr, + } + BootSelectionFirmwareMismatch { requested: d, readback: r } => + ResetRefusedByController { + diagnostic: join(["the override for '", d as String, "' does not match the required firmware mode; read back: ", r], "") as NonEmptyStr, + } BootSelectionUnverifiable { requested: d, readback: r } => ResetRefusedByController { diagnostic: join(["boot parameter 5 did not read back, so the override for '", d as String, "' is unverified; output: ", r], "") as NonEmptyStr, @@ -452,8 +462,7 @@ fn host_reset_return_issue_and_watch( capability_row: candidate.capability_row, username: candidate.username, password_file: password_file, - device: candidate.boot_device, - expected_rendering: candidate.boot_readback_rendering, + selection: candidate.boot_selection, power_action: candidate.power_action, ) let ack = acknowledgement_of_handoff(outcome: outcome, issued_at: issued_at) diff --git a/dag/gunbc/host/host_reset_subject_roster.dag b/dag/gunbc/host/host_reset_subject_roster.dag index 8167589e068..945bfd0b38c 100644 --- a/dag/gunbc/host/host_reset_subject_roster.dag +++ b/dag/gunbc/host/host_reset_subject_roster.dag @@ -9,14 +9,14 @@ import gunbc.machine_intake_oob_boot_handoff { oob_boot_handoff_admission, oob_handoff_is_admitted, } +import extdeps.bmc.ipmi_boot_selection { IpmiBootSelection } import gunbc.machine_intake_mtcollins1_access_observation { mtcollins1_access_observation, mtcollins1_capability_row, } import gunbc.machine_intake_mtcollins1_actuate { mtcollins1_bmc_username, - mtcollins1_cdrom_device, - mtcollins1_cdrom_readback_rendering, + mtcollins1_cdrom_selection, mtcollins1_power_action, } import gunbc.fleet_intent_network { operator_host_mtcollins1 } @@ -40,21 +40,20 @@ import gunbc.fleet_runner_connectivity { fleet_hosts } // and there is no second place to edit. // THE PER-HOST RESET POLICY TRAVELS WITH THE ROSTER ROW, and that is the layer it belongs to rather // than an accident of convenience. gunbc.machine_intake_oob_boot_handoff takes the boot device, the -// expected read-back rendering and the power action as PARAMETERS precisely because which of them a -// workflow picks is business policy, and its own note says so; this roster is the workflow-layer -// authority that picks them for a reset. +// boot options, the parameter-5 expectation and the power action as PARAMETERS precisely because +// which of them a workflow picks is business policy, and its own note says so; this roster is the +// workflow-layer authority that picks them for a reset. // // THE VALUES ARE IMPORTED, NEVER RE-SPELLED. gunbc.machine_intake_mtcollins1_actuate already carries -// the device word, the exact text ipmitool renders for it, and the cycle verb the unit's SEL -// confirmed. The username is mtcollins1_bmc_username — the BmcSecured managed account, not a -// factory literal. A host that is not BmcSecured is not a reset candidate. +// the device word, the boot-options word, the four-axis parameter-5 expectation, and the cycle verb +// the unit's SEL confirmed. The username is mtcollins1_bmc_username — the BmcSecured managed account, +// not a factory literal. A host that is not BmcSecured is not a reset candidate. type ResetSubjectCandidate { host: HostIdentity observation: BmcAccessObservation capability_row: BmcFirmwareReleaseCapabilityRow username: NonEmptyStr - boot_device: NonEmptyStr - boot_readback_rendering: NonEmptyStr + boot_selection: IpmiBootSelection power_action: NonEmptyStr } @@ -77,8 +76,7 @@ fn reset_subject_candidates() -> List { observation: mtcollins1_access_observation, capability_row: mtcollins1_capability_row, username: username, - boot_device: mtcollins1_cdrom_device, - boot_readback_rendering: mtcollins1_cdrom_readback_rendering, + boot_selection: mtcollins1_cdrom_selection, power_action: mtcollins1_power_action, }, ] diff --git a/dag/gunbc/instruments/approval_store_live_probe.dag b/dag/gunbc/instruments/approval_store_live_probe.dag index e959dc957fd..170647032ac 100644 --- a/dag/gunbc/instruments/approval_store_live_probe.dag +++ b/dag/gunbc/instruments/approval_store_live_probe.dag @@ -13,7 +13,7 @@ import gunbc.auth.approval_request_submission { PublisherTokenUnavailable, SubmissionFiledPublisherTokenUnavailable, SubmissionAlreadyFiled, SubmissionRefused, SubmissionNotEnrolled, } import gunbc.auth.approval_decision_store { - ApprovalKeyLoaded, approval_mac_key_id, + ApprovalKeyLoaded, approval_mac_key_id, approval_capability_authority, StoredApprovalRequest, file_approval_request, StoreWritten, StoreSlotOccupied, StoreRefused, capability_path_segment, IdentityHeaderTrustEstablished, redeem_over_store, RedeemedApproved, RedeemedDenied, AlreadyDecided, RedemptionEscalationNotFiled, RedemptionBrokerRefused, diff --git a/dag/gunbc/legacy_extdeps_scope_frontier.tsv b/dag/gunbc/legacy_extdeps_scope_frontier.tsv index 02a4ff34876..e701215923e 100644 --- a/dag/gunbc/legacy_extdeps_scope_frontier.tsv +++ b/dag/gunbc/legacy_extdeps_scope_frontier.tsv @@ -19,6 +19,7 @@ dag/extdeps/bmc/capability.dag dag/extdeps/bmc.dag dag/extdeps/bmc/http.dag dag/extdeps/bmc/ipmi.dag +dag/extdeps/bmc/ipmi_boot_selection.dag dag/extdeps/bmc/openbmc.dag dag/extdeps/bmc/openbmc_fan_control.dag dag/extdeps/bmc/openbmc_operation.dag diff --git a/dag/gunbc/machine_intake/megarac_media_attach.dag b/dag/gunbc/machine_intake/megarac_media_attach.dag index 1293b1b022f..4bdc1e74385 100644 --- a/dag/gunbc/machine_intake/megarac_media_attach.dag +++ b/dag/gunbc/machine_intake/megarac_media_attach.dag @@ -77,9 +77,14 @@ import extdeps.languages.json.parse { // controller's session table is finite — this lane exhausted one service's sessions by opening one // per probe and closing none — so the release has to be observable rather than assumed. // -// SessionReleaseUncertain EXISTS BECAUSE A LOST RESPONSE IS NOT A KNOWN STATE. A DELETE whose reply -// never arrived may have released the session or may not; treating that as released would -// under-count leaks exactly when the transport is already misbehaving, which is when leaks matter. +// A LOST OR UNCLASSIFIED GET IS NOT A RELEASED SESSION. CloseSession's own exit is discarded on +// purpose (this firmware has answered HTTP 401 on DELETE while an independent probe showed the +// session gone). The standing is the probe that follows, on a route this firmware serves +// (ProbeSessionOnMediaRoute), with curl --fail-with-body plus a trailing `%{http_code}` write-out, +// so a timeout (`000`), a drop, and an HTTP 401 are different stdout. Only a 401/403 after a +// SUCCEEDING probe before the close, under a parsed X-CSRFTOKEN, is evidence the cookie no longer +// authenticates; every other outcome, and any probe that used a sentinel header, stays +// SessionReleaseRefused. type SessionRelease = SessionReleased | SessionReleaseRefused { detail: String } @@ -172,12 +177,10 @@ fn start_media_body(image_name: NonEmptyStr, image_index: String) -> NonEmptyStr // THE EFFECT SEQUENCE. Session, list, resolve, start, confirm — and the confirmation is a separate // read rather than the start call's own answer, for the reason given at the top of this module. // -// THE CREDENTIAL IS A VALUE HERE AND A FILE PATH EVERYWHERE ELSE IN THIS LANE, and that asymmetry is -// forced rather than chosen: OpenSession is form-encoded, and curl offers no netrc equivalent for a -// form body, so the password reaches argv for exactly one call. Every subsequent operation carries -// no credential at all because the cookie is the bearer. The ipmi operations beside this take a -// password FILE for the same threat and can, because -f exists there. Naming the asymmetry is the -// point; hiding it behind a wrapper would make one call's exposure look like none. +// THE CREDENTIAL IS A FILE PATH ON EVERY CALL, INCLUDING OpenSession: curl's --data-urlencode +// password@{password_file} reads the form value from the file, so the password never reaches argv +// (extdeps.bmc.megarac OpenSession). Every subsequent operation carries no credential at all +// because the cookie is the bearer. // // AN OPENED SESSION WHOSE TOKEN CANNOT BE READ IS STILL AN ALLOCATED SESSION. An earlier // revision returned SessionReleaseNotAttempted here, which is the leak that saturated this @@ -185,11 +188,108 @@ fn start_media_body(image_name: NonEmptyStr, image_index: String) -> NonEmptyStr // the renderer treated the arm as "never opened". The DELETE still needs an X-CSRFTOKEN header // by the upstream contract; the cookie is the bearer, so the header is supplied as a sentinel // rather than inventing a second CloseSession shape. A refused or uncertain close remains -// SessionReleaseRefused, never NotAttempted. +// SessionReleaseRefused, never NotAttempted. HTTP 401 after CloseSession is evidence the cookie +// no longer authenticates only when the GET presented a parsed X-CSRFTOKEN; a sentinel header +// makes 401 indistinguishable from header rejection (review 67653). +type SessionCsrfStanding + = SessionCsrfParsed + | SessionCsrfUnreadable + +fn megarac_session_get_writeout_code(get_body: String) -> String { + match split(s: get_body, delimiter: "\n").last() { + Present { value: code } => code + Absent => "" + } +} + +// WHAT ONE COOKIE-BEARING GET ESTABLISHES, AND NOTHING MORE. The probe is a readonly GET on a +// route THIS firmware serves (the remote-media configurations route, which OpenSession's cookie +// succeeds on moments earlier), with the HTTP status as the trailing line. A succeeding GET is a +// session the controller still recognises; a 401 or 403 is a cookie it no longer names -- the +// ABSENCE observation. GET /api/session is NOT that route on this unit: it answers 404 with a live +// cookie (boots 20 and 21, runs 35437393331 and 35445470385), so a 404 here is the route missing, +// not the session, and is refused as unreadable rather than widened into absent. A mid-body code +// with a different trailer is not a status either. +type SessionPresence + = SessionPresent + | SessionAbsent { code: String } + | SessionPresenceUnreadable { detail: String } + +fn megarac_session_presence(get_succeeded: Bool, get_body: String) -> SessionPresence { + if get_succeeded { + SessionPresent + } else { + let code = megarac_session_get_writeout_code(get_body: get_body) + if code == "401" || code == "403" { + SessionAbsent { code: code } + } else { + SessionPresenceUnreadable { detail: get_body } + } + } +} + +// RELEASE IS A TRANSITION, NOT A STATUS. A 4xx AFTER CloseSession proves the cookie is gone only +// if the SAME GET succeeded BEFORE CloseSession: a route that 404s regardless of the cookie, or +// a cookie the controller had already dropped, answers 4xx both times and would otherwise read +// as a clean release -- the absorbing fallback DESIGN section 5 forbids, one status code wide. +// So the fold takes both observations and mints SessionReleased for exactly present -> absent. +fn megarac_session_release_by_reobservation( + before: SessionPresence, + after: SessionPresence, + csrf: SessionCsrfStanding, +) -> SessionRelease { + match csrf { + SessionCsrfUnreadable => + SessionReleaseRefused { detail: "the session probe around CloseSession used a sentinel X-CSRFTOKEN because the token was unreadable; a refusal here does not prove the cookie is gone" } + SessionCsrfParsed => + match before { + SessionPresent => + match after { + SessionAbsent { code: _ } => SessionReleased + SessionPresent => SessionReleaseRefused { detail: "the session probe still succeeded after CloseSession" } + SessionPresenceUnreadable { detail: d } => + SessionReleaseRefused { detail: concat("the session probe after CloseSession was unreadable; refusing to treat the session as released: ", d) } + } + SessionAbsent { code: c } => + SessionReleaseRefused { detail: concat("the session probe was already refused BEFORE CloseSession (code ", concat(c, "); a refusal after it proves nothing about release -- the route or the cookie was not observable")) } + SessionPresenceUnreadable { detail: d } => + SessionReleaseRefused { detail: concat("the session probe before CloseSession was unreadable; refusing to adjudicate release: ", d) } + } + } +} + +fn megarac_close_and_reobserve( + bmc_host: NonEmptyStr, + cookie_jar: NonEmptyStr, + csrf_token: NonEmptyStr, + csrf: SessionCsrfStanding, +) -> SessionRelease + uses net: std.resources.Network +{ + let before_get = megarac.Media.ProbeSessionOnMediaRoute( + bmc_host: bmc_host, + cookie_jar: cookie_jar, + csrf_token: csrf_token, + ) + let before = megarac_session_presence(get_succeeded: before_get.success, get_body: before_get.body) + let _closed = megarac.Media.CloseSession( + bmc_host: bmc_host, + cookie_jar: cookie_jar, + csrf_token: csrf_token, + ) + let after_get = megarac.Media.ProbeSessionOnMediaRoute( + bmc_host: bmc_host, + cookie_jar: cookie_jar, + csrf_token: csrf_token, + ) + let after = megarac_session_presence(get_succeeded: after_get.success, get_body: after_get.body) + return megarac_session_release_by_reobservation(before: before, after: after, csrf: csrf) +} + fn megarac_attach_remote_image( bmc_host: NonEmptyStr, username: NonEmptyStr, - password: NonEmptyStr, + password_file: NonEmptyStr, cookie_jar: NonEmptyStr, image_name: NonEmptyStr, firmware: BmcFirmwareReleaseIdentity, @@ -199,7 +299,7 @@ fn megarac_attach_remote_image( session = megarac.Media.OpenSession( bmc_host: bmc_host, username: username, - password: password, + password_file: password_file, cookie_jar: cookie_jar, ) if session.success == false { @@ -243,14 +343,14 @@ fn megarac_release_unreadable_session( ) -> MegaRacAttachResult uses net: std.resources.Network { - closed = megarac.Media.CloseSession( - bmc_host: bmc_host, - cookie_jar: cookie_jar, - csrf_token: "unreadable" as NonEmptyStr, - ) return MegaRacAttachResult { outcome: MegaRacSessionTokenUnreadable { detail: session_body }, - session_release: if closed.success { SessionReleased } else { SessionReleaseRefused { detail: closed.body } }, + session_release: megarac_close_and_reobserve( + bmc_host: bmc_host, + cookie_jar: cookie_jar, + csrf_token: "unreadable" as NonEmptyStr, + csrf: SessionCsrfUnreadable, + ), } } @@ -274,15 +374,14 @@ fn megarac_attach_then_release( firmware: firmware, ) - closed = megarac.Media.CloseSession( - bmc_host: bmc_host, - cookie_jar: cookie_jar, - csrf_token: csrf_token, - ) - return MegaRacAttachResult { outcome: outcome, - session_release: if closed.success { SessionReleased } else { SessionReleaseRefused { detail: closed.body } }, + session_release: megarac_close_and_reobserve( + bmc_host: bmc_host, + cookie_jar: cookie_jar, + csrf_token: csrf_token, + csrf: SessionCsrfParsed, + ), } } diff --git a/dag/gunbc/machine_intake/mtcollins1_actuate.dag b/dag/gunbc/machine_intake/mtcollins1_actuate.dag index 5a6d7e4a4d4..9d8652a2b19 100644 --- a/dag/gunbc/machine_intake/mtcollins1_actuate.dag +++ b/dag/gunbc/machine_intake/mtcollins1_actuate.dag @@ -15,16 +15,24 @@ import gunbc.machine_intake_mtcollins1_access_observation { } import gunbc.machine_intake_mtcollins1_bmc_secure_observation { mtcollins1_secured_account } import gunbc.machine_intake_oob_boot_handoff { - BootHandoffCompleted, + BootControlIssued, BootHandoffNotAdmitted, BootSelectionFlagInvalid, BootSelectionNotApplied, + BootSelectionFirmwareMismatch, + BootSelectionNotOneShot, BootSelectionRefused, BootSelectionUnverifiable, OobBootHandoffOutcome, PowerCycleRefused, oob_boot_handoff, } +import extdeps.bmc.ipmi_boot_selection { + IpmiBootCdrom, + IpmiBootPxe, + IpmiBootSelection, + aarch64_uefi_once_boot_selection, +} // THE POLICY LAYER FOR ONE HOST, and it is a separate module from the arm on purpose. The arm takes // device, power action and expected rendering as PARAMETERS because which of those a workflow picks @@ -36,14 +44,9 @@ import gunbc.machine_intake_oob_boot_handoff { // around a parameterized probe. It is NOT a per-host fork of the arm: everything host-specific here // is a value, and a second host is a second wrapper over the identical operation, never a second // operation. The one argument is the credential path, for the reason given on mtcollins1_password_file. -data mtcollins1_boot_device: NonEmptyStr = "pxe" +data mtcollins1_netboot_selection: IpmiBootSelection = aarch64_uefi_once_boot_selection(device: IpmiBootPxe) -// WHAT ipmitool RENDERS WHEN THE OVERRIDE TOOK. `chassis bootparam get 5` prints a "Boot Device -// Selector : Force PXE" line, and the wire word sent to `chassis bootdev` is "pxe" — two different -// vocabularies for one fact, so the expected text is supplied here rather than guessed inside the -// arm. A mapping guessed there would be a second, undeclared authority on IPMI's boot device names. -// Measured on this unit 2026-09-05. -data mtcollins1_boot_readback_rendering: NonEmptyStr = "Force PXE" +data mtcollins1_cdrom_selection: IpmiBootSelection = aarch64_uefi_once_boot_selection(device: IpmiBootCdrom) // A CYCLE AND NOT A RESET, because the unit's own SEL is the evidence that a cycle takes effect // here: driving `chassis power cycle` produced "Power Unit — Power cycle | Asserted" and "System @@ -104,13 +107,17 @@ fn mtcollins1_password_file(raw: String) -> CredentialPathStanding { // conflation DESIGN's failure-mode list names. fn mtcollins1_render_outcome(label: NonEmptyStr, outcome: OobBootHandoffOutcome) -> ProcessExit { match outcome { - BootHandoffCompleted { selected: _ } => ExitSuccess + BootControlIssued { selected: _ } => ExitSuccess BootSelectionRefused => exit_failure(reason: concat(label as String, ": controller refused the boot source override")) BootSelectionNotApplied { requested: d, readback: r } => exit_failure(reason: concat(label as String, ": override for '", d as String, "' did not take; boot parameter 5 read back: ", r)) BootSelectionFlagInvalid { requested: d, readback: r } => exit_failure(reason: concat(label as String, ": override for '", d as String, "' reported alongside an INVALID boot flag, so it will not be honoured; boot parameter 5 read back: ", r)) + BootSelectionNotOneShot { requested: d, readback: r } => + exit_failure(reason: concat(label as String, ": override for '", d as String, "' is not one-shot next-boot; boot parameter 5 read back: ", r)) + BootSelectionFirmwareMismatch { requested: d, readback: r } => + exit_failure(reason: concat(label as String, ": override for '", d as String, "' does not match the required firmware mode; boot parameter 5 read back: ", r)) BootSelectionUnverifiable { requested: d, readback: r } => exit_failure(reason: concat(label as String, ": boot parameter 5 read did not succeed, so the override for '", d as String, "' is unverified; output: ", r)) PowerCycleRefused { selected: d } => @@ -124,8 +131,7 @@ fn mtcollins1_drive_handoff( label: NonEmptyStr, password_file_raw: String, capability_row: BmcFirmwareReleaseCapabilityRow, - device: NonEmptyStr, - expected_rendering: NonEmptyStr, + selection: IpmiBootSelection, ) -> ProcessExit uses net: std.resources.Network { @@ -144,8 +150,7 @@ fn mtcollins1_drive_handoff( capability_row: capability_row, username: username, password_file: pw, - device: device, - expected_rendering: expected_rendering, + selection: selection, power_action: mtcollins1_power_action, ), ) @@ -160,8 +165,7 @@ fn mtcollins1_netboot_handoff(password_file_raw: String) -> ProcessExit label: "mtcollins1 handoff", password_file_raw: password_file_raw, capability_row: mtcollins1_capability_row, - device: mtcollins1_boot_device, - expected_rendering: mtcollins1_boot_readback_rendering, + selection: mtcollins1_netboot_selection, ) } @@ -178,13 +182,6 @@ fn mtcollins1_netboot_handoff(password_file_raw: String) -> ProcessExit // removable media. The DHCP capture in gunbc.machine_intake_mtcollins1_netboot_client_observation // records the other half — the host asks for a boot file over PXE and nothing on the segment // answers — so today this is the only delivery route that reaches this machine at all. -data mtcollins1_cdrom_device: NonEmptyStr = "cdrom" - -// Measured, not guessed: `chassis bootdev cdrom` followed by `chassis bootparam get 5` renders -// "Boot Device Selector : Force Boot from CD/DVD". The netboot route's rendering is "Force PXE", -// a different string for the same field, which is exactly why the arm takes the expected text from -// its caller rather than deriving it from the device word. -data mtcollins1_cdrom_readback_rendering: NonEmptyStr = "Force Boot from CD/DVD" fn mtcollins1_cdrom_handoff(password_file_raw: String) -> ProcessExit uses net: std.resources.Network @@ -193,8 +190,7 @@ fn mtcollins1_cdrom_handoff(password_file_raw: String) -> ProcessExit label: "mtcollins1 cdrom handoff", password_file_raw: password_file_raw, capability_row: mtcollins1_capability_row, - device: mtcollins1_cdrom_device, - expected_rendering: mtcollins1_cdrom_readback_rendering, + selection: mtcollins1_cdrom_selection, ) } @@ -234,7 +230,6 @@ fn mtcollins1_denied_admission_control(password_file_raw: String) -> ProcessExit label: "mtcollins1 denied-admission control", password_file_raw: password_file_raw, capability_row: mtcollins1_mismatched_capability_row, - device: mtcollins1_boot_device, - expected_rendering: mtcollins1_boot_readback_rendering, + selection: mtcollins1_netboot_selection, ) } diff --git a/dag/gunbc/machine_intake/mtcollins1_boot_authorization.dag b/dag/gunbc/machine_intake/mtcollins1_boot_authorization.dag new file mode 100644 index 00000000000..545c04f41fd --- /dev/null +++ b/dag/gunbc/machine_intake/mtcollins1_boot_authorization.dag @@ -0,0 +1,190 @@ +module gunbc.machine_intake_mtcollins1_boot_authorization + +import std.types { Bool, List, NonEmptyStr, String, Timestamp } +import std.content_hash { ContentHash, content_hash_of_value } +import std.effect_grant { NamespacePosition, ServiceOpTree, Write } +import std.scoped_authorization { + AuthorizedAction, + AttemptIdentity, + AuthorizationScope, + AuthorizationRequest, + ScopedAuthorization, + AuthorizationGranted, + AuthorizationDenied, + authorize, + AuthorizationPermitted, + AuthorizationRefused, +} +import gunbc.auth.approval_decision_store { + grant_from_approved_decision, GrantDerived, GrantForAnotherIntent, + request_revision_of, +} +import gunbc.auth.approval_request_client { + ApprovalPollStanding, PollApproved, PollDenied, PollExpired, PollStillPending, + PollNotFiled, PollUnreadable, +} +import gunbc.machine_intake_mtcollins1_boot_artifact { mtcollins1_diskless_image_name, mtcollins1_boot_export_dir } +import gunbc.machine_intake_mtcollins1_boot_image_fetch { mtcollins1_boot_image_sha256 } +import gunbc.machine_intake_mtcollins1_actuate { mtcollins1_power_action, mtcollins1_cdrom_selection } +import gunbc.machine_intake_mtcollins1_access_observation { mtcollins1_endpoint } +import gunbc.fleet_intent_network { operator_host_srv2 } +import extdeps.bmc.ipmi_boot_selection { + IpmiBootSelection, + ipmi_boot_selection_label, +} +import extdeps.toolchain.types { architecture_diagnostic_name } + +type MtCollins1BootSubject { + machine: NonEmptyStr + bmc_host: NonEmptyStr + image_name: NonEmptyStr + image_sha256: NonEmptyStr + export_host: NonEmptyStr + export_dir: NonEmptyStr + selection: IpmiBootSelection + power_action: NonEmptyStr + execution_revision: NonEmptyStr +} + +data mtcollins1_boot_machine: NonEmptyStr = "mtcollins1" + +fn mtcollins1_boot_subject(execution_revision: NonEmptyStr) -> MtCollins1BootSubject { + MtCollins1BootSubject { + machine: mtcollins1_boot_machine, + bmc_host: mtcollins1_endpoint.host, + image_name: mtcollins1_diskless_image_name, + image_sha256: mtcollins1_boot_image_sha256, + export_host: operator_host_srv2 as NonEmptyStr, + export_dir: mtcollins1_boot_export_dir, + selection: mtcollins1_cdrom_selection, + power_action: mtcollins1_power_action, + execution_revision: execution_revision, + } +} + +data mtcollins1_virtual_media_resource: NamespacePosition = NamespacePosition { + tree: ServiceOpTree { service: "megarac" }, + path: ["media", "remote-image"], +} + +data mtcollins1_virtual_media_scope: AuthorizationScope = AuthorizationScope { + verb: Write, + resource: mtcollins1_virtual_media_resource, + action: "megarac.virtual-media.attach" as AuthorizedAction, +} + +data mtcollins1_ipmi_boot_resource: NamespacePosition = NamespacePosition { + tree: ServiceOpTree { service: "ipmi" }, + path: ["chassis", "bootdev"], +} + +data mtcollins1_ipmi_boot_scope: AuthorizationScope = AuthorizationScope { + verb: Write, + resource: mtcollins1_ipmi_boot_resource, + action: "ipmi.chassis.bootdev.set" as AuthorizedAction, +} + +data mtcollins1_ipmi_power_resource: NamespacePosition = NamespacePosition { + tree: ServiceOpTree { service: "ipmi" }, + path: ["chassis", "power"], +} + +data mtcollins1_ipmi_power_scope: AuthorizationScope = AuthorizationScope { + verb: Write, + resource: mtcollins1_ipmi_power_resource, + action: "ipmi.chassis.power.cycle" as AuthorizedAction, +} + +data mtcollins1_boot_required_scopes: List = [ + mtcollins1_virtual_media_scope, + mtcollins1_ipmi_boot_scope, + mtcollins1_ipmi_power_scope, +] + +fn mtcollins1_boot_intent_hash(subject: MtCollins1BootSubject) -> ContentHash { + content_hash_of_value( + value: join([ + subject.machine as String, + subject.bmc_host as String, + subject.image_name as String, + subject.image_sha256 as String, + subject.export_host as String, + subject.export_dir as String, + architecture_diagnostic_name(arch: subject.selection.architecture) as String, + ipmi_boot_selection_label(selection: subject.selection) as String, + subject.power_action as String, + subject.execution_revision as String, + ], "|") as NonEmptyStr, + ) +} + +data mtcollins1_boot_purpose: NonEmptyStr = "Attach the diskless Ubuntu live image already exported on srv2 to Mt. Collins unit 1 over BMC virtual media, set one-shot CD boot, and power-cycle the host. This interrupts anything the unit is currently running." as NonEmptyStr + +fn mtcollins1_boot_authorization_request( + escalation_id: NonEmptyStr, + attempt: AttemptIdentity, + execution_revision: NonEmptyStr, +) -> AuthorizationRequest { + let subject = mtcollins1_boot_subject(execution_revision: execution_revision) + AuthorizationRequest { + escalation_id: escalation_id, + scopes: mtcollins1_boot_required_scopes, + subject: subject, + purpose: mtcollins1_boot_purpose, + attempt: attempt, + intent_hash: mtcollins1_boot_intent_hash(subject: subject), + destructive: true, + } +} + +type MtCollins1BootGate + = BootGateAdmitted { authorization: ScopedAuthorization } + | BootGatePending { escalation_id: NonEmptyStr } + | BootGateDenied { reason: NonEmptyStr } + | BootGateExpired + | BootGateNotFiled { escalation_id: NonEmptyStr } + | BootGateIntentMismatch { requested_revision: NonEmptyStr, decided_revision: NonEmptyStr } + | BootGateStoreUnreadable { detail: NonEmptyStr } + | BootGateAuthorizeRefused { detail: NonEmptyStr } + +fn mtcollins1_boot_gate_from_poll( + poll: ApprovalPollStanding, + request: AuthorizationRequest, + observed_at: Timestamp, +) -> MtCollins1BootGate { + match poll { + PollStillPending => BootGatePending { escalation_id: request.escalation_id } + PollNotFiled => BootGateNotFiled { escalation_id: request.escalation_id } + PollExpired => BootGateExpired + PollUnreadable { detail: d } => BootGateStoreUnreadable { detail: d } + PollDenied { revision: r, decided_by: _, decided_at: _, reason: why } => + if r != request_revision_of(request: request) { + BootGateIntentMismatch { + requested_revision: request_revision_of(request: request), + decided_revision: r, + } + } else { + BootGateDenied { reason: why } + } + PollApproved { revision: r, decided_by: by, decided_at: at, execute_by: until } => + match grant_from_approved_decision( + request: request, + revision: r, + decided_by: by, + decided_at: at, + execute_by: until, + ) { + GrantForAnotherIntent { requested_revision: a, decided_revision: b } => + BootGateIntentMismatch { requested_revision: a, decided_revision: b } + GrantDerived { authorization: auth } => + match authorize(authorization: auth, request: request, observed_at: observed_at) { + AuthorizationPermitted { grant: _ } => BootGateAdmitted { authorization: auth } + AuthorizationRefused { cause: _ } => + BootGateAuthorizeRefused { detail: "scoped authorization refused the HTTP grant for this request" as NonEmptyStr } + } + _ => + BootGateStoreUnreadable { detail: "approved GET standing did not yield a grant" as NonEmptyStr } + } + } +} + diff --git a/dag/gunbc/machine_intake/mtcollins1_boot_run.dag b/dag/gunbc/machine_intake/mtcollins1_boot_run.dag new file mode 100644 index 00000000000..461425ee683 --- /dev/null +++ b/dag/gunbc/machine_intake/mtcollins1_boot_run.dag @@ -0,0 +1,729 @@ +module gunbc.machine_intake_mtcollins1_boot_run + +import std.types { Bool, Int, NonEmptyStr, String, Timestamp } +import std.upsert_decision { ObservationVerdict, Converged, Absent, Drifted, Conflict, Inaccessible, UnknownRefused } +import std.temporal_effect { + HeldLeaseObservedState, + LeaseAbsent, + LeaseRunningExpected, + LeaseRunningStale, + LeaseInaccessible, + held_lease_observed_verdict, +} +import std.algebra { trim } +import std.measure { Second, second, second_count, Minute, minute, hardware_thread_count_value } +import std.process { ProcessExit, ExitSuccess, ExitFailure, exit_failure } +import std.scoped_authorization { + AttemptIdentity, AuthorizationRequest, + authorize, AuthorizationPermitted, AuthorizationRefused, authorization_refusal_reason, + ClaimHeld, ClaimLost, ClaimUndecided, +} +import gunbc.authorization_claim_slot { claim_authorization_slot, settle_authorization_claim, unterminated_claim_exit } +import gunbc.machine_intake_mtcollins1_boot_authorization { + MtCollins1BootGate, + MtCollins1BootSubject, + mtcollins1_boot_authorization_request, + mtcollins1_boot_gate_from_poll, + BootGateAdmitted, BootGatePending, BootGateDenied, BootGateExpired, BootGateNotFiled, + BootGateIntentMismatch, BootGateStoreUnreadable, BootGateAuthorizeRefused, +} +import gunbc.auth.approval_request_client { + stored_request_from_authorization, sign_stored_request, + approval_submit_url, approval_status_url, + approval_poll_standing, + SubmissionMacReady, SubmissionMacKeyNotHex, + PollStillPending, +} +import gunbc.auth.approval_status_wire { + parse_filed_approval_status, FiledApprovalStatus, StatusUnreadable, submission_body_admits_poll, +} +import gunbc.auth.approval_request_submission { submission_envelope_json } +import gunbc.auth.approval_keyring_pins { approval_submission_mac_key_path_env } +import gunbc.machine_intake_mtcollins1_media_attach { mtcollins1_attach_diskless_image } +import gunbc.machine_intake_mtcollins1_actuate { mtcollins1_bmc_username, mtcollins1_drive_handoff } +import gunbc.machine_intake_mtcollins1_memory_census_observation { mtcollins1_nproc } +import extdeps.provisioning.ubuntu_install_media { noble_numbat_2404_3_live_server_arm64 } +import gunbc.machine_intake_mtcollins1_access_observation { mtcollins1_endpoint, mtcollins1_capability_row } +import gunbc.host_reset_return_run { host_reset_bmc_credential_path_env } +import gunbc.clock_read { clock_now_probed_at } +import gunbc.auth.approval_capability { utc_instant_is_canonical } +import extdeps.clock { Clock } +import v2.std.optional { Present } +import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, actions_variable_trimmed, fleet_converge_expected_host_env_name } +import gunbc.fleet_intent_network { operator_host_srv1 } +import extdeps.github.actions_environment { github_run_id_variable_name, github_sha_variable_name, github_runner_temp_variable_name } +import extdeps.bmc.ipmi +import gunbc.machine_intake_sol_hold { mtcollins1_sol_hold_shell_emit_dissolution_trigger } +import std.dissolution { dissolution_description } +import extdeps.tools.sleep { sleep_delay_seconds_second_carrier_projection } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.http.client +import std.durable_compare_and_set { CasGeneration } + +data mtcollins1_boot_label: String = "mtcollins1 boot" + +data mtcollins1_boot_sol_capture_path_env: NonEmptyStr = "GUNBC_MTCOLLINS1_SOL_CAPTURE" + +data mtcollins1_boot_sol_pid_path_env: NonEmptyStr = "GUNBC_MTCOLLINS1_SOL_PID_FILE" + +data mtcollins1_boot_artifact_glob: String = "artifacts/mtcollins1-boot-*" + +data mtcollins1_boot_receipt_path: String = "artifacts/mtcollins1-boot-receipt.txt" + +data mtcollins1_boot_poll_cadence: Second = second(count: 15) + +data mtcollins1_boot_poll_tick_budget: Int = 90 + +data mtcollins1_boot_sol_settle: Second = second(count: 2) + +data mtcollins1_boot_prelude_allowance: Second = second(count: 120) + +data mtcollins1_boot_issue_allowance: Second = second(count: 120) + +data mtcollins1_boot_actuation_allowance: Second = second(count: 480) + +data mtcollins1_boot_cleanup_allowance: Second = second(count: 180) + +data mtcollins1_boot_submit_timeout: Second = second(count: 30) + +fn mtcollins1_boot_approval_window() -> Second { + second(count: mtcollins1_boot_poll_tick_budget * (second_count(s: mtcollins1_boot_poll_cadence) as Int)) +} + +fn mtcollins1_boot_step_timeout_minutes() -> Minute { + let secs = second_count(s: mtcollins1_boot_prelude_allowance) + + second_count(s: mtcollins1_boot_approval_window()) + + second_count(s: mtcollins1_boot_issue_allowance) + + second_count(s: mtcollins1_boot_actuation_allowance) + + second_count(s: mtcollins1_boot_cleanup_allowance) + minute(count: (secs + 59) / 60) +} + +fn mtcollins1_status_from_get(success: Bool, body: String) -> FiledApprovalStatus { + if success { + parse_filed_approval_status(body: body) + } else { + StatusUnreadable { detail: "status GET did not succeed; the roadmap standing is unknown, not pending" as NonEmptyStr } + } +} + +fn mtcollins1_approval_expires_at(issued: Timestamp) -> Timestamp? { + if !utc_instant_is_canonical(t: issued) { + none + } else { + Present { value: Clock.TimestampAdd(timestamp: issued, offset: second_count(s: mtcollins1_boot_approval_window())).result } + } +} + +fn mtcollins1_boot_refusal(gate: MtCollins1BootGate) -> ProcessExit { + match gate { + BootGateAdmitted { authorization: _ } => ExitSuccess + BootGatePending { escalation_id: e } => + exit_failure(reason: concat("mtcollins1 boot: still pending operator decision for ", e as String)) + BootGateDenied { reason: r } => + exit_failure(reason: concat("mtcollins1 boot: operator denied; controller untouched: ", r as String)) + BootGateExpired => + exit_failure(reason: "mtcollins1 boot: approval window expired; controller untouched") + BootGateNotFiled { escalation_id: e } => + exit_failure(reason: concat("mtcollins1 boot: no request filed for ", e as String)) + BootGateIntentMismatch { requested_revision: a, decided_revision: b } => + exit_failure(reason: concat("mtcollins1 boot: stored decision is for another intent (", concat(a as String, concat(" vs ", b as String)), ")")) + BootGateStoreUnreadable { detail: d } => + exit_failure(reason: concat("mtcollins1 boot: approval store unreadable: ", d as String)) + BootGateAuthorizeRefused { detail: d } => + exit_failure(reason: concat("mtcollins1 boot: grant present but authorize refused: ", d as String)) + } +} + +fn mtcollins1_boot_instant_from_probe(reading: NonEmptyStr?) -> Timestamp? { + match reading { + Absent => none + Present { value: ts } => + if utc_instant_is_canonical(t: ts as Timestamp) { + Present { value: ts as Timestamp } + } else { + none + } + } +} + +fn mtcollins1_boot_sol_held_from_observations( + pid_path: String, + pid_file_ok: Bool, + pid_file_content: String, + cmdline_ok: Bool, + cmdline_content: String, +) -> Bool { + if pid_path == "" { + false + } else if !pid_file_ok { + false + } else { + let pid = trim(s: pid_file_content) + if pid == "" { + false + } else { + cmdline_ok && string_contains(s: cmdline_content, pattern: "ipmitool") + } + } +} + +fn mtcollins1_sol_observed_from_collector( + pid_path_set: Bool, + pid_file_ok: Bool, + pid_file_content: String, + collector_held: Bool, +) -> HeldLeaseObservedState { + if !pid_path_set { + LeaseInaccessible + } else if collector_held { + LeaseRunningExpected + } else if pid_file_ok && trim(s: pid_file_content) != "" { + LeaseRunningStale + } else { + LeaseAbsent + } +} + +// ONE pid-file read, then the cmdline read that depends on it, then both the +// held-Bool and the lease state from that same observation. Two independent +// Filesystem.Read of the pid file can disagree on a mutable path and feed +// held_lease_observed_verdict a collector_held that does not match pid_file_ok. +fn mtcollins1_sol_observed_from_pid_path(pid_path: String) -> HeldLeaseObservedState { + if pid_path == "" { + mtcollins1_sol_observed_from_collector( + pid_path_set: false, + pid_file_ok: false, + pid_file_content: "", + collector_held: false, + ) + } else { + let pid_read = Filesystem.Read(path: pid_path) + if !pid_read.success { + mtcollins1_sol_observed_from_collector( + pid_path_set: true, + pid_file_ok: false, + pid_file_content: pid_read.content, + collector_held: mtcollins1_boot_sol_held_from_observations( + pid_path: pid_path, + pid_file_ok: false, + pid_file_content: pid_read.content, + cmdline_ok: false, + cmdline_content: "", + ), + ) + } else { + let pid = trim(s: pid_read.content) + if pid == "" { + mtcollins1_sol_observed_from_collector( + pid_path_set: true, + pid_file_ok: true, + pid_file_content: pid_read.content, + collector_held: mtcollins1_boot_sol_held_from_observations( + pid_path: pid_path, + pid_file_ok: true, + pid_file_content: pid_read.content, + cmdline_ok: false, + cmdline_content: "", + ), + ) + } else { + let cmdline = Filesystem.Read(path: concat("/proc/", concat(pid, "/cmdline"))) + mtcollins1_sol_observed_from_collector( + pid_path_set: true, + pid_file_ok: true, + pid_file_content: pid_read.content, + collector_held: mtcollins1_boot_sol_held_from_observations( + pid_path: pid_path, + pid_file_ok: true, + pid_file_content: pid_read.content, + cmdline_ok: cmdline.success, + cmdline_content: cmdline.content, + ), + ) + } + } + } +} + +fn mtcollins1_boot_sol_is_held(pid_path: String) -> Bool { + mtcollins1_sol_observed_from_pid_path(pid_path: pid_path) == LeaseRunningExpected +} + +fn mtcollins1_sol_release( + username: NonEmptyStr, + password_file: NonEmptyStr, +) -> Bool + uses net: std.resources.Network +{ + dropped = diagnostic.ipmi.Tool.SolDeactivate( + bmc_host: mtcollins1_endpoint.host, + username: username, + password_file: password_file, + ) + dropped.success +} + +fn mtcollins1_sol_acquire_after_grant( + password_file: NonEmptyStr, + pid_path: String, + capture_path: String, +) -> ProcessExit + uses net: std.resources.Network +{ + match mtcollins1_bmc_username { + Absent => + exit_failure(reason: "mtcollins1 boot: BMC is not BmcSecured; refusing SOL acquire") + Present { value: username } => + if pid_path == "" { + exit_failure(reason: "mtcollins1 boot: SOL pid path unset; cannot observe a collector we hold") + } else { + match held_lease_observed_verdict( + observed: mtcollins1_sol_observed_from_pid_path(pid_path: pid_path), + ) { + Conflict => + exit_failure(reason: "mtcollins1 boot: refusing live foreign SOL; not deactivating a session we do not hold") + Inaccessible => + exit_failure(reason: "mtcollins1 boot: SOL collector observation is inaccessible") + Converged => ExitSuccess + Drifted => { + let _cleared = mtcollins1_sol_release(username: username, password_file: password_file) + mtcollins1_sol_activate_held(username: username, password_file: password_file, pid_path: pid_path, capture_path: capture_path) + } + Absent => + mtcollins1_sol_activate_held(username: username, password_file: password_file, pid_path: pid_path, capture_path: capture_path) + UnknownRefused => + exit_failure(reason: "mtcollins1 boot: SOL lease observation classification unknown") + } + } + } +} + +fn mtcollins1_sol_activate_held( + username: NonEmptyStr, + password_file: NonEmptyStr, + pid_path: String, + capture_path: String, +) -> ProcessExit + uses net: std.resources.Network +{ + if pid_path == "" { + return exit_failure(reason: "mtcollins1 boot: SOL pid path unset; refusing acquire") + } + if capture_path == "" { + return exit_failure(reason: "mtcollins1 boot: SOL capture path unset; refusing acquire") + } + spawned = gunbc.machine_intake.sol_hold.ActivateHeld( + bmc_host: mtcollins1_endpoint.host, + username: username, + password_file: password_file, + capture_path: capture_path as NonEmptyStr, + pid_path: pid_path as NonEmptyStr, + ) + if !spawned.success { + return exit_failure(reason: concat(dissolution_description(condition: mtcollins1_sol_hold_shell_emit_dissolution_trigger), concat(" mtcollins1 boot: SOL activate held refused: ", spawned.stdout))) + } + let _paced = sleep_delay_seconds_second_carrier_projection(duration: mtcollins1_boot_sol_settle) + if mtcollins1_boot_sol_is_held(pid_path: pid_path) { + ExitSuccess + } else { + exit_failure(reason: concat("mtcollins1 boot: SOL collector is not held after acquire; treating the session as foreign and not deactivating: ", capture_path)) + } +} + +fn mtcollins1_boot_terminal_tick_budget() -> Int { + (second_count(s: mtcollins1_boot_actuation_allowance) as Int) + / (second_count(s: mtcollins1_boot_poll_cadence) as Int) +} + +fn mtcollins1_host_capture_section(capture: String, name: String) -> String? { + fold( + split(s: capture, delimiter: "----SECTION "), + init: none, + f: (acc, part) => match acc { + Present { value: _ } => acc + Absent => + if starts_with(s: part, prefix: concat(name, "----")) { + Present { value: part } + } else { + none + } + }, + ) +} + +fn mtcollins1_host_capture_section_exit_zero(capture: String, name: String) -> Bool { + match mtcollins1_host_capture_section(capture: capture, name: name) { + Absent => false + Present { value: section } => + string_contains(s: section, pattern: concat("----EXIT ", concat(name, ": 0"))) + } +} + +fn mtcollins1_host_capture_has_envelope_marker(capture: String, machine: NonEmptyStr, kind: String) -> Bool { + fold( + split(s: capture, delimiter: "\n"), + init: false, + f: (acc, line) => + acc || ( + string_contains(s: line, pattern: concat("GUNBC-HOST-CAPTURE ", concat(machine as String, " "))) + && string_contains(s: line, pattern: concat(" ", concat(kind, " "))) + ), + ) +} + +fn mtcollins1_host_capture_envelope_complete(capture: String, machine: NonEmptyStr) -> Bool { + mtcollins1_host_capture_has_envelope_marker(capture: capture, machine: machine, kind: "begin") + && mtcollins1_host_capture_has_envelope_marker(capture: capture, machine: machine, kind: "end") +} + +fn mtcollins1_host_capture_nproc_exact(capture: String, threads: String) -> Bool { + match mtcollins1_host_capture_section(capture: capture, name: "nproc") { + Absent => false + Present { value: section } => + string_contains(s: section, pattern: "----EXIT nproc: 0") + && string_contains(s: section, pattern: concat("\n", concat(threads, "\n----EXIT nproc: 0"))) + } +} + +fn mtcollins1_host_capture_mc0_counter(section: String, counter: String, value: String) -> Bool { + string_contains( + s: section, + pattern: concat("/sys/devices/system/edac/mc/mc0/", concat(counter, concat(" == ", value))), + ) +} + +fn mtcollins1_host_capture_edac_clean_section(capture: String, name: String) -> Bool { + match mtcollins1_host_capture_section(capture: capture, name: name) { + Absent => false + Present { value: section } => + string_contains(s: section, pattern: concat("----EXIT ", concat(name, ": 0"))) + && mtcollins1_host_capture_mc0_counter(section: section, counter: "ce_count", value: "0") + && mtcollins1_host_capture_mc0_counter(section: section, counter: "ue_count", value: "0") + } +} + +fn mtcollins1_host_capture_workload_stable(capture: String) -> Bool { + match mtcollins1_host_capture_section(capture: capture, name: "workload") { + Absent => false + Present { value: section } => + string_contains(s: section, pattern: "----EXIT workload: 0") + && string_contains(s: section, pattern: "workload-write-rc=0") + && string_contains(s: section, pattern: "workload-digest-stable=yes") + } +} + +fn mtcollins1_boot_terminal_observed(capture: String, subject: MtCollins1BootSubject) -> Bool { + let threads = hardware_thread_count_value(t: mtcollins1_nproc) as String + mtcollins1_host_capture_envelope_complete(capture: capture, machine: subject.machine) + && mtcollins1_host_capture_nproc_exact(capture: capture, threads: threads) + && mtcollins1_host_capture_section_exit_zero(capture: capture, name: "boot-media") + && match mtcollins1_host_capture_section(capture: capture, name: "boot-media") { + Absent => false + Present { value: media } => + string_contains(s: media, pattern: noble_numbat_2404_3_live_server_arm64.point_release as String) + } + && mtcollins1_host_capture_edac_clean_section(capture: capture, name: "edac-before") + && mtcollins1_host_capture_workload_stable(capture: capture) + && mtcollins1_host_capture_edac_clean_section(capture: capture, name: "edac-after") +} + +fn mtcollins1_boot_await_capture_terminal( + remaining: Int, + capture_path: String, + subject: MtCollins1BootSubject, +) -> ProcessExit { + if remaining <= 0 { + exit_failure(reason: "mtcollins1 boot: timed out waiting for the host-capture terminal in SOL; capture preserved; claim will abort, not complete") + } else { + let capture = Filesystem.Read(path: capture_path) + if capture.success && mtcollins1_boot_terminal_observed(capture: capture.content, subject: subject) { + ExitSuccess + } else { + let _paced = sleep_delay_seconds_second_carrier_projection(duration: mtcollins1_boot_poll_cadence) + mtcollins1_boot_await_capture_terminal( + remaining: remaining - 1, + capture_path: capture_path, + subject: subject, + ) + } + } +} + +fn mtcollins1_boot_release_sol(password_file: String, pid_path: String) -> HeldLeaseObservedState + uses net: std.resources.Network +{ + match mtcollins1_bmc_username { + Absent => LeaseInaccessible + Present { value: user } => { + let _attempted = mtcollins1_sol_release(username: user, password_file: password_file as NonEmptyStr) + mtcollins1_sol_observed_from_pid_path(pid_path: pid_path) + } + } +} + +fn mtcollins1_boot_settle_from_fresh_clock( + outcome: ProcessExit, + escalation_id: NonEmptyStr, + attempt: AttemptIdentity, + held_at: CasGeneration, +) -> ProcessExit + uses fs: std.resources.Filesystem +{ + match mtcollins1_boot_instant_from_probe(reading: clock_now_probed_at()) { + Absent => + unterminated_claim_exit( + label: mtcollins1_boot_label, + outcome: exit_failure(reason: "mtcollins1 boot: clock unreadable at the terminal; refusing to stamp completion or abort with the pre-actuation instant"), + detail: "clock unreadable at claim terminal", + ) + Present { value: at } => + settle_authorization_claim( + label: mtcollins1_boot_label, + outcome: outcome, + escalation_id: escalation_id, + attempt: attempt, + held_at: held_at, + at: at, + ) + } +} + +fn mtcollins1_boot_after_gate( + gate: MtCollins1BootGate, + request: AuthorizationRequest, + password_file: String, + attempt: String, + sol_pid_path: String, + sol_capture_path: String, +) -> ProcessExit + uses net: std.resources.Network +{ + match gate { + BootGateAdmitted { authorization: auth } => + match mtcollins1_boot_instant_from_probe(reading: clock_now_probed_at()) { + Absent => + exit_failure(reason: "mtcollins1 boot: clock unreadable; cannot decide whether the operator grant has expired before a controller write") + Present { value: at } => + match authorize(authorization: auth, request: request, observed_at: at) { + AuthorizationRefused { cause: c } => + exit_failure(reason: concat("mtcollins1 boot: ", authorization_refusal_reason(cause: c))) + AuthorizationPermitted { grant: g } => + match claim_authorization_slot(escalation_id: g.escalation_id, attempt: g.attempt, at: at) { + ClaimLost { holder: who } => + exit_failure(reason: concat("mtcollins1 boot: operator grant already claimed: ", who as String)) + ClaimUndecided { reason: why } => + exit_failure(reason: concat("mtcollins1 boot: ", why as String)) + ClaimHeld { state: _, generation: held_at } => + match mtcollins1_sol_acquire_after_grant(password_file: password_file as NonEmptyStr, pid_path: sol_pid_path, capture_path: sol_capture_path) { + ExitFailure { code: _, reason: r } => + mtcollins1_boot_settle_from_fresh_clock( + outcome: exit_failure(reason: r), + escalation_id: g.escalation_id, + attempt: g.attempt, + held_at: held_at, + ) + ExitSuccess => { + let issued = match mtcollins1_attach_diskless_image(authorization: auth, password_file: password_file, attempt: attempt) { + ExitSuccess => + mtcollins1_drive_handoff( + label: "mtcollins1 cdrom handoff", + password_file_raw: password_file, + capability_row: mtcollins1_capability_row, + selection: request.subject.selection, + ) + other => other + } + let observed = match issued { + ExitSuccess => + mtcollins1_boot_await_capture_terminal( + remaining: mtcollins1_boot_terminal_tick_budget(), + capture_path: sol_capture_path, + subject: request.subject, + ) + other => other + } + let teardown = mtcollins1_boot_release_sol(password_file: password_file, pid_path: sol_pid_path) + let terminal = match observed { + ExitSuccess => + match teardown { + LeaseRunningExpected => + exit_failure(reason: "mtcollins1 boot: SOL collector still held after deactivate") + LeaseRunningStale => + exit_failure(reason: "mtcollins1 boot: SOL pid file stale after deactivate") + _ => ExitSuccess + } + other => other + } + mtcollins1_boot_settle_from_fresh_clock( + outcome: terminal, + escalation_id: g.escalation_id, + attempt: g.attempt, + held_at: held_at, + ) + } + } + } + } + } + _ => mtcollins1_boot_refusal(gate: gate) + } +} + +fn mtcollins1_boot_poll_ticks( + remaining: Int, + request: AuthorizationRequest, + expires_at: Timestamp, +) -> MtCollins1BootGate + uses net: std.resources.Network +{ + match mtcollins1_boot_instant_from_probe(reading: clock_now_probed_at()) { + Absent => + BootGateStoreUnreadable { + detail: "clock unreadable; refusing to judge expiry against a fabricated instant" as NonEmptyStr, + } + Present { value: observed } => + if remaining <= 0 { + BootGateExpired + } else { + let got = http.Client.GetLocalhostBounded(url: approval_status_url(escalation_id: request.escalation_id)) + let status = mtcollins1_status_from_get(success: got.success, body: got.body) + let standing = approval_poll_standing(status: status, observed_at: observed, expires_at: expires_at) + match standing { + PollStillPending => { + let _paced = sleep_delay_seconds_second_carrier_projection(duration: mtcollins1_boot_poll_cadence) + mtcollins1_boot_poll_ticks(remaining: remaining - 1, request: request, expires_at: expires_at) + } + _ => mtcollins1_boot_gate_from_poll(poll: standing, request: request, observed_at: observed) + } + } + } +} + +fn mtcollins1_boot_host_must_be_srv1(host: String) -> Bool { + host == operator_host_srv1 as String +} + +fn mtcollins1_boot_refuses_off_srv1(host: String) -> ProcessExit { + if mtcollins1_boot_host_must_be_srv1(host: host) { + ExitSuccess + } else { + exit_failure(reason: concat("mtcollins1 boot refuses host '", concat(host, concat("': POST/GET /approvals is loopback on the machine that serves the dashboard, which is ", operator_host_srv1 as String)))) + } +} + +fn mtcollins1_boot_wet() -> ProcessExit + uses net: std.resources.Network +{ + match actions_variable_read(name: fleet_converge_expected_host_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => + exit_failure(reason: concat("mtcollins1 boot: ", concat(v, concat(" ", concat(d, "; loopback filing is srv1 only"))))) + ActionsVariablePresent { value: host } => + match mtcollins1_boot_refuses_off_srv1(host: host) { + ExitSuccess => mtcollins1_boot_wet_on_srv1() + other => other + } + } +} + +fn mtcollins1_boot_wet_on_srv1() -> ProcessExit + uses net: std.resources.Network +{ + let cred = actions_variable_trimmed(name: host_reset_bmc_credential_path_env) + if cred == "" { + return exit_failure(reason: concat("mtcollins1 boot: ", concat(host_reset_bmc_credential_path_env as String, " is unset; controller untouched"))) + } + let sub_path = actions_variable_trimmed(name: approval_submission_mac_key_path_env) + if sub_path == "" { + return exit_failure(reason: concat("mtcollins1 boot: ", concat(approval_submission_mac_key_path_env as String, " is unset; controller untouched"))) + } + let sol_pid_path = actions_variable_trimmed(name: mtcollins1_boot_sol_pid_path_env) + let sol_capture_path = actions_variable_trimmed(name: mtcollins1_boot_sol_capture_path_env) + let run_id = match actions_variable_read(name: github_run_id_variable_name) { + ActionsVariablePresent { value: v } => v + ActionsVariableAbsent { variable: _, detail: _ } => "" + } + if run_id == "" { + return exit_failure(reason: "mtcollins1 boot: GITHUB_RUN_ID is unset; refusing rather than reusing a standing escalation id") + } + let sha = match actions_variable_read(name: github_sha_variable_name) { + ActionsVariablePresent { value: v } => v + ActionsVariableAbsent { variable: _, detail: _ } => "" + } + if sha == "" { + return exit_failure(reason: "mtcollins1 boot: GITHUB_SHA is unset; the signed subject binds the execution revision") + } + let escalation = concat("mtcollins1-boot-", run_id) as NonEmptyStr + let request = mtcollins1_boot_authorization_request( + escalation_id: escalation, + attempt: concat("mtcollins1-boot-", run_id) as AttemptIdentity, + execution_revision: sha as NonEmptyStr, + ) + match mtcollins1_boot_instant_from_probe(reading: clock_now_probed_at()) { + Absent => + exit_failure(reason: "mtcollins1 boot: clock unreadable; cannot mint issued_at or an approval window; controller untouched") + Present { value: issued } => { + let expires = match mtcollins1_approval_expires_at(issued: issued) { + Absent => "" + Present { value: e } => e + } + if expires == "" { + return exit_failure(reason: "mtcollins1 boot: issued_at is not a canonical UTC instant; cannot mint an approval window; controller untouched") + } + let stored = stored_request_from_authorization(request: request, issued_at: issued, expires_at: expires) + let key_read = Filesystem.Read(path: sub_path) + if !key_read.success { + return exit_failure(reason: concat("mtcollins1 boot: submission MAC key unreadable: ", key_read.error)) + } + match sign_stored_request(key_material: trim(s: key_read.content), request: stored) { + SubmissionMacKeyNotHex { key_id: k } => + exit_failure(reason: concat("mtcollins1 boot: submission MAC key is not hex: ", k as String)) + SubmissionMacReady { tag_hex: tag } => { + let tmp = actions_variable_trimmed(name: github_runner_temp_variable_name as NonEmptyStr) + if tmp == "" { + return exit_failure(reason: "mtcollins1 boot: RUNNER_TEMP is unset; cannot write the submit body") + } + let body_path = concat(tmp, "/mtcollins1-approval-submit.json") + let body = submission_envelope_json(request: stored, tag_hex: tag) + let written = Filesystem.WriteOwnerOnly(path: body_path, content: body) + if !written.success { + return exit_failure(reason: concat("mtcollins1 boot: could not write submit body: ", written.error)) + } + let posted = http.Client.PostJsonFromFile( + url: approval_submit_url(), + request_body_file: body_path as NonEmptyStr, + max_seconds: to_string(second_count(s: mtcollins1_boot_submit_timeout)) as NonEmptyStr, + ) + let _wiped = Filesystem.Delete(path: body_path) + if !submission_body_admits_poll(body: posted.body) { + return exit_failure(reason: concat("mtcollins1 boot: POST /approvals refused; controller untouched; ", posted.body)) + } + let gate = mtcollins1_boot_poll_ticks( + remaining: mtcollins1_boot_poll_tick_budget, + request: request, + expires_at: expires, + ) + let pw_read = Filesystem.Read(path: cred) + if !pw_read.success { + return exit_failure(reason: concat("mtcollins1 boot: BMC credential unreadable after approval: ", pw_read.error)) + } + let actuated = mtcollins1_boot_after_gate( + gate: gate, + request: request, + password_file: cred, + attempt: run_id, + sol_pid_path: sol_pid_path, + sol_capture_path: sol_capture_path, + ) + let exit_word = match actuated { ExitSuccess => "ok" _ => "refused" } + let receipt_body = concat("escalation_id=", concat(escalation as String, concat("\nexit=", exit_word))) + let kept = Filesystem.Write(path: mtcollins1_boot_receipt_path, content: receipt_body) + if !kept.success { + return exit_failure(reason: concat("mtcollins1 boot: could not write receipt: ", kept.error)) + } + actuated + } + } + } + } +} diff --git a/dag/gunbc/machine_intake/mtcollins1_media_attach.dag b/dag/gunbc/machine_intake/mtcollins1_media_attach.dag index 3699f803a53..fd5880b9dc6 100644 --- a/dag/gunbc/machine_intake/mtcollins1_media_attach.dag +++ b/dag/gunbc/machine_intake/mtcollins1_media_attach.dag @@ -2,9 +2,12 @@ module gunbc.machine_intake_mtcollins1_media_attach import std.types { NonEmptyStr, String } import std.process { ExitSuccess, ProcessExit, exit_failure } +import std.scoped_authorization { ScopedAuthorization, AuthorizationGranted } +import gunbc.machine_intake_mtcollins1_boot_authorization { MtCollins1BootSubject } import gunbc.machine_intake_mtcollins1_access_observation { mtcollins1_endpoint, mtcollins1_firmware } import gunbc.machine_intake_mtcollins1_bmc_secure_observation { mtcollins1_secured_account } import gunbc.machine_intake_mtcollins1_boot_artifact { mtcollins1_diskless_image_name } +import gunbc.machine_intake_mtcollins1_boot_image_fetch { mtcollins1_boot_image_sha256 } import gunbc.machine_intake_megarac_media_attach { MegaRacAttachOutcome, MegaRacAttachResult, @@ -109,21 +112,21 @@ fn mtcollins1_render_release(standing: SessionRelease) -> String { // so a green positive and a red control together establish that the confirmation reads the name // rather than reporting whatever the controller happens to be presenting. Running it costs one // session and performs no write: the absent name is refused after the two reads, before start. -fn mtcollins1_attach_absent_image_control(password_raw: String, attempt: String) -> ProcessExit +fn mtcollins1_attach_absent_image_control(password_file: String, attempt: String) -> ProcessExit uses net: std.resources.Network { match mtcollins1_media_username { Absent => exit_failure(reason: "mtcollins1 media attach control: BMC is not BmcSecured; refusing before any request") Present { value: username } => - if password_raw == "" { - exit_failure(reason: "mtcollins1 media attach control: no controller password supplied; refusing before any request") + if password_file == "" { + exit_failure(reason: "mtcollins1 media attach control: no controller password file supplied; refusing before any request") } else { mtcollins1_render_attach_result( result: megarac_attach_remote_image( bmc_host: mtcollins1_endpoint.host, username: username, - password: password_raw as NonEmptyStr, + password_file: password_file as NonEmptyStr, cookie_jar: mtcollins1_media_cookie_jar(attempt: attempt as NonEmptyStr), image_name: "gunbc-absent-control-image.iso", firmware: mtcollins1_firmware, @@ -133,29 +136,43 @@ fn mtcollins1_attach_absent_image_control(password_raw: String, attempt: String) } } -fn mtcollins1_attach_diskless_image(password_raw: String, attempt: String) -> ProcessExit +fn mtcollins1_attach_diskless_image( + authorization: ScopedAuthorization, + password_file: String, + attempt: String, +) -> ProcessExit uses net: std.resources.Network { - match mtcollins1_media_username { - Absent => - exit_failure(reason: "mtcollins1 media attach: BMC is not BmcSecured; refusing before any request") - Present { value: username } => - if password_raw == "" { - exit_failure(reason: "mtcollins1 media attach: no controller password supplied; refusing before any request") - } else if attempt == "" { - exit_failure(reason: "mtcollins1 media attach: no attempt identifier supplied; the cookie jar is attempt-owned and a shared jar is what lets two runs consume one session") + match authorization { + AuthorizationGranted { grant: g, claim: _ } => + if g.subject.image_name != mtcollins1_diskless_image_name { + exit_failure(reason: "mtcollins1 media attach: the stored grant is for a different image; controller untouched") + } else if g.subject.image_sha256 != mtcollins1_boot_image_sha256 { + exit_failure(reason: "mtcollins1 media attach: the stored grant is for a different image digest; controller untouched") } else { - mtcollins1_render_attach_result( - result: megarac_attach_remote_image( - bmc_host: mtcollins1_endpoint.host, - username: username, - password: password_raw as NonEmptyStr, - cookie_jar: mtcollins1_media_cookie_jar(attempt: attempt as NonEmptyStr), - image_name: mtcollins1_diskless_image_name, - firmware: mtcollins1_firmware, - ) - ) + match mtcollins1_media_username { + Absent => + exit_failure(reason: "mtcollins1 media attach: BMC is not BmcSecured; refusing before any request") + Present { value: username } => + if password_file == "" { + exit_failure(reason: "mtcollins1 media attach: no controller password file supplied; refusing before any request") + } else if attempt == "" { + exit_failure(reason: "mtcollins1 media attach: no attempt identifier supplied; the cookie jar is attempt-owned and a shared jar is what lets two runs consume one session") + } else { + mtcollins1_render_attach_result( + result: megarac_attach_remote_image( + bmc_host: mtcollins1_endpoint.host, + username: username, + password_file: password_file as NonEmptyStr, + cookie_jar: mtcollins1_media_cookie_jar(attempt: attempt as NonEmptyStr), + image_name: mtcollins1_diskless_image_name, + firmware: mtcollins1_firmware, + ) + ) + } + } } + _ => exit_failure(reason: "mtcollins1 media attach: no operator grant; controller untouched") } } diff --git a/dag/gunbc/machine_intake/mtcollins1_memory_census_observation.dag b/dag/gunbc/machine_intake/mtcollins1_memory_census_observation.dag index ee2f35ad054..40204c4aa86 100644 --- a/dag/gunbc/machine_intake/mtcollins1_memory_census_observation.dag +++ b/dag/gunbc/machine_intake/mtcollins1_memory_census_observation.dag @@ -621,7 +621,7 @@ data mtcollins1_media_redirection_samples: List Bool { // next-boot override that nothing has consumed. A caller that retries must know which of the two it // is looking at, since one needs the selection re-applied and the other does not. type OobBootHandoffOutcome - = BootHandoffCompleted { selected: NonEmptyStr } + = BootControlIssued { selected: NonEmptyStr } | BootSelectionRefused | BootSelectionNotApplied { requested: NonEmptyStr, readback: String } | BootSelectionUnverifiable { requested: NonEmptyStr, readback: String } | BootSelectionFlagInvalid { requested: NonEmptyStr, readback: String } + | BootSelectionNotOneShot { requested: NonEmptyStr, readback: String } + | BootSelectionFirmwareMismatch { requested: NonEmptyStr, readback: String } | PowerCycleRefused { selected: NonEmptyStr } | BootHandoffNotAdmitted { admission: OobBootHandoffAdmission } -// WHAT A BOOT-PARAMETER-5 READ-BACK ESTABLISHED. Three outcomes, because they have three different -// remedies and collapsing them is what made the previous form unsafe. -// -// THE PREVIOUS FORM WAS A SUBSTRING TEST AND IT ACCEPTED AN INVALID OVERRIDE. `chassis bootparam -// get 5` renders a block of boot flags, and a controller can answer with BOTH of these lines at -// once: -// -// Boot Flag Invalid -// Boot Device Selector : Force PXE -// -// That is a documented output shape, not a contrived one, and it means the controller is reporting -// the selector it holds WHILE saying the flags are not valid. A `contains(expected)` test sees -// "Force PXE", answers true, and the handoff proceeds to power-cycle a machine whose override will -// not be honoured — producing exactly the wasted boot cycle reported as success that the -// select-verify-restart ordering exists to prevent. The verification was the weakest link in a -// sequence built around it. -// -// THE SUCCESS FLAG IS CHECKED FIRST, AND SEPARATELY. The previous form applied the predicate to -// `readback.stdout` without ever consulting `readback.success`, so a failed read whose output -// happened to carry the expected words would verify. A read that did not complete establishes -// nothing about the controller's state, which is a different fact from a read that completed and -// disagreed. type BootReadbackVerdict = BootReadbackConfirmed | BootReadbackFlagInvalid + | BootReadbackNotOneShot + | BootReadbackFirmwareMismatch | BootReadbackSelectorAbsent -// THE INVALID-FLAG MARKER IS AN UPSTREAM RENDERING, carried as a row rather than inlined so the one -// string this judgment turns on is nameable. ipmitool prints it inside the parameter-5 flag block. data ipmi_boot_flag_invalid_rendering: NonEmptyStr = "Boot Flag Invalid" -// ORDER MATTERS AND IS NOT INTERCHANGEABLE: invalidity is checked BEFORE the selector, because a -// block carrying both is invalid, not confirmed. Checking the selector first would return the old -// behaviour with extra steps. -fn boot_readback_verdict(readback: String, expected_rendering: NonEmptyStr) -> BootReadbackVerdict { +fn boot_readback_verdict(readback: String, selection: IpmiBootSelection) -> BootReadbackVerdict { if readback |> contains(ipmi_boot_flag_invalid_rendering as String) { BootReadbackFlagInvalid - } else if readback |> contains(expected_rendering as String) { + } else if !(readback |> contains(ipmi_boot_expected_flag_rendering(selection: selection) as String)) { + BootReadbackFlagInvalid + } else if !(readback |> contains(ipmi_boot_expected_persistence_rendering(selection: selection) as String)) { + BootReadbackNotOneShot + } else if !(readback |> contains(ipmi_boot_expected_mode_rendering(selection: selection) as String)) { + BootReadbackFirmwareMismatch + } else if readback |> contains(ipmi_boot_expected_device_rendering(selection: selection) as String) { BootReadbackConfirmed } else { BootReadbackSelectorAbsent } } -// THE RESTART LIVES BEHIND THE CONFIRMED ARM, which is what makes select-verify-restart structural -// rather than merely sequential. The power cycle is not a statement that follows a check; it is the -// body of the one verdict that permits it, so a future edit cannot reorder a refusal past it or fall -// through to it. The previous form eliminated the verdict for its refusals and then continued to the -// cycle, which is the same ordering held by convention instead of by shape. fn oob_power_cycle_after_confirmed_selection( bmc_host: NonEmptyStr, username: NonEmptyStr, @@ -187,7 +174,7 @@ fn oob_power_cycle_after_confirmed_selection( action: power_action, ) return if cycle.success { - BootHandoffCompleted { selected: device } + BootControlIssued { selected: device } } else { PowerCycleRefused { selected: device } } @@ -209,8 +196,7 @@ fn oob_boot_handoff( capability_row: BmcFirmwareReleaseCapabilityRow, username: NonEmptyStr, password_file: NonEmptyStr, - device: NonEmptyStr, - expected_rendering: NonEmptyStr, + selection: IpmiBootSelection, power_action: NonEmptyStr, ) -> OobBootHandoffOutcome uses net: std.resources.Network @@ -220,12 +206,14 @@ fn oob_boot_handoff( return BootHandoffNotAdmitted { admission: admission } } let bmc_host = observation.endpoint.host + let device = ipmi_boot_device_label(device: selection.device) - set = diagnostic.ipmi.Tool.ChassisBootDev( + set = diagnostic.ipmi.Tool.ChassisBootDevWithOptions( bmc_host: bmc_host, username: username, password_file: password_file, device: device, + options: ipmi_boot_selection_options_token(selection: selection), ) if set.success == false { return BootSelectionRefused @@ -241,8 +229,10 @@ fn oob_boot_handoff( return BootSelectionUnverifiable { requested: device, readback: readback.stdout } } - return match boot_readback_verdict(readback: readback.stdout, expected_rendering: expected_rendering) { + return match boot_readback_verdict(readback: readback.stdout, selection: selection) { BootReadbackFlagInvalid => BootSelectionFlagInvalid { requested: device, readback: readback.stdout } + BootReadbackNotOneShot => BootSelectionNotOneShot { requested: device, readback: readback.stdout } + BootReadbackFirmwareMismatch => BootSelectionFirmwareMismatch { requested: device, readback: readback.stdout } BootReadbackSelectorAbsent => BootSelectionNotApplied { requested: device, readback: readback.stdout } BootReadbackConfirmed => oob_power_cycle_after_confirmed_selection( diff --git a/dag/gunbc/machine_intake/sol_hold.dag b/dag/gunbc/machine_intake/sol_hold.dag new file mode 100644 index 00000000000..e32f9e4f14b --- /dev/null +++ b/dag/gunbc/machine_intake/sol_hold.dag @@ -0,0 +1,56 @@ +module gunbc.machine_intake_sol_hold + +import std.types { Bool, NonEmptyStr, Unit } +import std.string_type { String } +import std.decl_ref { DeclarationRef, WholeDeclaration } +import std.disposition { Disposition, Scaffold, SingleAuthority } +import std.dissolution { DissolutionCondition, unbound_dissolution } + +// HOLDING A STREAMING SOL SESSION IS REALIZATION, NOT THE IPMI INTERFACE. extdeps.bmc.ipmi +// SolDeactivate is a flat ipmitool argv; `sol activate` is the matching interface and it does not +// return until the session ends. Backgrounding, redirecting the collector, and writing a pid are +// how THIS workflow holds that session after the operator grant — one of N realizations, never a +// fact about IPMI (DESIGN §3). The hold is still bash: process-hold is not a Node in the grammar +// yet, so the transport is a marked scaffold rather than an unmarked script fused into the BMC +// interface (review 67590). + +data mtcollins1_sol_hold_shell_emit_scaffold: Disposition = Scaffold { + dissolves_to: SingleAuthority, + bind: DeclarationRef { + module_path: "gunbc.machine_intake_sol_hold", + decl_name: "mtcollins1_sol_hold_shell_emit_dissolution_trigger", + field: WholeDeclaration + } +} + +data mtcollins1_sol_hold_shell_emit_dissolution_trigger: DissolutionCondition = unbound_dissolution(description: "🟡 dissolve-on: gunbc.machine_intake.sol_hold ActivateHeld — holding `ipmitool sol activate` as a background collector (redirect, stdin from /dev/zero, pid file) is still a bash -c process-hold; paths are positional data (`$1`…`$5`), not interpolated shell syntax. DISSOLVES WHEN bash-emit (#5828 / ROADMAP 6-shell-slice0 / shell-to-intent Phase 2) or a typed process-hold realization starts a streaming session, redirects its bytes, and records the holder pid without a medium-as-string script") + +service gunbc.machine_intake.sol_hold { + operation ActivateHeld { + input { + bmc_host: NonEmptyStr + username: NonEmptyStr + password_file: NonEmptyStr + capture_path: NonEmptyStr + pid_path: NonEmptyStr + } + output { stdout: String from "stdout", success: Bool from "exit_success" } + transport shell { + argv: [ + "bash", + "-c", + "ipmitool -H \"$1\" -I lanplus -U \"$2\" -f \"$3\" sol activate >> \"$4\" 2>&1 < /dev/zero & echo $! > \"$5\"", + "sol-hold", + "{bmc_host}", + "{username}", + "{password_file}", + "{capture_path}", + "{pid_path}" + ] + } + exit { + 0 => Unit + nonzero => String "sol activate held failed" + } + } +} diff --git a/dag/gunbc/namespace/transition_admission/fleet_converge_plan_cli_fleet_converge_scoped_plan_wet_fleet_converge_expected_host_env_name.dag b/dag/gunbc/namespace/transition_admission/fleet_converge_plan_cli_fleet_converge_scoped_plan_wet_fleet_converge_expected_host_env_name.dag new file mode 100644 index 00000000000..77f731ed237 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/fleet_converge_plan_cli_fleet_converge_scoped_plan_wet_fleet_converge_expected_host_env_name.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.fleet_converge_plan_cli_fleet_converge_scoped_plan_wet_fleet_converge_expected_host_env_name + +import std.types { NonEmptyStr, List } +import std.integer { UInt32 } +import std.decl_ref { decl_ref } +import gunbc.compiler_frontend_program_interlock { TargetChanged } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, NotAuthored } + +data fleet_converge_plan_cli_fleet_converge_scoped_plan_wet_fleet_converge_expected_host_env_name: TransitionAdmission = TransitionAdmission { + label: "fleet_converge_expected_host_env_name home is actions_run_binding so ci_spec can import the pin without a receipt cycle (gunbc#11484)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.fleet_converge_plan_cli", "fleet_converge_scoped_plan_wet"), + spelling: "fleet_converge_expected_host_env_name" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.actions_run_binding", "fleet_converge_expected_host_env_name")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11484 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/fleet_converge_workflow_fleet_converge_launch_environment_plan_step_fleet_converge_expected_host_env_name.dag b/dag/gunbc/namespace/transition_admission/fleet_converge_workflow_fleet_converge_launch_environment_plan_step_fleet_converge_expected_host_env_name.dag new file mode 100644 index 00000000000..e9bdbad1951 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/fleet_converge_workflow_fleet_converge_launch_environment_plan_step_fleet_converge_expected_host_env_name.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.fleet_converge_workflow_fleet_converge_launch_environment_plan_step_fleet_converge_expected_host_env_name + +import std.types { NonEmptyStr, List } +import std.integer { UInt32 } +import std.decl_ref { decl_ref } +import gunbc.compiler_frontend_program_interlock { TargetChanged } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, NotAuthored } + +data fleet_converge_workflow_fleet_converge_launch_environment_plan_step_fleet_converge_expected_host_env_name: TransitionAdmission = TransitionAdmission { + label: "fleet_converge_expected_host_env_name home is actions_run_binding so ci_spec can import the pin without a receipt cycle (gunbc#11484)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_launch_environment_plan_step"), + spelling: "fleet_converge_expected_host_env_name" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.actions_run_binding", "fleet_converge_expected_host_env_name")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11484 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/fleet_converge_workflow_fleet_converge_plan_step_fleet_converge_expected_host_env_name.dag b/dag/gunbc/namespace/transition_admission/fleet_converge_workflow_fleet_converge_plan_step_fleet_converge_expected_host_env_name.dag new file mode 100644 index 00000000000..7c15fe76caa --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/fleet_converge_workflow_fleet_converge_plan_step_fleet_converge_expected_host_env_name.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.fleet_converge_workflow_fleet_converge_plan_step_fleet_converge_expected_host_env_name + +import std.types { NonEmptyStr, List } +import std.integer { UInt32 } +import std.decl_ref { decl_ref } +import gunbc.compiler_frontend_program_interlock { TargetChanged } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, NotAuthored } + +data fleet_converge_workflow_fleet_converge_plan_step_fleet_converge_expected_host_env_name: TransitionAdmission = TransitionAdmission { + label: "fleet_converge_expected_host_env_name home is actions_run_binding so ci_spec can import the pin without a receipt cycle (gunbc#11484)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_plan_step"), + spelling: "fleet_converge_expected_host_env_name" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.actions_run_binding", "fleet_converge_expected_host_env_name")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11484 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/fleet_converge_workflow_fleet_converge_runner_host_file_step_fleet_converge_expected_host_env_name.dag b/dag/gunbc/namespace/transition_admission/fleet_converge_workflow_fleet_converge_runner_host_file_step_fleet_converge_expected_host_env_name.dag new file mode 100644 index 00000000000..bd37094b424 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/fleet_converge_workflow_fleet_converge_runner_host_file_step_fleet_converge_expected_host_env_name.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.fleet_converge_workflow_fleet_converge_runner_host_file_step_fleet_converge_expected_host_env_name + +import std.types { NonEmptyStr, List } +import std.integer { UInt32 } +import std.decl_ref { decl_ref } +import gunbc.compiler_frontend_program_interlock { TargetChanged } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, NotAuthored } + +data fleet_converge_workflow_fleet_converge_runner_host_file_step_fleet_converge_expected_host_env_name: TransitionAdmission = TransitionAdmission { + label: "fleet_converge_expected_host_env_name home is actions_run_binding so ci_spec can import the pin without a receipt cycle (gunbc#11484)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_runner_host_file_step"), + spelling: "fleet_converge_expected_host_env_name" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.actions_run_binding", "fleet_converge_expected_host_env_name")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11484 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_auth_approval_device_redemption_enrolment_admission_enrollment_id_for_code.dag b/dag/gunbc/namespace/transition_admission/gunbc_auth_approval_device_redemption_enrolment_admission_enrollment_id_for_code.dag deleted file mode 100644 index fc48a65d1aa..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_auth_approval_device_redemption_enrolment_admission_enrollment_id_for_code.dag +++ /dev/null @@ -1,24 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_auth_approval_device_redemption_enrolment_admission_enrollment_id_for_code - -import std.types { NonEmptyStr, List } -import std.integer { UInt32 } -import std.decl_ref { decl_ref } -import gunbc.compiler_frontend_program_interlock { TargetChanged } -import gunbc.namespace.transition_admission { TransitionAdmission, Binding, NotAuthored } - -// enrollment_id_for_code moved from the server module into the wire module, so the iOS app can -// derive the readback key from the code it holds. Same function, one definition; the bare name in -// enrolment_admission now resolves to its new home. Consumer: namespace-wave-admission on gunbc#11660. -data gunbc_auth_approval_device_redemption_enrolment_admission_enrollment_id_for_code: TransitionAdmission = TransitionAdmission { - label: "enrollment_id_for_code moved into approval_device_wire so the app can mirror it" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.auth.approval_device_redemption", "enrolment_admission"), - spelling: "enrollment_id_for_code" as NonEmptyStr, - expected_candidates: [ - decl_ref("gunbc.auth.approval_device_wire", "enrollment_id_for_code"), - ], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11660 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/runner_host_file_converge_runner_host_file_ci_wet_fleet_converge_expected_host_env_name.dag b/dag/gunbc/namespace/transition_admission/runner_host_file_converge_runner_host_file_ci_wet_fleet_converge_expected_host_env_name.dag new file mode 100644 index 00000000000..0d8653a0721 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/runner_host_file_converge_runner_host_file_ci_wet_fleet_converge_expected_host_env_name.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.runner_host_file_converge_runner_host_file_ci_wet_fleet_converge_expected_host_env_name + +import std.types { NonEmptyStr, List } +import std.integer { UInt32 } +import std.decl_ref { decl_ref } +import gunbc.compiler_frontend_program_interlock { TargetChanged } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, NotAuthored } + +data runner_host_file_converge_runner_host_file_ci_wet_fleet_converge_expected_host_env_name: TransitionAdmission = TransitionAdmission { + label: "fleet_converge_expected_host_env_name home is actions_run_binding so ci_spec can import the pin without a receipt cycle (gunbc#11484)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.runner_host_file_converge", "runner_host_file_ci_wet"), + spelling: "fleet_converge_expected_host_env_name" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.actions_run_binding", "fleet_converge_expected_host_env_name")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11484 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/runner_password_session_tool_converge_runner_password_session_tool_converge_ci_wet_fleet_converge_expected_host_env_name.dag b/dag/gunbc/namespace/transition_admission/runner_password_session_tool_converge_runner_password_session_tool_converge_ci_wet_fleet_converge_expected_host_env_name.dag new file mode 100644 index 00000000000..02769a28554 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/runner_password_session_tool_converge_runner_password_session_tool_converge_ci_wet_fleet_converge_expected_host_env_name.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.runner_password_session_tool_converge_runner_password_session_tool_converge_ci_wet_fleet_converge_expected_host_env_name + +import std.types { NonEmptyStr, List } +import std.integer { UInt32 } +import std.decl_ref { decl_ref } +import gunbc.compiler_frontend_program_interlock { TargetChanged } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, NotAuthored } + +data runner_password_session_tool_converge_runner_password_session_tool_converge_ci_wet_fleet_converge_expected_host_env_name: TransitionAdmission = TransitionAdmission { + label: "fleet_converge_expected_host_env_name home is actions_run_binding so ci_spec can import the pin without a receipt cycle (gunbc#11484)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.runner_password_session_tool_converge", "runner_password_session_tool_converge_ci_wet"), + spelling: "fleet_converge_expected_host_env_name" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.actions_run_binding", "fleet_converge_expected_host_env_name")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11484 as UInt32, +} diff --git a/dag/gunbc/recurring_failure_mode/unbound_interpolation_in_service_argv_accepted_until_runtime.dag b/dag/gunbc/recurring_failure_mode/unbound_interpolation_in_service_argv_accepted_until_runtime.dag new file mode 100644 index 00000000000..e88766872ad --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/unbound_interpolation_in_service_argv_accepted_until_runtime.dag @@ -0,0 +1,22 @@ +module gunbc.recurring_failure_mode.unbound_interpolation_in_service_argv_accepted_until_runtime + +import std.types { NonEmptyStr } +import gunbc.recurring_failure_mode { RecurringFailureMode } + +data unbound_interpolation_in_service_argv_accepted_until_runtime: RecurringFailureMode = RecurringFailureMode { + identity: "unbound_interpolation_in_service_argv_accepted_until_runtime" as NonEmptyStr, + + receipts: [ + "**an interpolation in a service argv names a variable the operation does not bind, the front end accepts the program, and evaluation refuses on the live path** (INVALID STATE: a transport argv string contains `\{name}` that is not an operation input. The tokenizer treats it as interpolation. Compile and typecheck succeed. The first wet call evaluates the argv and refuses NoSuchVariable. HARM: DESIGN section 4b's floor -- names resolve -- is below baseline here: the miss is silent until hardware, a credential, or a destructive step has already run. DISTINGUISHING FACTS: the spelling is a LITERAL the foreign CLI owns, not a gunbc binding. curl -w `%\{http_code}` is curl's write-out token. jq object literals use `\{key: ...}`. Both are valid argv bytes and invalid interpolations.)", + + "SPECIMEN, found 2026-09-18 on gunbc#11484 boot 14 after operator grant, claim, SOL ActivateHeld, and MegaRAC OpenSession/GetRemoteConfigurations/CloseSession. extdeps.bmc.megarac GetSession argv carried `\\n%\{http_code}`. Evaluation of mtcollins1_boot_wet refused `NoSuchVariable \{ name: \"http_code\" \}`. The session GET is the re-observation that distinguishes a released MegaRAC session from a sentinel CSRF 401; the miss fired after the write path had already succeeded.", + + "PRECEDENT IN THE SAME TREE: extdeps.bmc.openbmc_password_ssh_transport escapes jq braces as a backslash-brace so they are bytes, not interpolations. The repair is that same escape on the curl write-out token so the argv bytes remain curl's percent-brace http_code marker.", + + "ATTAINABLE CEILING: structurally guaranteed on the compile path -- an argv interpolation whose name is not an input of that operation is a resolution miss, which the floor already knows how to refuse. TODAY it is outside that path: accepted source, runtime NoSuchVariable. NEXT-RUNG TRIGGER: refuse at ingest or bind_argv when a `\{name}` in a service argv is not an operation input, with a fixture-authored RED whose argv contains curl's `%\{http_code}` (or a jq `\{key}`) and whose operation declares no such input, plus a positive control that `\{cookie_jar}` still interpolates.", + + "RECOGNITION RULE: a wet diagnostic names a variable that does not appear in the operation's input list, and the argv string contains that name inside braces that a foreign CLI also uses. The tell is that a grep for the CLI token looks correct while the tokenizer has already claimed the braces.", + ], + + evidence: [], +} diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index cc81a4c234f..37045b02eec 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -771,7 +771,7 @@ data macbook_local_codex_home: FilePath = codex_account_state_root( // srv1-live, host srv1 and repo /opt/gunbc/gunbc while running on a laptop. Nothing was wrong in // srv1's own terms -- the page simply had no way to say whose it was. // -// FOUR FIELDS DIFFER FROM srv1-live FOR REASONS, not for local convenience: +// THREE FIELDS DIFFER FROM srv1-live FOR REASONS, not for local convenience: // // dispatch_state_scope is InstanceOwned rather than HostShared. srv1 puts worktrees and attempt // state beside the deployment under /opt/gunbc; on a development machine the repository root is @@ -784,11 +784,6 @@ data macbook_local_codex_home: FilePath = codex_account_state_root( // make the operator's own shells indistinguishable from dispatch sessions to the reaper. The named // server is the same isolation the labs already declare, for the same reason. // -// listen_host is loopback rather than 0.0.0.0. srv1 binds every interface because the dashboard is -// meant to be reachable across the LAN; a laptop on untrusted networks serving an ACTUATING -// dispatch surface is a different exposure, and nothing needs to reach this one but the browser on -// the same machine. -// // provision_transport is Local rather than ssh:srv1, which is the honest reading of a host that // provisions itself and is why DashboardLocalProvision already exists as a variant. // @@ -907,7 +902,7 @@ fn srv1_live_dashboard_instance() -> HostDashboardInstance { }, serve_binary: srv1_gunbc_serve_binary, serve_function: "roadmap_serve_handle" as NonEmptyStr, - listen_host: "0.0.0.0" as NonEmptyStr, + listen_host: "127.0.0.1" as NonEmptyStr, listen_port: 8080, tmux_server: DefaultTmuxServer, actuation: DashboardActuating, diff --git a/dag/gunbc/roadmap/roadmap_serve.dag b/dag/gunbc/roadmap/roadmap_serve.dag index c63f2dc7e4b..c445b289e64 100644 --- a/dag/gunbc/roadmap/roadmap_serve.dag +++ b/dag/gunbc/roadmap/roadmap_serve.dag @@ -3,7 +3,7 @@ module gunbc.roadmap_serve import extdeps.tailscale.identity { tailnet_identity_of, tailnet_identity_login } import gunbc.clock_read { clock_now_probed_at } import gunbc.auth.approval_request_submission { - admit_parsed_submission, SubmissionVerified, SubmissionNotAdmitted, file_and_notify, submission_outcome_reason, read_submission_keyring, read_publisher_token, escalation_status, + admit_parsed_submission, SubmissionVerified, SubmissionNotAdmitted, file_and_notify, submission_outcome_http_body, read_submission_keyring, read_publisher_token, escalation_status, parse_submission_envelope, SubmissionEnvelopeParsed, SubmissionEnvelopeUnreadable, SubmissionFiledAndNotified, SubmissionFiledNotNotified, SubmissionFiledPublisherTokenUnavailable, SubmissionFiledLinksNotIssued, SubmissionAlreadyFiled, SubmissionStoreRefused, SubmissionRefused, SubmissionFiledCapabilityKeyUnavailable, @@ -682,7 +682,7 @@ fn approval_submit_response(request: ServeHttpRequest) -> ServeHttpResponse { serve_text_response( status: status, content_type: text_plain_utf8, - body: join(["approval submission: ", submission_outcome_reason(o: outcome), "\n"], ""), + body: submission_outcome_http_body(o: outcome), ) } diff --git a/dag/gunbc/runner/runner_host_file_converge.dag b/dag/gunbc/runner/runner_host_file_converge.dag index f95212cec45..609a852c48a 100644 --- a/dag/gunbc/runner/runner_host_file_converge.dag +++ b/dag/gunbc/runner/runner_host_file_converge.dag @@ -22,8 +22,7 @@ import extdeps.needrestart { } import product.placement_supply { HostIdentity } import gunbc.clock_read { clock_now_probed_at, probed_at_word } -import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent } -import gunbc.fleet_converge_receipt { fleet_converge_expected_host_env_name } +import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, fleet_converge_expected_host_env_name } import gunbc.fleet_ssh_locus { prepare_fleet_ssh_agent_context, FleetSshContextReady, FleetSshContextRefused } import gunbc.fleet_known_hosts_anchor { FleetSshExecutionContext, SshTarget } import gunbc.fleet_reach_endpoint { fleet_probe_endpoint_for } diff --git a/dag/gunbc/runner/runner_password_session_tool_converge.dag b/dag/gunbc/runner/runner_password_session_tool_converge.dag index ae1b8927b20..207f7836829 100644 --- a/dag/gunbc/runner/runner_password_session_tool_converge.dag +++ b/dag/gunbc/runner/runner_password_session_tool_converge.dag @@ -21,8 +21,7 @@ import std.process { ProcessExit, ExitSuccess, exit_failure } import std.resources { Network } import extdeps.filesystem.filesystem_io { Filesystem } import gunbc.clock_read { clock_now_probed_at, probed_at_word } -import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent } -import gunbc.fleet_converge_receipt { fleet_converge_expected_host_env_name } +import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, fleet_converge_expected_host_env_name } import gunbc.fleet_ssh_locus { prepare_fleet_ssh_agent_context, FleetSshContextReady, FleetSshContextRefused } // THE OTHER HALF OF #11061, AND THE ONLY HALF THAT CHANGES A HOST. That change enrolled sshpass as diff --git a/dag/gunbc/srv3/srv3_boot_once_cd.dag b/dag/gunbc/srv3/srv3_boot_once_cd.dag index 0fce3c5d6c4..db91e631805 100644 --- a/dag/gunbc/srv3/srv3_boot_once_cd.dag +++ b/dag/gunbc/srv3/srv3_boot_once_cd.dag @@ -31,8 +31,7 @@ import gunbc.bmc_onboarding { srv3_onboarding_plan } import gunbc.auth.netrc_binding { NetrcBindingPlaced, NetrcBindingRefused, NetrcBindingRefusedResidueRemains, place_netrc_binding, } -import gunbc.durable_cas_file_store { - OwnerOnlyCreateOnly, admit_cas_attempt, file_compare_and_set } +import gunbc.authorization_claim_slot { claim_authorization_slot, settle_authorization_claim } import gunbc.tools.bmc_onboard { bmc_onboard_netrc_path, boot_override_body, @@ -51,6 +50,8 @@ data srv3_boot_once_cd_secret_debt: Disposition = Scaffold { } } +data srv3_boot_once_cd_label: String = "boot-once-cd" + data srv3_boot_cd_body_path: String = "/tmp/srv3_boot_cd_body.json" data srv3_reset_body_path: String = "/tmp/srv3_reset_body.json" @@ -116,7 +117,7 @@ fn srv3_boot_once_cd_gated(approval: ScopedAuthorization return exit_failure( reason: concat("boot-once-cd: operator grant already claimed by another attempt: ", who as String), @@ -130,92 +131,7 @@ fn srv3_boot_once_cd_gated(approval: ScopedAuthorization srv3_boot_once_cd_resolved(resolution: resolution) } - return srv3_settle_boot_once_cd_claim(outcome: outcome, held_at: held_at, at: observed_at) - } -} - -fn srv3_claim_boot_once_cd_authorization(at: Timestamp) -> AuthorizationClaimOutcome - uses fs: std.resources.Filesystem -{ - let proposed = claim_transition_claimed(attempt: srv3_boot_once_cd_attempt, at: at) - match admit_cas_attempt( - attempt: claim_authorization( - escalation_id: srv3_boot_once_cd_authorization_request.escalation_id, - expected: ExpectSlotAbsent, - proposed: proposed, - ), - ) { - CasAttemptAdmitted { verified: v } => - authorization_claim_outcome( - proposed: proposed, - outcome: file_compare_and_set(root: srv3_authorization_claim_root, publication: OwnerOnlyCreateOnly, verified: v), - ) - CasAttemptDigestMismatch { claimed: _, actual: _ } => - ClaimUndecided { reason: "claim payload and its digest disagree" as NonEmptyStr } - CasAttemptDigestIncomparable { claimed: _, actual: _ } => - ClaimUndecided { reason: "claim digest families are incomparable" as NonEmptyStr } - CasAttemptKeyNotSlotAddressable { key: _ } => - ClaimUndecided { reason: "authorization claim key is not slot-addressable" as NonEmptyStr } - } -} - -// The terminal transition rides the SAME compare-and-set, expecting the generation we hold. If it -// does not land, the step's own result still stands -- the mutation already happened, and lying -// about it to keep the ledger tidy would be the worse failure -- but the exit carries the fact -// that the claim was left unterminated, because an operator reading a bare success would not know -// a manual slot inspection is owed. -fn srv3_settle_boot_once_cd_claim( - outcome: ProcessExit, - held_at: CasGeneration, - at: Timestamp, -) -> ProcessExit - uses fs: std.resources.Filesystem -{ - let terminal = match outcome { - ExitSuccess => claim_transition_completed(attempt: srv3_boot_once_cd_attempt, at: at) - ExitFailure { code: _, reason: r } => - claim_transition_aborted(attempt: srv3_boot_once_cd_attempt, at: at, cause: r as NonEmptyStr) - } - match admit_cas_attempt( - attempt: claim_authorization( - escalation_id: srv3_boot_once_cd_authorization_request.escalation_id, - expected: ExpectSlotGeneration { generation: held_at }, - proposed: terminal, - ), - ) { - CasAttemptAdmitted { verified: v } => - match file_compare_and_set(root: srv3_authorization_claim_root, publication: OwnerOnlyCreateOnly, verified: v) { - CasCommitted { committed: _ } => outcome - CasPreconditionFailed { expected: _, observed: _ } => - srv3_unterminated_claim_exit(outcome: outcome, detail: "claim slot moved under us") - CasStoreRefused { cause: _ } => - srv3_unterminated_claim_exit(outcome: outcome, detail: "claim store refused the terminal write") - } - CasAttemptDigestMismatch { claimed: _, actual: _ } => - srv3_unterminated_claim_exit(outcome: outcome, detail: "terminal payload and its digest disagree") - CasAttemptDigestIncomparable { claimed: _, actual: _ } => - srv3_unterminated_claim_exit(outcome: outcome, detail: "terminal digest families are incomparable") - CasAttemptKeyNotSlotAddressable { key: _ } => - srv3_unterminated_claim_exit(outcome: outcome, detail: "authorization claim key is not slot-addressable") - } -} - -fn srv3_unterminated_claim_exit(outcome: ProcessExit, detail: String) -> ProcessExit { - exit_failure( - reason: concat( - "boot-once-cd: the step finished but its authorization claim was left unterminated (", - concat( - detail, - concat("); step outcome was: ", srv3_process_exit_render(exit: outcome)), - ), - ), - ) -} - -fn srv3_process_exit_render(exit: ProcessExit) -> String { - match exit { - ExitSuccess => "success" - ExitFailure { code: _, reason: r } => concat("failure: ", r) + return settle_authorization_claim(label: srv3_boot_once_cd_label, outcome: outcome, escalation_id: srv3_boot_once_cd_authorization_request.escalation_id, attempt: srv3_boot_once_cd_attempt, held_at: held_at, at: observed_at) } } diff --git a/dag/gunbc/srv3/srv3_os_install_actuate_workflow.dag b/dag/gunbc/srv3/srv3_os_install_actuate_workflow.dag index 8d33b5d941e..d062d79126f 100644 --- a/dag/gunbc/srv3/srv3_os_install_actuate_workflow.dag +++ b/dag/gunbc/srv3/srv3_os_install_actuate_workflow.dag @@ -112,11 +112,6 @@ data srv3_boot_once_cd_required_scopes: List = [ srv3_system_reset_scope, ] -// Where the exclusive claim for this escalation lives. One root, one slot per escalation id, on -// the machine that runs the step -- the claim has to outlive the process for a restart to be -// refused rather than admitted, which is what durable means here. -data srv3_authorization_claim_root: NonEmptyStr = "/var/lib/gunbc/authorization-claims" as NonEmptyStr - // The attempt this workflow's grant is bound to. A grant issued for this attempt refuses any // other, which is the difference between approving THIS run and issuing a standing approval for // every future run of the same step. diff --git a/dag/gunbc/typed_remote_file_write.dag b/dag/gunbc/typed_remote_file_write.dag index 249bb39b1b8..2c409eda1e0 100644 --- a/dag/gunbc/typed_remote_file_write.dag +++ b/dag/gunbc/typed_remote_file_write.dag @@ -94,11 +94,13 @@ type TypedRemoteFileWriteOutcome // For a unit file the transfer is a few hundred bytes. type RemoteFileMode = RemoteFileModeOwnerReadWrite + | RemoteFileModeOwnerReadWriteGroupRead | RemoteFileModeOwnerReadWriteWorldRead fn remote_file_mode_octal(mode: RemoteFileMode) -> String { match mode { RemoteFileModeOwnerReadWrite => "600" + RemoteFileModeOwnerReadWriteGroupRead => "640" RemoteFileModeOwnerReadWriteWorldRead => "644" } } diff --git a/dag/std/scoped_authorization.dag b/dag/std/scoped_authorization.dag index 7796612a104..6adbc87f3a1 100644 --- a/dag/std/scoped_authorization.dag +++ b/dag/std/scoped_authorization.dag @@ -395,11 +395,19 @@ fn authorize( // // The claim slot is keyed by escalation id, so every attempt that could claim one grant addresses // exactly one slot. Keying it by attempt instead would give each racer its own slot and exclude -// nothing, which is the shape of the race rather than its resolution. +// nothing, which is the shape of the race rather than its resolution. The key CARRIES ITS SCOPE: +// `authorization_claim_store_root` is one shared authorization-claim domain (srv3 boot-once and +// Mt. Collins boot both publish there), and the scope prefix is what keeps this relation's slots +// from colliding with any other relation that may share the root. The separator is `-` rather than +// `/` because a slash is not slot-addressable in gunbc.durable_cas_file_store (cas_key_is_slot_ +// addressable) -- the earlier repair for that refusal deleted the prefix and moved the scoping into +// caller convention (review 68728); this restores it as a property of the key. fn authorization_claim_key(escalation_id: NonEmptyStr) -> NonEmptyStr { - concat("scoped-authorization/", escalation_id as String) as NonEmptyStr + concat(authorization_claim_key_scope, escalation_id as String) as NonEmptyStr } +data authorization_claim_key_scope: String = "scoped-authorization-" + // THE SLOT HOLDS WHAT THE STORE HOLDS. std.durable_compare_and_set's realization // (gunbc.durable_cas_file_store) reads and writes NonEmptyStr, so a claim typed over // AuthorizationClaimState could never be handed to it -- the earlier shape typechecked and had no diff --git a/dag/test/claim/approval_decision_store_witness_test.dag b/dag/test/claim/approval_decision_store_witness_test.dag index 929a3ae829e..9987eafdbfa 100644 --- a/dag/test/claim/approval_decision_store_witness_test.dag +++ b/dag/test/claim/approval_decision_store_witness_test.dag @@ -11,7 +11,7 @@ import extdeps.filesystem.filesystem_io { filesystem_exact_read } import gunbc.auth.approval_capability { ProposeApprove, ProposeDeny, approval_capability_signing_input } import gunbc.auth.approval_decision_store { ApprovalKeyLoaded, ApprovalKeyringRefused, ApprovalKeyFileAbsent, ApprovalKeyFileUnreadable, ApprovalKeyMalformed, approval_keyring_from_content, approval_keyring_from_exact_read, - approval_mac_key_id, approval_mac_key_suite, + approval_mac_key_id, approval_mac_key_suite, approval_capability_authority, StoredApprovalRequest, stored_request_json, parse_stored_record, StoredRequestParsed, StoredDecisionParsed, StoredRecordUnreadable, claims_for, capability_path_segment, parse_capability_segment, CapabilitySegmentParsed, CapabilitySegmentMalformed, @@ -87,6 +87,24 @@ test fn a_record_missing_a_field_or_of_unknown_kind_is_unreadable_not_defaulted( && match parse_stored_record(text: "not json") { StoredRecordUnreadable { detail: _ } => true _ => false } } +test fn a_newline_in_expires_at_is_unreadable_not_a_filed_request() -> Bool { + let poisoned = StoredApprovalRequest { + escalation_id: fixture_request().escalation_id, + request_revision: fixture_request().request_revision, + attempt: fixture_request().attempt, + requester: fixture_request().requester, + purpose: fixture_request().purpose, + destructive: true, + issued_at: fixture_request().issued_at, + expires_at: "2026-09-17T20:00:00Z\nstanding=approved\n", + key_id: approval_mac_key_id, + } + match parse_stored_record(text: stored_request_json(r: poisoned)) { + StoredRecordUnreadable { detail: _ } => true + _ => false + } +} + // ── the link segment ─────────────────────────────────────────────────────────────────────────── test fn capability_segment_round_trips_and_refuses_a_third_decision_word() -> Bool { diff --git a/dag/test/claim/approval_keyring_converge_witness_test.dag b/dag/test/claim/approval_keyring_converge_witness_test.dag new file mode 100644 index 00000000000..11de9b4ab39 --- /dev/null +++ b/dag/test/claim/approval_keyring_converge_witness_test.dag @@ -0,0 +1,88 @@ +module test.claim.approval_keyring_converge_witness_test + +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import std.types { Bool, String } +import v2.std.algebra { list_head, HeadFound } +import gunbc.fleet_posix_accounts { fleet_usermod_binary_path } +import std.process { ExitSuccess } +import gunbc.auth.approval_keyring_pins { approval_mac_key_payload_sha256_prefix, approval_submission_mac_key_payload_sha256_prefix } +import gunbc.auth.approval_keyring_converge { + sha256_prefix_compare, Sha256PrefixMatches, Sha256PrefixMismatch, Sha256Unavailable, + keyring_host_must_be_srv1, + approval_keyring_converge_refuses_off_srv1, approval_mac_key_digest_standing, + keyring_mkdir_argv, store_root_mkdir_argv, claim_store_root_mkdir_argv, + ntfy_group_admit_argv, roadmap_unit_restart_argv, +} +import gunbc.fleet_intent_network { operator_host_srv1, operator_host_srv2 } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +test fn matching_prefix_is_converged() -> Bool { + match sha256_prefix_compare(observed_hex: "113a3274bd95aabb", expected_prefix: approval_mac_key_payload_sha256_prefix) { + Sha256PrefixMatches { prefix: p } => p == approval_mac_key_payload_sha256_prefix + _ => false + } +} + +test fn mismatched_prefix_refuses() -> Bool { + match sha256_prefix_compare(observed_hex: "000000000000aabb", expected_prefix: approval_mac_key_payload_sha256_prefix) { + Sha256PrefixMismatch { expected: e, observed: o } => + e == approval_mac_key_payload_sha256_prefix && o as String == "000000000000" + _ => false + } +} + +test fn short_digest_is_unavailable_not_a_match() -> Bool { + match sha256_prefix_compare(observed_hex: "11", expected_prefix: approval_mac_key_payload_sha256_prefix) { + Sha256Unavailable { detail: _ } => true + _ => false + } +} + +test fn keyring_converge_is_srv1_only() -> Bool { + keyring_host_must_be_srv1(host: operator_host_srv1 as String) + && !keyring_host_must_be_srv1(host: operator_host_srv2 as String) + && match approval_keyring_converge_refuses_off_srv1(host: operator_host_srv1 as String) { ExitSuccess => true _ => false } +} + +test fn digest_standing_admits_the_modeled_prefix_and_refuses_a_mismatch() -> Bool { + match approval_mac_key_digest_standing(observed_hex: "113a3274bd95aabb", expected_prefix: approval_mac_key_payload_sha256_prefix, role: "capability") { ExitSuccess => true _ => false } + && match approval_mac_key_digest_standing(observed_hex: "000000000000aabb", expected_prefix: approval_mac_key_payload_sha256_prefix, role: "capability") { ExitSuccess => false _ => true } + && match approval_mac_key_digest_standing(observed_hex: "01f19b2e2c7aaabb", expected_prefix: approval_submission_mac_key_payload_sha256_prefix, role: "submission") { ExitSuccess => true _ => false } + && match approval_mac_key_digest_standing(observed_hex: "000000000000aabb", expected_prefix: approval_submission_mac_key_payload_sha256_prefix, role: "submission") { ExitSuccess => false _ => true } +} + +test fn keyring_mkdir_is_install_not_a_local_sudo() -> Bool { + match list_head(xs: keyring_mkdir_argv()) { + HeadFound { value: w } => w == "install" + _ => false + } +} + +test fn store_root_mkdir_is_install_not_a_local_sudo() -> Bool { + match list_head(xs: store_root_mkdir_argv()) { + HeadFound { value: w } => w == "install" + _ => false + } +} + +test fn claim_store_root_mkdir_is_install_for_the_ci_runner() -> Bool { + match list_head(xs: claim_store_root_mkdir_argv()) { + HeadFound { value: w } => w == "install" + _ => false + } +} + +test fn ntfy_group_admit_is_usermod_not_a_token_chown() -> Bool { + match list_head(xs: ntfy_group_admit_argv()) { + HeadFound { value: w } => w == (fleet_usermod_binary_path as String) + _ => false + } +} + +test fn roadmap_unit_restart_is_systemctl_restart() -> Bool { + match list_head(xs: roadmap_unit_restart_argv()) { + HeadFound { value: w } => w == "systemctl" + _ => false + } +} diff --git a/dag/test/claim/approval_request_client_witness_test.dag b/dag/test/claim/approval_request_client_witness_test.dag new file mode 100644 index 00000000000..8a936ab260f --- /dev/null +++ b/dag/test/claim/approval_request_client_witness_test.dag @@ -0,0 +1,129 @@ +module test.claim.approval_request_client_witness_test + +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import std.types { Bool, NonEmptyStr, String } +import gunbc.auth.approval_request_client { + approval_submit_url, approval_status_url, approval_loopback_origin, approval_request_submit_path, + approval_poll_standing, PollStillPending, PollApproved, PollExpired, PollUnreadable, + StoredApprovalRequest, +} +import gunbc.auth.approval_status_wire { + parse_filed_approval_status, filed_approval_status_text, submission_filed_text, submission_body_admits_poll, + StatusNotFiled, StatusPending, StatusApproved, StatusDenied, StatusUnreadable, +} +import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } +import gunbc.auth.approval_request_submission { submission_envelope_json } +import gunbc.auth.approval_decision_store { approval_mac_key_id } +import std.scoped_authorization { AttemptIdentity } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +fn fixture_stored() -> StoredApprovalRequest { + StoredApprovalRequest { + escalation_id: "esc-1", + request_revision: "rev-1", + attempt: "attempt-1" as AttemptIdentity, + requester: "briansrls@gmail.com", + purpose: "boot", + destructive: true, + issued_at: "2026-09-16T20:00:00Z", + expires_at: "2026-09-16T21:00:00Z", + key_id: approval_mac_key_id, + } +} + +test fn submit_and_status_urls_follow_the_srv1_instance_listen_bind() -> Bool { + let instance = srv1_live_dashboard_instance() + let origin = approval_loopback_origin() as String + origin == concat(concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port))) + && approval_submit_url() as String == concat(origin, approval_request_submit_path as String) + && approval_status_url(escalation_id: "esc-1") as String == concat(concat(origin, concat(approval_request_submit_path as String, "/")), "esc-1") +} + +test fn submit_body_nests_the_stored_request_as_a_json_string() -> Bool { + let body = submission_envelope_json(request: fixture_stored(), tag_hex: "ab") + string_contains(s: body, pattern: "\"tag\"") + && string_contains(s: body, pattern: "\"request\"") + && string_contains(s: body, pattern: "\\\"kind\\\"") + && !string_contains(s: body, pattern: "\"kind\":\"request\"") +} + +test fn status_words_are_closed() -> Bool { + match parse_filed_approval_status(body: "standing=pending\nexpires_at=2026-09-16T21:00:00Z\n") { + StatusPending { expires_at: e } => e == "2026-09-16T21:00:00Z" + _ => false + } + && match parse_filed_approval_status(body: "standing=not-filed\n") { StatusNotFiled => true _ => false } + && match parse_filed_approval_status(body: "standing=approved\nrevision=r1\ndecided_by=op\ndecided_at=2026-09-16T20:30:00Z\nexecute_by=2026-09-16T21:00:00Z\n") { + StatusApproved { revision: r, decided_by: by, decided_at: _, execute_by: _ } => r as String == "r1" && by as String == "op" + _ => false + } + && match parse_filed_approval_status(body: "standing=denied\nrevision=r1\ndecided_by=op\ndecided_at=2026-09-16T20:30:00Z\nreason=no\n") { + StatusDenied { revision: _, decided_by: _, decided_at: _, reason: why } => why as String == "no" + _ => false + } + && match parse_filed_approval_status(body: "standing=unreadable\ndetail=corrupt\n") { + StatusUnreadable { detail: d } => d as String == "corrupt" + _ => false + } + && match parse_filed_approval_status(body: "standing=maybe\n") { StatusUnreadable { detail: _ } => true _ => false } + && match parse_filed_approval_status(body: "standing=approved\n") { StatusUnreadable { detail: _ } => true _ => false } +} + +test fn injected_kv_lines_in_expires_at_are_unreadable_not_approved() -> Bool { + match parse_filed_approval_status(body: "standing=pending\nexpires_at=2026-09-16T21:00:00Z\nstanding=approved\nrevision=r1\ndecided_by=op\ndecided_at=2026-09-16T20:30:00Z\nexecute_by=2026-09-16T21:00:00Z\n") { + StatusUnreadable { detail: _ } => true + _ => false + } +} + +test fn poll_expires_after_expires_at_unless_already_decided() -> Bool { + match approval_poll_standing(status: StatusPending { expires_at: "2026-09-16T21:00:00Z" }, observed_at: "2026-09-16T22:00:00Z", expires_at: "2026-09-16T21:00:00Z") { + PollExpired => true + _ => false + } + && match approval_poll_standing( + status: StatusApproved { + revision: "r1" as NonEmptyStr, + decided_by: "x" as NonEmptyStr, + decided_at: "2026-09-16T20:30:00Z", + execute_by: "2026-09-16T21:00:00Z", + }, + observed_at: "2026-09-16T22:00:00Z", + expires_at: "2026-09-16T21:00:00Z", + ) { + PollApproved { revision: _, decided_by: _, decided_at: _, execute_by: _ } => true + _ => false + } + && match approval_poll_standing(status: StatusPending { expires_at: "2026-09-16T21:00:00Z" }, observed_at: "2026-09-16T20:30:00Z", expires_at: "2026-09-16T21:00:00Z") { + PollStillPending => true + _ => false + } + && match approval_poll_standing( + status: StatusUnreadable { detail: "get failed" as NonEmptyStr }, + observed_at: "2026-09-16T22:00:00Z", + expires_at: "2026-09-16T21:00:00Z", + ) { + PollUnreadable { detail: _ } => true + _ => false + } +} + +test fn post_filed_token_admits_poll_including_notify_failure() -> Bool { + submission_body_admits_poll(body: submission_filed_text(filed: true, reason: "approval submission: filed esc-1 and notified (ntfy message m)")) + && submission_body_admits_poll(body: submission_filed_text(filed: true, reason: "approval submission: escalation esc-1 is already filed")) + && submission_body_admits_poll(body: submission_filed_text(filed: true, reason: "approval submission: filed esc-1 but the notification did not publish: refused 502 x")) + && !submission_body_admits_poll(body: submission_filed_text(filed: false, reason: "approval submission: requester not enrolled: bad tag")) + && !submission_body_admits_poll(body: "approval submission: filed esc-1 and notified (ntfy message m)") +} + +test fn status_text_round_trips_through_the_same_grammar() -> Bool { + match parse_filed_approval_status(body: filed_approval_status_text(status: StatusNotFiled)) { + StatusNotFiled => true + _ => false + } + && match parse_filed_approval_status(body: filed_approval_status_text(status: StatusPending { expires_at: "2026-09-16T21:00:00Z" })) { + StatusPending { expires_at: e } => e == "2026-09-16T21:00:00Z" + _ => false + } +} diff --git a/dag/test/claim/approval_request_submission_witness_test.dag b/dag/test/claim/approval_request_submission_witness_test.dag index 03fcc4b5f9b..80fc865edaa 100644 --- a/dag/test/claim/approval_request_submission_witness_test.dag +++ b/dag/test/claim/approval_request_submission_witness_test.dag @@ -17,6 +17,7 @@ import gunbc.auth.approval_request_submission { admit_parsed_submission, SubmissionVerdict, SubmissionVerified, SubmissionNotAdmitted, SubmissionUnreadable, SubmissionKeyUnavailable, SubmissionNotEnrolled, issue_links, LinksIssued, LinksNotIssued, approval_message, escalation_status, escalation_status_of, notify_filed, occupied_slot_standing, OccupiedRenotifiable, OccupiedConflict, OccupiedUnreadable, PublisherTokenLoaded, SubmissionFiledCapabilityKeyUnavailable, SubmissionFiledAndNotified, SubmissionFiledPublisherTokenUnavailable, PublisherTokenUnavailable, + SubmissionFiledLinksNotIssued, SubmissionStoreRefused, submission_outcome_admits_poll, } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly @@ -65,7 +66,7 @@ test fn a_signed_envelope_parses_back_to_the_same_request_and_tag() -> Bool { } test fn a_non_canonical_record_is_refused_before_it_is_verified() -> Bool { - match parse_submission_envelope(text: "{\"tag\":\"00\",\"request\":\"{\\\"kind\\\": \\\"request\\\", \\\"escalation_id\\\": \\\"e\\\", \\\"request_revision\\\": \\\"r\\\", \\\"attempt\\\": \\\"a\\\", \\\"requester\\\": \\\"q\\\", \\\"purpose\\\": \\\"p\\\", \\\"destructive\\\": true, \\\"issued_at\\\": \\\"2026-09-16T20:00:00Z\\\", \\\"expires_at\\\": \\\"2026-09-17T20:00:00Z\\\", \\\"key_id\\\": \\\"k\\\", \\\"extra\\\": 1}\"}") { + match parse_submission_envelope(text: "{\"tag\":\"00\",\"request\":\"{\\\"kind\\\": \\\"request\\\", \\\"escalation_id\\\": \\\"e\\\", \\\"request_revision\\\": \\\"r\\\", \\\"attempt\\\": \\\"a\\\", \\\"requester\\\": \\\"q\\\", \\\"purpose\\\": \\\"p\\\", \\\"destructive\\\": true, \\\"issued_at\\\": \\\"2026-09-16T20:00:00Z\\\", \\\"expires_at\\\": \\\"2026-09-17T20:00:00Z\\\", \\\"key_id\\\": \\\"k\\\", \\\"authority\\\": \\\"gunbc.auth.approval_capability\\\", \\\"extra\\\": 1}\"}") { SubmissionEnvelopeUnreadable { detail: _ } => true SubmissionEnvelopeParsed { tag_hex: _, request: _ } => false } @@ -92,6 +93,15 @@ test fn a_correctly_signed_submission_is_admitted_and_a_wrong_key_is_not_enrolle && match admit(text: "garbage", keyring: ApprovalKeyLoaded { key: submission_key() }) { SubmissionNotAdmitted { refusal: SubmissionUnreadable { detail: _ } } => true _ => false } } +test fn a_slot_with_no_minted_capability_does_not_admit_poll() -> Bool { + let no_links = SubmissionFiledLinksNotIssued { escalation_id: "esc-mtc1-boot-1" as NonEmptyStr, key_id: approval_mac_key_id } + let no_key = SubmissionFiledCapabilityKeyUnavailable { escalation_id: "esc-mtc1-boot-1" as NonEmptyStr, cause: ApprovalKeyFileAbsent { path: "/etc/gunbc-roadmap/approval-mac-key" } } + !submission_outcome_admits_poll(o: no_links) + && !submission_outcome_admits_poll(o: no_key) + && submission_outcome_admits_poll(o: SubmissionFiledAndNotified { escalation_id: "esc-mtc1-boot-1" as NonEmptyStr, message_id: "m1" as NonEmptyStr }) + && !submission_outcome_admits_poll(o: SubmissionStoreRefused { detail: "disk" as NonEmptyStr }) +} + // THE CAPABILITY KEY MUST NOT FILE: a runner holding only the key that mints consent is exactly // the requester the broker refuses to let reach the operator. test fn the_capability_key_cannot_file_a_request() -> Bool { diff --git a/dag/test/claim/live_deploy/emit_test.dag b/dag/test/claim/live_deploy/emit_test.dag index e07fdd5cf7d..24a90e63cc0 100644 --- a/dag/test/claim/live_deploy/emit_test.dag +++ b/dag/test/claim/live_deploy/emit_test.dag @@ -617,7 +617,7 @@ test fn witness_execstart_carries_exactly_the_admitted_revision() -> Bool { let expected = join([ "ExecStart=/opt/gunbc/bin/gunbc serve --source-root dag --source-root src/v2", " --entry dag/gunbc/roadmap/roadmap_serve.dag --function roadmap_serve_handle", - " --host 0.0.0.0 --port 8080", + " --host 127.0.0.1 --port 8080", " --release-revision ", deploy_witness_release_revision as String, " --eval-budget-cpu-ms 30000", " --eval-budget-wall-ms 120000", @@ -654,7 +654,7 @@ test fn witness_execstart_at_another_candidate_differs() -> Bool { test fn witness_systemd_unit_emitted() -> Bool { let unit = live_deploy_systemd_unit_for(spec: deployment_spec_srv1(), revision: RevisionBoundAtEmission { revision: deploy_witness_release_revision }) - string_contains(s: unit, pattern: "ExecStart=/opt/gunbc/bin/gunbc serve --source-root dag --source-root src/v2 --entry dag/gunbc/roadmap/roadmap_serve.dag --function roadmap_serve_handle --host 0.0.0.0 --port 8080") + string_contains(s: unit, pattern: "ExecStart=/opt/gunbc/bin/gunbc serve --source-root dag --source-root src/v2 --entry dag/gunbc/roadmap/roadmap_serve.dag --function roadmap_serve_handle --host 127.0.0.1 --port 8080") && string_contains(s: unit, pattern: "User=briansrls") && string_contains(s: unit, pattern: "Group=briansrls") && string_contains(s: unit, pattern: "WorkingDirectory=/opt/gunbc/gunbc") diff --git a/dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag b/dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag index d75217a5a75..ab58f995ac4 100644 --- a/dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag +++ b/dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag @@ -197,7 +197,7 @@ test fn the_belt_timer_unit_matches_the_deployed_bytes() -> Bool { // deployment_credential_env_path carries the argument. test fn the_serve_unit_matches_the_deployed_bytes() -> Bool { let emitted = render(doc: emit_systemd_unit_doc(spec: live_spec(), revision: live_revision()), proto: live_deploy_protocol) - emitted == "[Unit]\nDescription=gunbc roadmap HTTP server (gunbc serve)\nAfter=network-online.target tailscaled.service\nWants=network-online.target\n\n[Service]\nType=simple\nUser=briansrls\nGroup=briansrls\nSlice=gunbc-roadmap.slice\nWorkingDirectory=/opt/gunbc/gunbc\nEnvironment=XDG_RUNTIME_DIR=/run/user/1000\nEnvironment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus\nExecStart=/opt/gunbc/bin/gunbc serve --source-root dag --source-root src/v2 --entry dag/gunbc/roadmap/roadmap_serve.dag --function roadmap_serve_handle --host 0.0.0.0 --port 8080 --release-revision b2dd729f92954e50e3d8d9bbd65022c2d58f14dd --eval-budget-cpu-ms 30000 --eval-budget-wall-ms 30000\nRestart=on-failure\n\n[Install]\nWantedBy=multi-user.target\n" + emitted == "[Unit]\nDescription=gunbc roadmap HTTP server (gunbc serve)\nAfter=network-online.target tailscaled.service\nWants=network-online.target\n\n[Service]\nType=simple\nUser=briansrls\nGroup=briansrls\nSlice=gunbc-roadmap.slice\nWorkingDirectory=/opt/gunbc/gunbc\nEnvironment=XDG_RUNTIME_DIR=/run/user/1000\nEnvironment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus\nExecStart=/opt/gunbc/bin/gunbc serve --source-root dag --source-root src/v2 --entry dag/gunbc/roadmap/roadmap_serve.dag --function roadmap_serve_handle --host 127.0.0.1 --port 8080 --release-revision b2dd729f92954e50e3d8d9bbd65022c2d58f14dd --eval-budget-cpu-ms 30000 --eval-budget-wall-ms 120000\nRestart=on-failure\n\n[Install]\nWantedBy=multi-user.target\n" } // gunbc-tree-sync.service, AS EMITTED, and this row now asserts the emission rather than a @@ -497,7 +497,7 @@ test fn serve_unit_execstart_carries_admitted_entrypoint() -> Bool { " --entry dag/gunbc/roadmap/roadmap_serve.dag --function ", svc.serve_function as String, " --host ", host, " --port ", port, " --release-revision ", rev, - " --eval-budget-cpu-ms 30000 --eval-budget-wall-ms 30000", + " --eval-budget-cpu-ms 30000 --eval-budget-wall-ms 120000", ], "") as NonEmptyStr }) } diff --git a/dag/test/claim/machine_intake/megarac_session_release_witness_test.dag b/dag/test/claim/machine_intake/megarac_session_release_witness_test.dag new file mode 100644 index 00000000000..59ef8f4c040 --- /dev/null +++ b/dag/test/claim/machine_intake/megarac_session_release_witness_test.dag @@ -0,0 +1,116 @@ +module test.claim.machine_intake.megarac_session_release_witness_test + +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import std.types { Bool, String } +import gunbc.machine_intake_megarac_media_attach { + SessionAbsent, + SessionPresent, + SessionPresenceUnreadable, + SessionReleased, + SessionReleaseRefused, + SessionCsrfParsed, + SessionCsrfUnreadable, + megarac_session_presence, + megarac_session_release_by_reobservation, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// THE PRESENCE READER, on the real wire pairs: curl --fail-with-body keeps the body and the +// write-out trailer on stdout and exits nonzero, so success=false with a trailing code is the +// shape the controller actually produces (boot 20, run 35437393331). +test fn a_succeeding_get_is_present() -> Bool { + match megarac_session_presence(get_succeeded: true, get_body: "{\"ok\":true}\n200") { + SessionPresent => true + _ => false + } +} + +// THE ROUTE-MISSING CONTROL: 404 is what GET /api/session answered with a LIVE cookie on this +// firmware (boots 20 and 21), so 404 is unreadable here, never absence. +test fn http_404_after_the_trailer_is_unreadable_not_absent() -> Bool { + match megarac_session_presence(get_succeeded: false, get_body: "{ \"error\": \"Session not found\" }\n404") { + SessionPresenceUnreadable { detail: _ } => true + _ => false + } +} + +test fn http_403_after_the_trailer_is_absent() -> Bool { + match megarac_session_presence(get_succeeded: false, get_body: "{\"error\":\"forbidden\"}\n403") { + SessionAbsent { code: c } => c == "403" + _ => false + } +} + +test fn http_401_after_the_trailer_is_absent() -> Bool { + match megarac_session_presence(get_succeeded: false, get_body: "{\"error\":\"invalid session\"}\n401") { + SessionAbsent { code: c } => c == "401" + _ => false + } +} + +test fn a_timeout_get_is_unreadable() -> Bool { + match megarac_session_presence(get_succeeded: false, get_body: "\n000") { + SessionPresenceUnreadable { detail: _ } => true + _ => false + } +} + +test fn a_mid_body_401_with_a_non_401_trailer_is_unreadable() -> Bool { + match megarac_session_presence(get_succeeded: false, get_body: "note\n401\n200") { + SessionPresenceUnreadable { detail: _ } => true + _ => false + } +} + +// THE TRANSITION. Released is exactly present -> absent under a parsed token. +test fn present_then_403_is_released() -> Bool { + match megarac_session_release_by_reobservation(before: SessionPresent, after: SessionAbsent { code: "403" }, csrf: SessionCsrfParsed) { + SessionReleased => true + _ => false + } +} + +test fn present_then_401_is_released() -> Bool { + match megarac_session_release_by_reobservation(before: SessionPresent, after: SessionAbsent { code: "401" }, csrf: SessionCsrfParsed) { + SessionReleased => true + _ => false + } +} + +test fn a_live_get_after_close_is_not_released() -> Bool { + match megarac_session_release_by_reobservation(before: SessionPresent, after: SessionPresent, csrf: SessionCsrfParsed) { + SessionReleaseRefused { detail: d } => string_contains(s: d, pattern: "still succeeded") + _ => false + } +} + +test fn an_unreadable_get_after_close_is_not_released() -> Bool { + match megarac_session_release_by_reobservation(before: SessionPresent, after: SessionPresenceUnreadable { detail: "\n000" }, csrf: SessionCsrfParsed) { + SessionReleaseRefused { detail: d } => string_contains(s: d, pattern: "unreadable") + _ => false + } +} + +// THE ABSORBING-FALLBACK CONTROL: a route or cookie that answers 4xx BEFORE the close as well +// proves nothing about release, so absent -> absent must refuse, not mint. +test fn absent_before_the_close_is_not_released_even_if_absent_after() -> Bool { + match megarac_session_release_by_reobservation(before: SessionAbsent { code: "404" }, after: SessionAbsent { code: "404" }, csrf: SessionCsrfParsed) { + SessionReleaseRefused { detail: d } => string_contains(s: d, pattern: "BEFORE CloseSession") + _ => false + } +} + +test fn an_unreadable_get_before_the_close_refuses_to_adjudicate() -> Bool { + match megarac_session_release_by_reobservation(before: SessionPresenceUnreadable { detail: "\n000" }, after: SessionAbsent { code: "404" }, csrf: SessionCsrfParsed) { + SessionReleaseRefused { detail: d } => string_contains(s: d, pattern: "before CloseSession was unreadable") + _ => false + } +} + +test fn a_release_with_a_sentinel_csrf_is_not_released() -> Bool { + match megarac_session_release_by_reobservation(before: SessionPresent, after: SessionAbsent { code: "401" }, csrf: SessionCsrfUnreadable) { + SessionReleaseRefused { detail: d } => string_contains(s: d, pattern: "sentinel") + _ => false + } +} diff --git a/dag/test/claim/machine_intake/mtcollins1_boot_authorization_witness_test.dag b/dag/test/claim/machine_intake/mtcollins1_boot_authorization_witness_test.dag new file mode 100644 index 00000000000..39f3a6bf0e4 --- /dev/null +++ b/dag/test/claim/machine_intake/mtcollins1_boot_authorization_witness_test.dag @@ -0,0 +1,137 @@ +module test.claim.machine_intake.mtcollins1_boot_authorization_witness_test + +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import std.types { Bool, NonEmptyStr, String } +import std.scoped_authorization { AttemptIdentity, authorize, AuthorizationRefused, AuthorizationExpired } +import gunbc.machine_intake_mtcollins1_boot_authorization { + mtcollins1_boot_authorization_request, mtcollins1_boot_subject, mtcollins1_boot_required_scopes, + mtcollins1_boot_gate_from_poll, + BootGatePending, BootGateAdmitted, BootGateIntentMismatch, BootGateDenied, BootGateExpired, BootGateNotFiled, BootGateStoreUnreadable, +} +import gunbc.auth.approval_request_client { PollStillPending, PollExpired, PollDenied, PollNotFiled, PollUnreadable, PollApproved } +import gunbc.auth.approval_decision_store { request_revision_of } +import gunbc.machine_intake_mtcollins1_boot_artifact { mtcollins1_diskless_image_name } +import extdeps.toolchain.types { Aarch64 } +import extdeps.bmc.ipmi_boot_selection { IpmiBootCdrom, IpmiBootEfi, IpmiBootOnce, ipmi_boot_selection_label } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +test fn request_is_destructive_and_names_the_diskless_image() -> Bool { + let r = mtcollins1_boot_authorization_request( + escalation_id: "esc-mtc1-1", + attempt: "mtc1-1" as AttemptIdentity, + execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + r.destructive + && r.subject.image_name == mtcollins1_diskless_image_name + && r.subject.machine as String == "mtcollins1" + && r.subject.execution_revision as String == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + && r.subject.selection.architecture == Aarch64 + && r.subject.selection.firmware_mode == IpmiBootEfi + && r.subject.selection.persistence == IpmiBootOnce + && r.subject.selection.device == IpmiBootCdrom + && (ipmi_boot_selection_label(selection: r.subject.selection) as String) == "cdrom/efi/once" + && r.scopes.length() == mtcollins1_boot_required_scopes.length() + && r.escalation_id as String == "esc-mtc1-1" +} + +test fn pending_denied_expired_and_unfiled_do_not_write_the_controller() -> Bool { + let r = mtcollins1_boot_authorization_request( + escalation_id: "esc-mtc1-1", + attempt: "mtc1-1" as AttemptIdentity, + execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + let at = "2026-09-16T20:30:00Z" + let denied = PollDenied { + revision: request_revision_of(request: r), + decided_by: "op" as NonEmptyStr, + decided_at: at, + reason: "no" as NonEmptyStr, + } + match mtcollins1_boot_gate_from_poll(poll: PollStillPending, request: r, observed_at: at) { + BootGatePending { escalation_id: _ } => true + _ => false + } + && match mtcollins1_boot_gate_from_poll(poll: denied, request: r, observed_at: at) { + BootGateDenied { reason: _ } => true + _ => false + } + && match mtcollins1_boot_gate_from_poll(poll: PollExpired, request: r, observed_at: at) { + BootGateExpired => true + _ => false + } + && match mtcollins1_boot_gate_from_poll(poll: PollNotFiled, request: r, observed_at: at) { + BootGateNotFiled { escalation_id: _ } => true + _ => false + } + && match mtcollins1_boot_gate_from_poll(poll: PollUnreadable { detail: "get failed" as NonEmptyStr }, request: r, observed_at: at) { + BootGateStoreUnreadable { detail: _ } => true + _ => false + } +} + +test fn poll_pending_is_the_pending_gate() -> Bool { + let r = mtcollins1_boot_authorization_request( + escalation_id: "esc-mtc1-1", + attempt: "mtc1-1" as AttemptIdentity, + execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + match mtcollins1_boot_gate_from_poll(poll: PollStillPending, request: r, observed_at: "2026-09-16T20:30:00Z") { + BootGatePending { escalation_id: e } => e as String == "esc-mtc1-1" + _ => false + } +} + +test fn poll_approved_http_fields_admit_and_a_foreign_revision_does_not() -> Bool { + let r = mtcollins1_boot_authorization_request( + escalation_id: "esc-mtc1-1", + attempt: "mtc1-1" as AttemptIdentity, + execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + let at = "2026-09-16T20:30:00Z" + let until = "2026-09-16T21:00:00Z" + let matching = PollApproved { + revision: request_revision_of(request: r), + decided_by: "op" as NonEmptyStr, + decided_at: at, + execute_by: until, + } + let foreign = PollApproved { + revision: "other-intent" as NonEmptyStr, + decided_by: "op" as NonEmptyStr, + decided_at: at, + execute_by: until, + } + match mtcollins1_boot_gate_from_poll(poll: matching, request: r, observed_at: at) { + BootGateAdmitted { authorization: _ } => true + _ => false + } + && match mtcollins1_boot_gate_from_poll(poll: foreign, request: r, observed_at: at) { + BootGateIntentMismatch { requested_revision: _, decided_revision: d } => d as String == "other-intent" + _ => false + } +} + +test fn an_execute_by_that_lapses_after_poll_is_expired_on_the_actuation_clock() -> Bool { + let r = mtcollins1_boot_authorization_request( + escalation_id: "esc-mtc1-1", + attempt: "mtc1-1" as AttemptIdentity, + execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + let poll_at = "2026-09-16T20:30:00Z" + let until = "2026-09-16T20:45:00Z" + let matching = PollApproved { + revision: request_revision_of(request: r), + decided_by: "op" as NonEmptyStr, + decided_at: poll_at, + execute_by: until, + } + match mtcollins1_boot_gate_from_poll(poll: matching, request: r, observed_at: poll_at) { + BootGateAdmitted { authorization: auth } => + match authorize(authorization: auth, request: r, observed_at: "2026-09-16T20:45:01Z") { + AuthorizationRefused { cause: AuthorizationExpired { expires_at: _, observed_at: _ } } => true + _ => false + } + _ => false + } +} diff --git a/dag/test/claim/machine_intake/mtcollins1_boot_run_witness_test.dag b/dag/test/claim/machine_intake/mtcollins1_boot_run_witness_test.dag new file mode 100644 index 00000000000..79598dd9f5e --- /dev/null +++ b/dag/test/claim/machine_intake/mtcollins1_boot_run_witness_test.dag @@ -0,0 +1,314 @@ +module test.claim.machine_intake.mtcollins1_boot_run_witness_test + +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import std.types { Bool, Int, NonEmptyStr, String } +import std.measure { second_count, minute_count } +import v2.std.optional { Present } +import gunbc.machine_intake_mtcollins1_boot_run { + mtcollins1_boot_approval_window, mtcollins1_boot_poll_cadence, mtcollins1_boot_poll_tick_budget, + mtcollins1_boot_step_timeout_minutes, mtcollins1_boot_prelude_allowance, mtcollins1_boot_actuation_allowance, + mtcollins1_boot_issue_allowance, mtcollins1_boot_cleanup_allowance, + mtcollins1_boot_submit_timeout, + mtcollins1_status_from_get, mtcollins1_boot_instant_from_probe, mtcollins1_boot_sol_is_held, + mtcollins1_boot_sol_held_from_observations, + mtcollins1_sol_observed_from_collector, + mtcollins1_boot_terminal_observed, + mtcollins1_boot_host_must_be_srv1, mtcollins1_boot_refuses_off_srv1, +} +import gunbc.fleet_intent_network { operator_host_srv1, operator_host_srv2 } +import std.process { ExitSuccess } +import std.temporal_effect { + LeaseRunningExpected, LeaseRunningStale, LeaseInaccessible, + held_lease_observed_verdict, +} +import std.upsert_decision { Converged, Absent, Drifted, Inaccessible } +import std.scoped_authorization { authorization_claim_key } +import gunbc.machine_intake_mtcollins1_boot_authorization { mtcollins1_boot_subject } +import gunbc.auth.approval_request_client { + approval_poll_standing, PollExpired, PollUnreadable, +} +import gunbc.auth.approval_status_wire { + StatusUnreadable, StatusPending, parse_filed_approval_status, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +test fn approval_window_is_the_poll_budget_not_zero() -> Bool { + let window = second_count(s: mtcollins1_boot_approval_window()) as Int + window == mtcollins1_boot_poll_tick_budget * (second_count(s: mtcollins1_boot_poll_cadence) as Int) + && window > 0 +} + +test fn the_submit_timeout_is_a_second_not_a_bare_literal() -> Bool { + (second_count(s: mtcollins1_boot_submit_timeout) as Int) == 30 +} + +test fn the_step_timeout_covers_the_poll_window_and_the_actuation_allowance() -> Bool { + let covered = (second_count(s: mtcollins1_boot_prelude_allowance) as Int) + + (second_count(s: mtcollins1_boot_approval_window()) as Int) + + (second_count(s: mtcollins1_boot_issue_allowance) as Int) + + (second_count(s: mtcollins1_boot_actuation_allowance) as Int) + + (second_count(s: mtcollins1_boot_cleanup_allowance) as Int) + (minute_count(m: mtcollins1_boot_step_timeout_minutes()) as Int) * 60 >= covered +} + +test fn a_zero_window_expires_on_the_next_observation() -> Bool { + match approval_poll_standing( + status: StatusPending { expires_at: "2026-09-16T20:00:00Z" }, + observed_at: "2026-09-16T20:00:01Z", + expires_at: "2026-09-16T20:00:00Z", + ) { + PollExpired => true + _ => false + } +} + +test fn a_failed_status_get_is_unreadable_not_pending() -> Bool { + match mtcollins1_status_from_get(success: false, body: "") { + StatusUnreadable { detail: _ } => true + _ => false + } + && match approval_poll_standing( + status: mtcollins1_status_from_get(success: false, body: ""), + observed_at: "2026-09-16T20:00:00Z", + expires_at: "2026-09-16T20:22:30Z", + ) { + PollUnreadable { detail: _ } => true + _ => false + } + && match parse_filed_approval_status(body: "standing=pending\nexpires_at=2026-09-16T20:22:30Z\n") { + StatusPending { expires_at: _ } => true + _ => false + } +} + +test fn clock_unreadable_is_not_an_observation_instant() -> Bool { + match mtcollins1_boot_instant_from_probe(reading: none) { Absent => true _ => false } + && match mtcollins1_boot_instant_from_probe(reading: Present { value: "clock-unreadable" as NonEmptyStr }) { Absent => true _ => false } + && match mtcollins1_boot_instant_from_probe(reading: Present { value: "2026-09-16T20:00:00Z" as NonEmptyStr }) { + Present { value: t } => t as String == "2026-09-16T20:00:00Z" + _ => false + } +} + +test fn sol_held_is_a_live_ipmitool_pid_not_a_nonempty_capture() -> Bool { + !mtcollins1_boot_sol_is_held(pid_path: "") + && !mtcollins1_boot_sol_held_from_observations( + pid_path: "/tmp/sol.pid", + pid_file_ok: false, + pid_file_content: "123", + cmdline_ok: true, + cmdline_content: "ipmitool", + ) + && !mtcollins1_boot_sol_held_from_observations( + pid_path: "/tmp/sol.pid", + pid_file_ok: true, + pid_file_content: "\n", + cmdline_ok: true, + cmdline_content: "ipmitool", + ) + && !mtcollins1_boot_sol_held_from_observations( + pid_path: "/tmp/sol.pid", + pid_file_ok: true, + pid_file_content: "123\n", + cmdline_ok: true, + cmdline_content: "bash", + ) + && !mtcollins1_boot_sol_held_from_observations( + pid_path: "/tmp/sol.pid", + pid_file_ok: true, + pid_file_content: "123\n", + cmdline_ok: false, + cmdline_content: "ipmitool", + ) + && mtcollins1_boot_sol_held_from_observations( + pid_path: "/tmp/sol.pid", + pid_file_ok: true, + pid_file_content: "123\n", + cmdline_ok: true, + cmdline_content: "ipmitool sol activate", + ) +} + +// THE SLOT KEY CARRIES THE RELATION'S SCOPE AND IS SLOT-ADDRESSABLE: it is not the bare escalation +// id (a second relation on the shared root could then collide with it) and it carries no `/` +// (which gunbc.durable_cas_file_store refuses). Both halves are reds: the bare id and a slashed key. +test fn a_boot_claim_slot_key_is_scoped_and_slot_addressable() -> Bool { + let eid = "mtcollins1-boot-1" as NonEmptyStr + let key = authorization_claim_key(escalation_id: eid) as String + key != (eid as String) + && string_contains(s: key, pattern: eid as String) + && !key.contains("/") +} + +test fn sol_lease_observation_is_the_collector_we_hold_not_sol_info() -> Bool { + held_lease_observed_verdict( + observed: mtcollins1_sol_observed_from_collector( + pid_path_set: false, + pid_file_ok: false, + pid_file_content: "", + collector_held: false, + ), + ) == Inaccessible + && held_lease_observed_verdict( + observed: mtcollins1_sol_observed_from_collector( + pid_path_set: true, + pid_file_ok: true, + pid_file_content: "123\n", + collector_held: true, + ), + ) == Converged + && held_lease_observed_verdict( + observed: mtcollins1_sol_observed_from_collector( + pid_path_set: true, + pid_file_ok: true, + pid_file_content: "123\n", + collector_held: false, + ), + ) == Drifted + && held_lease_observed_verdict( + observed: mtcollins1_sol_observed_from_collector( + pid_path_set: true, + pid_file_ok: false, + pid_file_content: "", + collector_held: false, + ), + ) == Absent + && mtcollins1_sol_observed_from_collector( + pid_path_set: true, + pid_file_ok: true, + pid_file_content: "123\n", + collector_held: false, + ) == LeaseRunningStale + && mtcollins1_sol_observed_from_collector( + pid_path_set: true, + pid_file_ok: true, + pid_file_content: "123\n", + collector_held: true, + ) == LeaseRunningExpected + && mtcollins1_sol_observed_from_collector( + pid_path_set: false, + pid_file_ok: false, + pid_file_content: "SOL enabled", + collector_held: false, + ) == LeaseInaccessible +} + +test fn boot_terminal_is_the_host_capture_not_boot_control_issued() -> Bool { + let subject = mtcollins1_boot_subject(execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + let clean = concat( + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 begin 2026-09-18T00:00:00Z=====\n", + concat( + "----SECTION boot-media----\nLABEL=\"Ubuntu-Server 24.04.3 LTS arm64\"\n----EXIT boot-media: 0\n", + concat( + "----SECTION nproc----\n----ARGV nproc: nproc\n160\n----EXIT nproc: 0\n", + concat( + "----SECTION edac-before----\n== /sys/devices/system/edac/mc/mc0/ce_count == 0\n== /sys/devices/system/edac/mc/mc0/ue_count == 0\n----EXIT edac-before: 0\n", + concat( + "----SECTION workload----\nworkload-write-rc=0\nworkload-digest-stable=yes\n----EXIT workload: 0\n", + concat( + "----SECTION edac-after----\n== /sys/devices/system/edac/mc/mc0/ce_count == 0\n== /sys/devices/system/edac/mc/mc0/ue_count == 0\n----EXIT edac-after: 0\n", + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 end 2026-09-18T00:04:00Z=====", + ), + ), + ), + ), + ), + ) + let ghes_ce = concat( + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 begin 2026-09-18T00:00:00Z=====\n", + concat( + "----SECTION boot-media----\nLABEL=\"Ubuntu-Server 24.04.3 LTS arm64\"\n----EXIT boot-media: 0\n", + concat( + "----SECTION nproc----\n----ARGV nproc: nproc\n160\n----EXIT nproc: 0\n", + concat( + "----SECTION edac-before----\nghes_edac\n== /sys/devices/system/edac/mc/mc0/ce_count == 1\n== /sys/devices/system/edac/mc/mc0/ue_count == 0\n----EXIT edac-before: 0\n", + concat( + "----SECTION workload----\nworkload-write-rc=0\nworkload-digest-stable=yes\n----EXIT workload: 0\n", + concat( + "----SECTION edac-after----\n== /sys/devices/system/edac/mc/mc0/ce_count == 0\n== /sys/devices/system/edac/mc/mc0/ue_count == 0\n----EXIT edac-after: 0\n", + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 end 2026-09-18T00:04:00Z=====", + ), + ), + ), + ), + ), + ) + let ue = concat( + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 begin 2026-09-18T00:00:00Z=====\n", + concat( + "----SECTION boot-media----\nLABEL=\"Ubuntu-Server 24.04.3 LTS arm64\"\n----EXIT boot-media: 0\n", + concat( + "----SECTION nproc----\n----ARGV nproc: nproc\n160\n----EXIT nproc: 0\n", + concat( + "----SECTION edac-before----\n== /sys/devices/system/edac/mc/mc0/ce_count == 0\n== /sys/devices/system/edac/mc/mc0/ue_count == 1\n----EXIT edac-before: 0\n", + concat( + "----SECTION workload----\nworkload-write-rc=0\nworkload-digest-stable=yes\n----EXIT workload: 0\n", + concat( + "----SECTION edac-after----\n== /sys/devices/system/edac/mc/mc0/ce_count == 0\n== /sys/devices/system/edac/mc/mc0/ue_count == 0\n----EXIT edac-after: 0\n", + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 end 2026-09-18T00:04:00Z=====", + ), + ), + ), + ), + ), + ) + let nproc_elsewhere = concat( + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 begin 2026-09-18T00:00:00Z=====\n", + concat( + "----SECTION boot-media----\nLABEL=\"Ubuntu-Server 24.04.3 LTS arm64\"\n----EXIT boot-media: 0\n", + concat( + "----SECTION nproc----\n----EXIT nproc: 1\n----SECTION lscpu----\nCPU(s): 160\n----EXIT lscpu: 0\n", + concat( + "----SECTION edac-before----\n== /sys/devices/system/edac/mc/mc0/ce_count == 0\n== /sys/devices/system/edac/mc/mc0/ue_count == 0\n----EXIT edac-before: 0\n", + concat( + "----SECTION workload----\nworkload-write-rc=0\nworkload-digest-stable=yes\n----EXIT workload: 0\n", + concat( + "----SECTION edac-after----\n== /sys/devices/system/edac/mc/mc0/ce_count == 0\n== /sys/devices/system/edac/mc/mc0/ue_count == 0\n----EXIT edac-after: 0\n", + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 end 2026-09-18T00:04:00Z=====", + ), + ), + ), + ), + ), + ) + let digest_mismatch = concat( + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 begin 2026-09-18T00:00:00Z=====\n", + concat( + "----SECTION boot-media----\nLABEL=\"Ubuntu-Server 24.04.3 LTS arm64\"\n----EXIT boot-media: 0\n", + concat( + "----SECTION nproc----\n----ARGV nproc: nproc\n160\n----EXIT nproc: 0\n", + concat( + "----SECTION edac-before----\n== /sys/devices/system/edac/mc/mc0/ce_count == 0\n== /sys/devices/system/edac/mc/mc0/ue_count == 0\n----EXIT edac-before: 0\n", + concat( + "----SECTION workload----\nworkload-write-rc=0\nworkload-digest-stable=no\n----EXIT workload: 0\n", + concat( + "----SECTION edac-after----\n== /sys/devices/system/edac/mc/mc0/ce_count == 0\n== /sys/devices/system/edac/mc/mc0/ue_count == 0\n----EXIT edac-after: 0\n", + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 end 2026-09-18T00:04:00Z=====", + ), + ), + ), + ), + ), + ) + let begin_only = "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 begin 2026-09-18T00:00:00Z=====\n----SECTION nproc----\n160\n" + let begin_with_spurious_end = concat( + "=====GUNBC-HOST-CAPTURE mtcollins1 attempt=1 begin 2026-09-18T00:00:00Z=====\n", + "console said the process reached the end of its run\n----SECTION nproc----\n160\n", + ) + mtcollins1_boot_terminal_observed(capture: clean, subject: subject) + && !mtcollins1_boot_terminal_observed(capture: "BootControlIssued cdrom", subject: subject) + && !mtcollins1_boot_terminal_observed(capture: ghes_ce, subject: subject) + && !mtcollins1_boot_terminal_observed(capture: ue, subject: subject) + && !mtcollins1_boot_terminal_observed(capture: nproc_elsewhere, subject: subject) + && !mtcollins1_boot_terminal_observed(capture: digest_mismatch, subject: subject) + && !mtcollins1_boot_terminal_observed(capture: begin_only, subject: subject) + && !mtcollins1_boot_terminal_observed(capture: begin_with_spurious_end, subject: subject) +} + +test fn boot_loopback_filing_is_srv1_only() -> Bool { + mtcollins1_boot_host_must_be_srv1(host: operator_host_srv1 as String) + && !mtcollins1_boot_host_must_be_srv1(host: operator_host_srv2 as String) + && match mtcollins1_boot_refuses_off_srv1(host: operator_host_srv1 as String) { ExitSuccess => true _ => false } + && match mtcollins1_boot_refuses_off_srv1(host: operator_host_srv2 as String) { ExitSuccess => false _ => true } +} diff --git a/dag/test/claim/roadmap/roadmap_serve_witness_test.dag b/dag/test/claim/roadmap/roadmap_serve_witness_test.dag index c34f6b590c2..a744d526940 100644 --- a/dag/test/claim/roadmap/roadmap_serve_witness_test.dag +++ b/dag/test/claim/roadmap/roadmap_serve_witness_test.dag @@ -64,6 +64,7 @@ import gunbc.roadmap_launch_admission { launch_host_unobserved_static_render } import gunbc.roadmap_site_surface_render { roadmap_site_roadmap_url_path, roadmap_site_dispatch_url_path, roadmap_site_daily_url_path } import gunbc.v1_deletion_plan { v1_exit_finish_lines, v1_finish_lines_accepted_count } import gunbc.roadmap_model { RoadmapNodeId } +import gunbc.auth.approval_status_wire { filed_approval_status_text, StatusNotFiled } import extdeps.languages.html { EmitOk, EmitRejected, try_serialize_html_source } // Test-local extraction: a Refused build yields the EMPTY table, so every selection witness below @@ -411,6 +412,25 @@ test fn an_absent_identity_arrives_at_the_handler_as_absent() -> Bool { && !string_contains(s: r.body, pattern: "briansrls") } +test fn a_garbage_approval_submission_is_400_and_names_the_unreadable_body() -> Bool { + let r = roadmap_serve_respond( + method_label: "POST", + path: "/approvals", + body: "nope", + tailscale_identity: "", + release_revision: deploy_witness_release_revision, + ) + r.status == 400 + && string_contains(s: r.body, pattern: "approval submission:") + && !string_contains(s: r.body, pattern: "http") +} + +test fn an_unfiled_escalation_status_is_text_plain_standing() -> Bool { + let body = filed_approval_status_text(status: StatusNotFiled) + string_contains(s: body, pattern: "standing=") + && !string_contains(s: body, pattern: "{") +} + // ── The plan authority is a parameter ─────────────────────────────────────────────────────────── // // THE DISCRIMINATING DIRECTION IS THE NEGATIVE. Asserting that a caller-supplied plan appears in diff --git a/dag/test/claim/typed_remote_file_converge_witness_test.dag b/dag/test/claim/typed_remote_file_converge_witness_test.dag index b16bb0d47db..464bd7d4b22 100644 --- a/dag/test/claim/typed_remote_file_converge_witness_test.dag +++ b/dag/test/claim/typed_remote_file_converge_witness_test.dag @@ -14,6 +14,7 @@ import gunbc.typed_remote_file_write { TypedRemoteFileWriteAdmissionRefused, RemoteFileMode, RemoteFileModeOwnerReadWrite, + RemoteFileModeOwnerReadWriteGroupRead, RemoteFileModeOwnerReadWriteWorldRead, RemoteFileAsSessionPrincipal, RemoteFileViaNonInteractiveSudo, @@ -173,8 +174,9 @@ test fn the_sudo_arm_prefixes_every_leg_non_interactively() -> Bool { } } -test fn the_two_modes_render_their_octals() -> Bool { +test fn the_modes_render_their_octals() -> Bool { remote_file_mode_octal(mode: RemoteFileModeOwnerReadWrite) == "600" + && remote_file_mode_octal(mode: RemoteFileModeOwnerReadWriteGroupRead) == "640" && remote_file_mode_octal(mode: RemoteFileModeOwnerReadWriteWorldRead) == "644" }